rubocop-dev_doc 0.2.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 +230 -61
- 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/avoid_conditional_schema_changes.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +1 -1
- 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 +2 -2
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
- 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/style/avoid_send.rb +31 -4
- 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/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
- metadata +58 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Route
|
|
5
|
+
# Avoid custom `member` / `collection` actions; model them as RESTful
|
|
6
|
+
# sub-resources instead.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# Follow Rails' standard REST principles as much as possible. A resource
|
|
10
|
+
# exposes seven standard actions (`index`, `show`, `new`, `create`,
|
|
11
|
+
# `edit`, `update`, `destroy`); anything declared through a `member` or
|
|
12
|
+
# `collection` block is a custom verb bolted onto the resource. Each one
|
|
13
|
+
# is better expressed as its own RESTful sub-resource — the action then
|
|
14
|
+
# reads as a noun being created/destroyed, scopes cleanly under Pundit
|
|
15
|
+
# policies, and keeps every controller a thin CRUD controller.
|
|
16
|
+
#
|
|
17
|
+
# ❌ POST /products/3/activate
|
|
18
|
+
# resources :products, only: [:show] do
|
|
19
|
+
# member { post :activate }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# ✔️ POST /products/3/activations (create an activation)
|
|
23
|
+
# resources :products, only: [:show] do
|
|
24
|
+
# resources :activations, only: [:create]
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# ✔️ a mode on the standard edit/update, when it's really one resource
|
|
28
|
+
# PATCH /products/3?mode=activate
|
|
29
|
+
#
|
|
30
|
+
# A reversible pair (`activate` / `deactivate`, `lock` / `unlock`,
|
|
31
|
+
# `archive` / `restore`) maps naturally onto `create` / `destroy` of one
|
|
32
|
+
# sub-resource.
|
|
33
|
+
#
|
|
34
|
+
# ## Exception
|
|
35
|
+
# The doc says "as much as possible" — some custom actions are genuinely
|
|
36
|
+
# awkward to model as a sub-resource (a multi-step wizard step, a
|
|
37
|
+
# non-CRUD report endpoint). For those, disable the cop on the line with
|
|
38
|
+
# a written reason, e.g.:
|
|
39
|
+
#
|
|
40
|
+
# member do
|
|
41
|
+
# # rubocop:disable DevDoc/Route/NoCustomActions
|
|
42
|
+
# get :balance # multi-step finalize wizard; not a persisted resource
|
|
43
|
+
# # rubocop:enable DevDoc/Route/NoCustomActions
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# This cop flags every form a custom resource action takes:
|
|
47
|
+
# - inside a `member` / `collection` block
|
|
48
|
+
# - the inline `on: :member` / `on: :collection` option
|
|
49
|
+
# (`get :activate, on: :member`)
|
|
50
|
+
# - a bare verb directly inside a `resources` / `resource` block,
|
|
51
|
+
# which Rails treats as a collection route (`resources :x do
|
|
52
|
+
# get :search end`)
|
|
53
|
+
# - a bare verb inside a `concern` block (its routes are mixed into
|
|
54
|
+
# resources, so a custom verb there is a custom resource action)
|
|
55
|
+
#
|
|
56
|
+
# `constraints` / `defaults` wrappers between the verb and its resource
|
|
57
|
+
# are transparent — a custom action nested inside them is still caught.
|
|
58
|
+
#
|
|
59
|
+
# NOTE: Stand-alone non-resource routes (`get 'sitemap.xml'`,
|
|
60
|
+
# `get 'proxy'`) and verbs scoped by a `namespace` / `scope` block are
|
|
61
|
+
# not resourceful, and are intentionally left alone. A custom action
|
|
62
|
+
# written as a flat top-level route (`get 'photos/search', to:
|
|
63
|
+
# 'photos#search'`) is indistinguishable from such a route and cannot be
|
|
64
|
+
# flagged without false positives — that case is left to review.
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# # bad
|
|
68
|
+
# resources :products, only: [:show] do
|
|
69
|
+
# member do
|
|
70
|
+
# post :activate
|
|
71
|
+
# post :deactivate
|
|
72
|
+
# end
|
|
73
|
+
# end
|
|
74
|
+
#
|
|
75
|
+
# # good
|
|
76
|
+
# resources :products, only: [:show] do
|
|
77
|
+
# resources :activations, only: %i[create destroy]
|
|
78
|
+
# end
|
|
79
|
+
class NoCustomActions < Base
|
|
80
|
+
MSG = 'Custom `%<context>s` action `%<verb>s %<name>s`. Model it as a RESTful ' \
|
|
81
|
+
'sub-resource (e.g. `resources :activations, only: [:create]`) instead. ' \
|
|
82
|
+
'Disable with a reason if a custom action is genuinely unavoidable.'.freeze
|
|
83
|
+
|
|
84
|
+
RESTRICT_ON_SEND = %i[get post patch put delete match].freeze
|
|
85
|
+
|
|
86
|
+
MEMBER_OR_COLLECTION = %i[member collection].freeze
|
|
87
|
+
RESOURCEFUL = %i[resources resource].freeze
|
|
88
|
+
# Wrappers that add conditions but do not change whether the verb is a
|
|
89
|
+
# resource action — walk through them to find the real context.
|
|
90
|
+
TRANSPARENT_WRAPPERS = %i[constraints defaults].freeze
|
|
91
|
+
|
|
92
|
+
def on_send(node)
|
|
93
|
+
context = routing_context(node)
|
|
94
|
+
return unless context
|
|
95
|
+
|
|
96
|
+
add_offense(
|
|
97
|
+
node.loc.selector,
|
|
98
|
+
message: format(MSG, context: context, verb: node.method_name, name: action_name(node))
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# The routing context that makes this verb a custom resource action,
|
|
105
|
+
# or nil if it is a stand-alone route. Shapes that count:
|
|
106
|
+
# - inside a `member` / `collection` block (at any depth)
|
|
107
|
+
# - the inline `on: :member` / `on: :collection` / `on: :new` option
|
|
108
|
+
# - a bare verb whose nearest non-transparent enclosing block is
|
|
109
|
+
# `resources` / `resource` (a collection route) or `concern`
|
|
110
|
+
# `constraints` / `defaults` wrappers are transparent (walked through);
|
|
111
|
+
# a `namespace` / `scope` block turns the verb into a stand-alone route
|
|
112
|
+
# and stops the search.
|
|
113
|
+
def routing_context(node)
|
|
114
|
+
inline = inline_on_context(node)
|
|
115
|
+
return inline if inline
|
|
116
|
+
|
|
117
|
+
sends = enclosing_routing_sends(node)
|
|
118
|
+
|
|
119
|
+
# `member` / `collection` mark a custom action at any nesting depth.
|
|
120
|
+
explicit = sends.find { |s| MEMBER_OR_COLLECTION.include?(s.method_name) }
|
|
121
|
+
return explicit.method_name.to_s if explicit
|
|
122
|
+
|
|
123
|
+
# Otherwise the nearest block that isn't a transparent wrapper decides.
|
|
124
|
+
decider = sends.find { |s| !TRANSPARENT_WRAPPERS.include?(s.method_name) }
|
|
125
|
+
return unless decider
|
|
126
|
+
|
|
127
|
+
return 'collection' if RESOURCEFUL.include?(decider.method_name)
|
|
128
|
+
|
|
129
|
+
'concern' if decider.method_name == :concern
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The block call-sends enclosing this node, innermost first, limited to
|
|
133
|
+
# DSL blocks with no explicit receiver. Covers `do...end`, `{}`, and
|
|
134
|
+
# numbered (`_1`) / `it` block forms.
|
|
135
|
+
def enclosing_routing_sends(node)
|
|
136
|
+
node.each_ancestor(:block, :numblock, :itblock).filter_map do |b|
|
|
137
|
+
send = b.children.first
|
|
138
|
+
send if send.send_type? && send.receiver.nil?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The value of an inline `on:` option (`:member` / `:collection` /
|
|
143
|
+
# `:new`) as a string, or nil when absent. `on:` is only ever used to
|
|
144
|
+
# attach a custom action to a resource.
|
|
145
|
+
def inline_on_context(node)
|
|
146
|
+
options = node.arguments.find(&:hash_type?)
|
|
147
|
+
return unless options
|
|
148
|
+
|
|
149
|
+
pair = options.pairs.find { |p| p.key.sym_type? && p.key.value == :on }
|
|
150
|
+
return unless pair
|
|
151
|
+
|
|
152
|
+
pair.value.sym_type? ? pair.value.value.to_s : 'member'
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Display name for the action: the leading symbol/string argument
|
|
156
|
+
# (`:activate`, `'weeks/:start_date'`), or `?` when it can't be read.
|
|
157
|
+
def action_name(node)
|
|
158
|
+
arg = node.first_argument
|
|
159
|
+
return '?' unless arg
|
|
160
|
+
|
|
161
|
+
case arg.type
|
|
162
|
+
when :sym then ":#{arg.value}"
|
|
163
|
+
when :str then "'#{arg.value}'"
|
|
164
|
+
else '?'
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require 'active_support/inflector'
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module DevDoc
|
|
6
|
+
module Route
|
|
7
|
+
# Use a plural name for `resources` and a singular name for `resource`.
|
|
8
|
+
#
|
|
9
|
+
# ## Rationale
|
|
10
|
+
# Follow Rails' standard REST conventions. A plural `resources` maps to a
|
|
11
|
+
# collection (`/products`), so its name should be plural; a singular
|
|
12
|
+
# `resource` maps to a single implicit-id resource (`/profile`), so its
|
|
13
|
+
# name should be singular. Mismatched number reads against every Rails
|
|
14
|
+
# convention and produces awkward path/helper names.
|
|
15
|
+
#
|
|
16
|
+
# ❌ resources :product # => /product
|
|
17
|
+
# ❌ resource :sessions # singular resource, plural name
|
|
18
|
+
#
|
|
19
|
+
# ✔️ resources :products # => /products
|
|
20
|
+
# ✔️ resource :session # => /session
|
|
21
|
+
#
|
|
22
|
+
# The check uses ActiveSupport's inflector, so irregular and uncountable
|
|
23
|
+
# nouns are handled (`resources :people`, `resources :fish` are fine).
|
|
24
|
+
#
|
|
25
|
+
# ## Exception
|
|
26
|
+
# A project with custom inflections (`config/initializers/inflections.rb`)
|
|
27
|
+
# the bundled inflector doesn't know about may be misjudged. Disable the
|
|
28
|
+
# cop on that line with a reason.
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# # bad
|
|
32
|
+
# resources :product
|
|
33
|
+
# resource :sessions
|
|
34
|
+
#
|
|
35
|
+
# # good
|
|
36
|
+
# resources :products
|
|
37
|
+
# resource :session
|
|
38
|
+
class ResourceNameNumber < Base
|
|
39
|
+
MSG = '`%<method>s` should name a %<number>s resource — use `:%<expected>s`.'.freeze
|
|
40
|
+
|
|
41
|
+
RESTRICT_ON_SEND = %i[resources resource].freeze
|
|
42
|
+
|
|
43
|
+
def on_send(node)
|
|
44
|
+
node.arguments.each do |arg|
|
|
45
|
+
name = name_of(arg)
|
|
46
|
+
next unless name
|
|
47
|
+
|
|
48
|
+
expected = expected_name(node.method_name, name)
|
|
49
|
+
next if expected == name
|
|
50
|
+
|
|
51
|
+
add_offense(
|
|
52
|
+
arg,
|
|
53
|
+
message: format(MSG, method: node.method_name, number: number_word(node.method_name), expected: expected)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# The resource name as a string, for symbol or string arguments only
|
|
61
|
+
# (skips the options hash, blocks, etc.).
|
|
62
|
+
def name_of(arg)
|
|
63
|
+
arg.value.to_s if arg.sym_type? || arg.str_type?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def expected_name(method, name)
|
|
67
|
+
method == :resources ? ActiveSupport::Inflector.pluralize(name) : ActiveSupport::Inflector.singularize(name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def number_word(method)
|
|
71
|
+
method == :resources ? 'plural' : 'singular'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -36,17 +36,28 @@ module RuboCop
|
|
|
36
36
|
# ✔️ Restricted — only methods with the prefix can be called
|
|
37
37
|
# obj.send("export_#{method_name}")
|
|
38
38
|
#
|
|
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.
|
|
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
|
+
#
|
|
50
|
+
# # bad — interpolation with no static prefix restricts nothing
|
|
51
|
+
# obj.send("#{x}")
|
|
52
|
+
# obj.send("#{x}_run")
|
|
44
53
|
#
|
|
45
54
|
# # good — literal symbol: method name is statically visible
|
|
46
55
|
# instance.send(:private_helper, arg)
|
|
47
56
|
#
|
|
48
|
-
# # good
|
|
57
|
+
# # good — bracket notation for model attributes
|
|
49
58
|
# @user[attribute_name]
|
|
59
|
+
#
|
|
60
|
+
# # good — static prefix restricts the callable methods
|
|
50
61
|
# obj.send("export_#{method_name}")
|
|
51
62
|
class AvoidSend < Base
|
|
52
63
|
MSG = "Avoid dynamic `%<method>s` — use bracket notation for model attributes, " \
|
|
@@ -55,10 +66,26 @@ module RuboCop
|
|
|
55
66
|
|
|
56
67
|
def on_send(node)
|
|
57
68
|
return if node.receiver.nil?
|
|
58
|
-
|
|
69
|
+
|
|
70
|
+
arg = node.first_argument
|
|
71
|
+
return if arg&.sym_type?
|
|
72
|
+
return if prefixed_dynamic_method?(arg)
|
|
59
73
|
|
|
60
74
|
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
61
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
|
|
62
89
|
end
|
|
63
90
|
end
|
|
64
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
|