rubocop-dev_doc 0.6.0.beta1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +152 -1
  3. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
  4. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
  5. data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
  6. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
  7. data/lib/rubocop/cop/dev_doc/migration/avoid_boolean_column.rb +135 -0
  8. data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
  9. data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +72 -11
  10. data/lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb +122 -0
  11. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
  12. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
  13. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
  14. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
  15. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
  16. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
  17. data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
  18. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
  19. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
  20. data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
  21. data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
  22. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
  23. data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +5 -3
  24. data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
  25. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
  26. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
  27. data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
  28. data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
  29. data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
  30. data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
  31. data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
  32. data/lib/rubocop/dev_doc/version.rb +1 -1
  33. metadata +22 -5
@@ -26,6 +26,12 @@ module RuboCop
26
26
  # ❌ Regular column
27
27
  # add_column :users, :profile_completion_rate, :float, null: false
28
28
  #
29
+ # ❌ Tightening an existing column to NOT NULL
30
+ # change_column_null :users, :name, false
31
+ #
32
+ # ❌ Same tightening, via change_column
33
+ # change_column :users, :name, :string, null: false
34
+ #
29
35
  # ✔️ Regular column
30
36
  # add_column :users, :profile_completion_rate, :float
31
37
  #
@@ -47,6 +53,12 @@ module RuboCop
47
53
  # integer, so THIS cop cannot detect it and WILL flag it. Disable it on
48
54
  # the line with a brief reason — `-- enum` — so the migration is
49
55
  # self-documenting: a reader sees at a glance that the column is an enum.
56
+ # - **Boolean columns justified via `DevDoc/Migration/AvoidBooleanColumn`**
57
+ # — once the developer has justified a boolean through that cop's
58
+ # escape hatch, NULL is outside {true, false} (just as it is outside an
59
+ # enum's domain), so `null: false` is required and enforced by the
60
+ # sibling cop `DevDoc/Migration/BooleanColumnNotNull`. Disable this cop
61
+ # on the line with `-- boolean` so the migration is self-documenting.
50
62
  #
51
63
  # ✔️ Required foreign key (never flagged)
52
64
  # t.belongs_to :user, null: false, foreign_key: true
@@ -56,20 +68,41 @@ module RuboCop
56
68
  # add_column :orders, :status, :integer, null: false
57
69
  # # rubocop:enable DevDoc/Migration/AvoidNonNull
58
70
  #
71
+ # ✔️ Boolean (flagged here — disable with a brief `-- boolean` reason)
72
+ # # rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
73
+ # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
74
+ # add_column :things, :flag, :boolean, null: false
75
+ # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
76
+ # # rubocop:enable DevDoc/Migration/AvoidNonNull
77
+ #
59
78
  # NOTE: This cop is deliberately NOT enum-aware. It could read the
60
79
  # model's `enum` declarations and skip those columns, but requiring an
61
80
  # explicit per-line disable is intentional: it forces the developer to
62
81
  # signal that the column is an enum, which documents the migration. A
63
82
  # silent skip would hide that intent.
64
83
  #
65
- # NOTE: This cop only flags `null: false`. It does not flag `null: true`
66
- # (redundant but harmless), and it does not require foreign keys to carry
67
- # `null: false` adding it to an FK is encouraged but not enforced here.
84
+ # NOTE: This cop also flags NOT NULL set on an existing column, by either
85
+ # API: `change_column_null(table, column, false)` and
86
+ # `change_column(table, column, type, null: false)`. Both express the same
87
+ # constraint as `null: false` on a definition (the add-nullable -> backfill
88
+ # -> tighten step), so a legit enum/boolean tightening disables this cop
89
+ # inline with `-- enum`/`-- boolean`, exactly as for a new column.
90
+ #
91
+ # NOTE: This cop only flags `null: false` (and the equivalent `false` arg
92
+ # of `change_column_null`). It does not flag `null: true` (redundant but
93
+ # harmless), and it does not require foreign keys to carry `null: false`
94
+ # — adding it to an FK is encouraged but not enforced here.
68
95
  #
