rubocop-dev_doc 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,94 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # Mailer previews must not persist records. A preview renders on every
6
+ # GET in development, so a `save!`/`update!`/`create!` inside one
7
+ # mutates the development database as a side effect of merely VIEWING
8
+ # an email — and re-renders keep re-writing it.
9
+ #
10
+ # ## Rationale
11
+ # Previews are also a relocation target: when the HTTP-driven lint
12
+ # pushes mailer-body assertions into preview variants (snapshot-tested
13
+ # via generate_preview_tests), the temptation is to persist the state
14
+ # the variant needs. Don't — the mailer receives its objects in memory
15
+ # via `.with(...)`, so in-memory assignment (`record.attr = value`)
16
+ # renders identically with no side effect. For sample data, use
17
+ # fixtures/seeded rows or unsaved `new`/`build` records.
18
+ #
19
+ # This was a real defect: a preview called
20
+ # `checklist.update!(note_for_assignee: ...)` to stage a note variant,
21
+ # silently rewriting the first published checklist's note in the dev DB
22
+ # on every preview view. In-memory assignment renders the same body.
23
+ #
24
+ # ## When in-memory assignment can't work: add a FIXTURE, don't write
25
+ # Some mailers re-query their records at render time (async-safe
26
+ # designs re-scope ids against live access rules), so an in-memory
27
+ # attribute never reaches the render and persistence looks
28
+ # "irreducible". It still isn't — the fix is data, not code: put the
29
+ # state the variant needs into a fixture (or seeded row) and have the
30
+ # preview *select* it instead of *creating* it. Real case: a
31
+ # "documents shared with you" note variant used
32
+ # `doc.update_column(:standing_note, ...)` because the mailer
33
+ # re-queries the accessible documents; the fix was a fixture document
34
+ # that carries the standing note, with the two preview variants
35
+ # choosing their `document_ids` via `where(standing_note: nil)` vs
36
+ # all ids. Fixtures make the variant deterministic for snapshot-tested
37
+ # previews AND leave the dev DB untouched — never reach for an on-the-fly
38
+ # write (or an Exclude for this cop) before trying a fixture.
39
+ #
40
+ # `AllowedMethods` exists for projects whose preview data setup
41
+ # genuinely must write (e.g. a sandboxed preview database) — prefer
42
+ # leaving it empty.
43
+ #
44
+ # @example
45
+ # # bad — viewing the preview rewrites the record
46
+ # def reminder_with_note
47
+ # checklist.update!(note_for_assignee: 'Standing note')
48
+ # ChecklistMailer.with(checklist: checklist).reminder
49
+ # end
50
+ #
51
+ # # good — same rendered body, no side effect
52
+ # def reminder_with_note
53
+ # checklist.note_for_assignee = 'Standing note'
54
+ # ChecklistMailer.with(checklist: checklist).reminder
55
+ # end
56
+ class NoPersistenceInMailerPreviews < Base
57
+ MSG = '`%<method>s` persists from a mailer preview — previews render on every GET in ' \
58
+ 'development, so viewing the email mutates the dev DB. Assign in memory instead ' \
59
+ '(`record.attr = value`): the mailer receives the object via `.with(...)`, so the ' \
60
+ 'rendered body is identical. For sample data, use fixtures/seeded rows or unsaved ' \
61
+ '`new`/`build` records.'.freeze
62
+
63
+ # `delete` and `insert` are deliberately absent: Hash#delete /
64
+ # Array#insert are everyday preview code and would false-positive;
65
+ # record-level intent is still covered by destroy/delete_all/insert_all.
66
+ PERSISTENCE_METHODS = %i[
67
+ save save! create create! update update! update_column update_columns
68
+ update_all update_attribute delete_all destroy destroy! destroy_all
69
+ insert! insert_all insert_all! upsert upsert_all
70
+ increment! decrement! toggle! touch touch_all
71
+ find_or_create_by find_or_create_by! create_or_find_by create_or_find_by!
72
+ first_or_create first_or_create!
73
+ ].freeze
74
+
75
+ def on_send(node)
76
+ method = node.method_name
77
+ return unless PERSISTENCE_METHODS.include?(method)
78
+ return if allowed_methods.include?(method.to_s)
79
+
80
+ add_offense(node.loc.selector, message: format(MSG, method: method))
81
+ end
82
+ # Safe navigation (`record&.update_column`) arrives as csend, which
83
+ # on_send does NOT receive — without this alias it slips through
84
+ # (found by red-teaming: `first&.update_column` in a real preview).
85
+ alias on_csend on_send
86
+
87
+ private
88
+
89
+ def allowed_methods = cop_config.fetch('AllowedMethods', [])
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -1,3 +1,6 @@
1
+ require_relative 'justification_header'
2
+ require_relative 'test_file_placement'
3
+
1
4
  module RuboCop
