rubocop-dev_doc 0.9.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: 726cbfc87e097508ea26f7cb598c61e4f743e9979d688b340f277836899a0913
4
- data.tar.gz: 221eb62a0484ac2695d0ae1c06f340192e6741118676f2b30cbd14296f3b6e8f
3
+ metadata.gz: d919e99af15a6422fabc24cd8cd5697d45777275150060f5df47fd20be56f386
4
+ data.tar.gz: 4998be254bdb93920dfa694ab035a588bb5b44f768fa9f1867814c7e88feed19
5
5
  SHA512:
6
- metadata.gz: 3ea55c9934463e771ad0f465b691268d743f0598e07a758b84d8ca66b2a54af4ce290f137f64161232aab20f32ca274b762c49b66089a3ded781826be5c0d249
7
- data.tar.gz: edc9a58999125d644baa1db29d9bf89a5de47f72c864ee717460053c2f5e0104670bdbbb56f4744ca7f2c44ab085d18d75a4a68b77f01952a35e82ab759af0d7
6
+ metadata.gz: 13dcfa0d80c10d12254a6fba6ddcbc05ec075650184739ff705ced68fc59a0743bdef285e8f990fb44bd027cc84c97db392b5fee3ba54e4b48ededd0d2c8711f
7
+ data.tar.gz: b8bf463155762956e6c2ed5619554f2a84a70765c9619a94fb06d3190f056d29c8230bbff1f11e4ccca8560cab3765106f9f1b4f7abe9da68a9760e1e7f236bc
data/config/default.yml CHANGED
@@ -504,14 +504,42 @@ DevDoc/Test/RequireGlibIntegrationBase:
504
504
  RequiredBase: Glib::IntegrationTest
505
505
  LegacyBases:
506
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.
507
511
  Include:
508
512
  - "test/controllers/**/*_test.rb"
509
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"
510
530
 
511
531
  DevDoc/Test/RequireUnitTestJustification:
512
532
  Description: "Every test file must live in a recognized directory; unit-test directories additionally require a justification header (marker phrase: 'deliberate exception')."
513
533
  Enabled: true
514
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:"
515
543
  # Total classification — Include deliberately covers ALL test files so a test
516
544
  # can't dodge enforcement by being placed in an invented directory. Note
517
545
  # AvoidUnitTest's base-class heuristic is evadable when a project's universal
@@ -524,15 +552,37 @@ DevDoc/Test/RequireUnitTestJustification:
524
552
  # offense — a unit test parked in an exempt directory would dodge both the
525
553
  # header rule and the runtime lint (which only reaches integration-base
526
554
  # descendants).
527
- # - 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.
528
567
  # - Anything else (including test/ root): flagged as unrecognized — adding a
529
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
530
574
  UnitBaseClasses:
531
575
  - Glib::LastResortUnitTest
576
+ NonHttpBaseClasses:
577
+ - Glib::NonHttpIntegrationTest
532
578
  UnitTestDirectories:
533
579
  - models
534
580
  - services
535
581
  - helpers
582
+ NonHttpDirectories:
583
+ - jobs
584
+ - mailers
585
+ - channels
536
586
  ExemptDirectories:
537
587
  - controllers
538
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
@@ -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.9.0".freeze
3
+ VERSION = "0.10.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
@@ -132,7 +132,6 @@ files:
132
132
  - lib/rubocop/cop/dev_doc/style/avoid_head_response.rb
133
133
  - lib/rubocop/cop/dev_doc/style/avoid_insecure_send.rb
134
134
  - lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb
135
- - lib/rubocop/cop/dev_doc/style/avoid_send.rb
136
135
  - lib/rubocop/cop/dev_doc/style/case_else_decision.rb
137
136
  - lib/rubocop/cop/dev_doc/style/literal_operator_in_condition.rb
138
137
  - lib/rubocop/cop/dev_doc/style/literal_or_in_when_clause.rb
@@ -146,11 +145,14 @@ files:
146
145
  - lib/rubocop/cop/dev_doc/style/tap_block_ignores_value.rb
147
146
  - lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb
148
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
149
150
  - lib/rubocop/cop/dev_doc/test/require_glib_integration_base.rb
150
151
  - lib/rubocop/cop/dev_doc/test/require_glib_travel.rb
151
152
  - lib/rubocop/cop/dev_doc/test/require_glib_travel_block.rb
152
153
  - lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb
153
154
  - lib/rubocop/cop/dev_doc/test/response_assert_equal.rb
155
+ - lib/rubocop/cop/dev_doc/test/test_file_placement.rb
154
156
  - lib/rubocop/cop/dev_doc/view/prefer_prop_over_name.rb
155
157
  - lib/rubocop/dev_doc.rb
156
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