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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +230 -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 +230 -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,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
@@ -12,7 +12,7 @@ module RuboCop
12
12
  # particular model.
13
13
  #
14
14
  # ❌ (in a controller or service)
15
- # Checklist.transaction do
15
+ # Post.transaction do
16
16
  # ...
17
17
  # end
18
18
  #