69
96
  # @example
70
97
  # # bad
71
98
  # add_column :users, :name, :string, null: false
72
99
  #
100
+ # # bad (tightening an existing column to NOT NULL)
101
+ # change_column_null :users, :name, false
102
+ #
103
+ # # bad (same tightening, via change_column)
104
+ # change_column :users, :name, :string, null: false
105
+ #
73
106
  # # bad (enum without a disable — the cop flags it; disable with `-- enum`)
74
107
  # t.integer :processing_status, null: false
75
108
  #
@@ -91,28 +124,56 @@ module RuboCop
91
124
  json jsonb bigint
92
125
  ].freeze
93
126
 
94
- RESTRICT_ON_SEND = (COLUMN_METHODS + %i[add_column]).freeze
127
+ RESTRICT_ON_SEND = (COLUMN_METHODS + %i[add_column change_column change_column_null]).freeze
95
128
 
96
129
  def_node_matcher :null_false_pair, <<~PATTERN
97
130
  (hash <$(pair (sym :null) (false)) ...>)
98
131
  PATTERN
99
132
 
133
+ # Matches `change_column_null :table, :column, false` (and the optional
134
+ # trailing-default 4-arg form). The `false` arg IS the NOT NULL directive
135
+ # — the same constraint as `null: false` on a definition, just applied to
136
+ # an existing column via the standard add-nullable -> backfill -> tighten
137
+ # step. Flagged so the cop cannot be sidestepped by choice of API: a
138
+ # regular column tightened to NOT NULL is held to the same standard as one
139
+ # declared NOT NULL up front. A legit enum/boolean tightening resolves at
140
+ # the disable site, exactly as for a new column.
141
+ def_node_matcher :change_column_null_false, <<~PATTERN
142
+ (send _ :change_column_null _ _ $false ...)
143
+ PATTERN
144
+
100
145
  def on_send(node)
146
+ flag = offense_node(node)
147
+ add_offense(flag) if flag
148
+ end
149
+
150
+ private
151
+
152
+ # The node whose NOT NULL directive offends: the `false` arg of
153
+ # `change_column_null`, or the `null: false` pair on a regular column
154
+ # definition. nil when there is nothing to flag.
155
+ def offense_node(node)
156
+ change_column_null_false(node) || column_null_false_pair(node)
157
+ end
158
+
159
+ def column_null_false_pair(node)
101
160
  return unless column_method?(node)
102
161
 
103
162
  options = node.arguments.find(&:hash_type?)
104
163
  return unless options
105
164
 
106
- pair = null_false_pair(options)
107
- return unless pair
108
-
109
- add_offense(pair)
165
+ null_false_pair(options)
110
166
  end
111
167
 
112
- private
113
-
168
+ # Methods that define or change a regular column and carry an options
169
+ # hash where `null:` may appear: the `t.<type>` helpers plus
170
+ # `add_column`/`change_column`. (`change_column_null` is handled
171
+ # separately — its NOT NULL directive is a positional `false`, not an
172
+ # options pair.)
114
173
  def column_method?(node)
115
- node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
174
+ node.method?(:add_column) ||
175
+ node.method?(:change_column) ||
176
+ COLUMN_METHODS.include?(node.method_name)
116
177
  end
117
178
  end
118
179
  end
