rubocop-dev_doc 0.6.0.beta1 → 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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +152 -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_boolean_column.rb +135 -0
  8. data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
  9. data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +72 -11
  10. data/lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb +122 -0
  11. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
  12. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
  13. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
  14. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
  15. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
  16. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
  17. data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
  18. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
  19. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
  20. data/lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb +103 -0
  21. data/lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb +134 -0
  22. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +3 -1
  23. data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +5 -3
  24. data/lib/rubocop/cop/dev_doc/style/redundant_guard_after_bang.rb +155 -0
  25. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +7 -4
  26. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +25 -12
  27. data/lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb +57 -0
  28. data/lib/rubocop/cop/dev_doc/test/require_glib_travel.rb +119 -0
  29. data/lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb +123 -0
  30. data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +217 -0
  31. data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +109 -0
  32. data/lib/rubocop/dev_doc/version.rb +1 -1
  33. metadata +22 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfa7b6778294801c95bb37bb3c15a2c4b30f81e9b915ba3ee548475c4adc9cd4
4
- data.tar.gz: 6606b6c2b6745b2c54f63a89a7b3ae1b4ce3bfb60dda96a471dc51e33232e802
3
+ metadata.gz: 7d3924b0184e741e19b3f29854b6f472a9bd54667ddfef1e59ccc1aa000e5073
4
+ data.tar.gz: d59338919f825edec8ed8bbc717d26f19782fbc88411d90e1a8e0c110d07235d
5
5
  SHA512:
6
- metadata.gz: adc4743bfba263e139f6ca4cc5abbc213c427a46081e0aa5bc12be84ae96cc26f5b37eb218707449ca4566e5fca6958534f9581104c0d92c97f83e2ef17003b2
7
- data.tar.gz: f8aa2b1f19d0f1a628f86ae58b19ed71ba6c5f508fd3247203909f459fb22deeef7777fc5de17509af04a0e4618aebd95c1b19a6fdf78fe53c72f97db063e261
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
@@ -66,14 +70,39 @@ DevDoc/Migration/AvoidNonNull:
66
70
  - "db/migrate/*.rb"
67
71
  - "db/migrate/**/*.rb"
68
72
 
73
+ DevDoc/Migration/AvoidBooleanColumn:
74
+ Description: "Avoid `boolean` columns; prefer a timestamp, enum, or model method that better models the data."
75
+ Enabled: true
76
+ Include:
77
+ - "db/migrate/*.rb"
78
+ - "db/migrate/**/*.rb"
79
+
80
+ DevDoc/Migration/BooleanColumnNotNull:
81
+ Description: "Boolean columns must carry `null: false` — NULL is outside {true, false}."
82
+ Enabled: true
83
+ Include:
84
+ - "db/migrate/*.rb"
85
+ - "db/migrate/**/*.rb"
86
+
69
87
  # Intentionally global (no Include) — these patterns are risky in any file, not only migrations.
70
88
  # Add project-specific Exclude entries (e.g. db/seeds.rb, lib/tasks/**/*.rb) in your .rubocop.yml
71
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.
72
99
  DevDoc/Migration/AvoidBypassingValidation:
73
- 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."
74
101
  Enabled: false
102
+ AllowedMethods: []
75
103
  Exclude:
76
104
  - "spec/**/*"
105
+ - "test/**/*"
77
106
 
78
107
  DevDoc/Migration/DateColumnNaming:
79
108
  Description: "Date columns should end with `_on`; datetime columns should end with `_at`."
@@ -285,6 +314,51 @@ DevDoc/Style/StringSymbolComparison:
285
314
  Description: "Comparing a known-string source (params, request.headers, ENV) to a symbol literal is always false."
286
315
  Enabled: true
287
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
+
288
362
  DevDoc/Test/ResponseAssertEqual:
289
363
  Description: "Controller tests that assert on `response.body` should also snapshot the full response with `response_assert_equal`."
290
364
  # Enabled by default. A project adopting the gem with an existing backlog will
@@ -363,6 +437,19 @@ DevDoc/Test/AvoidGlibTravelFreeze:
363
437
  - "test/**/*.rb"
364
438
  - "spec/**/*.rb"
365
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
+
366
453
  DevDoc/Test/AvoidUnitTest:
367
454
  Description: "Prefer controller tests; unit tests (`< ActiveSupport::TestCase`) are a rare, justified exception."
368
455
  Enabled: true
@@ -370,6 +457,53 @@ DevDoc/Test/AvoidUnitTest:
370
457
  - "test/**/*.rb"
371
458
  - "spec/**/*.rb"
372
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
+
373
507
  Naming/PredicatePrefix:
374
508
  Enabled: false
375
509
 
@@ -411,11 +545,28 @@ DevDoc/I18n/AvoidTitleizeHumanize:
411
545
  Severity: warning
412
546
  Methods:
413
547
  - titleize
548
+ - titlecase # ActiveSupport's documented alias of titleize — not a dodge
414
549
  - humanize
415
550
  Include:
416
551
  - "app/views/**/*.jbuilder"
417
552
  - "app/views/**/*.rb"
418
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
+
419
570
  DevDoc/I18n/RequireTranslation:
420
571
  Description: "Localize user-facing strings in glib JSON-UI props; pass `t(...)` instead of a hardcoded string."
