rubocop-dev_doc 0.7.0 → 0.9.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 +182 -4
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +8 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +92 -0
- data/lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb +3 -1
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -1
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +69 -1
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +4 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +42 -13
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +11 -2
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +7 -2
- data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +8 -4
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +62 -12
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb +159 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +3 -1
- data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +87 -0
- 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 +10 -8
- 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 +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 726cbfc87e097508ea26f7cb598c61e4f743e9979d688b340f277836899a0913
|
|
4
|
+
data.tar.gz: 221eb62a0484ac2695d0ae1c06f340192e6741118676f2b30cbd14296f3b6e8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ea55c9934463e771ad0f465b691268d743f0598e07a758b84d8ca66b2a54af4ce290f137f64161232aab20f32ca274b762c49b66089a3ded781826be5c0d249
|
|
7
|
+
data.tar.gz: edc9a58999125d644baa1db29d9bf89a5de47f72c864ee717460053c2f5e0104670bdbbb56f4744ca7f2c44ab085d18d75a4a68b77f01952a35e82ab759af0d7
|
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`."
|
|
@@ -180,8 +195,8 @@ DevDoc/Style/NoUnscopedMethodDefinitions:
|
|
|
180
195
|
# included by default.
|
|
181
196
|
SafeDSLReceivers: []
|
|
182
197
|
|
|
183
|
-
DevDoc/Style/
|
|
184
|
-
Description: "Avoid `send`/`public_send`
|
|
198
|
+
DevDoc/Style/AvoidInsecureSend:
|
|
199
|
+
Description: "Avoid insecure dynamic `send`/`public_send` — `send` is always flagged; `public_send` requires a prefix or trusted source (validator/mailer)."
|
|
185
200
|
Enabled: true
|
|
186
201
|
|
|
187
202
|
DevDoc/Style/PreferPublicSend:
|
|
@@ -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
|
|
@@ -328,9 +388,22 @@ Rails/StrongParametersExpect:
|
|
|
328
388
|
# Narrower replacement for Rails/StrongParametersExpect — flags only the
|
|
329
389
|
# hash-form rewrite (`params.require(:foo).permit(...)` → `params.expect(foo: [...])`).
|
|
330
390
|
# Does not flag scalar `params[:foo]` in any context.
|
|
391
|
+
#
|
|
392
|
+
# SafeAutoCorrect is false because `expect` is NOT drop-in for require+permit
|
|
393
|
+
# (verified against a production suite; see the cop docs for full detail):
|
|
394
|
+
# 1. `expect` raises ParameterMissing (400) when the key is present but every
|
|
395
|
+
# value filters out — permit returned an empty slice and callers often rely
|
|
396
|
+
# on that ("slice-style" param methods probed on every request).
|
|
397
|
+
# 2. `expect` uses explicit_arrays: collection-valued nested attributes
|
|
398
|
+
# (numeric-keyed fields_for hashes) need the double-array `[[...]]`
|
|
399
|
+
# declaration; the flat `[...]` this autocorrect emits SILENTLY filters
|
|
400
|
+
# collections to empty. Audit every *_attributes key after correcting:
|
|
401
|
+
# has_many nests need `[[...]]`, has_one nests stay flat.
|
|
402
|
+
# Run with `-A` only alongside a test suite that exercises the forms.
|
|
331
403
|
DevDoc/Rails/StrongParametersExpect:
|
|
332
404
|
Description: "Use `params.expect(foo: [...])` instead of `params.require(:foo).permit(...)`."
|
|
333
405
|
Enabled: true
|
|
406
|
+
SafeAutoCorrect: false
|
|
334
407
|
Include:
|
|
335
408
|
- "app/controllers/**/*.rb"
|
|
336
409
|
|
|
@@ -352,10 +425,32 @@ Rails/SaveBang:
|
|
|
352
425
|
Exclude:
|
|
353
426
|
- "app/controllers/**/*"
|
|
354
427
|
|
|
428
|
+
# Superseded by DevDoc/Style/CaseElseDecision below: same detection, but the
|
|
429
|
+
# offense message carries the raise/report/fall-through decision framework
|
|
430
|
+
# instead of upstream's "Missing else statement" (which teaches the wrong
|
|
431
|
+
# reflex — add an else, any else). Running both double-flags every offense.
|
|
355
432
|
Style/MissingElse:
|
|
356
|
-
Enabled:
|
|
433
|
+
Enabled: false
|
|
357
434
|
EnforcedStyle: case
|
|
358
435
|
|
|
436
|
+
# The point is NOT to add a silent `else` — it is to force a decision about
|
|
437
|
+
# what no-branch-matching MEANS at each case. Three legitimate outcomes:
|
|
438
|
+
# 1. The else genuinely happens in normal operation -> `else` + a comment
|
|
439
|
+
# stating why fall-through is correct (requires Style/EmptyElse off).
|
|
440
|
+
# 2. The else indicates a bug and continuing is unsafe -> `else raise`
|
|
441
|
+
# (fail fast; unexpected enum/mode values must not proceed silently).
|
|
442
|
+
# 3. The else indicates a bug but users can safely continue -> report to the
|
|
443
|
+
# error tracker and degrade gracefully (e.g. log an error, then return the
|
|
444
|
+
# neutral value). Prefer this over raising in user-facing render paths.
|
|
445
|
+
# Never pick 1 by reflex: if you cannot write down why the else legitimately
|
|
446
|
+
# happens, it belongs in bucket 2 or 3. Also weigh WHERE the case runs — a
|
|
447
|
+
# raise on user-supplied input that executes before authorization turns
|
|
448
|
+
# garbage requests into 500s, and error-tracker reports on pre-auth,
|
|
449
|
+
# scanner-reachable paths become noise rather than signal.
|
|
450
|
+
DevDoc/Style/CaseElseDecision:
|
|
451
|
+
Description: "Every `case` needs an `else` that decides what a non-match means: raise (unsafe to continue), report + degrade (bug but tolerable), or a comment justifying fall-through. Supersedes Style/MissingElse."
|
|
452
|
+
Enabled: true
|
|
453
|
+
|
|
359
454
|
# Core default is `Max: 2`, which permits `a&.b&.c`. Tightened to `Max: 1`:
|
|
360
455
|
# in `a&.b&.c`, the second `&.` is ambiguous — the reader can't tell whether
|
|
361
456
|
# it's there because `b.c` can genuinely be nil on its own, or merely because
|
|
@@ -376,6 +471,25 @@ DevDoc/Test/AvoidGlibTravelFreeze:
|
|
|
376
471
|
Include:
|
|
377
472
|
- "test/**/*.rb"
|
|
378
473
|
- "spec/**/*.rb"
|
|
474
|
+
# Mailer previews aren't tests: freeze_time there exists to render stable
|
|
475
|
+
# dev-tool output, glib's test helpers aren't available in preview classes,
|
|
476
|
+
# and the "frozen time masks timing bugs" rationale doesn't apply.
|
|
477
|
+
Exclude:
|
|
478
|
+
- "test/mailers/previews/**/*"
|
|
479
|
+
- "spec/mailers/previews/**/*"
|
|
480
|
+
|
|
481
|
+
DevDoc/Test/RequireGlibTravelBlock:
|
|
482
|
+
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."
|
|
483
|
+
Enabled: true
|
|
484
|
+
Include:
|
|
485
|
+
- "test/**/*.rb"
|
|
486
|
+
- "spec/**/*.rb"
|
|
487
|
+
|
|
488
|
+
DevDoc/Test/RequireGlibTravel:
|
|
489
|
+
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."
|
|
490
|
+
Enabled: true
|
|
491
|
+
Include:
|
|
492
|
+
- "test/controllers/**/*_test.rb"
|
|
379
493
|
|
|
380
494
|
DevDoc/Test/AvoidUnitTest:
|
|
381
495
|
Description: "Prefer controller tests; unit tests (`< ActiveSupport::TestCase`) are a rare, justified exception."
|
|
@@ -384,6 +498,53 @@ DevDoc/Test/AvoidUnitTest:
|
|
|
384
498
|
- "test/**/*.rb"
|
|
385
499
|
- "spec/**/*.rb"
|
|
386
500
|
|
|
501
|
+
DevDoc/Test/RequireGlibIntegrationBase:
|
|
502
|
+
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."
|
|
503
|
+
Enabled: true
|
|
504
|
+
RequiredBase: Glib::IntegrationTest
|
|
505
|
+
LegacyBases:
|
|
506
|
+
- ActionDispatch::IntegrationTest
|
|
507
|
+
Include:
|
|
508
|
+
- "test/controllers/**/*_test.rb"
|
|
509
|
+
- "test/integration/**/*_test.rb"
|
|
510
|
+
|
|
511
|
+
DevDoc/Test/RequireUnitTestJustification:
|
|
512
|
+
Description: "Every test file must live in a recognized directory; unit-test directories additionally require a justification header (marker phrase: 'deliberate exception')."
|
|
513
|
+
Enabled: true
|
|
514
|
+
MarkerPhrase: "deliberate exception"
|
|
515
|
+
# Total classification — Include deliberately covers ALL test files so a test
|
|
516
|
+
# can't dodge enforcement by being placed in an invented directory. Note
|
|
517
|
+
# AvoidUnitTest's base-class heuristic is evadable when a project's universal
|
|
518
|
+
# test base already descends from ActionDispatch::IntegrationTest — this cop
|
|
519
|
+
# plus the runtime lint DevDoc::Test::Lints::HttpDrivenControllerTests (which
|
|
520
|
+
# guarantees everything under test/controllers/ actually drives HTTP) close
|
|
521
|
+
# that hole from both sides:
|
|
522
|
+
# - ExemptDirectories: otherwise-enforced or accepted integration-shaped
|
|
523
|
+
# homes. Inheriting a unit base (UnitBaseClasses) there is still an
|
|
524
|
+
# offense — a unit test parked in an exempt directory would dodge both the
|
|
525
|
+
# header rule and the runtime lint (which only reaches integration-base
|
|
526
|
+
# descendants).
|
|
527
|
+
# - UnitTestDirectories: allowed only with the justification header.
|
|
528
|
+
# - Anything else (including test/ root): flagged as unrecognized — adding a
|
|
529
|
+
# new test home is a reviewed config change, not an implicit act.
|
|
530
|
+
UnitBaseClasses:
|
|
531
|
+
- Glib::LastResortUnitTest
|
|
532
|
+
UnitTestDirectories:
|
|
533
|
+
- models
|
|
534
|
+
- services
|
|
535
|
+
- helpers
|
|
536
|
+
ExemptDirectories:
|
|
537
|
+
- controllers
|
|
538
|
+
- integration
|
|
539
|
+
- jobs
|
|
540
|
+
- mailers
|
|
541
|
+
- channels
|
|
542
|
+
- system
|
|
543
|
+
- linters
|
|
544
|
+
- tasks
|
|
545
|
+
Include:
|
|
546
|
+
- "test/**/*_test.rb"
|
|
547
|
+
|
|
387
548
|
Naming/PredicatePrefix:
|
|
388
549
|
Enabled: false
|
|
389
550
|
|
|
@@ -425,11 +586,28 @@ DevDoc/I18n/AvoidTitleizeHumanize:
|
|
|
425
586
|
Severity: warning
|
|
426
587
|
Methods:
|
|
427
588
|
- titleize
|
|
589
|
+
- titlecase # ActiveSupport's documented alias of titleize — not a dodge
|
|
428
590
|
- humanize
|
|
429
591
|
Include:
|
|
430
592
|
- "app/views/**/*.jbuilder"
|
|
431
593
|
- "app/views/**/*.rb"
|
|
432
594
|
|
|
595
|
+
DevDoc/View/PreferPropOverName:
|
|
596
|
+
Description: "Bind form fields to model attributes with `prop:` (i18n-resolved label/placeholder) instead of a raw `name: 'model[attr]'` with hardcoded text."
|
|
597
|
+
# Disabled by default (new department) — enable per consumer .rubocop.yml.
|
|
598
|
+
# fields_hidden is exempt out of the box: no label/placeholder to hardcode,
|
|
599
|
+
# and hidden fields (echoed tokens, dynamic-group indexes, forced values)
|
|
600
|
+
# are the dominant legitimate raw-`name:` users. Autocorrect is unsafe: it
|
|
601
|
+
# drops the `model` segment (prop: binds to the FORM's model) and leaves
|
|
602
|
+
# sibling label:/placeholder: for manual removal.
|
|
603
|
+
Enabled: false
|
|
604
|
+
SafeAutoCorrect: false
|
|
605
|
+
ExemptFieldMethods:
|
|
606
|
+
- fields_hidden
|
|
607
|
+
Include:
|
|
608
|
+
- "app/views/**/*.jbuilder"
|
|
609
|
+
- "app/views/**/*.rb"
|
|
610
|
+
|
|
433
611
|
DevDoc/I18n/RequireTranslation:
|
|
434
612
|
Description: "Localize user-facing strings in glib JSON-UI props; pass `t(...)` instead of a hardcoded string."
|
|
435
613
|
Enabled: false
|
|
@@ -17,6 +17,10 @@ module DevDoc
|
|
|
17
17
|
# duplicate group: `# allow_duplicate_snapshot: <reason>`.
|
|
18
18
|
MARKER = 'allow_duplicate_snapshot'.freeze
|
|
19
19
|
|
|
20
|
+
# A bare marker with no reason is mechanics without policy — the
|
|
21
|
+
# reason is the reviewable part, so it is mandatory.
|
|
22
|
+
MARKER_WITH_REASON = /#{MARKER}\s*:\s*\S/
|
|
23
|
+
|
|
20
24
|
# Matches a minitest `test '...' do` declaration; captures the quote
|
|
21
25
|
# (1), the name (2), and the trailing content after `do` (3).
|
|
22
26
|
TEST_DEFINITION = /^\s*test\s+(["'])(.+?)\1\s+do\b(.*)$/
|
|
@@ -86,10 +90,12 @@ module DevDoc
|
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
# A test is marked if the opt-out token appears on its `do` line's
|
|
89
|
-
# trailing comment, or on the comment line directly above it.
|
|
93
|
+
# trailing comment, or on the comment line directly above it. The
|
|
94
|
+
# token only counts WITH a reason (`allow_duplicate_snapshot: <why>`)
|
|
95
|
+
# — a bare marker still fails, so the opt-out cannot be cargo-culted.
|
|
90
96
|
def marked?(trailing, lines, index)
|
|
91
97
|
context = trailing.to_s + (index.positive? ? lines[index - 1] : '')
|
|
92
|
-
context.
|
|
98
|
+
context.match?(MARKER_WITH_REASON)
|
|
93
99
|
end
|
|
94
100
|
|
|
95
101
|
# Resolve the test file for a `*_results` dir. Usually it's the
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module DevDoc
|
|
2
|
+
module Test
|
|
3
|
+
module Lints
|
|
4
|
+
# Runtime guarantee that every test under `test/controllers/` actually
|
|
5
|
+
# drives the app through HTTP. Include into the project's base
|
|
6
|
+
# integration test class (a descendant of ActionDispatch::IntegrationTest):
|
|
7
|
+
#
|
|
8
|
+
# Glib::IntegrationTest.include DevDoc::Test::Lints::HttpDrivenControllerTests
|
|
9
|
+
#
|
|
10
|
+
# ## Rationale
|
|
11
|
+
# Best practice (see AvoidUnitTest) prefers controller tests because they
|
|
12
|
+
# exercise the wiring a user actually hits. Static enforcement keyed on
|
|
13
|
+
# base class or file name is trivially evaded — and observed evasions by
|
|
14
|
+
# AI agents include: naming files ambiguously, mixing unit tests into
|
|
15
|
+
# controller test files, and inheriting the integration base class so the
|
|
16
|
+
# `< ActiveSupport::TestCase` heuristic never fires.
|
|
17
|
+
#
|
|
18
|
+
# This lint closes those holes by keying on *observed behavior*:
|
|
19
|
+
# `ActionDispatch::Integration::Runner#process` is the single choke point
|
|
20
|
+
# every request helper funnels through (`get`/`post`/... — no matter how
|
|
21
|
+
# deeply wrapped in `log_user_in`-style helpers), so "issued at least one
|
|
22
|
+
# request" is checked per test, not per file. A unit test cannot pass it
|
|
23
|
+
# by renaming, relocating within the file, or subclassing anything.
|
|
24
|
+
#
|
|
25
|
+
# There is deliberately NO opt-out flag: a test that never issues a
|
|
26
|
+
# request does not belong under `test/controllers/`. Move it to the
|
|
27
|
+
# matching directory (`test/models/`, `test/jobs/`, ...) with the
|
|
28
|
+
# justification header your project's conventions require
|
|
29
|
+
# (see DevDoc/Test/RequireUnitTestJustification).
|
|
30
|
+
module HttpDrivenControllerTests
|
|
31
|
+
CONTROLLER_TEST_PATH = %r{(\A|/)test/controllers/}
|
|
32
|
+
|
|
33
|
+
MESSAGE = <<~MSG.freeze
|
|
34
|
+
%<location>s lives under test/controllers/ but never issued an HTTP request —
|
|
35
|
+
it is a unit test in controller-test clothing. Convert it to drive the app
|
|
36
|
+
through a request (the bugs live in the wiring), or move it to the directory
|
|
37
|
+
matching what it exercises (test/models/, test/jobs/, ...) with a documented
|
|
38
|
+
justification header.
|
|
39
|
+
MSG
|
|
40
|
+
|
|
41
|
+
def before_setup
|
|
42
|
+
@__dev_doc_http_request_count = 0
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ActionDispatch::Integration::Runner defines the verb helpers on the
|
|
47
|
+
# test case; each delegates to integration_session.process. Overriding
|
|
48
|
+
# the verbs here (the module sits ahead of Runner in the MRO) sees every
|
|
49
|
+
# request no matter how deeply wrapped in project helpers —
|
|
50
|
+
# `follow_redirect!` also funnels through these.
|
|
51
|
+
%i[get post patch put delete head options].each do |verb|
|
|
52
|
+
define_method(verb) do |*args, **kwargs, &block|
|
|
53
|
+
@__dev_doc_http_request_count = @__dev_doc_http_request_count.to_i + 1
|
|
54
|
+
super(*args, **kwargs, &block)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def after_teardown
|
|
59
|
+
super
|
|
60
|
+
ensure
|
|
61
|
+
__dev_doc_check_http_driven
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def __dev_doc_check_http_driven
|
|
67
|
+
return unless __dev_doc_controller_test?
|
|
68
|
+
return if skipped? || !passed? # don't mask the test's own failure
|
|
69
|
+
return if @__dev_doc_http_request_count.to_i.positive?
|
|
70
|
+
|
|
71
|
+
raise Minitest::Assertion, format(MESSAGE, location: __dev_doc_test_location)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def __dev_doc_controller_test?
|
|
75
|
+
(__dev_doc_source_file || '') =~ CONTROLLER_TEST_PATH
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The file the test method is defined in — robust against shared helpers
|
|
79
|
+
# because `test '...'` macros define the method at their call site.
|
|
80
|
+
def __dev_doc_source_file
|
|
81
|
+
method(name).source_location&.first
|
|
82
|
+
rescue NameError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def __dev_doc_test_location
|
|
87
|
+
"#{self.class}##{name} (#{__dev_doc_source_file})"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -35,7 +35,9 @@ module RuboCop
|
|
|
35
35
|
MSG = 'Avoid `.%<method>s` for display text: the result is ' \
|
|
36
36
|
'English-only and bypasses I18n. Use `t(...)` instead.'.freeze
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# titlecase is ActiveSupport's documented alias of titleize — same
|
|
39
|
+
# English-only output, so it must not be an alias-shaped dodge.
|
|
40
|
+
DEFAULT_METHODS = %w[titleize titlecase humanize].freeze
|
|
39
41
|
|
|
40
42
|
def on_send(node)
|
|
41
43
|
return unless forbidden_methods.include?(node.method_name.to_s)
|
|
@@ -45,7 +45,9 @@ module RuboCop
|
|
|
45
45
|
MSG = 'Translation key `%<key>s` must start with an allowed ' \
|
|
46
46
|
'prefix: %<prefixes>s.'.freeze
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
# t!/translate! are public I18n API (raise instead of returning the
|
|
49
|
+
# missing-translation marker) — same lookup, same prefix rule.
|
|
50
|
+
RESTRICT_ON_SEND = %i[t translate t! translate!].freeze
|
|
49
51
|
|
|
50
52
|
def on_send(node)
|
|
51
53
|
prefixes = allowed_prefixes
|
|
@@ -14,6 +14,17 @@ module RuboCop
|
|
|
14
14
|
# reason why existing records may be invalid. If there is, fix those
|
|
15
15
|
# records first rather than bypassing validation.
|
|
16
16
|
#
|
|
17
|
+
# ## Relationship with Rails/SkipsModelValidations
|
|
18
|
+
# This cop is a strict SUPERSET of the stock `Rails/SkipsModelValidations`
|
|
19
|
+
# (it covers every method that cop flags, plus `delete_all` and
|
|
20
|
+
# `save(validate: false)`), with per-method actionable messages instead
|
|
21
|
+
# of a generic one. Consumer projects should enable THIS cop and disable
|
|
22
|
+
# `Rails/SkipsModelValidations` — running both forces every disable
|
|
23
|
+
# comment to name two cops for the same offense. Methods that skip
|
|
24
|
+
# validations BY DESIGN in your domain (e.g. `touch`, counter-cache
|
|
25
|
+
# maintenance) can be permitted wholesale via `AllowedMethods` rather
|
|
26
|
+
# than disabled per site.
|
|
27
|
+
#
|
|
17
28
|
# ❌ Bypasses validation — hides data integrity issues
|
|
18
29
|
# Faq.where(purpose: nil).update_all(purpose: :intro)
|
|
19
30
|
#
|
|
@@ -45,31 +56,88 @@ module RuboCop
|
|
|
45
56
|
MESSAGES = {
|
|
46
57
|
update_column: 'Avoid `update_column`; it bypasses validations. Use `save!` instead.',
|
|
47
58
|
update_columns: 'Avoid `update_columns`; it bypasses validations. Use `save!` instead.',
|
|
59
|
+
update_attribute: 'Avoid `update_attribute`; it bypasses validations (the singular is not ' \
|
|
60
|
+
'a validating `update`). Use `update!` instead.',
|
|
48
61
|
update_all: 'Avoid `update_all`; it bypasses validations. Use `save!` in a loop instead.',
|
|
62
|
+
insert: 'Avoid `insert`; it bypasses validations. Use `create!` instead, ' \
|
|
63
|
+
'or `# rubocop:disable` with a reason if a raw insert is intentional.',
|
|
64
|
+
insert!: 'Avoid `insert!`; it bypasses validations (the bang is about duplicate keys, ' \
|
|
65
|
+
'not validity). Use `create!` instead.',
|
|
49
66
|
insert_all: 'Avoid `insert_all`; it bypasses validations. Use `create!` in a loop, ' \
|
|
50
67
|
'or `# rubocop:disable` with a reason if bulk-insert is intentional.',
|
|
68
|
+
upsert: 'Avoid `upsert`; it bypasses validations. Use `create!`/`update!` instead, ' \
|
|
69
|
+
'or `# rubocop:disable` with a reason if a raw upsert is intentional.',
|
|
51
70
|
upsert_all: 'Avoid `upsert_all`; it bypasses validations. Use `create!`/`update!` in a loop, ' \
|
|
52
71
|
'or `# rubocop:disable` with a reason if bulk-upsert is intentional.',
|
|
72
|
+
toggle!: 'Avoid `toggle!`; it saves without validations. Use `toggle(...).save!` instead.',
|
|
73
|
+
increment!: 'Avoid `increment!`; it writes without validations. Use `increment(...).save!` instead.',
|
|
74
|
+
decrement!: 'Avoid `decrement!`; it writes without validations. Use `decrement(...).save!` instead.',
|
|
75
|
+
increment_counter: 'Avoid `increment_counter`; it issues a direct UPDATE that bypasses validations ' \
|
|
76
|
+
'and callbacks. Use the record with `save!`, or `AllowedMethods` for deliberate ' \
|
|
77
|
+
'counter-cache maintenance.',
|
|
78
|
+
decrement_counter: 'Avoid `decrement_counter`; it issues a direct UPDATE that bypasses validations ' \
|
|
79
|
+
'and callbacks. Use the record with `save!`, or `AllowedMethods` for deliberate ' \
|
|
80
|
+
'counter-cache maintenance.',
|
|
81
|
+
update_counters: 'Avoid `update_counters`; it issues a direct UPDATE that bypasses validations ' \
|
|
82
|
+
'and callbacks. Use the record with `save!`, or `AllowedMethods` for deliberate ' \
|
|
83
|
+
'counter-cache maintenance.',
|
|
84
|
+
touch: 'Avoid `touch`; it writes timestamps without validations or save callbacks. Use `save!` ' \
|
|
85
|
+
'(`updated_at` updates automatically), or `AllowedMethods` if bare timestamp bumps are ' \
|
|
86
|
+
'accepted in this project.',
|
|
87
|
+
touch_all: 'Avoid `touch_all`; it issues a bulk UPDATE that bypasses validations and callbacks. ' \
|
|
88
|
+
'Iterate with `save!`, or `# rubocop:disable` with a reason if a bulk timestamp bump ' \
|
|
89
|
+
'is intentional.',
|
|
90
|
+
insert_all!: 'Avoid `insert_all!`; it bypasses validations (the bang is about duplicate keys, ' \
|
|
91
|
+
'not validity). Use `create!` in a loop, or `# rubocop:disable` with a reason if ' \
|
|
92
|
+
'bulk-insert is intentional.',
|
|
53
93
|
delete_all: 'Avoid `delete_all`; it bypasses callbacks. Use `destroy_all` to run callbacks, ' \
|
|
54
94
|
'or `# rubocop:disable` with a reason if bulk-delete is intentional.'
|
|
55
95
|
}.freeze
|
|
56
96
|
|
|
57
97
|
SAVE_MSG = 'Avoid `save(validate: false)`; it bypasses validations. Use `save!` instead.'.freeze
|
|
58
98
|
|
|
59
|
-
RESTRICT_ON_SEND = %i[
|
|
99
|
+
RESTRICT_ON_SEND = (MESSAGES.keys + %i[save]).freeze
|
|
100
|
+
|
|
101
|
+
# insert/insert!/upsert are AR *class-level* APIs (User.insert(...)),
|
|
102
|
+
# while `insert` on a non-const receiver is everyday Array#insert —
|
|
103
|
+
# flag only the const-receiver form to avoid false positives.
|
|
104
|
+
CONST_RECEIVER_ONLY = %i[insert insert! upsert].freeze
|
|
105
|
+
|
|
106
|
+
# The inverse for touch: the AR form is instance-level
|
|
107
|
+
# (`record.touch`), while a const receiver is `FileUtils.touch` or
|
|
108
|
+
# similar — not an ActiveRecord write at all.
|
|
109
|
+
NON_CONST_RECEIVER_ONLY = %i[touch].freeze
|
|
60
110
|
|
|
61
111
|
def on_send(node)
|
|
112
|
+
return if allowed_methods.include?(node.method_name.to_s)
|
|
113
|
+
|
|
62
114
|
if node.method?(:save)
|
|
63
115
|
return unless save_with_validate_false?(node)
|
|
64
116
|
|
|
65
117
|
add_offense(node.loc.selector, message: SAVE_MSG)
|
|
66
118
|
else
|
|
119
|
+
return unless receiver_shape_matches?(node)
|
|
120
|
+
|
|
67
121
|
add_offense(node.loc.selector, message: MESSAGES[node.method_name])
|
|
68
122
|
end
|
|
69
123
|
end
|
|
70
124
|
|
|
71
125
|
private
|
|
72
126
|
|
|
127
|
+
def receiver_shape_matches?(node)
|
|
128
|
+
return node.receiver&.const_type? if CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
129
|
+
return !node.receiver&.const_type? if NON_CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
130
|
+
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Methods a project accepts wholesale as by-design validation skips
|
|
135
|
+
# (e.g. `touch`, counter-cache maintenance) — configure in
|
|
136
|
+
# .rubocop.yml instead of scattering per-site disables.
|
|
137
|
+
def allowed_methods
|
|
138
|
+
cop_config.fetch('AllowedMethods', []).map(&:to_s)
|
|
139
|
+
end
|
|
140
|
+
|
|
73
141
|
def save_with_validate_false?(node)
|
|
74
142
|
node.arguments.any? do |arg|
|
|
75
143
|
next unless arg.hash_type?
|
|
@@ -34,7 +34,10 @@ module RuboCop
|
|
|
34
34
|
class ApplicationRecordTransaction < Base
|
|
35
35
|
extend AutoCorrector
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
# Source-string comparison, so the cbase-qualified spellings must be
|
|
38
|
+
# listed too — `::ApplicationRecord.transaction` was a false positive.
|
|
39
|
+
ALLOWED_RECEIVERS = %w[ApplicationRecord ::ApplicationRecord
|
|
40
|
+
ActiveRecord::Base ::ActiveRecord::Base].freeze
|
|
38
41
|
|
|
39
42
|
MSG = 'Use `ApplicationRecord.transaction` instead of `%<receiver>s.transaction` outside model files.'.freeze
|
|
40
43
|
RESTRICT_ON_SEND = %i[transaction].freeze
|
|
@@ -115,12 +115,17 @@ module RuboCop
|
|
|
115
115
|
# send_confirmation if saved
|
|
116
116
|
# end
|
|
117
117
|
class AvoidRailsCallbacks < Base
|
|
118
|
+
# Includes every *_commit shorthand and the underlying set_callback
|
|
119
|
+
# API — omitting any of them (after_update_commit was missed for a
|
|
120
|
+
# while) leaves a zero-effort spelling of the same callback.
|
|
118
121
|
CALLBACKS = %i[
|
|
119
|
-
after_create after_create_commit after_save
|
|
120
|
-
|
|
122
|
+
after_create after_create_commit after_save after_save_commit
|
|
123
|
+
after_update after_update_commit after_destroy after_destroy_commit
|
|
124
|
+
after_commit before_commit after_rollback after_initialize
|
|
121
125
|
after_find after_touch before_create before_save before_update
|
|
122
126
|
before_destroy before_validation after_validation
|
|
123
127
|
around_create around_save around_update around_destroy
|
|
128
|
+
set_callback
|
|
124
129
|
].freeze
|
|
125
130
|
|
|
126
131
|
MSG = 'Avoid `%<method>s` — extract an explicit method (e.g. `save_with_*`) ' \
|
|
@@ -64,7 +64,10 @@ module RuboCop
|
|
|
64
64
|
MSG = 'Use `%<bang>s` inside a `transaction` block, or check its return value. ' \
|
|
65
65
|
'A non-bang call whose return value is discarded does not roll back the transaction on failure.'.freeze
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
# destroy has the same failure mode: it returns false when a
|
|
68
|
+
# before_destroy callback aborts, and a discarded false does not
|
|
69
|
+
# roll the transaction back.
|
|
70
|
+
FLAGGED_METHODS = %i[save update create destroy].freeze
|
|
68
71
|
|
|
69
72
|
# Node types whose parent always means the return value is consumed.
|
|
70
73
|
CONSUMING_PARENT_TYPES = %i[
|
|
@@ -87,9 +90,11 @@ module RuboCop
|
|
|
87
90
|
|
|
88
91
|
private
|
|
89
92
|
|
|
93
|
+
# with_lock opens a transaction too, so a discarded non-bang save
|
|
94
|
+
# inside it fails just as silently.
|
|
90
95
|
def inside_transaction?(node)
|
|
91
96
|
node.each_ancestor(:block).any? do |ancestor|
|
|
92
|
-
ancestor.method_name
|
|
97
|
+
%i[transaction with_lock].include?(ancestor.method_name)
|
|
93
98
|
end
|
|
94
99
|
end
|
|
95
100
|
|
|
@@ -32,6 +32,16 @@ module RuboCop
|
|
|
32
32
|
# ✔
|
|
33
33
|
# enum_symbolize :payment_status, :finalize_intent
|
|
34
34
|
#
|
|
35
|
+
# Enums declared inside a concern's `included do` block are checked the
|
|
36
|
+
# same way — the pairing must live in the same `included do` block,
|
|
37
|
+
# which is also where it belongs so every includer gets both:
|
|
38
|
+
#
|
|
39
|
+
# ✔
|
|
40
|
+
# included do
|
|
41
|
+
# enum :discount_type, DISCOUNT_TYPES
|
|
42
|
+
# enum_symbolize :discount_type
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
35
45
|
# @example
|
|
36
46
|
# # bad
|
|
37
47
|
# enum :status, { active: 0, archived: 1 }
|
|
@@ -51,24 +61,29 @@ module RuboCop
|
|
|
51
61
|
(send nil? :enum_symbolize $...)
|
|
52
62
|
PATTERN
|
|
53
63
|
|
|
64
|
+
def_node_matcher :included_block?, <<~PATTERN
|
|
65
|
+
(block (send nil? :included) (args) _)
|
|
66
|
+
PATTERN
|
|
67
|
+
|
|
54
68
|
def on_class(node)
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
check_body(node.body)
|
|
70
|
+
end
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
# Concern-declared enums: `included do ... end` runs in the including
|
|
73
|
+
# class's context, so its statements are checked like a class body.
|
|
74
|
+
def on_block(node)
|
|
75
|
+
return unless included_block?(node)
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
check_body(node.body)
|
|
78
|
+
end
|
|
62
79
|
|
|
63
|
-
|
|
64
|
-
next unless stmt.respond_to?(:send_type?) && stmt.send_type?
|
|
80
|
+
private
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
end
|
|
82
|
+
def check_body(body)
|
|
83
|
+
return unless body
|
|
84
|
+
|
|
85
|
+
statements = body.begin_type? ? body.children : [body]
|
|
86
|
+
enum_decls, symbolized = scan(statements)
|
|
72
87
|
|
|
73
88
|
enum_decls.each do |name, decl|
|
|
74
89
|
next if symbolized.include?(name)
|
|
@@ -76,6 +91,20 @@ module RuboCop
|
|
|
76
91
|
add_offense(decl, message: format(MSG, name: name))
|
|
77
92
|
end
|
|
78
93
|
end
|
|
94
|
+
|
|
95
|
+
def scan(statements)
|
|
96
|
+
sends = statements.select { |stmt| stmt.respond_to?(:send_type?) && stmt.send_type? }
|
|
97
|
+
enum_decls = sends.filter_map { |stmt| (name = enum_call(stmt)) && [name, stmt] }
|
|
98
|
+
symbolized = sends.flat_map { |stmt| symbolized_names(stmt) }
|
|
99
|
+
[enum_decls, symbolized]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def symbolized_names(stmt)
|
|
103
|
+
args = enum_symbolize_call(stmt)
|
|
104
|
+
return [] unless args
|
|
105
|
+
|
|
106
|
+
args.select(&:sym_type?).map(&:value)
|
|
107
|
+
end
|
|
79
108
|
end
|
|
80
109
|
end
|
|
81
110
|
end
|