rubocop-dev_doc 0.3.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 163ac4a232d22f5d731e680d4ae882172570cdcf3085a07be7d6c3195223e365
4
- data.tar.gz: a3eae8c77dcb707ddc4875be90255bea2fa33bcaeba370c7aff06436cd045661
3
+ metadata.gz: 2076123d07503e775a29287eafcd8dc9d8cf6e344eeb4b462d29120fdef12879
4
+ data.tar.gz: 910414d217e37c808b7626add2b876d24f6ef214a725b373e86dc736acb24ff7
5
5
  SHA512:
6
- metadata.gz: a605681c5f22058138abe3d8bdcd1e12eb94073f74e02fde30bc1f1df0baa538022b69fe33c4f83279cfaed995e60e5c9a4936de43cad7a89f07105c4b6e7a24
7
- data.tar.gz: 0e845bcf2236245a8c4b0289a02d7731e12c9ac0023732f6135127f02f436c78f349012b98bd18127e89cdb4e70736597c2fc5313229ed3faaf572294e847168
6
+ metadata.gz: 7681307dae1b8d09d0344c199d4be497bc4c986b4314711ae39550d7d8ced8d8b811d07d98997f8e3fc85d294e6fde1b97c8cb6cdc3c33a40ad3074cda985d62
7
+ data.tar.gz: a047b648abfcaeb1842158a562c17028601a2f24c67e1f0aa2cdc548377cccd1658dae4b6b68cee1cb4906275a59822f2870160380e71cb5d730c04ce5d44196
data/config/default.yml CHANGED
@@ -79,11 +79,12 @@ DevDoc/Migration/DateColumnNaming:
79
79
  - "db/migrate/**/*.rb"
80
80
 
81
81
  DevDoc/Migration/AvoidVagueColumnNames:
82
- Description: "Avoid vague column names like `status` or `group`. Use more specific names."
82
+ Description: "Avoid vague column names like `status`, `group`, or `kind`. Use more specific names."
83
83
  Enabled: true
84
84
  VagueNames:
85
85
  - status
86
86
  - group
87
+ - kind
87
88
  Include:
88
89
  - "db/migrate/*.rb"
89
90
  - "db/migrate/**/*.rb"
@@ -146,10 +147,10 @@ DevDoc/Rails/EnumColumnNotNull:
146
147
  DevDoc/Rails/NoBlockPredicateOnRelation:
147
148
  Description: "Avoid block-form `count`/`reject`/`select`/`find`/`any?` on AR relations; push the predicate into SQL with `.where(...)` or a scope."
148
149
  Enabled: true
150
+ AdditionalNonRelationMethods: []
149
151
  Exclude:
150
152
  - "spec/**/*"
151
153
  - "test/**/*"
152
- - "db/seeds.rb"
153
154
  - "db/seeds/**/*.rb"
154
155
  - "lib/tasks/**/*.rb"
155
156
 
@@ -165,6 +166,10 @@ DevDoc/Style/AvoidSend:
165
166
  Description: "Avoid `send`/`public_send` with an explicit receiver; prefer direct calls or safer alternatives."
166
167
  Enabled: true
167
168
 
169
+ DevDoc/Style/PreferPublicSend:
170
+ Description: "Prefer `public_send` over `send` — visibility-respecting dispatch surfaces typos/misconfig and documents intent."
171
+ Enabled: true
172
+
168
173
  DevDoc/Style/RepeatedSafeNavigationReceiver:
169
174
  Description: "Avoid using `&.` on the same receiver more than once in a method body — assign once and use `.` after."
170
175
  Enabled: true
@@ -177,6 +182,10 @@ DevDoc/Style/MinimizeVariableScope:
177
182
  Description: "Assign a variable inside the `if` condition that guards it, to keep its scope local to the branch."
178
183
  Enabled: true
179
184
 
185
+ DevDoc/Style/TapBlockIgnoresValue:
186
+ Description: "Flag `tap` whose block ignores the yielded value — `tap` is for operating on the receiver, not eliding a temp var."
187
+ Enabled: true
188
+
180
189
  DevDoc/Style/AvoidHeadResponse:
