Skip to content

Commit 812bdfe

Browse files
authored
Merge pull request #2101 from lovro-bikic/leaky-local-variable-cop
Add new cop `RSpec/LeakyLocalVariable`
2 parents 413770b + ddaddea commit 812bdfe

File tree

8 files changed

+550
-0
lines changed

8 files changed

+550
-0
lines changed

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,4 @@ Performance/ZipWithoutBlock: {Enabled: true}
293293
# Enable our own pending cops.
294294

295295
RSpec/IncludeExamples: {Enabled: true}
296+
RSpec/LeakyLocalVariable: {Enabled: true}

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Master (Unreleased)
44

5+
- Add new cop `RSpec/LeakyLocalVariable`. ([@lovro-bikic])
6+
57
## 3.7.0 (2025-09-01)
68

79
- Mark `RSpec/IncludeExamples` as `SafeAutoCorrect: false`. ([@yujideveloper])

config/default.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,12 @@ RSpec/LeakyConstantDeclaration:
610610
StyleGuide: https://rspec.rubystyle.guide/#declare-constants
611611
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyConstantDeclaration
612612

613+
RSpec/LeakyLocalVariable:
614+
Description: Checks for local variables from outer scopes used inside examples.
615+
Enabled: pending
616+
VersionAdded: "<<next>>"
617+
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyLocalVariable
618+
613619
RSpec/LetBeforeExamples:
614620
Description: Checks for `let` definitions that come after an example.
615621
Enabled: true

docs/modules/ROOT/pages/cops.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
* xref:cops_rspec.adoc#rspeciteratedexpectation[RSpec/IteratedExpectation]
6060
* xref:cops_rspec.adoc#rspecleadingsubject[RSpec/LeadingSubject]
6161
* xref:cops_rspec.adoc#rspecleakyconstantdeclaration[RSpec/LeakyConstantDeclaration]
62+
* xref:cops_rspec.adoc#rspecleakylocalvariable[RSpec/LeakyLocalVariable]
6263
* xref:cops_rspec.adoc#rspecletbeforeexamples[RSpec/LetBeforeExamples]
6364
* xref:cops_rspec.adoc#rspecletsetup[RSpec/LetSetup]
6465
* xref:cops_rspec.adoc#rspecmatcharray[RSpec/MatchArray]

