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 +4 -4
- data/config/default.yml +51 -1
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +27 -9
- data/lib/rubocop/cop/dev_doc/style/prefer_public_send.rb +1 -1
- data/lib/rubocop/cop/dev_doc/test/justification_header.rb +80 -0
- data/lib/rubocop/cop/dev_doc/test/no_persistence_in_mailer_previews.rb +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 +4 -2
- 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
|
@@ -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
|
|
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
|
|
@@ -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,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.
|
|
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
|