rubocop-dev_doc 0.2.0 → 0.3.1
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 +235 -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 +287 -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,203 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Auth
|
|
5
|
+
# Forbid branching on authentication state in page-specific code.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# In a Rails app using Pundit + Devise, `current_user` is guaranteed
|
|
9
|
+
# non-nil inside any controller action or view that requires auth —
|
|
10
|
+
# the policy has already denied anonymous visitors. Branching on
|
|
11
|
+
# `if current_user` or `if user_signed_in?` inside that code is
|
|
12
|
+
# therefore either dead code (the branch for nil can never fire) or
|
|
13
|
+
# a signal that the developer was unsure whether the page requires auth.
|
|
14
|
+
#
|
|
15
|
+
# If the page genuinely serves both anonymous and signed-in visitors,
|
|
16
|
+
# the branching should be explicit and kept in shared/layout code, not
|
|
17
|
+
# sprinkled through action bodies and page views.
|
|
18
|
+
#
|
|
19
|
+
# ❌ Authenticated page branching on auth state (branch is dead code)
|
|
20
|
+
# # app/views/posts/show.json.jbuilder
|
|
21
|
+
# if current_user
|
|
22
|
+
# json.actions [:edit, :delete]
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# ✔️ Shared layout — branching here is the right place
|
|
26
|
+
# # app/views/layouts/_nav_bar.html.erb
|
|
27
|
+
# <% if user_signed_in? %>
|
|
28
|
+
# <%= render 'profile_menu' %>
|
|
29
|
+
# <% else %>
|
|
30
|
+
# <%= link_to 'Log in', new_user_session_path %>
|
|
31
|
+
# <% end %>
|
|
32
|
+
#
|
|
33
|
+
# ✔️ Genuinely dual-state page — suppress the cop with a reason comment
|
|
34
|
+
# def new
|
|
35
|
+
# # rubocop:disable DevDoc/Auth/CurrentUserBranching
|
|
36
|
+
# # Reason: contact form is intentionally dual-state; pre-fills for signed-in users.
|
|
37
|
+
# if current_user
|
|
38
|
+
# @form.name = current_user.full_name
|
|
39
|
+
# end
|
|
40
|
+
# # rubocop:enable DevDoc/Auth/CurrentUserBranching
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# ## Patterns flagged
|
|
44
|
+
# - `if current_user` / `unless current_user` (block or modifier) where
|
|
45
|
+
# the body is non-empty and is not a bare `return` (bare returns are
|
|
46
|
+
# the nil-guard pattern covered by LoadResourceCurrentUserGuard).
|
|
47
|
+
# - `if user_signed_in?` / `unless user_signed_in?` (any form).
|
|
48
|
+
# - `if current_user&.foo` and other safe-nav uses of `current_user`
|
|
49
|
+
# as a condition. The `&.` is itself a confession that the dev
|
|
50
|
+
# expects `current_user` to be nil sometimes — i.e., it's the same
|
|
51
|
+
# anti-pattern as `if current_user`, just in disguise: when
|
|
52
|
+
# `current_user` is nil the csend returns nil → branch is dead for
|
|
53
|
+
# anonymous visitors, exactly what the cop prevents.
|
|
54
|
+
# - Ternaries: `current_user ? a : b`, `user_signed_in? ? a : b`.
|
|
55
|
+
# - Hash/argument values: `authenticated: user_signed_in?`.
|
|
56
|
+
#
|
|
57
|
+
# ## Allowed paths (Exclude:)
|
|
58
|
+
# By default the cop is silent in:
|
|
59
|
+
# app/policies/**/*.rb
|
|
60
|
+
# app/helpers/**/*.rb
|
|
61
|
+
# app/controllers/concerns/**/*.rb
|
|
62
|
+
# app/views/layouts/**/*
|
|
63
|
+
# app/controllers/application_controller.rb
|
|
64
|
+
#
|
|
65
|
+
# Override via `Exclude:` in your `.rubocop.yml`.
|
|
66
|
+
#
|
|
67
|
+
# NOTE: The cop does not autocorrect — there is no mechanical fix. The
|
|
68
|
+
# right response depends on developer intent: drop the branch (if auth
|
|
69
|
+
# is required), restructure into a shared layout (if dual-state), or
|
|
70
|
+
# add an inline disable with a reason.
|
|
71
|
+
#
|
|
72
|
+
# @example
|
|
73
|
+
# # bad — inside an authenticated action
|
|
74
|
+
# def show
|
|
75
|
+
# if current_user
|
|
76
|
+
# @data = current_user.private_data
|
|
77
|
+
# end
|
|
78
|
+
# end
|
|
79
|
+
#
|
|
80
|
+
# # bad — safe-nav predicate, same anti-pattern in disguise
|
|
81
|
+
# def show
|
|
82
|
+
# if current_user&.admin?
|
|
83
|
+
# admin_thing
|
|
84
|
+
# end
|
|
85
|
+
# end
|
|
86
|
+
#
|
|
87
|
+
# # bad — inside a page view
|
|
88
|
+
# # user_signed_in? ? render_private : render_public
|
|
89
|
+
#
|
|
90
|
+
# # good — bare nil-guard return (LoadResourceCurrentUserGuard rule)
|
|
91
|
+
# return unless current_user
|
|
92
|
+
#
|
|
93
|
+
# # good — inside a shared layout file (excluded by default)
|
|
94
|
+
# # app/views/layouts/_nav.html.erb
|
|
95
|
+
# if user_signed_in?
|
|
96
|
+
# ...
|
|
97
|
+
# end
|
|
98
|
+
class CurrentUserBranching < Base
|
|
99
|
+
MSG = 'Avoid branching on auth state in page code. ' \
|
|
100
|
+
'If this page serves both anonymous and signed-in users, ' \
|
|
101
|
+
'add an inline disable with a reason.'.freeze
|
|
102
|
+
|
|
103
|
+
AUTH_METHODS = %i[current_user user_signed_in?].freeze
|
|
104
|
+
|
|
105
|
+
def on_if(node)
|
|
106
|
+
return unless auth_branch?(node)
|
|
107
|
+
return if bare_return_guard?(node)
|
|
108
|
+
|
|
109
|
+
add_offense(if_offense_location(node))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `value: user_signed_in?` — condition passed as a value.
|
|
113
|
+
def on_send(node)
|
|
114
|
+
return unless auth_method_call?(node)
|
|
115
|
+
return unless used_as_value?(node)
|
|
116
|
+
|
|
117
|
+
add_offense(node.loc.selector)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def auth_branch?(node)
|
|
123
|
+
node.if_type? && auth_condition?(node.condition)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def auth_condition?(condition)
|
|
127
|
+
return false unless condition
|
|
128
|
+
|
|
129
|
+
if condition.send_type?
|
|
130
|
+
AUTH_METHODS.include?(condition.method_name) && condition.receiver.nil?
|
|
131
|
+
elsif condition.csend_type?
|
|
132
|
+
# `current_user&.foo` — the safe-nav is itself the auth check;
|
|
133
|
+
# `bare_current_user_condition?` won't match (it requires send),
|
|
134
|
+
# so guard-return forms like `return unless current_user&.admin?`
|
|
135
|
+
# correctly stay flagged.
|
|
136
|
+
receiver = condition.receiver
|
|
137
|
+
receiver&.send_type? &&
|
|
138
|
+
receiver.method_name == :current_user &&
|
|
139
|
+
receiver.receiver.nil?
|
|
140
|
+
else
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# `return unless current_user` (and similar bare guards) should not
|
|
146
|
+
# be flagged — they are the correct pattern handled by
|
|
147
|
+
# LoadResourceCurrentUserGuard. Detect: an if whose condition is bare
|
|
148
|
+
# `current_user` and one branch is a bare `return` (no arguments).
|
|
149
|
+
def bare_return_guard?(node)
|
|
150
|
+
condition = node.condition
|
|
151
|
+
return false unless bare_current_user_condition?(condition)
|
|
152
|
+
|
|
153
|
+
bare_return_branch?(node.else_branch) || bare_return_branch?(node.if_branch)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def bare_current_user_condition?(condition)
|
|
157
|
+
condition&.send_type? &&
|
|
158
|
+
condition.method_name == :current_user &&
|
|
159
|
+
condition.receiver.nil?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def bare_return_branch?(branch)
|
|
163
|
+
branch&.return_type? && branch.children.empty?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# `if`/`unless` keyword forms have `loc.keyword`; ternary `?:` nodes
|
|
167
|
+
# have `loc.question` instead. Fall back to the condition source range.
|
|
168
|
+
def if_offense_location(node)
|
|
169
|
+
loc = node.loc
|
|
170
|
+
if loc.respond_to?(:keyword) && loc.keyword
|
|
171
|
+
loc.keyword
|
|
172
|
+
elsif loc.respond_to?(:question) && loc.question
|
|
173
|
+
loc.question
|
|
174
|
+
else
|
|
175
|
+
node.condition.source_range
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Is this send node an auth method call (`current_user`, `user_signed_in?`)
|
|
180
|
+
# on no receiver?
|
|
181
|
+
def auth_method_call?(node)
|
|
182
|
+
AUTH_METHODS.include?(node.method_name) && node.receiver.nil?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Is the send node used as a value (argument to another send, pair
|
|
186
|
+
# value in a hash, etc.) rather than already caught as a branch condition?
|
|
187
|
+
def used_as_value?(node)
|
|
188
|
+
parent = node.parent
|
|
189
|
+
return false unless parent
|
|
190
|
+
|
|
191
|
+
# Pair value: `key: user_signed_in?`
|
|
192
|
+
return true if parent.pair_type? && parent.value.equal?(node)
|
|
193
|
+
|
|
194
|
+
# Passed as argument (not the condition of an `if`)
|
|
195
|
+
return false if parent.if_type? && parent.condition.equal?(node)
|
|
196
|
+
|
|
197
|
+
parent.send_type? && parent.arguments.include?(node)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Auth
|
|
5
|
+
# Require an early-return nil-guard before using `current_user` inside
|
|
6
|
+
# the load-resource lifecycle method, and forbid safe-navigation (`&.`).
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# `glib_load_resource` (or a similarly named hook) runs *before* the
|
|
10
|
+
# Pundit policy. At that point `current_user` may still be nil — an
|
|
11
|
+
# anonymous visitor hasn't been denied yet. Code that calls
|
|
12
|
+
# `current_user.foo` without guarding first crashes for anonymous
|
|
13
|
+
# visitors; code that uses `current_user&.foo` hides the problem with
|
|
14
|
+
# soft nil-handling instead of making it explicit.
|
|
15
|
+
#
|
|
16
|
+
# The correct pattern is an early-return guard at the top of any branch
|
|
17
|
+
# that needs `current_user`:
|
|
18
|
+
#
|
|
19
|
+
# ✔️
|
|
20
|
+
# def glib_load_resource
|
|
21
|
+
# return unless current_user
|
|
22
|
+
#
|
|
23
|
+
# @post = current_user.posts.find(params[:id])
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# Guarded branches within a case/if are also fine:
|
|
27
|
+
#
|
|
28
|
+
# ✔️
|
|
29
|
+
# def glib_load_resource
|
|
30
|
+
# case action_name.to_sym
|
|
31
|
+
# when :new, :create
|
|
32
|
+
# return unless current_user
|
|
33
|
+
# @post = current_user.posts.new
|
|
34
|
+
# when :index
|
|
35
|
+
# # Nothing to do
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# glib's `assert_current_user_present` raises when `current_user` is nil,
|
|
40
|
+
# so it guarantees non-nil just as well as the early return — and any
|
|
41
|
+
# `raise` guard works like the `return` form:
|
|
42
|
+
#
|
|
43
|
+
# ✔️
|
|
44
|
+
# def glib_load_resource
|
|
45
|
+
# assert_current_user_present
|
|
46
|
+
#
|
|
47
|
+
# @post = current_user.posts.find(params[:id])
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# ✔️
|
|
51
|
+
# def glib_load_resource
|
|
52
|
+
# raise UnauthorizedError unless current_user
|
|
53
|
+
#
|
|
54
|
+
# @post = current_user.posts.find(params[:id])
|
|
55
|
+
# end
|
|
56
|
+
#
|
|
57
|
+
# ❌ Safe navigation — hides the pre-auth nil risk
|
|
58
|
+
# def glib_load_resource
|
|
59
|
+
# @post = current_user&.posts&.find(params[:id])
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
# ❌ Unguarded — crashes for anonymous visitors
|
|
63
|
+
# def glib_load_resource
|
|
64
|
+
# @post = current_user.posts.find(params[:id])
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# ## Configuration
|
|
68
|
+
# `LoadResourceMethodNames` (default: `[glib_load_resource]`) — list of
|
|
69
|
+
# method names where the guard rule applies. Projects standardising on a
|
|
70
|
+
# different lifecycle method can add it here.
|
|
71
|
+
#
|
|
72
|
+
# `CurrentUserAssertionMethodNames` (default:
|
|
73
|
+
# `[assert_current_user_present]`) — calls that raise when `current_user`
|
|
74
|
+
# is nil. A call to one of these before the first `current_user` use
|
|
75
|
+
# satisfies the guard. Add project-specific assertion helpers here. NOTE:
|
|
76
|
+
# the cop trusts the named method to actually raise on nil — a configured
|
|
77
|
+
# name that doesn't will turn into a false negative.
|
|
78
|
+
#
|
|
79
|
+
# NOTE: The cop performs structural analysis of the method body and does
|
|
80
|
+
# not track aliasing. If you assign `current_user` to a local variable
|
|
81
|
+
# and then call methods on that variable, the cop will not flag it —
|
|
82
|
+
# reviewers must catch that pattern manually.
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# # bad — safe navigation inside load hook
|
|
86
|
+
# def glib_load_resource
|
|
87
|
+
# @post = current_user&.posts&.find(params[:id])
|
|
88
|
+
# end
|
|
89
|
+
#
|
|
90
|
+
# # bad — unguarded call inside load hook
|
|
91
|
+
# def glib_load_resource
|
|
92
|
+
# @post = current_user.posts.find(params[:id])
|
|
93
|
+
# end
|
|
94
|
+
#
|
|
95
|
+
# # good — guarded with early return
|
|
96
|
+
# def glib_load_resource
|
|
97
|
+
# return unless current_user
|
|
98
|
+
#
|
|
99
|
+
# @post = current_user.posts.find(params[:id])
|
|
100
|
+
# end
|
|
101
|
+
#
|
|
102
|
+
# # good — guarded with the glib assertion (raises when nil)
|
|
103
|
+
# def glib_load_resource
|
|
104
|
+
# assert_current_user_present
|
|
105
|
+
#
|
|
106
|
+
# @post = current_user.posts.find(params[:id])
|
|
107
|
+
# end
|
|
108
|
+
class LoadResourceCurrentUserGuard < Base
|
|
109
|
+
MSG_SAFE_NAV = 'Avoid `current_user&.` inside `%<method>s` — use ' \
|
|
110
|
+
'`return unless current_user` then `current_user.` instead.'.freeze
|
|
111
|
+
MSG_MISSING_GUARD = '`current_user` is used without a prior ' \
|
|
112
|
+
'`return unless current_user` (or `assert_current_user_present`) ' \
|
|
113
|
+
'guard in `%<method>s`. ' \
|
|
114
|
+
'This method runs before the policy, so `current_user` may be nil.'.freeze
|
|
115
|
+
|
|
116
|
+
# Predicates safe to call on a nil `current_user` — a `current_user.nil?`
|
|
117
|
+
# etc. is not itself an unguarded use that risks NoMethodError.
|
|
118
|
+
NIL_CHECK_METHODS = %i[nil? blank? present? empty?].freeze
|
|
119
|
+
|
|
120
|
+
# Predicates split by polarity, so a guard's exit branch can be matched
|
|
121
|
+
# to the path on which `current_user` is nil. `present?` is a PRESENCE
|
|
122
|
+
# check (reversed from `nil?`/`blank?`), so `unless current_user.present?`
|
|
123
|
+
# is a valid guard while `if current_user.present?` is not.
|
|
124
|
+
ABSENCE_CHECK_METHODS = %i[nil? blank? empty?].freeze
|
|
125
|
+
PRESENCE_CHECK_METHODS = %i[present?].freeze
|
|
126
|
+
|
|
127
|
+
def on_def(node)
|
|
128
|
+
check_load_resource_method(node)
|
|
129
|
+
end
|
|
130
|
+
alias on_defs on_def
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def load_resource_method_names
|
|
135
|
+
Array(cop_config.fetch('LoadResourceMethodNames', ['glib_load_resource'])).map(&:to_sym)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def current_user_assertion_method_names
|
|
139
|
+
Array(cop_config.fetch('CurrentUserAssertionMethodNames', ['assert_current_user_present'])).map(&:to_sym)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def load_resource_method?(method_name)
|
|
143
|
+
load_resource_method_names.include?(method_name.to_sym)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def check_load_resource_method(def_node)
|
|
147
|
+
method_name = def_node.method_name
|
|
148
|
+
return unless load_resource_method?(method_name)
|
|
149
|
+
|
|
150
|
+
body = def_node.body
|
|
151
|
+
return unless body
|
|
152
|
+
|
|
153
|
+
check_body(body, method_name)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def check_body(body, method_name)
|
|
157
|
+
body.each_descendant do |node|
|
|
158
|
+
if safe_nav_on_current_user?(node)
|
|
159
|
+
add_offense(node.loc.dot,
|
|
160
|
+
message: format(MSG_SAFE_NAV, method: method_name))
|
|
161
|
+
elsif unguarded_current_user_call?(node)
|
|
162
|
+
add_offense(node.receiver.loc.selector,
|
|
163
|
+
message: format(MSG_MISSING_GUARD, method: method_name))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# `current_user&.something` — always an offense regardless of guards.
|
|
169
|
+
def safe_nav_on_current_user?(node)
|
|
170
|
+
node.csend_type? && bare_current_user?(node.receiver)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# `current_user.something` where `something` is not a nil check and no
|
|
174
|
+
# dominating guard protects the call — i.e. not inside an `if current_user`
|
|
175
|
+
# then-branch and not preceded by a guard statement in its enclosing `begin`.
|
|
176
|
+
def unguarded_current_user_call?(node)
|
|
177
|
+
return false unless node.send_type?
|
|
178
|
+
return false unless bare_current_user?(node.receiver)
|
|
179
|
+
return false if NIL_CHECK_METHODS.include?(node.method_name)
|
|
180
|
+
|
|
181
|
+
!(inside_current_user_branch?(node) || preceded_by_guard?(node))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# True if `node` is a bare `current_user` send (no receiver).
|
|
185
|
+
def bare_current_user?(node)
|
|
186
|
+
node&.send_type? && node.method_name == :current_user && node.receiver.nil?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Walk ancestor `if` nodes and return true when `node` is inside the
|
|
190
|
+
# then-branch of an `if current_user` (not the else-branch).
|
|
191
|
+
def inside_current_user_branch?(node)
|
|
192
|
+
node.each_ancestor(:if) do |if_node|
|
|
193
|
+
next unless bare_current_user?(if_node.condition)
|
|
194
|
+
|
|
195
|
+
if_br = if_node.if_branch
|
|
196
|
+
return true if if_br && (if_br.equal?(node) || descendant_by_identity?(if_br, node))
|
|
197
|
+
end
|
|
198
|
+
false
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Walk up through all ancestor `begin` nodes. For each, check whether
|
|
202
|
+
# a guard-return statement appears before the statement that contains
|
|
203
|
+
# `node`. This handles both flat method bodies and nested `when` branches.
|
|
204
|
+
def preceded_by_guard?(node)
|
|
205
|
+
node.each_ancestor do |ancestor|
|
|
206
|
+
next unless ancestor.begin_type?
|
|
207
|
+
|
|
208
|
+
stmts = ancestor.children
|
|
209
|
+
container_idx = stmts.index { |s| s.equal?(node) || descendant_by_identity?(s, node) }
|
|
210
|
+
next unless container_idx
|
|
211
|
+
|
|
212
|
+
return true if stmts[0...container_idx].any? { |s| guard_statement?(s) }
|
|
213
|
+
end
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# True if `descendant` is found within `root` by object identity.
|
|
218
|
+
def descendant_by_identity?(root, descendant)
|
|
219
|
+
root.each_descendant.any? { |d| d.equal?(descendant) }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# True if `stmt` guarantees `current_user` is non-nil for everything
|
|
223
|
+
# that follows it — either:
|
|
224
|
+
#
|
|
225
|
+
# 1. a bare call to a configured assertion helper (default
|
|
226
|
+
# `assert_current_user_present`), which raises when nil; or
|
|
227
|
+
# 2. an early-exit guard whose branch returns OR raises:
|
|
228
|
+
# return unless current_user raise ... unless current_user
|
|
229
|
+
# return if current_user.nil? raise ... if current_user.blank?
|
|
230
|
+
def guard_statement?(stmt)
|
|
231
|
+
assertion_guard_call?(stmt) || exit_guard_statement?(stmt)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# A bare call (nil receiver) to a "raise when current_user is nil"
|
|
235
|
+
# helper, e.g. glib's `assert_current_user_present`.
|
|
236
|
+
def assertion_guard_call?(stmt)
|
|
237
|
+
stmt.send_type? && stmt.receiver.nil? &&
|
|
238
|
+
current_user_assertion_method_names.include?(stmt.method_name)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A `return`/`raise` guard whose exit happens on the NIL path — the only
|
|
242
|
+
# polarity that actually protects later `current_user` use:
|
|
243
|
+
# `... unless current_user` exits via the else branch
|
|
244
|
+
# `... if current_user.nil?` exits via the if branch
|
|
245
|
+
# (Ruby models an `if`/`unless` modifier as an `if` node with an empty
|
|
246
|
+
# opposite branch, so we tie the required exit to the condition polarity.
|
|
247
|
+
# This rejects inverted guards like `return if current_user`.)
|
|
248
|
+
# A `return`/`raise` guard is valid only when the branch that runs while
|
|
249
|
+
# `current_user` is nil exits. `if_branch` is the written body regardless
|
|
250
|
+
# of `if`/`unless`, so combine the condition's polarity with the keyword to
|
|
251
|
+
# pick that branch. This rejects inverted guards (`return if current_user`).
|
|
252
|
+
def exit_guard_statement?(stmt)
|
|
253
|
+
return false unless stmt.if_type?
|
|
254
|
+
|
|
255
|
+
polarity = condition_polarity(stmt.condition)
|
|
256
|
+
return false unless polarity
|
|
257
|
+
|
|
258
|
+
body_runs_when_nil = polarity == (stmt.unless? ? :presence : :absence)
|
|
259
|
+
branch_exits?(body_runs_when_nil ? stmt.if_branch : stmt.else_branch)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# :presence — condition truthy when current_user is present (`current_user`,
|
|
263
|
+
# `current_user.present?`); :absence — truthy when absent (`.nil?`/`.blank?`/
|
|
264
|
+
# `.empty?`); nil — not a current_user condition.
|
|
265
|
+
def condition_polarity(condition)
|
|
266
|
+
return :presence if bare_current_user?(condition)
|
|
267
|
+
return unless condition&.send_type? && bare_current_user?(condition.receiver)
|
|
268
|
+
return :presence if PRESENCE_CHECK_METHODS.include?(condition.method_name)
|
|
269
|
+
|
|
270
|
+
:absence if ABSENCE_CHECK_METHODS.include?(condition.method_name)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# True if `branch` terminates on entry via an early `return` or a bare
|
|
274
|
+
# `raise`/`fail`. A multi-statement branch (a `begin`) counts when its
|
|
275
|
+
# LAST statement does.
|
|
276
|
+
def branch_exits?(branch)
|
|
277
|
+
return false unless branch
|
|
278
|
+
|
|
279
|
+
branch = branch.children.last if branch.begin_type?
|
|
280
|
+
branch&.return_type? ||
|
|
281
|
+
(branch&.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Flag conditional schema-change helpers (`add_column_if_not_exists`,
|
|
6
|
+
# `column_exists?`, etc.) inside migration files.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# Migrations are deterministic state transitions: "DB was at state N;
|
|
10
|
+
# after this migration it is at state N+1." Conditional schema helpers
|
|
11
|
+
# imply "I don't know what state the DB is in," which contradicts the
|
|
12
|
+
# migration model and hides schema drift.
|
|
13
|
+
#
|
|
14
|
+
# If a column "might already exist," that is a symptom — investigate
|
|
15
|
+
# why before papering over it with a defensive guard.
|
|
16
|
+
#
|
|
17
|
+
# The escape hatch is a per-line `rubocop:disable` comment with a
|
|
18
|
+
# rationale explaining the known-drift repair.
|
|
19
|
+
#
|
|
20
|
+
# ❌ hides drift; state is unknown
|
|
21
|
+
# add_column_if_not_exists :users, :something, :string
|
|
22
|
+
# add_column :users, :bar, :string unless column_exists?(:users, :bar)
|
|
23
|
+
#
|
|
24
|
+
# ✔️ declarative state transition
|
|
25
|
+
# add_column :users, :something, :string
|
|
26
|
+
#
|
|
27
|
+
# ✔️ documented one-shot drift repair (escape hatch)
|
|
28
|
+
# # rubocop:disable DevDoc/Migration/AvoidConditionalSchemaChanges
|
|
29
|
+
# add_column_if_not_exists :users, :something, :string
|
|
30
|
+
# # rubocop:enable DevDoc/Migration/AvoidConditionalSchemaChanges
|
|
31
|
+
#
|
|
32
|
+
# @example
|
|
33
|
+
# # bad
|
|
34
|
+
# add_column_if_not_exists :users, :something, :string
|
|
35
|
+
#
|
|
36
|
+
# # bad
|
|
37
|
+
# add_index_if_not_exists :users, :email
|
|
38
|
+
#
|
|
39
|
+
# # bad
|
|
40
|
+
# remove_column_if_exists :users, :legacy
|
|
41
|
+
#
|
|
42
|
+
# # bad (predicate guard shape)
|
|
43
|
+
# add_column :users, :foo, :string unless column_exists?(:users, :foo)
|
|
44
|
+
#
|
|
45
|
+
# # good
|
|
46
|
+
# add_column :users, :something, :string
|
|
47
|
+
class AvoidConditionalSchemaChanges < Base
|
|
48
|
+
IF_NOT_EXISTS_METHODS = %i[
|
|
49
|
+
add_column_if_not_exists
|
|
50
|
+
add_index_if_not_exists
|
|
51
|
+
add_foreign_key_if_not_exists
|
|
52
|
+
add_reference_if_not_exists
|
|
53
|
+
remove_column_if_exists
|
|
54
|
+
remove_index_if_exists
|
|
55
|
+
remove_foreign_key_if_exists
|
|
56
|
+
remove_reference_if_exists
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
59
|
+
EXISTENCE_PREDICATES = %i[
|
|
60
|
+
column_exists?
|
|
61
|
+
table_exists?
|
|
62
|
+
index_exists?
|
|
63
|
+
foreign_key_exists?
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
MSG_IF_NOT_EXISTS =
|
|
67
|
+
'`%<method>s` hides schema drift. Use the non-conditional form and investigate ' \
|
|
68
|
+
'why states diverge. Suppress with a `rubocop:disable` comment only for documented ' \
|
|
69
|
+
'one-shot drift repairs.'.freeze
|
|
70
|
+
|
|
71
|
+
MSG_PREDICATE =
|
|
72
|
+
'`%<method>s` guard hides schema drift. Use unconditional schema operations and ' \
|
|
73
|
+
'investigate why states diverge. Suppress with a `rubocop:disable` comment only for ' \
|
|
74
|
+
'documented one-shot drift repairs.'.freeze
|
|
75
|
+
|
|
76
|
+
def on_send(node)
|
|
77
|
+
if IF_NOT_EXISTS_METHODS.include?(node.method_name)
|
|
78
|
+
add_offense(node.loc.selector,
|
|
79
|
+
message: format(MSG_IF_NOT_EXISTS, method: node.method_name))
|
|
80
|
+
elsif EXISTENCE_PREDICATES.include?(node.method_name)
|
|
81
|
+
add_offense(node.loc.selector,
|
|
82
|
+
message: format(MSG_PREDICATE, method: node.method_name))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|