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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +235 -61
  3. data/lib/dev_doc/test/best_practice_lints.rb +31 -0
  4. data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
  5. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
  6. data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
  7. data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
  8. data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +287 -0
  9. data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
  10. data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
  11. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +1 -1
  12. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
  13. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
  14. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +2 -2
  15. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
  16. data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
  17. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
  18. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
  19. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +31 -4
  20. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
  21. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
  22. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
  23. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
  24. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
  25. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
  26. data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
  27. data/lib/rubocop/dev_doc/version.rb +1 -1
  28. 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