rubocop-dev_doc 0.2.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.
- checksums.yaml +4 -4
- data/config/default.yml +230 -61
- data/lib/dev_doc/test/best_practice_lints.rb +31 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +1 -1
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +2 -2
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +31 -4
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
- data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +58 -3
|
@@ -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
|