rubocop-dev_doc 0.8.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d3924b0184e741e19b3f29854b6f472a9bd54667ddfef1e59ccc1aa000e5073
4
- data.tar.gz: d59338919f825edec8ed8bbc717d26f19782fbc88411d90e1a8e0c110d07235d
3
+ metadata.gz: d919e99af15a6422fabc24cd8cd5697d45777275150060f5df47fd20be56f386
4
+ data.tar.gz: 4998be254bdb93920dfa694ab035a588bb5b44f768fa9f1867814c7e88feed19
5
5
  SHA512:
6
- metadata.gz: 606dad217da6a65ddc8eb66040f2f8747be5fed37aa27764f2ae74a2fa2266158e848d780b2ee10a7d690f20832b0c36b3a5b3b16d766c433c99088226b78c8e
7
- data.tar.gz: 49cac436e0dfef59326c94b89187c5d6001578972d0faa76f2fca683cfd93b9dc1e5866093351884a49e0ad58cb0f6f768a4711275f57e6bf85d24efc913137f
6
+ metadata.gz: 13dcfa0d80c10d12254a6fba6ddcbc05ec075650184739ff705ced68fc59a0743bdef285e8f990fb44bd027cc84c97db392b5fee3ba54e4b48ededd0d2c8711f
7
+ data.tar.gz: b8bf463155762956e6c2ed5619554f2a84a70765c9619a94fb06d3190f056d29c8230bbff1f11e4ccca8560cab3765106f9f1b4f7abe9da68a9760e1e7f236bc
data/config/default.yml CHANGED
@@ -195,8 +195,8 @@ DevDoc/Style/NoUnscopedMethodDefinitions:
195
195
  # included by default.
196
196
  SafeDSLReceivers: []
197
197
 
198
- DevDoc/Style/AvoidSend:
199
- Description: "Avoid `send`/`public_send` with an explicit receiver; prefer direct calls or safer alternatives."
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)."
200
200
  Enabled: true
201
201
 
202
202
  DevDoc/Style/PreferPublicSend:
@@ -388,9 +388,22 @@ Rails/StrongParametersExpect:
388
388
  # Narrower replacement for Rails/StrongParametersExpect — flags only the
389
389
  # hash-form rewrite (`params.require(:foo).permit(...)` → `params.expect(foo: [...])`).
390
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.
391
403
  DevDoc/Rails/StrongParametersExpect:
392
404
  Description: "Use `params.expect(foo: [...])` instead of `params.require(:foo).permit(...)`."
393
405
  Enabled: true
406
+ SafeAutoCorrect: false
394
407
  Include:
395
408
  - "app/controllers/**/*.rb"
396
409
 
@@ -412,10 +425,32 @@ Rails/SaveBang:
412
425
  Exclude:
413
426
  - "app/controllers/**/*"
414
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.
415
432
  Style/MissingElse:
416
- Enabled: true
433
+ Enabled: false
417
434
  EnforcedStyle: case
418
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
+
419
454
  # Core default is `Max: 2`, which permits `a&.b&.c`. Tightened to `Max: 1`:
420
455
  # in `a&.b&.c`, the second `&.` is ambiguous — the reader can't tell whether
421
456
  # it's there because `b.c` can genuinely be nil on its own, or merely because
@@ -436,6 +471,12 @@ DevDoc/Test/AvoidGlibTravelFreeze:
436
471
  Include:
437
472
  - "test/**/*.rb"
438
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/**/*"
439
480
 
440
481
  DevDoc/Test/RequireGlibTravelBlock:
441
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."
@@ -463,14 +504,42 @@ DevDoc/Test/RequireGlibIntegrationBase:
463
504
  RequiredBase: Glib::IntegrationTest
464
505
  LegacyBases:
465
506
  - ActionDispatch::IntegrationTest
507
+ # jobs/mailers/channels are included too: only LegacyBases are flagged, so the
508
+ # side-effect tier (Glib::NonHttpIntegrationTest) and unit bases there are
509
+ # untouched — this closes the seam where a raw ActionDispatch::IntegrationTest
510
+ # in test/jobs/ would sidestep both the glib plumbing and every base rule.
466
511
  Include:
467
512
  - "test/controllers/**/*_test.rb"
468
513
  - "test/integration/**/*_test.rb"
514
+ - "test/jobs/**/*_test.rb"
515
+ - "test/mailers/**/*_test.rb"
516
+ - "test/channels/**/*_test.rb"
517
+
518
+ # Previews render on every GET in development — persistence inside one mutates
519
+ # the dev DB as a side effect of viewing an email. Also guards the preview
520
+ # harness as a relocation target: mailer-body variants staged for snapshots
521
+ # must set state in memory, not write it.
522
+ DevDoc/Test/NoPersistenceInMailerPreviews:
523
+ Description: "Mailer previews must not persist records — previews render on GET; assign in memory, the mailer receives objects via .with(...)."
524
+ Enabled: true
525
+ AllowedMethods: []
526
+ Include:
527
+ - "test/mailers/previews/**/*.rb"
528
+ - "spec/mailers/previews/**/*.rb"
529
+ - "lib/mailer_previews/**/*.rb"
469
530
 
470
531
  DevDoc/Test/RequireUnitTestJustification:
471
532
  Description: "Every test file must live in a recognized directory; unit-test directories additionally require a justification header (marker phrase: 'deliberate exception')."
472
533
  Enabled: true
473
534
  MarkerPhrase: "deliberate exception"
535
+ # WiringPhrase: justified non-HTTP tests must also carry a line (matched
536
+ # case-insensitively, before the first class/module) accounting for the HTTP
537
+ # wiring their subject participates in — name the request test that covers
538
+ # that path, or state why no runtime HTTP path exists (naming the entry
539
+ # point). Writing it forces the relocating author to notice when a
540
+ # controller param / policy branch / rendered error is about to lose its
541
+ # only request-level coverage. Set to "" to disable (not recommended).
542
+ WiringPhrase: "wiring:"
474
543
  # Total classification — Include deliberately covers ALL test files so a test
475
544
  # can't dodge enforcement by being placed in an invented directory. Note
476
545
  # AvoidUnitTest's base-class heuristic is evadable when a project's universal
@@ -483,15 +552,37 @@ DevDoc/Test/RequireUnitTestJustification:
483
552
  # offense — a unit test parked in an exempt directory would dodge both the
484
553
  # header rule and the runtime lint (which only reaches integration-base