2
5
  module Cop
3
6
  module DevDoc
@@ -15,12 +18,37 @@ module RuboCop
15
18
  # in an exempt directory means a unit test is parked where the
16
19
  # justification-header rule (and, for test/controllers/, the runtime
17
20
  # HTTP lint — which only reaches integration-base descendants) cannot
18
- # see it.
21
+ # see it. The non-HTTP side-effect base (`NonHttpBaseClasses`, default
22
+ # `Glib::NonHttpIntegrationTest`) is confined the same way to its own
23
+ # directories (`NonHttpDirectories`, default: jobs, mailers,
24
+ # channels): full-stack tests whose contract is a side effect (a job's
25
+ # record mutations, a mailer's body) rather than an HTTP response.
26
+ # Outside those directories it is an evasion vector — in
27
+ # test/controllers/ it would dodge the runtime HTTP lint exactly like
28
+ # a unit base, and in a unit directory it would launder a unit test
29
+ # past the truthful-declaration rule. Inside them, a class on the
30
+ # non-HTTP base carries the same justification-header contract as a
31
+ # unit directory (it consciously opted out of HTTP), while classes on
32
+ # the integration base stay header-free — job tests legitimately mix
33
+ # HTTP assertions with side-effect assertions.
19
34
  # 2. **Unit-test directories** (`UnitTestDirectories`, default: models,
20
35
  # services, helpers) — allowed only with a justification header: the
21
36
  # leading comments (before the first class/module) must contain the
22
37
  # marker phrase (default "deliberate exception") explaining why a
23
- # controller test can't cover this.
38
+ # controller test can't cover this, AND a wiring line (`WiringPhrase`,
39
+ # default "wiring:") naming the request test that still covers the
40
+ # HTTP path this test's subject participates in — or why no runtime
41
+ # HTTP path exists, naming the actual entry point. The justification
42
+ # must state the concrete mechanism (who calls save!, where the data
43
+ # comes from); stock phrases ("the controller merely surfaces the
44
+ # model's errors") do not count, and a header that misstates the
45
+ # mechanism is worse than none — verify the claim by tracing the
46
+ # write path before writing it. Base classes are allowlisted here
47
+ # (`RequireUnitBase`, default true): a *Test class must subclass a
48
+ # `UnitBaseClasses` member — plain ActiveSupport::TestCase is
49
+ # rejected as an unexamined default, so the confession base is
50
+ # structural, not optional. Inline helpers/doubles (non-*Test names)
51
+ # are not policed.
24
52
  # 3. **Anything else** — flagged as an unrecognized test directory.
25
53
  # Adding a new test home is a reviewed decision made in this cop's
26
54
  # config, not something a generated test can do implicitly.
@@ -53,6 +81,8 @@ module RuboCop
53
81
  # # Per best practice, we focus on controller tests. This model test
54
82
  # # is a deliberate exception: the safely_* guards raise on ANY direct
55
83
  # # call, so by design no controller path can reach them.
84
+ # # Wiring: none — the guards are dead-man switches with no runtime
85
+ # # caller; member_soft_deletion_test covers the soft-delete flow.
56
86
  # class MemberTest < Glib::LastResortUnitTest
57
87
  #
58
88
  # @example
@@ -63,14 +93,11 @@ module RuboCop
63
93
  # # bad — test/controllers/member_test.rb inheriting Glib::LastResortUnitTest
64
94
  #
65
95
  # # good — test/models/member_test.rb whose leading comments contain
66
- # # "deliberate exception" and explain why no controller path exists
96
+ # # "deliberate exception" and a "Wiring:" line, and explain why no
97
+ # # controller path exists
67
98
  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
99
+ include JustificationHeader
100
+ include TestFilePlacement
74
101
 
