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.
@@ -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