@@ -0,0 +1,122 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Boolean columns must carry `null: false`.
6
+ #
7
+ # ## Rationale
8
+ # `null: false` is reserved for cases where NULL has no meaningful
9
+ # interpretation — and a justified boolean is one of those cases.
10
+ # NULL is outside the column's stated domain of {true, false}: it
11
+ # is neither value, and every read site has to silently coerce it
12
+ # (`nil || false == false`) or risk a footgun.
13
+ #
14
+ # The line is drawn here for standardization and non-subjectivity.
15
+ # Whether a *regular* column should be present is a business
16
+ # decision open to debate (see `DevDoc/Migration/AvoidNonNull`),
17
+ # but once the developer has justified a boolean via
18
+ # `DevDoc/Migration/AvoidBooleanColumn`'s escape hatch, the
19
+ # boolean's non-null-ness is objective — it does not depend on
20
+ # any further business decision — so it is enforced mechanically
21
+ # rather than argued column-by-column.
22
+ #
23
+ # This cop is the inverse of `DevDoc/Migration/AvoidNonNull` for
24
+ # the boolean case: that cop flags `null: false` on regular
25
+ # columns (including booleans, which it cannot distinguish from
26
+ # other types); this cop REQUIRES `null: false` on booleans. The
27
+ # contradiction is resolved at the disable site: when the
28
+ # developer disables `AvoidNonNull` on a boolean with `-- boolean`
29
+ # (alongside the `AvoidBooleanColumn` disable), this cop fires if
30
+ # `null: false` is missing.
31
+ #
32
+ # ## Adding `null: false` to a table with existing rows
33
+ # Postgres refuses to add a NOT NULL column to a non-empty table
34
+ # without a backfill. Use the model-validations backfill pattern
35
+ # (sanctioned by `DevDoc/Migration/AvoidColumnDefault`):
36
+ #
37
+ # def change
38
+ # add_column :table, :flag, :boolean
39
+ #
40
+ # reversible do |dir|
41
+ # dir.up do
42
+ # Table.reset_column_information
43
+ # Table.where(flag: nil).find_each do |t|
44
+ # t.flag = false
45
+ # t.save!
46
+ # end
47
+ # end
48
+ # end
49
+ #
50
+ # # rubocop:disable DevDoc/Migration/AvoidNonNull -- boolean
51
+ # change_column_null :table, :flag, false
52
+ # # rubocop:enable DevDoc/Migration/AvoidNonNull
53
+ # end
54
+ #
55
+ # The application layer is the source of truth for the default
56
+ # value on new records (e.g. via `after_initialize` or a factory).
57
+ # `default:` in the migration is still disallowed by
58
+ # `DevDoc/Migration/AvoidColumnDefault`.
59
+ #
60
+ # ## Interaction with AvoidBooleanColumn
61
+ # These two cops fire together on every boolean addition:
62
+ #
63
+ # - `AvoidBooleanColumn`: "reconsider using a boolean at all"
64
+ # - `BooleanColumnNotNull`: "if you do, it must be NOT NULL"
65
+ #
66
+ # Satisfy both by either changing the column type (timestamp /
67
+ # enum / model method) or, for a justified boolean, disabling
68
+ # `AvoidBooleanColumn` with `-- boolean` AND ensuring `null: false`
69
+ # is present so this cop does not fire.
70
+ #
71
+ # NOTE: This cop catches `t.boolean`, `add_column ..., :boolean`, and
72
+ # `change_column ..., :boolean` (a type change to boolean) without
73
+ # `null: false`. It does NOT scan `db/schema.rb` for legacy debt — it
74
+ # only enforces the invariant on declarations it can see. Existing
75
+ # nullable booleans are surfaced by inspection, not by this cop.
76
+ #
77
+ # @example
78
+ # # bad - nullable boolean
79
+ # add_column :users, :active, :boolean
80
+ #
81
+ # # bad - nullable boolean in a create_table block
82
+ # create_table :things do |t|
83
+ # t.boolean :flag
84
+ # end
85
+ #
86
+ # # good - boolean carries null: false
87
+ # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
88
+ # add_column :things, :flag, :boolean, null: false
89
+ # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
90
+ class BooleanColumnNotNull < Base
91
+ MSG = 'Boolean columns must carry `null: false` — NULL is outside {true, false}. ' \
92
+ 'Backfill existing rows via the model, then `change_column_null`.'.freeze
93
+
94
+ def_node_matcher :boolean_column?, <<~PATTERN
95
+ (send _ {:boolean} ...)
96
+ PATTERN
97
+
98
+ def_node_matcher :add_column_boolean?, <<~PATTERN
99
+ (send _ :add_column _ _ (sym :boolean) ...)
100
+ PATTERN
101
+
102
+ def_node_matcher :change_column_to_boolean?, <<~PATTERN
103
+ (send _ :change_column _ _ (sym :boolean) ...)
104
+ PATTERN
105
+
106
+ def_node_matcher :null_false_pair, <<~PATTERN
107
+ (hash <$(pair (sym :null) (false)) ...>)
108
+ PATTERN
109
+
110
+ def on_send(node)
111
+ return unless boolean_column?(node) || add_column_boolean?(node) || change_column_to_boolean?(node)
112
+
113
+ options = node.arguments.find(&:hash_type?)
114
+ return if options && null_false_pair(options)
115
+
116
+ add_offense(node)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -34,7 +34,10 @@ module RuboCop
34
34
  class ApplicationRecordTransaction < Base