75
102
  MSG_UNRECOGNIZED = 'Test file in unrecognized directory `test/%<dir>s/`. Unit tests hide in ' \
76
103
  'uncategorized directories: move this file to a recognized home ' \
@@ -87,8 +114,23 @@ module RuboCop
87
114
  'hiding from that enforcement — move it there and justify it, or write a ' \
88
115
  'real %<dir>s test.'.freeze
89
116
 
117
+ MSG_UNIT_BASE_REQUIRED = '`%<base>s` is not a sanctioned unit-test base. A justified unit ' \
118
+ 'test must subclass one of: %<unit_bases>s — the name is the ' \
119
+ 'policy (a conscious last resort) and base-class-keyed tooling ' \
120
+ 'relies on it; plain ActiveSupport::TestCase reads as an ' \
121
+ 'unexamined default. Extend UnitBaseClasses in this cop\'s ' \
122
+ 'config if your project has another sanctioned unit base.'.freeze
123
+
124
+ MSG_NON_HTTP_BASE = '`%<base>s` is the non-HTTP side-effect base — it belongs only in ' \
125
+ '%<non_http_dirs>s. In `test/%<dir>s/` it evades that directory\'s ' \
126
+ 'enforcement (the runtime HTTP lint reaches only integration-base ' \
127
+ 'descendants; unit directories require a unit base under a ' \
128
+ 'justification header) — move the test, or subclass the base this ' \
129
+ 'directory is enforced through.'.freeze
130
+
90
131
  FORBIDDEN_BASES = %w[ActionDispatch::IntegrationTest Glib::IntegrationTest].freeze
91
132
  EXEMPT_DIRS = %w[controllers integration jobs mailers channels system linters tasks].freeze
133
+ NON_HTTP_DIRS = %w[jobs mailers channels].freeze
92
134
 
93
135
  def on_new_investigation
94
136
  return if processed_source.blank?
@@ -103,109 +145,93 @@ module RuboCop
103
145
 
104
146
  def enforce_directory_rules(dir)
105
147
  if exempt_directories.include?(dir)
106
- check_unit_bases(dir)
148
+ check_confined_bases(dir)
149
+ check_justification if non_http_base_in_non_http_dir?(dir)
107
150
  elsif unit_test_directories.include?(dir)
108
151
  check_justification
109
152
  check_base_classes
153
+ check_confined_bases(dir)
110
154
  else
111
155
  add_offense(offense_range, message: format(MSG_UNRECOGNIZED, dir: dir, known: known_directories))
112
156
  end
113
157
  end
114
158
 
115
- def check_justification
116
- return if leading_comments_contain_phrase?
117
-
118
- add_offense(offense_range, message: format(MSG_JUSTIFY, phrase: marker_phrase))
159
+ # Landing on the non-HTTP side-effect base is a conscious exception
160
+ # too (the class opted out of HTTP inside an integration-shaped
161
+ # home), so it carries the same header contract as a unit directory.
162
+ def non_http_base_in_non_http_dir?(dir)
163
+ non_http_directories.include?(dir) && class_with_base?(non_http_base_classes)
119
164
  end
120
165
 
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).
166
+ # A justified unit test must also DECLARE itself truthfully. Two
167
+ # tiers: integration bases get the tailored lie-about-type message;
168
+ # any other base outside UnitBaseClasses (e.g. plain
169
+ # ActiveSupport::TestCase) is rejected as an unexamined default —
170
+ # allowlist semantics, so the confession base is structural, not
171
+ # optional (disable via RequireUnitBase: false). Non-HTTP bases are
172
+ # skipped here so check_confined_bases' specific message fires.
124
173
  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
174
+ each_test_class do |class_node, base|
175
+ next if base.nil? || unit_base_classes.include?(base) || non_http_base_classes.include?(base)
139
176
 
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)
177
+ message = base_offense_message(base)
178
+ add_offense(class_node.parent_class, message: message) if message
147
179
  end
148
180
  end
149
181
 
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/')
182
+ def base_offense_message(base)
183
+ if forbidden_base_classes.include?(base)
184
+ format(MSG_BASE, base: base)
185
+ elsif require_unit_base?
186
+ format(MSG_UNIT_BASE_REQUIRED, base: base, unit_bases: unit_base_classes.join(', '))
175
187
  end
176
188
  end