docs/modules/ROOT/pages/cops_rspec.adoc

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3390,6 +3390,88 @@ end
33903390
* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyConstantDeclaration
33913391
* https://rspec.info/features/3-12/rspec-mocks/mutating-constants
33923392
3393+
[#rspecleakylocalvariable]
3394+
== RSpec/LeakyLocalVariable
3395+
3396+
|===
3397+
| Enabled by default | Safe | Supports autocorrection | Version Added | Version Changed
3398+
3399+
| Pending
3400+
| Yes
3401+
| No
3402+
| <<next>>
3403+
| -
3404+
|===
3405+
3406+
Checks for local variables from outer scopes used inside examples.
3407+
3408+
Local variables assigned outside an example but used within it act
3409+
as shared state, which can make tests non-deterministic.
3410+
3411+
[#examples-rspecleakylocalvariable]
3412+
=== Examples
3413+
3414+
[source,ruby]
3415+
----
3416+
# bad - outside variable used in a hook
3417+
user = create(:user)
3418+
3419+
before { user.update(admin: true) }
3420+
3421+
# good
3422+
let(:user) { create(:user) }
3423+
3424+
before { user.update(admin: true) }
3425+
3426+
# bad - outside variable used in an example
3427+
user = create(:user)
3428+
3429+
it 'is persisted' do
3430+
expect(user).to be_persisted
3431+
end
3432+
3433+
# good
3434+
let(:user) { create(:user) }
3435+
3436+
it 'is persisted' do
3437+
expect(user).to be_persisted
3438+
end
3439+
3440+
# also good - assigning the variable within the example
3441+
it 'is persisted' do
3442+
user = create(:user)
3443+
3444+
expect(user).to be_persisted
3445+
end
3446+
3447+
# bad - outside variable passed to included examples
3448+
attrs = ['foo', 'bar']
3449+
3450+
it_behaves_like 'some examples', attrs
3451+
3452+
# good
3453+
it_behaves_like 'some examples' do
3454+
let(:attrs) { ['foo', 'bar'] }
3455+
end
3456+
3457+
# good - when variable is used only as example description
3458+
attribute = 'foo'
3459+
3460+
it "#{attribute} is persisted" do
3461+
expectations
3462+
end
3463+
3464+
# good - when variable is used only to include other examples
3465+
examples = foo ? 'some examples' : 'other examples'
3466+
3467+
it_behaves_like examples, another_argument
3468+
----
3469+
3470+
[#references-rspecleakylocalvariable]
3471+
=== References
3472+
3473+
* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyLocalVariable
3474+
33933475
[#rspecletbeforeexamples]
33943476
== RSpec/LetBeforeExamples
33953477
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module RSpec
6+
# Checks for local variables from outer scopes used inside examples.
7+
#
8+
# Local variables assigned outside an example but used within it act
9+
# as shared state, which can make tests non-deterministic.
10+
#
11+
# @example
12+
# # bad - outside variable used in a hook
13+
# user = create(:user)
14+
#
15+
# before { user.update(admin: true) }
16+
#
17+
# # good
18+
# let(:user) { create(:user) }
19+
#
20+
# before { user.update(admin: true) }
21+
#
22+
# # bad - outside variable used in an example
23+
# user = create(:user)
24+
#
25+
# it 'is persisted' do
26+
# expect(user).to be_persisted
27+
# end
28+
#
29+
# # good
30+
# let(:user) { create(:user) }
31+
#
32+
# it 'is persisted' do
33+
# expect(user).to be_persisted
34+
# end
35+
#
36+
# # also good - assigning the variable within the example
37+
# it 'is persisted' do
38+
# user = create(:user)
39+
#
40+
# expect(user).to be_persisted
41+
# end
42+
#
43+
# # bad - outside variable passed to included examples
44+
# attrs = ['foo', 'bar']
45+
#
46+
# it_behaves_like 'some examples', attrs
47+
#
48+
# # good
49+
# it_behaves_like 'some examples' do
50+
# let(:attrs) { ['foo', 'bar'] }
51+
# end
52+
#
53+
# # good - when variable is used only as example description
54+
# attribute = 'foo'
55+
#
56+
# it "#{attribute} is persisted" do
57+
# expectations
58+
# end
59+
#
60+
# # good - when variable is used only to include other examples
61+
# examples = foo ? 'some examples' : 'other examples'
62+
#
63+
# it_behaves_like examples, another_argument
64+
#
65+
class LeakyLocalVariable < Base
66+
MSG = 'Do not use local variables defined outside of ' \
67+
'examples inside of them.'
68+
69+
# @!method example_method?(node)
70+
def_node_matcher :example_method?, <<~PATTERN
71+
(send nil? #Examples.all _)
72+
PATTERN
73+
74+
# @!method includes_method?(node)
75+
def_node_matcher :includes_method?, <<~PATTERN
76+
(send nil? #Includes.all ...)
77+
PATTERN
78+
79+
def self.joining_forces
80+
VariableForce
81+
end
82+
83+
def after_leaving_scope(scope, _variable_table)
84+
scope.variables.each_value { |variable| check_references(variable) }
85+
end
86+
87+
private
88+
89+
def check_references(variable)
90+
variable.assignments.each do |assignment|
91+
next if part_of_example_scope?(assignment.node)
92+
93+
assignment.references.each do |reference|
94+
next unless inside_describe_block?(reference)
95+
next unless part_of_example_scope?(reference)
96+
next if allowed_reference?(reference)
97+
98+
add_offense(assignment.node)
99+
end
100+
end
101+
end
102+
103+
def allowed_reference?(node)
104+
node.each_ancestor.any? do |ancestor|
105+
next true if example_method?(ancestor)
106+
if includes_method?(ancestor)
107+
next allowed_includes_arguments?(ancestor, node)
108+
end
109+
110+
false
111+
end
112+
end
113+
114+
def allowed_includes_arguments?(node, argument)
115+
node.arguments[1..].all? do |argument_node|
116+
next true if argument_node.type?(:dstr, :dsym)
117+
118+
argument_node != argument &&
119+
argument_node.each_descendant.none?(argument)
120+
end
121+
end
122+
123+
def part_of_example_scope?(node)
124+
node.each_ancestor.any? { |ancestor| example_scope?(ancestor) }
125+
end
126+
127+
def example_scope?(node)
128+
subject?(node) || let?(node) || hook?(node) || example?(node) ||
129+
include?(node)
130+
end
131+
132+
def inside_describe_block?(node)
133+
node.each_ancestor(:block).any? { |ancestor| spec_group?(ancestor) }
134+
end
135+
end
136+
end
137+
end
138+
end

lib/rubocop/cop/rspec_cops.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
require_relative 'rspec/iterated_expectation'
5858
require_relative 'rspec/leading_subject'
5959
require_relative 'rspec/leaky_constant_declaration'
60+
require_relative 'rspec/leaky_local_variable'
6061
require_relative 'rspec/let_before_examples'
6162
require_relative 'rspec/let_setup'
6263
require_relative 'rspec/match_array'

0 commit comments

Comments
 (0)