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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +138 -1
  3. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
  4. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
  5. data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
  6. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
  7. data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
  8. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
  9. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
  10. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
  11. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
  12. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
  13. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
  14. data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
  15. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
  16. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
  17. data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
  18. data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
  19. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
  20. data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +5 -3
  21. data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
  22. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
  23. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
  24. data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
  25. data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
  26. data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
  27. data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
  28. data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
  29. data/lib/rubocop/dev_doc/version.rb +1 -1
  30. 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
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.7.0".freeze
3
+ VERSION = "0.8.0".freeze
4
4
  end
5
5
  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.7.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-03 00:00:00.000000000 Z
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