177
189
 
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)
190
+ # Bases confined to a directory set — the unit base to unit-test
191
+ # directories, the non-HTTP side-effect base to NonHttpDirectories.
192
+ # Anywhere else, the base parks a test where the enforcement that
193
+ # directory relies on (header rule, runtime HTTP lint — which only
194
+ # reaches integration-base descendants) cannot see it.
195
+ def check_confined_bases(dir)
196
+ base_confinements.each do |bases, allowed_dirs, message|
197
+ next if allowed_dirs.include?(dir)
198
+
199
+ each_class_with_base(bases) do |superclass, base|
200
+ add_offense(superclass,
201
+ message: format(message, base: base, dir: dir,
202
+ unit_dirs: unit_directories_list,
203
+ non_http_dirs: non_http_directories_list))
204
+ end
182
205
  end
183
206
  end
184
207
 
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
208
+ def base_confinements
209
+ [
210
+ [unit_base_classes, unit_test_directories, MSG_UNIT_BASE],
211
+ [non_http_base_classes, non_http_directories, MSG_NON_HTTP_BASE]
212
+ ]
193
213
  end
194
214
 
195
- def marker_phrase = cop_config.fetch('MarkerPhrase', 'deliberate exception')
196
-
197
215
  def forbidden_base_classes = cop_config.fetch('ForbiddenBaseClasses', FORBIDDEN_BASES)
198
216
 
199
217
  def unit_base_classes = cop_config.fetch('UnitBaseClasses', %w[Glib::LastResortUnitTest])
200
218
 
219
+ def non_http_base_classes = cop_config.fetch('NonHttpBaseClasses', %w[Glib::NonHttpIntegrationTest])
220
+
221
+ def require_unit_base? = cop_config.fetch('RequireUnitBase', true)
222
+
201
223
  def unit_test_directories = cop_config.fetch('UnitTestDirectories', %w[models services helpers])
202
224
 
225
+ def non_http_directories = cop_config.fetch('NonHttpDirectories', NON_HTTP_DIRS)
226
+
203
227
  def exempt_directories = cop_config.fetch('ExemptDirectories', EXEMPT_DIRS)
204
228
 
205
229
  def known_directories = (exempt_directories + unit_test_directories).map { |d| "test/#{d}/" }.join(', ')
206
230
 
207
231
  def unit_directories_list = unit_test_directories.map { |d| "test/#{d}/" }.join(', ')
208
232
 
233
+ def non_http_directories_list = non_http_directories.map { |d| "test/#{d}/" }.join(', ')
234
+
209
235
  def offense_range
210
236
  range = processed_source.ast&.source_range || processed_source.comments.first&.source_range
211
237
  range.with(end_pos: range.begin_pos + [range.source_line.length, 1].max)
@@ -0,0 +1,74 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # Helpers shared by test-placement cops: classifying which test/
6
+ # subdirectory a file belongs to, and scanning its classes for base
7
+ # classes that placement rules key on.
8
+ module TestFilePlacement
9
+ private
10
+
11
+ # First path segment after the (last) `test/` component; nil when the
12
+ # file is not under one, '.' for files directly in test/. Only
13
+ # `*_test.rb` files count — anything else (helpers, shared scenarios,
14
+ # previews) is never loaded by the test runner, so there is nothing to
15
+ # enforce. In-module guard on purpose: the config Include says the same,
16
+ # but the cop must stay correct even when run with a forced config.
17
+ def test_subdirectory
18
+ path = processed_source.file_path.to_s
19
+ return nil unless File.basename(path).end_with?('_test.rb')
20
+
21
+ rest = path_after_test_root(path)
22
+ return nil unless rest
23
+
24
+ segments = rest.split('/')
25
+ segments.length == 1 ? '.' : segments.first
26
+ end
27
+
28
+ # Everything after the LAST `test/` root component (handles nesting
29
+ # like test/services/ai/x_test.rb and repos checked out under a
30
+ # directory that itself contains `test/`).
31
+ def path_after_test_root(path)
32
+ if (index = path.rindex('/test/'))
33
+ path[(index + '/test/'.length)..]
34
+ elsif path.start_with?('test/')
35
+ path.delete_prefix('test/')
36
+ end
37
+ end
38
+
39
+ def each_class_with_base(bases)
40
+ root = processed_source.ast
41
+ return unless root
42
+
43
+ root.each_node(:class) do |class_node|
44
+ base = class_node.parent_class&.const_name
45
+ yield(class_node.parent_class, base) if base && bases.include?(base)
46
+ end
47
+ end
48
+
49
+ # Yields each class node whose NAME marks it as a test class
50
+ # (`...Test`), with its superclass const name (nil when none). Scoping
51
+ # to *Test names keeps inline helpers/doubles (`class FakeGateway <
52
+ # BaseGateway`) out of base-class enforcement.
53
+ def each_test_class
54
+ root = processed_source.ast
55
+ return unless root
56
+
57
+ root.each_node(:class) do |class_node|
58
+ name = class_node.identifier.const_name
59
+ next unless name&.end_with?('Test')
60
+
61
+ yield(class_node, class_node.parent_class&.const_name)
62
+ end
63
+ end
64
+
65
+ def class_with_base?(bases)
66
+ found = false
67
+ each_class_with_base(bases) { |_superclass, _base| found = true }
68
+ found
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -29,13 +29,61 @@ module RuboCop
29
29
  # hidden fields have no label/placeholder, and they are the