181
190
  Description: "Avoid `head()` with error statuses; delegate error handling to Rails exceptions or model validations."
182
191
  Enabled: true
@@ -247,6 +256,17 @@ DevDoc/Rails/AvoidRailsCallbacks:
247
256
  Include:
248
257
  - "app/models/**/*.rb"
249
258
 
259
+ DevDoc/Rails/AvoidLifecycleMethodOverride:
260
+ Description: "Avoid overriding Rails validation/persistence lifecycle methods (run_validations!, valid?, etc.) to dodge AvoidRailsCallbacks."
261
+ Enabled: true
262
+ Include:
263
+ - "app/models/**/*.rb"
264
+ Methods:
265
+ - run_validations!
266
+ - valid?
267
+ - invalid?
268
+ - perform_validations
269
+
250
270
  DevDoc/Rails/ApplicationRecordTransaction:
251
271
  Description: "Use `ApplicationRecord.transaction` instead of `SomeModel.transaction` outside model files."
252
272
  Enabled: true
@@ -296,6 +316,14 @@ DevDoc/Rails/StrongParametersExpect:
296
316
  Include:
297
317
  - "app/controllers/**/*.rb"
298
318
 
319
+ DevDoc/Rails/AvoidRawSql:
320
+ Description: "Avoid raw SQL — prefer the hash/array form, parameterized fragments, or Arel; disable with a reason when raw SQL is genuinely required."
321
+ Enabled: true
322
+
323
+ DevDoc/Rails/AvoidOrderingById:
324
+ Description: "Avoid `order`/`reorder` by `id` as the primary (sole or leftmost) sort — order by a business column, or use `id` only as a secondary tiebreaker."
325
+ Enabled: true
326
+
299
327
  # `save!` / `update!` raise on persistence failure, which is what
300
328
  # we want in jobs/services/rake tasks — silent `false` returns
301
329
  # hide bugs. Excluded for controllers, which lean on `save`
@@ -368,4 +396,3 @@ DevDoc/Auth/CurrentUserBranching:
368
396
  - "app/helpers/**/*.rb"
369
397
  - "app/controllers/concerns/**/*.rb"
370
398
  - "app/views/layouts/**/*"
371
- - "app/controllers/application_controller.rb"
@@ -56,13 +56,56 @@ module RuboCop
56
56
  #
57
57
  # ## Allowed paths (Exclude:)
58
58
  # By default the cop is silent in:
59
- # app/policies/**/*.rb
59
+ # app/policies/**/*.rb ← auth-dependent checks belong here
60
60
  # app/helpers/**/*.rb
61
61
  # app/controllers/concerns/**/*.rb
62
- # app/views/layouts/**/*
63
- # app/controllers/application_controller.rb
62
+ # app/views/layouts/**/* ← display branching (nav bars, etc.)
64
63
  #
