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.
- checksums.yaml +4 -4
- data/config/default.yml +69 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +27 -9
- data/lib/rubocop/cop/dev_doc/{migration → rails}/avoid_bypassing_validation.rb +1 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb +3 -3
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +133 -78
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +1 -1
- data/lib/rubocop/cop/dev_doc/test/justification_header.rb +80 -0
- data/lib/rubocop/cop/dev_doc/test/no_persistence_in_mailer_previews.rb +94 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +103 -77
- data/lib/rubocop/cop/dev_doc/test/test_file_placement.rb +74 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +64 -11
- data/lib/rubocop/dev_doc/version.rb +1 -1
- data/lib/rubocop/dev_doc.rb +1 -0
- metadata +6 -4
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +0 -95
|
@@ -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
|
|
96
|
+
# # "deliberate exception" and a "Wiring:" line, and explain why no
|
|
97
|
+
# # controller path exists
|
|
67
98
|
class RequireUnitTestJustification < Base
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
122
|
-
#
|
|
123
|
-
#
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
# -
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
|
69
|
-
'
|
|
70
|
-
'
|
|
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
|
data/lib/rubocop/dev_doc.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|