30
30
  # dominant legitimate `name:` users (echoed tokens, dynamic-group
31
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.
32
+ # - `fields_creditCard` is exempt by default: it captures transient
33
+ # card credentials (tokenised client-side, stripped from params
34
+ # server-side) that must never bind to a persisted model.
35
+ # - `fields_dynamicGroup` is exempt by default: its `name:` is the
36
+ # structural array-param key for the group's rows, not a labelled
37
+ # model attribute the row template's inner fields are where
38
+ # `prop:` belongs.
39
+ # ## How to fix — the decision ladder
40
+ # Work down this ladder before even considering a disable. A full
41
+ # convert-or-justify sweep of a mature codebase (27 offenses) ended
42
+ # with ZERO disables — every "deliberately raw" case fell to one of
43
+ # these:
44
+ #
45
+ # 1. **Form bound + real attribute** → plain `prop:`.
46
+ # 2. **Form unbound** (pre-auth, filters) → bind a model. A blank
47
+ # record works for auth flows (`model: User.new`); for
48
+ # params-driven filters, a small presentation-only ActiveModel
49
+ # form object (the filtering can keep running off permitted
50
+ # params — the object exists only so fields derive names, labels
51
+ # and sticky values).
52
+ # 3. **Param namespace differs from the bound model on purpose**
53
+ # (server composes another record from `other_thing[...]` params)
54
+ # → bind a form object whose namespace IS the contract; override
55
+ # `model_name` if the class name doesn't match, and pass an
56
+ # explicit `url:` (POROs have no route for polymorphic
57
+ # derivation).
58
+ # 4. **Virtual param** (posted key isn't a column; controller applies
59
+ # it manually) → add a READER-ONLY virtual attribute to the model
60
+ # that derives the prefill (e.g. a `visibility` reader derived
61
+ # from an enum, a `notification_note` reader defaulting to the
62
+ # saved note). No writer → no mass-assignment surface; the
63
+ # controller's manual handling is unchanged.
64
+ # 5. **Label-less by design** → keep `prop:` and pass `label: ''`.
65
+ # The empty string is truthy, so it blocks the i18n label
66
+ # derivation — "it must not have a label" is NOT a reason for raw
67
+ # `name:`.
68
+ #
69
+ # ## Legitimately raw (don't convert)
70
+ # - Deliberately TOP-LEVEL params the controller reads un-namespaced:
71
+ # mode switches (`params[:mode]`), auth params (`otp_attempt`),
72
+ # signed link tokens, bare GET search terms (`q`), client-routing
73
+ # pass-throughs (`target_form_id`). `prop:` would namespace them
74
+ # under `param_key[...]` and break the read.
75
+ # - Client-side-only controls that never post: select-all master
76
+ # toggles, show/hide drivers referenced via `{ "var": ... }`.
77
+ # - Bulk selection arrays (`foo[ids][]` with per-row `checkValue:`).
78
+ # - Names fixed by an external protocol (`g-recaptcha-response`).
79
+ #
80
+ # ## Trade-off to be aware of
81
+ # `prop:` removes the literal wire name from the source: you can no
82
+ # longer grep `user[email]` and land in the view — the name is
83
+ # assembled at render time. In exchange names become predictable
84
+ # (always `param_key[attr]`) and attribute renames fail loudly at
85
+ # render (`field_assert_respond_to`) instead of silently posting a
86
+ # dead param.
39
87
  #