65
- # Override via `Exclude:` in your `.rubocop.yml`.
64
+ # Override via `Exclude:` in your `.rubocop.yml`. Note: literal file
65
+ # paths (e.g. `app/controllers/application_controller.rb`) in
66
+ # `.rubocop.yml` `Exclude:` lists are flagged by the
67
+ # `DevDoc::Test::Lints::NoFileExcludes` lint — they hide the
68
+ # suppression from readers of that file. If `ApplicationController`
69
+ # needs an exception, use an inline `# rubocop:disable` at the
70
+ # specific line with a reason.
71
+ #
72
+ # ## Before disabling inline, consider the Policy
73
+ # The Policy exclusion is not accidental — it's the canonical
74
+ # Rails home for auth-dependent branching. If the flagged line
75
+ # is an authorization check (`return if @record.accessible_by?(current_user)`,
76
+ # `if current_user.admin?`, etc.), the right move is almost
77
+ # always to push that check into a Pundit policy method, not
78
+ # disable inline. The controller becomes
79
+ # `return unless policy(@record).access?` — same behaviour,
80
+ # no `current_user` reference in the controller, and the auth
81
+ # logic is reusable + testable in isolation.
82
+ #
83
+ # Even better: declare `glib_authorize_resource` at the
84
+ # controller level. glib-web runs the appropriate policy
85
+ # method before each action automatically, so the per-action
86
+ # `verify_access` / `authorize @record` line is usually not
87
+ # needed at all — the controller body no longer references
88
+ # `current_user` because the auth check has moved out of the
89
+ # action entirely. See `best_practices/backend/en/05_controller.md`
90
+ # item #7 for the canonical pattern.
91
+ #
92
+ # ❌ Auth check in the controller — flagged
93
+ # def verify_access
94
+ # return if @support_question.accessible_by?(current_user)
95
+ # # ... token fallback ...
96
+ # end
97
+ #
98
+ # ✔️ Same check in the Policy — silently allowed
99
+ # # app/policies/support_question_policy.rb
100
+ # def access?
101
+ # user.present? && record.accessible_by?(user)
102
+ # end
103
+ #
104
+ # # in the controller:
105
+ # def verify_access
106
+ # return if policy(@support_question).access?
107
+ # # ... token fallback ...
108
+ # end
66
109
  #
67
110
  # NOTE: The cop does not autocorrect — there is no mechanical fix. The
68
111
  # right response depends on developer intent: drop the branch (if auth
@@ -43,6 +43,13 @@ module RuboCop
43
43
  # end
44
44
  # end
45
45
  #
46
+ # ## Forms covered
47
+ # The cop catches `default:` set at column-creation time AND `default:`
48
+ # set later via `change_column_default(..., to: <non-nil>)`. Both are
49
+ # the same anti-pattern — a permanent default living in the schema.
50
+ # `change_column_default(..., to: nil)` (removing a default) is not
51
+ # flagged; that is the cleanup form.
52
+ #
46
53
  # ## Exception (auto-detected)
47
54
  # For performance reasons (large tables with millions of records) or when
48
55
  # using `null: false`, you may temporarily set a default and then
@@ -51,6 +58,10 @@ module RuboCop
51
58
  # for the same table and column anywhere in the same method body (`def
52
59
  # change` / `def up`), including inside `reversible do |dir| dir.up`.
53
60
  #
61
+ # The exception applies symmetrically:
62
+ # - `add_column ... default: X` paired with `change_column_default ... to: nil` → no offense
63
+ # - `change_column_default ... to: X` paired with `change_column_default ... to: nil` → no offense
64
+ #
54
65
  # ✔ (no offense — two-step pattern auto-detected)
55
66
  # add_column :users, :profile_completion_rate, :float, default: 0.0
56
67
  # change_column_default :users, :profile_completion_rate, from: 0.0, to: nil
@@ -62,14 +73,22 @@ module RuboCop
62
73
  # # bad
63
74
  # t.string :status, default: 'active'
64
75
  #
76
+ # # bad — permanent default set via change_column_default
77
+ # change_column_default :users, :score, from: nil, to: 0
78
+ #
65
79
  # # good
66
80
  # add_column :users, :score, :integer
67
81
  #
68
82
  # # good (temporary default immediately removed — two-step pattern)
69
83
  # add_column :users, :score, :integer, default: 0
70
84
  # change_column_default :users, :score, from: 0, to: nil
85
+ #
86
+ # # good (removing an existing default)
87
+ # change_column_default :users, :score, from: 0, to: nil
71
88
  class AvoidColumnDefault < Base
72
89
  MSG = 'Avoid setting `default:` in migrations. Keep business logic defaults in the application layer.'.freeze
90
+ MSG_CHANGE = 'Avoid setting a non-nil default via `change_column_default`. ' \
91
+ 'Keep business logic defaults in the application layer.'.freeze
73
92
 
74
93
  COLUMN_METHODS = %i[
75
94
  string integer float boolean datetime date text binary decimal
@@ -84,9 +103,22 @@ module RuboCop
84
103
  (send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) (nil)) ...>))
85
104
  PATTERN
86
105
 
