rubocop-rspec-guide 0.2.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +37 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +296 -0
- data/Rakefile +10 -0
- data/config/default.yml +38 -0
- data/devbox.json +14 -0
- data/devbox.lock +74 -0
- data/lib/rubocop/cop/factory_bot_guide/dynamic_attributes_for_time_and_random.rb +121 -0
- data/lib/rubocop/cop/rspec_guide/characteristics_and_contexts.rb +91 -0
- data/lib/rubocop/cop/rspec_guide/context_setup.rb +88 -0
- data/lib/rubocop/cop/rspec_guide/duplicate_before_hooks.rb +163 -0
- data/lib/rubocop/cop/rspec_guide/duplicate_let_values.rb +193 -0
- data/lib/rubocop/cop/rspec_guide/happy_path_first.rb +116 -0
- data/lib/rubocop/cop/rspec_guide/invariant_examples.rb +143 -0
- data/lib/rubocop/rspec/guide/version.rb +9 -0
- data/lib/rubocop/rspec/guide.rb +12 -0
- data/lib/rubocop-rspec-guide.rb +13 -0
- data/sig/rubocop/rspec/guide.rbs +8 -0
- metadata +136 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module RSpecGuide
|
|
6
|
+
# Checks that describe blocks have at least 2 context blocks
|
|
7
|
+
# to separate happy path from corner cases.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# describe '#process' do
|
|
12
|
+
# it 'works' do
|
|
13
|
+
# expect(subject.process).to be_success
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# # bad
|
|
18
|
+
# describe '#process' do
|
|
19
|
+
# context 'when data is valid' do
|
|
20
|
+
# it 'processes' do
|
|
21
|
+
# expect(subject.process).to be_success
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # good
|
|
27
|
+
# describe '#process' do
|
|
28
|
+
# context 'when data is valid' do
|
|
29
|
+
# it 'processes successfully' do
|
|
30
|
+
# expect(subject.process).to be_success
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# context 'when data is invalid' do
|
|
35
|
+
# it 'returns error' do
|
|
36
|
+
# expect(subject.process).to be_error
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
class CharacteristicsAndContexts < Base
|
|
42
|
+
MSG = "Describe block should have at least 2 contexts " \
|
|
43
|
+
"(happy path + edge cases). " \
|
|
44
|
+
"Use `# rubocop:disable RSpecGuide/CharacteristicsAndContexts` " \
|
|
45
|
+
"if truly no edge cases exist."
|
|
46
|
+
|
|
47
|
+
# @!method describe_block?(node)
|
|
48
|
+
def_node_matcher :describe_block?, <<~PATTERN
|
|
49
|
+
(block
|
|
50
|
+
(send nil? :describe ...)
|
|
51
|
+
...)
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
# @!method context_block?(node)
|
|
55
|
+
def_node_matcher :context_block?, <<~PATTERN
|
|
56
|
+
(block (send nil? :context ...) ...)
|
|
57
|
+
PATTERN
|
|
58
|
+
|
|
59
|
+
def on_block(node)
|
|
60
|
+
return unless describe_block?(node)
|
|
61
|
+
|
|
62
|
+
# Collect direct child context blocks
|
|
63
|
+
contexts = collect_sibling_contexts(node)
|
|
64
|
+
|
|
65
|
+
# Add offense if less than 2 contexts
|
|
66
|
+
add_offense(node) if contexts.size < 2
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def collect_sibling_contexts(node)
|
|
72
|
+
# The body of a describe/context block may be:
|
|
73
|
+
# 1. A single block node (if only one child)
|
|
74
|
+
# 2. A begin node containing multiple children
|
|
75
|
+
body = node.body
|
|
76
|
+
return [] unless body
|
|
77
|
+
|
|
78
|
+
if body.begin_type?
|
|
79
|
+
# Multiple children wrapped in begin node
|
|
80
|
+
body.children.select { |child| child.block_type? && context_block?(child) }
|
|
81
|
+
elsif body.block_type? && context_block?(body)
|
|
82
|
+
# Single context child
|
|
83
|
+
[body]
|
|
84
|
+
else
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module RSpecGuide
|
|
6
|
+
# Checks that context blocks have setup (let/before) to distinguish
|
|
7
|
+
# them from the parent context.
|
|
8
|
+
#
|
|
9
|
+
# Note: subject should be defined at describe level, not in contexts,
|
|
10
|
+
# as it describes the object under test, not context-specific state.
|
|
11
|
+
# Use RSpec/LeadingSubject cop to ensure subject is defined first.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# # bad
|
|
15
|
+
# context 'when user is premium' do
|
|
16
|
+
# it 'has access' do
|
|
17
|
+
# expect(user).to have_access
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # good
|
|
22
|
+
# context 'when user is premium' do
|
|
23
|
+
# let(:user) { create(:user, :premium) }
|
|
24
|
+
#
|
|
25
|
+
# it 'has access' do
|
|
26
|
+
# expect(user).to have_access
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # good
|
|
31
|
+
# context 'when user is premium' do
|
|
32
|
+
# before { user.upgrade_to_premium! }
|
|
33
|
+
#
|
|
34
|
+
# it 'has access' do
|
|
35
|
+
# expect(user).to have_access
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
class ContextSetup < Base
|
|
40
|
+
MSG = "Context should have setup (let/let!/let_it_be/let_it_be!/before) to distinguish it from parent context"
|
|
41
|
+
|
|
42
|
+
# @!method context_block?(node)
|
|
43
|
+
def_node_matcher :context_block?, <<~PATTERN
|
|
44
|
+
(block
|
|
45
|
+
(send nil? :context ...)
|
|
46
|
+
...)
|
|
47
|
+
PATTERN
|
|
48
|
+
|
|
49
|
+
# @!method let_declaration?(node)
|
|
50
|
+
def_node_matcher :let_declaration?, <<~PATTERN
|
|
51
|
+
(block (send nil? {:let :let! :let_it_be :let_it_be!} ...) ...)
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
# @!method before_hook?(node)
|
|
55
|
+
def_node_matcher :before_hook?, <<~PATTERN
|
|
56
|
+
(block (send nil? :before ...) ...)
|
|
57
|
+
PATTERN
|
|
58
|
+
|
|
59
|
+
def on_block(node)
|
|
60
|
+
return unless context_block?(node)
|
|
61
|
+
|
|
62
|
+
# Check if context has at least one setup node (let or before)
|
|
63
|
+
# Note: subject is NOT counted as context setup because it describes
|
|
64
|
+
# the object under test, not context-specific state
|
|
65
|
+
has_setup = has_context_setup?(node)
|
|
66
|
+
|
|
67
|
+
add_offense(node) unless has_setup
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def has_context_setup?(context_node)
|
|
73
|
+
# Look for let/before blocks directly in this context
|
|
74
|
+
context_node.each_descendant(:block) do |block_node|
|
|
75
|
+
# Only check immediate children (not nested contexts)
|
|
76
|
+
is_immediate_child = block_node.parent == context_node ||
|
|
77
|
+
(block_node.parent.begin_type? && block_node.parent.parent == context_node)
|
|
78
|
+
next unless is_immediate_child
|
|
79
|
+
|
|
80
|
+
return true if let_declaration?(block_node) || before_hook?(block_node)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module RSpecGuide
|
|
6
|
+
# Detects duplicate before hooks with identical code across all sibling contexts.
|
|
7
|
+
# These should be extracted to the parent context.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# describe 'Controller' do
|
|
12
|
+
# context 'as admin' do
|
|
13
|
+
# before { sign_in(user) }
|
|
14
|
+
# it { expect(response).to be_successful }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# context 'as guest' do
|
|
18
|
+
# before { sign_in(user) } # Duplicate!
|
|
19
|
+
# it { expect(response).to be_forbidden }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # good
|
|
24
|
+
# describe 'Controller' do
|
|
25
|
+
# before { sign_in(user) } # Extracted to parent
|
|
26
|
+
#
|
|
27
|
+
# context 'as admin' do
|
|
28
|
+
# it { expect(response).to be_successful }
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# context 'as guest' do
|
|
32
|
+
# it { expect(response).to be_forbidden }
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
class DuplicateBeforeHooks < Base
|
|
37
|
+
MSG_ERROR = "Duplicate `before` hook in ALL sibling contexts. " \
|
|
38
|
+
"Extract to parent context."
|
|
39
|
+
MSG_WARNING = "Before hook duplicated in %<count>d/%<total>d sibling contexts. " \
|
|
40
|
+
"Consider refactoring test hierarchy - this suggests poor organization."
|
|
41
|
+
|
|
42
|
+
# @!method context_block?(node)
|
|
43
|
+
def_node_matcher :context_block?, <<~PATTERN
|
|
44
|
+
(block (send nil? :context ...) ...)
|
|
45
|
+
PATTERN
|
|
46
|
+
|
|
47
|
+
# @!method before_hook?(node)
|
|
48
|
+
def_node_matcher :before_hook?, <<~PATTERN
|
|
49
|
+
(block
|
|
50
|
+
(send nil? :before ...)
|
|
51
|
+
(args)
|
|
52
|
+
$_body)
|
|
53
|
+
PATTERN
|
|
54
|
+
|
|
55
|
+
# @!method example_group?(node)
|
|
56
|
+
def_node_matcher :example_group?, <<~PATTERN
|
|
57
|
+
(block
|
|
58
|
+
(send nil? {:describe :context} ...)
|
|
59
|
+
...)
|
|
60
|
+
PATTERN
|
|
61
|
+
|
|
62
|
+
def on_block(node)
|
|
63
|
+
return unless example_group?(node)
|
|
64
|
+
|
|
65
|
+
# Collect all sibling contexts
|
|
66
|
+
sibling_contexts = collect_sibling_contexts(node)
|
|
67
|
+
return if sibling_contexts.size < 2
|
|
68
|
+
|
|
69
|
+
# Collect before hooks from each context
|
|
70
|
+
befores_by_context = sibling_contexts.map do |ctx|
|
|
71
|
+
collect_before_hooks(ctx)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Find duplicates
|
|
75
|
+
find_duplicate_befores(befores_by_context)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def collect_sibling_contexts(node)
|
|
81
|
+
# The body of a describe/context block may be:
|
|
82
|
+
# 1. A single block node (if only one child)
|
|
83
|
+
# 2. A begin node containing multiple children
|
|
84
|
+
body = node.body
|
|
85
|
+
return [] unless body
|
|
86
|
+
|
|
87
|
+
if body.begin_type?
|
|
88
|
+
# Multiple children wrapped in begin node
|
|
89
|
+
body.children.select { |child| child.block_type? && context_block?(child) }
|
|
90
|
+
elsif body.block_type? && context_block?(body)
|
|
91
|
+
# Single context child
|
|
92
|
+
[body]
|
|
93
|
+
else
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def collect_before_hooks(context_node)
|
|
99
|
+
befores = []
|
|
100
|
+
|
|
101
|
+
# The context block has a body that may contain before hooks
|
|
102
|
+
# We need to search within the body for before blocks
|
|
103
|
+
context_node.each_descendant(:block) do |block_node|
|
|
104
|
+
# Only check direct children of the context (not nested in sub-contexts)
|
|
105
|
+
is_immediate_child = block_node.parent == context_node ||
|
|
106
|
+
(block_node.parent.begin_type? && block_node.parent.parent == context_node)
|
|
107
|
+
next unless is_immediate_child
|
|
108
|
+
|
|
109
|
+
before_hook?(block_node) do |body|
|
|
110
|
+
befores << {body_source: normalize_source(body.source), node: block_node}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
befores
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def find_duplicate_befores(befores_by_context)
|
|
118
|
+
# Get all unique before hook sources
|
|
119
|
+
all_befores = befores_by_context.flatten
|
|
120
|
+
unique_sources = all_befores.map { |b| b[:body_source] }.uniq
|
|
121
|
+
total_contexts = befores_by_context.size
|
|
122
|
+
|
|
123
|
+
unique_sources.each do |source|
|
|
124
|
+
# Count how many contexts have this before hook
|
|
125
|
+
contexts_with_this_before = befores_by_context.count do |context_befores|
|
|
126
|
+
context_befores.any? { |b| b[:body_source] == source }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Skip if only in one context
|
|
130
|
+
next if contexts_with_this_before <= 1
|
|
131
|
+
|
|
132
|
+
# ERROR: If this before hook is in ALL sibling contexts
|
|
133
|
+
if contexts_with_this_before == total_contexts
|
|
134
|
+
all_befores.each do |before_info|
|
|
135
|
+
next unless before_info[:body_source] == source
|
|
136
|
+
|
|
137
|
+
add_offense(before_info[:node], message: MSG_ERROR)
|
|
138
|
+
end
|
|
139
|
+
# WARNING: If duplicated in multiple (but not all) contexts
|
|
140
|
+
elsif cop_config.fetch("WarnOnPartialDuplicates", true) && contexts_with_this_before >= 2
|
|
141
|
+
all_befores.each do |before_info|
|
|
142
|
+
next unless before_info[:body_source] == source
|
|
143
|
+
|
|
144
|
+
add_offense(
|
|
145
|
+
before_info[:node],
|
|
146
|
+
message: format(MSG_WARNING, count: contexts_with_this_before, total: total_contexts),
|
|
147
|
+
severity: :warning
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_source(source)
|
|
155
|
+
# Normalize code for comparison:
|
|
156
|
+
# - Remove extra whitespace
|
|
157
|
+
# - Remove line breaks at start/end
|
|
158
|
+
source.strip.gsub(/\s+/, " ")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module RSpecGuide
|
|
6
|
+
# Detects duplicate let declarations with identical values across all sibling contexts.
|
|
7
|
+
# These should be extracted to the parent context.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# describe 'Calculator' do
|
|
12
|
+
# context 'with addition' do
|
|
13
|
+
# let(:currency) { :usd }
|
|
14
|
+
# it { expect(result).to eq(10) }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# context 'with subtraction' do
|
|
18
|
+
# let(:currency) { :usd } # Duplicate!
|
|
19
|
+
# it { expect(result).to eq(5) }
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # good
|
|
24
|
+
# describe 'Calculator' do
|
|
25
|
+
# let(:currency) { :usd } # Extracted to parent
|
|
26
|
+
#
|
|
27
|
+
# context 'with addition' do
|
|
28
|
+
# it { expect(result).to eq(10) }
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# context 'with subtraction' do
|
|
32
|
+
# it { expect(result).to eq(5) }
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
class DuplicateLetValues < Base
|
|
37
|
+
MSG_ERROR = "Duplicate `let(:%<name>s)` with same value `%<value>s` " \
|
|
38
|
+
"in ALL sibling contexts. Extract to parent context."
|
|
39
|
+
MSG_WARNING = "Let `:%<name>s` with value `%<value>s` duplicated in %<count>d/%<total>d contexts. " \
|
|
40
|
+
"Consider refactoring test hierarchy - this suggests poor organization."
|
|
41
|
+
|
|
42
|
+
# @!method context_block?(node)
|
|
43
|
+
def_node_matcher :context_block?, <<~PATTERN
|
|
44
|
+
(block (send nil? :context ...) ...)
|
|
45
|
+
PATTERN
|
|
46
|
+
|
|
47
|
+
# @!method let_declaration?(node)
|
|
48
|
+
def_node_matcher :let_declaration?, <<~PATTERN
|
|
49
|
+
(block
|
|
50
|
+
(send nil? {:let :let!} (sym $_name))
|
|
51
|
+
(args)
|
|
52
|
+
$_value)
|
|
53
|
+
PATTERN
|
|
54
|
+
|
|
55
|
+
# @!method example_group?(node)
|
|
56
|
+
def_node_matcher :example_group?, <<~PATTERN
|
|
57
|
+
(block
|
|
58
|
+
(send nil? {:describe :context} ...)
|
|
59
|
+
...)
|
|
60
|
+
PATTERN
|
|
61
|
+
|
|
62
|
+
def on_block(node)
|
|
63
|
+
return unless example_group?(node)
|
|
64
|
+
|
|
65
|
+
# Collect all sibling contexts
|
|
66
|
+
sibling_contexts = collect_sibling_contexts(node)
|
|
67
|
+
return if sibling_contexts.size < 2
|
|
68
|
+
|
|
69
|
+
# Collect let declarations from each context
|
|
70
|
+
lets_by_context = sibling_contexts.map do |ctx|
|
|
71
|
+
collect_lets_in_context(ctx)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Find duplicates
|
|
75
|
+
find_duplicate_lets(lets_by_context)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def collect_sibling_contexts(node)
|
|
81
|
+
# The body of a describe/context block may be:
|
|
82
|
+
# 1. A single block node (if only one child)
|
|
83
|
+
# 2. A begin node containing multiple children
|
|
84
|
+
body = node.body
|
|
85
|
+
return [] unless body
|
|
86
|
+
|
|
87
|
+
if body.begin_type?
|
|
88
|
+
# Multiple children wrapped in begin node
|
|
89
|
+
body.children.select { |child| child.block_type? && context_block?(child) }
|
|
90
|
+
elsif body.block_type? && context_block?(body)
|
|
91
|
+
# Single context child
|
|
92
|
+
[body]
|
|
93
|
+
else
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def collect_lets_in_context(context_node)
|
|
99
|
+
lets = {}
|
|
100
|
+
|
|
101
|
+
# Search within the context body for let declarations
|
|
102
|
+
context_node.each_descendant(:block) do |block_node|
|
|
103
|
+
# Only check direct children of the context (not nested in sub-contexts)
|
|
104
|
+
is_immediate_child = block_node.parent == context_node ||
|
|
105
|
+
(block_node.parent.begin_type? && block_node.parent.parent == context_node)
|
|
106
|
+
next unless is_immediate_child
|
|
107
|
+
|
|
108
|
+
let_declaration?(block_node) do |name, value|
|
|
109
|
+
# Only check simple values that can be compared by source
|
|
110
|
+
if simple_value?(value)
|
|
111
|
+
lets[name] = {value: value.source, node: block_node}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
lets
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def find_duplicate_lets(lets_by_context)
|
|
120
|
+
# Get all let names across contexts
|
|
121
|
+
all_let_names = lets_by_context.flat_map(&:keys).uniq
|
|
122
|
+
total_contexts = lets_by_context.size
|
|
123
|
+
|
|
124
|
+
all_let_names.each do |let_name|
|
|
125
|
+
# Find contexts that have this let
|
|
126
|
+
contexts_with_let = lets_by_context.select { |lets| lets.key?(let_name) }
|
|
127
|
+
|
|
128
|
+
# Skip if only in one context
|
|
129
|
+
next if contexts_with_let.size <= 1
|
|
130
|
+
|
|
131
|
+
# Collect all values
|
|
132
|
+
values = contexts_with_let.map { |lets| lets[let_name][:value] }
|
|
133
|
+
|
|
134
|
+
# Skip if values differ
|
|
135
|
+
next unless values.uniq.size == 1
|
|
136
|
+
|
|
137
|
+
value = values.first
|
|
138
|
+
|
|
139
|
+
# ERROR: If let with same value is in ALL contexts
|
|
140
|
+
if contexts_with_let.size == total_contexts
|
|
141
|
+
contexts_with_let.each do |lets|
|
|
142
|
+
add_offense(
|
|
143
|
+
lets[let_name][:node],
|
|
144
|
+
message: format(MSG_ERROR, name: let_name, value: value)
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
# WARNING: If duplicated in multiple (but not all) contexts
|
|
148
|
+
elsif cop_config.fetch("WarnOnPartialDuplicates", true) && contexts_with_let.size >= 2
|
|
149
|
+
contexts_with_let.each do |lets|
|
|
150
|
+
add_offense(
|
|
151
|
+
lets[let_name][:node],
|
|
152
|
+
message: format(MSG_WARNING, name: let_name, value: value,
|
|
153
|
+
count: contexts_with_let.size, total: total_contexts),
|
|
154
|
+
severity: :warning
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def simple_value?(node)
|
|
162
|
+
# Values we can safely compare by source
|
|
163
|
+
return true if node.sym_type? || node.str_type? || node.int_type? || node.float_type?
|
|
164
|
+
return true if node.true_type? || node.false_type? || node.nil_type?
|
|
165
|
+
|
|
166
|
+
# Handle hash pair nodes (key-value pairs inside hashes)
|
|
167
|
+
if node.pair_type?
|
|
168
|
+
return simple_value?(node.key) && simple_value?(node.value)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if node.hash_type?
|
|
172
|
+
# Hash children are pair nodes
|
|
173
|
+
return node.children.all? { |child| simple_value?(child) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
if node.array_type?
|
|
177
|
+
# Array children can be any simple values
|
|
178
|
+
return node.children.all? { |child| simple_value?(child) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Method calls like create(:user), build(:post), etc.
|
|
182
|
+
# We can compare these by source code
|
|
183
|
+
return true if node.send_type?
|
|
184
|
+
|
|
185
|
+
# Blocks like { Time.now } - compare by source
|
|
186
|
+
return true if node.block_type?
|
|
187
|
+
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module RSpecGuide
|
|
6
|
+
# Checks that corner cases are not the first context in a describe block.
|
|
7
|
+
# Happy path should come first for better readability.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# describe '#process' do
|
|
12
|
+
# context 'but user is blocked' do
|
|
13
|
+
# # ...
|
|
14
|
+
# end
|
|
15
|
+
# context 'when user is valid' do
|
|
16
|
+
# # ...
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# # bad
|
|
21
|
+
# describe '#activate' do
|
|
22
|
+
# context 'when user does NOT exist' do
|
|
23
|
+
# # ...
|
|
24
|
+
# end
|
|
25
|
+
# context 'when user exists' do
|
|
26
|
+
# # ...
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # good
|
|
31
|
+
# describe '#subscribe' do
|
|
32
|
+
# context 'with valid card' do
|
|
33
|
+
# # ...
|
|
34
|
+
# end
|
|
35
|
+
# context 'but payment fails' do
|
|
36
|
+
# # ...
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
class HappyPathFirst < Base
|
|
41
|
+
MSG = "Place happy path contexts before corner cases. " \
|
|
42
|
+
"First context appears to be a corner case: %<description>s"
|
|
43
|
+
|
|
44
|
+
# Words indicating corner cases
|
|
45
|
+
CORNER_CASE_WORDS = %w[
|
|
46
|
+
error failure invalid suspended blocked denied
|
|
47
|
+
fails missing absent unavailable
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# @!method context_with_description?(node)
|
|
51
|
+
def_node_matcher :context_with_description?, <<~PATTERN
|
|
52
|
+
(block
|
|
53
|
+
(send nil? :context (str $_description) ...)
|
|
54
|
+
...)
|
|
55
|
+
PATTERN
|
|
56
|
+
|
|
57
|
+
# @!method example_group?(node)
|
|
58
|
+
def_node_matcher :example_group?, <<~PATTERN
|
|
59
|
+
(block
|
|
60
|
+
(send nil? {:describe :context} ...)
|
|
61
|
+
...)
|
|
62
|
+
PATTERN
|
|
63
|
+
|
|
64
|
+
def on_block(node)
|
|
65
|
+
return unless example_group?(node)
|
|
66
|
+
|
|
67
|
+
contexts = collect_direct_child_contexts(node)
|
|
68
|
+
return if contexts.size < 2
|
|
69
|
+
|
|
70
|
+
# Check first context
|
|
71
|
+
context_with_description?(contexts.first) do |description|
|
|
72
|
+
if corner_case_context?(description)
|
|
73
|
+
add_offense(
|
|
74
|
+
contexts.first,
|
|
75
|
+
message: format(MSG, description: description)
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def collect_direct_child_contexts(node)
|
|
84
|
+
body = node.body
|
|
85
|
+
return [] unless body
|
|
86
|
+
|
|
87
|
+
if body.begin_type?
|
|
88
|
+
# Multiple children wrapped in begin node
|
|
89
|
+
body.children.select { |child| child.block_type? && context_with_description?(child) }
|
|
90
|
+
elsif body.block_type? && context_with_description?(body)
|
|
91
|
+
# Single context child
|
|
92
|
+
[body]
|
|
93
|
+
else
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def corner_case_context?(description)
|
|
99
|
+
lower_desc = description.downcase
|
|
100
|
+
|
|
101
|
+
# 1. Starts with "but" (opposition)
|
|
102
|
+
return true if description.start_with?("but ")
|
|
103
|
+
|
|
104
|
+
# 2. Contains NOT in caps (explicit negation)
|
|
105
|
+
return true if description.include?(" NOT ")
|
|
106
|
+
|
|
107
|
+
# 3. Contains negative words
|
|
108
|
+
return true if CORNER_CASE_WORDS.any? { |word| lower_desc.include?(word) }
|
|
109
|
+
|
|
110
|
+
# 4. "without" is NOT a corner case - it's a binary alternative
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|