35
35
  extend AutoCorrector
36
36
 
37
- ALLOWED_RECEIVERS = %w[ApplicationRecord ActiveRecord::Base].freeze
37
+ # Source-string comparison, so the cbase-qualified spellings must be
38
+ # listed too — `::ApplicationRecord.transaction` was a false positive.
39
+ ALLOWED_RECEIVERS = %w[ApplicationRecord ::ApplicationRecord
40
+ ActiveRecord::Base ::ActiveRecord::Base].freeze
38
41
 
39
42
  MSG = 'Use `ApplicationRecord.transaction` instead of `%<receiver>s.transaction` outside model files.'.freeze
40
43
  RESTRICT_ON_SEND = %i[transaction].freeze
@@ -115,12 +115,17 @@ module RuboCop
115
115
  # send_confirmation if saved
116
116
  # end
117
117
  class AvoidRailsCallbacks < Base
118
+ # Includes every *_commit shorthand and the underlying set_callback
119
+ # API — omitting any of them (after_update_commit was missed for a
120
+ # while) leaves a zero-effort spelling of the same callback.
118
121
  CALLBACKS = %i[
119
- after_create after_create_commit after_save after_update
120
- after_destroy after_commit after_rollback after_initialize
122
+ after_create after_create_commit after_save after_save_commit
123
+ after_update after_update_commit after_destroy after_destroy_commit
124
+ after_commit before_commit after_rollback after_initialize
121
125
  after_find after_touch before_create before_save before_update
122
126
  before_destroy before_validation after_validation
123
127
  around_create around_save around_update around_destroy
128
+ set_callback
124
129
  ].freeze
125
130
 
126
131
  MSG = 'Avoid `%<method>s` — extract an explicit method (e.g. `save_with_*`) ' \
@@ -64,7 +64,10 @@ module RuboCop
64
64
  MSG = 'Use `%<bang>s` inside a `transaction` block, or check its return value. ' \
65
65
  'A non-bang call whose return value is discarded does not roll back the transaction on failure.'.freeze
66
66
 
67
- FLAGGED_METHODS = %i[save update create].freeze
67
+ # destroy has the same failure mode: it returns false when a
68
+ # before_destroy callback aborts, and a discarded false does not
69
+ # roll the transaction back.
70
+ FLAGGED_METHODS = %i[save update create destroy].freeze
68
71
 
69
72
  # Node types whose parent always means the return value is consumed.