485
554
  # descendants).
486
- # - UnitTestDirectories: allowed only with the justification header.
555
+ # - UnitTestDirectories: allowed only with the justification header
556
+ # (marker phrase + wiring line).
557
+ # - NonHttpDirectories: the only homes for NonHttpBaseClasses — the
558
+ # side-effect tier (full stack, fixtures, deliveries, but no HTTP verbs)
559
+ # for jobs/mailers whose contract is a side effect, not a response.
560
+ # Elsewhere that base is an evasion vector (in test/controllers/ it would
561
+ # dodge the runtime HTTP lint exactly like a unit base) and is flagged.
562
+ # Glib::IntegrationTest remains legal in these directories: job tests
563
+ # legitimately mix HTTP assertions with side-effect assertions. A class
564
+ # on the non-HTTP base, however, carries the same justification-header
565
+ # contract as a unit directory — it consciously opted out of HTTP, so it
566
+ # must say why and account for the wiring.
487
567
  # - Anything else (including test/ root): flagged as unrecognized — adding a
488
568
  # new test home is a reviewed config change, not an implicit act.
569
+ # RequireUnitBase: allowlist semantics in unit-test directories — a *Test
570
+ # class must subclass a UnitBaseClasses member; plain ActiveSupport::TestCase
571
+ # is rejected as an unexamined default (the confession base is structural,
572
+ # not optional). Set false to revert to blocklist-only (integration bases).
573
+ RequireUnitBase: true
489
574
  UnitBaseClasses:
490
575
  - Glib::LastResortUnitTest
576
+ NonHttpBaseClasses:
577
+ - Glib::NonHttpIntegrationTest
491
578
  UnitTestDirectories:
492
579
  - models
493
580
  - services
494
581
  - helpers
582
+ NonHttpDirectories:
583
+ - jobs
584
+ - mailers
585
+ - channels
495
586
  ExemptDirectories:
496
587
  - controllers
497
588
  - integration
@@ -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
@@ -38,6 +38,31 @@ module RuboCop
38
38
  # Scalar `params[:foo]` in any context — leave that to per-project
39
39
  # decision or the upstream cop.
40
40
  #
41
+ # ## Autocorrect is UNSAFE (SafeAutoCorrect: false)
42
+ # `expect` is not a drop-in for require+permit; both deltas below were
43
+ # verified against a production suite (81 test failures before manual
44
+ # fix-ups):
45
+ #
46
+ # 1. **Empty-slice 400**: `expect` raises ParameterMissing when the key
47
+ # is present but every value filters out — `permit` returned an
48
+ # empty slice and proceeded. Bites "slice-style" param methods that
49
+ # probe for whichever keys arrived (e.g. a publishing_params called
50
+ # unconditionally in update). Keep those tolerant with
51
+ # `params.fetch(:key, {}).permit(...)` + an inline reason.
52
+ # 2. **Silent collection loss**: `expect` filters with explicit_arrays,
53
+ # so collection-valued nested attributes (numeric-keyed fields_for /
54
+ # dynamic-group hashes, `{"0"=>{...}}`) require the double-array
55
+ # `[[...]]` declaration. The flat `[...]` this autocorrect emits
56
+ # SILENTLY filters such collections to empty — no error, no data.
57
+ # After correcting, audit every *_attributes key: has_many nests
58
+ # need `[[...]]`, has_one nests stay flat.
59
+ #
60
+ # The corrector also deliberately skips permit calls whose arguments
61
+ # are computed (e.g. `permit([...static...] + extra)`): copying the
62
+ # expression into `expect(key: [<expr>])` would produce a one-element
63
+ # outer array — which IS expect's array-of-hashes syntax, silently
64
+ # changing semantics. Those sites are flagged without a correction.
65
+ #
41
66
  # @example
42
67
  # # bad
43
68
  # params.require(:user).permit(:name, :email)
@@ -57,9 +82,9 @@ module RuboCop
57
82
  extend AutoCorrector
58
83
 
59
84
  MSG_REQUIRE_PERMIT = 'Use `params.expect(%<key>s: [...])` instead of ' \
60
- '`params.require(:%<key>s).permit(...)`.'
85
+ '`params.require(:%<key>s).permit(...)`.'.freeze
61
86
  MSG_PERMIT_REQUIRE = 'Use `params.expect(%<key>s: ...)` instead of ' \
62
- '`params.permit(%<key>s: ...).require(:%<key>s)`.'
87
+ '`params.permit(%<key>s: ...).require(:%<key>s)`.'.freeze
63
88
 
64
89
  RESTRICT_ON_SEND = %i[permit require].freeze
65
90
 
@@ -73,25 +98,33 @@ module RuboCop
73
98
  # Match: params.require(:foo).permit(...)
74
99
  def check_require_permit(permit_node)
75
100
  require_node = permit_node.receiver
76
- return unless require_node&.send_type? && require_node.method_name == :require
77
- return unless params_receiver?(require_node.receiver)
78
- return unless require_node.arguments.one? && require_node.first_argument.sym_type?
101
+ return unless chained_call?(require_node, :require) && params_receiver?(require_node.receiver)
102
+ return unless single_sym_arg?(require_node)
79
103
 
80
104
  key = require_node.first_argument.value
81
105
  add_offense(permit_node, message: format(MSG_REQUIRE_PERMIT, key: key)) do |corrector|
82
- replacement = build_require_permit_replacement(
83
- require_node.receiver, key, permit_node.arguments
84
- )
85
- corrector.replace(permit_node, replacement)
106
+ autocorrect_require_permit(corrector, permit_node, require_node, key)
86
107
  end
87
108
  end
88
109
 
110
+ # Computed args (`permit([...] + extra)`, `permit(some_method)`) must
111
+ # not be source-copied into the expect array: a single computed-array
112
+ # element becomes `expect(key: [expr])` — expect's array-of-hashes
113
+ # syntax — silently changing semantics. Flag without correcting.
114
+ def autocorrect_require_permit(corrector, permit_node, require_node, key)
115
+ return unless literal_permit_args?(permit_node)
116
+
117
+ corrector.replace(
118
+ permit_node,
119
+ build_require_permit_replacement(require_node.receiver, key, permit_node.arguments)
120
+ )
121
+ end
122
+
89
123
  # Match: params.permit(foo: ...).require(:foo)
90
124
  def check_permit_require(require_node)
91
125
  permit_node = require_node.receiver
