rubocop-dev_doc 0.7.0 → 0.9.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 +182 -4
  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_bypassing_validation.rb +69 -1
  8. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
  9. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
  10. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
  11. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
  12. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
  13. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
  14. data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
  15. data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +62 -12
  16. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
  17. data/lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb +159 -0
  18. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
  19. data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +87 -0
  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 +10 -8
  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 +13 -2
@@ -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
@@ -38,6 +38,31 @@ module RuboCop
38
38
  # Scalar `params[:foo]` in any context — leave that to per-project
39
39
  # decision or the upstream cop.
40
40
  #
41
+ # ## Autocorrect is UNSAFE (SafeAutoCorrect: false)
42
+ # `expect` is not a drop-in for require+permit; both deltas below were
43
+ # verified against a production suite (81 test failures before manual
44
+ # fix-ups):
45
+ #
46
+ # 1. **Empty-slice 400**: `expect` raises ParameterMissing when the key
47
+ # is present but every value filters out — `permit` returned an
48
+ # empty slice and proceeded. Bites "slice-style" param methods that
49
+ # probe for whichever keys arrived (e.g. a publishing_params called
50
+ # unconditionally in update). Keep those tolerant with
51
+ # `params.fetch(:key, {}).permit(...)` + an inline reason.
52
+ # 2. **Silent collection loss**: `expect` filters with explicit_arrays,
53
+ # so collection-valued nested attributes (numeric-keyed fields_for /
54
+ # dynamic-group hashes, `{"0"=>{...}}`) require the double-array
55
+ # `[[...]]` declaration. The flat `[...]` this autocorrect emits
56
+ # SILENTLY filters such collections to empty — no error, no data.
57
+ # After correcting, audit every *_attributes key: has_many nests
58
+ # need `[[...]]`, has_one nests stay flat.
59
+ #
60
+ # The corrector also deliberately skips permit calls whose arguments
61
+ # are computed (e.g. `permit([...static...] + extra)`): copying the
62
+ # expression into `expect(key: [<expr>])` would produce a one-element
63
+ # outer array — which IS expect's array-of-hashes syntax, silently
64
+ # changing semantics. Those sites are flagged without a correction.
65
+ #
41
66
  # @example
42
67
  # # bad
43
68
  # params.require(:user).permit(:name, :email)
@@ -57,9 +82,9 @@ module RuboCop
57
82
  extend AutoCorrector
58
83
 
59
84
  MSG_REQUIRE_PERMIT = 'Use `params.expect(%<key>s: [...])` instead of ' \
60
- '`params.require(:%<key>s).permit(...)`.'
85
+ '`params.require(:%<key>s).permit(...)`.'.freeze
61
86
  MSG_PERMIT_REQUIRE = 'Use `params.expect(%<key>s: ...)` instead of ' \
62
- '`params.permit(%<key>s: ...).require(:%<key>s)`.'
87
+ '`params.permit(%<key>s: ...).require(:%<key>s)`.'.freeze
63
88
 
64
89
  RESTRICT_ON_SEND = %i[permit require].freeze
65
90
 
@@ -73,25 +98,33 @@ module RuboCop
73
98
  # Match: params.require(:foo).permit(...)
74
99
  def check_require_permit(permit_node)
75
100
  require_node = permit_node.receiver
76
- return unless require_node&.send_type? && require_node.method_name == :require
77
- return unless params_receiver?(require_node.receiver)
78
- return unless require_node.arguments.one? && require_node.first_argument.sym_type?
101
+ return unless chained_call?(require_node, :require) && params_receiver?(require_node.receiver)
102
+ return unless single_sym_arg?(require_node)
79
103
 
80
104
  key = require_node.first_argument.value
81
105
  add_offense(permit_node, message: format(MSG_REQUIRE_PERMIT, key: key)) do |corrector|
82
- replacement = build_require_permit_replacement(
83
- require_node.receiver, key, permit_node.arguments
84
- )
85
- corrector.replace(permit_node, replacement)
106
+ autocorrect_require_permit(corrector, permit_node, require_node, key)
86
107
  end
87
108
  end
88
109
 
