rubocop-dev_doc 0.7.0 → 0.8.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 +138 -1
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
- data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
- data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +5 -3
- data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
- data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
- data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d3924b0184e741e19b3f29854b6f472a9bd54667ddfef1e59ccc1aa000e5073
|
|
4
|
+
data.tar.gz: d59338919f825edec8ed8bbc717d26f19782fbc88411d90e1a8e0c110d07235d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 606dad217da6a65ddc8eb66040f2f8747be5fed37aa27764f2ae74a2fa2266158e848d780b2ee10a7d690f20832b0c36b3a5b3b16d766c433c99088226b78c8e
|
|
7
|
+
data.tar.gz: 49cac436e0dfef59326c94b89187c5d6001578972d0faa76f2fca683cfd93b9dc1e5866093351884a49e0ad58cb0f6f768a4711275f57e6bf85d24efc913137f
|
data/config/default.yml
CHANGED
|
@@ -31,6 +31,10 @@ DevDoc/Test:
|
|
|
31
31
|
DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
|
|
32
32
|
DocumentationExtension: ".md"
|
|
33
33
|
|
|
34
|
+
DevDoc/View:
|
|
35
|
+
DocumentationBaseURL: https://github.com/hgani/dev-doc/blob/main/docs/cops
|
|
36
|
+
DocumentationExtension: ".md"
|
|
37
|
+
|
|
34
38
|
DevDoc/Migration/AvoidJsonColumn:
|
|
35
39
|
Description: "Use `jsonb` instead of `json` for column types."
|
|
36
40
|
Enabled: true
|
|
@@ -83,11 +87,22 @@ DevDoc/Migration/BooleanColumnNotNull:
|
|
|
83
87
|
# Intentionally global (no Include) — these patterns are risky in any file, not only migrations.
|
|
84
88
|
# Add project-specific Exclude entries (e.g. db/seeds.rb, lib/tasks/**/*.rb) in your .rubocop.yml
|
|
85
89
|
# for places where bulk operations are intentional and performance-critical.
|
|
90
|
+
#
|
|
91
|
+
# Strict SUPERSET of Rails/SkipsModelValidations: covers all 18 methods that
|
|
92
|
+
# cop flags, plus `delete_all` and `save(validate: false)`, with per-method
|
|
93
|
+
# actionable messages. When enabling this cop, DISABLE the stock one —
|
|
94
|
+
# otherwise every offense needs a disable comment naming both cops:
|
|
95
|
+
# Rails/SkipsModelValidations:
|
|
96
|
+
# Enabled: false
|
|
97
|
+
# Use AllowedMethods for skips that are by-design in your project (touch,
|
|
98
|
+
# counter caches) instead of per-site disables.
|
|
86
99
|
DevDoc/Migration/AvoidBypassingValidation:
|
|
87
|
-
Description: "Avoid methods that bypass validations and callbacks (`update_column`, `update_all`, `insert_all`, etc.)."
|
|
100
|
+
Description: "Avoid methods that bypass validations and callbacks (`update_column`, `update_all`, `insert_all`, `touch`, etc.). Superset of Rails/SkipsModelValidations — disable that cop when enabling this one."
|
|
88
101
|
Enabled: false
|
|
102
|
+
AllowedMethods: []
|
|
89
103
|
Exclude:
|
|
90
104
|
- "spec/**/*"
|
|
105
|
+
- "test/**/*"
|
|
91
106
|
|
|
92
107
|
DevDoc/Migration/DateColumnNaming:
|
|
93
108
|
Description: "Date columns should end with `_on`; datetime columns should end with `_at`."
|
|
@@ -299,6 +314,51 @@ DevDoc/Style/StringSymbolComparison:
|
|
|
299
314
|
Description: "Comparing a known-string source (params, request.headers, ENV) to a symbol literal is always false."
|
|
300
315
|
Enabled: true
|
|
301
316
|
|
|
317
|
+
DevDoc/Style/LiteralOrInWhenClause:
|
|
318
|
+
Description: "`||`/`&&` between literals in a `when` clause folds to one value — the other literal silently never matches; use comma-separated alternatives."
|
|
319
|
+
Enabled: true
|
|
320
|
+
# Autocorrect (|| chains of truthy literals -> comma list) deliberately changes
|
|
321
|
+
# behavior: the dead alternative starts matching. That is the fix, but re-verify
|
|
322
|
+
# any code that depended on the buggy match — hence unsafe.
|
|
323
|
+
SafeAutoCorrect: false
|
|
324
|
+
|
|
325
|
+
DevDoc/Style/RedundantGuardAfterBang:
|
|
326
|
+
Description: "A nil/presence guard right after assigning from a bang method is dead code — bang methods raise on failure instead of returning nil."
|
|
327
|
+
Enabled: true
|
|
328
|
+
# Ruby-core mutator bangs legitimately return nil when nothing changed, so
|
|
329
|
+
# guarding them is real logic, not a dead guard. Extend this list for any
|
|
330
|
+
# app-specific bang with deliberate nil-on-no-op semantics (or better, rename
|
|
331
|
+
# such a method — a nil-returning `!` is itself misleading).
|
|
332
|
+
AllowedMethods:
|
|
333
|
+
- gsub!
|
|
334
|
+
- sub!
|
|
335
|
+
- strip!
|
|
336
|
+
- lstrip!
|
|
337
|
+
- rstrip!
|
|
338
|
+
- chomp!
|
|
339
|
+
- chop!
|
|
340
|
+
- squeeze!
|
|
341
|
+
- tr!
|
|
342
|
+
- tr_s!
|
|
343
|
+
- delete!
|
|
344
|
+
- delete_prefix!
|
|
345
|
+
- delete_suffix!
|
|
346
|
+
- capitalize!
|
|
347
|
+
- downcase!
|
|
348
|
+
- upcase!
|
|
349
|
+
- swapcase!
|
|
350
|
+
- slice!
|
|
351
|
+
- uniq!
|
|
352
|
+
- compact!
|
|
353
|
+
- flatten!
|
|
354
|
+
- reject!
|
|
355
|
+
- select!
|
|
356
|
+
- filter!
|
|
357
|
+
|
|
358
|
+
DevDoc/Style/LiteralOperatorInCondition:
|
|
359
|
+
Description: "`||` between literals in a boolean condition (if/unless/while/until/ternary) folds to one operand, making the condition constant. Flag only — the fix depends on intent. `&&` is left to Lint/LiteralAsCondition."
|
|
360
|
+
Enabled: true
|
|
361
|
+
|
|
302
362
|
DevDoc/Test/ResponseAssertEqual:
|
|
303
363
|
Description: "Controller tests that assert on `response.body` should also snapshot the full response with `response_assert_equal`."
|
|
304
364
|
# Enabled by default. A project adopting the gem with an existing backlog will
|
|
@@ -377,6 +437,19 @@ DevDoc/Test/AvoidGlibTravelFreeze:
|
|
|
377
437
|
- "test/**/*.rb"
|
|
378
438
|
- "spec/**/*.rb"
|
|
379
439
|
|
|
440
|
+
DevDoc/Test/RequireGlibTravelBlock:
|
|
441
|
+
Description: "`glib_travel` must be called with a block — the bare anchor form leaks frozen time past the test. A bare duration argument (`glib_travel(61.seconds)`) that advances the clock is the accepted non-block form."
|
|
442
|
+
Enabled: true
|
|
443
|
+
Include:
|
|
444
|
+
- "test/**/*.rb"
|
|
445
|
+
- "spec/**/*.rb"
|
|
446
|
+
|
|
447
|
+
DevDoc/Test/RequireGlibTravel:
|
|
448
|
+
Description: "Controller tests must freeze time inside a `glib_travel` (or `glib_travel_freeze`) block — they integrate HTTP, DB, and the clock, so deterministic time is required."
|
|
449
|
+
Enabled: true
|
|
450
|
+
Include:
|
|
451
|
+
- "test/controllers/**/*_test.rb"
|
|
452
|
+
|
|
380
453
|
DevDoc/Test/AvoidUnitTest:
|
|
381
454
|
Description: "Prefer controller tests; unit tests (`< ActiveSupport::TestCase`) are a rare, justified exception."
|
|
382
455
|
Enabled: true
|
|
@@ -384,6 +457,53 @@ DevDoc/Test/AvoidUnitTest:
|
|
|
384
457
|
- "test/**/*.rb"
|
|
385
458
|
- "spec/**/*.rb"
|
|
386
459
|
|
|
460
|
+
DevDoc/Test/RequireGlibIntegrationBase:
|
|
461
|
+
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."
|
|
462
|
+
Enabled: true
|
|
463
|
+
RequiredBase: Glib::IntegrationTest
|
|
464
|
+
LegacyBases:
|
|
465
|
+
- ActionDispatch::IntegrationTest
|
|
466
|
+
Include:
|
|
467
|
+
- "test/controllers/**/*_test.rb"
|
|
468
|
+
- "test/integration/**/*_test.rb"
|
|
469
|
+
|
|
470
|
+
DevDoc/Test/RequireUnitTestJustification:
|
|
471
|
+
Description: "Every test file must live in a recognized directory; unit-test directories additionally require a justification header (marker phrase: 'deliberate exception')."
|
|
472
|
+
Enabled: true
|
|
473
|
+
MarkerPhrase: "deliberate exception"
|
|
474
|
+
# Total classification — Include deliberately covers ALL test files so a test
|
|
475
|
+
# can't dodge enforcement by being placed in an invented directory. Note
|
|
476
|
+
# AvoidUnitTest's base-class heuristic is evadable when a project's universal
|
|
477
|
+
# test base already descends from ActionDispatch::IntegrationTest — this cop
|
|
478
|
+
# plus the runtime lint DevDoc::Test::Lints::HttpDrivenControllerTests (which
|
|
479
|
+
# guarantees everything under test/controllers/ actually drives HTTP) close
|
|
480
|
+
# that hole from both sides:
|
|
481
|
+
# - ExemptDirectories: otherwise-enforced or accepted integration-shaped
|
|
482
|
+
# homes. Inheriting a unit base (UnitBaseClasses) there is still an
|
|
483
|
+
# offense — a unit test parked in an exempt directory would dodge both the
|
|
484
|
+
# header rule and the runtime lint (which only reaches integration-base
|
|
485
|
+
# descendants).
|
|
486
|
+
# - UnitTestDirectories: allowed only with the justification header.
|
|
487
|
+
# - Anything else (including test/ root): flagged as unrecognized — adding a
|
|
488
|
+
# new test home is a reviewed config change, not an implicit act.
|
|
489
|
+
UnitBaseClasses:
|
|
490
|
+
- Glib::LastResortUnitTest
|
|
491
|
+
UnitTestDirectories:
|
|
492
|
+
- models
|
|
493
|
+
- services
|
|
494
|
+
- helpers
|
|
495
|
+
ExemptDirectories:
|
|
496
|
+
- controllers
|
|
497
|
+
- integration
|
|
498
|
+
- jobs
|
|
499
|
+
- mailers
|
|
500
|
+
- channels
|
|
501
|
+
- system
|
|
502
|
+
- linters
|
|
503
|
+
- tasks
|
|
504
|
+
Include:
|
|
505
|
+
- "test/**/*_test.rb"
|
|
506
|
+
|
|
387
507
|
Naming/PredicatePrefix:
|
|
388
508
|
Enabled: false
|
|
389
509
|
|
|
@@ -425,11 +545,28 @@ DevDoc/I18n/AvoidTitleizeHumanize:
|
|
|
425
545
|
Severity: warning
|
|
426
546
|
Methods:
|
|
427
547
|
- titleize
|
|
548
|
+
- titlecase # ActiveSupport's documented alias of titleize — not a dodge
|
|
428
549
|
- humanize
|
|
429
550
|
Include:
|
|
430
551
|
- "app/views/**/*.jbuilder"
|
|
431
552
|
- "app/views/**/*.rb"
|
|
432
553
|
|
|
554
|
+
DevDoc/View/PreferPropOverName:
|
|
555
|
+
Description: "Bind form fields to model attributes with `prop:` (i18n-resolved label/placeholder) instead of a raw `name: 'model[attr]'` with hardcoded text."
|
|
556
|
+
# Disabled by default (new department) — enable per consumer .rubocop.yml.
|
|
557
|
+
# fields_hidden is exempt out of the box: no label/placeholder to hardcode,
|
|
558
|
+
# and hidden fields (echoed tokens, dynamic-group indexes, forced values)
|
|
559
|
+
# are the dominant legitimate raw-`name:` users. Autocorrect is unsafe: it
|
|
560
|
+
# drops the `model` segment (prop: binds to the FORM's model) and leaves
|
|
561
|
+
# sibling label:/placeholder: for manual removal.
|
|
562
|
+
Enabled: false
|
|
563
|
+
SafeAutoCorrect: false
|
|
564
|
+
ExemptFieldMethods:
|
|
565
|
+
- fields_hidden
|
|
566
|
+
Include:
|
|
567
|
+
- "app/views/**/*.jbuilder"
|
|
568
|
+
- "app/views/**/*.rb"
|
|
569
|
+
|
|
433
570
|
DevDoc/I18n/RequireTranslation:
|
|
434
571
|
Description: "Localize user-facing strings in glib JSON-UI props; pass `t(...)` instead of a hardcoded string."
|
|
435
572
|
Enabled: false
|
|
@@ -17,6 +17,10 @@ module DevDoc
|
|
|
17
17
|
# duplicate group: `# allow_duplicate_snapshot: <reason>`.
|
|
18
18
|
MARKER = 'allow_duplicate_snapshot'.freeze
|
|
19
19
|
|
|
20
|
+
# A bare marker with no reason is mechanics without policy — the
|
|
21
|
+
# reason is the reviewable part, so it is mandatory.
|
|
22
|
+
MARKER_WITH_REASON = /#{MARKER}\s*:\s*\S/
|
|
23
|
+
|
|
20
24
|
# Matches a minitest `test '...' do` declaration; captures the quote
|
|
21
25
|
# (1), the name (2), and the trailing content after `do` (3).
|
|
22
26
|
TEST_DEFINITION = /^\s*test\s+(["'])(.+?)\1\s+do\b(.*)$/
|
|
@@ -86,10 +90,12 @@ module DevDoc
|
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
# 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.
|
|
93
|
+
# trailing comment, or on the comment line directly above it. The
|
|
94
|
+
# token only counts WITH a reason (`allow_duplicate_snapshot: <why>`)
|
|
95
|
+
# — a bare marker still fails, so the opt-out cannot be cargo-culted.
|
|
90
96
|
def marked?(trailing, lines, index)
|
|
91
97
|
context = trailing.to_s + (index.positive? ? lines[index - 1] : '')
|
|
92
|
-
context.
|
|
98
|
+
context.match?(MARKER_WITH_REASON)
|
|
93
99
|
end
|
|
94
100
|
|
|
95
101
|
# Resolve the test file for a `*_results` dir. Usually it's the
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module DevDoc
|
|
2
|
+
module Test
|
|
3
|
+
module Lints
|
|
4
|
+
# Runtime guarantee that every test under `test/controllers/` actually
|
|
5
|
+
# drives the app through HTTP. Include into the project's base
|
|
6
|
+
# integration test class (a descendant of ActionDispatch::IntegrationTest):
|
|
7
|
+
#
|
|
8
|
+
# Glib::IntegrationTest.include DevDoc::Test::Lints::HttpDrivenControllerTests
|
|
9
|
+
#
|
|
10
|
+
# ## Rationale
|
|
11
|
+
# Best practice (see AvoidUnitTest) prefers controller tests because they
|
|
12
|
+
# exercise the wiring a user actually hits. Static enforcement keyed on
|
|
13
|
+
# base class or file name is trivially evaded — and observed evasions by
|
|
14
|
+
# AI agents include: naming files ambiguously, mixing unit tests into
|
|
15
|
+
# controller test files, and inheriting the integration base class so the
|
|
16
|
+
# `< ActiveSupport::TestCase` heuristic never fires.
|
|
17
|
+
#
|
|
18
|
+
# This lint closes those holes by keying on *observed behavior*:
|
|
19
|
+
# `ActionDispatch::Integration::Runner#process` is the single choke point
|
|
20
|
+
# every request helper funnels through (`get`/`post`/... — no matter how
|
|
21
|
+
# deeply wrapped in `log_user_in`-style helpers), so "issued at least one
|
|
22
|
+
# request" is checked per test, not per file. A unit test cannot pass it
|
|
23
|
+
# by renaming, relocating within the file, or subclassing anything.
|
|
24
|
+
#
|
|
25
|
+
# There is deliberately NO opt-out flag: a test that never issues a
|
|
26
|
+
# request does not belong under `test/controllers/`. Move it to the
|
|
27
|
+
# matching directory (`test/models/`, `test/jobs/`, ...) with the
|
|
28
|
+
# justification header your project's conventions require
|
|
29
|
+
# (see DevDoc/Test/RequireUnitTestJustification).
|
|
30
|
+
module HttpDrivenControllerTests
|
|
31
|
+
CONTROLLER_TEST_PATH = %r{(\A|/)test/controllers/}
|
|
32
|
+
|
|
33
|
+
MESSAGE = <<~MSG.freeze
|
|
34
|
+
%<location>s lives under test/controllers/ but never issued an HTTP request —
|
|
35
|
+
it is a unit test in controller-test clothing. Convert it to drive the app
|
|
36
|
+
through a request (the bugs live in the wiring), or move it to the directory
|
|
37
|
+
matching what it exercises (test/models/, test/jobs/, ...) with a documented
|
|
38
|
+
justification header.
|
|
39
|
+
MSG
|
|
40
|
+
|
|
41
|
+
def before_setup
|
|
42
|
+
@__dev_doc_http_request_count = 0
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ActionDispatch::Integration::Runner defines the verb helpers on the
|
|
47
|
+
# test case; each delegates to integration_session.process. Overriding
|
|
48
|
+
# the verbs here (the module sits ahead of Runner in the MRO) sees every
|
|
49
|
+
# request no matter how deeply wrapped in project helpers —
|
|
50
|
+
# `follow_redirect!` also funnels through these.
|
|
51
|
+
%i[get post patch put delete head options].each do |verb|
|
|
52
|
+
define_method(verb) do |*args, **kwargs, &block|
|
|
53
|
+
@__dev_doc_http_request_count = @__dev_doc_http_request_count.to_i + 1
|
|
54
|
+
super(*args, **kwargs, &block)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def after_teardown
|
|
59
|
+
super
|
|
60
|
+
ensure
|
|
61
|
+
__dev_doc_check_http_driven
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def __dev_doc_check_http_driven
|
|
67
|
+
return unless __dev_doc_controller_test?
|
|
68
|
+
return if skipped? || !passed? # don't mask the test's own failure
|
|
69
|
+
return if @__dev_doc_http_request_count.to_i.positive?
|
|
70
|
+
|
|
71
|
+
raise Minitest::Assertion, format(MESSAGE, location: __dev_doc_test_location)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def __dev_doc_controller_test?
|
|
75
|
+
(__dev_doc_source_file || '') =~ CONTROLLER_TEST_PATH
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The file the test method is defined in — robust against shared helpers
|
|
79
|
+
# because `test '...'` macros define the method at their call site.
|
|
80
|
+
def __dev_doc_source_file
|
|
81
|
+
method(name).source_location&.first
|
|
82
|
+
rescue NameError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def __dev_doc_test_location
|
|
87
|
+
"#{self.class}##{name} (#{__dev_doc_source_file})"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -35,7 +35,9 @@ module RuboCop
|
|
|
35
35
|
MSG = 'Avoid `.%<method>s` for display text: the result is ' \
|
|
36
36
|
'English-only and bypasses I18n. Use `t(...)` instead.'.freeze
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# titlecase is ActiveSupport's documented alias of titleize — same
|
|
39
|
+
# English-only output, so it must not be an alias-shaped dodge.
|
|
40
|
+
DEFAULT_METHODS = %w[titleize titlecase humanize].freeze
|
|
39
41
|
|
|
40
42
|
def on_send(node)
|
|
41
43
|
return unless forbidden_methods.include?(node.method_name.to_s)
|
|
@@ -45,7 +45,9 @@ module RuboCop
|
|
|
45
45
|
MSG = 'Translation key `%<key>s` must start with an allowed ' \
|
|
46
46
|
'prefix: %<prefixes>s.'.freeze
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
# t!/translate! are public I18n API (raise instead of returning the
|
|
49
|
+
# missing-translation marker) — same lookup, same prefix rule.
|
|
50
|
+
RESTRICT_ON_SEND = %i[t translate t! translate!].freeze
|
|
49
51
|
|
|
50
52
|
def on_send(node)
|
|
51
53
|
prefixes = allowed_prefixes
|
|
@@ -14,6 +14,17 @@ module RuboCop
|
|
|
14
14
|
# reason why existing records may be invalid. If there is, fix those
|
|
15
15
|
# records first rather than bypassing validation.
|
|
16
16
|
#
|
|
17
|
+
# ## Relationship with Rails/SkipsModelValidations
|
|
18
|
+
# This cop is a strict SUPERSET of the stock `Rails/SkipsModelValidations`
|
|
19
|
+
# (it covers every method that cop flags, plus `delete_all` and
|
|
20
|
+
# `save(validate: false)`), with per-method actionable messages instead
|
|
21
|
+
# of a generic one. Consumer projects should enable THIS cop and disable
|
|
22
|
+
# `Rails/SkipsModelValidations` — running both forces every disable
|
|
23
|
+
# comment to name two cops for the same offense. Methods that skip
|
|
24
|
+
# validations BY DESIGN in your domain (e.g. `touch`, counter-cache
|
|
25
|
+
# maintenance) can be permitted wholesale via `AllowedMethods` rather
|
|
26
|
+
# than disabled per site.
|
|
27
|
+
#
|
|
17
28
|
# ❌ Bypasses validation — hides data integrity issues
|
|
18
29
|
# Faq.where(purpose: nil).update_all(purpose: :intro)
|
|
19
30
|
#
|
|
@@ -45,31 +56,88 @@ module RuboCop
|
|
|
45
56
|
MESSAGES = {
|
|
46
57
|
update_column: 'Avoid `update_column`; it bypasses validations. Use `save!` instead.',
|
|
47
58
|
update_columns: 'Avoid `update_columns`; it bypasses validations. Use `save!` instead.',
|
|
59
|
+
update_attribute: 'Avoid `update_attribute`; it bypasses validations (the singular is not ' \
|
|
60
|
+
'a validating `update`). Use `update!` instead.',
|
|
48
61
|
update_all: 'Avoid `update_all`; it bypasses validations. Use `save!` in a loop instead.',
|
|
62
|
+
insert: 'Avoid `insert`; it bypasses validations. Use `create!` instead, ' \
|
|
63
|
+
'or `# rubocop:disable` with a reason if a raw insert is intentional.',
|
|
64
|
+
insert!: 'Avoid `insert!`; it bypasses validations (the bang is about duplicate keys, ' \
|
|
65
|
+
'not validity). Use `create!` instead.',
|
|
49
66
|
insert_all: 'Avoid `insert_all`; it bypasses validations. Use `create!` in a loop, ' \
|
|
50
67
|
'or `# rubocop:disable` with a reason if bulk-insert is intentional.',
|
|
68
|
+
upsert: 'Avoid `upsert`; it bypasses validations. Use `create!`/`update!` instead, ' \
|
|
69
|
+
'or `# rubocop:disable` with a reason if a raw upsert is intentional.',
|
|
51
70
|
upsert_all: 'Avoid `upsert_all`; it bypasses validations. Use `create!`/`update!` in a loop, ' \
|
|
52
71
|
'or `# rubocop:disable` with a reason if bulk-upsert is intentional.',
|
|
72
|
+
toggle!: 'Avoid `toggle!`; it saves without validations. Use `toggle(...).save!` instead.',
|
|
73
|
+
increment!: 'Avoid `increment!`; it writes without validations. Use `increment(...).save!` instead.',
|
|
74
|
+
decrement!: 'Avoid `decrement!`; it writes without validations. Use `decrement(...).save!` instead.',
|
|
75
|
+
increment_counter: 'Avoid `increment_counter`; it issues a direct UPDATE that bypasses validations ' \
|
|
76
|
+
'and callbacks. Use the record with `save!`, or `AllowedMethods` for deliberate ' \
|
|
77
|
+
'counter-cache maintenance.',
|
|
78
|
+
decrement_counter: 'Avoid `decrement_counter`; it issues a direct UPDATE that bypasses validations ' \
|
|
79
|
+
'and callbacks. Use the record with `save!`, or `AllowedMethods` for deliberate ' \
|
|
80
|
+
'counter-cache maintenance.',
|
|
81
|
+
update_counters: 'Avoid `update_counters`; it issues a direct UPDATE that bypasses validations ' \
|
|
82
|
+
'and callbacks. Use the record with `save!`, or `AllowedMethods` for deliberate ' \
|
|
83
|
+
'counter-cache maintenance.',
|
|
84
|
+
touch: 'Avoid `touch`; it writes timestamps without validations or save callbacks. Use `save!` ' \
|
|
85
|
+
'(`updated_at` updates automatically), or `AllowedMethods` if bare timestamp bumps are ' \
|
|
86
|
+
'accepted in this project.',
|
|
87
|
+
touch_all: 'Avoid `touch_all`; it issues a bulk UPDATE that bypasses validations and callbacks. ' \
|
|
88
|
+
'Iterate with `save!`, or `# rubocop:disable` with a reason if a bulk timestamp bump ' \
|
|
89
|
+
'is intentional.',
|
|
90
|
+
insert_all!: 'Avoid `insert_all!`; it bypasses validations (the bang is about duplicate keys, ' \
|
|
91
|
+
'not validity). Use `create!` in a loop, or `# rubocop:disable` with a reason if ' \
|
|
92
|
+
'bulk-insert is intentional.',
|
|
53
93
|
delete_all: 'Avoid `delete_all`; it bypasses callbacks. Use `destroy_all` to run callbacks, ' \
|
|
54
94
|
'or `# rubocop:disable` with a reason if bulk-delete is intentional.'
|
|
55
95
|
}.freeze
|
|
56
96
|
|
|
57
97
|
SAVE_MSG = 'Avoid `save(validate: false)`; it bypasses validations. Use `save!` instead.'.freeze
|
|
58
98
|
|
|
59
|
-
RESTRICT_ON_SEND = %i[
|
|
99
|
+
RESTRICT_ON_SEND = (MESSAGES.keys + %i[save]).freeze
|
|
100
|
+
|
|
101
|
+
# insert/insert!/upsert are AR *class-level* APIs (User.insert(...)),
|
|
102
|
+
# while `insert` on a non-const receiver is everyday Array#insert —
|
|
103
|
+
# flag only the const-receiver form to avoid false positives.
|
|
104
|
+
CONST_RECEIVER_ONLY = %i[insert insert! upsert].freeze
|
|
105
|
+
|
|
106
|
+
# The inverse for touch: the AR form is instance-level
|
|
107
|
+
# (`record.touch`), while a const receiver is `FileUtils.touch` or
|
|
108
|
+
# similar — not an ActiveRecord write at all.
|
|
109
|
+
NON_CONST_RECEIVER_ONLY = %i[touch].freeze
|
|
60
110
|
|
|
61
111
|
def on_send(node)
|
|
112
|
+
return if allowed_methods.include?(node.method_name.to_s)
|
|
113
|
+
|
|
62
114
|
if node.method?(:save)
|
|
63
115
|
return unless save_with_validate_false?(node)
|
|
64
116
|
|
|
65
117
|
add_offense(node.loc.selector, message: SAVE_MSG)
|
|
66
118
|
else
|
|
119
|
+
return unless receiver_shape_matches?(node)
|
|
120
|
+
|
|
67
121
|
add_offense(node.loc.selector, message: MESSAGES[node.method_name])
|
|
68
122
|
end
|
|
69
123
|
end
|
|
70
124
|
|
|
71
125
|
private
|
|
72
126
|
|
|
127
|
+
def receiver_shape_matches?(node)
|
|
128
|
+
return node.receiver&.const_type? if CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
129
|
+
return !node.receiver&.const_type? if NON_CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
130
|
+
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Methods a project accepts wholesale as by-design validation skips
|
|
135
|
+
# (e.g. `touch`, counter-cache maintenance) — configure in
|
|
136
|
+
# .rubocop.yml instead of scattering per-site disables.
|
|
137
|
+
def allowed_methods
|
|
138
|
+
cop_config.fetch('AllowedMethods', []).map(&:to_s)
|
|
139
|
+
end
|
|
140
|
+
|
|
73
141
|
def save_with_validate_false?(node)
|
|
74
142
|
node.arguments.any? do |arg|
|
|
75
143
|
next unless arg.hash_type?
|
|
@@ -34,7 +34,10 @@ module RuboCop
|
|
|
34
34
|
class ApplicationRecordTransaction < Base
|
|
35
35
|
extend AutoCorrector
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
# Source-string comparison, so the cbase-qualified spellings must be
|
|
38
|
+
# listed too — `::ApplicationRecord.transaction` was a false positive.
|
|
39
|
+
ALLOWED_RECEIVERS = %w[ApplicationRecord ::ApplicationRecord
|
|
40
|
+
ActiveRecord::Base ::ActiveRecord::Base].freeze
|
|
38
41
|
|
|
39
42
|
MSG = 'Use `ApplicationRecord.transaction` instead of `%<receiver>s.transaction` outside model files.'.freeze
|
|
40
43
|
RESTRICT_ON_SEND = %i[transaction].freeze
|
|
@@ -115,12 +115,17 @@ module RuboCop
|
|
|
115
115
|
# send_confirmation if saved
|
|
116
116
|
# end
|
|
117
117
|
class AvoidRailsCallbacks < Base
|
|
118
|
+
# Includes every *_commit shorthand and the underlying set_callback
|
|
119
|
+
# API — omitting any of them (after_update_commit was missed for a
|
|
120
|
+
# while) leaves a zero-effort spelling of the same callback.
|
|
118
121
|
CALLBACKS = %i[
|
|
119
|
-
after_create after_create_commit after_save
|
|
120
|
-
|
|
122
|
+
after_create after_create_commit after_save after_save_commit
|
|
123
|
+
after_update after_update_commit after_destroy after_destroy_commit
|
|
124
|
+
after_commit before_commit after_rollback after_initialize
|
|
121
125
|
after_find after_touch before_create before_save before_update
|
|
122
126
|
before_destroy before_validation after_validation
|
|
123
127
|
around_create around_save around_update around_destroy
|
|
128
|
+
set_callback
|
|
124
129
|
].freeze
|
|
125
130
|
|
|
126
131
|
MSG = 'Avoid `%<method>s` — extract an explicit method (e.g. `save_with_*`) ' \
|
|
@@ -64,7 +64,10 @@ module RuboCop
|
|
|
64
64
|
MSG = 'Use `%<bang>s` inside a `transaction` block, or check its return value. ' \
|
|
65
65
|
'A non-bang call whose return value is discarded does not roll back the transaction on failure.'.freeze
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
# destroy has the same failure mode: it returns false when a
|
|
68
|
+
# before_destroy callback aborts, and a discarded false does not
|
|
69
|
+
# roll the transaction back.
|
|
70
|
+
FLAGGED_METHODS = %i[save update create destroy].freeze
|
|
68
71
|
|
|
69
72
|
# Node types whose parent always means the return value is consumed.
|
|
70
73
|
CONSUMING_PARENT_TYPES = %i[
|
|
@@ -87,9 +90,11 @@ module RuboCop
|
|
|
87
90
|
|
|
88
91
|
private
|
|
89
92
|
|
|
93
|
+
# with_lock opens a transaction too, so a discarded non-bang save
|
|
94
|
+
# inside it fails just as silently.
|
|
90
95
|
def inside_transaction?(node)
|
|
91
96
|
node.each_ancestor(:block).any? do |ancestor|
|
|
92
|
-
ancestor.method_name
|
|
97
|
+
%i[transaction with_lock].include?(ancestor.method_name)
|
|
93
98
|
end
|
|
94
99
|
end
|
|
95
100
|
|
|
@@ -32,6 +32,16 @@ module RuboCop
|
|
|
32
32
|
# ✔
|
|
33
33
|
# enum_symbolize :payment_status, :finalize_intent
|
|
34
34
|
#
|
|
35
|
+
# Enums declared inside a concern's `included do` block are checked the
|
|
36
|
+
# same way — the pairing must live in the same `included do` block,
|
|
37
|
+
# which is also where it belongs so every includer gets both:
|
|
38
|
+
#
|
|
39
|
+
# ✔
|
|
40
|
+
# included do
|
|
41
|
+
# enum :discount_type, DISCOUNT_TYPES
|
|
42
|
+
# enum_symbolize :discount_type
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
35
45
|
# @example
|
|
36
46
|
# # bad
|
|
37
47
|
# enum :status, { active: 0, archived: 1 }
|
|
@@ -51,24 +61,29 @@ module RuboCop
|
|
|
51
61
|
(send nil? :enum_symbolize $...)
|
|
52
62
|
PATTERN
|
|
53
63
|
|
|
64
|
+
def_node_matcher :included_block?, <<~PATTERN
|
|
65
|
+
(block (send nil? :included) (args) _)
|
|
66
|
+
PATTERN
|
|
67
|
+
|
|
54
68
|
def on_class(node)
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
check_body(node.body)
|
|
70
|
+
end
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
# Concern-declared enums: `included do ... end` runs in the including
|
|
73
|
+
# class's context, so its statements are checked like a class body.
|
|
74
|
+
def on_block(node)
|
|
75
|
+
return unless included_block?(node)
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
check_body(node.body)
|
|
78
|
+
end
|
|
62
79
|
|
|
63
|
-
|
|
64
|
-
next unless stmt.respond_to?(:send_type?) && stmt.send_type?
|
|
80
|
+
private
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
end
|
|
82
|
+
def check_body(body)
|
|
83
|
+
return unless body
|
|
84
|
+
|
|
85
|
+
statements = body.begin_type? ? body.children : [body]
|
|
86
|
+
enum_decls, symbolized = scan(statements)
|
|
72
87
|
|
|
73
88
|
enum_decls.each do |name, decl|
|
|
74
89
|
next if symbolized.include?(name)
|
|
@@ -76,6 +91,20 @@ module RuboCop
|
|
|
76
91
|
add_offense(decl, message: format(MSG, name: name))
|
|
77
92
|
end
|
|
78
93
|
end
|
|
94
|
+
|
|
95
|
+
def scan(statements)
|
|
96
|
+
sends = statements.select { |stmt| stmt.respond_to?(:send_type?) && stmt.send_type? }
|
|
97
|
+
enum_decls = sends.filter_map { |stmt| (name = enum_call(stmt)) && [name, stmt] }
|
|
98
|
+
symbolized = sends.flat_map { |stmt| symbolized_names(stmt) }
|
|
99
|
+
[enum_decls, symbolized]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def symbolized_names(stmt)
|
|
103
|
+
args = enum_symbolize_call(stmt)
|
|
104
|
+
return [] unless args
|
|
105
|
+
|
|
106
|
+
args.select(&:sym_type?).map(&:value)
|
|
107
|
+
end
|
|
79
108
|
end
|
|
80
109
|
end
|
|
81
110
|
end
|
|
@@ -104,7 +104,9 @@ module RuboCop
|
|
|
104
104
|
MSG = '`%<method>s` with a block loads every row into Ruby. ' \
|
|
105
105
|
'Push the predicate into SQL with `.where(...)` or a model scope.'.freeze
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
# filter/detect are the Ruby aliases of select/find — leaving them
|
|
108
|
+
# out would make the alias a zero-cost dodge.
|
|
109
|
+
RESTRICT_ON_SEND = %i[count reject select filter find detect any?].freeze
|
|
108
110
|
|
|
109
111
|
# Methods whose return value is known to be a non-Relation collection
|
|
110
112
|
# (Array, Hash, or Enumerator). When a `.select`/`.reject`/etc. with a
|
|
@@ -129,7 +131,7 @@ module RuboCop
|
|
|
129
131
|
].freeze
|
|
130
132
|
|
|
131
133
|
def on_send(node)
|
|
132
|
-
return unless node.block_literal?
|
|
134
|
+
return unless node.block_literal? || symbol_block_pass?(node)
|
|
133
135
|
return if node.receiver.nil?
|
|
134
136
|
return if excluded_receiver?(node.receiver)
|
|
135
137
|
return if excluded_block?(node)
|
|
@@ -139,6 +141,13 @@ module RuboCop
|
|
|
139
141
|
|
|
140
142
|
private
|
|
141
143
|
|
|
144
|
+
# `reject(&:archived?)` loads every row exactly like the literal
|
|
145
|
+
# block form — one character shorter, so it must not be a dodge.
|
|
146
|
+
def symbol_block_pass?(node)
|
|
147
|
+
last = node.last_argument
|
|
148
|
+
last&.block_pass_type? && last.children.first&.sym_type?
|
|
149
|
+
end
|
|
150
|
+
|
|
142
151
|
def excluded_receiver?(receiver)
|
|
143
152
|
return true if receiver.array_type? || receiver.hash_type?
|
|
144
153
|
return true if receiver.const_type? && screaming_case_const?(receiver)
|
|
@@ -55,7 +55,10 @@ module RuboCop
|
|
|
55
55
|
class NoDeliverLaterInTransaction < Base
|
|
56
56
|
MSG = '`%<method>s` inside a `transaction` block may use stale data. Move it outside the transaction.'.freeze
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
# Also the bang form, the bulk API, and the enqueue primitive that
|
|
59
|
+
# perform_later wraps — each is an equally easy spelling of the same
|
|
60
|
+
# premature enqueue.
|
|
61
|
+
CORE_METHODS = %i[deliver_later deliver_later! perform_later perform_all_later enqueue].freeze
|
|
59
62
|
|
|
60
63
|
def on_send(node)
|
|
61
64
|
return unless inside_transaction?(node)
|
|
@@ -74,9 +77,11 @@ module RuboCop
|
|
|
74
77
|
cop_config.fetch('KnownAsyncWrappers', [])
|
|
75
78
|
end
|
|
76
79
|
|
|
80
|
+
# with_lock opens a transaction too (lock! inside a transaction
|
|
81
|
+
# block), so an enqueue inside it has the identical hazard.
|
|
77
82
|
def inside_transaction?(node)
|
|
78
83
|
node.each_ancestor(:block).any? do |ancestor|
|
|
79
|
-
ancestor.method_name
|
|
84
|
+
%i[transaction with_lock].include?(ancestor.method_name)
|
|
80
85
|
end
|
|
81
86
|
end
|
|
82
87
|
end
|