106
+ # change_column_default(:table, :col, ..., to: <non-nil>, ...) — captures table + col.
107
+ def_node_matcher :change_column_default_to_non_nil?, <<~PATTERN
108
+ (send _ :change_column_default (sym $_) (sym $_) (hash <(pair (sym :to) !nil) ...>))
109
+ PATTERN
110
+
87
111
  def on_send(node)
88
- return unless column_method?(node)
112
+ if node.method?(:change_column_default)
113
+ check_change_column_default(node)
114
+ elsif column_method?(node)
115
+ check_column_method(node)
116
+ end
117
+ end
118
+
119
+ private
89
120
 
121
+ def check_column_method(node)
90
122
  options = node.arguments.find(&:hash_type?)
91
123
  return unless options && has_default_option?(options)
92
124
  return if two_step_pattern?(node)
@@ -94,7 +126,16 @@ module RuboCop
94
126
  add_offense(find_default_pair(options))
95
127
  end
96
128
 
97
- private
129
+ def check_change_column_default(node)
130
+ captures = change_column_default_to_non_nil?(node)
131
+ return unless captures
132
+
133
+ table_name, col_name = captures
134
+ return if cancelled_in_same_method?(node, table_name, col_name)
135
+
136
+ options = node.arguments.find(&:hash_type?)
137
+ add_offense(find_to_pair(options), message: MSG_CHANGE)
138
+ end
98
139
 
99
140
  def column_method?(node)
100
141
  node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
@@ -104,10 +145,20 @@ module RuboCop
104
145
  options.pairs.find { |p| p.key.sym_type? && p.key.value == :default }
105
146
  end
106
147
 
148
+ def find_to_pair(options)
149
+ options.pairs.find { |p| p.key.sym_type? && p.key.value == :to }
150
+ end
151
+
107
152
  def two_step_pattern?(node)
108
153
  table_name, col_name = extract_table_and_column(node)
109
154
  return false unless table_name && col_name
110
155
 
156
+ cancelled_in_same_method?(node, table_name, col_name)
157
+ end
158
+
159
+ # Is there a `change_column_default(..., to: nil)` for the same
160
+ # (table, col) anywhere in the enclosing method? Used by both forms.
161
+ def cancelled_in_same_method?(node, table_name, col_name)
111
162
  enclosing_def = node.each_ancestor(:def).first
112
163
  return false unless enclosing_def
113
164
 
@@ -2,20 +2,24 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Migration
5
- # Avoid vague column names like `status` or `group`.
5
+ # Avoid vague column names like `status`, `group`, or `kind`.
6
6
  #
7
7
  # ## Rationale
8
8
  # Use more specific names that describe the domain context. A column
9
9
  # named `status` reveals nothing about *what* status it tracks; a column
10
10
  # named `processing_status` makes the intent obvious at the call site.
11
+ # `kind` is the same shape — a content-free "what variety" label that
12
+ # reads no better than `type` (which Rails reserves for STI).
11
13
  #
12
14
  # ❌
13
15
  # t.string :status
14
16
  # add_column :orders, :group, :integer
17
+ # t.integer :kind
15
18
  #
16
19
  # ✔️
17
20
  # t.string :processing_status
18
21
  # add_column :orders, :user_group, :integer
22
+ # t.integer :membership_kind
19
23
  #
20
24
  # ## Note about `type`
21
25
  # `type` is reserved by Rails for Single Table Inheritance (STI). Even
@@ -29,10 +33,12 @@ module RuboCop
29
33
  # # bad
30
34
  # t.string :status
31
35
  # add_column :orders, :group, :integer
36
+ # t.integer :kind
32
37
  #
33
38
  # # good
34
39
  # t.string :processing_status
35
40
  # add_column :orders, :user_group, :integer
41
+ # t.integer :membership_kind
36
42
  class AvoidVagueColumnNames < Base
37
43
  MSG = 'Avoid vague column name `%<name>s`. Use a more specific name that includes context.'.freeze
38
44
 