110
+ # Computed args (`permit([...] + extra)`, `permit(some_method)`) must
111
+ # not be source-copied into the expect array: a single computed-array
112
+ # element becomes `expect(key: [expr])` — expect's array-of-hashes
113
+ # syntax — silently changing semantics. Flag without correcting.
114
+ def autocorrect_require_permit(corrector, permit_node, require_node, key)
115
+ return unless literal_permit_args?(permit_node)
116
+
117
+ corrector.replace(
118
+ permit_node,
119
+ build_require_permit_replacement(require_node.receiver, key, permit_node.arguments)
120
+ )
121
+ end
122
+
89
123
  # Match: params.permit(foo: ...).require(:foo)
90
124
  def check_permit_require(require_node)
91
125
  permit_node = require_node.receiver
92
- return unless permit_node&.send_type? && permit_node.method_name == :permit
93
- return unless params_receiver?(permit_node.receiver)
94
- return unless require_node.arguments.one? && require_node.first_argument.sym_type?
126
+ return unless chained_call?(permit_node, :permit) && params_receiver?(permit_node.receiver)
127
+ return unless single_sym_arg?(require_node)
95
128
 
96
129
  key = require_node.first_argument.value
97
130
  pair = permit_hash_pair_for_key(permit_node, key)
@@ -103,6 +136,23 @@ module RuboCop
103
136
  end
104
137
  end
105
138
 
139
+ def chained_call?(node, method_name)
140
+ node&.send_type? && node.method_name == method_name
141
+ end
142
+
143
+ def single_sym_arg?(node)
144
+ node.arguments.one? && node.first_argument.sym_type?
145
+ end
146
+
147
+ # Source-copying is only safe for literal permit arguments; any
148
+ # computed expression (method call, `+`, splat, const) changes
149
+ # meaning inside `expect`'s array literal.
150
+ def literal_permit_args?(permit_node)
151
+ permit_node.arguments.all? do |arg|
152
+ arg.sym_type? || arg.str_type? || arg.hash_type? || arg.array_type?
153
+ end
154
+ end
155
+
106
156
  def params_receiver?(node)
107
157
  node&.send_type? && node.method_name == :params && node.receiver.nil?
108
158
  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
