rubocop-dev_doc 0.11.0 → 0.12.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +28 -0
  3. data/lib/dev_doc/i18n/pseudo_locale.rb +23 -19
  4. data/lib/dev_doc/test/lints/cop_drift_check.rb +43 -0
  5. data/lib/dev_doc/test/lints/cop_drift_test.rb +40 -0
  6. data/lib/dev_doc/test/lints/cron_schedule.rb +137 -115
  7. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +5 -2
  8. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +5 -0
  9. data/lib/dev_doc/test/lints/no_file_excludes.rb +32 -21
  10. data/lib/dev_doc/test/pseudo_i18n_crawler.rb +96 -60
  11. data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +3 -3
  12. data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +12 -11
  13. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -0
  14. data/lib/rubocop/cop/dev_doc/migration/date_column_naming.rb +3 -0
  15. data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +3 -2
  16. data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +2 -2
  17. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +3 -3
  18. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +3 -1
  19. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +4 -1
  20. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +5 -4
  21. data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +3 -4
  22. data/lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb +116 -0
  23. data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +2 -1
  24. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +23 -13
  25. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +21 -0
  26. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +13 -9
  27. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +13 -10
  28. data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +15 -7
  29. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +2 -2
  30. data/lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb +134 -0
  31. data/lib/rubocop/dev_doc/plugin.rb +2 -0
  32. data/lib/rubocop/dev_doc/version.rb +1 -1
  33. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83da1f7ff50b778721408a7f3713a0708a4749bf0af5e5df9e96810aa7f2be1c
4
- data.tar.gz: 6b69c63a8bd53bd6a012acaa46ae7134581dbf2760f5e7627948dcd8b137558f
3
+ metadata.gz: 51389b117ef5ff9744ada6455ee819b98b221eb789eef110531097ed54a3c7b3
4
+ data.tar.gz: fbd6bddccbe8b3edcea38a20f901536c6297ae664277d54ae7b35a7b2bccdf88
5
5
  SHA512:
6
- metadata.gz: 8b79ae456b8c5518023e9b89ba0232dfeb9fc64cb9a53d2e3e0ac18854a2d8b6f96b02e5a2ff3fd0928c19a32e9f5ae5204e3a5c3aa24af566dea1d8c7b24186
7
- data.tar.gz: 7ffae58fc78c00ca6abdfab21e3c4517528183c3130e765b2aac198a44b91c2e8b2c9969fc9c3e7bd7c42243d78406f659ea4be94575df28fde2753bb4f22dfa
6
+ metadata.gz: cad293d67ee400ef543df57dc721baa74d784df7fbcdf2536ef6e6f7475732f8fb6cb8b80a924ff1a210d5bd5ec9cd60eec12a06f2b04b0a1827bab38899fc01
7
+ data.tar.gz: df1facc84e226e99d256ce256063011c6aeed3d491a7e417c9b2eff7555c1aa7730e46e005a58234e826cb8b87221e1b26b60b0ddeb27515bb7d0b3f129be615
data/config/default.yml CHANGED
@@ -325,6 +325,10 @@ DevDoc/Style/StringSymbolComparison:
325
325
  Description: "Comparing a known-string source (params, request.headers, ENV) to a symbol literal is always false."
326
326
  Enabled: true
327
327
 
328
+ DevDoc/Style/AvoidSymbolizingBoundaryInput:
329
+ Description: "Unvalidated `.to_sym` on params/request.headers lets any client value into the symbol domain; allow-list it (glib_allowlist_symbol_param) instead."
330
+ Enabled: true
331
+
328
332
  DevDoc/Style/LiteralOrInWhenClause:
329
333
  Description: "`||`/`&&` between literals in a `when` clause folds to one value — the other literal silently never matches; use comma-separated alternatives."
330
334
  Enabled: true
@@ -521,6 +525,30 @@ DevDoc/Test/AvoidUnitTest:
521
525
  - "test/**/*.rb"
522
526
  - "spec/**/*.rb"
523
527
 
