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 +4 -4
- data/config/default.yml +95 -4
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +27 -9
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +62 -12
- data/lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb +159 -0
- data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +87 -0
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +6 -6
- 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 +78 -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/dev_doc/version.rb +1 -1
- metadata +7 -3
- 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: d919e99af15a6422fabc24cd8cd5697d45777275150060f5df47fd20be56f386
|
|
4
|
+
data.tar.gz: 4998be254bdb93920dfa694ab035a588bb5b44f768fa9f1867814c7e88feed19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
199
|
-
Description: "Avoid `send`/`public_send`
|
|
198
|
+
DevDoc/Style/AvoidInsecureSend:
|
|
199
|
+
Description: "Avoid insecure dynamic `send`/`public_send` — `send` is always flagged; `public_send` requires a prefix or trusted source (validator/mailer)."
|
|
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:
|
|
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
|
|
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
|
|
@@ -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
|
|
77
|
-
return unless
|
|
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
|
-
|
|
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
|
|
93
|
-
return unless
|
|
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
|
|
25
|
-
# `DevDoc/Style/
|
|
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
|
|
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) #
|
|
31
|
+
# obj.public_send(method_name) # AvoidInsecureSend: 1 disable
|
|
32
32
|
# obj.public_send(:foo) # clean
|
|
33
|
-
# obj.send(method_name) #
|
|
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.
|
|
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
|
|
96
|
+
# # "deliberate exception" and a "Wiring:" line, and explain why no
|
|
97
|
+
# # controller path exists
|
|
67
98
|
class RequireUnitTestJustification < Base
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
122
|
-
#
|
|
123
|
-
#
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
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.
|
|
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-
|
|
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/
|
|
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
|