rubocop-rspec_parity 0.1.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d97c9609bdd7e3ee9d2693e548eaf8f4e18af613449fc807b78f40386f8aad55
4
- data.tar.gz: f2f86bfac2c25c6c88c250f5c48a1a8ca7215b4265fbbb549dfe784359e42c91
3
+ metadata.gz: 2e75d169afdb6caa654d618ae15b194b3329b53c1151ce2cc492ed521a513327
4
+ data.tar.gz: 7ae507090fa12baa552224b911b5f50fb98096f87181880c98395b93368009cf
5
5
  SHA512:
6
- metadata.gz: 0f176130a0935ee6b3f3f22dabf39f21b8aafbe7335585cad02281366ed298d936559dd745809f77ff977210429715e3294f10b33dc2c245c9f1d3e3f6a347e0
7
- data.tar.gz: c0083330fd6fa6e3874488476b878159d203559c3cf21967f86a7175c8e4acae8c7dac213e9a552117b7ba43083fc45dd1e0a90200595c97e34c91c5f6dda85e
6
+ metadata.gz: 4c7e2fda565eafad49f2d7017edc91df2f12c200c821f59313afdc4c0e8221c7136e9197010a21ff6b2819c91b7a5cd72c7f71936aea2321ad389638bd830f70
7
+ data.tar.gz: b67f7496c9f0c483c82af7691579b3c9bb21efed23a4d7d50d70dac5fc5e3b2b7b531ecab0aedc660579fbbed659b1c38db0bf563c2f09ebf2f05be1d68665ae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2026-01-27
4
+
5
+ Added: `IgnoreMemoization` configuration option for `SufficientContexts` cop to ignore memoization patterns like `@var ||=` and `return @var if defined?(@var)`
6
+ Fixed: `SufficientContexts` cop now works with absolute file paths
7
+ Removed: `NoLetBang` cop
8
+
3
9
  ## [0.1.0] - 2026-01-15
4
10
 
5
11
  - Initial release
data/CLAUDE.md CHANGED
@@ -14,6 +14,43 @@
14
14
  - All RuboCop violations are resolved
15
15
  - All RSpec tests pass
16
16
  - No new warnings or errors are introduced
17
+ - CHANGELOG.md has been updated if applicable (see below)
18
+
19
+ ## Changelog Management
20
+
21
+ **Update CHANGELOG.md ONLY for:**
22
+ - New features (Added)
23
+ - Bug fixes (Fixed)
24
+ - Deprecations (Deprecated)
25
+ - Removed features (Removed)
26
+ - Dependency updates (Updated)
27
+
28
+ **DO NOT update for:**
29
+ - Refactoring or code quality improvements
30
+ - Minor documentation updates
31
+ - Internal implementation changes
32
+
33
+ **Format (compact, one line per change):**
34
+ ```
35
+ Added: Allow passing custom decorator build strategy
36
+ Fixed: Better handle render in controller hooks
37
+ Updated: GraphQL and gems version dependencies
38
+ Removed: NoLetBang cop
39
+ ```
40
+
41
+ **During Release:**
42
+ - Move items from `[Unreleased]` to: `## [X.Y.Z] - YYYY-MM-DD`
43
+ - Leave `[Unreleased]` empty
44
+
45
+ ## Release Process
46
+
47
+ To release a new version:
48
+ 1. Update version in `lib/rubocop/rspec_parity/version.rb`
49
+ 2. Update CHANGELOG.md (move [Unreleased] items to new version section)
50
+ 3. Run tests: `bundle exec rspec && bundle exec rubocop`
51
+ 4. Use rake task to release: `bundle exec rake release`
52
+ - This will create a git tag, push commits/tags, and push the gem to rubygems.org
53
+ 5. **DO NOT** manually run `gem build` or `gem push` - use the rake task
17
54
 
18
55
  ## Git Commits
19
56
 
data/README.md CHANGED
@@ -9,7 +9,6 @@ This plugin provides these custom cops:
9
9
  - **RSpecParity/FileHasSpec**: Ensures every Ruby file in your app directory has a corresponding spec file
10
10
  - **RSpecParity/PublicMethodHasSpec**: Ensures every public method has spec test coverage
11
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
12
 
14
13
  ## Examples
15
14
 
