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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +138 -1
  3. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
  4. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
  5. data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
  6. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
  7. data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
  8. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
  9. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
  10. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
  11. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
  12. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
  13. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
  14. data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
  15. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
  16. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
  17. data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
  18. data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
  19. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
  20. data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +5 -3
  21. data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
  22. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
  23. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
  24. data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
  25. data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
  26. data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
  27. data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
  28. data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
  29. data/lib/rubocop/dev_doc/version.rb +1 -1
  30. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d7cebd4d2c1b85850d5a704dc26319f49526040c26d2573824f68461d42ae8c
4
- data.tar.gz: 5fede674c17c29dd330e9499a1367c3f1174902b0a76c44d94c7f6683a35d130
3
+ metadata.gz: 7d3924b0184e741e19b3f29854b6f472a9bd54667ddfef1e59ccc1aa000e5073
4
+ data.tar.gz: d59338919f825edec8ed8bbc717d26f19782fbc88411d90e1a8e0c110d07235d
5
5
  SHA512:
6
- metadata.gz: 7b3666259413a6d37a6d13765c095005fd8395f1033a6d33de9539616b75d0e3d5091b26ab27ade88069ecd9c678231bd3126f116eb60a36db4e1220c50fefde
7
- data.tar.gz: 2fcbae3b394369ab9e969a0edc7f1b7ba2379b4572f73e3311228fe265ab29285ebe34a7635d43f7bf02c9ab8e56921454b94817323c723a473fc37e20999726
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.include?(MARKER)
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
- DEFAULT_METHODS = %w[titleize humanize].freeze
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
- RESTRICT_ON_SEND = %i[t translate].freeze
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[update_column update_columns update_all insert_all upsert_all delete_all save].freeze
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
- ALLOWED_RECEIVERS = %w[ApplicationRecord ActiveRecord::Base].freeze
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 after_update
120
- after_destroy after_commit after_rollback after_initialize
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
- FLAGGED_METHODS = %i[save update create].freeze
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 == :transaction
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
- body = node.body
56
- return unless body
69
+ check_body(node.body)
70
+ end
57
71
 
58
- statements = body.begin_type? ? body.children : [body]
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
- enum_decls = []
61
- symbolized = []
77
+ check_body(node.body)
78
+ end
62
79
 
63
- statements.each do |stmt|
64
- next unless stmt.respond_to?(:send_type?) && stmt.send_type?
80
+ private
65
81
 
66
- if (name = enum_call(stmt))
67
- enum_decls << [name, stmt]
68
- elsif (args = enum_symbolize_call(stmt))
69
- symbolized.concat(args.select(&:sym_type?).map(&:value))
70
- end
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
- RESTRICT_ON_SEND = %i[count reject select find any?].freeze
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
- CORE_METHODS = %i[deliver_later perform_later].freeze
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 == :transaction
84
+ %i[transaction with_lock].include?(ancestor.method_name)
80
85
  end
81
86
  end
82
87
  end