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.
- checksums.yaml +4 -4
- data/config/default.yml +182 -4
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
- data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +62 -12
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb +159 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +87 -0
- data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
- data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +10 -8
- data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
- data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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 `
|
|
51
|
-
'
|
|
52
|
-
|
|
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
|
|
77
|
-
return unless
|
|
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
|
-
|
|
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
|
|
93
|
-
return unless
|
|
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
|
-
|
|
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
|
-
|
|
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
|