rubocop-dev_doc 0.9.0 → 0.10.1
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 +69 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +27 -9
- data/lib/rubocop/cop/dev_doc/{migration → rails}/avoid_bypassing_validation.rb +1 -1
- data/lib/rubocop/cop/dev_doc/rails/avoid_raw_sql.rb +3 -3
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +133 -78
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +1 -1
- data/lib/rubocop/cop/dev_doc/test/justification_header.rb +80 -0
- data/lib/rubocop/cop/dev_doc/test/no_persistence_in_mailer_previews.rb +94 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +103 -77
- data/lib/rubocop/cop/dev_doc/test/test_file_placement.rb +74 -0
- data/lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb +64 -11
- data/lib/rubocop/dev_doc/version.rb +1 -1
- data/lib/rubocop/dev_doc.rb +1 -0
- metadata +6 -4
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +0 -95
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f669dc6f7e91c7e3aaac4e42e680df95fc24acbf6b467e7831387866cc2dc0d4
|
|
4
|
+
data.tar.gz: 41f06d6d2b20efd95b60389aae5f6803d206ad8f844d8de9daf04d87369ebf8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7cdef60287b7ef58701aab3c5e025416b2033d93c3a09d06b6addc3636daaf169010584df484ec6377caa192d74d901ee52a0abf56473490c3f4e289cf6895ba
|
|
7
|
+
data.tar.gz: 84a55b7ffaf35e17e8ee436c7f696c176659c30394fa7ed6a50a7bc46fa64ecbb2b5728a5df6547d457ba0006154e9279409b7fc2f6075f4937d0a0406a37ee6
|
data/config/default.yml
CHANGED
|
@@ -96,7 +96,11 @@ DevDoc/Migration/BooleanColumnNotNull:
|
|
|
96
96
|
# Enabled: false
|
|
97
97
|
# Use AllowedMethods for skips that are by-design in your project (touch,
|
|
98
98
|
# counter caches) instead of per-site disables.
|
|
99
|
-
DevDoc/Migration/AvoidBypassingValidation
|
|
99
|
+
# Renamed from DevDoc/Migration/AvoidBypassingValidation in 0.9.0 — the cop was
|
|
100
|
+
# never migration-specific (deliberately global, no Include); the old department
|
|
101
|
+
# name misled readers into thinking app code was unguarded. Old name raises a
|
|
102
|
+
# rename error via config/obsoletion.yml.
|
|
103
|
+
DevDoc/Rails/AvoidBypassingValidation:
|
|
100
104
|
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."
|
|
101
105
|
Enabled: false
|
|
102
106
|
AllowedMethods: []
|
|
@@ -177,9 +181,16 @@ DevDoc/Rails/EnumColumnNotNull:
|
|
|
177
181
|
Include:
|
|
178
182
|
- "app/models/**/*.rb"
|
|
179
183
|
|
|
184
|
+
# Receiver detection is inclusion-based (precision-first): the cop only fires
|
|
185
|
+
# when the chain visibly proves a relation (where/joins/includes/order/... or
|
|
186
|
+
# a scope listed in AdditionalRelationMethods). Opaque receivers and bare
|
|
187
|
+
# association reads are NOT flagged — the prior exclusion-based design
|
|
188
|
+
# dogfooded at 21 offenses / 0 true positives on a mature codebase. Recover
|
|
189
|
+
# recall on hot association/scope names via AdditionalRelationMethods.
|
|
180
190
|
DevDoc/Rails/NoBlockPredicateOnRelation:
|
|
181
191
|
Description: "Avoid block-form `count`/`reject`/`select`/`find`/`any?` on AR relations; push the predicate into SQL with `.where(...)` or a scope."
|
|
182
192
|
Enabled: true
|
|
193
|
+
AdditionalRelationMethods: []
|
|
183
194
|
AdditionalNonRelationMethods: []
|
|
184
195
|
Exclude:
|
|
185
196
|
- "spec/**/*"
|
|
@@ -504,14 +515,42 @@ DevDoc/Test/RequireGlibIntegrationBase:
|
|
|
504
515
|
RequiredBase: Glib::IntegrationTest
|
|
505
516
|
LegacyBases:
|
|
506
517
|
- ActionDispatch::IntegrationTest
|
|
518
|
+
# jobs/mailers/channels are included too: only LegacyBases are flagged, so the
|
|
519
|
+
# side-effect tier (Glib::NonHttpIntegrationTest) and unit bases there are
|
|
520
|
+
# untouched — this closes the seam where a raw ActionDispatch::IntegrationTest
|
|
521
|
+
# in test/jobs/ would sidestep both the glib plumbing and every base rule.
|
|
507
522
|
Include:
|
|
508
523
|
- "test/controllers/**/*_test.rb"
|
|
509
524
|
- "test/integration/**/*_test.rb"
|
|
525
|
+
- "test/jobs/**/*_test.rb"
|
|
526
|
+
- "test/mailers/**/*_test.rb"
|
|
527
|
+
- "test/channels/**/*_test.rb"
|
|
528
|
+
|
|
529
|
+
# Previews render on every GET in development — persistence inside one mutates
|
|
530
|
+
# the dev DB as a side effect of viewing an email. Also guards the preview
|
|
531
|
+
# harness as a relocation target: mailer-body variants staged for snapshots
|
|
532
|
+
# must set state in memory, not write it.
|
|
533
|
+
DevDoc/Test/NoPersistenceInMailerPreviews:
|
|
534
|
+
Description: "Mailer previews must not persist records — previews render on GET; assign in memory, the mailer receives objects via .with(...)."
|
|
535
|
+
Enabled: true
|
|
536
|
+
AllowedMethods: []
|
|
537
|
+
Include:
|
|
538
|
+
- "test/mailers/previews/**/*.rb"
|
|
539
|
+
- "spec/mailers/previews/**/*.rb"
|
|
540
|
+
- "lib/mailer_previews/**/*.rb"
|
|
510
541
|
|
|
511
542
|
DevDoc/Test/RequireUnitTestJustification:
|
|
512
543
|
Description: "Every test file must live in a recognized directory; unit-test directories additionally require a justification header (marker phrase: 'deliberate exception')."
|
|
513
544
|
Enabled: true
|
|
514
545
|
MarkerPhrase: "deliberate exception"
|
|
546
|
+
# WiringPhrase: justified non-HTTP tests must also carry a line (matched
|
|
547
|
+
# case-insensitively, before the first class/module) accounting for the HTTP
|
|
548
|
+
# wiring their subject participates in — name the request test that covers
|
|
549
|
+
# that path, or state why no runtime HTTP path exists (naming the entry
|
|
550
|
+
# point). Writing it forces the relocating author to notice when a
|
|
551
|
+
# controller param / policy branch / rendered error is about to lose its
|
|
552
|
+
# only request-level coverage. Set to "" to disable (not recommended).
|
|
553
|
+
WiringPhrase: "wiring:"
|
|
515
554
|
# Total classification — Include deliberately covers ALL test files so a test
|
|
516
555
|
# can't dodge enforcement by being placed in an invented directory. Note
|
|
517
556
|
# AvoidUnitTest's base-class heuristic is evadable when a project's universal
|
|
@@ -524,15 +563,37 @@ DevDoc/Test/RequireUnitTestJustification:
|
|
|
524
563
|
# offense — a unit test parked in an exempt directory would dodge both the
|
|
525
564
|
# header rule and the runtime lint (which only reaches integration-base
|
|
526
565
|
# descendants).
|
|
527
|
-
# - UnitTestDirectories: allowed only with the justification header
|
|
566
|
+
# - UnitTestDirectories: allowed only with the justification header
|
|
567
|
+
# (marker phrase + wiring line).
|
|
568
|
+
# - NonHttpDirectories: the only homes for NonHttpBaseClasses — the
|
|
569
|
+
# side-effect tier (full stack, fixtures, deliveries, but no HTTP verbs)
|
|
570
|
+
# for jobs/mailers whose contract is a side effect, not a response.
|
|
571
|
+
# Elsewhere that base is an evasion vector (in test/controllers/ it would
|
|
572
|
+
# dodge the runtime HTTP lint exactly like a unit base) and is flagged.
|
|
573
|
+
# Glib::IntegrationTest remains legal in these directories: job tests
|
|
574
|
+
# legitimately mix HTTP assertions with side-effect assertions. A class
|
|
575
|
+
# on the non-HTTP base, however, carries the same justification-header
|
|
576
|
+
# contract as a unit directory — it consciously opted out of HTTP, so it
|
|
577
|
+
# must say why and account for the wiring.
|
|
528
578
|
# - Anything else (including test/ root): flagged as unrecognized — adding a
|
|
529
579
|
# new test home is a reviewed config change, not an implicit act.
|
|
580
|
+
# RequireUnitBase: allowlist semantics in unit-test directories — a *Test
|
|
581
|
+
# class must subclass a UnitBaseClasses member; plain ActiveSupport::TestCase
|
|
582
|
+
# is rejected as an unexamined default (the confession base is structural,
|
|
583
|
+
# not optional). Set false to revert to blocklist-only (integration bases).
|
|
584
|
+
RequireUnitBase: true
|
|
530
585
|
UnitBaseClasses:
|
|
531
586
|
- Glib::LastResortUnitTest
|
|
587
|
+
NonHttpBaseClasses:
|
|
588
|
+
- Glib::NonHttpIntegrationTest
|
|
532
589
|
UnitTestDirectories:
|
|
533
590
|
- models
|
|
534
591
|
- services
|
|
535
592
|
- helpers
|
|
593
|
+
NonHttpDirectories:
|
|
594
|
+
- jobs
|
|
595
|
+
- mailers
|
|
596
|
+
- channels
|
|
536
597
|
ExemptDirectories:
|
|
537
598
|
- controllers
|
|
538
599
|
- integration
|
|
@@ -602,8 +663,14 @@ DevDoc/View/PreferPropOverName:
|
|
|
602
663
|
# sibling label:/placeholder: for manual removal.
|
|
603
664
|
Enabled: false
|
|
604
665
|
SafeAutoCorrect: false
|
|
666
|
+
# fields_creditCard and fields_dynamicGroup are structurally non-model:
|
|
667
|
+
# credit-card fields capture transient credentials that must never bind to
|
|
668
|
+
# a persisted model, and dynamic-group names are array-param structural keys
|
|
669
|
+
# with no label/placeholder to derive.
|
|
605
670
|
ExemptFieldMethods:
|
|
606
671
|
- fields_hidden
|
|
672
|
+
- fields_creditCard
|
|
673
|
+
- fields_dynamicGroup
|
|
607
674
|
Include:
|
|
608
675
|
- "app/views/**/*.jbuilder"
|
|
609
676
|
- "app/views/**/*.rb"
|
|
@@ -22,20 +22,38 @@ module DevDoc
|
|
|
22
22
|
# request" is checked per test, not per file. A unit test cannot pass it
|
|
23
23
|
# by renaming, relocating within the file, or subclassing anything.
|
|
24
24
|
#
|
|
25
|
-
# There is deliberately NO opt-out flag
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# (
|
|
25
|
+
# There is deliberately NO opt-out flag, and the escape route is
|
|
26
|
+
# deliberately ordered: CONVERT first, relocate only as a last resort.
|
|
27
|
+
# The observed failure mode when this lint fires is relocation-verbatim —
|
|
28
|
+
# the test moves to a unit/side-effect directory unchanged, and the HTTP
|
|
29
|
+
# wiring its subject participates in (a controller param handed to a
|
|
30
|
+
# mailer, a policy branch, a rendered error) silently loses its only
|
|
31
|
+
# prospective coverage. That is why the destination header must carry a
|
|
32
|
+
# "Wiring:" line (enforced by DevDoc/Test/RequireUnitTestJustification):
|
|
33
|
+
# writing it forces the mover to go look for the request test that still
|
|
34
|
+
# covers the wiring — and to notice when there isn't one. Relocated tests
|
|
35
|
+
# must also re-base off the integration class (e.g. onto
|
|
36
|
+
# Glib::LastResortUnitTest or Glib::NonHttpIntegrationTest); the
|
|
37
|
+
# destination dirs forbid integration bases, so a test moved unchanged
|
|
38
|
+
# keeps tripping that cop.
|
|
30
39
|
module HttpDrivenControllerTests
|
|
31
40
|
CONTROLLER_TEST_PATH = %r{(\A|/)test/controllers/}
|
|
32
41
|
|
|
33
42
|
MESSAGE = <<~MSG.freeze
|
|
34
43
|
%<location>s lives under test/controllers/ but never issued an HTTP request —
|
|
35
|
-
it is a unit test in controller-test clothing.
|
|
36
|
-
through a request (the bugs live in the wiring),
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
it is a unit test in controller-test clothing. CONVERT it first: drive the
|
|
45
|
+
same behavior through a request (the bugs live in the wiring), assuming a
|
|
46
|
+
request test IS possible and looking harder — that conclusion is almost
|
|
47
|
+
always premature (see DevDoc/Test/AvoidUnitTest for the patterns). Only if
|
|
48
|
+
conversion is genuinely impossible, RELOCATE it to the directory matching
|
|
49
|
+
what it exercises (test/models/, test/jobs/, ...) on a non-integration base
|
|
50
|
+
(e.g. Glib::LastResortUnitTest / Glib::NonHttpIntegrationTest) — never
|
|
51
|
+
verbatim: the destination header must justify the exception AND carry a
|
|
52
|
+
"Wiring:" line naming the request test that still covers the HTTP path this
|
|
53
|
+
test's subject participates in (or stating why no HTTP path exists), both
|
|
54
|
+
enforced by DevDoc/Test/RequireUnitTestJustification. Relocating assertions
|
|
55
|
+
into the mailer-preview harness only pins the mailer itself — a request test
|
|
56
|
+
must still prove the controller feeds it (params -> Mailer.with(...)).
|
|
39
57
|
MSG
|
|
40
58
|
|
|
41
59
|
def before_setup
|
|
@@ -59,7 +59,7 @@ module RuboCop
|
|
|
59
59
|
# For **single records**, use `Model.increment_counter(:col, id)`
|
|
60
60
|
# or `Model.update_counters(id, col: 1)` — both generate
|
|
61
61
|
# parameterized SQL and surface typos in the column name. Neither
|
|
62
|
-
# is flagged by `DevDoc/
|
|
62
|
+
# is flagged by `DevDoc/Rails/AvoidBypassingValidation` (they
|
|
63
63
|
# are the Rails-blessed atomic-counter primitives).
|
|
64
64
|
#
|
|
65
65
|
# For **bulk counter updates**, the only clean option is
|
|
@@ -67,7 +67,7 @@ module RuboCop
|
|
|
67
67
|
# queries, slow on large tables, but free of the
|
|
68
68
|
# validation-bypass smell. If the N-query cost is genuinely
|
|
69
69
|
# unacceptable, `update_all("col = col + 1")` requires disabling
|
|
70
|
-
# BOTH this cop AND `DevDoc/
|
|
70
|
+
# BOTH this cop AND `DevDoc/Rails/AvoidBypassingValidation`
|
|
71
71
|
# with reasons — the friction is the audit trail (locking
|
|
72
72
|
# implications, idempotency on re-runs, and the like are worth
|
|
73
73
|
# a second look).
|
|
@@ -95,7 +95,7 @@ module RuboCop
|
|
|
95
95
|
# migration failing because a model changed.
|
|
96
96
|
#
|
|
97
97
|
# The correct backfill pattern goes through the model with
|
|
98
|
-
# `save!` — `DevDoc/
|
|
98
|
+
# `save!` — `DevDoc/Rails/AvoidBypassingValidation`
|
|
99
99
|
# enforces this, and `best_practices/backend/en/02_migration.md`
|
|
100
100
|
# shows the shape:
|
|
101
101
|
#
|
|
@@ -28,11 +28,31 @@ module RuboCop
|
|
|
28
28
|
# attributes, non-trivial Ruby logic). For those, add a
|
|
29
29
|
# `# rubocop:disable` with a brief reason.
|
|
30
30
|
#
|
|
31
|
+
# ## Receiver detection is precision-first (inclusion, not exclusion)
|
|
32
|
+
# The cop only fires when the receiver chain PROVES it is a relation:
|
|
33
|
+
# it must contain a relation-returning method (`where`, `joins`,
|
|
34
|
+
# `includes`, `order`, `limit`, ... — see RELATION_RETURNING_METHODS)
|
|
35
|
+
# or a project-declared scope from `AdditionalRelationMethods`.
|
|
36
|
+
# Opaque receivers (locals, ivars, method params, bare association
|
|
37
|
+
# reads like `user.posts`) are NOT flagged.
|
|
38
|
+
#
|
|
39
|
+
# This is a deliberate trade. The earlier exclusion-based design
|
|
40
|
+
# ("flag anything that isn't provably a non-relation") was dogfooded
|
|
41
|
+
# against a mature codebase and scored 21 offenses with ZERO true
|
|
42
|
+
# positives — params arrays, gem data, AST enumerators, and validators
|
|
43
|
+
# that must see unsaved in-memory records. A cop that is all noise
|
|
44
|
+
# teaches people to write disables, which is worse than the missed
|
|
45
|
+
# recall: a bare `user.posts.any? { }` slipping through is a perf
|
|
46
|
+
# nit; twenty reflex disables are a culture problem. Projects can
|
|
47
|
+
# recover recall selectively by listing their hot association/scope
|
|
48
|
+
# names in `AdditionalRelationMethods`.
|
|
49
|
+
#
|
|
31
50
|
# ## Excluded receivers
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
51
|
+
# Even within a proven relation chain, the cop skips receivers whose
|
|
52
|
+
# FINAL call is known to return a non-Relation (the collection is
|
|
53
|
+
# already materialised, so SQL push-down is no longer possible), plus
|
|
54
|
+
# obvious non-relations: array literals (`[...]`), hash literals
|
|
55
|
+
# (`{...}`), and screaming-case constants (e.g. `PRICING_PLANS`):
|
|
36
56
|
# * Array-returning — `pluck`, `to_a`, `map`, `flatten`, `compact`,
|
|
37
57
|
# `uniq`, `sort`, `sort_by`, `reduce`, `inject`, `each_with_object`,
|
|
38
58
|
# `zip`, `take`, `drop`, `group_by`, `partition`, `tally`,
|
|
@@ -63,24 +83,20 @@ module RuboCop
|
|
|
63
83
|
# proves the element is a String.
|
|
64
84
|
#
|
|
65
85
|
# ## Configuration
|
|
66
|
-
# `
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
# send-chains ending in those methods. Example:
|
|
86
|
+
# `AdditionalRelationMethods` (default `[]`): per-project list of
|
|
87
|
+
# association/scope names the cop should treat as relation-returning.
|
|
88
|
+
# This is how a project recovers recall on its hot paths without
|
|
89
|
+
# re-opening the false-positive door. Example:
|
|
71
90
|
#
|
|
72
91
|
# DevDoc/Rails/NoBlockPredicateOnRelation:
|
|
73
|
-
#
|
|
74
|
-
# -
|
|
75
|
-
# -
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
# `# rubocop:disable DevDoc/Rails/NoBlockPredicateOnRelation` with a
|
|
82
|
-
# brief reason — the friction is intentional and ensures the choice
|
|
83
|
-
# is reviewed.
|
|
92
|
+
# AdditionalRelationMethods:
|
|
93
|
+
# - accessible_organization_documents
|
|
94
|
+
# - active_subscriptions
|
|
95
|
+
#
|
|
96
|
+
# `AdditionalNonRelationMethods` (default `[]`): per-project list of
|
|
97
|
+
# method names that return non-Relation collections (e.g. a presenter
|
|
98
|
+
# factory) — a chain ENDING in one of these is skipped even when an
|
|
99
|
+
# earlier link is a relation method.
|
|
84
100
|
#
|
|
85
101
|
# @example
|
|
86
102
|
# # bad
|
|
@@ -108,31 +124,38 @@ module RuboCop
|
|
|
108
124
|
# out would make the alias a zero-cost dodge.
|
|
109
125
|
RESTRICT_ON_SEND = %i[count reject select filter find detect any?].freeze
|
|
110
126
|
|
|
127
|
+
# Methods returning an ActiveRecord::Relation — a chain containing one
|
|
128
|
+
# of these PROVES the receiver is a relation (inclusion-based
|
|
129
|
+
# detection; see the docstring). `merge`, `all` and `from` are
|
|
130
|
+
# deliberately absent: Hash#merge is everyday Ruby, `.all` is also
|
|
131
|
+
# the API of non-AR clients (e.g. Stripe list endpoints), and
|
|
132
|
+
# ActiveSupport adds Array#from.
|
|
133
|
+
RELATION_RETURNING_METHODS = %i[
|
|
134
|
+
where rewhere not joins left_joins left_outer_joins includes
|
|
135
|
+
preload eager_load references order reorder group having limit
|
|
136
|
+
offset distinct unscope unscoped lock readonly or
|
|
137
|
+
].freeze
|
|
138
|
+
|
|
111
139
|
# Methods whose return value is known to be a non-Relation collection
|
|
112
140
|
# (Array, Hash, or Enumerator). When a `.select`/`.reject`/etc. with a
|
|
113
141
|
# block is chained onto a call to one of these, the block runs over
|
|
114
142
|
# the materialised collection — pushing into SQL isn't possible.
|
|
115
143
|
NON_RELATION_RETURNING_METHODS = %i[
|
|
116
|
-
pluck pluck_to_hash
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
slice except merge transform_values transform_keys to_h
|
|
127
|
-
compact_blank with_indifferent_access index_by index_with
|
|
128
|
-
|
|
129
|
-
each_with_index each_slice each_cons each_entry each_key each_value each_pair
|
|
130
|
-
chunk slice_before slice_after lazy with_index with_object
|
|
144
|
+
pluck pluck_to_hash to_a to_ary values keys map flat_map collect
|
|
145
|
+
collect_concat filter_map flatten compact uniq sort sort_by reverse
|
|
146
|
+
reduce inject each_with_object split lines chars bytes zip take
|
|
147
|
+
drop drop_while take_while group_by partition tally tally_by
|
|
148
|
+
chunk_while slice_when slice except merge transform_values
|
|
149
|
+
transform_keys to_h compact_blank with_indifferent_access index_by
|
|
150
|
+
index_with each_with_index each_slice each_cons each_entry each_key
|
|
151
|
+
each_value each_pair chunk slice_before slice_after lazy with_index
|
|
152
|
+
with_object
|
|
131
153
|
].freeze
|
|
132
154
|
|
|
133
155
|
def on_send(node)
|
|
134
156
|
return unless node.block_literal? || symbol_block_pass?(node)
|
|
135
157
|
return if node.receiver.nil?
|
|
158
|
+
return unless relation_chain?(node.receiver)
|
|
136
159
|
return if excluded_receiver?(node.receiver)
|
|
137
160
|
return if excluded_block?(node)
|
|
138
161
|
|
|
@@ -148,55 +171,83 @@ module RuboCop
|
|
|
148
171
|
last&.block_pass_type? && last.children.first&.sym_type?
|
|
149
172
|
end
|
|
150
173
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
174
|
+
# Inclusion gate: walk down the receiver chain looking for a link
|
|
175
|
+
# that PROVES this is a relation. Opaque receivers (lvars, ivars,
|
|
176
|
+
# bare association reads) never pass — that's the precision-first
|
|
177
|
+
# doctrine documented above.
|
|
178
|
+
def relation_chain?(node)
|
|
179
|
+
current = node
|
|
180
|
+
while current
|
|
181
|
+
current = unwrap_begin(current)
|
|
182
|
+
break unless current&.send_type?
|
|
183
|
+
return true if relation_method?(current.method_name)
|
|
156
184
|
|
|
185
|
+
current = current.receiver
|
|
186
|
+
end
|
|
157
187
|
false
|
|
158
188
|
end
|
|
159
189
|
|
|
160
|
-
#
|
|
161
|
-
# `
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
190
|
+
# A paren group's value is its LAST child (`(a; b).select { }` runs
|
|
191
|
+
# the block on `b`), so unwrap to that — `children.first` would judge
|
|
192
|
+
# the discarded statement.
|
|
193
|
+
def unwrap_begin(node)
|
|
194
|
+
node = node.children.last while node&.begin_type?
|
|
195
|
+
node
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def relation_method?(method_name)
|
|
199
|
+
RELATION_RETURNING_METHODS.include?(method_name) ||
|
|
200
|
+
configured_methods('AdditionalRelationMethods').include?(method_name)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Even inside a proven relation chain, skip when the FINAL call
|
|
204
|
+
# materialises the collection (`where(...).pluck(:id)`,
|
|
205
|
+
# `where(...).to_a`, ...): the block then runs over an in-memory
|
|
206
|
+
# collection and SQL push-down is no longer possible.
|
|
207
|
+
def excluded_receiver?(receiver)
|
|
208
|
+
receiver = unwrap_begin(receiver)
|
|
209
|
+
receiver&.send_type? && non_relation_method?(receiver.method_name)
|
|
210
|
+
end
|
|
167
211
|
|
|
168
|
-
|
|
169
|
-
|
|
212
|
+
# Built-in known-safe methods plus project-specific ones from
|
|
213
|
+
# `AdditionalNonRelationMethods` (e.g. a `CashBookEntry.for_account`
|
|
214
|
+
# returning an Array of plain Ruby presenters).
|
|
215
|
+
def non_relation_method?(method_name)
|
|
216
|
+
NON_RELATION_RETURNING_METHODS.include?(method_name) ||
|
|
217
|
+
configured_methods('AdditionalNonRelationMethods').include?(method_name)
|
|
170
218
|
end
|
|
171
219
|
|
|
172
|
-
def
|
|
173
|
-
|
|
220
|
+
def configured_methods(key)
|
|
221
|
+
(cop_config[key] || []).map(&:to_sym)
|
|
174
222
|
end
|
|
175
223
|
|
|
176
224
|
# Look at the block parameters and body for evidence that the
|
|
177
|
-
# iterated element can't be an ActiveRecord record.
|
|
225
|
+
# iterated element can't be an ActiveRecord record. 2+ arg
|
|
226
|
+
# destructuring means the iterator yields a pair/tuple (Hash#each,
|
|
227
|
+
# zip, etc.) — AR relations only yield single records; for a
|
|
228
|
+
# single-arg block, inspect how the arg is used inside the body.
|
|
178
229
|
def excluded_block?(send_node)
|
|
179
230
|
block_node = send_node.block_node
|
|
180
|
-
return false unless block_node
|
|
181
|
-
|
|
182
|
-
args = block_node.arguments
|
|
183
|
-
return false unless args
|
|
231
|
+
return false unless block_node.respond_to?(:arguments)
|
|
232
|
+
return true if pair_destructuring_block?(block_node)
|
|
184
233
|
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
|
|
234
|
+
# `to_a` normalises across node kinds: a block's ArgsNode yields its
|
|
235
|
+
# children; a numblock's `arguments` is already a plain (empty) Array.
|
|
236
|
+
args = block_node.arguments.to_a
|
|
237
|
+
arg_name = args.length == 1 ? block_arg_name(args.first) : nil
|
|
238
|
+
return false unless arg_name && block_node.body
|
|
188
239
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
arg_node = args.children.first
|
|
193
|
-
arg_name = block_arg_name(arg_node)
|
|
194
|
-
return false unless arg_name
|
|
240
|
+
block_arg_indicates_non_record?(block_node.body, arg_name)
|
|
241
|
+
end
|
|
195
242
|
|
|
196
|
-
|
|
197
|
-
|
|
243
|
+
# 2+ block params — literal `|k, v|` or numbered (`_2` yields arity 2
|
|
244
|
+
# as an Integer child, there is no ArgsNode) — mean the iterator
|
|
245
|
+
# yields a pair/tuple (Hash#each, zip, ...); AR relations yield
|
|
246
|
+
# single records.
|
|
247
|
+
def pair_destructuring_block?(block_node)
|
|
248
|
+
return block_node.children[1] >= 2 if block_node.numblock_type?
|
|
198
249
|
|
|
199
|
-
|
|
250
|
+
block_node.arguments.to_a.length >= 2
|
|
200
251
|
end
|
|
201
252
|
|
|
202
253
|
# Extract the simple name of a block argument, regardless of whether
|
|
@@ -214,18 +265,22 @@ module RuboCop
|
|
|
214
265
|
# * Single-character string-literal indexing — `arg[0] == '+'` —
|
|
215
266
|
# implies String
|
|
216
267
|
def block_arg_indicates_non_record?(body, arg_name)
|
|
217
|
-
body.each_descendant(:send) do |send|
|
|
218
|
-
|
|
219
|
-
next unless send.receiver&.lvar_type?
|
|
220
|
-
next unless send.receiver.children.first == arg_name
|
|
221
|
-
next unless send.arguments.length == 1
|
|
222
|
-
|
|
223
|
-
key = send.first_argument
|
|
224
|
-
return true if key.sym_type?
|
|
225
|
-
return true if key.int_type? && compared_to_single_char_string?(send)
|
|
268
|
+
body.each_descendant(:send).any? do |send|
|
|
269
|
+
indexes_block_arg?(send, arg_name) && non_record_key?(send)
|
|
226
270
|
end
|
|
271
|
+
end
|
|
227
272
|
|
|
228
|
-
|
|
273
|
+
# `arg[...]` with exactly one index argument, on the named block arg.
|
|
274
|
+
def indexes_block_arg?(send, arg_name)
|
|
275
|
+
send.method_name == :[] &&
|
|
276
|
+
send.receiver&.lvar_type? &&
|
|
277
|
+
send.receiver.children.first == arg_name &&
|
|
278
|
+
send.arguments.length == 1
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def non_record_key?(send)
|
|
282
|
+
key = send.first_argument
|
|
283
|
+
key.sym_type? || (key.int_type? && compared_to_single_char_string?(send))
|
|
229
284
|
end
|
|
230
285
|
|
|
231
286
|
# `arg[0] == '+'` — the [] send is one side of a `==` or `!=`
|
|
@@ -51,7 +51,7 @@ module RuboCop
|
|
|
51
51
|
# `send(:foo)` (no explicit receiver) calls a method on `self` and is
|
|
52
52
|
# commonly used inside a class to dispatch to its own private methods.
|
|
53
53
|
# Rewriting to `public_send` would either change semantics (the
|
|
54
|
-
# method must become public) or fail.
|
|
54
|
+
# method must become public) or fail.
|
|
55
55
|
#
|
|
56
56
|
# @example
|
|
57
57
|
# # bad — prefer public_send
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
# The justification-header contract shared by test-placement cops: a
|
|
6
|
+
# justified non-HTTP test must open with leading comments (before the
|
|
7
|
+
# first class/module) that contain the marker phrase (why a controller
|
|
8
|
+
# test can't cover this) AND a wiring line (which request test still
|
|
9
|
+
# covers the HTTP path its subject participates in — or why no runtime
|
|
10
|
+
# HTTP path exists). Host cops must provide `offense_range` and
|
|
11
|
+
# `cop_config`.
|
|
12
|
+
module JustificationHeader
|
|
13
|
+
MSG_JUSTIFY = 'Unit-test file without a justification header. Controller tests catch the ' \
|
|
14
|
+
'wiring bugs unit tests miss, so first assume a controller test IS possible ' \
|
|
15
|
+
'and look harder — that conclusion is almost always premature (see ' \
|
|
16
|
+
'DevDoc/Test/AvoidUnitTest for patterns that reach "unit-only" behaviour ' \
|
|
17
|
+
'end-to-end). If genuinely impossible, explain why in the leading comments, ' \
|
|
18
|
+
'including the marker phrase "%<phrase>s".'.freeze
|
|
19
|
+
|
|
20
|
+
MSG_JUSTIFY_WIRING = ' The header must also carry a "%<wiring>s" line naming the request ' \
|
|
21
|
+
'test that still covers the HTTP wiring this test bypasses (or why ' \
|
|
22
|
+
'no runtime HTTP path exists).'.freeze
|
|
23
|
+
|
|
24
|
+
MSG_WIRING = 'Justification header lacks a "%<phrase>s" line. Every justified non-HTTP ' \
|
|
25
|
+
'test must account for the HTTP wiring its subject participates in: name the ' \
|
|
26
|
+
'request test that covers that path end-to-end, or state why no runtime HTTP ' \
|
|
27
|
+
'path exists (naming the actual entry point — job, sync service, rake task). ' \
|
|
28
|
+
'Writing this line is what catches a relocation that would silently strand a ' \
|
|
29
|
+
'controller param, policy branch, or rendered error with no request-level ' \
|
|
30
|
+
'coverage.'.freeze
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# One offense at a time (same range — RuboCop dedupes): a missing
|
|
35
|
+
# header gets MSG_JUSTIFY (which names the full contract, wiring line
|
|
36
|
+
# included); MSG_WIRING fires only when the marker is present but the
|
|
37
|
+
# wiring line is not — the signature of a relocation that never asked
|
|
38
|
+
# what request-level coverage remains.
|
|
39
|
+
def check_justification
|
|
40
|
+
unless leading_comments_contain?(marker_phrase)
|
|
41
|
+
add_offense(offense_range, message: justify_message)
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
return if wiring_phrase.empty? || leading_comments_contain?(wiring_phrase)
|
|
45
|
+
|
|
46
|
+
add_offense(offense_range, message: format(MSG_WIRING, phrase: wiring_phrase))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def justify_message
|
|
50
|
+
message = format(MSG_JUSTIFY, phrase: marker_phrase)
|
|
51
|
+
return message if wiring_phrase.empty?
|
|
52
|
+
|
|
53
|
+
message + format(MSG_JUSTIFY_WIRING, wiring: wiring_phrase)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def leading_comments_contain?(phrase)
|
|
57
|
+
processed_source.comments.any? do |comment|
|
|
58
|
+
comment.location.line < first_definition_line &&
|
|
59
|
+
comment.text.downcase.include?(phrase.downcase)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Header comments legitimately sit between the `require`s and the test
|
|
64
|
+
# class, so "leading" means before the first class/module definition.
|
|
65
|
+
def first_definition_line
|
|
66
|
+
root = processed_source.ast
|
|
67
|
+
return Float::INFINITY unless root
|
|
68
|
+
|
|
69
|
+
definition = root.each_node(:class, :module).first
|
|
70
|
+
(definition || root).first_line
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def marker_phrase = cop_config.fetch('MarkerPhrase', 'deliberate exception')
|
|
74
|
+
|
|
75
|
+
def wiring_phrase = cop_config.fetch('WiringPhrase', 'wiring:').to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|