70
73
  CONSUMING_PARENT_TYPES = %i[
@@ -87,9 +90,11 @@ module RuboCop
87
90
 
88
91
  private
89
92
 
93
+ # with_lock opens a transaction too, so a discarded non-bang save
94
+ # inside it fails just as silently.
90
95
  def inside_transaction?(node)
91
96
  node.each_ancestor(:block).any? do |ancestor|
92
- ancestor.method_name == :transaction
97
+ %i[transaction with_lock].include?(ancestor.method_name)
93
98
  end
94
99
  end
95
100
 
@@ -32,6 +32,16 @@ module RuboCop
32
32
  # ✔
33
33
  # enum_symbolize :payment_status, :finalize_intent
34
34
  #
35
+ # Enums declared inside a concern's `included do` block are checked the
36
+ # same way — the pairing must live in the same `included do` block,
37
+ # which is also where it belongs so every includer gets both:
38
+ #
39
+ # ✔
40
+ # included do
41
+ # enum :discount_type, DISCOUNT_TYPES
42
+ # enum_symbolize :discount_type
43
+ # end
44
+ #
35
45
  # @example
36
46
  # # bad
37
47
  # enum :status, { active: 0, archived: 1 }
@@ -51,24 +61,29 @@ module RuboCop
51
61
  (send nil? :enum_symbolize $...)
52
62
  PATTERN
53
63
 
64
+ def_node_matcher :included_block?, <<~PATTERN
65
+ (block (send nil? :included) (args) _)
66
+ PATTERN
67
+
54
68
  def on_class(node)
55
- body = node.body
56
- return unless body
69
+ check_body(node.body)
70
+ end
57
71
 
58
- statements = body.begin_type? ? body.children : [body]
72
+ # Concern-declared enums: `included do ... end` runs in the including
73
+ # class's context, so its statements are checked like a class body.
74
+ def on_block(node)
75
+ return unless included_block?(node)
59
76
 
60
- enum_decls = []
61
- symbolized = []
77
+ check_body(node.body)
78
+ end
62
79
 
63
- statements.each do |stmt|
64
- next unless stmt.respond_to?(:send_type?) && stmt.send_type?
80
+ private
65
81
 
66
- if (name = enum_call(stmt))
67
- enum_decls << [name, stmt]
68
- elsif (args = enum_symbolize_call(stmt))
69
- symbolized.concat(args.select(&:sym_type?).map(&:value))
70
- end
71
- end
82
+ def check_body(body)
83
+ return unless body
84
+
85
+ statements = body.begin_type? ? body.children : [body]
86
+ enum_decls, symbolized = scan(statements)
72
87
 
73
88
  enum_decls.each do |name, decl|
74
89
  next if symbolized.include?(name)
@@ -76,6 +91,20 @@ module RuboCop
76
91
  add_offense(decl, message: format(MSG, name: name))
77
92
  end
78
93
  end
94
+
95
+ def scan(statements)
96
+ sends = statements.select { |stmt| stmt.respond_to?(:send_type?) && stmt.send_type? }
97
+ enum_decls = sends.filter_map { |stmt| (name = enum_call(stmt)) && [name, stmt] }
98
+ symbolized = sends.flat_map { |stmt| symbolized_names(stmt) }
99
+ [enum_decls, symbolized]
100
+ end
101
+
102
+ def symbolized_names(stmt)
103
+ args = enum_symbolize_call(stmt)
104
+ return [] unless args
105
+
106
+ args.select(&:sym_type?).map(&:value)
107
+ end
79
108
  end
80
109
  end
81
110
  end
@@ -104,7 +104,9 @@ module RuboCop
104
104
  MSG = '`%<method>s` with a block loads every row into Ruby. ' \
105
105
  'Push the predicate into SQL with `.where(...)` or a model scope.'.freeze
106
106
 
107
- RESTRICT_ON_SEND = %i[count reject select find any?].freeze
107
+ # filter/detect are the Ruby aliases of select/find — leaving them
108
+ # out would make the alias a zero-cost dodge.
109
+ RESTRICT_ON_SEND = %i[count reject select filter find detect any?].freeze
108
110
 
109
111
  # Methods whose return value is known to be a non-Relation collection
110
112
  # (Array, Hash, or Enumerator). When a `.select`/`.reject`/etc. with a
@@ -129,7 +131,7 @@ module RuboCop
129
131
  ].freeze
130
132
 
131
133
  def on_send(node)
132
- return unless node.block_literal?
134
+ return unless node.block_literal? || symbol_block_pass?(node)
133
135
  return if node.receiver.nil?
134
136
  return if excluded_receiver?(node.receiver)
135
137
  return if excluded_block?(node)
@@ -139,6 +141,13 @@ module RuboCop
139
141
 
140
142
  private
141
143
 