@@ -96,44 +95,21 @@ RSpec.describe UserCreator do
96
95
  end
97
96
  end
98
97
  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
98
 
110
- it 'does something' do
111
- expect(user).to be_valid
112
- end
99
+ # Memoization patterns are ignored by default
100
+ def cached_value
101
+ @cached_value ||= expensive_operation # Not counted as a branch
113
102
  end
114
103
 
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
104
+ def cached_value
105
+ return @cached_value if defined?(@cached_value) # Not counted as a branch
106
+ @cached_value = expensive_operation
122
107
  end
108
+ ```
123
109
 
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
110
+ **Configuration options:**
131
111
 
132
- it 'does something' do
133
- expect(user).to be_persisted
134
- end
135
- end
136
- ```
112
+ - `IgnoreMemoization` (default: `true`) - When enabled, common memoization patterns like `@var ||=` and `return @var if defined?(@var)` are not counted as branches. Set to `false` if you want to count these as branches.
137
113
 
138
114
  ## Assumptions
139
115
 
@@ -198,16 +174,12 @@ RSpecParity/PublicMethodHasSpec:
198
174
 
199
175
  RSpecParity/SufficientContexts:
200
176
  Enabled: true
177
+ IgnoreMemoization: true # Set to false to count memoization patterns as branches
201
178
  Include:
202
179
  - 'app/**/*.rb'
203
180
  Exclude:
204
181
  - 'app/assets/**/*'
205
182
  - 'app/views/**/*'