@@ -0,0 +1,107 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid overriding Rails validation/persistence *lifecycle* methods in
6
+ # model files (`run_validations!`, `valid?`, etc.).
7
+ #
8
+ # ## Rationale
9
+ # `DevDoc/Rails/AvoidRailsCallbacks` bans the callback DSL so lifecycle
10
+ # behaviour stays visible at explicit call sites. Overriding a lifecycle
11
+ # method is a loophole — it reproduces the same hidden control flow
12
+ # without tripping that cop:
13
+ #
14
+ # # Flagged by AvoidRailsCallbacks:
15
+ # before_validation :do_something
16
+ #
17
+ # # NOT flagged by it — but functionally identical, and worse:
18
+ # def run_validations!
19
+ # do_something
20
+ # super
21
+ # end
22
+ #
23
+ # `def run_validations!; do_something; super; end` is a
24
+ # `before_validation :do_something` in disguise, and it's strictly worse:
25
+ #
26
+ # - **Silent on typo.** Misspell the method name (drop the `!`, or
27
+ # `run_validatons!`) and it's just a never-called method — no load-time
28
+ # error, no override in effect, behaviour silently reverts to stock
29
+ # Rails. The callback DSL fails loudly (`NoMethodError`) on a mistyped
30
+ # macro or symbol, caught by the first test that validates.
31
+ # - **More obscure.** Overriding an internal Rails method hides the
32
+ # lifecycle hook even more than the DSL it's avoiding.
33
+ #
34
+ # This cop pairs with (it does not replace) `AvoidRailsCallbacks`: the two
35
+ # cover the two ways to inject lifecycle behaviour — the DSL, and the
36
+ # override.
37
+ #
38
+ # ## What to do instead
39
+ # 1. Prefer an explicit method at the call site (e.g. `save_with_*`) that
40
+ # makes the behaviour visible, or a plain `validate :some_check` where
41
+ # a validation-time check is all you need.
42
+ # 2. If an override is genuinely required (e.g. a model whose validators
43
+ # read access-gated attributes and must wrap the *entire* validation
44
+ # run — something `validate` can't express), disable this cop inline
45
+ # with a written justification:
46
+ #
47
+ # def run_validations! # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride
48
+ # # Reason: <explanation>
49
+ # with_access { super }
50
+ # end
51
+ #
52
+ # ## Not airtight
53
+ # A determined dodge can still `prepend` a module, `alias_method`, or use
54
+ # `method_missing`. This cop raises the bar and makes the documented path
55
+ # (inline disable + justification) the easy one; it does not seal every
56
+ # hole.
57
+ #
58
+ # ## Configuration
59
+ # The flagged methods are configurable via `Methods`. The default is the
60
+ # validation-lifecycle set — the cleanest 1:1 substitute for the
61
+ # validation callbacks `AvoidRailsCallbacks` bans, with the least
62
+ # false-positive noise. Projects that also want to guard persistence
63
+ # verbs can add `save`/`save!`/`create`/`update`/`destroy` etc.
64
+ #
65
+ # @example
66
+ # # bad
67
+ # def run_validations!
68
+ # normalize
69
+ # super
70
+ # end
71
+ #
72
+ # # bad
73
+ # def valid?(context = nil)
74
+ # toggle_access { super }
75
+ # end
76
+ #
77
+ # # good — an explicit method / a plain validation declaration
78
+ # validate :assert_consistent
79
+ #
80
+ # # good — a non-lifecycle override is fine
81
+ # def to_param
82
+ # slug
83
+ # end
84
+ class AvoidLifecycleMethodOverride < Base
85
+ MSG = 'Avoid overriding the Rails lifecycle method `%<method>s` — it hides ' \
86
+ 'callback-like control flow and silently no-ops if mistyped. Prefer an ' \
87
+ 'explicit method or a `validate` declaration; if an override is genuinely ' \
88
+ 'required, disable this cop inline with a written justification.'.freeze
89
+
90
+ DEFAULT_METHODS = %w[run_validations! valid? invalid? perform_validations].freeze
91
+
92
+ def on_def(node)
93
+ return unless flagged_methods.include?(node.method_name)
94
+
95
+ add_offense(node.loc.name, message: format(MSG, method: node.method_name))
96
+ end
97
+
98
+ private
99
+
100
+ def flagged_methods
101
+ @flagged_methods ||= Array(cop_config['Methods'] || DEFAULT_METHODS).map(&:to_sym)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,167 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Avoid ordering by `id` as the primary (sole or leftmost) sort.
6
+ #
7
+ # ## Rationale
8
+ # `order(id: ...)` / `order(:id)` sorts by the primary key, which
9
+ # reflects insertion order (and, for UUIDs, random order) rather
10
+ # than a column the reader can reason about. It makes a list read
11
+ # as "most recent" by accident, hides the real ordering intent,
12
+ # and silently breaks the moment rows are imported out of sequence
13
+ # or the primary key type changes.
14
+ #
15
+ # Order by a meaningful business column instead, and reach for
16
+ # `id` only as a **secondary** key to break ties deterministically:
17
+ #
18
+ # ❌ Primary key is not a sort the reader can reason about
19
+ # Snapshot.order(id: :desc)
20
+ #
21
+ # ✔️ Business column carries the intent
22
+ # Snapshot.order(created_at: :desc)
23
+ #
24
+ # ✔️ `id` as a deterministic tiebreaker — never the primary sort
25
+ # Snapshot.order(created_at: :desc, id: :desc)
26
+ #
27
+ # See `best_practices/backend/en/05_controller.md` — always `.order()`
28
+ # when displaying more than one record, and reserve `order(id: ...)`
29
+ # for the rare case where id-ordering is genuinely intended.
30
+ #
31
+ # ## Scope
32
+ # Flags `order` and `reorder` where `:id` is the **sole** ordering
33
+ # column or the **leftmost** (primary) one, in hash form
34
+ # (`order(id: :desc)`), symbol form (`order(:id)`,
35
+ # `order(:id, :name)`), or array form (`order([:id])`).
36
+ #
37
+ # Only the FIRST argument is inspected — it is the primary sort;
38
+ # every later argument is a tiebreaker. Raw-SQL string ordering
39
+ # (`order("id")`, `order("id DESC")`) is the domain of
40
+ # `DevDoc/Rails/AvoidRawSql`, so this cop defers the moment its
41
+ # leading argument is a string, an `Arel.sql(...)`, or anything
42
+ # other than a hash/symbol/array — the primary sort can't be read
43
+ # statically, and flagging would risk a false positive on a
44
+ # tiebreaker.
45
+ #
46
+ # NOTE: Each `order`/`reorder` call is inspected in isolation, so a
47
+ # chained form like `rel.order(:created_at).order(id: :desc)` is
48
+ # flagged even though Rails *appends* chained `order` calls — making
49
+ # `id` a secondary tiebreaker there. The deterministic-tiebreaker
50
+ # idiom this cop endorses is the single-call hash/symbol form
51
+ # (`order(created_at: :desc, id: :desc)`); prefer that, or disable
52
+ # inline with a reason when the chained form is genuinely intended.
53
+ #
54
+ # ## `id` as a secondary key is allowed
55
+ # `order(created_at: :desc, id: :desc)` is the standard pattern for
56
+ # deterministic ordering — `id` only breaks ties after the business
57
+ # column. The cop allows `:id` in any non-primary position:
58
+ #
59
+ # ✔️ `id` breaks ties only — primary sort is `created_at`
60
+ # Snapshot.order(created_at: :desc, id: :desc)
61
+ # Snapshot.order(:created_at, :id)
62
+ #
63
+ # ## Exception
64
+ # When `id` is genuinely the intended primary sort (rare — usually a
65
+ # sign something is special about the code), suppress the offense
66
+ # with an inline disable comment and a brief reason — e.g. an
67
+ # append-only audit log where `id` is creation order, so
68
+ # id-ordering is the real intent rather than an accident.
69
+ #
70
+ # NOTE: A named scope or model method that wraps the offense hides
71
+ # it at the call site — `scope :newest, -> { order(id: :desc) }` is
72
+ # flagged in the model file, but callers of `.newest` are not. The
73
+ # cop flags the definition, which is where the decision belongs.
74
+ #
75
+ # NOTE: Prefer `order(created_at: :desc)` for deterministic
76
+ # results — see `best_practices/backend/en/05_controller.md` #4.
77
+ # The rare legitimate `order(id:)` cases (see Exception above) —
78
+ # e.g. a test grabbing a record a controller just created, where
79
+ # `created_at` ties across one request and `id` is the only
80
+ # reliable insertion-order proxy — take an inline disable with a
81
+ # reason, not a blanket `Exclude` of `spec/**`/`test/**` (that
82
+ # silences accidental id-ordering in tests).
83
+ #
84
+ # @example
85
+ # # bad — `id` is the sole or primary sort
86
+ # Snapshot.order(id: :desc)
87
+ # Snapshot.order(:id)
88
+ # Snapshot.order(id: :desc, created_at: :desc)
89
+ # Snapshot.order(:id, :created_at)
90
+ # submission.snapshots.reorder(id: :asc)
91
+ #
92
+ # # good — business column primary, `id` as a tiebreaker
93
+ # Snapshot.order(created_at: :desc)
94
+ # Snapshot.order(created_at: :desc, id: :desc)
95
+ # Snapshot.order(:created_at, :id)
96
+ class AvoidOrderingById < Base
97
+ MSG = 'Ordering by `id` as the primary sort exposes the primary ' \
98
+ "key's insertion order rather than a meaningful column. " \
99
+ 'Order by a business column (e.g. `created_at:`), or move ' \
100
+ '`id` to a secondary position as a deterministic tiebreaker. ' \
101
+ 'Disable with a reason when `id` is genuinely the intended ' \
102
+ 'primary sort.'.freeze
103
+
104
+ RESTRICT_ON_SEND = %i[order reorder].freeze
105
+
106
+ def on_send(node)
107
+ first = first_ordering_column(node)
108
+ return unless first
109
+ return unless id_column?(first)
110
+
111
+ add_offense(node.loc.selector)
112
+ end
113
+
114
+ # Safe navigation (`rel&.order(id: :desc)`) parses as a `:csend`
115
+ # node, which `on_send` does not visit — alias it so the same
116
+ # check covers both dispatch forms.
117
+ alias on_csend on_send
118
+
119
+ private
120
+
121
+ # The leftmost ordering column, or nil when the primary sort
122
+ # isn't statically knowable. Only the FIRST argument is inspected
123
+ # — it alone is the primary sort; every later argument is a
124
+ # tiebreaker.
125
+ #
126
+ # order(:created_at, id: :desc) → :created_at (id secondary)
127
+ # order(id: :desc) → :id pair key
128
+ # order([:id]) → :id (array recurses)
129
+ #
130
+ # Returns nil (defers) for a raw-SQL string (`DevDoc/Rails/
131
+ # AvoidRawSql` owns it) AND for any other non-structured leading
132
+ # arg — an `Arel.sql` node, a helper call, a splat, a literal —
133
+ # because the primary sort can't be read statically and flagging
134
+ # would risk a false positive on what may be a tiebreaker.
135
+ def first_ordering_column(node)
136
+ ordering_column_of(node.arguments.first)
137
+ end
138
+
139
+ # Resolve the primary sort from a single argument node, recursing
140
+ # into an array (`order([:id])`) so the array form matches the
141
+ # bare-symbol form.
142
+ def ordering_column_of(arg)
143
+ return nil unless arg
144
+ return nil if arg.str_type? || arg.dstr_type?
145
+ return first_hash_key(arg) if arg.hash_type?
146
+ return ordering_column_of(arg.children.first) if arg.array_type?
147
+ return arg if arg.sym_type?
148
+
149
+ nil
150
+ end
151
+
152
+ def first_hash_key(hash_node)
153
+ pair = hash_node.pairs.first
154
+ return nil unless pair
155
+
156
+ key = pair.key
157
+ key if key&.sym_type?
158
+ end
159
+
160
+ def id_column?(node)
161
+ node.sym_type? && node.value == :id
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -22,12 +22,19 @@ module RuboCop
22
22
  # class Order < ApplicationRecord