144
+ # `reject(&:archived?)` loads every row exactly like the literal
145
+ # block form — one character shorter, so it must not be a dodge.
146
+ def symbol_block_pass?(node)
147
+ last = node.last_argument
148
+ last&.block_pass_type? && last.children.first&.sym_type?
149
+ end
150
+
142
151
  def excluded_receiver?(receiver)
143
152
  return true if receiver.array_type? || receiver.hash_type?
144
153
  return true if receiver.const_type? && screaming_case_const?(receiver)
@@ -55,7 +55,10 @@ module RuboCop
55
55
  class NoDeliverLaterInTransaction < Base
56
56
  MSG = '`%<method>s` inside a `transaction` block may use stale data. Move it outside the transaction.'.freeze
57
57
 
58
- CORE_METHODS = %i[deliver_later perform_later].freeze
58
+ # Also the bang form, the bulk API, and the enqueue primitive that
59
+ # perform_later wraps — each is an equally easy spelling of the same
60
+ # premature enqueue.
61
+ CORE_METHODS = %i[deliver_later deliver_later! perform_later perform_all_later enqueue].freeze
59
62
 
60
63
  def on_send(node)
61
64
  return unless inside_transaction?(node)
@@ -74,9 +77,11 @@ module RuboCop
74
77
  cop_config.fetch('KnownAsyncWrappers', [])
75
78
  end
76
79
 
80
+ # with_lock opens a transaction too (lock! inside a transaction
81
+ # block), so an enqueue inside it has the identical hazard.
77
82
  def inside_transaction?(node)
78
83
  node.each_ancestor(:block).any? do |ancestor|
79
- ancestor.method_name == :transaction
84
+ %i[transaction with_lock].include?(ancestor.method_name)
80
85
  end
81
86
  end
82
87
  end
@@ -47,12 +47,16 @@ module RuboCop
47
47
  # OrderJob.perform_later(self)
48
48
  # end
49
49
  class NoPerformLaterInModel < Base
50
- MSG = 'Avoid `perform_later` in model files. Call it from the controller, ' \
51
- 'or use an explicit method name to signal the side effect.'.freeze
52
- RESTRICT_ON_SEND = %i[perform_later].freeze
50
+ MSG = 'Avoid `%<method>s` in model files it enqueues an ActiveJob (mailer deliveries ' \
51
+ 'included), with the same transaction/stale-data hazard as `perform_later`. Call it ' \
52
+ 'from the controller, or use an explicit method name to signal the side effect.'.freeze
53
+ # deliver_later is ActiveJob under the hood (MailDeliveryJob) and
54
+ # enqueue is the primitive perform_later wraps — omitting either
55
+ # would leave an everyday spelling of the same async enqueue.
56
+ RESTRICT_ON_SEND = %i[perform_later perform_all_later deliver_later deliver_later! enqueue].freeze
53
57
 
54
58
  def on_send(node)
55
- add_offense(node.loc.selector)
59
+ add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
56
60
  end
57
61
  end
58
62
  end
@@ -60,7 +60,9 @@ module RuboCop
60
60
  ].freeze
61
61
 
62
62
  def on_send(node)
63
- return unless node.receiver.nil?
63
+ # `self.head(...)` is the same controller call — a bare receiver
64
+ # check would make explicit-self a one-token dodge.
65
+ return unless node.receiver.nil? || node.receiver.self_type?
64
66
 
65
67
  status_node = node.arguments.first
66
68
  return unless status_node
@@ -62,7 +62,9 @@ module RuboCop
62
62
  class AvoidSend < Base
63
63
  MSG = "Avoid dynamic `%<method>s` — use bracket notation for model attributes, " \
64
64
  "or a prefix (`obj.send(\"export_\#{x}\")`) to restrict callable methods.".freeze
65
- RESTRICT_ON_SEND = %i[send public_send].freeze
65
+ # `__send__` is the canonical alias — omitting it would leave a
66
+ # zero-cost dodge for the exact dynamic dispatch this cop restricts.
67
+ RESTRICT_ON_SEND = %i[send public_send __send__].freeze
66
68
 
67
69
  def on_send(node)
68
70
  return if node.receiver.nil?
