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,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
@@ -0,0 +1,179 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # Controller tests that assert on the rendered `response.body` should
6
+ # also snapshot the full response with `response_assert_equal`.
7
+ #
8
+ # ## Rationale
9
+ # `response_assert_equal` (glib-web's `Glib::TestHelpers`) is the most
10
+ # blackbox assertion available — it snapshots the entire rendered
11
+ # response, so it catches regressions anywhere in the output, not just
12
+ # the one substring a targeted assertion happens to check. Per the
13
+ # testing best practice, happy- and unhappy-path controller tests
14
+ # should always use it.
15
+ #
16
+ # The common slip this cop guards against: a test inspects the rendered
17
+ # body with `assert_match` / `assert_no_match` / `assert_includes` /
18
+ # `response.body.scan(...)` but forgets the snapshot. The targeted
19
+ # assertion documents intent, but on its own it only proves the one
20
+ # line — collateral changes elsewhere in the response slip through.
21
+ #
22
+ # ❌ Inspects the body, but no snapshot
23
+ # test 'index lists the item' do
24
+ # get items_url(format: :json)
25
+ # assert_response :success
26
+ # assert_match 'Widget', response.body
27
+ # end
28
+ #
29
+ # ✔️ Keep the focused assertion AND snapshot the whole response
30
+ # test 'index lists the item' do
31
+ # get items_url(format: :json)
32
+ # assert_response :success
33
+ # assert_match 'Widget', response.body
34
+ # response_assert_equal
35
+ # end
36
+ #
37
+ # ## What is NOT flagged
38
+ # - Tests that already call `response_assert_equal`.
39
+ # - Exception/redirect paths — a test asserting a non-success status
40
+ # (`assert_response :not_found`, `:forbidden`, `:redirect`, etc.)
41
+ # often has no stable rendered body to snapshot.
42
+ # - State-change tests that never read `response.body` (e.g.
43
+ # `assert_difference 'Model.count'`, mailer assertions). Passing
44
+ # `response.body` to a helper like `submit_form(response.body, ...)`
45
+ # is not a body assertion and is not flagged.
46
+ #
47
+ # ## Inline disable
48
+ # For the rare happy-path test whose response is genuinely
49
+ # non-deterministic and cannot be made stable, add a
50
+ # `rubocop:disable DevDoc/Test/ResponseAssertEqual` comment to the
51
+ # `test '...' do` line, with a written reason on the next line
52
+ # (e.g. "chunked response includes a per-run boundary token").
53
+ #
54
+ # @example
55
+ # # bad
56
+ # test 'shows the row' do
57
+ # get foo_url(format: :json)
58
+ # assert_match 'bar', response.body
59
+ # end
60
+ #
61
+ # # good
62
+ # test 'shows the row' do
63
+ # get foo_url(format: :json)
64
+ # assert_match 'bar', response.body
65
+ # response_assert_equal
66
+ # end
67
+ class ResponseAssertEqual < Base
68
+ MSG = 'Asserts on `response.body` but never calls `response_assert_equal`. ' \
69
+ 'Add the snapshot assertion (usually last) — it captures the whole rendered ' \
70
+ 'response, a stronger guard than a targeted body assertion. Exception-path ' \
71
+ 'tests that cannot snapshot may disable this cop with a reason.'.freeze
72
+
73
+ # @!method test_block?(node)
74
+ def_node_matcher :test_block?, <<~PATTERN
75
+ (block (send nil? :test ...) _args _body)
76
+ PATTERN
77
+
78
+ # @!method response_body_read?(node)
79
+ def_node_matcher :response_body_read?, <<~PATTERN
80
+ (send (send nil? :response) {:body :parsed_body})
81
+ PATTERN
82
+
83
+ # @!method calls_snapshot?(node)
84
+ def_node_search :calls_snapshot?, <<~PATTERN
85
+ (send nil? :response_assert_equal)
86
+ PATTERN
87
+
88
+ # @!method response_status?(node)
89
+ def_node_matcher :response_status?, '(send (send nil? :response) :status)'
90
+
91
+ # Non-success HTTP statuses (symbol form). A test asserting any of
92
+ # these is an exception / redirect / empty-body path — no JSON body
93
+ # to snapshot.
94
+ NON_SUCCESS_STATUS_SYMBOLS = %i[
95
+ not_found forbidden unauthorized unprocessable_entity unprocessable_content
96
+ bad_request conflict gone no_content not_modified redirect moved_permanently
97
+ found see_other payment_required too_many_requests precondition_failed
98
+ ].freeze
99
+
100
+ def on_block(node)
101
+ return unless test_block?(node)
102
+ return unless node.body
103
+ return if calls_snapshot?(node)
104
+ return if exempt_from_snapshot?(node)
105
+ return unless inspects_response_body?(node)
106
+
107
+ add_offense(node.send_node.loc.selector)
108
+ end
109
+
110
+ private
111
+
112
+ # Exception / redirect / empty-body responses can't (and shouldn't) be
113
+ # snapshotted. Handles both the `assert_response :symbol` convention
114
+ # and the numeric `assert_response 404` / `assert_equal 404,
115
+ # response.status` convention, plus `assert_empty response.body`.
116
+ def exempt_from_snapshot?(test_node)
117
+ test_node.each_descendant(:send).any? { |s| non_snapshotable_assertion?(s) }
118
+ end
119
+
120
+ def non_snapshotable_assertion?(send_node)
121
+ case send_node.method_name
122
+ when :assert_response then non_success_status?(send_node.first_argument)
123
+ when :assert_equal then non_success_status_equal?(send_node)
124
+ when :assert_empty then response_body_read?(send_node.first_argument)
125
+ else false
126
+ end
127
+ end
128
+
129
+ # `assert_equal <non-2xx code>, response.status`
130
+ def non_success_status_equal?(send_node)
131
+ args = send_node.arguments
132
+ args.size >= 2 && non_success_status?(args[0]) && response_status?(args[1])
133
+ end
134
+
135
+ # A non-success status: a symbol (`:not_found`) or a numeric code
136
+ # >= 300 (`404`). 2xx codes are NOT exempt — they should snapshot.
137
+ def non_success_status?(node)
138
+ return false unless node
139
+
140
+ (node.sym_type? && NON_SUCCESS_STATUS_SYMBOLS.include?(node.value)) ||
141
+ (node.int_type? && node.value >= 300)
142
+ end
143
+
144
+ # True when the test inspects the response *as JSON*. Three forms count:
145
+ # - `response.parsed_body` (any use — it's the JSON-parsed body)
146
+ # - `JSON.parse(response.body)` (explicit JSON parse, even if the
147
+ # result is stored in a local first)
148
+ # - `response.body` directly inside an assertion
149
+ # (`assert_match 'x', response.body`)
150
+ #
151
+ # Deliberately NOT counted: `response.body` fed to a format-specific
152
+ # parser (`parse_xlsx`, `CSV.parse`, `Nokogiri…parse`) or a render/submit
153
+ # helper (`submit_form`) — those are non-JSON or pass-through, and
154
+ # `response_assert_equal` (JSON-only) can't snapshot them.
155
+ def inspects_response_body?(test_node)
156
+ test_node.each_descendant(:send).any? do |read|
157
+ next false unless response_body_read?(read)
158
+
159
+ read.method_name == :parsed_body || asserted?(read) || json_parsed?(read)
160
+ end
161
+ end
162
+
163
+ # `response.body` / `response.parsed_body` read within an assertion.
164
+ def asserted?(read)
165
+ read.each_ancestor(:send).any? { |a| a.method_name.to_s.start_with?('assert') }
166
+ end
167
+
168
+ # `JSON.parse(response.body)` — the JSON constant specifically, so
169
+ # format-specific parsers (`parse_xlsx`, `CSV.parse`) don't match.
170
+ def json_parsed?(read)
171
+ parent = read.parent
172
+ parent&.send_type? && parent.method_name == :parse &&
173
+ parent.receiver&.const_type? && parent.receiver.const_name == 'JSON'
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.1.0".freeze
3
+ VERSION = "0.3.0".freeze
4
4
  end
5
5
  end