23
23
  # def save_with_confirmation_email
24
24
  # transaction do
25
- # save!
26
- # OrderMailer.confirmation(self).deliver_later
25
+ # saved = save
26
+ # invoice.save! if saved
27
27
  # end
28
+ # OrderMailer.confirmation(self).deliver_later if saved
28
29
  # end
29
30
  # end
30
31
  #
32
+ # The `deliver_later` is *outside* the transaction deliberately —
33
+ # `DevDoc/Rails/NoDeliverLaterInTransaction` flags mailers/jobs
34
+ # queued inside a transaction (the job may run before commit and
35
+ # read stale data). The `saved` local leaks out of the block
36
+ # (Ruby's normal scoping), gating the mailer on actual success.
37
+ #
31
38
  # ## When you have multiple call sites needing the same guard
32
39
  # The single-method-per-place pattern above works when there is one
33
40
  # natural call site. When several controllers/jobs/bulk operations all
@@ -101,8 +108,11 @@ module RuboCop
101
108
  #
102
109
  # # good
103
110
  # def save_with_confirmation
104
- # transaction { save! }
105
- # send_confirmation
111
+ # transaction do
112
+ # saved = save
113
+ # invoice.save! if saved
114
+ # end
115
+ # send_confirmation if saved
106
116
  # end
