rubocop-dev_doc 0.1.0 → 0.3.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 +318 -33
- data/lib/dev_doc/test/best_practice_lints.rb +31 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
- data/lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb +92 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +86 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +68 -13
- data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb +18 -3
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
- data/lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb +53 -0
- data/lib/rubocop/cop/dev_doc/migration/require_primary_key.rb +55 -0
- data/lib/rubocop/cop/dev_doc/migration/require_timestamps.rb +4 -13
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +56 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +135 -0
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +83 -0
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
- data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +29 -15
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +56 -22
- data/lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb +102 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +42 -10
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
- data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- data/lib/rubocop-dev_doc.rb +1 -0
- metadata +73 -10
- data/lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb +0 -53
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Avoid `**options`-style kwargs in method signatures; use explicit keyword args.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Keyword args raise `ArgumentError` on typos, are self-labeled at the
|
|
9
|
+
# call site, and get IDE autocomplete. Options hashes (via `**opts`)
|
|
10
|
+
# silently swallow misspelled keys, hide what's accepted, and depend on
|
|
11
|
+
# doc/source-reading to use correctly.
|
|
12
|
+
#
|
|
13
|
+
# ❌ Options hash — typos pass silently
|
|
14
|
+
# def configure(name:, **options)
|
|
15
|
+
# title = options[:title]
|
|
16
|
+
# color = options[:color]
|
|
17
|
+
# end
|
|
18
|
+
# configure(name: 'x', titel: 'wrong') # silently ignored — title is nil
|
|
19
|
+
#
|
|
20
|
+
# ✔️ Keyword args — typo raises immediately
|
|
21
|
+
# def configure(name:, title: nil, color: nil)
|
|
22
|
+
# end
|
|
23
|
+
# configure(name: 'x', titel: 'wrong') # ArgumentError: unknown keyword: :titel
|
|
24
|
+
#
|
|
25
|
+
# Pure-forwarding kwargs are exempt — when the only use of the kwrestarg
|
|
26
|
+
# is to splat it into another call, there is no options-hash behaviour:
|
|
27
|
+
#
|
|
28
|
+
# ✔️ Pure forwarding — exempt
|
|
29
|
+
# def foo(**args)
|
|
30
|
+
# other(**args)
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# Anonymous double-splat (`**`) is also always exempt.
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# # bad
|
|
37
|
+
# def configure(name:, **options)
|
|
38
|
+
# options[:title]
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# # good
|
|
42
|
+
# def configure(name:, title: nil, color: nil)
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# # good (pure forwarding)
|
|
46
|
+
# def foo(**args)
|
|
47
|
+
# other(**args)
|
|
48
|
+
# end
|
|
49
|
+
class AvoidOptionsHash < Base
|
|
50
|
+
MSG = 'Use keyword arguments instead of `**%<name>s` — ' \
|
|
51
|
+
'typos in keyword args raise `ArgumentError`; options hashes swallow them silently.'.freeze
|
|
52
|
+
|
|
53
|
+
def on_def(node)
|
|
54
|
+
check_method(node)
|
|
55
|
+
end
|
|
56
|
+
alias on_defs on_def
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def check_method(node)
|
|
61
|
+
kwrestarg = find_kwrestarg(node)
|
|
62
|
+
return unless kwrestarg
|
|
63
|
+
|
|
64
|
+
kwrest_name = kwrestarg.node_parts[0]
|
|
65
|
+
return if kwrest_name.nil?
|
|
66
|
+
|
|
67
|
+
body = node.body
|
|
68
|
+
return if pure_forwarding?(body, kwrest_name)
|
|
69
|
+
|
|
70
|
+
add_offense(kwrestarg, message: format(MSG, name: kwrest_name))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def find_kwrestarg(node)
|
|
74
|
+
args_node = node.arguments
|
|
75
|
+
args_node.each_child_node(:kwrestarg).first
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pure_forwarding?(body, kwrest_name)
|
|
79
|
+
return true if body.nil?
|
|
80
|
+
|
|
81
|
+
lvar_refs = collect_lvar_refs(body, kwrest_name)
|
|
82
|
+
return true if lvar_refs.empty?
|
|
83
|
+
|
|
84
|
+
lvar_refs.all? { |lvar| under_kwsplat?(lvar) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def collect_lvar_refs(node, name)
|
|
88
|
+
refs = []
|
|
89
|
+
node.each_descendant(:lvar) do |lvar|
|
|
90
|
+
refs << lvar if lvar.node_parts[0] == name
|
|
91
|
+
end
|
|
92
|
+
refs
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def under_kwsplat?(node)
|
|
96
|
+
node.parent&.kwsplat_type?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -2,14 +2,16 @@ module RuboCop
|
|
|
2
2
|
module Cop
|
|
3
3
|
module DevDoc
|
|
4
4
|
module Style
|
|
5
|
-
# Avoid `send` and `public_send` with an explicit receiver.
|
|
5
|
+
# Avoid dynamic `send` and `public_send` with an explicit receiver.
|
|
6
6
|
#
|
|
7
7
|
# ## Rationale
|
|
8
8
|
# `send()` can call *any* method, including destructive ones like
|
|
9
|
-
# `destroy`.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
+
# **Literal symbol arguments are exempt** — the method name is fixed at
|
|
14
|
+
# code-write time and visible to reviewers, equivalent to a direct call.
|
|
13
15
|
#
|
|
14
16
|
# ## Safer alternatives
|
|
15
17
|
#
|
|
@@ -34,26 +36,56 @@ module RuboCop
|
|
|
34
36
|
# ✔️ Restricted — only methods with the prefix can be called
|
|
35
37
|
# obj.send("export_#{method_name}")
|
|
36
38
|
#
|
|
37
|
-
#
|
|
39
|
+
# NOTE: A prefix narrows the callable surface but does not eliminate it —
|
|
40
|
+
# an attacker-controlled suffix can still reach any method sharing the
|
|
41
|
+
# prefix (e.g. `"export_#{x}"` could hit `export_and_destroy`). Use the
|
|
42
|
+
# narrowest prefix that fits, and prefer an explicit whitelist when the
|
|
43
|
+
# set of targets is small.
|
|
38
44
|
#
|
|
39
45
|
# @example
|
|
40
|
-
# # bad
|
|
46
|
+
# # bad — dynamic method name from a variable
|
|
41
47
|
# @user.send(method_name)
|
|
42
48
|
# obj.public_send(action)
|
|
43
49
|
#
|
|
44
|
-
# #
|
|
50
|
+
# # bad — interpolation with no static prefix restricts nothing
|
|
51
|
+
# obj.send("#{x}")
|
|
52
|
+
# obj.send("#{x}_run")
|
|
53
|
+
#
|
|
54
|
+
# # good — literal symbol: method name is statically visible
|
|
55
|
+
# instance.send(:private_helper, arg)
|
|
56
|
+
#
|
|
57
|
+
# # good — bracket notation for model attributes
|
|
45
58
|
# @user[attribute_name]
|
|
59
|
+
#
|
|
60
|
+
# # good — static prefix restricts the callable methods
|
|
46
61
|
# obj.send("export_#{method_name}")
|
|
47
62
|
class AvoidSend < Base
|
|
48
|
-
MSG =
|
|
49
|
-
|
|
63
|
+
MSG = "Avoid dynamic `%<method>s` — use bracket notation for model attributes, " \
|
|
64
|
+
"or a prefix (`obj.send(\"export_\#{x}\")`) to restrict callable methods.".freeze
|
|
50
65
|
RESTRICT_ON_SEND = %i[send public_send].freeze
|
|
51
66
|
|
|
52
67
|
def on_send(node)
|
|
53
68
|
return if node.receiver.nil?
|
|
54
69
|
|
|
70
|
+
arg = node.first_argument
|
|
71
|
+
return if arg&.sym_type?
|
|
72
|
+
return if prefixed_dynamic_method?(arg)
|
|
73
|
+
|
|
55
74
|
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
56
75
|
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# A dynamic string/symbol that begins with a static prefix (e.g.
|
|
80
|
+
# `"export_#{x}"`) restricts the callable surface to methods sharing
|
|
81
|
+
# that prefix, so it is exempt. Pure interpolation (`"#{x}"`) or a
|
|
82
|
+
# trailing prefix (`"#{x}_run"`) restricts nothing and is still flagged.
|
|
83
|
+
def prefixed_dynamic_method?(arg)
|
|
84
|
+
return false unless arg&.type?(:dstr, :dsym)
|
|
85
|
+
|
|
86
|
+
first = arg.children.first
|
|
87
|
+
first&.str_type? && !first.value.empty?
|
|
88
|
+
end
|
|
57
89
|
end
|
|
58
90
|
end
|
|
59
91
|
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Assign a variable inside the `if` condition that guards it, so the
|
|
6
|
+
# variable's scope is the branch that actually uses it.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# When a local is assigned and then immediately gated by a truthiness
|
|
10
|
+
# check, hoisting the assignment to its own line widens its scope to the
|
|
11
|
+
# whole method and separates the binding from the guard. Folding the
|
|
12
|
+
# assignment into the condition (with parentheses) keeps the variable
|
|
13
|
+
# local to the branch that uses it and reads as one thought.
|
|
14
|
+
#
|
|
15
|
+
# ❌
|
|
16
|
+
# token = params[:token]
|
|
17
|
+
# if token
|
|
18
|
+
# authenticate(token)
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# ✔️
|
|
22
|
+
# if (token = params[:token])
|
|
23
|
+
# authenticate(token)
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# Parentheses around the assignment silence Ruby's "assignment in
|
|
27
|
+
# condition" warning and signal the assignment is intentional.
|
|
28
|
+
#
|
|
29
|
+
# ## When it fires
|
|
30
|
+
# Only when the assigned variable is used **only** inside the guarding
|
|
31
|
+
# `if` — its condition plus the true branch — and is read at least once
|
|
32
|
+
# in that branch. If the variable is read in the `else` branch or after
|
|
33
|
+
# the block, folding wouldn't narrow its scope, so the cop leaves it.
|
|
34
|
+
#
|
|
35
|
+
# ## Exception
|
|
36
|
+
# When the assigned expression is long, inlining it into the condition
|
|
37
|
+
# hurts readability more than the scope-narrowing helps. Keep the
|
|
38
|
+
# two-line form and inline-`disable` with a reason.
|
|
39
|
+
#
|
|
40
|
+
# NOTE: Conservative by design — it skips reassigned variables, `op_asgn`
|
|
41
|
+
# (`+=`, `||=`), compound/comparison conditions, and scopes containing a
|
|
42
|
+
# nested `def` or a block that rebinds the same name. Those are left
|
|
43
|
+
# un-flagged rather than risk a wrong rewrite.
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# # bad
|
|
47
|
+
# user = account.users.find_by(id: params[:id])
|
|
48
|
+
# if user
|
|
49
|
+
# redirect_to user
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# # good
|
|
53
|
+
# if (user = account.users.find_by(id: params[:id]))
|
|
54
|
+
# redirect_to user
|
|
55
|
+
# end
|
|
56
|
+
class MinimizeVariableScope < Base
|
|
57
|
+
MSG = "Assign `%<name>s` inside the `if` condition " \
|
|
58
|
+
"(`if (%<name>s = ...)`) so its scope is the branch that uses it.".freeze
|
|
59
|
+
|
|
60
|
+
# Truthiness-shaped predicates we fold. Comparisons (`x == 1`) are
|
|
61
|
+
# deliberately excluded — folding those reads as assignment-in-condition.
|
|
62
|
+
TRUTHY_PREDICATES = %i[present? any? presence].freeze
|
|
63
|
+
|
|
64
|
+
def on_lvasgn(node)
|
|
65
|
+
name, value = *node
|
|
66
|
+
return unless value
|
|
67
|
+
|
|
68
|
+
if_node = guarding_if(node)
|
|
69
|
+
return unless if_node
|
|
70
|
+
return unless truthiness_check?(if_node.condition, name)
|
|
71
|
+
return unless confined_to_if?(node, if_node, name)
|
|
72
|
+
|
|
73
|
+
add_offense(node.loc.name, message: format(MSG, name: name))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# The `if` immediately following this assignment as the next statement
|
|
79
|
+
# in the same body (not a ternary or modifier-if).
|
|
80
|
+
def guarding_if(asgn)
|
|
81
|
+
parent = asgn.parent
|
|
82
|
+
return unless parent&.begin_type?
|
|
83
|
+
|
|
84
|
+
siblings = parent.children
|
|
85
|
+
nxt = siblings[siblings.index(asgn) + 1]
|
|
86
|
+
nxt if nxt&.if_type? && !nxt.ternary? && !nxt.modifier_form? && nxt.if?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `if x` or `if x.present?` — a truthiness check on the assigned var.
|
|
90
|
+
def truthiness_check?(condition, name)
|
|
91
|
+
return false unless condition
|
|
92
|
+
|
|
93
|
+
if condition.lvar_type?
|
|
94
|
+
condition.children.first == name
|
|
95
|
+
elsif condition.send_type? && TRUTHY_PREDICATES.include?(condition.method_name)
|
|
96
|
+
recv = condition.receiver
|
|
97
|
+
recv&.lvar_type? && recv.children.first == name
|
|
98
|
+
else
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Every read of `name` in the enclosing scope sits inside the if's
|
|
104
|
+
# condition or true branch, it's read at least once in that branch,
|
|
105
|
+
# and nothing reassigns or rebinds the name.
|
|
106
|
+
def confined_to_if?(asgn, if_node, name)
|
|
107
|
+
scope = enclosing_scope(asgn)
|
|
108
|
+
return false unless scope
|
|
109
|
+
return false unless single_plain_assignment?(scope, name, asgn)
|
|
110
|
+
return false if rebinds_name?(scope, name)
|
|
111
|
+
|
|
112
|
+
true_branch = if_node.if_branch
|
|
113
|
+
return false unless true_branch
|
|
114
|
+
|
|
115
|
+
refs = reads(scope, name)
|
|
116
|
+
in_branch = refs.select { |r| within?(r, true_branch) }
|
|
117
|
+
return false if in_branch.empty?
|
|
118
|
+
|
|
119
|
+
refs.all? { |r| within?(r, if_node.condition) || within?(r, true_branch) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def enclosing_scope(node)
|
|
123
|
+
node.each_ancestor(:def, :defs, :block, :numblock, :itblock).first
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def reads(scope, name)
|
|
127
|
+
scope.each_descendant(:lvar).select { |n| n.children.first == name }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Exactly one `name = ...` (our node) and no `op_asgn`/`or_asgn`/
|
|
131
|
+
# `and_asgn` touching it.
|
|
132
|
+
def single_plain_assignment?(scope, name, asgn)
|
|
133
|
+
lvasgns = scope.each_descendant(:lvasgn).select { |n| n.children.first == name }
|
|
134
|
+
return false unless lvasgns.size == 1 && lvasgns.first.equal?(asgn)
|
|
135
|
+
|
|
136
|
+
scope.each_descendant(:op_asgn, :or_asgn, :and_asgn).none? do |n|
|
|
137
|
+
target = n.children.first
|
|
138
|
+
target.respond_to?(:children) && target.children.first == name
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A nested `def`/`defs` (separate scope) or a block re-binding `name`
|
|
143
|
+
# would make the read analysis unreliable — bail.
|
|
144
|
+
def rebinds_name?(scope, name)
|
|
145
|
+
scope.each_descendant(:def, :defs).any? ||
|
|
146
|
+
scope.each_descendant(:block).any? do |blk|
|
|
147
|
+
blk.arguments.any? { |a| a.respond_to?(:name) && a.name == name }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def within?(node, container)
|
|
152
|
+
node.equal?(container) || node.each_ancestor.any? { |a| a.equal?(container) }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Flag `def` / `define_method` whose enclosing scope chain does not
|
|
6
|
+
# include a `class` or `module` body — the method lands on `Object`.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# A `def` not inside an explicit `class` or `module` body defines a
|
|
10
|
+
# method on `Object`, even when it visually looks scoped. The most common
|
|
11
|
+
# failure mode is inside Rake's `namespace` block:
|
|
12
|
+
#
|
|
13
|
+
# ❌ Two rake files both define `build_load_plan` — whichever file
|
|
14
|
+
# loads second silently wins. Tests for one task call the other
|
|
15
|
+
# task's helper logic without warning.
|
|
16
|
+
# namespace :faqs do
|
|
17
|
+
# def build_load_plan(fixtures, by_slug)
|
|
18
|
+
# ...
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# ✔️ Wrapped in a real Ruby scope — no collision risk.
|
|
23
|
+
# module FaqsLoader
|
|
24
|
+
# extend self
|
|
25
|
+
#
|
|
26
|
+
# def build_load_plan(fixtures, by_slug)
|
|
27
|
+
# ...
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# Core's `Style/TopLevelMethodDefinition` catches literal top-level
|
|
32
|
+
# `def` (not inside any block) but misses the rake `namespace` pattern
|
|
33
|
+
# because the `def` is technically inside a `block` node. This cop
|
|
34
|
+
# subsumes that case — disable `Style/TopLevelMethodDefinition` when
|
|
35
|
+
# this cop is enabled to avoid double-flagging.
|
|
36
|
+
#
|
|
37
|
+
# ## Allowlist
|
|
38
|
+
# Some DSLs legitimately define methods inside blocks where the block's
|
|
39
|
+
# receiver is not `Object` (`Struct.new`, `Class.new`, `Module.new`).
|
|
40
|
+
# Configure `SafeDSLReceivers` to extend the allowlist.
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# # bad — literal top-level def
|
|
44
|
+
# def helper_method
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# # bad — inside a rake namespace (lands on Object)
|
|
48
|
+
# namespace :faqs do
|
|
49
|
+
# def build_load_plan(fixtures, by_slug)
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# # good — inside an explicit module
|
|
54
|
+
# module FaqsLoader
|
|
55
|
+
# extend self
|
|
56
|
+
#
|
|
57
|
+
# def build_load_plan(fixtures, by_slug)
|
|
58
|
+
# end
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# # good — inside an explicit class
|
|
62
|
+
# class FaqsImporter
|
|
63
|
+
# def import
|
|
64
|
+
# end
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# # good — Struct.new block (allowlisted by default)
|
|
68
|
+
# Point = Struct.new(:x, :y) do
|
|
69
|
+
# def distance
|
|
70
|
+
# end
|
|
71
|
+
# end
|
|
72
|
+
class NoUnscopedMethodDefinitions < Base
|
|
73
|
+
MSG = 'Define methods inside an explicit `module` or `class`, not at the top level ' \
|
|
74
|
+
'or inside a DSL block (e.g. Rake `namespace`). ' \
|
|
75
|
+
'Methods defined here land on `Object` and can silently collide across files.'.freeze
|
|
76
|
+
|
|
77
|
+
DEFAULT_SAFE_DSL_RECEIVERS = %w[Struct Class Module].freeze
|
|
78
|
+
|
|
79
|
+
def on_def(node)
|
|
80
|
+
add_offense(node.loc.keyword) unless enclosed_in_class_or_module?(node)
|
|
81
|
+
end
|
|
82
|
+
alias on_defs on_def
|
|
83
|
+
|
|
84
|
+
def on_send(node)
|
|
85
|
+
return unless node.method_name == :define_method
|
|
86
|
+
return if enclosed_in_class_or_module?(node)
|
|
87
|
+
|
|
88
|
+
add_offense(node.loc.selector)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def enclosed_in_class_or_module?(node)
|
|
94
|
+
node.each_ancestor.any? do |ancestor|
|
|
95
|
+
next true if ancestor.class_type? || ancestor.module_type?
|
|
96
|
+
next true if safe_dsl_block?(ancestor)
|
|
97
|
+
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A `block` node is safe when its method call receiver is an
|
|
103
|
+
# allowlisted DSL (Struct.new, Class.new, Module.new, etc.)
|
|
104
|
+
def safe_dsl_block?(node)
|
|
105
|
+
return false unless node.block_type?
|
|
106
|
+
|
|
107
|
+
send_node = node.send_node
|
|
108
|
+
receiver = send_node.receiver
|
|
109
|
+
|
|
110
|
+
return false if receiver.nil?
|
|
111
|
+
|
|
112
|
+
safe_receivers.any? do |safe|
|
|
113
|
+
receiver_matches?(receiver, safe)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def receiver_matches?(receiver, safe_name)
|
|
118
|
+
# Handles `Struct`, `Class`, `Module` (const nodes)
|
|
119
|
+
receiver.const_type? && receiver.short_name.to_s == safe_name
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def safe_receivers
|
|
123
|
+
DEFAULT_SAFE_DSL_RECEIVERS + Array(cop_config.fetch('SafeDSLReceivers', []))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Avoid reading `obj[key]` more than once with the same receiver and
|
|
6
|
+
# same key in a single method body.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# When the same bracket read appears in multiple places, two distinct
|
|
10
|
+
# failure modes open up:
|
|
11
|
+
#
|
|
12
|
+
# 1. **Silent typos.** Bracket access returns nil for missing keys;
|
|
13
|
+
# nothing raises. Two occurrences of `params[:status]` keep the
|
|
14
|
+
# spelling in sync, but if one of them silently becomes
|
|
15
|
+
# `params[:stutus]`, the line returns nil and the bug ships. A
|
|
16
|
+
# single assignment fixes the spelling in exactly one place — a
|
|
17
|
+
# typo there becomes a `NameError`, not a silent nil.
|
|
18
|
+
#
|
|
19
|
+
# 2. **Reader ambiguity and mutation risk.** The reader has to verify
|
|
20
|
+
# every occurrence really is the same key and that nothing in
|
|
21
|
+
# between mutates the hash. Assigning once makes the value a
|
|
22
|
+
# stable named thing — and on receivers like `session` or shared
|
|
23
|
+
# hashes, it also avoids a real TOCTOU shape where the value can
|
|
24
|
+
# change between the guard and the use.
|
|
25
|
+
#
|
|
26
|
+
# ❌
|
|
27
|
+
# def show
|
|
28
|
+
# @item = Item.lookup_by_slug(params[:slug])
|
|
29
|
+
# redirect_to canonical_url(@item) if @item.slug != params[:slug]
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# ✔️
|
|
33
|
+
# def show
|
|
34
|
+
# slug = params[:slug]
|
|
35
|
+
# @item = Item.lookup_by_slug(slug)
|
|
36
|
+
# redirect_to canonical_url(@item) if @item.slug != slug
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# The cop only counts reads. `obj[k] = v` is a write (`:[]=`) and is
|
|
40
|
+
# not compared against bracket reads of the same key.
|
|
41
|
+
#
|
|
42
|
+
# ## Exception
|
|
43
|
+
# Genuine intentional re-reads (e.g. a deliberate second check after
|
|
44
|
+
# a write that may have mutated the receiver) go through inline
|
|
45
|
+
# `# rubocop:disable` with a reason.
|
|
46
|
+
#
|
|
47
|
+
# NOTE: Receiver and key are compared by source text — `hash['foo']`
|
|
48
|
+
# and `hash[:foo]` look different to the cop even though they're the
|
|
49
|
+
# same value on a `HashWithIndifferentAccess` like `params`. That is
|
|
50
|
+
# a known false negative; the cop will miss that shape rather than
|
|
51
|
+
# over-fire.
|
|
52
|
+
#
|
|
53
|
+
# NOTE: Scope is the enclosing `def`/`defs` body, including nested
|
|
54
|
+
# blocks. Block parameters are scoped to their block, so the same name
|
|
55
|
+
# in two sibling blocks (`arr.each { |item| item[:k] }; other.each {
|
|
56
|
+
# |item| item[:k] }`) is correctly treated as two different bindings,
|
|
57
|
+
# not a repeat. Reads of the same block param *within one block* are
|
|
58
|
+
# still flagged. (Numbered/`it` block params are matched textually — a
|
|
59
|
+
# rare residual false positive; inline-disable if it surfaces.)
|
|
60
|
+
#
|
|
61
|
+
# NOTE: Multi-argument bracket calls (`arr[i, len]`, slicing) are
|
|
62
|
+
# ignored. Only single-argument reads participate.
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# # bad — same key read twice
|
|
66
|
+
# def show
|
|
67
|
+
# @item = Item.lookup_by_slug(params[:slug])
|
|
68
|
+
# redirect_to canonical_url(@item) if @item.slug != params[:slug]
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# # bad — guard-then-use re-reads the receiver
|
|
72
|
+
# def session_get(key)
|
|
73
|
+
# return nil unless session[key]
|
|
74
|
+
# session[key].symbolize_keys
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
# # good — assign once
|
|
78
|
+
# def session_get(key)
|
|
79
|
+
# payload = session[key]
|
|
80
|
+
# return nil unless payload
|
|
81
|
+
# payload.symbolize_keys
|
|
82
|
+
# end
|
|
83
|
+
#
|
|
84
|
+
# # good — different keys, no offense
|
|
85
|
+
# def filters
|
|
86
|
+
# @search = params[:search]
|
|
87
|
+
# @status = params[:status]
|
|
88
|
+
# end
|
|
89
|
+
class RepeatedBracketRead < Base
|
|
90
|
+
MSG = "Receiver `%<receiver>s[%<key>s]` already read earlier in this method — assign once and reuse.".freeze
|
|
91
|
+
|
|
92
|
+
def on_def(node)
|
|
93
|
+
check_method(node)
|
|
94
|
+
end
|
|
95
|
+
alias on_defs on_def
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def check_method(def_node)
|
|
100
|
+
body = def_node.body
|
|
101
|
+
return unless body
|
|
102
|
+
|
|
103
|
+
seen = {}
|
|
104
|
+
body.each_descendant(:send) do |send_node|
|
|
105
|
+
next unless bracket_read?(send_node)
|
|
106
|
+
|
|
107
|
+
receiver = send_node.receiver
|
|
108
|
+
key = send_node.first_argument
|
|
109
|
+
composite = composite_key(receiver, key)
|
|
110
|
+
|
|
111
|
+
if seen[composite]
|
|
112
|
+
add_offense(send_node.loc.selector, message: format(MSG, receiver: receiver.source, key: key.source))
|
|
113
|
+
else
|
|
114
|
+
seen[composite] = true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Block parameters are scoped to their block: the same name read in two
|
|
120
|
+
# sibling blocks is a *different* binding, not a repeat. Key those reads
|
|
121
|
+
# by their binding block so they don't collide. Method-call / ivar /
|
|
122
|
+
# method-local receivers keep a purely textual key.
|
|
123
|
+
def composite_key(receiver, key_node)
|
|
124
|
+
key = key_node.source
|
|
125
|
+
block = binding_block(receiver)
|
|
126
|
+
block ? "#{block.object_id}|#{receiver.source}|#{key}" : "#{receiver.source}|#{key}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The nearest enclosing block that binds the receiver as a block
|
|
130
|
+
# argument, or nil when the receiver is not a block-local variable.
|
|
131
|
+
def binding_block(receiver)
|
|
132
|
+
return nil unless receiver.lvar_type?
|
|
133
|
+
|
|
134
|
+
name = receiver.children.first
|
|
135
|
+
receiver.each_ancestor(:block) do |block|
|
|
136
|
+
return block if block.arguments.any? { |arg| arg.respond_to?(:name) && arg.name == name }
|
|
137
|
+
end
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def bracket_read?(node)
|
|
142
|
+
node.method_name == :[] &&
|
|
143
|
+
node.receiver &&
|
|
144
|
+
node.arguments.size == 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|