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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 726cbfc87e097508ea26f7cb598c61e4f743e9979d688b340f277836899a0913
4
- data.tar.gz: 221eb62a0484ac2695d0ae1c06f340192e6741118676f2b30cbd14296f3b6e8f
3
+ metadata.gz: f669dc6f7e91c7e3aaac4e42e680df95fc24acbf6b467e7831387866cc2dc0d4
4
+ data.tar.gz: 41f06d6d2b20efd95b60389aae5f6803d206ad8f844d8de9daf04d87369ebf8f
5
5
  SHA512:
6
- metadata.gz: 3ea55c9934463e771ad0f465b691268d743f0598e07a758b84d8ca66b2a54af4ce290f137f64161232aab20f32ca274b762c49b66089a3ded781826be5c0d249
7
- data.tar.gz: edc9a58999125d644baa1db29d9bf89a5de47f72c864ee717460053c2f5e0104670bdbbb56f4744ca7f2c44ab085d18d75a4a68b77f01952a35e82ab759af0d7
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: 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).
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. 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.
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
@@ -1,7 +1,7 @@
1
1
  module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
- module Migration
4
+ module Rails
5
5
  # Avoid methods that bypass validations and callbacks.
6
6
  #
7
7
  # ## Rationale
@@ -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/Migration/AvoidBypassingValidation` (they
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/Migration/AvoidBypassingValidation`
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/Migration/AvoidBypassingValidation`
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
- # To keep false positives low, the cop skips receivers that clearly
33
- # aren't AR relations: array literals (`[...]`), hash literals
34
- # (`{...}`), screaming-case constants (e.g. `PRICING_PLANS`), and
35
- # send-chains ending in a method known to return a non-Relation:
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
- # `AdditionalNonRelationMethods` (default `[]`): per-project list of
67
- # method names that return non-Relation collections. Useful when a
68
- # codebase has its own helper methods returning plain Arrays / Hashes
69
- # (e.g. a presenter factory) — adding them here lets the cop skip
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
- # AdditionalNonRelationMethods:
74
- # - all_items
75
- # - for_account
76
- # - gst_registration_ranges
77
- #
78
- # NOTE: The cop cannot determine whether a local variable, instance
79
- # variable, or method parameter is an AR relation or a plain
80
- # collection. When the receiver is in fact a plain Array/Hash, add
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
- to_a to_ary
118
- values keys
119
- map flat_map collect collect_concat filter_map
120
- flatten compact uniq sort sort_by reverse
121
- reduce inject each_with_object
122
- split lines chars bytes
123
- zip take drop drop_while take_while
124
- group_by partition tally tally_by chunk_while slice_when
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
- def excluded_receiver?(receiver)
152
- return true if receiver.array_type? || receiver.hash_type?
153
- return true if receiver.const_type? && screaming_case_const?(receiver)
154
- return excluded_receiver?(receiver.children.first) if receiver.begin_type?
155
- return true if receiver.send_type? && non_relation_method?(receiver.method_name)
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
- # Merge built-in known-safe methods with project-specific ones from
161
- # `AdditionalNonRelationMethods` in .rubocop.yml. The config knob
162
- # lets each project opt-in their own domain methods (e.g. a
163
- # `CashBookEntry.for_account` that returns an Array of plain Ruby
164
- # presenters) without modifying this cop.
165
- def non_relation_method?(method_name)
166
- return true if NON_RELATION_RETURNING_METHODS.include?(method_name)
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
- additional = cop_config['AdditionalNonRelationMethods'] || []
169
- additional.map(&:to_sym).include?(method_name)
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 screaming_case_const?(const_node)
173
- const_node.short_name.to_s.match?(/\A[A-Z][A-Z0-9_]*\z/)
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
- # 2+ arg destructuring means the iterator yields a pair/tuple
186
- # (Hash#each, zip, etc.). AR relations only yield single records.
187
- return true if args.children.length >= 2
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
- # Single-arg block: inspect how the arg is used inside the body.
190
- return false unless args.children.length == 1
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
- body = block_node.body
197
- return false unless body
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
- block_arg_indicates_non_record?(body, arg_name)
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
- next unless send.method_name == :[]
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
- false
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. Same exemption as `AvoidSend`.
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