107
117
  class AvoidRailsCallbacks < Base
108
118
  CALLBACKS = %i[
@@ -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
@@ -120,15 +120,21 @@ module RuboCop
120
120
  end
121
121
 
122
122
  # Build the replacement for `params.require(:key).permit(*args)`.
123
- # Symbol args stay as-is; hash args are wrapped in `{ }` since they
124
- # need explicit braces when placed inside an array literal.
123
+ # Symbol args stay as-is; hash args need explicit braces when placed
124
+ # inside the `expect` array literal.
125
125
  def build_require_permit_replacement(params_node, key, permit_args)
126
126
  inner = permit_args.map { |arg| permit_arg_source(arg) }.join(', ')
127
127
  "#{params_node.source}.expect(#{key}: [#{inner}])"
128
128
  end
129
129
 
130
+ # A hash arg written WITHOUT braces (the trailing-kwargs form,
131
+ # `permit(:a, b: [1])`) has a brace-less source, so add them. A hash
132
+ # arg written WITH explicit braces (`permit(:a, { b: [1] })`) already
133
+ # includes them in its source — wrapping again would emit `{ {...} }`.
130
134
  def permit_arg_source(arg)
131
- arg.hash_type? ? "{ #{arg.source} }" : arg.source
135
+ return arg.source unless arg.hash_type?
136
+
137
+ arg.braces? ? arg.source : "{ #{arg.source} }"
132
138
  end
133
139
  end
134
140
  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.3.1".freeze
3
+ VERSION = "0.5.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-11 00:00:00.000000000 Z
11
+ date: 2026-06-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -107,7 +107,10 @@ files:
107
107
  - lib/rubocop/cop/dev_doc/migration/require_primary_key.rb
108
108
  - lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
109
109
  - lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
110
+ - lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb
111
+ - lib/rubocop/cop/dev_doc/rails/avoid_ordering_by_id.rb
110
112
  - lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb
113
+ - lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb
111
114
  - lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb
112
115
  - lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb
113
116
  - lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb
@@ -123,9 +126,11 @@ files:
123
126
  - lib/rubocop/cop/dev_doc/style/avoid_send.rb
124
127
  - lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb
125
128
  - lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb
129
+ - lib/rubocop/cop/dev_doc/style/prefer_public_send.rb
126
130
  - lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb
127
131
  - lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb
128
132
  - lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb
133
+ - lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb
129
134
  - lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
130
135
  - lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
131
136
  - lib/rubocop/cop/dev_doc/test/response_assert_equal.rb