rubocop-rspec_parity 1.0.0 → 1.1.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/CHANGELOG.md +6 -0
- data/README.md +124 -0
- data/config/default.yml +4 -0
- data/lib/rubocop/cop/rspec_parity/public_method_has_spec.rb +106 -10
- data/lib/rubocop/cop/rspec_parity/spec_file_finder.rb +78 -0
- data/lib/rubocop/cop/rspec_parity/sufficient_contexts.rb +116 -5
- data/lib/rubocop/rspec_parity/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9e0dc1e3be84865d59b94dfae0d55c808bb307c2bfefd5f85bc66921048afbe
|
|
4
|
+
data.tar.gz: e6abf7807ddc099af831d46b90e5c18913c76f0be754553dbffef0c83f723eba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c434bf6e258c007681582e7666184bd5233ba260ab60f981e46140f7a420a28e6079ecc506810728f552e2aaacc3aa71f181df6d684e6fd8f8f28d4279ee2b8f
|
|
7
|
+
data.tar.gz: 4671da5c79fe882e1cc8808764430d819b30646594d39aadc774559eb1055c01903b869577bf38139f8a32acc9f6a10f72afcde256cc49bb7d88324785ece872
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.1.0] - 2026-02-06
|
|
4
|
+
|
|
5
|
+
Added: Support for checking wildcard spec files (`<model>_*_spec.rb`) in addition to base spec file, with automatic class validation
|
|
6
|
+
Added: `SkipMethodDescribeFor` configuration for `PublicMethodHasSpec` and `SufficientContexts` cops to allow single-method classes (like service objects) to skip method describe blocks in specs
|
|
7
|
+
Added: `DescribeAliases` configuration for mapping describe strings to alias describe patterns
|
|
8
|
+
|
|
3
9
|
## [1.0.0] - 2026-01-27
|
|
4
10
|
|
|
5
11
|
Added: `IgnoreMemoization` configuration option for `SufficientContexts` cop to ignore memoization patterns like `@var ||=` and `return @var if defined?(@var)`
|
data/README.md
CHANGED
|
@@ -54,6 +54,126 @@ RSpec.describe UserCreator do
|
|
|
54
54
|
end
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
#### SkipMethodDescribeFor Configuration
|
|
58
|
+
|
|
59
|
+
For service objects and similar patterns with a single public method, you can skip the method describe block:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
RSpecParity/PublicMethodHasSpec:
|
|
63
|
+
SkipMethodDescribeFor:
|
|
64
|
+
- 'app/services/**/*'
|
|
65
|
+
- 'app/operations/**/*'
|
|
66
|
+
|
|
67
|
+
RSpecParity/SufficientContexts:
|
|
68
|
+
SkipMethodDescribeFor:
|
|
69
|
+
- 'app/services/**/*'
|
|
70
|
+
- 'app/operations/**/*'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
With this configuration:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# app/services/user_creator.rb
|
|
77
|
+
class UserCreator
|
|
78
|
+
def call(params)
|
|
79
|
+
User.create(params)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def validate(params)
|
|
85
|
+
# Private helper - doesn't count
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# spec/services/user_creator_spec.rb - Valid!
|
|
90
|
+
RSpec.describe UserCreator do
|
|
91
|
+
it 'creates a user' do
|
|
92
|
+
# test implementation
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
context 'when invalid params' do
|
|
96
|
+
it 'raises error' do
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Requirements:**
|
|
103
|
+
- Class must have exactly ONE public method
|
|
104
|
+
- Spec must contain at least one example (`it`, `example`, or `specify`)
|
|
105
|
+
- Traditional method describes still work if you prefer them
|
|
106
|
+
|
|
107
|
+
**How it works:**
|
|
108
|
+
- `PublicMethodHasSpec`: Instead of requiring `describe '#call'`, it just checks that examples exist
|
|
109
|
+
- `SufficientContexts`: Counts top-level contexts/examples instead of looking inside method describes
|
|
110
|
+
|
|
111
|
+
#### DescribeAliases Configuration
|
|
112
|
+
|
|
113
|
+
When an instance method is tested via a class method (e.g., service objects where `def call` is tested via `describe '.call'`, or jobs where `def perform` is tested via `describe '.perform_later'`), you can configure describe aliases:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
RSpecParity/PublicMethodHasSpec:
|
|
117
|
+
DescribeAliases:
|
|
118
|
+
'#call': '.call'
|
|
119
|
+
'#perform':
|
|
120
|
+
- '.perform_later'
|
|
121
|
+
- '.perform_now'
|
|
122
|
+
|
|
123
|
+
RSpecParity/SufficientContexts:
|
|
124
|
+
DescribeAliases:
|
|
125
|
+
'#call': '.call'
|
|
126
|
+
'#perform':
|
|
127
|
+
- '.perform_later'
|
|
128
|
+
- '.perform_now'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
With this configuration:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# app/services/user_creator.rb
|
|
135
|
+
class UserCreator
|
|
136
|
+
def call(params)
|
|
137
|
+
User.create(params)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# spec/services/user_creator_spec.rb - Valid!
|
|
142
|
+
# The alias '#call' => '.call' allows .call to satisfy #call
|
|
143
|
+
RSpec.describe UserCreator do
|
|
144
|
+
describe '.call' do
|
|
145
|
+
it 'creates a user' do
|
|
146
|
+
# test implementation
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# app/jobs/user_job.rb
|
|
154
|
+
class UserJob
|
|
155
|
+
def perform(params)
|
|
156
|
+
process(params)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# spec/jobs/user_job_spec.rb - Valid!
|
|
161
|
+
# The alias '#perform' => ['.perform_later', '.perform_now'] allows either
|
|
162
|
+
RSpec.describe UserJob do
|
|
163
|
+
describe '.perform_later' do
|
|
164
|
+
it 'processes params' do
|
|
165
|
+
# test implementation
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Notes:**
|
|
172
|
+
- Keys use `#` for instance method describes, `.` for class method describes
|
|
173
|
+
- Values can be a single string or an array of strings
|
|
174
|
+
- Aliases are unidirectional: `'#call': '.call'` allows `.call` to satisfy `#call`, but not vice versa
|
|
175
|
+
- `SufficientContexts` aggregates contexts from both the original and alias describe blocks
|
|
176
|
+
|
|
57
177
|
### RSpecParity/SufficientContexts
|
|
58
178
|
|
|
59
179
|
Ensures specs have at least as many contexts as the method has branches.
|
|
@@ -169,12 +289,16 @@ RSpecParity/FileHasSpec:
|
|
|
169
289
|
|
|
170
290
|
RSpecParity/PublicMethodHasSpec:
|
|
171
291
|
Enabled: true
|
|
292
|
+
SkipMethodDescribeFor: [] # Paths where single-method classes don't need method describe
|
|
293
|
+
DescribeAliases: {} # Map describe strings to aliases, e.g. '#call': '.call'
|
|
172
294
|
Include:
|
|
173
295
|
- 'app/**/*.rb'
|
|
174
296
|
|
|
175
297
|
RSpecParity/SufficientContexts:
|
|
176
298
|
Enabled: true
|
|
177
299
|
IgnoreMemoization: true # Set to false to count memoization patterns as branches
|
|
300
|
+
SkipMethodDescribeFor: [] # Paths where single-method classes use top-level contexts
|
|
301
|
+
DescribeAliases: {} # Map describe strings to aliases, e.g. '#call': '.call'
|
|
178
302
|
Include:
|
|
179
303
|
- 'app/**/*.rb'
|
|
180
304
|
Exclude:
|
data/config/default.yml
CHANGED
|
@@ -13,6 +13,8 @@ RSpecParity/FileHasSpec:
|
|
|
13
13
|
RSpecParity/PublicMethodHasSpec:
|
|
14
14
|
Description: 'Checks that each public method has a corresponding spec test.'
|
|
15
15
|
Enabled: true
|
|
16
|
+
SkipMethodDescribeFor: []
|
|
17
|
+
DescribeAliases: {}
|
|
16
18
|
Include:
|
|
17
19
|
- 'app/**/*.rb'
|
|
18
20
|
Exclude:
|
|
@@ -24,6 +26,8 @@ RSpecParity/SufficientContexts:
|
|
|
24
26
|
Description: 'Ensures specs have at least as many contexts as the method has branches.'
|
|
25
27
|
Enabled: true
|
|
26
28
|
IgnoreMemoization: true
|
|
29
|
+
SkipMethodDescribeFor: []
|
|
30
|
+
DescribeAliases: {}
|
|
27
31
|
Include:
|
|
28
32
|
- 'app/**/*.rb'
|
|
29
33
|
Exclude:
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "spec_file_finder"
|
|
4
|
+
|
|
3
5
|
module RuboCop
|
|
4
6
|
module Cop
|
|
5
7
|
module RSpecParity
|
|
@@ -10,7 +12,9 @@ module RuboCop
|
|
|
10
12
|
#
|
|
11
13
|
# # good - public method `perform` has describe '#perform' in spec
|
|
12
14
|
#
|
|
13
|
-
class PublicMethodHasSpec < Base
|
|
15
|
+
class PublicMethodHasSpec < Base # rubocop:disable Metrics/ClassLength
|
|
16
|
+
include SpecFileFinder
|
|
17
|
+
|
|
14
18
|
MSG = "Missing spec for public method `%<method_name>s`. " \
|
|
15
19
|
"Expected describe '#%<method_name>s' or describe '.%<method_name>s' in %<spec_path>s"
|
|
16
20
|
|
|
@@ -19,6 +23,12 @@ module RuboCop
|
|
|
19
23
|
EXCLUDED_PATTERNS = [/^before_/, /^after_/, /^around_/, /^validate_/, /^autosave_/].freeze
|
|
20
24
|
VISIBILITY_METHODS = { private: :private, protected: :protected, public: :public }.freeze
|
|
21
25
|
|
|
26
|
+
def initialize(config = nil, options = nil)
|
|
27
|
+
super
|
|
28
|
+
@skip_method_describe_paths = cop_config.fetch("SkipMethodDescribeFor", [])
|
|
29
|
+
@describe_aliases = cop_config.fetch("DescribeAliases", {})
|
|
30
|
+
end
|
|
31
|
+
|
|
22
32
|
def on_def(node)
|
|
23
33
|
return unless checkable_method?(node) && public_method?(node)
|
|
24
34
|
|
|
@@ -82,24 +92,44 @@ module RuboCop
|
|
|
82
92
|
EXCLUDED_PATTERNS.any? { |pattern| pattern.match?(method_name) }
|
|
83
93
|
end
|
|
84
94
|
|
|
95
|
+
def source_file_path
|
|
96
|
+
processed_source.file_path
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
85
100
|
def check_method_has_spec(node, instance_method:)
|
|
86
|
-
|
|
87
|
-
return unless
|
|
101
|
+
class_name = extract_class_name(node)
|
|
102
|
+
return unless class_name
|
|
103
|
+
|
|
104
|
+
base_spec_path = expected_spec_path
|
|
105
|
+
spec_paths = find_valid_spec_files(class_name, base_spec_path)
|
|
106
|
+
return if spec_paths.empty?
|
|
88
107
|
|
|
89
108
|
method_name = node.method_name.to_s
|
|
90
|
-
return if spec_covers_method?(spec_path, method_name, instance_method)
|
|
91
109
|
|
|
92
|
-
|
|
110
|
+
# Check if relaxed validation applies
|
|
111
|
+
if matches_skip_path? && count_public_methods(node) == 1
|
|
112
|
+
# For single-method classes in configured paths, just check for examples
|
|
113
|
+
return if spec_paths.any? { |spec_path| spec_has_examples?(spec_path, class_name) }
|
|
114
|
+
elsif spec_paths.any? { |spec_path| spec_covers_method?(spec_path, method_name, instance_method) }
|
|
115
|
+
# Normal validation: check for method describe
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
add_method_offense(node, method_name, spec_paths.first)
|
|
93
120
|
end
|
|
121
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
94
122
|
|
|
95
123
|
def spec_covers_method?(spec_path, method_name, instance_method)
|
|
96
124
|
return true if method_tested_in_spec?(spec_path, method_name, instance_method)
|
|
97
125
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
prefix = instance_method ? "#" : "."
|
|
127
|
+
describe_key = "#{prefix}#{method_name}"
|
|
128
|
+
describe_aliases_for(describe_key).any? do |alias_desc|
|
|
129
|
+
alias_name = alias_desc.sub(/^[#.]/, "")
|
|
130
|
+
alias_instance = !alias_desc.start_with?(".")
|
|
131
|
+
method_tested_in_spec?(spec_path, alias_name, alias_instance)
|
|
132
|
+
end
|
|
103
133
|
end
|
|
104
134
|
|
|
105
135
|
def add_method_offense(node, method_name, spec_path)
|
|
@@ -142,6 +172,72 @@ module RuboCop
|
|
|
142
172
|
app_index = path.split("/").index("app")
|
|
143
173
|
app_index ? path.split("/")[0...app_index].join("/") : nil
|
|
144
174
|
end
|
|
175
|
+
|
|
176
|
+
def matches_skip_path?
|
|
177
|
+
return false if @skip_method_describe_paths.empty?
|
|
178
|
+
|
|
179
|
+
file_path = processed_source.file_path
|
|
180
|
+
return false unless file_path
|
|
181
|
+
|
|
182
|
+
@skip_method_describe_paths.any? do |pattern|
|
|
183
|
+
# Match against both absolute path and relative path
|
|
184
|
+
File.fnmatch?(pattern, file_path, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
185
|
+
File.fnmatch?(pattern, extract_relative_path(file_path), File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def extract_relative_path(file_path)
|
|
190
|
+
# Extract path starting from 'app/' directory
|
|
191
|
+
app_index = file_path.split("/").index("app")
|
|
192
|
+
return file_path unless app_index
|
|
193
|
+
|
|
194
|
+
file_path.split("/")[app_index..].join("/")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
198
|
+
def count_public_methods(node)
|
|
199
|
+
class_node = find_class_or_module(node)
|
|
200
|
+
return 0 unless class_node&.body
|
|
201
|
+
|
|
202
|
+
public_methods = []
|
|
203
|
+
visibility = :public
|
|
204
|
+
|
|
205
|
+
# Get all child nodes, handling both single method and begin-wrapped bodies
|
|
206
|
+
children = if class_node.body.begin_type?
|
|
207
|
+
class_node.body.children
|
|
208
|
+
else
|
|
209
|
+
[class_node.body]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
children.each do |child|
|
|
213
|
+
next unless child
|
|
214
|
+
|
|
215
|
+
case child.type
|
|
216
|
+
when :send
|
|
217
|
+
visibility = VISIBILITY_METHODS[child.method_name] if VISIBILITY_METHODS.key?(child.method_name)
|
|
218
|
+
when :def
|
|
219
|
+
# Only count instance methods (def), not class methods (defs)
|
|
220
|
+
if visibility == :public
|
|
221
|
+
method_name = child.method_name.to_s
|
|
222
|
+
public_methods << method_name unless excluded_method?(method_name)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
public_methods.size
|
|
228
|
+
end
|
|
229
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
230
|
+
|
|
231
|
+
def spec_has_examples?(spec_path, class_name)
|
|
232
|
+
spec_content = File.read(spec_path)
|
|
233
|
+
escaped_class_name = Regexp.escape(class_name)
|
|
234
|
+
|
|
235
|
+
# Check that the spec describes the correct class
|
|
236
|
+
return false unless spec_content.match?(/(?:RSpec\.)?describe\s+#{escaped_class_name}(?:\s|,|do)/)
|
|
237
|
+
|
|
238
|
+
# Check for any it/example/specify blocks
|
|
239
|
+
spec_content.match?(/^\s*(?:it|example|specify)\s+/)
|
|
240
|
+
end
|
|
145
241
|
end
|
|
146
242
|
end
|
|
147
243
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module RSpecParity
|
|
6
|
+
# Shared module for finding and validating spec files
|
|
7
|
+
module SpecFileFinder
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def extract_class_name(node)
|
|
11
|
+
class_node = find_class_or_module(node)
|
|
12
|
+
|
|
13
|
+
if class_node
|
|
14
|
+
# Get the class/module name from the AST
|
|
15
|
+
const_node = class_node.children[0]
|
|
16
|
+
return const_node.const_name if const_node.const_type?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Fallback: infer class name from file path
|
|
20
|
+
infer_class_name_from_path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find_class_or_module(node)
|
|
24
|
+
node.each_ancestor.find { |n| n.class_type? || n.module_type? }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def infer_class_name_from_path
|
|
28
|
+
path = source_file_path
|
|
29
|
+
return nil unless path
|
|
30
|
+
|
|
31
|
+
# Extract filename and convert to class name
|
|
32
|
+
# e.g., app/services/user_creator.rb → UserCreator
|
|
33
|
+
filename = File.basename(path, ".rb")
|
|
34
|
+
filename.split("_").map(&:capitalize).join
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def find_valid_spec_files(class_name, base_spec_path)
|
|
38
|
+
return [] unless base_spec_path
|
|
39
|
+
|
|
40
|
+
valid_files = []
|
|
41
|
+
|
|
42
|
+
# Always include the base spec file if it exists (maintains original behavior)
|
|
43
|
+
valid_files << base_spec_path if File.exist?(base_spec_path)
|
|
44
|
+
|
|
45
|
+
# Also check for wildcard spec files (e.g., user_updates_spec.rb)
|
|
46
|
+
# But only include them if they describe the correct class
|
|
47
|
+
spec_dir = File.dirname(base_spec_path)
|
|
48
|
+
base_name = File.basename(base_spec_path, "_spec.rb")
|
|
49
|
+
wildcard_files = Dir.glob(File.join(spec_dir, "#{base_name}_*_spec.rb"))
|
|
50
|
+
|
|
51
|
+
wildcard_files.each do |file|
|
|
52
|
+
valid_files << file if File.exist?(file) && spec_describes_class?(file, class_name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
valid_files.uniq
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def spec_describes_class?(spec_path, class_name)
|
|
59
|
+
spec_content = File.read(spec_path)
|
|
60
|
+
# Match RSpec.describe ClassName or describe ClassName
|
|
61
|
+
spec_content.match?(/(?:RSpec\.)?describe\s+#{Regexp.escape(class_name)}(?:\s|,|do)/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def describe_aliases_for(describe_key)
|
|
65
|
+
value = @describe_aliases[describe_key]
|
|
66
|
+
return [] unless value
|
|
67
|
+
|
|
68
|
+
Array(value).map(&:to_s)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Override this method in the including class
|
|
72
|
+
def source_file_path
|
|
73
|
+
processed_source.file_path
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "spec_file_finder"
|
|
4
|
+
|
|
3
5
|
module RuboCop
|
|
4
6
|
module Cop
|
|
5
7
|
module RSpecParity
|
|
@@ -36,6 +38,8 @@ module RuboCop
|
|
|
36
38
|
# context 'when creating a regular user' do
|
|
37
39
|
# end
|
|
38
40
|
class SufficientContexts < Base # rubocop:disable Metrics/ClassLength
|
|
41
|
+
include SpecFileFinder
|
|
42
|
+
|
|
39
43
|
MSG = "Method `%<method_name>s` has %<branches>d %<branch_word>s but only %<contexts>d %<context_word>s " \
|
|
40
44
|
"in spec. Add %<missing>d more %<missing_word>s to cover all branches."
|
|
41
45
|
|
|
@@ -61,6 +65,8 @@ module RuboCop
|
|
|
61
65
|
def initialize(config = nil, options = nil)
|
|
62
66
|
super
|
|
63
67
|
@ignore_memoization = cop_config.fetch("IgnoreMemoization", true)
|
|
68
|
+
@skip_method_describe_paths = cop_config.fetch("SkipMethodDescribeFor", [])
|
|
69
|
+
@describe_aliases = cop_config.fetch("DescribeAliases", {})
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
def on_def(node)
|
|
@@ -73,18 +79,28 @@ module RuboCop
|
|
|
73
79
|
|
|
74
80
|
private
|
|
75
81
|
|
|
76
|
-
def check_method(node) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
82
|
+
def check_method(node) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
77
83
|
return unless in_covered_directory?
|
|
78
84
|
return if excluded_method?(method_name(node))
|
|
79
85
|
|
|
80
86
|
branches = count_branches(node)
|
|
81
87
|
return if branches < 2 # Only check methods with branches
|
|
82
88
|
|
|
83
|
-
|
|
84
|
-
return unless
|
|
89
|
+
class_name = extract_class_name(node)
|
|
90
|
+
return unless class_name
|
|
91
|
+
|
|
92
|
+
base_spec_path = spec_file_path
|
|
93
|
+
spec_files = find_valid_spec_files(class_name, base_spec_path)
|
|
94
|
+
return if spec_files.empty?
|
|
85
95
|
|
|
86
|
-
|
|
87
|
-
contexts =
|
|
96
|
+
# Aggregate contexts from all valid spec files
|
|
97
|
+
contexts = if matches_skip_path? && count_public_methods(node) == 1
|
|
98
|
+
# For single-method classes, count top-level contexts instead
|
|
99
|
+
spec_files.sum { |spec_file| count_top_level_contexts(File.read(spec_file), class_name) }
|
|
100
|
+
else
|
|
101
|
+
# Normal path: look for method describes
|
|
102
|
+
spec_files.sum { |spec_file| count_method_contexts(File.read(spec_file), method_name(node)) }
|
|
103
|
+
end
|
|
88
104
|
|
|
89
105
|
return if contexts.zero? # Method has no specs at all - PublicMethodHasSpec handles this
|
|
90
106
|
return if contexts >= branches
|
|
@@ -123,6 +139,10 @@ module RuboCop
|
|
|
123
139
|
EXCLUDED_PATTERNS.any? { |pattern| pattern.match?(method_name) }
|
|
124
140
|
end
|
|
125
141
|
|
|
142
|
+
def source_file_path
|
|
143
|
+
processed_source.path
|
|
144
|
+
end
|
|
145
|
+
|
|
126
146
|
def spec_file_path
|
|
127
147
|
path = processed_source.path
|
|
128
148
|
# Handle both absolute and relative paths
|
|
@@ -187,6 +207,15 @@ module RuboCop
|
|
|
187
207
|
when_count + (has_else ? 1 : 0)
|
|
188
208
|
end
|
|
189
209
|
|
|
210
|
+
def count_method_contexts(spec_content, mname)
|
|
211
|
+
count = count_contexts_for_method(spec_content, mname)
|
|
212
|
+
describe_aliases_for("##{mname}").each do |alias_desc|
|
|
213
|
+
alias_name = alias_desc.sub(/^[#.]/, "")
|
|
214
|
+
count += count_contexts_for_method(spec_content, alias_name) if alias_name != mname
|
|
215
|
+
end
|
|
216
|
+
count
|
|
217
|
+
end
|
|
218
|
+
|
|
190
219
|
def count_contexts_for_method(spec_content, method_name)
|
|
191
220
|
method_pattern = Regexp.escape(method_name)
|
|
192
221
|
context_count, has_examples = parse_spec_content(spec_content, method_pattern)
|
|
@@ -341,6 +370,88 @@ module RuboCop
|
|
|
341
370
|
left = node.children[0]
|
|
342
371
|
left&.ivar_type?
|
|
343
372
|
end
|
|
373
|
+
|
|
374
|
+
def matches_skip_path?
|
|
375
|
+
return false if @skip_method_describe_paths.empty?
|
|
376
|
+
|
|
377
|
+
path = processed_source.path
|
|
378
|
+
return false unless path
|
|
379
|
+
|
|
380
|
+
@skip_method_describe_paths.any? do |pattern|
|
|
381
|
+
File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
386
|
+
def count_public_methods(node)
|
|
387
|
+
class_node = find_class_or_module(node)
|
|
388
|
+
return 0 unless class_node&.body
|
|
389
|
+
|
|
390
|
+
public_methods = []
|
|
391
|
+
visibility = :public
|
|
392
|
+
visibility_methods = { private: :private, protected: :protected, public: :public }
|
|
393
|
+
|
|
394
|
+
# Get all child nodes, handling both single method and begin-wrapped bodies
|
|
395
|
+
children = if class_node.body.begin_type?
|
|
396
|
+
class_node.body.children
|
|
397
|
+
else
|
|
398
|
+
[class_node.body]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
children.each do |child|
|
|
402
|
+
next unless child
|
|
403
|
+
|
|
404
|
+
case child.type
|
|
405
|
+
when :send
|
|
406
|
+
visibility = visibility_methods[child.method_name] if visibility_methods.key?(child.method_name)
|
|
407
|
+
when :def
|
|
408
|
+
# Only count instance methods (def), not class methods (defs)
|
|
409
|
+
if visibility == :public
|
|
410
|
+
method_name = child.method_name.to_s
|
|
411
|
+
public_methods << method_name unless excluded_method?(method_name)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
public_methods.size
|
|
417
|
+
end
|
|
418
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
419
|
+
|
|
420
|
+
def find_class_or_module(node)
|
|
421
|
+
node.each_ancestor.find { |n| n.class_type? || n.module_type? }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
425
|
+
def count_top_level_contexts(spec_content, class_name)
|
|
426
|
+
# Find the class describe block
|
|
427
|
+
describe_pattern = /^\s*(?:RSpec\.)?describe\s+#{Regexp.escape(class_name)}(?:\s|,|do)/
|
|
428
|
+
|
|
429
|
+
lines = spec_content.lines
|
|
430
|
+
describe_line_index = lines.index { |line| line.match?(describe_pattern) }
|
|
431
|
+
return 0 unless describe_line_index
|
|
432
|
+
|
|
433
|
+
# Count contexts/describes and examples at the top level (under class describe)
|
|
434
|
+
base_indent = lines[describe_line_index].match(/^(\s*)/)[1].length
|
|
435
|
+
context_count = 0
|
|
436
|
+
has_examples = false
|
|
437
|
+
|
|
438
|
+
lines[(describe_line_index + 1)..].each do |line|
|
|
439
|
+
indent = line.match(/^(\s*)/)[1].length
|
|
440
|
+
break if indent <= base_indent && !line.strip.empty? && line.match?(/^\s*(?:describe|context|end)/)
|
|
441
|
+
|
|
442
|
+
next unless indent > base_indent
|
|
443
|
+
|
|
444
|
+
if line.match?(/^\s*(?:context|describe)\s+/)
|
|
445
|
+
context_count += 1
|
|
446
|
+
elsif line.match?(/^\s*(?:it|example|specify)\s+/)
|
|
447
|
+
has_examples = true
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# If no contexts but has examples, count as 1
|
|
452
|
+
context_count.zero? && has_examples ? 1 : context_count
|
|
453
|
+
end
|
|
454
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
344
455
|
end
|
|
345
456
|
end
|
|
346
457
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-rspec_parity
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Povilas Jurcys
|
|
@@ -55,6 +55,7 @@ files:
|
|
|
55
55
|
- config/default.yml
|
|
56
56
|
- lib/rubocop-rspec_parity.rb
|
|
57
57
|
- lib/rubocop/cop/rspec_parity/public_method_has_spec.rb
|
|
58
|
+
- lib/rubocop/cop/rspec_parity/spec_file_finder.rb
|
|
58
59
|
- lib/rubocop/cop/rspec_parity/sufficient_contexts.rb
|
|
59
60
|
- lib/rubocop/rspec_parity.rb
|
|
60
61
|
- lib/rubocop/rspec_parity/plugin.rb
|