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,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,230 @@
|
|
|
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
|
+
# ❌ Safe navigation — hides the pre-auth nil risk
|
|
40
|
+
# def glib_load_resource
|
|
41
|
+
# @post = current_user&.posts&.find(params[:id])
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# ❌ Unguarded — crashes for anonymous visitors
|
|
45
|
+
# def glib_load_resource
|
|
46
|
+
# @post = current_user.posts.find(params[:id])
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# ## Configuration
|
|
50
|
+
# `LoadResourceMethodNames` (default: `[glib_load_resource]`) — list of
|
|
51
|
+
# method names where the guard rule applies. Projects standardising on a
|
|
52
|
+
# different lifecycle method can add it here.
|
|
53
|
+
#
|
|
54
|
+
# NOTE: The cop performs structural analysis of the method body and does
|
|
55
|
+
# not track aliasing. If you assign `current_user` to a local variable
|
|
56
|
+
# and then call methods on that variable, the cop will not flag it —
|
|
57
|
+
# reviewers must catch that pattern manually.
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# # bad — safe navigation inside load hook
|
|
61
|
+
# def glib_load_resource
|
|
62
|
+
# @post = current_user&.posts&.find(params[:id])
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# # bad — unguarded call inside load hook
|
|
66
|
+
# def glib_load_resource
|
|
67
|
+
# @post = current_user.posts.find(params[:id])
|
|
68
|
+
# end
|
|
69
|
+
#
|
|
70
|
+
# # good — guarded with early return
|
|
71
|
+
# def glib_load_resource
|
|
72
|
+
# return unless current_user
|
|
73
|
+
#
|
|
74
|
+
# @post = current_user.posts.find(params[:id])
|
|
75
|
+
# end
|
|
76
|
+
class LoadResourceCurrentUserGuard < Base
|
|
77
|
+
MSG_SAFE_NAV = 'Avoid `current_user&.` inside `%<method>s` — use ' \
|
|
78
|
+
'`return unless current_user` then `current_user.` instead.'.freeze
|
|
79
|
+
MSG_MISSING_GUARD = '`current_user` is used without a prior ' \
|
|
80
|
+
'`return unless current_user` guard in `%<method>s`. ' \
|
|
81
|
+
'This method runs before the policy, so `current_user` may be nil.'.freeze
|
|
82
|
+
|
|
83
|
+
# Methods that test current_user for nil — not calls that risk NoMethodError.
|
|
84
|
+
NIL_CHECK_METHODS = %i[nil? blank? present? empty?].freeze
|
|
85
|
+
|
|
86
|
+
def on_def(node)
|
|
87
|
+
check_load_resource_method(node)
|
|
88
|
+
end
|
|
89
|
+
alias on_defs on_def
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def load_resource_method_names
|
|
94
|
+
Array(cop_config.fetch('LoadResourceMethodNames', ['glib_load_resource'])).map(&:to_sym)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def load_resource_method?(method_name)
|
|
98
|
+
load_resource_method_names.include?(method_name.to_sym)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_load_resource_method(def_node)
|
|
102
|
+
method_name = def_node.method_name
|
|
103
|
+
return unless load_resource_method?(method_name)
|
|
104
|
+
|
|
105
|
+
body = def_node.body
|
|
106
|
+
return unless body
|
|
107
|
+
|
|
108
|
+
check_body(body, method_name)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def check_body(body, method_name)
|
|
112
|
+
body.each_descendant do |node|
|
|
113
|
+
if safe_nav_on_current_user?(node)
|
|
114
|
+
add_offense(node.loc.dot,
|
|
115
|
+
message: format(MSG_SAFE_NAV, method: method_name))
|
|
116
|
+
elsif unguarded_current_user_call?(node)
|
|
117
|
+
add_offense(node.receiver.loc.selector,
|
|
118
|
+
message: format(MSG_MISSING_GUARD, method: method_name))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# `current_user&.something` — always an offense regardless of guards.
|
|
124
|
+
def safe_nav_on_current_user?(node)
|
|
125
|
+
node.csend_type? && current_user_receiver?(node)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# `current_user.something` where `something` is not a nil check and
|
|
129
|
+
# no dominating guard protects the call.
|
|
130
|
+
def unguarded_current_user_call?(node)
|
|
131
|
+
return false unless node.send_type?
|
|
132
|
+
return false unless current_user_receiver?(node)
|
|
133
|
+
return false if nil_check_method?(node.method_name)
|
|
134
|
+
|
|
135
|
+
!dominated_by_guard?(node)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Does the direct receiver of `node` resolve to bare `current_user`?
|
|
139
|
+
def current_user_receiver?(node)
|
|
140
|
+
recv = node.receiver
|
|
141
|
+
recv&.send_type? && recv.method_name == :current_user && recv.receiver.nil?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def nil_check_method?(method_name)
|
|
145
|
+
NIL_CHECK_METHODS.include?(method_name)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Returns true if `node` is protected by a `current_user` nil-guard via:
|
|
149
|
+
# 1. Being inside an `if current_user` then-branch, OR
|
|
150
|
+
# 2. Having a preceding guard-return statement in its nearest enclosing
|
|
151
|
+
# `begin` sequence (handles flat sequences AND nested `when` branches).
|
|
152
|
+
def dominated_by_guard?(node)
|
|
153
|
+
inside_current_user_branch?(node) || preceded_by_guard?(node)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Walk ancestor `if` nodes and return true when `node` is inside the
|
|
157
|
+
# then-branch of an `if current_user` (not the else-branch).
|
|
158
|
+
def inside_current_user_branch?(node)
|
|
159
|
+
node.each_ancestor(:if) do |if_node|
|
|
160
|
+
next unless current_user_truthy_condition?(if_node.condition)
|
|
161
|
+
|
|
162
|
+
if_br = if_node.if_branch
|
|
163
|
+
return true if if_br && (if_br.equal?(node) || descendant_by_identity?(if_br, node))
|
|
164
|
+
end
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Walk up through all ancestor `begin` nodes. For each, check whether
|
|
169
|
+
# a guard-return statement appears before the statement that contains
|
|
170
|
+
# `node`. This handles both flat method bodies and nested `when` branches.
|
|
171
|
+
def preceded_by_guard?(node)
|
|
172
|
+
node.each_ancestor do |ancestor|
|
|
173
|
+
next unless ancestor.begin_type?
|
|
174
|
+
|
|
175
|
+
stmts = ancestor.children
|
|
176
|
+
container_idx = stmts.index { |s| s.equal?(node) || descendant_by_identity?(s, node) }
|
|
177
|
+
next unless container_idx
|
|
178
|
+
|
|
179
|
+
return true if stmts[0...container_idx].any? { |s| guard_statement?(s) }
|
|
180
|
+
end
|
|
181
|
+
false
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# True if `descendant` is found within `root` by object identity.
|
|
185
|
+
def descendant_by_identity?(root, descendant)
|
|
186
|
+
root.each_descendant.any? { |d| d.equal?(descendant) }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# True if `stmt` is a guard-return on `current_user`:
|
|
190
|
+
# return unless current_user — condition: current_user, one branch: (return)
|
|
191
|
+
# return if current_user.nil? — condition: current_user.nil?, one branch: (return)
|
|
192
|
+
# return if current_user.blank? — condition: current_user.blank?, one branch: (return)
|
|
193
|
+
#
|
|
194
|
+
# The parser gem swaps `if_branch`/`else_branch` for `unless`-modifier
|
|
195
|
+
# forms, so we check BOTH branches for a bare `return` rather than
|
|
196
|
+
# assuming which side it falls on.
|
|
197
|
+
def guard_statement?(stmt)
|
|
198
|
+
return false unless stmt.if_type?
|
|
199
|
+
|
|
200
|
+
condition = stmt.condition
|
|
201
|
+
return false unless current_user_truthy_condition?(condition) ||
|
|
202
|
+
current_user_nil_condition?(condition)
|
|
203
|
+
|
|
204
|
+
return_node?(stmt.if_branch) || return_node?(stmt.else_branch)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Condition is bare `current_user` (truthy check).
|
|
208
|
+
def current_user_truthy_condition?(condition)
|
|
209
|
+
condition&.send_type? &&
|
|
210
|
+
condition.method_name == :current_user &&
|
|
211
|
+
condition.receiver.nil?
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Condition is `current_user.nil?` or `current_user.blank?`.
|
|
215
|
+
def current_user_nil_condition?(condition)
|
|
216
|
+
return false unless condition&.send_type?
|
|
217
|
+
return false unless NIL_CHECK_METHODS.include?(condition.method_name)
|
|
218
|
+
|
|
219
|
+
recv = condition.receiver
|
|
220
|
+
recv&.send_type? && recv.method_name == :current_user && recv.receiver.nil?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def return_node?(node)
|
|
224
|
+
node&.return_type?
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
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
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Avoid `null: false` on regular columns.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# `null: false` on a regular column bakes a business rule (presence) into
|
|
9
|
+
# the schema. Presence belongs in the application layer (model
|
|
10
|
+
# validations), where it is easy to change.
|
|
11
|
+
#
|
|
12
|
+
# The test for whether `null: false` is justified is "what would NULL
|
|
13
|
+
# mean for this column?":
|
|
14
|
+
#
|
|
15
|
+
# - If NULL is — or could become — a meaningful business state, presence
|
|
16
|
+
# is a business decision: keep it in the model. `email` NULL = a
|
|
17
|
+
# phone-only user; `organization_id` NULL = an unowned template.
|
|
18
|
+
# - If NULL is never a meaningful state by the nature of the data, it is
|
|
19
|
+
# a data-integrity concern and belongs in the schema (see Exception).
|
|
20
|
+
#
|
|
21
|
+
# The line is drawn for standardization and non-subjectivity. Whether a
|
|
22
|
+
# regular column is "required" is subjective and invites per-column
|
|
23
|
+
# debate (`email` looks required until phone signup makes it optional),
|
|
24
|
+
# so the schema should not bake in that debatable call.
|
|
25
|
+
#
|
|
26
|
+
# ❌ Regular column
|
|
27
|
+
# add_column :users, :profile_completion_rate, :float, null: false
|
|
28
|
+
#
|
|
29
|
+
# ✔️ Regular column
|
|
30
|
+
# add_column :users, :profile_completion_rate, :float
|
|
31
|
+
#
|
|
32
|
+
# ## Exception
|
|
33
|
+
# `null: false` IS the right choice where NULL is never a meaningful
|
|
34
|
+
# state:
|
|
35
|
+
#
|
|
36
|
+
# - **Required foreign keys** — NOT flagged: this cop never looks at
|
|
37
|
+
# `belongs_to`, `references`, or `add_reference`. A required FK bundles
|
|
38
|
+
# two things: `foreign_key: true` is pure referential integrity (never
|
|
39
|
+
# a business decision), while `null: false` on the FK is a
|
|
40
|
+
# *mandatory-ness* decision that can flip (a `document` may later be an
|
|
41
|
+
# unowned template). Both are allowed in the schema pragmatically — the
|
|
42
|
+
# referential-integrity guarantee carries the mandatory-ness with it.
|
|
43
|
+
# - **Enum columns** — NULL is outside the enum's domain (a type
|
|
44
|
+
# violation), so `null: false` is required, and enforced from the model
|
|
45
|
+
# side by `DevDoc/Rails/EnumColumnNotNull`. But an enum is a plain
|
|
46
|
+
# `integer` column, statically indistinguishable from any other
|
|
47
|
+
# integer, so THIS cop cannot detect it and WILL flag it. Disable it on
|
|
48
|
+
# the line with a brief reason — `-- enum` — so the migration is
|
|
49
|
+
# self-documenting: a reader sees at a glance that the column is an enum.
|
|
50
|
+
#
|
|
51
|
+
# ✔️ Required foreign key (never flagged)
|
|
52
|
+
# t.belongs_to :user, null: false, foreign_key: true
|
|
53
|
+
#
|
|
54
|
+
# ✔️ Enum (flagged here — disable with a brief `-- enum` reason)
|
|
55
|
+
# # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
|
|
56
|
+
# add_column :orders, :status, :integer, null: false
|
|
57
|
+
# # rubocop:enable DevDoc/Migration/AvoidNonNull
|
|
58
|
+
#
|
|
59
|
+
# NOTE: This cop is deliberately NOT enum-aware. It could read the
|
|
60
|
+
# model's `enum` declarations and skip those columns, but requiring an
|
|
61
|
+
# explicit per-line disable is intentional: it forces the developer to
|
|
62
|
+
# signal that the column is an enum, which documents the migration. A
|
|
63
|
+
# silent skip would hide that intent.
|
|
64
|
+
#
|
|
65
|
+
# NOTE: This cop only flags `null: false`. It does not flag `null: true`
|
|
66
|
+
# (redundant but harmless), and it does not require foreign keys to carry
|
|
67
|
+
# `null: false` — adding it to an FK is encouraged but not enforced here.
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# # bad
|
|
71
|
+
# add_column :users, :name, :string, null: false
|
|
72
|
+
#
|
|
73
|
+
# # bad (enum without a disable — the cop flags it; disable with `-- enum`)
|
|
74
|
+
# t.integer :processing_status, null: false
|
|
75
|
+
#
|
|
76
|
+
# # good
|
|
77
|
+
# add_column :users, :name, :string
|
|
78
|
+
#
|
|
79
|
+
# # good (required foreign key — never flagged)
|
|
80
|
+
# t.belongs_to :user, null: false, foreign_key: true
|
|
81
|
+
class AvoidNonNull < Base
|
|
82
|
+
MSG = 'Avoid `null: false` on regular columns; enforce presence in the model layer. ' \
|
|
83
|
+
'If this is an enum column, disable this cop on the line with a brief reason, e.g. `-- enum`.'.freeze
|
|
84
|
+
|
|
85
|
+
# Column-definition helpers that take a `null:` option. Deliberately
|
|
86
|
+
# EXCLUDES `references` / `belongs_to` (and the separate `add_reference`
|
|
87
|
+
# method): a required foreign key SHOULD carry `null: false`, so those
|
|
88
|
+
# are never flagged.
|
|
89
|
+
COLUMN_METHODS = %i[
|
|
90
|
+
string integer float boolean datetime date text binary decimal
|
|
91
|
+
json jsonb bigint
|
|
92
|
+
].freeze
|
|
93
|
+
|
|
94
|
+
RESTRICT_ON_SEND = (COLUMN_METHODS + %i[add_column]).freeze
|
|
95
|
+
|
|
96
|
+
def_node_matcher :null_false_pair, <<~PATTERN
|
|
97
|
+
(hash <$(pair (sym :null) (false)) ...>)
|
|
98
|
+
PATTERN
|
|
99
|
+
|
|
100
|
+
def on_send(node)
|
|
101
|
+
return unless column_method?(node)
|
|
102
|
+
|
|
103
|
+
options = node.arguments.find(&:hash_type?)
|
|
104
|
+
return unless options
|
|
105
|
+
|
|
106
|
+
pair = null_false_pair(options)
|
|
107
|
+
return unless pair
|
|
108
|
+
|
|
109
|
+
add_offense(pair)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def column_method?(node)
|
|
115
|
+
node.method?(:add_column) || COLUMN_METHODS.include?(node.method_name)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|