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.
- checksums.yaml +4 -4
- data/config/default.yml +152 -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_boolean_column.rb +135 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +72 -11
- data/lib/rubocop/cop/dev_doc/migration/boolean_column_not_null.rb +122 -0
- 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 +22 -5
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
|
|
@@ -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.
|
|
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
|
|
@@ -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[
|
|
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?
|