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,129 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag `def` / `define_method` whose enclosing scope chain does not
6
+ # include a `class` or `module` body — the method lands on `Object`.
7
+ #
8
+ # ## Rationale
9
+ # A `def` not inside an explicit `class` or `module` body defines a
10
+ # method on `Object`, even when it visually looks scoped. The most common
11
+ # failure mode is inside Rake's `namespace` block:
12
+ #
13
+ # ❌ Two rake files both define `build_load_plan` — whichever file
14
+ # loads second silently wins. Tests for one task call the other
15
+ # task's helper logic without warning.
16
+ # namespace :faqs do
17
+ # def build_load_plan(fixtures, by_slug)
18
+ # ...
19
+ # end
20
+ # end
21
+ #
22
+ # ✔️ Wrapped in a real Ruby scope — no collision risk.
23
+ # module FaqsLoader
24
+ # extend self
25
+ #
26
+ # def build_load_plan(fixtures, by_slug)
27
+ # ...
28
+ # end
29
+ # end
30
+ #
31
+ # Core's `Style/TopLevelMethodDefinition` catches literal top-level
32
+ # `def` (not inside any block) but misses the rake `namespace` pattern
33
+ # because the `def` is technically inside a `block` node. This cop
34
+ # subsumes that case — disable `Style/TopLevelMethodDefinition` when
35
+ # this cop is enabled to avoid double-flagging.
36
+ #
37
+ # ## Allowlist
38
+ # Some DSLs legitimately define methods inside blocks where the block's
39
+ # receiver is not `Object` (`Struct.new`, `Class.new`, `Module.new`).
40
+ # Configure `SafeDSLReceivers` to extend the allowlist.
41
+ #
42
+ # @example
43
+ # # bad — literal top-level def
44
+ # def helper_method
45
+ # end
46
+ #
47
+ # # bad — inside a rake namespace (lands on Object)
48
+ # namespace :faqs do
49
+ # def build_load_plan(fixtures, by_slug)
50
+ # end
51
+ # end
52
+ #
53
+ # # good — inside an explicit module
54
+ # module FaqsLoader
55
+ # extend self
56
+ #
57
+ # def build_load_plan(fixtures, by_slug)
58
+ # end
59
+ # end
60
+ #
61
+ # # good — inside an explicit class
62
+ # class FaqsImporter
63
+ # def import
64
+ # end
65
+ # end
66
+ #
67
+ # # good — Struct.new block (allowlisted by default)
68
+ # Point = Struct.new(:x, :y) do
69
+ # def distance
70
+ # end
71
+ # end
72
+ class NoUnscopedMethodDefinitions < Base
73
+ MSG = 'Define methods inside an explicit `module` or `class`, not at the top level ' \
74
+ 'or inside a DSL block (e.g. Rake `namespace`). ' \
75
+ 'Methods defined here land on `Object` and can silently collide across files.'.freeze
76
+
77
+ DEFAULT_SAFE_DSL_RECEIVERS = %w[Struct Class Module].freeze
78
+
79
+ def on_def(node)
80
+ add_offense(node.loc.keyword) unless enclosed_in_class_or_module?(node)
81
+ end
82
+ alias on_defs on_def
83
+
84
+ def on_send(node)
85
+ return unless node.method_name == :define_method
86
+ return if enclosed_in_class_or_module?(node)
87
+
88
+ add_offense(node.loc.selector)
89
+ end
90
+
91
+ private
92
+
93
+ def enclosed_in_class_or_module?(node)
94
+ node.each_ancestor.any? do |ancestor|
95
+ next true if ancestor.class_type? || ancestor.module_type?
96
+ next true if safe_dsl_block?(ancestor)
97
+
98
+ false
99
+ end
100
+ end
101
+
102
+ # A `block` node is safe when its method call receiver is an
103
+ # allowlisted DSL (Struct.new, Class.new, Module.new, etc.)
104
+ def safe_dsl_block?(node)
105
+ return false unless node.block_type?
106
+
107
+ send_node = node.send_node
108
+ receiver = send_node.receiver
109
+
110
+ return false if receiver.nil?
111
+
112
+ safe_receivers.any? do |safe|
113
+ receiver_matches?(receiver, safe)
114
+ end
115
+ end
116
+
117
+ def receiver_matches?(receiver, safe_name)
118
+ # Handles `Struct`, `Class`, `Module` (const nodes)
119
+ receiver.const_type? && receiver.short_name.to_s == safe_name
120
+ end
121
+
122
+ def safe_receivers
123
+ DEFAULT_SAFE_DSL_RECEIVERS + Array(cop_config.fetch('SafeDSLReceivers', []))
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,150 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Avoid reading `obj[key]` more than once with the same receiver and
6
+ # same key in a single method body.
7
+ #
8
+ # ## Rationale
9
+ # When the same bracket read appears in multiple places, two distinct
10
+ # failure modes open up:
11
+ #
12
+ # 1. **Silent typos.** Bracket access returns nil for missing keys;
13
+ # nothing raises. Two occurrences of `params[:status]` keep the
14
+ # spelling in sync, but if one of them silently becomes
15
+ # `params[:stutus]`, the line returns nil and the bug ships. A
16
+ # single assignment fixes the spelling in exactly one place — a
17
+ # typo there becomes a `NameError`, not a silent nil.
18
+ #
19
+ # 2. **Reader ambiguity and mutation risk.** The reader has to verify
20
+ # every occurrence really is the same key and that nothing in
21
+ # between mutates the hash. Assigning once makes the value a
22
+ # stable named thing — and on receivers like `session` or shared
23
+ # hashes, it also avoids a real TOCTOU shape where the value can
24
+ # change between the guard and the use.
25
+ #
26
+ # ❌
27
+ # def show
28
+ # @item = Item.lookup_by_slug(params[:slug])
29
+ # redirect_to canonical_url(@item) if @item.slug != params[:slug]
30
+ # end
31
+ #
32
+ # ✔️
33
+ # def show
34
+ # slug = params[:slug]
35
+ # @item = Item.lookup_by_slug(slug)
36
+ # redirect_to canonical_url(@item) if @item.slug != slug
37
+ # end
38
+ #
39
+ # The cop only counts reads. `obj[k] = v` is a write (`:[]=`) and is
40
+ # not compared against bracket reads of the same key.
41
+ #
42
+ # ## Exception
43
+ # Genuine intentional re-reads (e.g. a deliberate second check after
44
+ # a write that may have mutated the receiver) go through inline
45
+ # `# rubocop:disable` with a reason.
46
+ #
47
+ # NOTE: Receiver and key are compared by source text — `hash['foo']`
48
+ # and `hash[:foo]` look different to the cop even though they're the
49
+ # same value on a `HashWithIndifferentAccess` like `params`. That is
50
+ # a known false negative; the cop will miss that shape rather than
51
+ # over-fire.
52
+ #
53
+ # NOTE: Scope is the enclosing `def`/`defs` body, including nested
54
+ # blocks. Block parameters are scoped to their block, so the same name
55
+ # in two sibling blocks (`arr.each { |item| item[:k] }; other.each {
56
+ # |item| item[:k] }`) is correctly treated as two different bindings,
57
+ # not a repeat. Reads of the same block param *within one block* are
58
+ # still flagged. (Numbered/`it` block params are matched textually — a
59
+ # rare residual false positive; inline-disable if it surfaces.)
60
+ #
61
+ # NOTE: Multi-argument bracket calls (`arr[i, len]`, slicing) are
62
+ # ignored. Only single-argument reads participate.
63
+ #
64
+ # @example
65
+ # # bad — same key read twice
66
+ # def show
67
+ # @item = Item.lookup_by_slug(params[:slug])
68
+ # redirect_to canonical_url(@item) if @item.slug != params[:slug]
69
+ # end
70
+ #
71
+ # # bad — guard-then-use re-reads the receiver
72
+ # def session_get(key)
73
+ # return nil unless session[key]
74
+ # session[key].symbolize_keys
75
+ # end
76
+ #
77
+ # # good — assign once
78
+ # def session_get(key)
79
+ # payload = session[key]
80
+ # return nil unless payload
81
+ # payload.symbolize_keys
82
+ # end
83
+ #
84
+ # # good — different keys, no offense
85
+ # def filters
86
+ # @search = params[:search]
87
+ # @status = params[:status]
88
+ # end
89
+ class RepeatedBracketRead < Base
90
+ MSG = "Receiver `%<receiver>s[%<key>s]` already read earlier in this method — assign once and reuse.".freeze
91
+
92
+ def on_def(node)
93
+ check_method(node)
94
+ end
95
+ alias on_defs on_def
96
+
97
+ private
98
+
99
+ def check_method(def_node)
100
+ body = def_node.body
101
+ return unless body
102
+
103
+ seen = {}
104
+ body.each_descendant(:send) do |send_node|
105
+ next unless bracket_read?(send_node)
106
+
107
+ receiver = send_node.receiver
108
+ key = send_node.first_argument
109
+ composite = composite_key(receiver, key)
110
+
111
+ if seen[composite]
112
+ add_offense(send_node.loc.selector, message: format(MSG, receiver: receiver.source, key: key.source))
113
+ else
114
+ seen[composite] = true
115
+ end
116
+ end
117
+ end
118
+
119
+ # Block parameters are scoped to their block: the same name read in two
120
+ # sibling blocks is a *different* binding, not a repeat. Key those reads
121
+ # by their binding block so they don't collide. Method-call / ivar /
122
+ # method-local receivers keep a purely textual key.
123
+ def composite_key(receiver, key_node)
124
+ key = key_node.source
125
+ block = binding_block(receiver)
126
+ block ? "#{block.object_id}|#{receiver.source}|#{key}" : "#{receiver.source}|#{key}"
127
+ end
128
+
129
+ # The nearest enclosing block that binds the receiver as a block
130
+ # argument, or nil when the receiver is not a block-local variable.
131
+ def binding_block(receiver)
132
+ return nil unless receiver.lvar_type?
133
+
134
+ name = receiver.children.first
135
+ receiver.each_ancestor(:block) do |block|
136
+ return block if block.arguments.any? { |arg| arg.respond_to?(:name) && arg.name == name }
137
+ end
138
+ nil
139
+ end
140
+
141
+ def bracket_read?(node)
142
+ node.method_name == :[] &&
143
+ node.receiver &&
144
+ node.arguments.size == 1
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,118 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Avoid using `&.` on the same receiver more than once in a method body.
6
+ #
7
+ # ## Rationale
8
+ # When a receiver appears with `&.` repeatedly across a method, the
9
+ # reader has to mentally re-evaluate the nil case at every call, and
10
+ # the *intent* of each `&.` becomes ambiguous — is this call nullable
11
+ # for a fresh reason, or is the dev just mirroring the earlier `&.`
12
+ # out of habit? Repetition also propagates nil silently into
13
+ # downstream comparisons (`current_user&.id == something` evaluates
14
+ # to `nil == something`, which is false but not for the reason the
15
+ # reader expects).
16
+ #
17
+ # Resolve the nil case once at the top of the method, then use the
18
+ # local with plain `.`:
19
+ #
20
+ # ❌
21
+ # def show?
22
+ # current_user&.super_admin? ||
23
+ # current_user&.developer? ||
24
+ # current_user&.admin_of_organization?(organization)
25
+ # end
26
+ #
27
+ # ✔️ Guard once, then plain calls
28
+ # def show?
29
+ # return false unless current_user
30
+ #
31
+ # current_user.super_admin? ||
32
+ # current_user.developer? ||
33
+ # current_user.admin_of_organization?(organization)
34
+ # end
35
+ #
36
+ # ✔️ Alternative — assign-and-test in the condition
37
+ # def label
38
+ # if (user = current_user)
39
+ # "#{user.full_name} <#{user.email}>"
40
+ # end
41
+ # end
42
+ #
43
+ # The cop is policy-safe by design: it does not assume anything
44
+ # about `current_user` non-nullability or branching semantics — it
45
+ # only flags repeated `&.` on the same receiver, which is a smell
46
+ # regardless of whether the receiver is `current_user`, a model
47
+ # attribute, or a local variable.
48
+ #
49
+ # ## Exception
50
+ # Legitimate cases (e.g. when the repeated calls are conceptually
51
+ # distinct sources that happen to share source spelling) go through
52
+ # inline `# rubocop:disable` with a reason.
53
+ #
54
+ # NOTE: Receivers are compared by source text — two `&.` calls
55
+ # share a receiver if their source spelling matches verbatim. The
56
+ # cop does not perform flow analysis, so a receiver reassigned
57
+ # between uses is not detected (false negative).
58
+ #
59
+ # NOTE: Scope is the enclosing `def`/`defs` body. The cop does not
60
+ # partition by inner blocks, so the same parameter name reused
61
+ # across separate block bodies in one method may produce a false
62
+ # positive — inline-disable with a reason if it surfaces.
63
+ #
64
+ # @example
65
+ # # bad — same receiver, multiple safe-nav reads
66
+ # def card
67
+ # name = current_user&.full_name
68
+ # email = current_user&.email
69
+ # end
70
+ #
71
+ # # bad — same receiver, multiple safe-nav predicates
72
+ # def show?
73
+ # user&.super_admin? || user&.developer?
74
+ # end
75
+ #
76
+ # # good — assign once, then plain calls
77
+ # def show?
78
+ # return false unless user
79
+ #
80
+ # user.super_admin? || user.developer?
81
+ # end
82
+ #
83
+ # # good — single use of safe-nav
84
+ # def label
85
+ # current_user&.full_name
86
+ # end
87
+ class RepeatedSafeNavigationReceiver < Base
88
+ MSG = "Receiver `%<receiver>s` already used with `&.` earlier in this method — assign once and use `.` after.".freeze
89
+
90
+ def on_def(node)
91
+ check_method(node)
92
+ end
93
+ alias on_defs on_def
94
+
95
+ private
96
+
97
+ def check_method(def_node)
98
+ body = def_node.body
99
+ return unless body
100
+
101
+ seen = {}
102
+ body.each_descendant(:csend) do |csend|
103
+ receiver = csend.receiver
104
+ next unless receiver
105
+
106
+ key = receiver.source
107
+ if seen[key]
108
+ add_offense(csend.loc.dot, message: format(MSG, receiver: key))
109
+ else
110
+ seen[key] = true
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,53 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # Flag `glib_travel_freeze` calls in test files — use `glib_travel` instead.
6
+ #
7
+ # ## Rationale
8
+ # `glib_travel_freeze` stops the clock completely, which is not representative
9
+ # of real-world behavior: in production, time ticks as code executes.
10
+ # We have had bugs that went undetected in tests because `glib_travel_freeze`
11
+ # masked timing issues, only to surface in production.
12
+ #
13
+ # `glib_travel` advances time normally inside the block — use it instead.
14
+ # Reserve `glib_travel_freeze` only as a last resort when `glib_travel`
15
+ # truly cannot work for a specific test, and document why.
16
+ #
17
+ # ## Escape hatch
18
+ # Disable per-line with a comment explaining why `glib_travel` doesn't work:
19
+ #
20
+ # glib_travel_freeze(time) do # rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze
21
+ # # Reason: <explanation>
22
+ # end
23
+ #
24
+ # @example
25
+ # # bad
26
+ # glib_travel_freeze(Time.current) do
27
+ # expect(order.expired?).to be true
28
+ # end
29
+ #
30
+ # # bad (non-block form)
31
+ # glib_travel_freeze(Time.current)
32
+ # expect(order.expired?).to be true
33
+ #
34
+ # # good
35
+ # glib_travel(Time.current) do
36
+ # expect(order.expired?).to be true
37
+ # end
38
+ class AvoidGlibTravelFreeze < Base
39
+ MSG = 'Avoid `glib_travel_freeze` — use `glib_travel` instead. ' \
40
+ 'Frozen time masks timing bugs that surface in production. ' \
41
+ 'Use `# rubocop:disable DevDoc/Test/AvoidGlibTravelFreeze` ' \
42
+ 'with a comment explaining why `glib_travel` cannot work for this test.'
43
+
44
+ RESTRICT_ON_SEND = %i[glib_travel_freeze].freeze
45
+
46
+ def on_send(node)
47
+ add_offense(node.loc.selector)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,66 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # Prefer controller tests; flag unit/service tests (`< ActiveSupport::TestCase`).
6
+ #
7
+ # ## Rationale
8
+ # What the user sees and experiences is what matters; internal
9
+ # implementation does not. A controller test exercises behaviour
10
+ # end-to-end through the same path a user takes, so it catches the
11
+ # regressions that actually reach production. A unit/service test is only
12
+ # needed when a code path genuinely cannot be reached through a
13
+ # controller test (very rare) — e.g. a search-ranking detail the
14
+ # controller never exposes.
15
+ #
16
+ # This cop flags only the literal `< ActiveSupport::TestCase`
17
+ # superclass. The blessed blackbox bases —
18
+ # `ActionDispatch::IntegrationTest`, `Glib::IntegrationTest`,
19
+ # `ActionMailer::TestCase`, `ActiveJob::TestCase` — are NOT flagged, even
20
+ # though they inherit from `ActiveSupport::TestCase` transitively.
21
+ #
22
+ # ## Escape hatch
23
+ # When a unit test is genuinely necessary, suppress with a reason that
24
+ # explains why a controller test can't cover the path. That reason IS the
25
+ # required justification — keep it specific and reviewable:
26
+ #
27
+ # # rubocop:disable DevDoc/Test/AvoidUnitTest -- search ranking isn't visible through the controller
28
+ # class Ai::Retrieval::PgSearchStrategyTest < ActiveSupport::TestCase
29
+ # # ...
30
+ # end
31
+ # # rubocop:enable DevDoc/Test/AvoidUnitTest
32
+ #
33
+ # NOTE: The cop matches the *direct* superclass only. A project base
34
+ # (`class ApplicationServiceTest < ActiveSupport::TestCase`) is flagged
35
+ # once (justify it there); subclasses of that base are not re-flagged.
36
+ #
37
+ # @example
38
+ # # bad
39
+ # class NoteRenderingTest < ActiveSupport::TestCase
40
+ # end
41
+ #
42
+ # # good — exercised through the controller
43
+ # class NotesControllerTest < ActionDispatch::IntegrationTest
44
+ # end
45
+ #
46
+ # # good — genuinely unit-only, justified with a reason
47
+ # # rubocop:disable DevDoc/Test/AvoidUnitTest -- <why a controller test can't cover this>
48
+ # class SomeServiceTest < ActiveSupport::TestCase
49
+ # end
50
+ # # rubocop:enable DevDoc/Test/AvoidUnitTest
51
+ class AvoidUnitTest < Base
52
+ MSG = 'Prefer a controller test — unit tests are a rare exception. If a controller test ' \
53
+ 'genuinely cannot cover this path, disable this cop on the class with a reason.'.freeze
54
+
55
+ def on_class(node)
56
+ superclass = node.parent_class
57
+ return unless superclass&.const_type?
58
+ return unless superclass.const_name == 'ActiveSupport::TestCase'
59
+
60
+ add_offense(superclass)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end