rubocop-dev_doc 0.7.0 → 0.8.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 +138 -1
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
- data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
- data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +5 -3
- data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
- data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +11 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# Controller tests must run inside a `glib_travel` block.
|
|
6
|
+
#
|
|
7
|
+
# ## Rationale
|
|
8
|
+
# Per the testing best practice ("Time Travel"), tests should always run
|
|
9
|
+
# inside a `glib_travel` block — even when they do not appear
|
|
10
|
+
# time-sensitive. Controller tests integrate HTTP, the database, and the
|
|
11
|
+
# clock (timestamps, expiry windows, token validity, rate limits), so they
|
|
12
|
+
# are the highest-leverage place to enforce it. A controller test that
|
|
13
|
+
# does not freeze time passes today and flakes or regresses the moment an
|
|
14
|
+
# ordering, expiry, or TTL boundary shifts under it.
|
|
15
|
+
#
|
|
16
|
+
# The block form auto-returns to real time at the end of the test, so no
|
|
17
|
+
# teardown is needed:
|
|
18
|
+
#
|
|
19
|
+
# test 'shows the row' do
|
|
20
|
+
# glib_travel(FROZEN_TIME) do
|
|
21
|
+
# get items_url(format: :json)
|
|
22
|
+
# assert_response :success
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# This cop is a *presence* check — it only asks "did you freeze time?".
|
|
27
|
+
# Whether you used the block form (vs. the leak-prone bare anchor) is
|
|
28
|
+
# enforced separately by `DevDoc/Test/RequireGlibTravelBlock`, and
|
|
29
|
+
# `glib_travel_freeze` vs. `glib_travel` by `AvoidGlibTravelFreeze`.
|
|
30
|
+
#
|
|
31
|
+
# ## What is NOT flagged
|
|
32
|
+
# - Tests that already call `glib_travel` or `glib_travel_freeze`.
|
|
33
|
+
# - Non-controller test files — the cop is scoped to
|
|
34
|
+
# `test/controllers/**` via `Include`, where the time-sensitivity risk
|
|
35
|
+
# is highest. (The best practice recommends the block form for every
|
|
36
|
+
# test; this cop enforces the subset with the strongest evidence.)
|
|
37
|
+
#
|
|
38
|
+
# ## Inline disable
|
|
39
|
+
# For the rare controller test that is genuinely time-independent and the
|
|
40
|
+
# block adds noise, add a `rubocop:disable DevDoc/Test/RequireGlibTravel`
|
|
41
|
+
# comment to the test's opening line (`test '...' do` or `def test_*`)
|
|
42
|
+
# with a written reason.
|
|
43
|
+
#
|
|
44
|
+
# @example
|
|
45
|
+
# # bad
|
|
46
|
+
# test 'shows the row' do
|
|
47
|
+
# get items_url(format: :json)
|
|
48
|
+
# assert_response :success
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# # good
|
|
52
|
+
# test 'shows the row' do
|
|
53
|
+
# glib_travel(FROZEN_TIME) do
|
|
54
|
+
# get items_url(format: :json)
|
|
55
|
+
# assert_response :success
|
|
56
|
+
# end
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
# # good (method-style test — also covered)
|
|
60
|
+
# def test_shows_the_row
|
|
61
|
+
# glib_travel(FROZEN_TIME) do
|
|
62
|
+
# get items_url(format: :json)
|
|
63
|
+
# end
|
|
64
|
+
# end
|
|
65
|
+
class RequireGlibTravel < Base
|
|
66
|
+
MSG = 'Wrap this test in a `glib_travel` block — controller tests integrate ' \
|
|
67
|
+
'HTTP, DB, and time, so the frozen time must be deterministic. See the ' \
|
|
68
|
+
'testing best practice ("Time Travel"). Disable with a written reason ' \
|
|
69
|
+
'only if the test is genuinely time-independent.'.freeze
|
|
70
|
+
|
|
71
|
+
# @!method test_block?(node)
|
|
72
|
+
def_node_matcher :test_block?, <<~PATTERN
|
|
73
|
+
(block (send nil? :test ...) _args _body)
|
|
74
|
+
PATTERN
|
|
75
|
+
|
|
76
|
+
# Any glib time helper satisfies the presence check. `glib_travel_freeze`
|
|
77
|
+
# counts as "froze time" here — its form is policed by
|
|
78
|
+
# AvoidGlibTravelFreeze, not by this cop.
|
|
79
|
+
# @!method calls_glib_time_helper?(node)
|
|
80
|
+
def_node_search :calls_glib_time_helper?, <<~PATTERN
|
|
81
|
+
(send nil? {:glib_travel :glib_travel_freeze} ...)
|
|
82
|
+
PATTERN
|
|
83
|
+
|
|
84
|
+
def on_block(node)
|
|
85
|
+
return unless test_block?(node)
|
|
86
|
+
|
|
87
|
+
check_test(node, node.send_node.loc.selector)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Minitest also allows method-style tests (`def test_shows_the_row`);
|
|
91
|
+
# those are the same kind of controller test and must freeze time too.
|
|
92
|
+
def on_def(node)
|
|
93
|
+
return unless test_method?(node)
|
|
94
|
+
|
|
95
|
+
check_test(node, node.loc.name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Shared by the block form (`test '...' do`) and the method form
|
|
101
|
+
# (`def test_*`): a test with a body that never freezes time.
|
|
102
|
+
def check_test(node, location)
|
|
103
|
+
return unless node.body
|
|
104
|
+
return if calls_glib_time_helper?(node)
|
|
105
|
+
|
|
106
|
+
add_offense(location)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Minitest treats any `def test_*` method as a test. Node patterns
|
|
110
|
+
# match exact method names rather than a prefix, so this is a plain
|
|
111
|
+
# Ruby string check. (`def test` — no underscore — is not a test.)
|
|
112
|
+
def test_method?(node)
|
|
113
|
+
node.method_name.to_s.start_with?('test_')
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# `glib_travel` must be called with a block — the bare anchor form leaks
|
|
6
|
+
# frozen time past the test.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# `glib_travel(point) do ... end` auto-returns to real time at the end of
|
|
10
|
+
# the block, so a test cannot leak a frozen clock into the next one. The
|
|
11
|
+
# bare anchor form `glib_travel(point)` (no block) freezes time until a
|
|
12
|
+
# matching `glib_travel_back`, which is easy to forget in `teardown` and
|
|
13
|
+
# then silently corrupts every test that runs afterward. This is the form
|
|
14
|
+
# that has repeatedly slipped in alongside the correct block form.
|
|
15
|
+
#
|
|
16
|
+
# ## What is NOT flagged
|
|
17
|
+
# - The block form `glib_travel(point) do ... end` (the correct form).
|
|
18
|
+
# - A bare DURATION argument that advances the frozen clock across
|
|
19
|
+
# multiple requests within one test, e.g. `glib_travel(61.seconds)`,
|
|
20
|
+
# `glib_travel(1.hour)`, `glib_travel(-5.minutes)`, or
|
|
21
|
+
# `glib_travel(5.minutes + 30.seconds)`. This is the accepted
|
|
22
|
+
# non-block form for rate-limiting / clock-tick tests; it is
|
|
23
|
+
# recognized by a `Numeric#second(s)/minute(s)/hour(s)/day(s)/
|
|
24
|
+
# week(s)/month(s)/year(s)` leaf, optionally unary-negated
|
|
25
|
+
# (`-5.minutes`) or composed additively/subtractively
|
|
26
|
+
# (`5.minutes + 30.seconds`), and is paired with an explicit
|
|
27
|
+
# `glib_travel_back`. An absolute point expression such as
|
|
28
|
+
# `Time.current`, `DateTime.new(...)`, or `2.hours.from_now` is NOT a
|
|
29
|
+
# duration and is flagged. A duration reached through anything the
|
|
30
|
+
# matcher cannot see statically — a constant or variable holding a
|
|
31
|
+
# duration (`glib_travel(SOME_DURATION)`), a method call, or
|
|
32
|
+
# multiplication/division (`glib_travel(5.minutes * 2)`) — is NOT
|
|
33
|
+
# recognized; inline the literal expression or `# rubocop:disable`
|
|
34
|
+
# with a reason.
|
|
35
|
+
# - `glib_travel_freeze` (covered by `DevDoc/Test/AvoidGlibTravelFreeze`).
|
|
36
|
+
#
|
|
37
|
+
# ## Inline disable
|
|
38
|
+
# For the rare case where the bare anchor form is genuinely required, add
|
|
39
|
+
# a `rubocop:disable DevDoc/Test/RequireGlibTravelBlock` comment with a
|
|
40
|
+
# written reason.
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# # bad (bare anchor — leaks frozen time without glib_travel_back)
|
|
44
|
+
# glib_travel(FROZEN_TIME)
|
|
45
|
+
# get items_url(format: :json)
|
|
46
|
+
#
|
|
47
|
+
# # good
|
|
48
|
+
# glib_travel(FROZEN_TIME) do
|
|
49
|
+
# get items_url(format: :json)
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# # good (bare duration tick — advances the clock, paired with travel_back)
|
|
53
|
+
# glib_travel(61.seconds)
|
|
54
|
+
class RequireGlibTravelBlock < Base
|
|
55
|
+
MSG = 'Use the block form `glib_travel(time) do ... end` — the bare anchor ' \
|
|
56
|
+
'form leaks frozen time past the test unless paired with ' \
|
|
57
|
+
'`glib_travel_back`. Use `# rubocop:disable ' \
|
|
58
|
+
'DevDoc/Test/RequireGlibTravelBlock` with a reason if the bare form ' \
|
|
59
|
+
'is genuinely required.'.freeze
|
|
60
|
+
|
|
61
|
+
RESTRICT_ON_SEND = %i[glib_travel].freeze
|
|
62
|
+
|
|
63
|
+
# A duration unit — the leaf of a duration expression. Both singular
|
|
64
|
+
# and plural ActiveSupport forms (`1.hour`, `2.minutes`). Receiver is
|
|
65
|
+
# unconstrained; in practice it is always a Numeric literal.
|
|
66
|
+
# @!method duration_argument?(node)
|
|
67
|
+
def_node_matcher :duration_argument?, <<~PATTERN
|
|
68
|
+
(send _ {:second :seconds :minute :minutes :hour :hours :day :days :week :weeks :month :months :year :years})
|
|
69
|
+
PATTERN
|
|
70
|
+
|
|
71
|
+
def on_send(node)
|
|
72
|
+
# `glib_travel` is a global test helper; a receiver-bearing call
|
|
73
|
+
# (`obj.glib_travel`) is a different method and none of our
|
|
74
|
+
# business — but `self.glib_travel` IS the same call and must not
|
|
75
|
+
# become a one-token dodge of the block-form rule.
|
|
76
|
+
return if node.receiver && !node.receiver.self_type?
|
|
77
|
+
|
|
78
|
+
return if block_form?(node)
|
|
79
|
+
return if bare_duration_tick?(node)
|
|
80
|
+
|
|
81
|
+
add_offense(node.loc.selector)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# True when this `glib_travel` send is the body of a block, i.e. the
|
|
87
|
+
# call is `glib_travel(...) do ... end`. Covers regular blocks and
|
|
88
|
+
# numbered-parameter blocks (`{ _1 }`).
|
|
89
|
+
def block_form?(node)
|
|
90
|
+
parent = node.parent
|
|
91
|
+
return false unless parent
|
|
92
|
+
return false unless parent.block_type? || parent.numblock_type?
|
|
93
|
+
|
|
94
|
+
parent.send_node == node
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A bare call whose argument is a Duration expression advances the
|
|
98
|
+
# frozen clock and is the accepted non-block form.
|
|
99
|
+
def bare_duration_tick?(node)
|
|
100
|
+
duration_expression?(node.first_argument)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A single duration unit (`61.seconds`), optionally unary-negated
|
|
104
|
+
# (`-5.minutes`), or an additive/subtractive composition of durations
|
|
105
|
+
# (`5.minutes + 30.seconds`). A duration mixed with an absolute point
|
|
106
|
+
# (`Time.current + 5.minutes` — a Time, not a Duration) is NOT
|
|
107
|
+
# recognized and remains flagged; neither is multiplication/division
|
|
108
|
+
# nor a duration held in a variable/constant/method (see docstring).
|
|
109
|
+
def duration_expression?(node)
|
|
110
|
+
return false unless node
|
|
111
|
+
return true if duration_argument?(node)
|
|
112
|
+
return false unless node.send_type?
|
|
113
|
+
# A unary sign on a duration is still a duration (`-5.minutes`).
|
|
114
|
+
return duration_expression?(node.receiver) if %i[+@ -@].include?(node.method_name)
|
|
115
|
+
return false unless %i[+ -].include?(node.method_name)
|
|
116
|
+
|
|
117
|
+
duration_expression?(node.receiver) && duration_expression?(node.first_argument)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# Classifies every test file (`test/**/*_test.rb`) into one of three
|
|
6
|
+
# buckets, so no directory placement can dodge test-type enforcement:
|
|
7
|
+
#
|
|
8
|
+
# 1. **Exempt** (`ExemptDirectories`, default: controllers, integration,
|
|
9
|
+
# jobs, mailers, channels, system, linters, tasks) — covered by other
|
|
10
|
+
# enforcement (test/controllers/ is guaranteed HTTP-driven at runtime
|
|
11
|
+
# by DevDoc::Test::Lints::HttpDrivenControllerTests) or accepted
|
|
12
|
+
# integration-shaped homes. Even here, inheriting a unit base class
|
|
13
|
+
# (`UnitBaseClasses`, default `Glib::LastResortUnitTest`) is an offense: a unit
|
|
14
|
+
# base is only legitimate in a unit-test directory, so its appearance
|
|
15
|
+
# in an exempt directory means a unit test is parked where the
|
|
16
|
+
# justification-header rule (and, for test/controllers/, the runtime
|
|
17
|
+
# HTTP lint — which only reaches integration-base descendants) cannot
|
|
18
|
+
# see it.
|
|
19
|
+
# 2. **Unit-test directories** (`UnitTestDirectories`, default: models,
|
|
20
|
+
# services, helpers) — allowed only with a justification header: the
|
|
21
|
+
# leading comments (before the first class/module) must contain the
|
|
22
|
+
# marker phrase (default "deliberate exception") explaining why a
|
|
23
|
+
# controller test can't cover this.
|
|
24
|
+
# 3. **Anything else** — flagged as an unrecognized test directory.
|
|
25
|
+
# Adding a new test home is a reviewed decision made in this cop's
|
|
26
|
+
# config, not something a generated test can do implicitly.
|
|
27
|
+
#
|
|
28
|
+
# ## Rationale
|
|
29
|
+
# Best practice strongly prefers controller tests (see AvoidUnitTest):
|
|
30
|
+
# unit tests stay green while the production wiring is broken, so a
|
|
31
|
+
# passing unit test is false confidence about production. The
|
|
32
|
+
# justification bar is deliberately high — before writing the header,
|
|
33
|
+
# assume a controller test IS possible and look harder, because that
|
|
34
|
+
# conclusion is almost always premature: AvoidUnitTest's docs list the
|
|
35
|
+
# patterns (inject a failure at a class-method chokepoint the real
|
|
36
|
+
# request hits, assert the observable guarantee through the real path)
|
|
37
|
+
# that make seemingly unit-only behaviour reachable end-to-end. Observed
|
|
38
|
+
# evasions by AI agents include ambiguous file names, unit tests mixed
|
|
39
|
+
# into controller-test files, inheriting the integration base class so
|
|
40
|
+
# the `< ActiveSupport::TestCase` heuristic never fires — and, once
|
|
41
|
+
# per-directory enforcement exists, inventing new directories outside
|
|
42
|
+
# it. The three-bucket total classification closes the last of those:
|
|
43
|
+
# every test file is either behaviorally enforced, explicitly justified,
|
|
44
|
+
# or flagged.
|
|
45
|
+
#
|
|
46
|
+
# ❌ test/models/member_test.rb with no header
|
|
47
|
+
# class MemberTest < Glib::IntegrationTest
|
|
48
|
+
#
|
|
49
|
+
# ❌ test/controllers/member_test.rb — unit base hiding in an exempt dir
|
|
50
|
+
# class MemberTest < Glib::LastResortUnitTest
|
|
51
|
+
#
|
|
52
|
+
# ✔ justified
|
|
53
|
+
# # Per best practice, we focus on controller tests. This model test
|
|
54
|
+
# # is a deliberate exception: the safely_* guards raise on ANY direct
|
|
55
|
+
# # call, so by design no controller path can reach them.
|
|
56
|
+
# class MemberTest < Glib::LastResortUnitTest
|
|
57
|
+
#
|
|
58
|
+
# @example
|
|
59
|
+
# # bad — test/units/quiet_test.rb (unrecognized directory)
|
|
60
|
+
#
|
|
61
|
+
# # bad — test/models/member_test.rb without the marker phrase
|
|
62
|
+
#
|
|
63
|
+
# # bad — test/controllers/member_test.rb inheriting Glib::LastResortUnitTest
|
|
64
|
+
#
|
|
65
|
+
# # good — test/models/member_test.rb whose leading comments contain
|
|
66
|
+
# # "deliberate exception" and explain why no controller path exists
|
|
67
|
+
class RequireUnitTestJustification < Base
|
|
68
|
+
MSG_JUSTIFY = 'Unit-test file without a justification header. Controller tests catch the ' \
|
|
69
|
+
'wiring bugs unit tests miss, so first assume a controller test IS possible ' \
|
|
70
|
+
'and look harder — that conclusion is almost always premature (see ' \
|
|
71
|
+
'DevDoc/Test/AvoidUnitTest for patterns that reach "unit-only" behaviour ' \
|
|
72
|
+
'end-to-end). If genuinely impossible, explain why in the leading comments, ' \
|
|
73
|
+
'including the marker phrase "%<phrase>s".'.freeze
|
|
74
|
+
|
|
75
|
+
MSG_UNRECOGNIZED = 'Test file in unrecognized directory `test/%<dir>s/`. Unit tests hide in ' \
|
|
76
|
+
'uncategorized directories: move this file to a recognized home ' \
|
|
77
|
+
'(%<known>s), or add the directory to this cop\'s configuration — a ' \
|
|
78
|
+
'reviewed decision, not an implicit one.'.freeze
|
|
79
|
+
|
|
80
|
+
MSG_BASE = '`%<base>s` is an integration base class — a unit test inheriting it lies about ' \
|
|
81
|
+
'its type and gains HTTP helpers it must never use. Subclass a unit-test base ' \
|
|
82
|
+
'(e.g. `Glib::LastResortUnitTest`) so the declaration is truthful and the verbs are ' \
|
|
83
|
+
'structurally absent.'.freeze
|
|
84
|
+
|
|
85
|
+
MSG_UNIT_BASE = '`%<base>s` belongs only in a unit-test directory (%<unit_dirs>s), where ' \
|
|
86
|
+
'the justification header is enforced. A unit test in `test/%<dir>s/` is ' \
|
|
87
|
+
'hiding from that enforcement — move it there and justify it, or write a ' \
|
|
88
|
+
'real %<dir>s test.'.freeze
|
|
89
|
+
|
|
90
|
+
FORBIDDEN_BASES = %w[ActionDispatch::IntegrationTest Glib::IntegrationTest].freeze
|
|
91
|
+
EXEMPT_DIRS = %w[controllers integration jobs mailers channels system linters tasks].freeze
|
|
92
|
+
|
|
93
|
+
def on_new_investigation
|
|
94
|
+
return if processed_source.blank?
|
|
95
|
+
|
|
96
|
+
dir = test_subdirectory
|
|
97
|
+
return if dir.nil? # not under a test/ tree at all
|
|
98
|
+
|
|
99
|
+
enforce_directory_rules(dir)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def enforce_directory_rules(dir)
|
|
105
|
+
if exempt_directories.include?(dir)
|
|
106
|
+
check_unit_bases(dir)
|
|
107
|
+
elsif unit_test_directories.include?(dir)
|
|
108
|
+
check_justification
|
|
109
|
+
check_base_classes
|
|
110
|
+
else
|
|
111
|
+
add_offense(offense_range, message: format(MSG_UNRECOGNIZED, dir: dir, known: known_directories))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def check_justification
|
|
116
|
+
return if leading_comments_contain_phrase?
|
|
117
|
+
|
|
118
|
+
add_offense(offense_range, message: format(MSG_JUSTIFY, phrase: marker_phrase))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A justified unit test must also DECLARE itself truthfully: inheriting
|
|
122
|
+
# an integration base class hides the test type from readers and from
|
|
123
|
+
# base-class-keyed tooling (the observed AvoidUnitTest evasion).
|
|
124
|
+
def check_base_classes
|
|
125
|
+
each_class_with_base(forbidden_base_classes) do |superclass, base|
|
|
126
|
+
add_offense(superclass, message: format(MSG_BASE, base: base))
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The inverse rule for exempt directories: a unit base there means a
|
|
131
|
+
# unit test parked where neither the header rule nor the runtime HTTP
|
|
132
|
+
# lint (which only reaches integration-base descendants) can see it.
|
|
133
|
+
def check_unit_bases(dir)
|
|
134
|
+
each_class_with_base(unit_base_classes) do |superclass, base|
|
|
135
|
+
add_offense(superclass,
|
|
136
|
+
message: format(MSG_UNIT_BASE, base: base, dir: dir, unit_dirs: unit_directories_list))
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def each_class_with_base(bases)
|
|
141
|
+
root = processed_source.ast
|
|
142
|
+
return unless root
|
|
143
|
+
|
|
144
|
+
root.each_node(:class) do |class_node|
|
|
145
|
+
base = class_node.parent_class&.const_name
|
|
146
|
+
yield(class_node.parent_class, base) if base && bases.include?(base)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# First path segment after the (last) `test/` component; nil when the
|
|
151
|
+
# file is not under one, '.' for files directly in test/. Only
|
|
152
|
+
# `*_test.rb` files count — anything else (helpers, shared scenarios,
|
|
153
|
+
# previews) is never loaded by the test runner, so there is nothing to
|
|
154
|
+
# enforce. In-cop guard on purpose: the config Include says the same,
|
|
155
|
+
# but the cop must stay correct even when run with a forced config.
|
|
156
|
+
def test_subdirectory
|
|
157
|
+
path = processed_source.file_path.to_s
|
|
158
|
+
return nil unless File.basename(path).end_with?('_test.rb')
|
|
159
|
+
|
|
160
|
+
rest = path_after_test_root(path)
|
|
161
|
+
return nil unless rest
|
|
162
|
+
|
|
163
|
+
segments = rest.split('/')
|
|
164
|
+
segments.length == 1 ? '.' : segments.first
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Everything after the LAST `test/` root component (handles nesting
|
|
168
|
+
# like test/services/ai/x_test.rb and repos checked out under a
|
|
169
|
+
# directory that itself contains `test/`).
|
|
170
|
+
def path_after_test_root(path)
|
|
171
|
+
if (index = path.rindex('/test/'))
|
|
172
|
+
path[(index + '/test/'.length)..]
|
|
173
|
+
elsif path.start_with?('test/')
|
|
174
|
+
path.delete_prefix('test/')
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def leading_comments_contain_phrase?
|
|
179
|
+
processed_source.comments.any? do |comment|
|
|
180
|
+
comment.location.line < first_definition_line &&
|
|
181
|
+
comment.text.downcase.include?(marker_phrase.downcase)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Header comments legitimately sit between the `require`s and the test
|
|
186
|
+
# class, so "leading" means before the first class/module definition.
|
|
187
|
+
def first_definition_line
|
|
188
|
+
root = processed_source.ast
|
|
189
|
+
return Float::INFINITY unless root
|
|
190
|
+
|
|
191
|
+
definition = root.each_node(:class, :module).first
|
|
192
|
+
(definition || root).first_line
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def marker_phrase = cop_config.fetch('MarkerPhrase', 'deliberate exception')
|
|
196
|
+
|
|
197
|
+
def forbidden_base_classes = cop_config.fetch('ForbiddenBaseClasses', FORBIDDEN_BASES)
|
|
198
|
+
|
|
199
|
+
def unit_base_classes = cop_config.fetch('UnitBaseClasses', %w[Glib::LastResortUnitTest])
|
|
200
|
+
|
|
201
|
+
def unit_test_directories = cop_config.fetch('UnitTestDirectories', %w[models services helpers])
|
|
202
|
+
|
|
203
|
+
def exempt_directories = cop_config.fetch('ExemptDirectories', EXEMPT_DIRS)
|
|
204
|
+
|
|
205
|
+
def known_directories = (exempt_directories + unit_test_directories).map { |d| "test/#{d}/" }.join(', ')
|
|
206
|
+
|
|
207
|
+
def unit_directories_list = unit_test_directories.map { |d| "test/#{d}/" }.join(', ')
|
|
208
|
+
|
|
209
|
+
def offense_range
|
|
210
|
+
range = processed_source.ast&.source_range || processed_source.comments.first&.source_range
|
|
211
|
+
range.with(end_pos: range.begin_pos + [range.source_line.length, 1].max)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module View
|
|
5
|
+
# Bind form fields to model attributes with `prop:`, not a raw
|
|
6
|
+
# `name: 'model[attr]'`.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# The form-field builder's `prop:` option binds a field to the form's
|
|
10
|
+
# model attribute and resolves its label, placeholder, and validation
|
|
11
|
+
# hints from i18n. Writing `name: 'model[attr]'` (the raw HTML-form
|
|
12
|
+
# instinct) bypasses all of that, so the label/placeholder get
|
|
13
|
+
# hardcoded next to it in English — the exact text this ecosystem
|
|
14
|
+
# keeps in locale files.
|
|
15
|
+
#
|
|
16
|
+
# ❌ raw name + hardcoded text
|
|
17
|
+
# form.fields_text name: 'user[email]', label: 'Email', placeholder: 'Email'
|
|
18
|
+
#
|
|
19
|
+
# ✔️ bound to the model; text resolved from i18n
|
|
20
|
+
# form.fields_text prop: :email
|
|
21
|
+
#
|
|
22
|
+
# Matching is receiver-agnostic (`form.`, `f.`, any block-arg name):
|
|
23
|
+
# keying on specific receiver names would make renaming the block
|
|
24
|
+
# argument a silent dodge. Any `fields_*` call with a
|
|
25
|
+
# single-level-bracket string `name:` is flagged.
|
|
26
|
+
#
|
|
27
|
+
# ## Exemptions
|
|
28
|
+
# - `fields_hidden` is exempt by default (`ExemptFieldMethods`):
|
|
29
|
+
# hidden fields have no label/placeholder, and they are the
|
|
30
|
+
# dominant legitimate `name:` users (echoed tokens, dynamic-group
|
|
31
|
+
# indexes, forced values).
|
|
32
|
+
# - A field on an UNBOUND form (pre-auth flows: sessions, password
|
|
33
|
+
# reset) cannot use `prop:` — there is no model to resolve against.
|
|
34
|
+
# Prefer binding a fresh instance (e.g. `User.new`) so `prop:`
|
|
35
|
+
# works; otherwise keep `name:` with a per-line
|
|
36
|
+
# `rubocop:disable ... -- <reason>`.
|
|
37
|
+
# - Virtual/transient/credential fields where model-driven resolution
|
|
38
|
+
# is undesirable: per-line disable with a reason.
|
|
39
|
+
#
|
|
40
|
+
# ## Autocorrect (unsafe)
|
|
41
|
+
# `name: 'model[attr]'` becomes `prop: :attr`. Two caveats make this
|
|
42
|
+
# unsafe:
|
|
43
|
+
# - The `model` segment is dropped: `prop:` binds to the FORM's
|
|
44
|
+
# model, so if the raw name deliberately posted under a different
|
|
45
|
+
# key, the rewrite silently re-parents the param. No static check
|
|
46
|
+
# can compare the two — review the diff.
|
|
47
|
+
# - Sibling `label:`/`placeholder:` are NOT auto-deleted; verify the
|
|
48
|
+
# i18n keys exist, then remove them manually.
|
|
49
|
+
#
|
|
50
|
+
# NOTE: a bare `name: 'something'` (no brackets) that should have
|
|
51
|
+
# been `prop:` is statically indistinguishable from a legitimately
|
|
52
|
+
# unbound field, so it is not flagged — a render-time assertion could
|
|
53
|
+
# close that gap later.
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# # bad
|
|
57
|
+
# form.fields_text name: 'user[email]', label: 'Email'
|
|
58
|
+
#
|
|
59
|
+
# # good
|
|
60
|
+
# form.fields_text prop: :email
|
|
61
|
+
#
|
|
62
|
+
# # good — hidden fields are exempt by default
|
|
63
|
+
# form.fields_hidden name: 'user[reset_password_token]', value: token
|
|
64
|
+
class PreferPropOverName < Base
|
|
65
|
+
extend AutoCorrector
|
|
66
|
+
|
|
67
|
+
MSG = 'Bind the model attribute with `prop: :%<attr>s` instead of `name: %<name>s` — ' \
|
|
68
|
+
'`prop:` resolves label/placeholder/validation from i18n. After switching, remove ' \
|
|
69
|
+
'the now-redundant hardcoded `label:`/`placeholder:` (verify the i18n keys exist). ' \
|
|
70
|
+
'If this field deliberately posts a raw param, disable with a reason.'.freeze
|
|
71
|
+
|
|
72
|
+
MODEL_ATTR_NAME = /\A\w+\[(\w+)\]\z/
|
|
73
|
+
|
|
74
|
+
def on_send(node)
|
|
75
|
+
return unless node.method_name.to_s.start_with?('fields_')
|
|
76
|
+
return if exempt_field_methods.include?(node.method_name.to_s)
|
|
77
|
+
|
|
78
|
+
pair = bracket_name_pair(node)
|
|
79
|
+
return unless pair
|
|
80
|
+
|
|
81
|
+
attr = pair.value.value[MODEL_ATTR_NAME, 1]
|
|
82
|
+
add_offense(pair, message: format(MSG, attr: attr, name: pair.value.source)) do |corrector|
|
|
83
|
+
corrector.replace(pair, "prop: :#{attr}")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# The `name:` kwarg pair whose literal string value has the
|
|
90
|
+
# single-level `model[attr]` shape. Dynamic values and bare names
|
|
91
|
+
# are not provably model-bound, so they are left alone.
|
|
92
|
+
def bracket_name_pair(node)
|
|
93
|
+
hash = node.arguments.find(&:hash_type?)
|
|
94
|
+
return nil unless hash
|
|
95
|
+
|
|
96
|
+
hash.pairs.find do |pair|
|
|
97
|
+
pair.key.sym_type? && pair.key.value == :name &&
|
|
98
|
+
pair.value.str_type? && pair.value.value.match?(MODEL_ATTR_NAME)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def exempt_field_methods
|
|
103
|
+
cop_config.fetch('ExemptFieldMethods', %w[fields_hidden])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dev-doc contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -90,6 +90,7 @@ files:
|
|
|
90
90
|
- lib/dev_doc/test/best_practice_lints.rb
|
|
91
91
|
- lib/dev_doc/test/lints/cron_schedule.rb
|
|
92
92
|
- lib/dev_doc/test/lints/duplicate_snapshot.rb
|
|
93
|
+
- lib/dev_doc/test/lints/http_driven_controller_tests.rb
|
|
93
94
|
- lib/dev_doc/test/lints/no_file_excludes.rb
|
|
94
95
|
- lib/rubocop-dev_doc.rb
|
|
95
96
|
- lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
|
|
@@ -131,16 +132,24 @@ files:
|
|
|
131
132
|
- lib/rubocop/cop/dev_doc/style/avoid_head_response.rb
|
|
132
133
|
- lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb
|
|
133
134
|
- lib/rubocop/cop/dev_doc/style/avoid_send.rb
|
|
135
|
+
- lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb
|
|
136
|
+
- lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb
|
|
134
137
|
- lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb
|
|
135
138
|
- lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb
|
|
136
139
|
- lib/rubocop/cop/dev_doc/style/prefer_public_send.rb
|
|
140
|
+
- lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb
|
|
137
141
|
- lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb
|
|
138
142
|
- lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb
|
|
139
143
|
- lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb
|
|
140
144
|
- lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb
|
|
141
145
|
- lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
|
|
142
146
|
- lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
|
|
147
|
+
- lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb
|
|
148
|
+
- lib/rubocop/cop/dev_doc/test/require_glib_travel.rb
|
|
149
|
+
- lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb
|
|
150
|
+
- lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb
|
|
143
151
|
- lib/rubocop/cop/dev_doc/test/response_assert_equal.rb
|
|
152
|
+
- lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb
|
|
144
153
|
- lib/rubocop/dev_doc.rb
|
|
145
154
|
- lib/rubocop/dev_doc/plugin.rb
|
|
146
155
|
- lib/rubocop/dev_doc/version.rb
|