206
-
207
- RSpecParity/NoLetBang:
208
- Enabled: true
209
- Include:
210
- - 'spec/**/*_spec.rb'
211
183
  ```
212
184
 
213
185
  Run RuboCop as usual:
data/config/default.yml CHANGED
@@ -23,15 +23,10 @@ RSpecParity/PublicMethodHasSpec:
23
23
  RSpecParity/SufficientContexts:
24
24
  Description: 'Ensures specs have at least as many contexts as the method has branches.'
25
25
  Enabled: true
26
+ IgnoreMemoization: true
26
27
  Include:
27
28
  - 'app/**/*.rb'
28
29
  Exclude:
29
30
  - 'app/assets/**/*'
30
31
  - 'app/views/**/*'
31
32
  - 'app/javascript/**/*'
32
-
33
- RSpecParity/NoLetBang:
34
- Description: 'Disallows the use of `let!` in specs.'
35
- Enabled: true
36
- Include:
37
- - 'spec/**/*_spec.rb'
@@ -58,6 +58,11 @@ module RuboCop
58
58
  /^autosave_/
59
59
  ].freeze
60
60
 
61
+ def initialize(config = nil, options = nil)
62
+ super
63
+ @ignore_memoization = cop_config.fetch("IgnoreMemoization", true)
64
+ end
65
+
61
66
  def on_def(node)
62
67
  check_method(node)
63
68
  end
@@ -81,6 +86,7 @@ module RuboCop
81
86
  spec_content = File.read(spec_file)
82
87
  contexts = count_contexts_for_method(spec_content, method_name(node))
83
88
 
89
+ return if contexts.zero? # Method has no specs at all - PublicMethodHasSpec handles this
84
90
  return if contexts >= branches
85
91
 
86
92
  missing = branches - contexts
@@ -104,7 +110,11 @@ module RuboCop
104
110
  end
105
111
 
106
112
  def in_covered_directory?
107
- COVERED_DIRECTORIES.any? { |dir| processed_source.path.start_with?(dir) }
113
+ path = processed_source.path
114
+ # Handle both absolute and relative paths
115
+ COVERED_DIRECTORIES.any? do |dir|
116
+ path.start_with?(dir) || path.include?("/#{dir}/") || path.match?(%r{/#{Regexp.escape(dir)}$})
117
+ end
108
118
  end
109
119
 
110
120
  def excluded_method?(method_name)
@@ -115,37 +125,50 @@ module RuboCop
115
125
 
116
126
  def spec_file_path
117
127
  path = processed_source.path
118
- path.sub(%r{^app/}, "spec/").sub(/\.rb$/, "_spec.rb")
128
+ # Handle both absolute and relative paths
129
+ path.sub(%r{/app/}, "/spec/").sub(%r{^app/}, "spec/").sub(/\.rb$/, "_spec.rb")
119
130
  end
120
131
 
121
132
  def count_branches(node)
122
133
  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
134
+ elsif_nodes = collect_elsif_nodes(node)
129
135
 
130
- # Second pass: count branches, skipping elsif nodes
131
136
  node.each_descendant do |descendant|
132
137
  next if elsif_nodes.include?(descendant)
138
+ next if should_skip_node?(descendant)
133
139
 
134
140
  branches += branch_count_for_node(descendant)
135
141
  end
136
142
  branches
137
143
  end
138
144
 
145
+ def collect_elsif_nodes(node)
146
+ elsif_nodes = Set.new
147
+ node.each_descendant(:if) do |if_node|
148
+ elsif_nodes.add(if_node.else_branch) if if_node.else_branch&.if_type?
149
+ end
150
+ elsif_nodes
151
+ end
152
+
153
+ def should_skip_node?(node)
154
+ @ignore_memoization && memoization_pattern?(node)
155
+ end
156
+
139
157
  def branch_count_for_node(node)
140
158
  case node.type
141
159
  when :if then count_if_branches(node)
142
160
  when :case then count_case_branches(node)
143
161
  when :and, :or then 1
144
- when :send then node.method?(:&) || node.method?(:|) ? 1 : 0
162
+ when :or_asgn, :and_asgn then 2 # ||= and &&= create 2 branches (set vs already set)
163
+ when :send then send_node_branch_count(node)
145
164
  else 0
146
165
  end
147
166
  end
148
167
 
168
+ def send_node_branch_count(node)
169
+ node.method?(:&) || node.method?(:|) ? 1 : 0
170
+ end
171
+
149
172
  def count_if_branches(node)
150
173
  # if/else is 2 branches, each elsif adds 1
151
174
  branches = 2
@@ -164,37 +187,55 @@ module RuboCop
164
187
  when_count + (has_else ? 1 : 0)
165
188
  end
166
189
 
167
- # rubocop:disable Metrics/MethodLength
168
190
  def count_contexts_for_method(spec_content, method_name)
169
191
  method_pattern = Regexp.escape(method_name)
192
+ context_count, has_examples = parse_spec_content(spec_content, method_pattern)
193
+
194
+ # If no contexts but has examples, count as 1 scenario
195
+ context_count.zero? && has_examples ? 1 : context_count
196
+ end
197
+
198
+ # rubocop:disable Metrics/MethodLength
199
+ def parse_spec_content(spec_content, method_pattern)
170
200
  in_method_block = false
171
201
  context_count = 0
202
+ has_examples = false
172
203
  base_indent = 0
173
204
 
174
205
  spec_content.each_line do |line|
175
206
  current_indent = line[/^\s*/].length
176
207
 
177
- # Entering a describe block for this method
178
208
  if matches_method_describe?(line, method_pattern)
179
209
  in_method_block = true
180
210
  base_indent = current_indent
181
- # Don't count the describe itself, only nested contexts
182
211
  next
183
212
  end
184
213
 
185
- # Process lines inside the method block
186
214
  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)
215
+ context_count, has_examples, in_method_block = process_method_block_line(
216
+ line, current_indent, base_indent, context_count, has_examples
217
+ )
189
218
  elsif matches_context_pattern?(line, method_pattern)
190
219
  context_count += 1
191
220
  end
192
221
  end
193
222
 
194
- context_count
223
+ [context_count, has_examples]
195
224
  end
196
-
197
225
  # rubocop:enable Metrics/MethodLength
226
+
227
+ def process_method_block_line(line, current_indent, base_indent, context_count, has_examples)
228
+ in_method_block = !exiting_block?(line, current_indent, base_indent)
229
+
230
+ if nested_context?(line)
231
+ context_count += 1
232
+ elsif nested_example?(line)
233
+ has_examples = true
234
+ end
235
+
236
+ [context_count, has_examples, in_method_block]
237
+ end
238
+
198
239
  def matches_method_describe?(line, method_pattern)
199
240
  line =~ /^\s*describe\s+['"](?:#|\.)?#{method_pattern}['"]/ ||
200
241
  line =~ /^\s*describe\s+:#{method_pattern}/
@@ -208,6 +249,10 @@ module RuboCop
208
249
  line =~ /^\s*(?:context|describe)\s+/
209
250
  end
210
251
 
252
+ def nested_example?(line)
253
+ line =~ /^\s*(?:it|example|specify)\s+/
254
+ end
255
+
211
256
  def exiting_block?(line, current_indent, base_indent)
212
257
  current_indent <= base_indent && line =~ /^\s*(?:describe|context|end)/
213
258
  end
@@ -221,6 +266,81 @@ module RuboCop
221
266
  else "#{word}s"
222
267
  end
223
268
  end
269
+
270
+ def memoization_pattern?(node)
271
+ # Pattern: @var ||= value
272
+ return true if or_asgn_ivar_pattern?(node)
273
+
274
+ # Pattern: return @var if defined?(@var)
275
+ return true if defined_check_pattern?(node)
276
+
277
+ # Pattern: @var = value if @var.nil? or similar
278
+ return true if nil_check_pattern?(node)
279
+
280
+ # Pattern: || with instance variable (part of @var ||= which creates both :or and :or_asgn nodes)
281
+ return true if or_with_ivar_pattern?(node)
282
+
283
+ false
284
+ end
285
+
286
+ # @var ||= value
287
+ def or_asgn_ivar_pattern?(node)
288
+ node.or_asgn_type? && node.children[0]&.ivasgn_type?
289
+ end
290
+
291
+ # return @var if defined?(@var)
292
+ def defined_check_pattern?(node)
293
+ return false unless node.if_type?
294
+
295
+ condition = node.condition
296
+ return false unless condition&.defined_type?
297
+
298
+ # Check if it's checking an instance variable
299
+ condition.children[0]&.ivar_type?
300
+ end
301
+
302
+ # @var = value if @var.nil? or @var = value unless @var
303
+ def nil_check_pattern?(node)
304
+ return false unless node.if_type?
305
+
306
+ condition = node.condition
307
+ body = node.body
308
+
309
+ # Check if body is an ivasgn
310
+ return false unless body&.ivasgn_type?
311
+
312
+ ivar_name = body.children[0]
313
+
314
+ # Check if condition checks the same ivar for nil
315
+ checks_same_ivar_for_nil?(condition, ivar_name)
316
+ end
317
+
318
+ def checks_same_ivar_for_nil?(condition, ivar_name)
319
+ return false unless condition
320
+
321
+ nil_check?(condition, ivar_name) || negation_check?(condition, ivar_name)
322
+ end
323
+
324
+ def nil_check?(condition, ivar_name)
325
+ return false unless condition.send_type? && condition.method?(:nil?)
326
+
327
+ condition.receiver&.ivar_type? && condition.receiver.children[0] == ivar_name
328
+ end
329
+
330
+ def negation_check?(condition, ivar_name)
331
+ return false unless condition.send_type? && condition.method?(:!)
332
+
333
+ receiver = condition.receiver
334
+ receiver&.ivar_type? && receiver.children[0] == ivar_name
335
+ end
336
+
337
+ # || operator with instance variable on left side
338
+ def or_with_ivar_pattern?(node)
339
+ return false unless node.or_type?
340
+
341
+ left = node.children[0]
342
+ left&.ivar_type?
343
+ end
224
344
  end
225
345
  end
226
346
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module RSpecParity
5
- VERSION = "0.1.0"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
@@ -5,6 +5,5 @@ require "rubocop"
5
5
  require_relative "rubocop/rspec_parity"
6
6
  require_relative "rubocop/rspec_parity/version"
7
7
  require_relative "rubocop/rspec_parity/plugin"
8
- require_relative "rubocop/cop/rspec_parity/no_let_bang"
9
8
  require_relative "rubocop/cop/rspec_parity/public_method_has_spec"
10
9
  require_relative "rubocop/cop/rspec_parity/sufficient_contexts"
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: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Povilas Jurcys
@@ -54,7 +54,6 @@ files:
54
54
  - Rakefile
55
55
  - config/default.yml
56
56
  - lib/rubocop-rspec_parity.rb
57
- - lib/rubocop/cop/rspec_parity/no_let_bang.rb
58
57
  - lib/rubocop/cop/rspec_parity/public_method_has_spec.rb
59
58
  - lib/rubocop/cop/rspec_parity/sufficient_contexts.rb
60
59
  - lib/rubocop/rspec_parity.rb
@@ -1,36 +0,0 @@
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