421
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
@@ -0,0 +1,135 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Migration
5
+ # Avoid `boolean` columns; prefer an alternative that better models
6
+ # the data.
7
+ #
8
+ # ## Rationale
9
+ # A boolean column collapses a domain into exactly two states, but
10
+ # most "is X?" questions are richer than that in practice. The
11
+ # constraint pushes developers past the path-of-least-resistance
12
+ # `add_column :boolean` toward a shape that captures what the data
13
+ # actually means.
14
+ #
15
+ # The question to ask before reaching for `t.boolean` is:
16
+ # "what does NULL mean here?" — and if the answer is "unset" or
17
+ # "not yet decided", that semantic should be explicit in the
18
+ # schema, not silently smuggled in as a third boolean state.
19
+ #
20
+ # Consider the alternatives in order:
21
+ #
22
+ # ### 1. Timestamp — when the flag implies a moment in time
23
+ # If the boolean answers "did X happen?", a timestamp gives both
24
+ # the answer (`approved_at.present?`) AND the moment it happened
25
+ # (`approved_at`), for free. A boolean gives only the first.
26
+ #
27
+ # ❌ `approved` loses the moment of approval
28
+ # add_column :submissions, :approved, :boolean
29
+ #
30
+ # ✔️ `approved_at` answers both "is it approved?" and "when?"
31
+ # add_column :submissions, :approved_at, :datetime
32
+ #
33
+ # This pattern dominates the codebase (`published_at`, `deleted_at`,
34
+ # `archived_at`, `completed_at`, etc.) for good reason.
35
+ #
36
+ # ### 2. Enum — when "unset" is a meaningful state, or a third
37
+ # value is plausible
38
+ # An enum with an explicit "no_X_needed" value models "unset"
39
+ # honestly. NULL is outside an enum's domain (a type violation),
40
+ # so the column can carry `null: false` without controversy — see
41
+ # `DevDoc/Rails/EnumColumnNotNull`.
42
+ #
43
+ # ❌ nullable boolean; nil is a silent third state
44
+ # add_column :checklists, :approval_required, :boolean
45
+ #
46
+ # ✔️ enum; "unset" is explicit, null: false is justified
47
+ # # rubocop:disable DevDoc/Migration/AvoidNonNull -- enum
48
+ # add_column :checklists, :approval_requirement, :integer, null: false
49
+ # # rubocop:enable DevDoc/Migration/AvoidNonNull
50
+ #
51
+ # class Checklist < ApplicationRecord
52
+ # enum :approval_requirement, { no_approval_needed: 0, approval_by_admins: 1 }
53
+ # end
54
+ #
55
+ # ### 3. Model method — when the flag is derived from other data
56
+ # If the answer can be computed from existing columns or
57
+ # associations, do not store it. A method keeps the source of
58
+ # truth singular.
59
+ #
60
+ # ❌ denormalises role into a column
61
+ # add_column :users, :is_admin, :boolean
62
+ #
63
+ # ✔️ derived from `role`
64
+ # class User < ApplicationRecord
65
+ # def admin?
66
+ # role == "admin"
67
+ # end
68
+ # end
69
+ #
70
+ # ### 4. Boolean — only when none of the above apply
71
+ # A genuine binary preference with no meaningful third state
72
+ # (e.g. `auto_renew`, `communication_allowed`). Inline-disable
73
+ # this cop with a brief `-- boolean` reason. The column MUST also
74
+ # carry `null: false` — enforced by the sibling cop
75
+ # `DevDoc/Migration/BooleanColumnNotNull`.
76
+ #
77
+ # ✔️ true binary preference, justified
78
+ # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference; user opt-in
79
+ # add_column :service_subscriptions, :auto_renew, :boolean, null: false
80
+ # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
81
+ #
82
+ # NOTE: This cop catches `t.boolean`, `add_column ..., :boolean`,
83
+ # and `change_column ..., :boolean` (a type change to boolean).
84
+ # `change_column_type` migrations to a non-boolean type are not
85
+ # flagged — that is the cleanup direction.
86
+ #
87
+ # NOTE: This cop is deliberately NOT timestamp-aware. It cannot
88
+ # tell whether a `:datetime` column would have been a better fit;
89
+ # that judgment is the developer's at review time. The role of
90
+ # this cop is to force the moment of judgment, not to make it.
91
+ #
92
+ # @example
93
+ # # bad
94
+ # add_column :users, :is_admin, :boolean
95
+ #
96
+ # # bad
97
+ # t.boolean :approved
98
+ #
99
+ # # good (timestamp alternative)
100
+ # add_column :submissions, :approved_at, :datetime
101
+ #
102
+ # # good (enum alternative)
103
+ # add_column :checklists, :approval_requirement, :integer
104
+ #
105
+ # # good (justified boolean)
106
+ # # rubocop:disable DevDoc/Migration/AvoidBooleanColumn -- true binary preference
107
+ # add_column :service_subscriptions, :auto_renew, :boolean, null: false
108
+ # # rubocop:enable DevDoc/Migration/AvoidBooleanColumn
109
+ class AvoidBooleanColumn < Base
110
+ MSG = 'Avoid `boolean` columns; consider a timestamp (approved_at), enum, or model method. ' \
111
+ 'If a boolean is genuinely the right shape, disable this cop with a brief `-- boolean` reason ' \
112
+ 'and ensure the column carries `null: false` (see DevDoc/Migration/BooleanColumnNotNull).'.freeze
113
+
114
+ def_node_matcher :boolean_column?, <<~PATTERN
115
+ (send _ {:boolean} ...)
116
+ PATTERN
117
+
118
+ def_node_matcher :add_column_boolean?, <<~PATTERN
119
+ (send _ :add_column _ _ (sym :boolean) ...)
120
+ PATTERN
121
+
122
+ def_node_matcher :change_column_to_boolean?, <<~PATTERN
123
+ (send _ :change_column _ _ (sym :boolean) ...)
124
+ PATTERN
125
+
126
+ def on_send(node)
127
+ return unless boolean_column?(node) || add_column_boolean?(node) || change_column_to_boolean?(node)
128
+
129
+ add_offense(node)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -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?