@@ -0,0 +1,103 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag `||` between literals in a boolean condition (`if`, `unless`,
6
+ # `while`, `until`, and ternary). The operator folds to one operand
7
+ # immediately (`:a || :b` is just `:a`), so the condition is constant —
8
+ # the branch (or loop body) always runs the same way, and the other
9
+ # literal is dead.
10
+ #
11
+ # ## Rationale
12
+ # `if :billing || :payments` reads like "either is truthy" but compiles
13
+ # to `if :billing` — always truthy. The author almost certainly meant a
14
+ # real comparison (`==`) or a variable, not two literal alternatives.
15
+ # Unlike `DevDoc/Style/LiteralOrInWhenClause`, a condition has no
16
+ # comma-form alternative, so the right fix depends on intent and is NOT
17
+ # autocorrected — the cop only flags.
18
+ #
19
+ # Scoped to `||` only: core `Lint/LiteralAsCondition` already catches
20
+ # literal `&&` chains and single literals, but misses `||` chains and
21
+ # ternaries — this cop fills exactly that gap with no overlap.
22
+ #
23
+ # ❌ always truthy — :payments is dead
24
+ # if :billing || :payments
25
+ # charge
26
+ # end
27
+ #
28
+ # ✔️ a real comparison
29
+ # if status == :billing || status == :payments
30
+ # charge
31
+ # end
32
+ #
33
+ # @example
34
+ # # bad
35
+ # x = (:a || :b) ? one : two
36
+ #
37
+ # # good
38
+ # x = (val == :a || val == :b) ? one : two
39
+ class LiteralOperatorInCondition < Base
40
+ MSG = '`%<source>s` always evaluates to `%<result>s`, so this condition is constant.'.freeze
41
+
42
+ LITERAL_TYPES = %i[sym str int float true false nil].freeze
43
+ FALSY_TYPES = %i[false nil].freeze
44
+
45
+ # `unless` desugars to `if` and the ternary `? :` is an `if` node, so a
46
+ # single on_if covers both (plus modifier forms). `until` mirrors
47
+ # `while` — both expose `condition`.
48
+ def on_if(node)
49
+ check(node.condition)
50
+ end
51
+
52
+ def on_while(node)
53
+ check(node.condition)
54
+ end
55
+
56
+ alias on_until on_while
57
+
58
+ private
59
+
60
+ def check(condition)
61
+ inner = unwrap_begin(condition)
62
+ return unless inner.or_type?
63
+
64
+ leaves = flatten_chain(inner)
65
+ return unless leaves.all? { |leaf| LITERAL_TYPES.include?(leaf.type) }
66
+
67
+ add_offense(
68
+ inner,
69
+ message: format(MSG, source: inner.source, result: fold(leaves).source)
70
+ )
71
+ end
72
+
73
+ # The helpers below parallel DevDoc/Style/LiteralOrInWhenClause
74
+ # (`||`-only here). Lift them into a shared module if a third consumer
75
+ # appears; duplicated for now to keep that cop untouched.
76
+
77
+ # Parentheses wrap a condition in a `begin` node; unwrap it.
78
+ def unwrap_begin(node)
79
+ node = node.children.first while node.begin_type?
80
+ node
81
+ end
82
+
83
+ # Flatten a homogeneous `||` chain into its operand nodes. An operand
84
+ # wrapped in parentheses arrives as a `begin` node (`(:a || :b) || :c`),
85
+ # so unwrap before recursing; otherwise the inner chain reads as a
86
+ # single non-literal leaf and slips past the all-literal check.
87
+ def flatten_chain(node)
88
+ node = unwrap_begin(node)
89
+ return [node] unless node.or_type?
90
+
91
+ flatten_chain(node.lhs) + flatten_chain(node.rhs)
92
+ end
93
+
94
+ # The first truthy literal the `||` chain folds to (or its last
95
+ # operand when the whole chain is nil/false).
96
+ def fold(leaves)
97
+ leaves.find { |leaf| !FALSY_TYPES.include?(leaf.type) } || leaves.last
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end