528
+ # Closes the fig-leaf hole in the anti-evasion pack: the runtime HTTP lint
529
+ # proves a request was ISSUED, not that the assertions observe it. The base
530
+ # class is an honesty declaration — under the integration base, ALL unit-test
531
+ # idioms flag: controller internals (@controller/assigns/ivar reflection),
532
+ # app-layer instantiation (*Service/*Policy/... — used to compute an expected
533
+ # value it is a CIRCULAR assertion), direct mailer invocation, production
534
+ # helper includes, and mock/stub machinery. Deliberately NOT flagged: model
535
+ # reads (the DB is an outbound edge of the boundary; fixture lookup is setup)
536
+ # and job invocation (jobs are the inbound edge for cron flows). Born from a
537
+ # live 2026-07 evasion by a guard-aware agent (@controller.policy(...) behind
538
+ # a fig-leaf GET); structure transfers between agents and machines, memory
539
+ # doesn't. Honest unit tests declare themselves: Glib::LastResortUnitTest.
540
+ DevDoc/Test/NoUnitIdiomsInIntegrationTests:
541
+ Description: "Integration tests are boundary tests: one entry point (request/job), assert only observable outputs (status, body, DB, mails). Unit-test idioms belong under Glib::LastResortUnitTest."
542
+ Enabled: true
543
+ # ADDITIVE to the cop's built-in baseline (Service, Policy, Form, Presenter,
544
+ # Serializer, Decorator — DEFAULT_UNIT_CLASS_SUFFIXES, un-droppable by
545
+ # config). List your app's extra unit-layer suffixes here.
546
+ UnitClassSuffixes: []
547
+ Include:
548
+ - "test/controllers/**/*.rb"
549
+ - "test/integration/**/*.rb"
550
+ - "spec/requests/**/*.rb"
551
+
524
552
  DevDoc/Test/RequireGlibIntegrationBase:
525
553
  Description: "Integration tests in HTTP-driven directories must subclass the project base (Glib::IntegrationTest) — the raw framework base sidesteps the runtime HTTP lint and the glib plumbing."
526
554
  Enabled: true
@@ -39,9 +39,12 @@
39
39
  # locale can never leak to real users.
40
40
  module DevDoc
41
41
  module I18n
42
+ # Pseudo-locale ('en-PSEUDO') machinery for hardcoded-text detection:
43
+ # wraps every translated string in visible markers so any UNMARKED copy
44
+ # in a rendered page is, by construction, text that bypassed I18n.
42
45
  module PseudoLocale
43
- PSEUDO_LOCALE = 'en-PSEUDO'
44
- MARKER = '⟦'
46
+ PSEUDO_LOCALE = 'en-PSEUDO'.freeze
47
+ MARKER = '⟦'.freeze
45
48
 
46
49
  def self.active?
47
50
  ENV['PSEUDO_I18N'] == '1'
@@ -79,7 +82,8 @@ module DevDoc
79
82
  ::I18n::Backend::Simple.prepend(Backend)
80
83
  ::I18n.available_locales |= [PSEUDO_LOCALE.to_sym]
81
84
  force_locale!
82
- ::Rails.logger.info('[pseudo_locale] en-PSEUDO locale enabled (forced) for hardcoded-text detection') if defined?(::Rails.logger)
85
+ # &. only because the logger may not be assigned yet this early in boot.
86
+ ::Rails.logger&.info('[pseudo_locale] en-PSEUDO locale enabled (forced) for hardcoded-text detection')
83
87
  end
84
88
 
85
89
  # Force every request to render in the pseudo locale.
@@ -109,6 +113,22 @@ module DevDoc
109
113
  # copy. Accenting these would corrupt strftime (`%A`), number formats.
110
114
  SKIP_NAMESPACES = %w[i18n date time datetime number support].freeze
111
115
 
116
+ # Some t() values are not display copy — asset filenames, paths, URLs.
117
+ # The asset helpers feed these to Vite, which raises on a missing
118
+ # manifest entry, so accenting them 500s the page. Leave them untouched.
119
+ #
120
+ # Note: do NOT treat a bare "/" as a path — display copy legitimately
121
+ # contains slashes ("Lamp/Bulbs", "Food/Food Scraps"). A real path is an
122
+ # all-lowercase identifier chain (`shared/layouts/main`, `images/icon`);
123
+ # copy has capitals or spaces, so it won't match the lowercase-path rule.
124
+ NON_COPY = %r{
125
+ \A\s*\z # blank
126
+ | \Ahttps?:// # url
127
+ | \A[a-z0-9_]+(?:[/.][a-z0-9_]+)+\z # lowercase path / dotted id (shared/layouts/main)
128
+ }x
129
+
130
+ ASSET_EXT = /\.(?:png|jpe?g|svg|gif|webp|ico|bmp|css|js|mjs|html?|pdf|woff2?|ttf|otf|mp[34]|json|xml|csv)\z/i
131
+
112
132
  def lookup(locale, key, scope = [], options = ::I18n::EMPTY_HASH)