40
88
  # ## Autocorrect (unsafe)
41
89
  # `name: 'model[attr]'` becomes `prop: :attr`. Two caveats make this
@@ -65,9 +113,14 @@ module RuboCop
65
113
  extend AutoCorrector
66
114
 
67
115
  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
116
+ '`prop:` derives the field name and resolves label/placeholder from i18n. ' \
117
+ 'No bound model is not a blocker: bind a blank record or an ActiveModel form ' \
118
+ 'object (override `model_name` when the param namespace is the contract). A ' \
119
+ 'virtual param is not a blocker: add a reader-only attribute deriving the ' \
120
+ 'prefill. Label-less by design is not a blocker: keep `prop:` and pass ' \
121
+ "`label: ''`. Raw `name:` is right only for deliberately top-level params the " \
122
+ 'controller reads un-namespaced (mode switches, auth params, search terms) ' \
123
+ 'and client-side-only controls.'.freeze
71
124
 
72
125
  MODEL_ATTR_NAME = /\A\w+\[(\w+)\]\z/
73
126
 
@@ -100,7 +153,7 @@ module RuboCop
100
153
  end
101
154
 
102
155
  def exempt_field_methods
103
- cop_config.fetch('ExemptFieldMethods', %w[fields_hidden])
156
+ cop_config.fetch('ExemptFieldMethods', %w[fields_hidden fields_creditCard fields_dynamicGroup])
104
157
  end
105
158
  end
106
159
  end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.9.0".freeze
3
+ VERSION = "0.10.1".freeze
4
4
  end
5
5
  end
@@ -9,5 +9,6 @@ module RuboCop
9
9
  end
10
10
 
11
11
  RuboCop::ConfigLoader.ignore_parent_exclusion = true
12
+ RuboCop::ConfigObsoletion.files << RuboCop::DevDoc::PROJECT_ROOT.join("config", "obsoletion.yml").to_s
12
13
 
13
14
  Dir[File.join(__dir__, "cop", "dev_doc", "**", "*.rb")].each { |f| require f }
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.9.0
4
+ version: 0.10.1
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-05 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -102,7 +102,6 @@ files:
102
102
  - lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb
103
103
  - lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb
104
104
  - lib/rubocop/cop/dev_doc/migration/avoid_boolean_column.rb
105
- - lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb
106
105
  - lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb
107
106
  - lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb
108
107
  - lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb
@@ -115,6 +114,7 @@ files:
115
114
  - lib/rubocop/cop/dev_doc/migration/require_primary_key.rb
116
115
  - lib/rubocop/cop/dev_doc/migration/require_timestamps.rb
117
116
  - lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb
117
+ - lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb
118
118
  - lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb
119
119
  - lib/rubocop/cop/dev_doc/rails/avoid_ordering_by_id.rb
120
120
  - lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb
@@ -132,7 +132,6 @@ files:
132
132
  - lib/rubocop/cop/dev_doc/style/avoid_head_response.rb
133
133
  - lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb
134
134
  - lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb
135
- - lib/rubocop/cop/dev_doc/style/avoid_send.rb
136
135
  - lib/rubocop/cop/dev_doc/style/case_else_decision.rb
137
136
  - lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb
138
137
  - lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb
@@ -146,11 +145,14 @@ files:
146
145
  - lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb
147
146
  - lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
148
147
  - lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
148
+ - lib/rubocop/cop/dev_doc/test/justification_header.rb
149
+ - lib/rubocop/cop/dev_doc/test/no_persistence_in_mailer_previews.rb
149
150
  - lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb
150
151
  - lib/rubocop/cop/dev_doc/test/require_glib_travel.rb
151
152
  - lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb
152
153
  - lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb
153
154
  - lib/rubocop/cop/dev_doc/test/response_assert_equal.rb
155
+ - lib/rubocop/cop/dev_doc/test/test_file_placement.rb
154
156
  - lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb
155
157
  - lib/rubocop/dev_doc.rb
156
158
  - lib/rubocop/dev_doc/plugin.rb