@@ -0,0 +1,159 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Avoid insecure dynamic `send` and `public_send` with an explicit receiver.
6
+ #
7
+ # ## Rationale
8
+ # `send()` can call *any* method, including destructive ones like
9
+ # `destroy`. The risk is specifically with **dynamic** method names —
10
+ # when the argument is a variable or interpolated string, a crafted
11
+ # value could invoke methods the developer never intended to expose.
12
+ #
13
+ # `public_send` respects method visibility, but still allows calling
14
+ # any public method. When the method name is dynamic, a prefix restricts
15
+ # the callable surface to methods sharing that prefix.
16
+ #
17
+ # ## Rules
18
+ #
19
+ # **`send()` with a dynamic argument is always flagged.** There is no
20
+ # safe use case for dynamic `send` — use `public_send` instead, and
21
+ # disable with a justification when bypassing visibility is intentional.
22
+ #
23
+ # **`public_send()` with a dynamic argument is allowed when:**
24
+ # - The argument is a **literal symbol or string** (method name is fixed at code-write time).
25
+ # - The call uses a **prefix pattern** (`"prefix_#{method_name}"`) that restricts callable methods.
26
+ # - The call is in a **validator** file (`app/validators/`) — method names come from model DSL, not user input.
27
+ # - The call is a **mailer dispatch** — method names are trusted internal symbols.
28
+ #
29
+ # **`public_send()` with a dynamic argument is flagged when:**
30
+ # - The argument is an unrestricted variable or interpolation with no static prefix.
31
+ #
32
+ # ## Safer alternatives
33
+ #
34
+ # **a) For model attributes — use bracket notation instead.**
35
+ # `@model[column_name]` only accesses database columns, so it cannot
36
+ # accidentally invoke methods like `destroy`.
37
+ #
38
+ # ❌ Dangerous — method_name could be :destroy or any other method
39
+ # @user.send(method_name)
40
+ #
41
+ # ✔️ Safe — only accesses database columns
42
+ # @user[method_name]
43
+ #
44
+ # **b) For non-model objects — use a prefix to restrict callable methods.**
45
+ # By interpolating the dynamic part into a fixed prefix, only methods
46
+ # with that prefix (e.g. `export_csv`, `export_pdf`) can be invoked,
47
+ # preventing accidental calls to unintended methods.
48
+ #
49
+ # ❌ Unrestricted — any method can be called
50
+ # obj.public_send(method_name)
51
+ #
52
+ # ✔️ Restricted — only methods with the prefix can be called
53
+ # obj.public_send("export_#{method_name}")
54
+ #
55
+ # NOTE: A prefix narrows the callable surface but does not eliminate it —
56
+ # an attacker-controlled suffix can still reach any method sharing the
57
+ # prefix (e.g. `"export_#{x}"` could hit `export_and_destroy`). Use the
58
+ # narrowest prefix that fits, and prefer an explicit whitelist when the
59
+ # set of targets is small.
60
+ #
61
+ # @example
62
+ # # bad — dynamic method name from a variable
63
+ # @user.send(method_name)
64
+ # obj.public_send(action)
65
+ #
66
+ # # bad — interpolation with no static prefix restricts nothing
67
+ # obj.send("#{x}")
68
+ # obj.send("#{x}_run")
69
+ #
70
+ # # good — literal symbol: method name is statically visible
71
+ # instance.public_send(:email)
72
+ #
73
+ # # good — bracket notation for model attributes
74
+ # @user[attribute_name]
75
+ #
76
+ # # good — static prefix restricts the callable methods
77
+ # obj.public_send("export_#{method_name}")
78
+ #
79
+ # # good — validator pattern (trusted source)
80
+ # record.public_send(attribute)
81
+ #
82
+ # # good — mailer dispatch (trusted source)
83
+ # mailer.public_send(action)
84
+ class AvoidInsecureSend < Base
85
+ MSG_SEND = "Avoid dynamic `send` — use `public_send` instead, or " \
86
+ "bracket notation for model attributes. Disable with a " \
87
+ "justification when bypassing visibility is intentional.".freeze
88
+
89
+ MSG_PUBLIC_SEND = "Avoid unrestricted dynamic `public_send` — use a " \
90
+ "prefix (`obj.public_send(\"export_\#{x}\")`) to " \
91
+ "restrict callable methods, or bracket notation for " \
92
+ "model attributes.".freeze
93
+
94
+ # `__send__` is the canonical alias — omitting it would leave a
95
+ # zero-cost dodge for the exact dynamic dispatch this cop restricts.
96
+ RESTRICT_ON_SEND = %i[send public_send __send__].freeze
97
+
98
+ def on_send(node)
99
+ return if node.receiver.nil?
100
+ return if literal_argument?(node)
101
+ return if prefixed_dynamic_method?(node)
102
+
103
+ check_send(node)
104
+ end
105
+
106
+ private
107
+
108
+ def literal_argument?(node)
109
+ node.first_argument&.sym_type?
110
+ end
111
+
112
+ def prefixed_dynamic_method?(node)
113
+ arg = node.first_argument
114
+ return false unless arg&.type?(:dstr, :dsym)
115
+
116
+ first = arg.children.first
117
+ first&.str_type? && !first.value.empty?
118
+ end
119
+
120
+ def check_send(node)
121
+ if node.method?(:send) || node.method?(:__send__)
122
+ add_offense(node.loc.selector, message: MSG_SEND)
123
+ else
124
+ check_public_send(node)
125
+ end
126
+ end
127
+
128
+ def check_public_send(node)
129
+ return if validator_file?
130
+ return if mailer_dispatch?(node)
131
+
132
+ add_offense(node.loc.selector, message: MSG_PUBLIC_SEND)
133
+ end
134
+
135
+ # Validator files are exempt — method names come from model DSL
136
+ # (developer-controlled), not user input.
137
+ def validator_file?
138
+ processed_source.file_path.to_s.include?('app/validators/')
139
+ end
140
+
141
+ # Mailer dispatch is exempt — method names are trusted internal symbols
142
+ # (constructor args, method params), not user input.
143
+ #
144
+ # Heuristic: the receiver is a mailer class or a parameterized mailer,
145
+ # and the method is a mailer action (not a private helper).
146
+ def mailer_dispatch?(node)
147
+ receiver = node.receiver
148
+ return false unless receiver
149
+
150
+ receiver_source = receiver.source
151
+ receiver_source.match?(/Mailer\b/) ||
152
+ receiver_source.match?(/\.with\(/) ||
153
+ receiver_source.match?(/parameterized_mailer/)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -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,87 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Every `case` must carry an `else` that DECIDES what a non-matching
6
+ # value means — this cop supersedes `Style/MissingElse`
7
+ # (EnforcedStyle: case), whose "Missing `else` statement" message
8
+ # teaches the wrong reflex: add an else, any else.
9
+ #
10
+ # ## Rationale
11
+ # A `case` without `else` makes "exhaustive dispatch" and "deliberate
12
+ # partial match" indistinguishable — an unanticipated value silently
13
+ # evaluates to nil. The fix is not a bare `else`; it is a decision.
14
+ # Three legitimate outcomes:
15
+ #
16
+ # 1. **Fall-through genuinely happens in normal operation** — add
17
+ # `else` with a comment stating WHY it is correct (requires
18
+ # `Style/EmptyElse` to be disabled).
19
+ # 2. **A non-match is a bug and continuing is unsafe** — `else raise`
20
+ # (fail fast; unexpected enum/mode values must not proceed).
21
+ # 3. **A non-match is a bug but users can safely continue** — report
22
+ # to the error tracker, then degrade gracefully (return the neutral
23
+ # value). Prefer this over raising in user-facing render paths.
24
+ #
25
+ # Never pick 1 by reflex: if you cannot write down why the else
26
+ # legitimately happens, it belongs in bucket 2 or 3. Also weigh WHERE
27
+ # the case runs: raising on user-supplied input that executes before
28
+ # authorization turns garbage requests into 500s, and error-tracker
29
+ # reports on pre-auth, scanner-reachable paths are noise, not signal.
30
+ #
31
+ # ## Interaction with Style/MissingElse
32
+ # Same detection surface (case statements and `case/in` pattern
33
+ # matches; `if` is exempt) — running both double-flags every offense.
34
+ # Disable `Style/MissingElse` when enabling this cop:
35
+ #
36
+ # Style/MissingElse:
37
+ # Enabled: false
38
+ #
39
+ # @example
40
+ # # bad — a value nobody anticipated silently becomes nil
41
+ # case status
42
+ # when :active then process
43
+ # when :archived then skip
44
+ # end
45
+ #
46
+ # # good — closed set: fail fast
47
+ # case status
48
+ # when :active then process
49
+ # when :archived then skip
50
+ # else
51
+ # raise "Unexpected status: #{status}"
52
+ # end
53
+ #
54
+ # # good — bug, but users can continue: report and degrade
55
+ # case status
56
+ # when :active then process
57
+ # when :archived then skip
58
+ # else
59
+ # ErrorTracker.error("Unexpected status: #{status}")
60
+ # nil
61
+ # end
62
+ #
63
+ # # good — legitimate fall-through, reason stated
64
+ # case action_name.to_sym
65
+ # when :new, :create then load_import
66
+ # else
67
+ # # No resource setup needed for the remaining actions.
68
+ # end
69
+ class CaseElseDecision < Base
70
+ MSG = '`case` has no `else` — decide what a non-match means: ' \
71
+ '`raise` if continuing is unsafe (closed set); report to the ' \
72
+ 'error tracker and degrade gracefully if users can continue; ' \
73
+ 'or `else` + a comment stating why fall-through is ' \
74
+ 'legitimate. Never add a bare `else` by reflex.'.freeze
75
+
76
+ def on_case(node)
77
+ add_offense(node.loc.keyword) unless node.else?
78
+ end
79
+
80
+ def on_case_match(node)
81
+ add_offense(node.loc.keyword) unless node.else?
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -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