92
- return unless permit_node&.send_type? && permit_node.method_name == :permit
93
- return unless params_receiver?(permit_node.receiver)
94
- return unless require_node.arguments.one? && require_node.first_argument.sym_type?
126
+ return unless chained_call?(permit_node, :permit) && params_receiver?(permit_node.receiver)
127
+ return unless single_sym_arg?(require_node)
95
128
 
96
129
  key = require_node.first_argument.value
97
130
  pair = permit_hash_pair_for_key(permit_node, key)
@@ -103,6 +136,23 @@ module RuboCop
103
136
  end
104
137
  end
105
138
 
139
+ def chained_call?(node, method_name)
140
+ node&.send_type? && node.method_name == method_name
141
+ end
142
+
143
+ def single_sym_arg?(node)
144
+ node.arguments.one? && node.first_argument.sym_type?
145
+ end
146
+
147
+ # Source-copying is only safe for literal permit arguments; any
148
+ # computed expression (method call, `+`, splat, const) changes
149
+ # meaning inside `expect`'s array literal.
150
+ def literal_permit_args?(permit_node)
151
+ permit_node.arguments.all? do |arg|
152
+ arg.sym_type? || arg.str_type? || arg.hash_type? || arg.array_type?
153
+ end
154
+ end
155
+
106
156
  def params_receiver?(node)
107
157
  node&.send_type? && node.method_name == :params && node.receiver.nil?
108
158
  end
