rubocop-rspec_parity 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d97c9609bdd7e3ee9d2693e548eaf8f4e18af613449fc807b78f40386f8aad55
4
+ data.tar.gz: f2f86bfac2c25c6c88c250f5c48a1a8ca7215b4265fbbb549dfe784359e42c91
5
+ SHA512:
6
+ metadata.gz: 0f176130a0935ee6b3f3f22dabf39f21b8aafbe7335585cad02281366ed298d936559dd745809f77ff977210429715e3294f10b33dc2c245c9f1d3e3f6a347e0
7
+ data.tar.gz: c0083330fd6fa6e3874488476b878159d203559c3cf21967f86a7175c8e4acae8c7dac213e9a552117b7ba43083fc45dd1e0a90200595c97e34c91c5f6dda85e
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.1
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-01-15
4
+
5
+ - Initial release
data/CLAUDE.md ADDED
@@ -0,0 +1,28 @@
1
+ # Claude Instructions for rubocop-rspec_parity
2
+
3
+ ## Code Quality Rules
4
+
5
+ 1. **Never modify `.rubocop.yml`** unless the user explicitly asks to change RuboCop configuration. Always fix the code to satisfy linter rules rather than disabling or modifying the rules.
6
+
7
+ 2. **After any code change**, always run linters and specs:
8
+ ```bash
9
+ bundle exec rubocop
10
+ bundle exec rspec
11
+ ```
12
+
13
+ 3. **A task is only considered complete** when:
14
+ - All RuboCop violations are resolved
15
+ - All RSpec tests pass
16
+ - No new warnings or errors are introduced
17
+
18
+ ## Git Commits
19
+
20
+ - Keep commit descriptions short but clear: 1-5 sentences
21
+ - Focus on what changed and why, not implementation details
22
+
23
+ ## Project Structure
24
+
25
+ - `lib/rubocop_rspec_parity.rb` - Main entry point
26
+ - `lib/rubocop/cop/spec_rparity/` - Custom RuboCop cops
27
+ - `config/default.yml` - Default cop configuration
28
+ - `spec/` - RSpec tests
@@ -0,0 +1,132 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our
6
+ community a harassment-free experience for everyone, regardless of age, body
7
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+ identity and expression, level of experience, education, socio-economic status,
9
+ nationality, personal appearance, race, caste, color, religion, or sexual
10
+ identity and orientation.
11
+
12
+ We pledge to act and interact in ways that contribute to an open, welcoming,
13
+ diverse, inclusive, and healthy community.
14
+
15
+ ## Our Standards
16
+
17
+ Examples of behavior that contributes to a positive environment for our
18
+ community include:
19
+
20
+ * Demonstrating empathy and kindness toward other people
21
+ * Being respectful of differing opinions, viewpoints, and experiences
22
+ * Giving and gracefully accepting constructive feedback
23
+ * Accepting responsibility and apologizing to those affected by our mistakes,
24
+ and learning from the experience
25
+ * Focusing on what is best not just for us as individuals, but for the overall
26
+ community
27
+
28
+ Examples of unacceptable behavior include:
29
+
30
+ * The use of sexualized language or imagery, and sexual attention or advances of
31
+ any kind
32
+ * Trolling, insulting or derogatory comments, and personal or political attacks
33
+ * Public or private harassment
34
+ * Publishing others' private information, such as a physical or email address,
35
+ without their explicit permission
36
+ * Other conduct which could reasonably be considered inappropriate in a
37
+ professional setting
38
+
39
+ ## Enforcement Responsibilities
40
+
41
+ Community leaders are responsible for clarifying and enforcing our standards of
42
+ acceptable behavior and will take appropriate and fair corrective action in
43
+ response to any behavior that they deem inappropriate, threatening, offensive,
44
+ or harmful.
45
+
46
+ Community leaders have the right and responsibility to remove, edit, or reject
47
+ comments, commits, code, wiki edits, issues, and other contributions that are
48
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
49
+ decisions when appropriate.
50
+
51
+ ## Scope
52
+
53
+ This Code of Conduct applies within all community spaces, and also applies when
54
+ an individual is officially representing the community in public spaces.
55
+ Examples of representing our community include using an official email address,
56
+ posting via an official social media account, or acting as an appointed
57
+ representative at an online or offline event.
58
+
59
+ ## Enforcement
60
+
61
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
+ reported to the community leaders responsible for enforcement at
63
+ [INSERT CONTACT METHOD].
64
+ All complaints will be reviewed and investigated promptly and fairly.
65
+
66
+ All community leaders are obligated to respect the privacy and security of the
67
+ reporter of any incident.
68
+
69
+ ## Enforcement Guidelines
70
+
71
+ Community leaders will follow these Community Impact Guidelines in determining
72
+ the consequences for any action they deem in violation of this Code of Conduct:
73
+
74
+ ### 1. Correction
75
+
76
+ **Community Impact**: Use of inappropriate language or other behavior deemed
77
+ unprofessional or unwelcome in the community.
78
+
79
+ **Consequence**: A private, written warning from community leaders, providing
80
+ clarity around the nature of the violation and an explanation of why the
81
+ behavior was inappropriate. A public apology may be requested.
82
+
83
+ ### 2. Warning
84
+
85
+ **Community Impact**: A violation through a single incident or series of
86
+ actions.
87
+
88
+ **Consequence**: A warning with consequences for continued behavior. No
89
+ interaction with the people involved, including unsolicited interaction with
90
+ those enforcing the Code of Conduct, for a specified period of time. This
91
+ includes avoiding interactions in community spaces as well as external channels
92
+ like social media. Violating these terms may lead to a temporary or permanent
93
+ ban.
94
+
95
+ ### 3. Temporary Ban
96
+
97
+ **Community Impact**: A serious violation of community standards, including
98
+ sustained inappropriate behavior.
99
+
100
+ **Consequence**: A temporary ban from any sort of interaction or public
101
+ communication with the community for a specified period of time. No public or
102
+ private interaction with the people involved, including unsolicited interaction
103
+ with those enforcing the Code of Conduct, is allowed during this period.
104
+ Violating these terms may lead to a permanent ban.
105
+
106
+ ### 4. Permanent Ban
107
+
108
+ **Community Impact**: Demonstrating a pattern of violation of community
109
+ standards, including sustained inappropriate behavior, harassment of an
110
+ individual, or aggression toward or disparagement of classes of individuals.
111
+
112
+ **Consequence**: A permanent ban from any sort of public interaction within the
113
+ community.
114
+
115
+ ## Attribution
116
+
117
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118
+ version 2.1, available at
119
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120
+
121
+ Community Impact Guidelines were inspired by
122
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123
+
124
+ For answers to common questions about this code of conduct, see the FAQ at
125
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126
+ [https://www.contributor-covenant.org/translations][translations].
127
+
128
+ [homepage]: https://www.contributor-covenant.org
129
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130
+ [Mozilla CoC]: https://github.com/mozilla/diversity
131
+ [FAQ]: https://www.contributor-covenant.org/faq
132
+ [translations]: https://www.contributor-covenant.org/translations
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Povilas Jurcys
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,235 @@
1
+ # RuboCop RSpecParity
2
+
3
+ A RuboCop plugin that enforces spec parity and best practices in RSpec test suites. This gem helps ensure your Ruby code has proper test coverage and follows RSpec conventions.
4
+
5
+ ## Features
6
+
7
+ This plugin provides these custom cops:
8
+
9
+ - **RSpecParity/FileHasSpec**: Ensures every Ruby file in your app directory has a corresponding spec file
10
+ - **RSpecParity/PublicMethodHasSpec**: Ensures every public method has spec test coverage
11
+ - **RSpecParity/SufficientContexts**: Ensures specs have at least as many contexts as the method has branches (if/elsif/else, case/when, &&, ||, ternary operators)
12
+ - **RSpecParity/NoLetBang**: Disallows the use of `let!` in specs, encouraging explicit setup
13
+
14
+ ## Examples
15
+
16
+ ### RSpecParity/FileHasSpec
17
+
18
+ Ensures every Ruby file in your app directory has a corresponding spec file.
19
+
20
+ ```ruby
21
+ # bad - app/models/user.rb exists but spec/models/user_spec.rb doesn't
22
+ # This will trigger an offense
23
+
24
+ # good - both files exist:
25
+ # app/models/user.rb
26
+ # spec/models/user_spec.rb
27
+ ```
28
+
29
+ ### RSpecParity/PublicMethodHasSpec
30
+
31
+ Ensures every public method has spec test coverage.
32
+
33
+ ```ruby
34
+ # bad - app/services/user_creator.rb
35
+ class UserCreator
36
+ def create(params) # No spec coverage for this method
37
+ User.create(params)
38
+ end
39
+ end
40
+
41
+ # good - app/services/user_creator.rb with spec/services/user_creator_spec.rb
42
+ class UserCreator
43
+ def create(params)
44
+ User.create(params)
45
+ end
46
+ end
47
+
48
+ # spec/services/user_creator_spec.rb
49
+ RSpec.describe UserCreator do
50
+ describe '#create' do
51
+ it 'creates a user' do
52
+ # test implementation
53
+ end
54
+ end
55
+ end
56
+ ```
57
+
58
+ ### RSpecParity/SufficientContexts
59
+
60
+ Ensures specs have at least as many contexts as the method has branches.
61
+
62
+ ```ruby
63
+ # bad - app/services/user_creator.rb
64
+ def create_user(params)
65
+ if params[:admin]
66
+ create_admin(params)
67
+ elsif params[:moderator]
68
+ create_moderator(params)
69
+ else
70
+ create_regular_user(params)
71
+ end
72
+ end
73
+
74
+ # spec/services/user_creator_spec.rb - only 1 context for 3 branches
75
+ RSpec.describe UserCreator do
76
+ describe '#create_user' do
77
+ context 'when creating users' do
78
+ # Only one context for 3 branches - triggers offense
79
+ end
80
+ end
81
+ end
82
+
83
+ # good - 3 contexts for 3 branches
84
+ RSpec.describe UserCreator do
85
+ describe '#create_user' do
86
+ context 'when admin' do
87
+ # tests admin branch
88
+ end
89
+
90
+ context 'when moderator' do
91
+ # tests moderator branch
92
+ end
93
+
94
+ context 'when regular user' do
95
+ # tests regular user branch
96
+ end
97
+ end
98
+ end
99
+ ```
100
+
101
+ ### RSpecParity/NoLetBang
102
+
103
+ Disallows the use of `let!` in specs, encouraging explicit setup.
104
+
105
+ ```ruby
106
+ # bad
107
+ RSpec.describe User do
108
+ let!(:user) { create(:user) }
109
+
110
+ it 'does something' do
111
+ expect(user).to be_valid
112
+ end
113
+ end
114
+
115
+ # good - use let with explicit reference
116
+ RSpec.describe User do
117
+ let(:user) { create(:user) }
118
+
119
+ it 'does something' do
120
+ expect(user).to be_valid # Explicit reference
121
+ end
122
+ end
123
+
124
+ # good - use before block when setup is needed
125
+ RSpec.describe User do
126
+ let(:user) { build(:user) }
127
+
128
+ before do
129
+ user.save! # Explicit setup in before block
130
+ end
131
+
132
+ it 'does something' do
133
+ expect(user).to be_persisted
134
+ end
135
+ end
136
+ ```
137
+
138
+ ## Assumptions
139
+
140
+ These cops work based on the following conventions:
141
+
142
+ - **File organization**: Each Ruby file has a corresponding spec file stored in the `spec/` directory, mirroring the same directory structure. For example, `app/models/user.rb` should have a spec at `spec/models/user_spec.rb`.
143
+ - **Spec file naming**: Spec files use the `_spec.rb` suffix.
144
+ - **Instance method specs**: Instance methods are tested using the convention `describe '#method_name' do`.
145
+ - **Class method specs**: Class methods are tested using the convention `describe '.class_method' do`.
146
+ - **Context blocks for branches**: The `SufficientContexts` cop counts `context` blocks within a method's `describe` block to ensure each branch path is tested.
147
+
148
+ If your project uses different conventions, these cops may not work as expected.
149
+
150
+ ## Installation
151
+
152
+ Add this line to your application's Gemfile:
153
+
154
+ ```ruby
155
+ gem 'rubocop-rspec_parity', require: false
156
+ ```
157
+
158
+ And then execute:
159
+
160
+ ```bash
161
+ bundle install
162
+ ```
163
+
164
+ Or install it directly:
165
+
166
+ ```bash
167
+ gem install rubocop-rspec_parity
168
+ ```
169
+
170
+ ## Usage
171
+
172
+ Add `rubocop-rspec_parity` to your `.rubocop.yml`:
173
+
174
+ ```yaml
175
+ require:
176
+ - rubocop-rspec_parity
177
+
178
+ # For RuboCop >= 1.72
179
+ plugins:
180
+ - rubocop-rspec_parity
181
+ ```
182
+
183
+ The default configuration enables all cops. You can customize them in your `.rubocop.yml`:
184
+
185
+ ```yaml
186
+ RSpecParity/FileHasSpec:
187
+ Enabled: true
188
+ Include:
189
+ - 'app/**/*.rb'
190
+ Exclude:
191
+ - 'app/assets/**/*'
192
+ - 'app/views/**/*'
193
+
194
+ RSpecParity/PublicMethodHasSpec:
195
+ Enabled: true
196
+ Include:
197
+ - 'app/**/*.rb'
198
+
199
+ RSpecParity/SufficientContexts:
200
+ Enabled: true
201
+ Include:
202
+ - 'app/**/*.rb'
203
+ Exclude:
204
+ - 'app/assets/**/*'
205
+ - 'app/views/**/*'
206
+
207
+ RSpecParity/NoLetBang:
208
+ Enabled: true
209
+ Include:
210
+ - 'spec/**/*_spec.rb'
211
+ ```
212
+
213
+ Run RuboCop as usual:
214
+
215
+ ```bash
216
+ bundle exec rubocop
217
+ ```
218
+
219
+ ## Development
220
+
221
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
222
+
223
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
224
+
225
+ ## Contributing
226
+
227
+ Bug reports and pull requests are welcome on GitHub at https://github.com/povilasjurcys/rubocop-rspec_parity. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/povilasjurcys/rubocop-rspec_parity/blob/main/CODE_OF_CONDUCT.md).
228
+
229
+ ## License
230
+
231
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
232
+
233
+ ## Code of Conduct
234
+
235
+ Everyone interacting in the RuboCop RSpecParity project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/povilasjurcys/rubocop-rspec_parity/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,37 @@
1
+ # Default configuration for rubocop-rspec_parity
2
+
3
+ RSpecParity/FileHasSpec:
4
+ Description: 'Checks that each Ruby file in the app directory has a corresponding spec file.'
5
+ Enabled: true
6
+ Include:
7
+ - 'app/**/*.rb'
8
+ Exclude:
9
+ - 'app/assets/**/*'
10
+ - 'app/views/**/*'
11
+ - 'app/javascript/**/*'
12
+
13
+ RSpecParity/PublicMethodHasSpec:
14
+ Description: 'Checks that each public method has a corresponding spec test.'
15
+ Enabled: true
16
+ Include:
17
+ - 'app/**/*.rb'
18
+ Exclude:
19
+ - 'app/assets/**/*'
20
+ - 'app/views/**/*'
21
+ - 'app/javascript/**/*'
22
+
23
+ RSpecParity/SufficientContexts:
24
+ Description: 'Ensures specs have at least as many contexts as the method has branches.'
25
+ Enabled: true
26
+ Include:
27
+ - 'app/**/*.rb'
28
+ Exclude:
29
+ - 'app/assets/**/*'
30
+ - 'app/views/**/*'
31
+ - 'app/javascript/**/*'
32
+
33
+ RSpecParity/NoLetBang:
34
+ Description: 'Disallows the use of `let!` in specs.'
35
+ Enabled: true
36
+ Include:
37
+ - 'spec/**/*_spec.rb'
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module RSpecParity
6
+ # Disallows the use of `let!` in specs.
7
+ #
8
+ # `let!` creates implicit setup that runs before each example,
9
+ # which can make tests harder to understand and debug.
10
+ # Prefer using `let` with explicit references or `before` blocks.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # let!(:user) { create(:user) }
15
+ #
16
+ # # good
17
+ # let(:user) { create(:user) }
18
+ #
19
+ # # good
20
+ # before { create(:user) }
21
+ #
22
+ class NoLetBang < Base
23
+ MSG = "Do not use `let!`. Use `let` with explicit reference or `before` block instead."
24
+
25
+ # @!method let_bang?(node)
26
+ def_node_matcher :let_bang?, "(send nil? :let! ...)"
27
+
28
+ def on_send(node)
29
+ return unless let_bang?(node)
30
+
31
+ add_offense(node)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module RSpecParity
6
+ # Checks that each public method in a class has a corresponding spec test.
7
+ #
8
+ # @example
9
+ # # bad - public method `perform` exists but no describe '#perform' in spec
10
+ #
11
+ # # good - public method `perform` has describe '#perform' in spec
12
+ #
13
+ class PublicMethodHasSpec < Base
14
+ MSG = "Missing spec for public method `%<method_name>s`. " \
15
+ "Expected describe '#%<method_name>s' or describe '.%<method_name>s' in %<spec_path>s"
16
+
17
+ COVERED_DIRECTORIES = %w[models controllers services jobs mailers helpers].freeze
18
+ EXCLUDED_METHODS = %w[initialize].freeze
19
+ EXCLUDED_PATTERNS = [/^before_/, /^after_/, /^around_/, /^validate_/, /^autosave_/].freeze
20
+ VISIBILITY_METHODS = { private: :private, protected: :protected, public: :public }.freeze
21
+
22
+ def on_def(node)
23
+ return unless checkable_method?(node) && public_method?(node)
24
+
25
+ check_method_has_spec(node, instance_method: !inside_eigenclass?(node))
26
+ end
27
+
28
+ def on_defs(node)
29
+ return unless checkable_method?(node)
30
+
31
+ check_method_has_spec(node, instance_method: false)
32
+ end
33
+
34
+ private
35
+
36
+ def checkable_method?(node)
37
+ should_check_file? && !excluded_method?(node.method_name.to_s)
38
+ end
39
+
40
+ def inside_eigenclass?(node)
41
+ node.each_ancestor.any? { |a| a.sclass_type? && a.children.first&.self_type? }
42
+ end
43
+
44
+ def should_check_file?
45
+ path = processed_source.file_path
46
+ return false if path.nil? || !path.include?("/app/") || path.end_with?("_spec.rb")
47
+
48
+ COVERED_DIRECTORIES.any? { |dir| path.include?("/app/#{dir}/") }
49
+ end
50
+
51
+ def public_method?(node)
52
+ return false if node.nil?
53
+
54
+ class_or_module = find_class_or_module(node)
55
+ return true unless class_or_module
56
+
57
+ compute_visibility(class_or_module, node) == :public
58
+ end
59
+
60
+ def find_class_or_module(node)
61
+ node.each_ancestor.find { |n| n.class_type? || n.module_type? }
62
+ end
63
+
64
+ def compute_visibility(class_or_module, target_node)
65
+ visibility = :public
66
+ class_or_module.body&.each_child_node do |child|
67
+ break if child == target_node
68
+
69
+ visibility = update_visibility(child, visibility)
70
+ end
71
+ visibility
72
+ end
73
+
74
+ def update_visibility(child, current_visibility)
75
+ return current_visibility unless child.send_type?
76
+
77
+ VISIBILITY_METHODS.fetch(child.method_name, current_visibility)
78
+ end
79
+
80
+ def excluded_method?(method_name)
81
+ EXCLUDED_METHODS.include?(method_name) ||
82
+ EXCLUDED_PATTERNS.any? { |pattern| pattern.match?(method_name) }
83
+ end
84
+
85
+ def check_method_has_spec(node, instance_method:)
86
+ spec_path = expected_spec_path
87
+ return unless spec_path && File.exist?(spec_path)
88
+
89
+ method_name = node.method_name.to_s
90
+ return if spec_covers_method?(spec_path, method_name, instance_method)
91
+
92
+ add_method_offense(node, method_name, spec_path)
93
+ end
94
+
95
+ def spec_covers_method?(spec_path, method_name, instance_method)
96
+ return true if method_tested_in_spec?(spec_path, method_name, instance_method)
97
+
98
+ service_call_method?(method_name) && method_tested_in_spec?(spec_path, method_name, !instance_method)
99
+ end
100
+
101
+ def service_call_method?(method_name)
102
+ method_name == "call" && processed_source.file_path&.include?("/app/services/")
103
+ end
104
+
105
+ def add_method_offense(node, method_name, spec_path)
106
+ add_offense(
107
+ node.loc.keyword.join(node.loc.name),
108
+ message: format(MSG, method_name: method_name, spec_path: relative_spec_path(spec_path))
109
+ )
110
+ end
111
+
112
+ def method_tested_in_spec?(spec_path, method_name, instance_method)
113
+ spec_content = File.read(spec_path)
114
+ prefix = instance_method ? "#" : "."
115
+ test_patterns(prefix, method_name).any? { |pattern| spec_content.match?(pattern) }
116
+ end
117
+
118
+ def test_patterns(prefix, method_name)
119
+ escaped_prefix = Regexp.escape(prefix)
120
+ escaped_name = Regexp.escape(method_name)
121
+ [
122
+ /describe\s+['"]#{escaped_prefix}#{escaped_name}['"]/,
123
+ /context\s+['"]#{escaped_prefix}#{escaped_name}['"]/,
124
+ /it\s+['"](tests?|checks?|verifies?|validates?)\s+#{escaped_name}/i,
125
+ /describe\s+['"]#{escaped_name}['"]/
126
+ ]
127
+ end
128
+
129
+ def expected_spec_path
130
+ processed_source.file_path&.sub("/app/", "/spec/")&.sub(/\.rb$/, "_spec.rb")
131
+ end
132
+
133
+ def relative_spec_path(spec_path)
134
+ root = find_project_root
135
+ root ? spec_path.sub("#{root}/", "") : spec_path
136
+ end
137
+
138
+ def find_project_root
139
+ path = processed_source.file_path
140
+ return nil if path.nil?
141
+
142
+ app_index = path.split("/").index("app")
143
+ app_index ? path.split("/")[0...app_index].join("/") : nil
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module RSpecParity
6
+ # Ensures that specs have at least as many contexts as the method has branches.
7
+ #
8
+ # This cop helps ensure thorough test coverage by checking that complex methods
9
+ # with multiple branches (if/elsif/else, case/when, &&, ||, ternary) have
10
+ # corresponding context blocks in their specs to test each branch.
11
+ #
12
+ # @example
13
+ # # bad - method has 3 branches, spec has only 1 context
14
+ # # app/services/user_creator.rb
15
+ # def create_user(params)
16
+ # if params[:admin]
17
+ # create_admin(params)
18
+ # elsif params[:moderator]
19
+ # create_moderator(params)
20
+ # else
21
+ # create_regular_user(params)
22
+ # end
23
+ # end
24
+ #
25
+ # # spec/services/user_creator_spec.rb
26
+ # context 'when creating a user' do
27
+ # # only one context for 3 branches
28
+ # end
29
+ #
30
+ # # good - method has 3 branches, spec has 3 contexts
31
+ # # spec/services/user_creator_spec.rb
32
+ # context 'when creating an admin' do
33
+ # end
34
+ # context 'when creating a moderator' do
35
+ # end
36
+ # context 'when creating a regular user' do
37
+ # end
38
+ class SufficientContexts < Base # rubocop:disable Metrics/ClassLength
39
+ MSG = "Method `%<method_name>s` has %<branches>d %<branch_word>s but only %<contexts>d %<context_word>s " \
40
+ "in spec. Add %<missing>d more %<missing_word>s to cover all branches."
41
+
42
+ COVERED_DIRECTORIES = %w[
43
+ app/models
44
+ app/controllers
45
+ app/services
46
+ app/jobs
47
+ app/mailers
48
+ app/helpers
49
+ ].freeze
50
+
51
+ EXCLUDED_METHODS = %w[initialize].freeze
52
+
53
+ EXCLUDED_PATTERNS = [
54
+ /^before_/,
55
+ /^after_/,
56
+ /^around_/,
57
+ /^validate_/,
58
+ /^autosave_/
59
+ ].freeze
60
+
61
+ def on_def(node)
62
+ check_method(node)
63
+ end
64
+
65
+ def on_defs(node)
66
+ check_method(node)
67
+ end
68
+
69
+ private
70
+
71
+ def check_method(node) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
72
+ return unless in_covered_directory?
73
+ return if excluded_method?(method_name(node))
74
+
75
+ branches = count_branches(node)
76
+ return if branches < 2 # Only check methods with branches
77
+
78
+ spec_file = spec_file_path
79
+ return unless File.exist?(spec_file)
80
+
81
+ spec_content = File.read(spec_file)
82
+ contexts = count_contexts_for_method(spec_content, method_name(node))
83
+
84
+ return if contexts >= branches
85
+
86
+ missing = branches - contexts
87
+ add_offense(node,
88
+ message: format(MSG,
89
+ method_name: method_name(node),
90
+ branches: branches,
91
+ branch_word: pluralize("branch", branches),
92
+ contexts: contexts,
93
+ context_word: pluralize("context", contexts),
94
+ missing: missing,
95
+ missing_word: pluralize("context", missing)))
96
+ end
97
+
98
+ def method_name(node)
99
+ if node.def_type?
100
+ node.method_name.to_s
101
+ else
102
+ node.children[1].to_s
103
+ end
104
+ end
105
+
106
+ def in_covered_directory?
107
+ COVERED_DIRECTORIES.any? { |dir| processed_source.path.start_with?(dir) }
108
+ end
109
+
110
+ def excluded_method?(method_name)
111
+ return true if EXCLUDED_METHODS.include?(method_name)
112
+
113
+ EXCLUDED_PATTERNS.any? { |pattern| pattern.match?(method_name) }
114
+ end
115
+
116
+ def spec_file_path
117
+ path = processed_source.path
118
+ path.sub(%r{^app/}, "spec/").sub(/\.rb$/, "_spec.rb")
119
+ end
120
+
121
+ def count_branches(node)
122
+ branches = 0
123
+ elsif_nodes = Set.new
124
+
125
+ # First pass: collect all elsif nodes (if nodes in else branches)
126
+ node.each_descendant(:if) do |if_node|
127
+ elsif_nodes.add(if_node.else_branch) if if_node.else_branch&.if_type?
128
+ end
129
+
130
+ # Second pass: count branches, skipping elsif nodes
131
+ node.each_descendant do |descendant|
132
+ next if elsif_nodes.include?(descendant)
133
+
134
+ branches += branch_count_for_node(descendant)
135
+ end
136
+ branches
137
+ end
138
+
139
+ def branch_count_for_node(node)
140
+ case node.type
141
+ when :if then count_if_branches(node)
142
+ when :case then count_case_branches(node)
143
+ when :and, :or then 1
144
+ when :send then node.method?(:&) || node.method?(:|) ? 1 : 0
145
+ else 0
146
+ end
147
+ end
148
+
149
+ def count_if_branches(node)
150
+ # if/else is 2 branches, each elsif adds 1
151
+ branches = 2
152
+ current = node
153
+ while current&.if_type? && current.else_branch&.if_type?
154
+ branches += 1
155
+ current = current.else_branch
156
+ end
157
+ branches
158
+ end
159
+
160
+ def count_case_branches(node)
161
+ # Each when clause is a branch, plus default/else
162
+ when_count = node.when_branches.count
163
+ has_else = !node.else_branch.nil?
164
+ when_count + (has_else ? 1 : 0)
165
+ end
166
+
167
+ # rubocop:disable Metrics/MethodLength
168
+ def count_contexts_for_method(spec_content, method_name)
169
+ method_pattern = Regexp.escape(method_name)
170
+ in_method_block = false
171
+ context_count = 0
172
+ base_indent = 0
173
+
174
+ spec_content.each_line do |line|
175
+ current_indent = line[/^\s*/].length
176
+
177
+ # Entering a describe block for this method
178
+ if matches_method_describe?(line, method_pattern)
179
+ in_method_block = true
180
+ base_indent = current_indent
181
+ # Don't count the describe itself, only nested contexts
182
+ next
183
+ end
184
+
185
+ # Process lines inside the method block
186
+ if in_method_block
187
+ in_method_block = false if exiting_block?(line, current_indent, base_indent)
188
+ context_count += 1 if nested_context?(line)
189
+ elsif matches_context_pattern?(line, method_pattern)
190
+ context_count += 1
191
+ end
192
+ end
193
+
194
+ context_count
195
+ end
196
+
197
+ # rubocop:enable Metrics/MethodLength
198
+ def matches_method_describe?(line, method_pattern)
199
+ line =~ /^\s*describe\s+['"](?:#|\.)?#{method_pattern}['"]/ ||
200
+ line =~ /^\s*describe\s+:#{method_pattern}/
201
+ end
202
+
203
+ def matches_context_pattern?(line, method_pattern)
204
+ line =~ /^\s*(?:context|describe)\s+.*(?:#|\.)?#{method_pattern}/
205
+ end
206
+
207
+ def nested_context?(line)
208
+ line =~ /^\s*(?:context|describe)\s+/
209
+ end
210
+
211
+ def exiting_block?(line, current_indent, base_indent)
212
+ current_indent <= base_indent && line =~ /^\s*(?:describe|context|end)/
213
+ end
214
+
215
+ def pluralize(word, count)
216
+ return word if count == 1
217
+
218
+ case word
219
+ when "branch" then "branches"
220
+ when "context" then "contexts"
221
+ else "#{word}s"
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lint_roller"
4
+
5
+ module RuboCop
6
+ module RSpecParity
7
+ # LintRoller plugin for RuboCop integration (RuboCop >= 1.72)
8
+ class Plugin < LintRoller::Plugin
9
+ def about
10
+ LintRoller::About.new(
11
+ name: "rubocop-rspec_parity",
12
+ version: VERSION,
13
+ homepage: "https://github.com/povilasjurcys/rubocop-rspec_parity",
14
+ description: "RuboCop cops for RSpec parity checks"
15
+ )
16
+ end
17
+
18
+ def supported?(context)
19
+ context.engine == :rubocop
20
+ end
21
+
22
+ def rules(_context)
23
+ project_root = Pathname.new(__dir__).join("../../..")
24
+
25
+ LintRoller::Rules.new(
26
+ type: :path,
27
+ config_format: :rubocop,
28
+ value: project_root.join("config", "default.yml")
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module RSpecParity
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rspec_parity/version"
4
+
5
+ module RuboCop
6
+ module RSpecParity
7
+ class Error < StandardError; end
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rubocop_rspec_parity"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require_relative "rubocop/rspec_parity"
6
+ require_relative "rubocop/rspec_parity/version"
7
+ require_relative "rubocop/rspec_parity/plugin"
8
+ require_relative "rubocop/cop/rspec_parity/no_let_bang"
9
+ require_relative "rubocop/cop/rspec_parity/public_method_has_spec"
10
+ require_relative "rubocop/cop/rspec_parity/sufficient_contexts"
@@ -0,0 +1,6 @@
1
+ module Rubocop
2
+ module SpecParity
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubocop-rspec_parity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Povilas Jurcys
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: lint_roller
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rubocop
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.72.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.72.0
40
+ description: A RuboCop plugin that provides custom cops to ensure RSpec test coverage
41
+ parity and enforce RSpec best practices in your Ruby projects.
42
+ email:
43
+ - po.jurcys@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".ruby-version"
49
+ - CHANGELOG.md
50
+ - CLAUDE.md
51
+ - CODE_OF_CONDUCT.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - Rakefile
55
+ - config/default.yml
56
+ - lib/rubocop-rspec_parity.rb
57
+ - lib/rubocop/cop/rspec_parity/no_let_bang.rb
58
+ - lib/rubocop/cop/rspec_parity/public_method_has_spec.rb
59
+ - lib/rubocop/cop/rspec_parity/sufficient_contexts.rb
60
+ - lib/rubocop/rspec_parity.rb
61
+ - lib/rubocop/rspec_parity/plugin.rb
62
+ - lib/rubocop/rspec_parity/version.rb
63
+ - lib/rubocop_rspec_parity.rb
64
+ - sig/rubocop/rspec_parity.rbs
65
+ homepage: https://github.com/povilasjurcys/rubocop-rspec_parity
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://github.com/povilasjurcys/rubocop-rspec_parity
70
+ source_code_uri: https://github.com/povilasjurcys/rubocop-rspec_parity
71
+ changelog_uri: https://github.com/povilasjurcys/rubocop-rspec_parity/blob/main/CHANGELOG.md
72
+ rubygems_mfa_required: 'true'
73
+ default_lint_roller_plugin: RuboCop::RSpecParity::Plugin
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.2.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 4.0.4
89
+ specification_version: 4
90
+ summary: RuboCop plugin for enforcing RSpec spec parity and best practices
91
+ test_files: []