113
133
  return super unless locale.to_s == PSEUDO_LOCALE
114
134
 
@@ -136,22 +156,6 @@ module DevDoc
136
156
  end
137
157
  end
138
158
 
139
- # Some t() values are not display copy — asset filenames, paths, URLs.
140
- # The asset helpers feed these to Vite, which raises on a missing
141
- # manifest entry, so accenting them 500s the page. Leave them untouched.
142
- #
143
- # Note: do NOT treat a bare "/" as a path — display copy legitimately
144
- # contains slashes ("Lamp/Bulbs", "Food/Food Scraps"). A real path is an
145
- # all-lowercase identifier chain (`shared/layouts/main`, `images/icon`);
146
- # copy has capitals or spaces, so it won't match the lowercase-path rule.
147
- NON_COPY = %r{
148
- \A\s*\z # blank
149
- | \Ahttps?:// # url
150
- | \A[a-z0-9_]+(?:[/.][a-z0-9_]+)+\z # lowercase path / dotted id (shared/layouts/main)
151
- }x
152
-
153
- ASSET_EXT = /\.(?:png|jpe?g|svg|gif|webp|ico|bmp|css|js|mjs|html?|pdf|woff2?|ttf|otf|mp[34]|json|xml|csv)\z/i
154
-
155
159
  def wrap(str)
156
160
  return str if str.match?(NON_COPY) || str.match?(ASSET_EXT)
157
161
 
