rubocop-dev_doc 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +318 -33
  3. data/lib/dev_doc/test/best_practice_lints.rb +31 -0
  4. data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
  5. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
  6. data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
  7. data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
  8. data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
  9. data/lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb +92 -0
  10. data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +86 -0
  11. data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +68 -13
  12. data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
  13. data/lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb +18 -3
  14. data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
  15. data/lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb +53 -0
  16. data/lib/rubocop/cop/dev_doc/migration/require_primary_key.rb +55 -0
  17. data/lib/rubocop/cop/dev_doc/migration/require_timestamps.rb +4 -13
  18. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +56 -0
  19. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +135 -0
  20. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
  21. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
  22. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +83 -0
  23. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
  24. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
  25. data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
  26. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
  27. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
  28. data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +29 -15
  29. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +56 -22
  30. data/lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb +102 -0
  31. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +42 -10
  32. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
  33. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
  34. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
  35. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
  36. data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
  37. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
  38. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
  39. data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
  40. data/lib/rubocop/dev_doc/version.rb +1 -1
  41. data/lib/rubocop-dev_doc.rb +1 -0
  42. metadata +73 -10
  43. data/lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb +0 -53
@@ -0,0 +1,345 @@
1
+ require 'yaml'
2
+ require 'fugit'
3
+
4
+ module DevDoc
5
+ module Test
6
+ module Lints
7
+ # Framework-agnostic check: reads a sidekiq-cron-style `schedules.yml`
8
+ # and returns a list of offender descriptions for any cron expression
9
+ # that fires less often than `max_interval_seconds`, and optionally
10
+ # checks that each job's staleness threshold is greater than the cron
11
+ # interval.
12
+ #
13
+ # Wrapped by the Minitest module `CronSchedule` below — see that
14
+ # module for the rationale and examples. Tests cover the checker
15
+ # directly so they don't have to mock a test framework.
16
+ class CronScheduleChecker
17
+ # 1 day. Weekly (and longer) crons are always wrong: no staleness
18
+ # threshold can usefully exceed the cron interval, so a weekly cron
19
+ # can't be paired with a meaningful self-throttle. Daily cron is the
20
+ # most generous interval the pattern supports (works for
21
+ # weekly-desired refreshes like Myki). Hourly+ is always safe.
22
+ DEFAULT_MAX_INTERVAL_SECONDS = 86_400
23
+
24
+ # Sentinel returned by `#offenders` when the schedules file doesn't
25
+ # exist. Distinguishable from `[]` (file present, all good) so the
26
+ # Minitest wrapper can `skip` rather than `assert`.
27
+ MissingFile = Module.new
28
+
29
+ # Default constant name to look up in job source files when checking
30
+ # that the staleness threshold is larger than the cron interval.
31
+ DEFAULT_STALENESS_CONSTANT = 'STALENESS_THRESHOLD'.freeze
32
+
33
+ def initialize(schedules_path, max_interval_seconds: DEFAULT_MAX_INTERVAL_SECONDS)
34
+ @schedules_path = schedules_path
35
+ @max_interval_seconds = max_interval_seconds
36
+ end
37
+
38
+ # Returns an Array<String> of offender descriptions (one per
39
+ # invalid or too-infrequent cron), `[]` if all crons are
40
+ # compliant, or `MissingFile` if the schedules file doesn't
41
+ # exist.
42
+ def offenders
43
+ return MissingFile unless @schedules_path.exist?
44
+
45
+ # YAML.safe_load returns nil for an empty file.
46
+ (YAML.safe_load(@schedules_path.read) || {}).each_with_object([]) do |(name, opts), acc|
47
+ next unless opts.is_a?(Hash) && (cron = opts['cron'])
48
+
49
+ interval = interval_seconds_for(cron)
50
+ if interval == :invalid
51
+ acc << " #{name}: invalid cron expression (cron: #{cron.inspect})"
52
+ next
53
+ end
54
+ next if interval.nil? || interval <= @max_interval_seconds
55
+
56
+ acc << " #{name}: every #{self.class.format_seconds(interval)} (cron: #{cron.inspect})"
57
+ end
58
+ end
59
+
60
+ # Checks that each job's staleness threshold constant is strictly
61
+ # greater than the cron interval. Returns an Array<String> of
62
+ # offender descriptions, `[]` if all are compliant, or `MissingFile`
63
+ # if the schedules file doesn't exist.
64
+ #
65
+ # Parameters:
66
+ # jobs_path - Pathname/String pointing to `app/jobs/` (or any
67
+ # directory tree to search for job source files).
68
+ # If nil or the path doesn't exist, returns `[]`
69
+ # (nothing to check — silently skipped so projects
70
+ # without job-level constants don't have to opt out).
71
+ # constant - Name of the constant to look up (default:
72
+ # `STALENESS_THRESHOLD`). Projects that use a
73
+ # different convention (e.g. `STALE_AFTER`) can
74
+ # override this.
75
+ #
76
+ # The constant value is extracted by grepping the job source files for
77
+ # the pattern `CONSTANT = <number>.<unit>` (e.g. `1.hour`, `45.minutes`).
78
+ # Jobs that use a per-record stale? method instead of a job-level
79
+ # constant produce no constant to inspect and are skipped silently.
80
+ def staleness_offenders(jobs_path: nil, constant: DEFAULT_STALENESS_CONSTANT)
81
+ return MissingFile unless @schedules_path.exist?
82
+ return [] if jobs_path.nil?
83
+
84
+ jobs_root = Pathname.new(jobs_path)
85
+ return [] unless jobs_root.exist?
86
+
87
+ (YAML.safe_load(@schedules_path.read) || {}).each_with_object([]) do |(name, opts), acc|
88
+ next unless opts.is_a?(Hash) && (cron = opts['cron']) && (class_name = opts['class'])
89
+
90
+ interval = interval_seconds_for(cron)
91
+ next if interval == :invalid || interval.nil?
92
+
93
+ threshold = staleness_threshold_for(class_name, jobs_root, constant)
94
+ next if threshold.nil? # no constant found — skip silently
95
+
96
+ next if interval < threshold
97
+
98
+ acc << " #{name} (#{class_name}): cron fires every " \
99
+ "#{self.class.format_seconds(interval)} but #{constant} is " \
100
+ "#{self.class.format_seconds(threshold)} — " \
101
+ 'cron interval must be strictly less than the staleness threshold'
102
+ end
103
+ end
104
+
105
+ def self.format_seconds(seconds)
106
+ return "#{seconds}s" if seconds < 60
107
+ return "#{seconds / 60}m" if seconds < 3600
108
+ return "#{seconds / 3600}h" if seconds < 86_400
109
+
110
+ "#{seconds / 86_400}d"
111
+ end
112
+
113
+ # Anchored reference for sampling cron fires. A fixed Monday so
114
+ # weekday-restricted crons behave the same regardless of when the
115
+ # test suite runs.
116
+ SAMPLE_ANCHOR = Time.utc(2025, 1, 6).freeze
117
+
118
+ # How many consecutive fires to sample. 100 covers:
119
+ # - sub-hourly crons (max gap surfaces within minutes)
120
+ # - business-hours patterns like `0 9-17 * * *` (overnight gap
121
+ # visible after the first day)
122
+ # - weekly/monthly (max gap visible within a few cycles)
123
+ # - yearly (still gets ≥2 fires)
124
+ SAMPLE_COUNT = 100
125
+
126
+ private
127
+
128
+ # Computes the maximum interval (in seconds) between consecutive
129
+ # fires of a cron expression, sampled from a fixed anchor so the
130
+ # result is independent of `Time.now`. Returns `:invalid` if
131
+ # the expression doesn't parse, or nil if it parses but yields
132
+ # fewer than two fires.
133
+ def interval_seconds_for(cron_string)
134
+ cron = parse_in_utc(cron_string)
135
+ return :invalid unless cron
136
+
137
+ fires = []
138
+ t = SAMPLE_ANCHOR
139
+ SAMPLE_COUNT.times do
140
+ t = cron.next_time(t)
141
+ break if t.nil?
142
+
143
+ fires << t
144
+ end
145
+
146
+ return nil if fires.size < 2
147
+
148
+ fires.each_cons(2).map { |a, b| (b - a).to_i }.max
149
+ end
150
+
151
+ # We measure the cron's nominal interval, not its wall-clock
152
+ # interval in the server's local zone. A "daily" cron in a
153
+ # DST-observing zone really does have a 25h gap twice a year —
154
+ # but that's not what this lint is asking about. Parsing with
155
+ # UTC removes DST entirely; if the user already specified a
156
+ # zone, we honour it.
157
+ def parse_in_utc(cron_string)
158
+ Fugit::Cron.parse("#{cron_string} UTC") ||
159
+ Fugit::Cron.parse(cron_string)
160
+ end
161
+
162
+ # Locates the job source file for `class_name` under `jobs_root`
163
+ # and extracts the value of `constant` in seconds. Returns nil
164
+ # if the file or constant is not found, or if the value cannot be
165
+ # parsed as a simple ActiveSupport::Duration literal.
166
+ def staleness_threshold_for(class_name, jobs_root, constant)
167
+ source = job_source(class_name, jobs_root)
168
+ return nil unless source
169
+
170
+ parse_duration_constant(source, constant)
171
+ end
172
+
173
+ # Searches `jobs_root` recursively for a .rb file that defines
174
+ # `class_name`. Returns the file contents as a String, or nil.
175
+ def job_source(class_name, jobs_root)
176
+ # Build the conventional filename (e.g. MyFetchJob → my_fetch_job.rb)
177
+ # and also search by class name presence as a fallback.
178
+ expected_file = "#{class_name_to_file(class_name)}.rb"
179
+
180
+ # Fast path: try the conventional file name first.
181
+ Dir.glob(jobs_root.join('**', expected_file)).each do |path|
182
+ content = File.read(path)
183
+ return content if content.include?(class_name)
184
+ end
185
+
186
+ # Fallback: search all .rb files for a class definition matching
187
+ # the name. Useful for non-conventional file layouts.
188
+ Dir.glob(jobs_root.join('**', '*.rb')).each do |path|
189
+ content = File.read(path)
190
+ return content if content.include?("class #{class_name}")
191
+ end
192
+
193
+ nil
194
+ end
195
+
196
+ # Converts CamelCase class name to snake_case filename stem.
197
+ # E.g. WorkDiaryBackfillJob → work_diary_backfill_job
198
+ def class_name_to_file(class_name)
199
+ # Strip leading module namespaces (Foo::BarJob → BarJob)
200
+ base = class_name.split('::').last.to_s
201
+ base.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
202
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
203
+ .downcase
204
+ end
205
+
206
+ # Extracts the numeric second value of a simple duration constant
207
+ # from source text. Handles `<n>.<unit>` ActiveSupport::Duration
208
+ # literals (e.g. `45.minutes`, `1.hour`, `7.days`).
209
+ # Returns an Integer number of seconds, or nil if not found/parseable.
210
+ DURATION_UNITS = {
211
+ 'second' => 1, 'seconds' => 1,
212
+ 'minute' => 60, 'minutes' => 60,
213
+ 'hour' => 3600, 'hours' => 3600,
214
+ 'day' => 86_400, 'days' => 86_400,
215
+ 'week' => 604_800, 'weeks' => 604_800
216
+ }.freeze
217
+
218
+ def parse_duration_constant(source, constant)
219
+ units_pattern = DURATION_UNITS.keys.join('|')
220
+ pattern = /#{Regexp.escape(constant)}\s*=\s*(\d+(?:\.\d+)?)\.(#{units_pattern})\b/
221
+
222
+ match = source.match(pattern)
223
+ return nil unless match
224
+
225
+ value = match[1].to_f
226
+ unit = match[2]
227
+ (value * DURATION_UNITS.fetch(unit)).to_i
228
+ end
229
+ end
230
+
231
+ # Reject cron schedules that fire less often than once per day
232
+ # (configurable). Anything weekly or longer is always wrong.
233
+ #
234
+ # ## Rationale
235
+ # Long-interval cron jobs fail catastrophically when missed.
236
+ # Sidekiq-cron does not replay missed occurrences — if the server is
237
+ # down at Mon 3am, the next fire is the following Mon 3am. A single
238
+ # server restart at the wrong moment costs a full interval of
239
+ # staleness. Same in production: transient Sidekiq issues, deploys,
240
+ # or scheduler glitches all cause the same outage.
241
+ #
242
+ # The recommended pattern is a frequent cron + persisted last-run
243
+ # timestamp ("self-throttled cron"). Cron fires often; the job
244
+ # itself decides whether to do the work based on a `fetched_at`-style
245
+ # column. Fresh? Fast no-op. Stale? Run the actual work. Missed runs
246
+ # self-heal within the cron interval.
247
+ #
248
+ # The cron interval must be strictly less than the job's desired
249
+ # refresh cadence (its staleness threshold). One missed cron fire
250
+ # then only adds ≤ 1 cron interval of staleness, never doubling the
251
+ # cadence.
252
+ #
253
+ # Desired refresh | Right cron | Wrong cron
254
+ # ----------------+---------------------------+-----------
255
+ # Weekly | Daily (or shorter) | Weekly
256
+ # Daily | Hourly (or shorter) | Daily
257
+ # Hourly | Every 15 min (or shorter) | Hourly
258
+ #
259
+ # Weekly+ crons are always wrong, regardless of staleness threshold:
260
+ # no useful threshold can exceed the cron interval, so the
261
+ # self-throttle mechanism can't work. This lint enforces that.
262
+ # A second test (`test_staleness_threshold_aligned`) also verifies
263
+ # that each job's `STALENESS_THRESHOLD` constant (or a configurable
264
+ # name) is strictly greater than the cron interval — catching the
265
+ # "inverted pattern" where every cron fire does the work because the
266
+ # threshold is equal to or shorter than the interval.
267
+ #
268
+ # ❌
269
+ # myki_fetch:
270
+ # cron: "0 3 * * 1" # weekly — one miss = 7 days stale
271
+ # class: MykiFetchJob
272
+ #
273
+ # ✔️
274
+ # myki_fetch:
275
+ # cron: "0 3 * * *" # daily; MykiAccount#stale?(7.days) gates work
276
+ # class: MykiFetchJob
277
+ #
278
+ # See `best_practices/backend/en/08_job.md#cron-schedule-frequency`.
279
+ #
280
+ # ## Usage
281
+ # Include this module in a Minitest test class to get the
282
+ # `test_no_long_interval_cron` and `test_staleness_threshold_aligned`
283
+ # methods. To override the cap, redefine `MAX_INTERVAL_SECONDS` on the
284
+ # test class. To change the constant name searched in job files, redefine
285
+ # `STALENESS_CONSTANT_NAME`.
286
+ #
287
+ # class CronScheduleTest < ActiveSupport::TestCase
288
+ # include DevDoc::Test::Lints::CronSchedule
289
+ # # MAX_INTERVAL_SECONDS = 3600 # tighten to 1h
290
+ # # STALENESS_CONSTANT_NAME = 'STALE_AFTER' # different convention
291
+ # end
292
+ module CronSchedule
293
+ # Default cap. Per-project override: redefine the constant on the
294
+ # test class that includes this module.
295
+ MAX_INTERVAL_SECONDS = CronScheduleChecker::DEFAULT_MAX_INTERVAL_SECONDS
296
+
297
+ # Name of the constant to look up in job source files. Per-project
298
+ # override: redefine on the test class that includes this module.
299
+ STALENESS_CONSTANT_NAME = CronScheduleChecker::DEFAULT_STALENESS_CONSTANT
300
+
301
+ def test_no_long_interval_cron
302
+ max = self.class::MAX_INTERVAL_SECONDS
303
+ result = CronScheduleChecker
304
+ .new(schedules_path, max_interval_seconds: max)
305
+ .offenders
306
+
307
+ skip "no #{schedules_path} found" if result == CronScheduleChecker::MissingFile
308
+
309
+ assert result.empty?,
310
+ "Cron schedules must be valid and fire at least every " \
311
+ "#{CronScheduleChecker.format_seconds(max)}. Offenders:\n" \
312
+ "#{result.join("\n")}\n\n" \
313
+ "Use a frequent cron + a persisted staleness check in the job. " \
314
+ 'See https://github.com/hgani/dev-doc/blob/main/best_practices/backend/en/08_job.md#cron-schedule-frequency'
315
+ end
316
+
317
+ def test_staleness_threshold_aligned
318
+ constant = self.class::STALENESS_CONSTANT_NAME
319
+ result = CronScheduleChecker
320
+ .new(schedules_path)
321
+ .staleness_offenders(jobs_path: jobs_path, constant: constant)
322
+
323
+ skip "no #{schedules_path} found" if result == CronScheduleChecker::MissingFile
324
+
325
+ assert result.empty?,
326
+ "Each job's #{constant} must be strictly greater than its cron " \
327
+ "interval (otherwise every cron fire does the work and the " \
328
+ "staleness check is useless). Offenders:\n" \
329
+ "#{result.join("\n")}\n\n" \
330
+ 'See https://github.com/hgani/dev-doc/blob/main/best_practices/backend/en/08_job.md#cron-schedule-frequency'
331
+ end
332
+
333
+ private
334
+
335
+ def schedules_path
336
+ Rails.root.join('config/schedules.yml')
337
+ end
338
+
339
+ def jobs_path
340
+ Rails.root.join('app/jobs')
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,197 @@
1
+ require 'digest'
2
+ require 'pathname'
3
+
4
+ module DevDoc
5
+ module Test
6
+ module Lints
7
+ # Framework-agnostic check: scans a directory of controller-test
8
+ # snapshot result dirs (`*_results/*.json`) and reports groups of
9
+ # byte-identical snapshots within the same dir.
10
+ #
11
+ # Wrapped by the Minitest module `DuplicateSnapshot` below — see that
12
+ # module for the rationale and inline opt-out format. Tests cover the
13
+ # checker directly so they don't have to mock a test framework.
14
+ class DuplicateSnapshotChecker
15
+ # Inline opt-out token. Append to the `test '...' do` line (or a
16
+ # comment line directly above) of EACH test in an intentionally
17
+ # duplicate group: `# allow_duplicate_snapshot: <reason>`.
18
+ MARKER = 'allow_duplicate_snapshot'.freeze
19
+
20
+ # Matches a minitest `test '...' do` declaration; captures the quote
21
+ # (1), the name (2), and the trailing content after `do` (3).
22
+ TEST_DEFINITION = /^\s*test\s+(["'])(.+?)\1\s+do\b(.*)$/
23
+
24
+ # Sentinel returned by `#offenders` when no `*_results` dirs exist,
25
+ # so the Minitest wrapper can `skip` rather than `assert`.
26
+ MissingDir = Module.new
27
+
28
+ # @param results_root [String, Pathname] dir that contains the
29
+ # `*_results/` snapshot dirs (e.g. `test/controllers`).
30
+ def initialize(results_root)
31
+ @results_root = Pathname(results_root)
32
+ end
33
+
34
+ # Returns Array<String> of offender descriptions (one per duplicate
35
+ # group that has at least one un-marked member), `[]` if there are
36
+ # no unjustified duplicates, or `MissingDir` if there are no
37
+ # `*_results` dirs at all.
38
+ def offenders
39
+ dirs = Dir.glob(@results_root.join('*_results')).select { |d| File.directory?(d) }
40
+ return MissingDir if dirs.empty?
41
+
42
+ dirs.sort.flat_map { |dir| offenders_in(dir) }
43
+ end
44
+
45
+ private
46
+
47
+ def offenders_in(dir)
48
+ files = Dir.glob(File.join(dir, '*.json'))
49
+ return [] if files.size < 2
50
+
51
+ marked = marker_map_for(dir)
52
+ duplicate_groups(files)
53
+ .reject { |group| group.all? { |f| marked[File.basename(f, '.json')] } }
54
+ .map { |group| describe_group(dir, group, marked) }
55
+ end
56
+
57
+ # Groups of byte-identical snapshot files (size >= 2).
58
+ def duplicate_groups(files)
59
+ files.group_by { |f| Digest::MD5.hexdigest(File.read(f)) }
60
+ .values.select { |group| group.size >= 2 }
61
+ end
62
+
63
+ def describe_group(dir, group, marked)
64
+ rows = group.sort.map do |f|
65
+ meth = File.basename(f, '.json')
66
+ " #{marked[meth] ? '[marked] ' : '[UNMARKED] '}#{meth}"
67
+ end
68
+ " #{File.basename(dir)}: #{group.size} identical snapshots —\n#{rows.join("\n")}"
69
+ end
70
+
71
+ # Maps each `test '...'` in the dir's sibling test file to whether
72
+ # it carries the inline opt-out marker. Key is the minitest method
73
+ # name (`test_` + name with whitespace → `_`), which is also the
74
+ # snapshot file's basename.
75
+ def marker_map_for(dir)
76
+ test_file = test_file_for(dir)
77
+ return {} unless test_file.exist?
78
+
79
+ lines = test_file.readlines
80
+ (0...lines.size).each_with_object({}) do |i, map|
81
+ m = lines[i].match(TEST_DEFINITION)
82
+ next unless m
83
+
84
+ map["test_#{m[2].gsub(/\s+/, '_')}"] = marked?(m[3], lines, i)
85
+ end
86
+ end
87
+
88
+ # A test is marked if the opt-out token appears on its `do` line's
89
+ # trailing comment, or on the comment line directly above it.
90
+ def marked?(trailing, lines, index)
91
+ context = trailing.to_s + (index.positive? ? lines[index - 1] : '')
92
+ context.include?(MARKER)
93
+ end
94
+
95
+ # Resolve the test file for a `*_results` dir. Usually it's the
96
+ # same-named sibling (`foo_controller_test_results` →
97
+ # `foo_controller_test.rb`). But the results dir is written by the test
98
+ # framework under `test/controllers/` regardless of where the test file
99
+ # lives — a job/integration test, or a class whose file is named
100
+ # differently, lands its snapshots here too. When the same-named file is
101
+ # absent, fall back to locating the class by name anywhere under `test/`,
102
+ # so the inline marker is always reachable.
103
+ def test_file_for(dir)
104
+ base = File.basename(dir).sub(/_results\z/, '')
105
+ direct = Pathname(File.join(File.dirname(dir), "#{base}.rb"))
106
+ return direct if direct.exist?
107
+
108
+ find_file_defining(camelize(base)) || direct
109
+ end
110
+
111
+ # First test file under the test root that defines the given class.
112
+ def find_file_defining(class_name)
113
+ pattern = /^\s*class\s+#{Regexp.escape(class_name)}\b/
114
+ match = Dir.glob(@results_root.parent.join('**', '*.rb')).find do |f|
115
+ File.foreach(f).any? { |line| line.match?(pattern) }
116
+ end
117
+ Pathname(match) if match
118
+ end
119
+
120
+ def camelize(snake)
121
+ snake.split('_').map(&:capitalize).join
122
+ end
123
+ end
124
+
125
+ # Reject byte-identical controller-test snapshots within the same
126
+ # `*_results` dir.
127
+ #
128
+ # ## Rationale
129
+ # "Test quality" is normally subjective. Two tests serializing the
130
+ # *exact same* rendered response is an objective, computable proxy for
131
+ # a redundant or weak test. When `response_assert_equal` snapshots
132
+ # collide, it's almost always one of:
133
+ #
134
+ # 1. **Same scenario tested twice** — should be one test with the
135
+ # combined assertions.
136
+ # 2. **Fixtures don't exercise the difference** — the test claims to
137
+ # verify behaviour X, but the inputs all collapse to the same
138
+ # rendered state, so it passes without actually testing X. (The
139
+ # most dangerous case — green but vacuous.)
140
+ # 3. **A negative/absence assertion** — a test for "X is NOT shown"
141
+ # legitimately renders identically to the baseline. This is the
142
+ # one *correct* duplicate; acknowledge it with the inline marker.
143
+ #
144
+ # Aggressive by design: a false positive costs only a quick review and
145
+ # one inline comment, while a silent duplicate hides a real (1) or (2).
146
+ #
147
+ # ## Inline opt-out (NOT a central allow-list)
148
+ # The justification lives next to the test, so it can't rot or detach.
149
+ # Mark EACH test in an intentionally-duplicate group with
150
+ # `# allow_duplicate_snapshot: <reason>` — either as a trailing comment on
151
+ # its `test '...' do` line, or on the comment line directly above it (use
152
+ # the latter when the test line is already long):
153
+ #
154
+ # test 'index hides the banner' do # allow_duplicate_snapshot: renders like the baseline
155
+ # ...
156
+ # end
157
+ #
158
+ # # allow_duplicate_snapshot: renders like the baseline
159
+ # test 'index hides the banner for a guest who is not in any of the groups' do
160
+ # ...
161
+ # end
162
+ #
163
+ # ## Usage
164
+ # class DuplicateSnapshotTest < ActiveSupport::TestCase
165
+ # include DevDoc::Test::Lints::DuplicateSnapshot
166
+ # # SNAPSHOT_RESULTS_ROOT = 'test/requests' # override if needed
167
+ # end
168
+ module DuplicateSnapshot
169
+ def test_no_unjustified_duplicate_snapshots
170
+ result = DuplicateSnapshotChecker.new(snapshot_results_root).offenders
171
+ if result == DuplicateSnapshotChecker::MissingDir
172
+ skip "no snapshot result dirs under #{snapshot_results_root}"
173
+ end
174
+
175
+ assert result.empty?, failure_message(result)
176
+ end
177
+
178
+ private
179
+
180
+ def failure_message(result)
181
+ "Found byte-identical controller-test snapshots. Identical output usually means a " \
182
+ "redundant or weak test: the same scenario tested twice (merge them), or fixtures that " \
183
+ "don't actually exercise the difference (strengthen the test). De-duplicate, or — if the " \
184
+ "duplication is intentional (e.g. a negative/absence assertion that correctly renders like " \
185
+ "the baseline) — mark EACH test in the group with `# #{DuplicateSnapshotChecker::MARKER}: " \
186
+ "<reason>`, as a trailing comment on its `test '...' do` line or on the line directly " \
187
+ "above it.\n\nOffending groups:\n#{result.join("\n")}"
188
+ end
189
+
190
+ def snapshot_results_root
191
+ root = defined?(self.class::SNAPSHOT_RESULTS_ROOT) ? self.class::SNAPSHOT_RESULTS_ROOT : 'test/controllers'
192
+ Rails.root.join(root)
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,128 @@
1
+ require 'yaml'
2
+
3
+ module DevDoc
4
+ module Test
5
+ module Lints
6
+ # Framework-agnostic check: reads a `.rubocop.yml`, walks every
7
+ # `Exclude:` list under each top-level section, and returns offender
8
+ # descriptions for entries that are literal file paths (no glob
9
+ # characters) outside the configured allowlist.
10
+ #
11
+ # Wrapped by the Minitest module `NoFileExcludes` below — see that
12
+ # module for the rationale.
13
+ class NoFileExcludesChecker
14
+ # File paths universally exempt because they're auto-generated and
15
+ # nobody reads them like source code. Consumer projects can extend
16
+ # this via the per-project override on the `NoFileExcludes` module.
17
+ DEFAULT_ALLOWED_FILES = %w[db/schema.rb].freeze
18
+
19
+ GLOB_CHARACTERS = /[*?\[\]{}]/.freeze
20
+
21
+ # Sentinel returned by `#offenders` when the `.rubocop.yml` doesn't
22
+ # exist. Distinguishable from `[]` (file present, all good) so the
23
+ # Minitest wrapper can `skip` rather than `assert`.
24
+ MissingFile = Module.new
25
+
26
+ def initialize(rubocop_yml_path, allowed_files: DEFAULT_ALLOWED_FILES)
27
+ @rubocop_yml_path = rubocop_yml_path
28
+ @allowed_files = allowed_files.to_set
29
+ end
30
+
31
+ # Returns an Array<String> of offender descriptions (one per
32
+ # literal-path entry in any `Exclude:` list), `[]` if all entries
33
+ # are globs or allowlisted, or `MissingFile` if the `.rubocop.yml`
34
+ # doesn't exist.
35
+ def offenders
36
+ return MissingFile unless @rubocop_yml_path.exist?
37
+
38
+ # YAML.safe_load returns nil for an empty file. Rubocop config
39
+ # is a flat top-level hash; `Exclude:` keys live one level down.
40
+ (YAML.safe_load(@rubocop_yml_path.read) || {}).each_with_object([]) do |(section, value), acc|
41
+ next unless value.is_a?(Hash)
42
+
43
+ Array(value['Exclude']).each do |entry|
44
+ next unless entry.is_a?(String)
45
+ next if entry.match?(GLOB_CHARACTERS)
46
+ next if @allowed_files.include?(entry)
47
+
48
+ acc << " #{section}: Exclude includes literal file #{entry.inspect}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ # Reject literal-file entries in any `Exclude:` list under
55
+ # `.rubocop.yml`.
56
+ #
57
+ # ## Rationale
58
+ # A literal-path `Exclude:` makes the suppression invisible to anyone
59
+ # reading the excluded file. They see the violating code, see no
60
+ # `# rubocop:disable` annotation near it, and reasonably conclude the
61
+ # pattern is acceptable for new code in the same file. That sets the
62
+ # wrong expectation and lets violations multiply.
63
+ #
64
+ # Inline `# rubocop:disable Cop/Name` at the violation line(s) keeps
65
+ # the suppression visible to anyone reading the code AND scopes it to
66
+ # the specific lines — a fresh violation in the same file still gets
67
+ # flagged. The rationale for the suppression lives next to the code it
68
+ # describes, so a future reader sees it without having to cross-check
69
+ # `.rubocop.yml`.
70
+ #
71
+ # Globs (`vendor/**/*`, `db/migrate/**/*.rb`, `bin/*`) remain fine
72
+ # because they target a category, not a file someone might be reading.
73
+ #
74
+ # ❌ Hides the suppression from readers of the file
75
+ #
76
+ # DevDoc/Migration/AvoidColumnDefault:
77
+ # Exclude:
78
+ # - "db/migrate/20260505035728_create_pr_reviews.rb"
79
+ #
80
+ # ✔ Visible inline, scoped to the specific line(s)
81
+ #
82
+ # # db/migrate/20260505035728_create_pr_reviews.rb
83
+ # t.jsonb :diff_files, default: [] # rubocop:disable DevDoc/Migration/AvoidColumnDefault
84
+ #
85
+ # ✔ Glob — still acceptable, targets a category not a specific file
86
+ #
87
+ # AllCops:
88
+ # Exclude:
89
+ # - "vendor/**/*"
90
+ #
91
+ # ## Usage
92
+ # Include this module in a Minitest test class. To allowlist additional
93
+ # auto-generated literal paths (e.g. a project-specific generated file),
94
+ # redefine `ALLOWED_FILE_EXCLUDES` on the test class.
95
+ #
96
+ # class RubocopConfigTest < ActiveSupport::TestCase
97
+ # include DevDoc::Test::Lints::NoFileExcludes
98
+ # # ALLOWED_FILE_EXCLUDES = %w[db/schema.rb Gemfile.lock].freeze
99
+ # end
100
+ module NoFileExcludes
101
+ # Default allowlist. Per-project override: redefine the constant on
102
+ # the test class that includes this module.
103
+ ALLOWED_FILE_EXCLUDES = NoFileExcludesChecker::DEFAULT_ALLOWED_FILES
104
+
105
+ def test_no_file_excludes_in_rubocop_yml
106
+ result = NoFileExcludesChecker
107
+ .new(rubocop_yml_path, allowed_files: self.class::ALLOWED_FILE_EXCLUDES)
108
+ .offenders
109
+
110
+ skip "no #{rubocop_yml_path} found" if result == NoFileExcludesChecker::MissingFile
111
+
112
+ assert result.empty?,
113
+ "`.rubocop.yml` Exclude lists must contain only globs " \
114
+ "(category patterns), not literal file paths. A literal-path " \
115
+ "exclude hides the suppression from readers of the file. " \
116
+ "Use `# rubocop:disable Cop/Name` at the violation line(s) " \
117
+ "instead — visible to readers, scoped to the specific lines.\n\n" \
118
+ "Offenders:\n#{result.join("\n")}"
119
+ end
120
+
121
+ private
122
+ def rubocop_yml_path
123
+ Rails.root.join('.rubocop.yml')
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end