rubocop-dev_doc 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/config/default.yml +318 -33
- data/lib/dev_doc/test/best_practice_lints.rb +31 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
- data/lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb +92 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +86 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +68 -13
- data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb +18 -3
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
- data/lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb +53 -0
- data/lib/rubocop/cop/dev_doc/migration/require_primary_key.rb +55 -0
- data/lib/rubocop/cop/dev_doc/migration/require_timestamps.rb +4 -13
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +56 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +135 -0
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +83 -0
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
- data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +29 -15
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +56 -22
- data/lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb +102 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +42 -10
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
- data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- data/lib/rubocop-dev_doc.rb +1 -0
- metadata +73 -10
- data/lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb +0 -53
|
@@ -0,0 +1,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,91 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Flag direct `==` / `!=` comparisons between a known-string source
|
|
6
|
+
# and a symbol literal. Such comparisons are silently always false
|
|
7
|
+
# (`'draft' == :draft` is `false` in Ruby), so they signal that the
|
|
8
|
+
# caller forgot to convert at the boundary.
|
|
9
|
+
#
|
|
10
|
+
# ## Rationale
|
|
11
|
+
# Strings and symbols are not equal in Ruby, and string-typed values
|
|
12
|
+
# arrive from boundaries the developer doesn't control: HTTP params,
|
|
13
|
+
# request headers, ENV. Comparing one of these directly against a
|
|
14
|
+
# symbol literal is a guaranteed bug. Convert at the boundary instead
|
|
15
|
+
# — see `best_practices/backend/en/01a_defensive_programming.md` item 7.
|
|
16
|
+
#
|
|
17
|
+
# ❌
|
|
18
|
+
# if params[:status] == :draft # always false
|
|
19
|
+
#
|
|
20
|
+
# ✔ Convert at the boundary, then compare
|
|
21
|
+
# @filter_status = params[:status]&.to_sym
|
|
22
|
+
# if @filter_status == :draft
|
|
23
|
+
#
|
|
24
|
+
# ## Sources detected
|
|
25
|
+
# - `params[…]`
|
|
26
|
+
# - `request.headers[…]`
|
|
27
|
+
# - `ENV[…]`
|
|
28
|
+
#
|
|
29
|
+
# ## Limitations
|
|
30
|
+
# The cop only catches the *direct* comparison form. Once the value
|
|
31
|
+
# flows into a local or instance variable, type information is lost
|
|
32
|
+
# and Rubocop cannot trace it back to the original string source.
|
|
33
|
+
# Those cases are caught by review.
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# # bad
|
|
37
|
+
# params[:status] == :draft
|
|
38
|
+
# :production == ENV['MODE']
|
|
39
|
+
# request.headers['X-Mode'] != :debug
|
|
40
|
+
#
|
|
41
|
+
# # good
|
|
42
|
+
# params[:status]&.to_sym == :draft
|
|
43
|
+
# ENV['MODE'] == 'production'
|
|
44
|
+
class StringSymbolComparison < Base
|
|
45
|
+
MSG = 'Comparing string-typed `%<source>s` to a symbol literal is always false. ' \
|
|
46
|
+
'Convert at the boundary with `&.to_sym` or compare against a string instead ' \
|
|
47
|
+
'(see backend/01a_defensive_programming.md item 7).'.freeze
|
|
48
|
+
|
|
49
|
+
RESTRICT_ON_SEND = %i[== !=].freeze
|
|
50
|
+
|
|
51
|
+
def_node_matcher :params_access?, <<~PATTERN
|
|
52
|
+
(send (send nil? :params) :[] _)
|
|
53
|
+
PATTERN
|
|
54
|
+
|
|
55
|
+
def_node_matcher :request_headers_access?, <<~PATTERN
|
|
56
|
+
(send (send _ :headers) :[] _)
|
|
57
|
+
PATTERN
|
|
58
|
+
|
|
59
|
+
def_node_matcher :env_access?, <<~PATTERN
|
|
60
|
+
(send (const nil? :ENV) :[] _)
|
|
61
|
+
PATTERN
|
|
62
|
+
|
|
63
|
+
def on_send(node)
|
|
64
|
+
lhs = node.receiver
|
|
65
|
+
rhs = node.first_argument
|
|
66
|
+
return unless lhs && rhs
|
|
67
|
+
|
|
68
|
+
source_node = pick_string_source(lhs, rhs)
|
|
69
|
+
return unless source_node
|
|
70
|
+
|
|
71
|
+
add_offense(node, message: format(MSG, source: source_node.source))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def pick_string_source(left, right)
|
|
77
|
+
if string_source?(left) && right.sym_type?
|
|
78
|
+
left
|
|
79
|
+
elsif string_source?(right) && left.sym_type?
|
|
80
|
+
right
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def string_source?(node)
|
|
85
|
+
params_access?(node) || request_headers_access?(node) || env_access?(node)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
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
|
data/lib/rubocop-dev_doc.rb
CHANGED
metadata
CHANGED
|
@@ -1,42 +1,43 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-dev_doc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dev-doc contributors
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-06-11 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
14
|
+
name: activesupport
|
|
14
15
|
requirement: !ruby/object:Gem::Requirement
|
|
15
16
|
requirements:
|
|
16
17
|
- - ">="
|
|
17
18
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
19
|
+
version: '4.2'
|
|
19
20
|
type: :runtime
|
|
20
21
|
prerelease: false
|
|
21
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
23
|
requirements:
|
|
23
24
|
- - ">="
|
|
24
25
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
26
|
+
version: '4.2'
|
|
26
27
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
28
|
+
name: fugit
|
|
28
29
|
requirement: !ruby/object:Gem::Requirement
|
|
29
30
|
requirements:
|
|
30
31
|
- - ">="
|
|
31
32
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
33
|
+
version: '1.9'
|
|
33
34
|
type: :runtime
|
|
34
35
|
prerelease: false
|
|
35
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
37
|
requirements:
|
|
37
38
|
- - ">="
|
|
38
39
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
40
|
+
version: '1.9'
|
|
40
41
|
- !ruby/object:Gem::Dependency
|
|
41
42
|
name: lint_roller
|
|
42
43
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -51,31 +52,92 @@ dependencies:
|
|
|
51
52
|
- - ">="
|
|
52
53
|
- !ruby/object:Gem::Version
|
|
53
54
|
version: '0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.72'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.72'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop-rails
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '2.0'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '2.0'
|
|
83
|
+
description:
|
|
84
|
+
email:
|
|
54
85
|
executables: []
|
|
55
86
|
extensions: []
|
|
56
87
|
extra_rdoc_files: []
|
|
57
88
|
files:
|
|
58
89
|
- config/default.yml
|
|
90
|
+
- lib/dev_doc/test/best_practice_lints.rb
|
|
91
|
+
- lib/dev_doc/test/lints/cron_schedule.rb
|
|
92
|
+
- lib/dev_doc/test/lints/duplicate_snapshot.rb
|
|
93
|
+
- lib/dev_doc/test/lints/no_file_excludes.rb
|
|
59
94
|
- lib/rubocop-dev_doc.rb
|
|
95
|
+
- lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
|
|
96
|
+
- lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
|
|
97
|
+
- lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb
|
|
98
|
+
- lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb
|
|
60
99
|
- lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb
|
|
100
|
+
- lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb
|
|
61
101
|
- lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb
|
|
62
|
-
- lib/rubocop/cop/dev_doc/migration/
|
|
102
|
+
- lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb
|
|
63
103
|
- lib/rubocop/cop/dev_doc/migration/avoid_vague_column_names.rb
|
|
64
104
|
- lib/rubocop/cop/dev_doc/migration/date_column_naming.rb
|
|
105
|
+
- lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb
|
|
65
106
|
- lib/rubocop/cop/dev_doc/migration/prefer_belongs_to.rb
|
|
107
|
+
- lib/rubocop/cop/dev_doc/migration/require_primary_key.rb
|
|
66
108
|
- lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
|
|
109
|
+
- lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
|
|
110
|
+
- lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb
|
|
111
|
+
- lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb
|
|
112
|
+
- lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb
|
|
113
|
+
- lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb
|
|
114
|
+
- lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb
|
|
67
115
|
- lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb
|
|
68
116
|
- lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb
|
|
117
|
+
- lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb
|
|
118
|
+
- lib/rubocop/cop/dev_doc/route/no_custom_actions.rb
|
|
119
|
+
- lib/rubocop/cop/dev_doc/route/resource_name_number.rb
|
|
69
120
|
- lib/rubocop/cop/dev_doc/route/resources_require_only.rb
|
|
70
121
|
- lib/rubocop/cop/dev_doc/style/avoid_head_response.rb
|
|
122
|
+
- lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb
|
|
71
123
|
- lib/rubocop/cop/dev_doc/style/avoid_send.rb
|
|
124
|
+
- lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb
|
|
125
|
+
- lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb
|
|
126
|
+
- lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb
|
|
127
|
+
- lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb
|
|
128
|
+
- lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb
|
|
129
|
+
- lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
|
|
130
|
+
- lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
|
|
131
|
+
- lib/rubocop/cop/dev_doc/test/response_assert_equal.rb
|
|
72
132
|
- lib/rubocop/dev_doc.rb
|
|
73
133
|
- lib/rubocop/dev_doc/plugin.rb
|
|
74
134
|
- lib/rubocop/dev_doc/version.rb
|
|
135
|
+
homepage:
|
|
75
136
|
licenses: []
|
|
76
137
|
metadata:
|
|
77
138
|
default_lint_roller_plugin: RuboCop::DevDoc::Plugin
|
|
78
139
|
rubygems_mfa_required: 'true'
|
|
140
|
+
post_install_message:
|
|
79
141
|
rdoc_options: []
|
|
80
142
|
require_paths:
|
|
81
143
|
- lib
|
|
@@ -90,7 +152,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
90
152
|
- !ruby/object:Gem::Version
|
|
91
153
|
version: '0'
|
|
92
154
|
requirements: []
|
|
93
|
-
rubygems_version: 4.
|
|
155
|
+
rubygems_version: 3.4.6
|
|
156
|
+
signing_key:
|
|
94
157
|
specification_version: 4
|
|
95
158
|
summary: RuboCop cops enforcing dev-doc best practices
|
|
96
159
|
test_files: []
|