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,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,92 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Monetary columns must be stored as integer cents with an `_in_cents` suffix.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Storing monetary values as floats or decimals introduces floating-point
|
|
9
|
+
# precision issues. The safe approach is to store values as integer cents
|
|
10
|
+
# and convert to dollars only in user-facing forms and displays.
|
|
11
|
+
#
|
|
12
|
+
# This cop is heuristic: it matches column names whose last segment is a
|
|
13
|
+
# known monetary word (configurable via `MonetaryNames`). Extend the list
|
|
14
|
+
# in your `.rubocop.yml` if your domain uses different names.
|
|
15
|
+
#
|
|
16
|
+
# ❌
|
|
17
|
+
# t.float :amount
|
|
18
|
+
# t.decimal :price
|
|
19
|
+
# add_column :orders, :total, :decimal
|
|
20
|
+
#
|
|
21
|
+
# ✔️
|
|
22
|
+
# t.integer :amount_in_cents
|
|
23
|
+
# t.integer :price_in_cents
|
|
24
|
+
# add_column :orders, :total_in_cents, :integer
|
|
25
|
+
#
|
|
26
|
+
# To extend the monetary names list:
|
|
27
|
+
#
|
|
28
|
+
# DevDoc/Migration/AmountColumnInCents:
|
|
29
|
+
# Enabled: true
|
|
30
|
+
# MonetaryNames:
|
|
31
|
+
# - amount
|
|
32
|
+
# - price
|
|
33
|
+
# - balance
|
|
34
|
+
# - cost
|
|
35
|
+
# - fee
|
|
36
|
+
# - total
|
|
37
|
+
# - subtotal
|
|
38
|
+
# - discount
|
|
39
|
+
# - tax
|
|
40
|
+
# - revenue # custom addition
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# # bad
|
|
44
|
+
# t.float :amount
|
|
45
|
+
# t.decimal :price
|
|
46
|
+
# add_column :orders, :total, :decimal
|
|
47
|
+
#
|
|
48
|
+
# # good
|
|
49
|
+
# t.integer :amount_in_cents
|
|
50
|
+
# t.integer :price_in_cents
|
|
51
|
+
# add_column :orders, :total_in_cents, :integer
|
|
52
|
+
class AmountColumnInCents < Base
|
|
53
|
+
DEFAULT_MONETARY_NAMES = %w[amount price balance cost fee total subtotal discount tax].freeze
|
|
54
|
+
|
|
55
|
+
MSG = 'Store monetary values as integer cents — ' \
|
|
56
|
+
'rename `%<name>s` to `%<name>s_in_cents` and use `t.integer`.'.freeze
|
|
57
|
+
|
|
58
|
+
COLUMN_METHODS = %i[float decimal integer].freeze
|
|
59
|
+
|
|
60
|
+
def on_send(node)
|
|
61
|
+
col_name_node = column_name_node(node)
|
|
62
|
+
return unless col_name_node&.sym_type?
|
|
63
|
+
|
|
64
|
+
col_name = col_name_node.value.to_s
|
|
65
|
+
return if col_name.end_with?('_in_cents')
|
|
66
|
+
return unless monetary_suffix?(col_name)
|
|
67
|
+
|
|
68
|
+
add_offense(col_name_node, message: format(MSG, name: col_name))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def column_name_node(node)
|
|
74
|
+
if node.method?(:add_column)
|
|
75
|
+
node.arguments[1]
|
|
76
|
+
elsif COLUMN_METHODS.include?(node.method_name)
|
|
77
|
+
node.first_argument
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def monetary_suffix?(name)
|
|
82
|
+
monetary_names.include?(name.split('_').last)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def monetary_names
|
|
86
|
+
cop_config.fetch('MonetaryNames', DEFAULT_MONETARY_NAMES)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Migration
|
|
5
|
+
# Avoid methods that bypass validations and callbacks.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Avoid bypassing validation unless absolutely necessary. Methods like
|
|
9
|
+
# `update_column`, `update_all`, `insert_all`, `upsert_all`, `delete_all`,
|
|
10
|
+
# and `save(validate: false)` skip validations and callbacks, which hides
|
|
11
|
+
# data integrity issues rather than surfacing them.
|
|
12
|
+
#
|
|
13
|
+
# Even in migrations, check the code to see if there is any blatant
|
|
14
|
+
# reason why existing records may be invalid. If there is, fix those
|
|
15
|
+
# records first rather than bypassing validation.
|
|
16
|
+
#
|
|
17
|
+
# ❌ Bypasses validation — hides data integrity issues
|
|
18
|
+
# Faq.where(purpose: nil).update_all(purpose: :intro)
|
|
19
|
+
#
|
|
20
|
+
# ✔️ Runs validation — surfaces problems early
|
|
21
|
+
# Faq.where(purpose: nil).find_each do |faq|
|
|
22
|
+
# faq.purpose = :intro
|
|
23
|
+
# faq.save!
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# # bad
|
|
28
|
+
# Faq.where(purpose: nil).update_all(purpose: :intro)
|
|
29
|
+
#
|
|
30
|
+
# # bad
|
|
31
|
+
# user.update_column(:status, 'active')
|
|
32
|
+
#
|
|
33
|
+
# # bad
|
|
34
|
+
# user.save(validate: false)
|
|
35
|
+
#
|
|
36
|
+
# # bad
|
|
37
|
+
# User.insert_all(rows)
|
|
38
|
+
#
|
|
39
|
+
# # good
|
|
40
|
+
# Faq.where(purpose: nil).find_each do |faq|
|
|
41
|
+
# faq.purpose = :intro
|
|
42
|
+
# faq.save!
|
|
43
|
+
# end
|
|
44
|
+
class AvoidBypassingValidation < Base
|
|
45
|
+
MESSAGES = {
|
|
46
|
+
update_column: 'Avoid `update_column`; it bypasses validations. Use `save!` instead.',
|
|
47
|
+
update_columns: 'Avoid `update_columns`; it bypasses validations. Use `save!` instead.',
|
|
48
|
+
update_all: 'Avoid `update_all`; it bypasses validations. Use `save!` in a loop instead.',
|
|
49
|
+
insert_all: 'Avoid `insert_all`; it bypasses validations. Use `create!` in a loop, ' \
|
|
50
|
+
'or `# rubocop:disable` with a reason if bulk-insert is intentional.',
|
|
51
|
+
upsert_all: 'Avoid `upsert_all`; it bypasses validations. Use `create!`/`update!` in a loop, ' \
|
|
52
|
+
'or `# rubocop:disable` with a reason if bulk-upsert is intentional.',
|
|
53
|
+
delete_all: 'Avoid `delete_all`; it bypasses callbacks. Use `destroy_all` to run callbacks, ' \
|
|
54
|
+
'or `# rubocop:disable` with a reason if bulk-delete is intentional.'
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
SAVE_MSG = 'Avoid `save(validate: false)`; it bypasses validations. Use `save!` instead.'.freeze
|
|
58
|
+
|
|
59
|
+
RESTRICT_ON_SEND = %i[update_column update_columns update_all insert_all upsert_all delete_all save].freeze
|
|
60
|
+
|
|
61
|
+
def on_send(node)
|
|
62
|
+
if node.method?(:save)
|
|
63
|
+
return unless save_with_validate_false?(node)
|
|
64
|
+
|
|
65
|
+
add_offense(node.loc.selector, message: SAVE_MSG)
|
|
66
|
+
else
|
|
67
|
+
add_offense(node.loc.selector, message: MESSAGES[node.method_name])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def save_with_validate_false?(node)
|
|
74
|
+
node.arguments.any? do |arg|
|
|
75
|
+
next unless arg.hash_type?
|
|
76
|
+
|
|
77
|
+
arg.pairs.any? do |pair|
|
|
78
|
+
pair.key.sym_type? && pair.key.value == :validate && pair.value.false_type?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|