@@ -0,0 +1,43 @@
1
+ module DevDoc
2
+ module Test
3
+ module Lints
4
+ # Cop-inventory tripwire: every DevDoc cop the installed gem registers
5
+ # must be MENTIONED BY FULL NAME in the project's `.rubocop.yml` —
6
+ # enabled, disabled-with-reason, or a commented adoption-TODO all count.
7
+ # A mention proves a reviewed decision; a cop nobody has named is a cop
8
+ # nobody has decided about.
9
+ #
10
+ # ## Why offenses alone can't guard this
11
+ # A new cop WITH offenses announces itself by failing rubocop. Two
12
+ # shapes stay silent forever without this check:
13
+ # - Cops shipped `Enabled: false` (review aids) never produce an
14
+ # offense, so no run ever surfaces them.
15
+ # - Zero-offense auto-enables that need PROJECT configuration to bite
16
+ # (AllowedMethods / RolePredicates-style lists) run at weak gem
17
+ # defaults while looking like coverage.
18
+ # Agent memory and human habit don't transfer between machines; a
19
+ # suite-failing check does.
20
+ #
21
+ # ## Usage
22
+ # This file holds only the pure, Rails-free core (so the gem's own spec
23
+ # suite can load it). Projects require the test-class wrapper instead,
24
+ # from the test helper (Rails test env):
25
+ #
26
+ # require 'dev_doc/test/lints/cop_drift_test'
27
+ module CopDriftCheck
28
+ COP_NAME = %r{^(DevDoc/[A-Za-z0-9]+/[A-Za-z0-9]+):}
29
+
30
+ module_function
31
+
32
+ # Pure core, spec-covered: which registered cops does the config text
33
+ # never mention? Boundary-aware match — a plain substring check would
34
+ # let a cop whose name PREFIXES another mentioned cop pass for free
35
+ # (RequireGlibTravel riding on RequireGlibTravelBlock).
36
+ def unmentioned_cops(show_cops_output, config_text)
37
+ registered = show_cops_output.scan(COP_NAME).flatten.uniq
38
+ registered.reject { |cop| config_text.match?(/#{Regexp.escape(cop)}(?![A-Za-z0-9])/) }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ require 'dev_doc/test/lints/cop_drift_check'
2
+
3
+ # Suite-failing tripwire (see DevDoc::Test::Lints::CopDriftCheck for the
4
+ # doctrine): fails until every registered DevDoc cop is mentioned by full
5
+ # name in the project's .rubocop.yml.
6
+ #
7
+ # Require from the project's test helper (Rails test env):
8
+ #
9
+ # require 'dev_doc/test/lints/cop_drift_test'
10
+ #
11
+ # The superclass reference is deliberately UNGUARDED: consumers are Rails
12
+ # projects, so if ActiveSupport isn't loaded yet the require fails loudly
13
+ # (fix the require order) instead of silently skipping the tripwire.
14
+ # Opt out of a single run with SKIP_COP_DRIFT=1.
15
+ class DevDocCopDriftTest < ActiveSupport::TestCase
16
+ def test_every_dev_doc_cop_is_mentioned_in_rubocop_yml
17
+ skip 'opted out via SKIP_COP_DRIFT' if ENV['SKIP_COP_DRIFT'] == '1'
18
+
19
+ output = `bundle exec rubocop --show-cops 2>/dev/null`
20
+ assert output.match?(DevDoc::Test::Lints::CopDriftCheck::COP_NAME),
21
+ 'no DevDoc cops parsed from `rubocop --show-cops` — did the command fail, ' \
22
+ 'or is the rubocop-dev_doc plugin not loaded in .rubocop.yml?'
23
+
24
+ unmentioned = DevDoc::Test::Lints::CopDriftCheck.unmentioned_cops(output, project_rubocop_config)
25
+ assert_empty unmentioned, drift_message(unmentioned)
26
+ end
27
+
28
+ private
29
+
30
+ def project_rubocop_config
31
+ Rails.root.join('.rubocop.yml').read
32
+ end
33
+
34
+ def drift_message(unmentioned)
35
+ 'The installed rubocop-dev_doc registers cops that .rubocop.yml never mentions — ' \
36
+ 'a gem release shipped new cops nobody adopted or declined. Run an adoption wave ' \
37
+ "(measure, triage, enable) or decline each BY NAME with a reasoned comment:\n " \
38
+ "#{unmentioned.join("\n ")}"
39
+ end
40
+ end
@@ -4,6 +4,73 @@ require 'fugit'
4
4
  module DevDoc
5
5
  module Test
6
6
  module Lints
7
+ # Locates a job class's source file under a jobs directory and reads a
8
+ # duration constant (e.g. `STALENESS_THRESHOLD = 1.hour`) from it.
9
+ # Split out of CronScheduleChecker: file location + duration parsing is
10
+ # a self-contained concern with no knowledge of cron.
11
+ module JobDurationConstant
12
+ # Seconds per unit for `<n>.<unit>` ActiveSupport::Duration literals
13
+ # (e.g. `45.minutes`, `1.hour`, `7.days`).
14
+ DURATION_UNITS = {
15
+ 'second' => 1, 'seconds' => 1,
16
+ 'minute' => 60, 'minutes' => 60,
17
+ 'hour' => 3600, 'hours' => 3600,
18
+ 'day' => 86_400, 'days' => 86_400,
19
+ 'week' => 604_800, 'weeks' => 604_800
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ # Returns the constant's value in seconds, or nil if the job file or
25
+ # constant is not found, or the value isn't a simple duration literal.
26
+ def read(class_name, jobs_root, constant)
27
+ source = job_source(class_name, jobs_root)
28
+ return nil unless source
29
+
30
+ parse_duration(source, constant)
31
+ end
32
+
33
+ # Searches `jobs_root` recursively for a .rb file that defines
34
+ # `class_name`. Returns the file contents as a String, or nil.
35
+ def job_source(class_name, jobs_root)
36
+ # Fast path: the conventional file name (MyFetchJob → my_fetch_job.rb).
37
+ expected_file = "#{class_name_to_file(class_name)}.rb"
38
+ Dir.glob(jobs_root.join('**', expected_file)).each do |path|
39
+ content = File.read(path)
40
+ return content if content.include?(class_name)
41
+ end
42
+
43
+ # Fallback: search all .rb files for a matching class definition.
44
+ # Useful for non-conventional file layouts.
45
+ Dir.glob(jobs_root.join('**', '*.rb')).each do |path|
46
+ content = File.read(path)
47
+ return content if content.include?("class #{class_name}")
48
+ end
49
+
50
+ nil
51
+ end
52
+
53
+ # Converts CamelCase class name to snake_case filename stem.
54
+ # E.g. WorkDiaryBackfillJob → work_diary_backfill_job
55
+ def class_name_to_file(class_name)
56
+ # Strip leading module namespaces (Foo::BarJob → BarJob)
57
+ base = class_name.split('::').last.to_s
58
+ base.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
59
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
60
+ .downcase
61
+ end
62
+
63
+ def parse_duration(source, constant)
64
+ units_pattern = DURATION_UNITS.keys.join('|')
65
+ pattern = /#{Regexp.escape(constant)}\s*=\s*(\d+(?:\.\d+)?)\.(#{units_pattern})\b/
66
+
67
+ match = source.match(pattern)
68
+ return nil unless match
69
+
70
+ (match[1].to_f * DURATION_UNITS.fetch(match[2])).to_i
71
+ end
72
+ end
73
+
7
74
  # Framework-agnostic check: reads a sidekiq-cron-style `schedules.yml`
8
75
  # and returns a list of offender descriptions for any cron expression
9
76
  # that fires less often than `max_interval_seconds`, and optionally
@@ -42,19 +109,7 @@ module DevDoc
42
109
  def offenders
43
110
  return MissingFile unless @schedules_path.exist?
44
111
 
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
112
+ schedule_entries.filter_map { |name, cron, _opts| interval_offense(name, cron) }
58
113
  end
59
114
 
60
115
  # Checks that each job's staleness threshold constant is strictly
@@ -79,26 +134,12 @@ module DevDoc
79
134
  # constant produce no constant to inspect and are skipped silently.
80
135
  def staleness_offenders(jobs_path: nil, constant: DEFAULT_STALENESS_CONSTANT)
81
136
  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
137
 
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'])
138
+ jobs_root = jobs_path && Pathname.new(jobs_path)
139
+ return [] unless jobs_root&.exist?
89
140
 
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'
141
+ schedule_entries.filter_map do |name, cron, opts|
142
+ staleness_offense(name, cron, opts['class'], jobs_root, constant)
102
143
  end
103
144
  end
104
145
 
@@ -125,6 +166,44 @@ module DevDoc
125
166
 
126
167
  private
127
168
 
169
+ # Parses the schedules file into [name, cron, opts] triples, skipping
170
+ # entries without a cron. YAML.safe_load returns nil for an empty file.
171
+ def schedule_entries
172
+ (YAML.safe_load(@schedules_path.read) || {}).filter_map do |name, opts|
173
+ next unless opts.is_a?(Hash) && (cron = opts['cron'])
174
+
175
+ [name, cron, opts]
176
+ end
177
+ end
178
+
179
+ # Offender description for one schedule entry, or nil if compliant.
180
+ def interval_offense(name, cron)
181
+ interval = interval_seconds_for(cron)
182
+ return " #{name}: invalid cron expression (cron: #{cron.inspect})" if interval == :invalid
183
+ return nil if interval.nil? || interval <= @max_interval_seconds
184
+
185
+ " #{name}: every #{self.class.format_seconds(interval)} (cron: #{cron.inspect})"
186
+ end
187
+
188
+ # Offender description for one schedule entry whose job's staleness
189
+ # constant is not strictly greater than the cron interval, or nil if
190
+ # compliant. Jobs without a parseable constant are skipped silently
191
+ # (they may use a per-record stale? method instead).
192
+ def staleness_offense(name, cron, class_name, jobs_root, constant)
193
+ return nil unless class_name
194
+
195
+ interval = interval_seconds_for(cron)
196
+ return nil if interval == :invalid || interval.nil?
197
+
198
+ threshold = JobDurationConstant.read(class_name, jobs_root, constant)
199
+ return nil if threshold.nil? || interval < threshold
200
+
201
+ " #{name} (#{class_name}): cron fires every " \
202
+ "#{self.class.format_seconds(interval)} but #{constant} is " \
203
+ "#{self.class.format_seconds(threshold)} — " \
204
+ 'cron interval must be strictly less than the staleness threshold'
205
+ end
206
+
128
207
  # Computes the maximum interval (in seconds) between consecutive
129
208
  # fires of a cron expression, sampled from a fixed anchor so the
130
209
  # result is independent of `Time.now`. Returns `:invalid` if
@@ -134,6 +213,14 @@ module DevDoc
134
213
  cron = parse_in_utc(cron_string)
135
214
  return :invalid unless cron
136
215
 
216
+ fires = sample_fires(cron)
217
+ return nil if fires.size < 2
218
+
219
+ fires.each_cons(2).map { |a, b| (b - a).to_i }.max
220
+ end
221
+
222
+ # Up to SAMPLE_COUNT consecutive fire times from the fixed anchor.
223
+ def sample_fires(cron)
137
224
  fires = []
138
225
  t = SAMPLE_ANCHOR
139
226
  SAMPLE_COUNT.times do
@@ -142,10 +229,7 @@ module DevDoc
142
229
 
143
230
  fires << t
144
231
  end
145
-
146
- return nil if fires.size < 2
147
-
148
- fires.each_cons(2).map { |a, b| (b - a).to_i }.max
232
+ fires
149
233
  end
150
234
 
151
235
  # We measure the cron's nominal interval, not its wall-clock
@@ -158,74 +242,6 @@ module DevDoc
158
242
  Fugit::Cron.parse("#{cron_string} UTC") ||
159
243
  Fugit::Cron.parse(cron_string)
160
244
  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
245
  end
230
246
 
231
247
  # Reject cron schedules that fire less often than once per day
@@ -306,12 +322,7 @@ module DevDoc
306
322
 
307
323
  skip "no #{schedules_path} found" if result == CronScheduleChecker::MissingFile
308
324
 
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'
325
+ assert result.empty?, long_interval_message(max, result)
315
326
  end
316
327
 
317
328
  def test_staleness_threshold_aligned
@@ -322,12 +333,7 @@ module DevDoc
322
333
 
323
334
  skip "no #{schedules_path} found" if result == CronScheduleChecker::MissingFile
324
335
 
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'
336
+ assert result.empty?, staleness_message(constant, result)
331
337
  end
332
338
 
333
339
  private
@@ -339,6 +345,22 @@ module DevDoc
339
345
  def jobs_path
340
346
  Rails.root.join('app/jobs')
341
347
  end
348
+
349
+ def long_interval_message(max, offenders)
350
+ "Cron schedules must be valid and fire at least every " \
351
+ "#{CronScheduleChecker.format_seconds(max)}. Offenders:\n" \
352
+ "#{offenders.join("\n")}\n\n" \
353
+ "Use a frequent cron + a persisted staleness check in the job. " \
354
+ 'See https://github.com/hgani/dev-doc/blob/main/best_practices/backend/en/08_job.md#cron-schedule-frequency'
355
+ end
356
+
357
+ def staleness_message(constant, offenders)
358
+ "Each job's #{constant} must be strictly greater than its cron " \
359
+ "interval (otherwise every cron fire does the work and the " \
360
+ "staleness check is useless). Offenders:\n" \
361
+ "#{offenders.join("\n")}\n\n" \
362
+ 'See https://github.com/hgani/dev-doc/blob/main/best_practices/backend/en/08_job.md#cron-schedule-frequency'
363
+ end
342
364
  end
343
365
  end
344
366
  end
@@ -172,6 +172,10 @@ module DevDoc
172
172
  # # SNAPSHOT_RESULTS_ROOT = 'test/requests' # override if needed
173
173
  # end
174
174
  module DuplicateSnapshot
175
+ # Default snapshot-dir root. Per-project override: redefine the
176
+ # constant on the test class that includes this module.
177
+ SNAPSHOT_RESULTS_ROOT = 'test/controllers'.freeze
178
+
175
179
  def test_no_unjustified_duplicate_snapshots
176
180
  result = DuplicateSnapshotChecker.new(snapshot_results_root).offenders
177
181
  if result == DuplicateSnapshotChecker::MissingDir
@@ -194,8 +198,7 @@ module DevDoc
194
198
  end
195
199
 
196
200
  def snapshot_results_root
197
- root = defined?(self.class::SNAPSHOT_RESULTS_ROOT) ? self.class::SNAPSHOT_RESULTS_ROOT : 'test/controllers'
198
- Rails.root.join(root)
201
+ Rails.root.join(self.class::SNAPSHOT_RESULTS_ROOT)
199
202
  end
200
203
  end
201
204
  end
@@ -54,6 +54,11 @@ module DevDoc
54
54
  enforced by DevDoc/Test/RequireUnitTestJustification. Relocating assertions
55
55
  into the mailer-preview harness only pins the mailer itself — a request test
56
56
  must still prove the controller feeds it (params -> Mailer.with(...)).
57
+ NEVER bolt on a throwaway request just to silence this lint: a fig-leaf
58
+ GET whose response nothing asserts against is the disguise the companion
59
+ cop (DevDoc/Test/NoUnitIdiomsInIntegrationTests) polices. HTTP is not the
60
+ only entry point — cron-driven flows are legitimately exercised by
61
+ invoking their JOB and asserting its observable outputs.
57
62
  MSG
58
63
 
59
64
  def before_setup
@@ -16,7 +16,7 @@ module DevDoc
16
16
  # this via the per-project override on the `NoFileExcludes` module.
17
17
  DEFAULT_ALLOWED_FILES = %w[db/schema.rb].freeze
18
18
 
19
- GLOB_CHARACTERS = /[*?\[\]{}]/.freeze
19
+ GLOB_CHARACTERS = /[*?\[\]{}]/
20
20
 
21
21
  # Sentinel returned by `#offenders` when the `.rubocop.yml` doesn't
22
22
  # exist. Distinguishable from `[]` (file present, all good) so the
@@ -37,18 +37,24 @@ module DevDoc
37
37
 
38
38
  # YAML.safe_load returns nil for an empty file. Rubocop config
39
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)
40
+ (YAML.safe_load(@rubocop_yml_path.read) || {}).flat_map do |section, value|
41
+ next [] unless value.is_a?(Hash)
42
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}"
43
+ literal_excludes(value).map do |entry|
44
+ " #{section}: Exclude includes literal file #{entry.inspect}"
49
45
  end
50
46
  end
51
47
  end
48
+
49
+ private
50
+
51
+ # Literal-path (non-glob, non-allowlisted) entries of one section's
52
+ # `Exclude:` list.
53
+ def literal_excludes(section_config)
54
+ Array(section_config['Exclude']).select do |entry|
55
+ entry.is_a?(String) && !entry.match?(GLOB_CHARACTERS) && !@allowed_files.include?(entry)
56
+ end
57
+ end
52
58
  end
53
59
 
54
60
  # Reject literal-file entries in any `Exclude:` list under
@@ -61,7 +67,7 @@ module DevDoc
61
67
  # pattern is acceptable for new code in the same file. That sets the
62
68
  # wrong expectation and lets violations multiply.
63
69
  #
64
- # Inline `# rubocop:disable Cop/Name` at the violation line(s) keeps
70
+ # An inline `rubocop:disable Cop/Name` directive at the violation line(s) keeps
65
71
  # the suppression visible to anyone reading the code AND scopes it to
66
72
  # the specific lines — a fresh violation in the same file still gets
67
73
  # flagged. The rationale for the suppression lives next to the code it
@@ -80,7 +86,8 @@ module DevDoc
80
86
  # ✔ Visible inline, scoped to the specific line(s)
81
87
  #
82
88
  # # db/migrate/20260505035728_create_pr_reviews.rb
83
- # t.jsonb :diff_files, default: [] # rubocop:disable DevDoc/Migration/AvoidColumnDefault
89
+ # # rubocop:disable DevDoc/Migration/AvoidColumnDefault -- <reason>
90
+ # t.jsonb :diff_files, default: []
84
91
  #
85
92
  # ✔ Glob — still acceptable, targets a category not a specific file
86
93
  #
@@ -109,19 +116,23 @@ module DevDoc
109
116
 
110
117
  skip "no #{rubocop_yml_path} found" if result == NoFileExcludesChecker::MissingFile
111
118
 
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
+ assert result.empty?, no_file_excludes_message(result)
119
120
  end
120
121
 
121
122
  private
122
- def rubocop_yml_path
123
- Rails.root.join('.rubocop.yml')
124
- end
123
+
124
+ def rubocop_yml_path
125
+ Rails.root.join('.rubocop.yml')
126
+ end
127
+
128
+ def no_file_excludes_message(offenders)
129
+ "`.rubocop.yml` Exclude lists must contain only globs " \
130
+ "(category patterns), not literal file paths. A literal-path " \
131
+ "exclude hides the suppression from readers of the file. " \
132
+ "Use `# rubocop:disable Cop/Name` at the violation line(s) " \
133
+ "instead — visible to readers, scoped to the specific lines.\n\n" \
134
+ "Offenders:\n#{offenders.join("\n")}"
135
+ end
125
136
  end
126
137
  end
127
138
  end