@@ -0,0 +1,159 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Avoid insecure dynamic `send` and `public_send` with an explicit receiver.
6
+ #
7
+ # ## Rationale
8
+ # `send()` can call *any* method, including destructive ones like
9
+ # `destroy`. The risk is specifically with **dynamic** method names —
10
+ # when the argument is a variable or interpolated string, a crafted
11
+ # value could invoke methods the developer never intended to expose.
12
+ #
13
+ # `public_send` respects method visibility, but still allows calling
14
+ # any public method. When the method name is dynamic, a prefix restricts
15
+ # the callable surface to methods sharing that prefix.
16
+ #
17
+ # ## Rules
18
+ #
19
+ # **`send()` with a dynamic argument is always flagged.** There is no
20
+ # safe use case for dynamic `send` — use `public_send` instead, and
21
+ # disable with a justification when bypassing visibility is intentional.
22
+ #
23
+ # **`public_send()` with a dynamic argument is allowed when:**
24
+ # - The argument is a **literal symbol or string** (method name is fixed at code-write time).
25
+ # - The call uses a **prefix pattern** (`"prefix_#{method_name}"`) that restricts callable methods.
26
+ # - The call is in a **validator** file (`app/validators/`) — method names come from model DSL, not user input.
27
+ # - The call is a **mailer dispatch** — method names are trusted internal symbols.
28
+ #
29
+ # **`public_send()` with a dynamic argument is flagged when:**
30
+ # - The argument is an unrestricted variable or interpolation with no static prefix.
31
+ #
32
+ # ## Safer alternatives
33
+ #
34
+ # **a) For model attributes — use bracket notation instead.**
35
+ # `@model[column_name]` only accesses database columns, so it cannot
36
+ # accidentally invoke methods like `destroy`.
37
+ #
38
+ # ❌ Dangerous — method_name could be :destroy or any other method
39
+ # @user.send(method_name)
40
+ #
41
+ # ✔️ Safe — only accesses database columns
42
+ # @user[method_name]
43
+ #
44
+ # **b) For non-model objects — use a prefix to restrict callable methods.**
45
+ # By interpolating the dynamic part into a fixed prefix, only methods
46
+ # with that prefix (e.g. `export_csv`, `export_pdf`) can be invoked,
47
+ # preventing accidental calls to unintended methods.
48
+ #
49
+ # ❌ Unrestricted — any method can be called
50
+ # obj.public_send(method_name)
51
+ #
52
+ # ✔️ Restricted — only methods with the prefix can be called
53
+ # obj.public_send("export_#{method_name}")
54
+ #
55
+ # NOTE: A prefix narrows the callable surface but does not eliminate it —
56
+ # an attacker-controlled suffix can still reach any method sharing the
57
+ # prefix (e.g. `"export_#{x}"` could hit `export_and_destroy`). Use the
58
+ # narrowest prefix that fits, and prefer an explicit whitelist when the
59
+ # set of targets is small.
60
+ #
61
+ # @example
62
+ # # bad — dynamic method name from a variable
63
+ # @user.send(method_name)
64
+ # obj.public_send(action)
65
+ #
66
+ # # bad — interpolation with no static prefix restricts nothing
67
+ # obj.send("#{x}")
68
+ # obj.send("#{x}_run")
69
+ #
70
+ # # good — literal symbol: method name is statically visible
71
+ # instance.public_send(:email)
72
+ #
73
+ # # good — bracket notation for model attributes
74
+ # @user[attribute_name]
75
+ #
76
+ # # good — static prefix restricts the callable methods
77
+ # obj.public_send("export_#{method_name}")
78
+ #
79
+ # # good — validator pattern (trusted source)
80
+ # record.public_send(attribute)
81
+ #
82
+ # # good — mailer dispatch (trusted source)
83
+ # mailer.public_send(action)
84
+ class AvoidInsecureSend < Base
85
+ MSG_SEND = "Avoid dynamic `send` — use `public_send` instead, or " \
86
+ "bracket notation for model attributes. Disable with a " \
87
+ "justification when bypassing visibility is intentional.".freeze
88
+
89
+ MSG_PUBLIC_SEND = "Avoid unrestricted dynamic `public_send` — use a " \
90
+ "prefix (`obj.public_send(\"export_\#{x}\")`) to " \
91
+ "restrict callable methods, or bracket notation for " \
92
+ "model attributes.".freeze
93
+
94
+ # `__send__` is the canonical alias — omitting it would leave a
95
+ # zero-cost dodge for the exact dynamic dispatch this cop restricts.
96
+ RESTRICT_ON_SEND = %i[send public_send __send__].freeze
97
+
98
+ def on_send(node)
99
+ return if node.receiver.nil?
100
+ return if literal_argument?(node)
101
+ return if prefixed_dynamic_method?(node)
102
+
103
+ check_send(node)
104
+ end
105
+
106
+ private
107
+
108
+ def literal_argument?(node)
109
+ node.first_argument&.sym_type?
110
+ end
111
+
112
+ def prefixed_dynamic_method?(node)
113
+ arg = node.first_argument
114
+ return false unless arg&.type?(:dstr, :dsym)
115
+
116
+ first = arg.children.first
117
+ first&.str_type? && !first.value.empty?
118
+ end
119
+
120
+ def check_send(node)
121
+ if node.method?(:send) || node.method?(:__send__)
122
+ add_offense(node.loc.selector, message: MSG_SEND)
123
+ else
124
+ check_public_send(node)
125
+ end
126
+ end
127
+
128
+ def check_public_send(node)
129
+ return if validator_file?
130
+ return if mailer_dispatch?(node)
131
+
132
+ add_offense(node.loc.selector, message: MSG_PUBLIC_SEND)
133
+ end
134
+
135
+ # Validator files are exempt — method names come from model DSL
136
+ # (developer-controlled), not user input.
137
+ def validator_file?
138
+ processed_source.file_path.to_s.include?('app/validators/')
139
+ end
140
+
141
+ # Mailer dispatch is exempt — method names are trusted internal symbols
142
+ # (constructor args, method params), not user input.
143
+ #
144
+ # Heuristic: the receiver is a mailer class or a parameterized mailer,
145
+ # and the method is a mailer action (not a private helper).
146
+ def mailer_dispatch?(node)
147
+ receiver = node.receiver
148
+ return false unless receiver
149
+
150
+ receiver_source = receiver.source
151
+ receiver_source.match?(/Mailer\b/) ||
152
+ receiver_source.match?(/\.with\(/) ||
153
+ receiver_source.match?(/parameterized_mailer/)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,87 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Every `case` must carry an `else` that DECIDES what a non-matching
6
+ # value means — this cop supersedes `Style/MissingElse`
7
+ # (EnforcedStyle: case), whose "Missing `else` statement" message
8
+ # teaches the wrong reflex: add an else, any else.
9
+ #
10
+ # ## Rationale
11
+ # A `case` without `else` makes "exhaustive dispatch" and "deliberate
12
+ # partial match" indistinguishable — an unanticipated value silently
13
+ # evaluates to nil. The fix is not a bare `else`; it is a decision.
14
+ # Three legitimate outcomes:
15
+ #
16
+ # 1. **Fall-through genuinely happens in normal operation** — add
17
+ # `else` with a comment stating WHY it is correct (requires
18
+ # `Style/EmptyElse` to be disabled).
19
+ # 2. **A non-match is a bug and continuing is unsafe** — `else raise`
20
+ # (fail fast; unexpected enum/mode values must not proceed).
21
+ # 3. **A non-match is a bug but users can safely continue** — report
22
+ # to the error tracker, then degrade gracefully (return the neutral
23
+ # value). Prefer this over raising in user-facing render paths.
24
+ #
25
+ # Never pick 1 by reflex: if you cannot write down why the else
26
+ # legitimately happens, it belongs in bucket 2 or 3. Also weigh WHERE
27
+ # the case runs: raising on user-supplied input that executes before
28
+ # authorization turns garbage requests into 500s, and error-tracker
29
+ # reports on pre-auth, scanner-reachable paths are noise, not signal.
30
+ #
31
+ # ## Interaction with Style/MissingElse
32
+ # Same detection surface (case statements and `case/in` pattern
33
+ # matches; `if` is exempt) — running both double-flags every offense.
34
+ # Disable `Style/MissingElse` when enabling this cop:
35
+ #
36
+ # Style/MissingElse:
37
+ # Enabled: false
38
+ #
39
+ # @example
40
+ # # bad — a value nobody anticipated silently becomes nil
41
+ # case status
42
+ # when :active then process
43
+ # when :archived then skip
44
+ # end
45
+ #
46
+ # # good — closed set: fail fast
47
+ # case status
48
+ # when :active then process
49
+ # when :archived then skip
50
+ # else
51
+ # raise "Unexpected status: #{status}"
52
+ # end
53
+ #
54
+ # # good — bug, but users can continue: report and degrade
55
+ # case status
56
+ # when :active then process
57
+ # when :archived then skip
58
+ # else
59
+ # ErrorTracker.error("Unexpected status: #{status}")
60
+ # nil
61
+ # end
62
+ #
63
+ # # good — legitimate fall-through, reason stated
64
+ # case action_name.to_sym
65
+ # when :new, :create then load_import
66
+ # else
67
+ # # No resource setup needed for the remaining actions.
68
+ # end
69
+ class CaseElseDecision < Base
70
+ MSG = '`case` has no `else` — decide what a non-match means: ' \
71
+ '`raise` if continuing is unsafe (closed set); report to the ' \
72
+ 'error tracker and degrade gracefully if users can continue; ' \
73
+ 'or `else` + a comment stating why fall-through is ' \
74
+ 'legitimate. Never add a bare `else` by reflex.'.freeze
75
+
76
+ def on_case(node)
77
+ add_offense(node.loc.keyword) unless node.else?
78
+ end
79
+
80
+ def on_case_match(node)
81
+ add_offense(node.loc.keyword) unless node.else?
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -21,16 +21,16 @@ module RuboCop
21
21
  # meant a public-API call. `send` leaves it ambiguous whether
22
22
  # private access was wanted.
23
23
  #
24
- # ## Relationship to AvoidSend
25
- # `DevDoc/Style/AvoidSend` flags *dynamic* dispatch (both `send` and
24
+ # ## Relationship to AvoidInsecureSend
25
+ # `DevDoc/Style/AvoidInsecureSend` flags *dynamic* dispatch (both `send` and
26
26
  # `public_send`) as risky. This cop is orthogonal: it flags `send`
27
- # specifically, including the cases AvoidSend exempts (literal-symbol
27
+ # specifically, including the cases AvoidInsecureSend exempts (literal-symbol
28
28
  # args, prefix-string args). The friction asymmetry is intentional —
29
29
  # `send` is the deeper exception, so it costs an extra disable:
30
30
  #
31
- # obj.public_send(method_name) # AvoidSend: 1 disable
31
+ # obj.public_send(method_name) # AvoidInsecureSend: 1 disable
32
32
  # obj.public_send(:foo) # clean
33
- # obj.send(method_name) # AvoidSend + this cop: 2 disables
33
+ # obj.send(method_name) # AvoidInsecureSend + this cop: 2 disables
34
34
  # obj.send(:foo) # this cop only: 1 disable
35
35
  #
36
36
  # ## When `send` IS the right choice
@@ -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
@@ -0,0 +1,78 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # Mailer previews must not persist records. A preview renders on every
6
+ # GET in development, so a `save!`/`update!`/`create!` inside one
7
+ # mutates the development database as a side effect of merely VIEWING
8
+ # an email — and re-renders keep re-writing it.
9
+ #
10
+ # ## Rationale
11
+ # Previews are also a relocation target: when the HTTP-driven lint
12
+ # pushes mailer-body assertions into preview variants (snapshot-tested
13
+ # via generate_preview_tests), the temptation is to persist the state
14
+ # the variant needs. Don't — the mailer receives its objects in memory
15
+ # via `.with(...)`, so in-memory assignment (`record.attr = value`)
16
+ # renders identically with no side effect. For sample data, use
17
+ # fixtures/seeded rows or unsaved `new`/`build` records.
18
+ #
19
+ # This was a real defect: a preview called
20
+ # `checklist.update!(note_for_assignee: ...)` to stage a note variant,
21
+ # silently rewriting the first published checklist's note in the dev DB
22
+ # on every preview view. In-memory assignment renders the same body.
23
+ #
24
+ # `AllowedMethods` exists for projects whose preview data setup
25
+ # genuinely must write (e.g. a sandboxed preview database) — prefer
26
+ # leaving it empty.
27
+ #
28
+ # @example
29
+ # # bad — viewing the preview rewrites the record
30
+ # def reminder_with_note
31
+ # checklist.update!(note_for_assignee: 'Standing note')
32
+ # ChecklistMailer.with(checklist: checklist).reminder
33
+ # end
34
+ #
35
+ # # good — same rendered body, no side effect
36
+ # def reminder_with_note
37
+ # checklist.note_for_assignee = 'Standing note'
38
+ # ChecklistMailer.with(checklist: checklist).reminder
39
+ # end
40
+ class NoPersistenceInMailerPreviews < Base
41
+ MSG = '`%<method>s` persists from a mailer preview — previews render on every GET in ' \
42
+ 'development, so viewing the email mutates the dev DB. Assign in memory instead ' \
43
+ '(`record.attr = value`): the mailer receives the object via `.with(...)`, so the ' \
44
+ 'rendered body is identical. For sample data, use fixtures/seeded rows or unsaved ' \
45
+ '`new`/`build` records.'.freeze
46
+
47
+ # `delete` and `insert` are deliberately absent: Hash#delete /
48
+ # Array#insert are everyday preview code and would false-positive;
49
+ # record-level intent is still covered by destroy/delete_all/insert_all.
50
+ PERSISTENCE_METHODS = %i[
51
+ save save! create create! update update! update_column update_columns
52
+ update_all update_attribute delete_all destroy destroy! destroy_all
53
+ insert! insert_all insert_all! upsert upsert_all
54
+ increment! decrement! toggle! touch touch_all
55
+ find_or_create_by find_or_create_by! create_or_find_by create_or_find_by!
56
+ first_or_create first_or_create!
57
+ ].freeze
58
+
59
+ def on_send(node)
60
+ method = node.method_name
61
+ return unless PERSISTENCE_METHODS.include?(method)
62
+ return if allowed_methods.include?(method.to_s)
63
+
64
+ add_offense(node.loc.selector, message: format(MSG, method: method))
65
+ end
66
+ # Safe navigation (`record&.update_column`) arrives as csend, which
67
+ # on_send does NOT receive — without this alias it slips through
68
+ # (found by red-teaming: `first&.update_column` in a real preview).
69
+ alias on_csend on_send
70
+
71
+ private
72
+
73
+ def allowed_methods = cop_config.fetch('AllowedMethods', [])
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,3 +1,6 @@
1
+ require_relative 'justification_header'
2
+ require_relative 'test_file_placement'
3
+
1
4
  module RuboCop
2
5
  module Cop
3
6
  module DevDoc
@@ -15,12 +18,37 @@ module RuboCop
15
18
  # in an exempt directory means a unit test is parked where the
16
19
  # justification-header rule (and, for test/controllers/, the runtime
17
20
  # HTTP lint — which only reaches integration-base descendants) cannot
18
- # see it.
21
+ # see it. The non-HTTP side-effect base (`NonHttpBaseClasses`, default
22
+ # `Glib::NonHttpIntegrationTest`) is confined the same way to its own
23
+ # directories (`NonHttpDirectories`, default: jobs, mailers,
24
+ # channels): full-stack tests whose contract is a side effect (a job's
25
+ # record mutations, a mailer's body) rather than an HTTP response.
26
+ # Outside those directories it is an evasion vector — in
27
+ # test/controllers/ it would dodge the runtime HTTP lint exactly like
28
+ # a unit base, and in a unit directory it would launder a unit test
29
+ # past the truthful-declaration rule. Inside them, a class on the
30
+ # non-HTTP base carries the same justification-header contract as a
31
+ # unit directory (it consciously opted out of HTTP), while classes on
32
+ # the integration base stay header-free — job tests legitimately mix
33
+ # HTTP assertions with side-effect assertions.
19
34
  # 2. **Unit-test directories** (`UnitTestDirectories`, default: models,
20
35
  # services, helpers) — allowed only with a justification header: the
21
36
  # leading comments (before the first class/module) must contain the
22
37
  # marker phrase (default "deliberate exception") explaining why a
23
- # controller test can't cover this.
38
+ # controller test can't cover this, AND a wiring line (`WiringPhrase`,
39
+ # default "wiring:") naming the request test that still covers the
40
+ # HTTP path this test's subject participates in — or why no runtime
41
+ # HTTP path exists, naming the actual entry point. The justification
42
+ # must state the concrete mechanism (who calls save!, where the data
43
+ # comes from); stock phrases ("the controller merely surfaces the
44
+ # model's errors") do not count, and a header that misstates the
45
+ # mechanism is worse than none — verify the claim by tracing the
46
+ # write path before writing it. Base classes are allowlisted here
47
+ # (`RequireUnitBase`, default true): a *Test class must subclass a
48
+ # `UnitBaseClasses` member — plain ActiveSupport::TestCase is
49
+ # rejected as an unexamined default, so the confession base is
50
+ # structural, not optional. Inline helpers/doubles (non-*Test names)
51
+ # are not policed.
24
52
  # 3. **Anything else** — flagged as an unrecognized test directory.
25
53
  # Adding a new test home is a reviewed decision made in this cop's
26
54
  # config, not something a generated test can do implicitly.
@@ -53,6 +81,8 @@ module RuboCop
53
81
  # # Per best practice, we focus on controller tests. This model test
54
82
  # # is a deliberate exception: the safely_* guards raise on ANY direct
55
83
  # # call, so by design no controller path can reach them.
84
+ # # Wiring: none — the guards are dead-man switches with no runtime
85
+ # # caller; member_soft_deletion_test covers the soft-delete flow.
56
86
  # class MemberTest < Glib::LastResortUnitTest
57
87
  #
58
88
  # @example
@@ -63,14 +93,11 @@ module RuboCop
63
93
  # # bad — test/controllers/member_test.rb inheriting Glib::LastResortUnitTest
64
94
  #
65
95
  # # good — test/models/member_test.rb whose leading comments contain
66
- # # "deliberate exception" and explain why no controller path exists
96
+ # # "deliberate exception" and a "Wiring:" line, and explain why no
97
+ # # controller path exists
67
98
  class RequireUnitTestJustification < Base
68
- MSG_JUSTIFY = 'Unit-test file without a justification header. Controller tests catch the ' \
69
- 'wiring bugs unit tests miss, so first assume a controller test IS possible ' \
70
- 'and look harder — that conclusion is almost always premature (see ' \
71
- 'DevDoc/Test/AvoidUnitTest for patterns that reach "unit-only" behaviour ' \
72
- 'end-to-end). If genuinely impossible, explain why in the leading comments, ' \
73
- 'including the marker phrase "%<phrase>s".'.freeze
99
+ include JustificationHeader
100
+ include TestFilePlacement
74
101
 
75
102
  MSG_UNRECOGNIZED = 'Test file in unrecognized directory `test/%<dir>s/`. Unit tests hide in ' \
76
103
  'uncategorized directories: move this file to a recognized home ' \
@@ -87,8 +114,23 @@ module RuboCop
87
114
  'hiding from that enforcement — move it there and justify it, or write a ' \
88
115
  'real %<dir>s test.'.freeze
89
116
 
117
+ MSG_UNIT_BASE_REQUIRED = '`%<base>s` is not a sanctioned unit-test base. A justified unit ' \
118
+ 'test must subclass one of: %<unit_bases>s — the name is the ' \
119
+ 'policy (a conscious last resort) and base-class-keyed tooling ' \
120
+ 'relies on it; plain ActiveSupport::TestCase reads as an ' \
121
+ 'unexamined default. Extend UnitBaseClasses in this cop\'s ' \
122
+ 'config if your project has another sanctioned unit base.'.freeze
123
+
124
+ MSG_NON_HTTP_BASE = '`%<base>s` is the non-HTTP side-effect base — it belongs only in ' \
125
+ '%<non_http_dirs>s. In `test/%<dir>s/` it evades that directory\'s ' \
126
+ 'enforcement (the runtime HTTP lint reaches only integration-base ' \
127
+ 'descendants; unit directories require a unit base under a ' \
128
+ 'justification header) — move the test, or subclass the base this ' \
129
+ 'directory is enforced through.'.freeze
130
+
90
131
  FORBIDDEN_BASES = %w[ActionDispatch::IntegrationTest Glib::IntegrationTest].freeze
91
132
  EXEMPT_DIRS = %w[controllers integration jobs mailers channels system linters tasks].freeze
133
+ NON_HTTP_DIRS = %w[jobs mailers channels].freeze
92
134
 
93
135
  def on_new_investigation
94
136
  return if processed_source.blank?
@@ -103,109 +145,93 @@ module RuboCop
103
145
 
104
146
  def enforce_directory_rules(dir)
105
147
  if exempt_directories.include?(dir)
106
- check_unit_bases(dir)
148
+ check_confined_bases(dir)
149
+ check_justification if non_http_base_in_non_http_dir?(dir)
107
150
  elsif unit_test_directories.include?(dir)
108
151
  check_justification
109
152
  check_base_classes
153
+ check_confined_bases(dir)
110
154
  else
111
155
  add_offense(offense_range, message: format(MSG_UNRECOGNIZED, dir: dir, known: known_directories))
112
156
  end
113
157
  end
114
158
 
115
- def check_justification
116
- return if leading_comments_contain_phrase?
117
-
118
- add_offense(offense_range, message: format(MSG_JUSTIFY, phrase: marker_phrase))
159
+ # Landing on the non-HTTP side-effect base is a conscious exception
160
+ # too (the class opted out of HTTP inside an integration-shaped
161
+ # home), so it carries the same header contract as a unit directory.
162
+ def non_http_base_in_non_http_dir?(dir)
163
+ non_http_directories.include?(dir) && class_with_base?(non_http_base_classes)
119
164
  end
120
165
 
121
- # A justified unit test must also DECLARE itself truthfully: inheriting
122
- # an integration base class hides the test type from readers and from
123
- # base-class-keyed tooling (the observed AvoidUnitTest evasion).
166
+ # A justified unit test must also DECLARE itself truthfully. Two
167
+ # tiers: integration bases get the tailored lie-about-type message;
168
+ # any other base outside UnitBaseClasses (e.g. plain
169
+ # ActiveSupport::TestCase) is rejected as an unexamined default —
170
+ # allowlist semantics, so the confession base is structural, not
171
+ # optional (disable via RequireUnitBase: false). Non-HTTP bases are
172
+ # skipped here so check_confined_bases' specific message fires.
124
173
  def check_base_classes
125
- each_class_with_base(forbidden_base_classes) do |superclass, base|
126
- add_offense(superclass, message: format(MSG_BASE, base: base))
127
- end
128
- end
129
-
130
- # The inverse rule for exempt directories: a unit base there means a
131
- # unit test parked where neither the header rule nor the runtime HTTP
132
- # lint (which only reaches integration-base descendants) can see it.
133
- def check_unit_bases(dir)
134
- each_class_with_base(unit_base_classes) do |superclass, base|
135
- add_offense(superclass,
136
- message: format(MSG_UNIT_BASE, base: base, dir: dir, unit_dirs: unit_directories_list))
137
- end
138
- end
174
+ each_test_class do |class_node, base|
175
+ next if base.nil? || unit_base_classes.include?(base) || non_http_base_classes.include?(base)
139
176
 
140
- def each_class_with_base(bases)
141
- root = processed_source.ast
142
- return unless root
143
-
144
- root.each_node(:class) do |class_node|
145
- base = class_node.parent_class&.const_name
146
- yield(class_node.parent_class, base) if base && bases.include?(base)
177
+ message = base_offense_message(base)
178
+ add_offense(class_node.parent_class, message: message) if message
147
179
  end
148
180
  end
149
181
 
150
- # First path segment after the (last) `test/` component; nil when the
151
- # file is not under one, '.' for files directly in test/. Only
152
- # `*_test.rb` files count — anything else (helpers, shared scenarios,
153
- # previews) is never loaded by the test runner, so there is nothing to
154
- # enforce. In-cop guard on purpose: the config Include says the same,
155
- # but the cop must stay correct even when run with a forced config.
156
- def test_subdirectory
157
- path = processed_source.file_path.to_s
158
- return nil unless File.basename(path).end_with?('_test.rb')
159
-
160
- rest = path_after_test_root(path)
161
- return nil unless rest
162
-
163
- segments = rest.split('/')
164
- segments.length == 1 ? '.' : segments.first
165
- end
166
-
167
- # Everything after the LAST `test/` root component (handles nesting
168
- # like test/services/ai/x_test.rb and repos checked out under a
169
- # directory that itself contains `test/`).
170
- def path_after_test_root(path)
171
- if (index = path.rindex('/test/'))
172
- path[(index + '/test/'.length)..]
173
- elsif path.start_with?('test/')
174
- path.delete_prefix('test/')
182
+ def base_offense_message(base)
183
+ if forbidden_base_classes.include?(base)
184
+ format(MSG_BASE, base: base)
185
+ elsif require_unit_base?
186
+ format(MSG_UNIT_BASE_REQUIRED, base: base, unit_bases: unit_base_classes.join(', '))
175
187
  end
176
188
  end
177
189
 
178
- def leading_comments_contain_phrase?
179
- processed_source.comments.any? do |comment|
180
- comment.location.line < first_definition_line &&
181
- comment.text.downcase.include?(marker_phrase.downcase)
190
+ # Bases confined to a directory set — the unit base to unit-test
191
+ # directories, the non-HTTP side-effect base to NonHttpDirectories.
192
+ # Anywhere else, the base parks a test where the enforcement that
193
+ # directory relies on (header rule, runtime HTTP lint — which only
194
+ # reaches integration-base descendants) cannot see it.
195
+ def check_confined_bases(dir)
196
+ base_confinements.each do |bases, allowed_dirs, message|
197
+ next if allowed_dirs.include?(dir)
198
+
199
+ each_class_with_base(bases) do |superclass, base|
200
+ add_offense(superclass,
201
+ message: format(message, base: base, dir: dir,
202
+ unit_dirs: unit_directories_list,
203
+ non_http_dirs: non_http_directories_list))
204
+ end
182
205
  end
183
206
  end
184
207
 
185
- # Header comments legitimately sit between the `require`s and the test
186
- # class, so "leading" means before the first class/module definition.
187
- def first_definition_line
188
- root = processed_source.ast
189
- return Float::INFINITY unless root
190
-
191
- definition = root.each_node(:class, :module).first
192
- (definition || root).first_line
208
+ def base_confinements
209
+ [
210
+ [unit_base_classes, unit_test_directories, MSG_UNIT_BASE],
211
+ [non_http_base_classes, non_http_directories, MSG_NON_HTTP_BASE]
212
+ ]
193
213
  end
194
214
 
195
- def marker_phrase = cop_config.fetch('MarkerPhrase', 'deliberate exception')
196
-
197
215
  def forbidden_base_classes = cop_config.fetch('ForbiddenBaseClasses', FORBIDDEN_BASES)
198
216
 
199
217
  def unit_base_classes = cop_config.fetch('UnitBaseClasses', %w[Glib::LastResortUnitTest])
200
218
 
219
+ def non_http_base_classes = cop_config.fetch('NonHttpBaseClasses', %w[Glib::NonHttpIntegrationTest])
220
+
221
+ def require_unit_base? = cop_config.fetch('RequireUnitBase', true)
222
+
201
223
  def unit_test_directories = cop_config.fetch('UnitTestDirectories', %w[models services helpers])
202
224
 
225
+ def non_http_directories = cop_config.fetch('NonHttpDirectories', NON_HTTP_DIRS)
226
+
203
227
  def exempt_directories = cop_config.fetch('ExemptDirectories', EXEMPT_DIRS)
204
228
 
205
229
  def known_directories = (exempt_directories + unit_test_directories).map { |d| "test/#{d}/" }.join(', ')
206
230
 
207
231
  def unit_directories_list = unit_test_directories.map { |d| "test/#{d}/" }.join(', ')
208
232
 
233
+ def non_http_directories_list = non_http_directories.map { |d| "test/#{d}/" }.join(', ')
234
+
209
235
  def offense_range
210
236
  range = processed_source.ast&.source_range || processed_source.comments.first&.source_range
211
237
  range.with(end_pos: range.begin_pos + [range.source_line.length, 1].max)
@@ -0,0 +1,74 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Test
5
+ # Helpers shared by test-placement cops: classifying which test/
6
+ # subdirectory a file belongs to, and scanning its classes for base
7
+ # classes that placement rules key on.
8
+ module TestFilePlacement
9
+ private
10
+
11
+ # First path segment after the (last) `test/` component; nil when the
12
+ # file is not under one, '.' for files directly in test/. Only
13
+ # `*_test.rb` files count — anything else (helpers, shared scenarios,
14
+ # previews) is never loaded by the test runner, so there is nothing to
15
+ # enforce. In-module guard on purpose: the config Include says the same,
16
+ # but the cop must stay correct even when run with a forced config.
17
+ def test_subdirectory
18
+ path = processed_source.file_path.to_s
19
+ return nil unless File.basename(path).end_with?('_test.rb')
20
+
21
+ rest = path_after_test_root(path)
22
+ return nil unless rest
23
+
24
+ segments = rest.split('/')
25
+ segments.length == 1 ? '.' : segments.first
26
+ end
27
+
28
+ # Everything after the LAST `test/` root component (handles nesting
29
+ # like test/services/ai/x_test.rb and repos checked out under a
30
+ # directory that itself contains `test/`).
31
+ def path_after_test_root(path)
32
+ if (index = path.rindex('/test/'))
33
+ path[(index + '/test/'.length)..]
34
+ elsif path.start_with?('test/')
35
+ path.delete_prefix('test/')
36
+ end
37
+ end
38
+
39
+ def each_class_with_base(bases)
40
+ root = processed_source.ast
41
+ return unless root
42
+
43
+ root.each_node(:class) do |class_node|
44
+ base = class_node.parent_class&.const_name
45
+ yield(class_node.parent_class, base) if base && bases.include?(base)
46
+ end
47
+ end
48
+
49
+ # Yields each class node whose NAME marks it as a test class
50
+ # (`...Test`), with its superclass const name (nil when none). Scoping
51
+ # to *Test names keeps inline helpers/doubles (`class FakeGateway <
52
+ # BaseGateway`) out of base-class enforcement.
53
+ def each_test_class
54
+ root = processed_source.ast
55
+ return unless root
56
+
57
+ root.each_node(:class) do |class_node|
58
+ name = class_node.identifier.const_name
59
+ next unless name&.end_with?('Test')
60
+
61
+ yield(class_node, class_node.parent_class&.const_name)
62
+ end
63
+ end
64
+
65
+ def class_with_base?(bases)
66
+ found = false
67
+ each_class_with_base(bases) { |_superclass, _base| found = true }
68
+ found
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.8.0".freeze
3
+ VERSION = "0.10.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-04 00:00:00.000000000 Z
11
+ date: 2026-07-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -130,8 +130,9 @@ files:
130
130
  - lib/rubocop/cop/dev_doc/route/resource_name_number.rb
131
131
  - lib/rubocop/cop/dev_doc/route/resources_require_only.rb
132
132
  - lib/rubocop/cop/dev_doc/style/avoid_head_response.rb
133
+ - lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb
133
134
  - lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb
134
- - lib/rubocop/cop/dev_doc/style/avoid_send.rb
135
+ - lib/rubocop/cop/dev_doc/style/case_else_decision.rb
135
136
  - lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb
136
137
  - lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb
137
138
  - lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb
@@ -144,11 +145,14 @@ files:
144
145
  - lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb
145
146
  - lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
146
147
  - lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb
148
+ - lib/rubocop/cop/dev_doc/test/justification_header.rb
149
+ - lib/rubocop/cop/dev_doc/test/no_persistence_in_mailer_previews.rb
147
150
  - lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb
148
151
  - lib/rubocop/cop/dev_doc/test/require_glib_travel.rb
149
152
  - lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb
150
153
  - lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb
151
154
  - lib/rubocop/cop/dev_doc/test/response_assert_equal.rb
155
+ - lib/rubocop/cop/dev_doc/test/test_file_placement.rb
152
156
  - lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb
153
157
  - lib/rubocop/dev_doc.rb
154
158
  - lib/rubocop/dev_doc/plugin.rb
@@ -1,95 +0,0 @@
1
- module RuboCop
2
- module Cop
3
- module DevDoc
4
- module Style
5
- # Avoid dynamic `send` and `public_send` with an explicit receiver.
6
- #
7
- # ## Rationale
8
- # `send()` can call *any* method, including destructive ones like
9
- # `destroy`. The risk is specifically with **dynamic** method names —
10
- # when the argument is a variable or interpolated string, a crafted
11
- # value could invoke methods the developer never intended to expose.
12
- #
13
- # **Literal symbol arguments are exempt** — the method name is fixed at
14
- # code-write time and visible to reviewers, equivalent to a direct call.
15
- #
16
- # ## Safer alternatives
17
- #
18
- # **a) For model attributes — use bracket notation instead.**
19
- # `@model[column_name]` only accesses database columns, so it cannot
20
- # accidentally invoke methods like `destroy`.
21
- #
22
- # ❌ Dangerous — method_name could be :destroy or any other method
23
- # @user.send(method_name)
24
- #
25
- # ✔️ Safe — only accesses database columns
26
- # @user[method_name]
27
- #
28
- # **b) For non-model objects — use a prefix to restrict callable methods.**
29
- # By interpolating the dynamic part into a fixed prefix, only methods
30
- # with that prefix (e.g. `export_csv`, `export_pdf`) can be invoked,
31
- # preventing accidental calls to unintended methods.
32
- #
33
- # ❌ Unrestricted — any method can be called
34
- # obj.send(method_name)
35
- #
36
- # ✔️ Restricted — only methods with the prefix can be called
37
- # obj.send("export_#{method_name}")
38
- #
39
- # NOTE: A prefix narrows the callable surface but does not eliminate it —
40
- # an attacker-controlled suffix can still reach any method sharing the
41
- # prefix (e.g. `"export_#{x}"` could hit `export_and_destroy`). Use the
42
- # narrowest prefix that fits, and prefer an explicit whitelist when the
43
- # set of targets is small.
44
- #
45
- # @example
46
- # # bad — dynamic method name from a variable
47
- # @user.send(method_name)
48
- # obj.public_send(action)
49
- #
50
- # # bad — interpolation with no static prefix restricts nothing
51
- # obj.send("#{x}")
52
- # obj.send("#{x}_run")
53
- #
54
- # # good — literal symbol: method name is statically visible
55
- # instance.send(:private_helper, arg)
56
- #
57
- # # good — bracket notation for model attributes
58
- # @user[attribute_name]
59
- #
60
- # # good — static prefix restricts the callable methods
61
- # obj.send("export_#{method_name}")
62
- class AvoidSend < Base
63
- MSG = "Avoid dynamic `%<method>s` — use bracket notation for model attributes, " \
64
- "or a prefix (`obj.send(\"export_\#{x}\")`) to restrict callable methods.".freeze
65
- # `__send__` is the canonical alias — omitting it would leave a
66
- # zero-cost dodge for the exact dynamic dispatch this cop restricts.
67
- RESTRICT_ON_SEND = %i[send public_send __send__].freeze
68
-
69
- def on_send(node)
70
- return if node.receiver.nil?
71
-
72
- arg = node.first_argument
73
- return if arg&.sym_type?
74
- return if prefixed_dynamic_method?(arg)
75
-
76
- add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
77
- end
78
-
79
- private
80
-
81
- # A dynamic string/symbol that begins with a static prefix (e.g.
82
- # `"export_#{x}"`) restricts the callable surface to methods sharing
83
- # that prefix, so it is exempt. Pure interpolation (`"#{x}"`) or a
84
- # trailing prefix (`"#{x}_run"`) restricts nothing and is still flagged.
85
- def prefixed_dynamic_method?(arg)
86
- return false unless arg&.type?(:dstr, :dsym)
87
-
88
- first = arg.children.first
89
- first&.str_type? && !first.value.empty?
90
- end
91
- end
92
- end
93
- end
94
- end
95
- end