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.
- checksums.yaml +4 -4
- data/config/default.yml +28 -0
- data/lib/dev_doc/i18n/pseudo_locale.rb +23 -19
- data/lib/dev_doc/test/lints/cop_drift_check.rb +43 -0
- data/lib/dev_doc/test/lints/cop_drift_test.rb +40 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +137 -115
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +5 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +5 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +32 -21
- data/lib/dev_doc/test/pseudo_i18n_crawler.rb +96 -60
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +3 -3
- data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +12 -11
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -0
- data/lib/rubocop/cop/dev_doc/migration/date_column_naming.rb +3 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +3 -2
- data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +2 -2
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +3 -3
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +3 -1
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +4 -1
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +5 -4
- data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +3 -4
- data/lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb +116 -0
- data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +2 -1
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +23 -13
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +21 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +13 -9
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +13 -10
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +15 -7
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +2 -2
- data/lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb +134 -0
- data/lib/rubocop/dev_doc/plugin.rb +2 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51389b117ef5ff9744ada6455ee819b98b221eb789eef110531097ed54a3c7b3
|
|
4
|
+
data.tar.gz: fbd6bddccbe8b3edcea38a20f901536c6297ae664277d54ae7b35a7b2bccdf88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
138
|
+
jobs_root = jobs_path && Pathname.new(jobs_path)
|
|
139
|
+
return [] unless jobs_root&.exist?
|
|
89
140
|
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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 = /[*?\[\]{}]
|
|
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) || {}).
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|