rubocop-vicenzo 0.6.0 → 0.8.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/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +15 -0
- data/README.md +1 -1
- data/config/default.yml +103 -0
- data/lib/rubocop/cop/vicenzo/rspec/competing_subjects.rb +149 -0
- data/lib/rubocop/cop/vicenzo/rspec/let_is_method_result.rb +113 -0
- data/lib/rubocop/cop/vicenzo/rspec/mixin/described_method.rb +71 -0
- data/lib/rubocop/cop/vicenzo/rspec/mixin/expectation_target.rb +101 -0
- data/lib/rubocop/cop/vicenzo/rspec/mixin/top_level_definition.rb +52 -0
- data/lib/rubocop/cop/vicenzo/rspec/subject_defined_as_let.rb +108 -0
- data/lib/rubocop/cop/vicenzo/rspec/subject_is_method_result.rb +86 -0
- data/lib/rubocop/cop/vicenzo/rspec/top_level_context.rb +62 -0
- data/lib/rubocop/cop/vicenzo/rspec/top_level_example.rb +48 -0
- data/lib/rubocop/cop/vicenzo/rspec/top_level_hook.rb +55 -0
- data/lib/rubocop/cop/vicenzo/rspec/top_level_let.rb +93 -0
- data/lib/rubocop/cop/vicenzo/rspec/top_level_subject.rb +92 -0
- data/lib/rubocop/cop/vicenzo_cops.rb +9 -0
- data/lib/rubocop/vicenzo/version.rb +1 -1
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '094cfb18e36f28e2f311e80f435b1b5159f22875cfb8f13b5a0551ca9b5e9218'
|
|
4
|
+
data.tar.gz: b96a9634c956e7cec399e10a2f7f512a5f2894822ba07fecf7294a502fee56b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba554984ec7092154352415043f2868cd59bd89015de29b0098e01c1f0b2016b8b3a837845cd22e8219e1115b37f4b6e80eb3d5861d0f3325c0ad2894466de23
|
|
7
|
+
data.tar.gz: 031a968654d68f7fa65e7a57a6178fc97651ae8c20cb4861f25a1f4c9feb5c6d044a3bc7e269e4de392a9a38483ce51296517f852abe61e88ba804ad723140b4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/bvicenzo/rubocop-vicenzo/compare/v0.7.0...v0.8.0) (2026-09-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add RSpec cops against top-level declarations ([#31](https://github.com/bvicenzo/rubocop-vicenzo/issues/31)) ([ef5e207](https://github.com/bvicenzo/rubocop-vicenzo/commit/ef5e20728c0c4bc94dbb5eec54a48ff5197d1b25))
|
|
9
|
+
|
|
10
|
+
## [0.7.0](https://github.com/bvicenzo/rubocop-vicenzo/compare/v0.6.0...v0.7.0) (2026-08-31)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* add RSpec cops against subjects hidden in lets ([#30](https://github.com/bvicenzo/rubocop-vicenzo/issues/30)) ([9daf259](https://github.com/bvicenzo/rubocop-vicenzo/commit/9daf25972252fdfdb92a51bba9cfe70e63446d17))
|
|
16
|
+
* add Vicenzo/RSpec/SubjectIsMethodResult cop ([#28](https://github.com/bvicenzo/rubocop-vicenzo/issues/28)) ([b69245a](https://github.com/bvicenzo/rubocop-vicenzo/commit/b69245af973f3341bbe62bca628333c5acb909d7))
|
|
17
|
+
|
|
3
18
|
## [0.6.0](https://github.com/bvicenzo/rubocop-vicenzo/compare/v0.5.0...v0.6.0) (2026-08-19)
|
|
4
19
|
|
|
5
20
|
|
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Add it to the `development`/`test` group of your `Gemfile`:
|
|
|
11
11
|
<!-- x-release-please-start-version -->
|
|
12
12
|
```ruby
|
|
13
13
|
group :development, :test do
|
|
14
|
-
gem 'rubocop-vicenzo', '~> 0.
|
|
14
|
+
gem 'rubocop-vicenzo', '~> 0.8.0', require: false
|
|
15
15
|
end
|
|
16
16
|
```
|
|
17
17
|
<!-- x-release-please-end-version -->
|
data/config/default.yml
CHANGED
|
@@ -11,6 +11,18 @@ Vicenzo/Rails/EnumInclusionOfValidation:
|
|
|
11
11
|
Severity: convention
|
|
12
12
|
VersionAdded: '0.1.0'
|
|
13
13
|
|
|
14
|
+
Vicenzo/RSpec/CompetingSubjects:
|
|
15
|
+
Description: 'Definitions the expectations assert on, two or more in a group declaring no subject, are either hidden contexts or a premise that belongs in a before.'
|
|
16
|
+
Enabled: true
|
|
17
|
+
Severity: convention
|
|
18
|
+
AllowedMethods: []
|
|
19
|
+
Include:
|
|
20
|
+
- '**/spec/**/*_spec.rb'
|
|
21
|
+
Exclude:
|
|
22
|
+
- '**/spec/support/**/*'
|
|
23
|
+
- '**/spec/factories/**/*'
|
|
24
|
+
VersionAdded: '0.7.0'
|
|
25
|
+
|
|
14
26
|
Vicenzo/RSpec/ConditionalInSpec:
|
|
15
27
|
Description: 'Do not use conditional logic in specs. Extract each branch into an explicit context instead.'
|
|
16
28
|
Enabled: true
|
|
@@ -91,6 +103,97 @@ Vicenzo/RSpec/LeakyDefinition:
|
|
|
91
103
|
- '**/spec/support/**/*'
|
|
92
104
|
- '**/spec/factories/**/*'
|
|
93
105
|
|
|
106
|
+
Vicenzo/RSpec/LetIsMethodResult:
|
|
107
|
+
Description: 'A let under an example group describing a method must not hold the result of calling that method on the subject.'
|
|
108
|
+
Enabled: true
|
|
109
|
+
Severity: convention
|
|
110
|
+
AllowedMethods: []
|
|
111
|
+
Include:
|
|
112
|
+
- '**/spec/**/*_spec.rb'
|
|
113
|
+
Exclude:
|
|
114
|
+
- '**/spec/support/**/*'
|
|
115
|
+
- '**/spec/factories/**/*'
|
|
116
|
+
VersionAdded: '0.7.0'
|
|
117
|
+
|
|
118
|
+
Vicenzo/RSpec/SubjectDefinedAsLet:
|
|
119
|
+
Description: 'A let the expectations assert on, in a group declaring no subject, must be declared as the subject.'
|
|
120
|
+
Enabled: true
|
|
121
|
+
Severity: convention
|
|
122
|
+
AllowedMethods: []
|
|
123
|
+
Include:
|
|
124
|
+
- '**/spec/**/*_spec.rb'
|
|
125
|
+
Exclude:
|
|
126
|
+
- '**/spec/support/**/*'
|
|
127
|
+
- '**/spec/factories/**/*'
|
|
128
|
+
VersionAdded: '0.7.0'
|
|
129
|
+
|
|
130
|
+
Vicenzo/RSpec/SubjectIsMethodResult:
|
|
131
|
+
Description: 'The subject of an example group describing a method must be the object under test, not the method result.'
|
|
132
|
+
Enabled: true
|
|
133
|
+
Severity: convention
|
|
134
|
+
AllowedMethods: []
|
|
135
|
+
Include:
|
|
136
|
+
- '**/spec/**/*_spec.rb'
|
|
137
|
+
Exclude:
|
|
138
|
+
- '**/spec/support/**/*'
|
|
139
|
+
- '**/spec/factories/**/*'
|
|
140
|
+
VersionAdded: '0.7.0'
|
|
141
|
+
|
|
142
|
+
Vicenzo/RSpec/TopLevelContext:
|
|
143
|
+
Description: 'A context must not be declared in the top-level example group, where it is a scenario for every example in the file.'
|
|
144
|
+
Enabled: true
|
|
145
|
+
Severity: warning
|
|
146
|
+
Include:
|
|
147
|
+
- '**/spec/**/*_spec.rb'
|
|
148
|
+
Exclude:
|
|
149
|
+
- '**/spec/support/**/*'
|
|
150
|
+
- '**/spec/factories/**/*'
|
|
151
|
+
VersionAdded: '0.8.0'
|
|
152
|
+
|
|
153
|
+
Vicenzo/RSpec/TopLevelExample:
|
|
154
|
+
Description: 'An example must not be declared in the top-level example group, which names no behaviour.'
|
|
155
|
+
Enabled: true
|
|
156
|
+
Severity: warning
|
|
157
|
+
Include:
|
|
158
|
+
- '**/spec/**/*_spec.rb'
|
|
159
|
+
Exclude:
|
|
160
|
+
- '**/spec/support/**/*'
|
|
161
|
+
- '**/spec/factories/**/*'
|
|
162
|
+
VersionAdded: '0.8.0'
|
|
163
|
+
|
|
164
|
+
Vicenzo/RSpec/TopLevelHook:
|
|
165
|
+
Description: 'A hook must not be declared in the top-level example group, where it runs for every example in the file.'
|
|
166
|
+
Enabled: true
|
|
167
|
+
Severity: warning
|
|
168
|
+
Include:
|
|
169
|
+
- '**/spec/**/*_spec.rb'
|
|
170
|
+
Exclude:
|
|
171
|
+
- '**/spec/support/**/*'
|
|
172
|
+
- '**/spec/factories/**/*'
|
|
173
|
+
VersionAdded: '0.8.0'
|
|
174
|
+
|
|
175
|
+
Vicenzo/RSpec/TopLevelLet:
|
|
176
|
+
Description: 'A let must not be declared in the top-level example group, where it reaches every example in the file.'
|
|
177
|
+
Enabled: true
|
|
178
|
+
Severity: warning
|
|
179
|
+
Include:
|
|
180
|
+
- '**/spec/**/*_spec.rb'
|
|
181
|
+
Exclude:
|
|
182
|
+
- '**/spec/support/**/*'
|
|
183
|
+
- '**/spec/factories/**/*'
|
|
184
|
+
VersionAdded: '0.8.0'
|
|
185
|
+
|
|
186
|
+
Vicenzo/RSpec/TopLevelSubject:
|
|
187
|
+
Description: 'A subject must not be declared in the top-level example group, where it reaches every example in the file.'
|
|
188
|
+
Enabled: true
|
|
189
|
+
Severity: warning
|
|
190
|
+
Include:
|
|
191
|
+
- '**/spec/**/*_spec.rb'
|
|
192
|
+
Exclude:
|
|
193
|
+
- '**/spec/support/**/*'
|
|
194
|
+
- '**/spec/factories/**/*'
|
|
195
|
+
VersionAdded: '0.8.0'
|
|
196
|
+
|
|
94
197
|
Vicenzo/Style/JsonParseSymbolizeNames:
|
|
95
198
|
Description: 'Enforces passing `symbolize_names: true` to `JSON.parse` so keys are symbols.'
|
|
96
199
|
Enabled: true
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/described_method'
|
|
4
|
+
require_relative 'mixin/expectation_target'
|
|
5
|
+
require_relative 'mixin/premise_tracking'
|
|
6
|
+
|
|
7
|
+
module RuboCop
|
|
8
|
+
module Cop
|
|
9
|
+
module Vicenzo
|
|
10
|
+
module RSpec
|
|
11
|
+
# Two or more definitions the expectations assert on, in a group that declares no subject, are
|
|
12
|
+
# contexts that were never written.
|
|
13
|
+
#
|
|
14
|
+
# A group speaks about one subject. When its examples each assert on a different definition, the
|
|
15
|
+
# group is not one scenario with several facts: it is several scenarios sharing a roof, and the
|
|
16
|
+
# circumstance that tells them apart - the one a `context` would have named - lives only in the
|
|
17
|
+
# definitions' names and in the reader's head.
|
|
18
|
+
#
|
|
19
|
+
# The fix is structural, and it is never to pick one of them to promote. Which shape it takes
|
|
20
|
+
# depends on what the definitions are to this group, and working that out is the whole job -
|
|
21
|
+
# which is why the offense names what was found rather than prescribing a repair:
|
|
22
|
+
#
|
|
23
|
+
# 1. They are different scenarios. Give each its own `context`, saying out loud the circumstance
|
|
24
|
+
# it stands for, and let each declare the subject it is about. What was encoded in
|
|
25
|
+
# `record_1` / `record_2` becomes a sentence, and the example shrinks to the outcome alone.
|
|
26
|
+
# 2. One outcome covers them all. Then they were never subjects: they are the premise. Move them
|
|
27
|
+
# into a `before`, unnamed, and assert on the collection they belong to.
|
|
28
|
+
#
|
|
29
|
+
# Both are corrections to the spec. Neither is silencing the cop: a definition named
|
|
30
|
+
# `record_1` is the shape this smell takes, so renaming it settles nothing, and an inline
|
|
31
|
+
# disable directive keeps the hidden scenario hidden - which is the cost being paid here.
|
|
32
|
+
#
|
|
33
|
+
# A group that already declares a subject is left alone - there the subject is settled and the
|
|
34
|
+
# other definitions are premises, whatever their names suggest.
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# # bad - two subjects under one roof; the circumstances are only in the names
|
|
38
|
+
# describe '#approved?' do
|
|
39
|
+
# let!(:reviewed_post) { create(:post, reviewed_at: Time.current) }
|
|
40
|
+
# let!(:draft_post) { create(:post) }
|
|
41
|
+
#
|
|
42
|
+
# it { expect(reviewed_post).to be_approved }
|
|
43
|
+
# it { expect(draft_post).not_to be_approved }
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# # good - one context per scenario, each with its own subject
|
|
47
|
+
# describe '#approved?' do
|
|
48
|
+
# context 'when the post has been reviewed' do
|
|
49
|
+
# subject(:post) { create(:post, reviewed_at: Time.current) }
|
|
50
|
+
#
|
|
51
|
+
# it { is_expected.to be_approved }
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
# context 'without a review' do
|
|
55
|
+
# subject(:post) { create(:post) }
|
|
56
|
+
#
|
|
57
|
+
# it { is_expected.not_to be_approved }
|
|
58
|
+
# end
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# @example when one outcome covers every record, they are the premise
|
|
62
|
+
# # bad - two records named as if each were a subject
|
|
63
|
+
# describe '#deactivate' do
|
|
64
|
+
# let!(:pending_alert) { create(:alert, :pending) }
|
|
65
|
+
# let!(:firing_alert) { create(:alert, :firing) }
|
|
66
|
+
#
|
|
67
|
+
# it 'dismisses every alert', :aggregate_failures do
|
|
68
|
+
# monitor.deactivate
|
|
69
|
+
#
|
|
70
|
+
# expect(pending_alert.reload).to be_dismissed
|
|
71
|
+
# expect(firing_alert.reload).to be_dismissed
|
|
72
|
+
# end
|
|
73
|
+
# end
|
|
74
|
+
#
|
|
75
|
+
# # good - the records are the premise, and the outcome is about the collection
|
|
76
|
+
# describe '#deactivate' do
|
|
77
|
+
# before do
|
|
78
|
+
# create(:alert, :pending)
|
|
79
|
+
# create(:alert, :firing)
|
|
80
|
+
# end
|
|
81
|
+
#
|
|
82
|
+
# it 'dismisses every alert' do
|
|
83
|
+
# monitor.deactivate
|
|
84
|
+
#
|
|
85
|
+
# expect(monitor.alerts).to contain_exactly(be_dismissed, be_dismissed)
|
|
86
|
+
# end
|
|
87
|
+
# end
|
|
88
|
+
#
|
|
89
|
+
# @example records a single expectation compares are not competing subjects
|
|
90
|
+
# # good - both records build the one answer the example is about
|
|
91
|
+
# describe '.recent' do
|
|
92
|
+
# let!(:recent_order) { create(:order, created_at: 1.day.ago) }
|
|
93
|
+
# let!(:old_order) { create(:order, created_at: 1.year.ago) }
|
|
94
|
+
#
|
|
95
|
+
# it { expect(described_class.recent).to contain_exactly(recent_order) }
|
|
96
|
+
# end
|
|
97
|
+
class CompetingSubjects < RuboCop::Cop::RSpec::Base
|
|
98
|
+
include DescribedMethod
|
|
99
|
+
include ExpectationTarget
|
|
100
|
+
include PremiseTracking
|
|
101
|
+
|
|
102
|
+
MSG = 'The expectations here are about %<names>s, so this group has no one subject. Work out ' \
|
|
103
|
+
'what these definitions are to it, and let the structure say so.'
|
|
104
|
+
|
|
105
|
+
def on_block(node)
|
|
106
|
+
return unless example_group?(node)
|
|
107
|
+
|
|
108
|
+
names = competing_names(node)
|
|
109
|
+
|
|
110
|
+
return if names.size < 2
|
|
111
|
+
|
|
112
|
+
add_offense(node.send_node, message: format(MSG, names: names.join(', ')))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
alias on_numblock on_block
|
|
116
|
+
alias on_itblock on_block
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def competing_names(group)
|
|
121
|
+
return [] if declares_subject?(group)
|
|
122
|
+
|
|
123
|
+
asserted_definitions(group).reject { |name| exempt?(group, name) }.map { |name| "`:#{name}`" }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def exempt?(group, name)
|
|
127
|
+
return false if allowed_methods.empty?
|
|
128
|
+
|
|
129
|
+
definition = definition_named(group, name)
|
|
130
|
+
|
|
131
|
+
!definition.nil? && !call_in_chain(definition.body) do |call|
|
|
132
|
+
allowed_methods.include?(call.method_name.to_s)
|
|
133
|
+
end.nil?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def definition_named(group, name)
|
|
137
|
+
found = nil
|
|
138
|
+
|
|
139
|
+
each_child_block(group) do |child|
|
|
140
|
+
found ||= child if premise?(child) && premise_name(child)&.to_sym == name
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
found
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/described_method'
|
|
4
|
+
require_relative 'mixin/premise_tracking'
|
|
5
|
+
|
|
6
|
+
module RuboCop
|
|
7
|
+
module Cop
|
|
8
|
+
module Vicenzo
|
|
9
|
+
module RSpec
|
|
10
|
+
# A `let` under an example group that describes a method must not hold what calling that method
|
|
11
|
+
# on the subject returned.
|
|
12
|
+
#
|
|
13
|
+
# This is the same inversion `Vicenzo/RSpec/SubjectIsMethodResult` reports, moved one definition
|
|
14
|
+
# away: the subject stays the object under test, and the result is parked in a `let` that the
|
|
15
|
+
# expectation then asserts on. What the example says out loud becomes a name, and the action -
|
|
16
|
+
# the subject receiving the method - is nowhere in the example that is supposed to describe it.
|
|
17
|
+
#
|
|
18
|
+
# The fix is not to rename the `let` or to move it: it is to undo it. Even when the `let` is read
|
|
19
|
+
# inside an `expect`, what it holds belongs in the expectation itself, where the sentence reads
|
|
20
|
+
# whole - the cat catches the ball, and that is what is asserted.
|
|
21
|
+
#
|
|
22
|
+
# Only calls on the subject count, which is what separates the result from the setup: a `let`
|
|
23
|
+
# calling the described method on some other object is building a premise, not the outcome the
|
|
24
|
+
# example is about. `AllowedMethods` is shared with the sibling cop and ships empty, for the
|
|
25
|
+
# layers whose convention is a single entry point.
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# # bad - the expectation asserts on a name; the cat never catches anything in the example
|
|
29
|
+
# describe '#catch' do
|
|
30
|
+
# subject(:cat) { Cat.new(name: 'Bixano') }
|
|
31
|
+
#
|
|
32
|
+
# let(:catch) { cat.catch(object: Ball.new) }
|
|
33
|
+
#
|
|
34
|
+
# it { expect(catch).to eq(:success) }
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# # good - the action is in the expectation
|
|
38
|
+
# describe '#catch' do
|
|
39
|
+
# subject(:cat) { Cat.new(name: 'Bixano') }
|
|
40
|
+
#
|
|
41
|
+
# it { expect(cat.catch(object: Ball.new)).to eq(:success) }
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# @example a call on another object is a premise, not the result
|
|
45
|
+
# # good - the described method builds the state the example starts from
|
|
46
|
+
# describe '#catch' do
|
|
47
|
+
# subject(:cat) { Cat.new(name: 'Bixano') }
|
|
48
|
+
#
|
|
49
|
+
# let(:taken_ball) { other_cat.catch(object: Ball.new) }
|
|
50
|
+
# end
|
|
51
|
+
class LetIsMethodResult < RuboCop::Cop::RSpec::Base
|
|
52
|
+
include DescribedMethod
|
|
53
|
+
include PremiseTracking
|
|
54
|
+
|
|
55
|
+
MSG = 'Let `:%<name>s` holds what `%<method>s` returned. Undo it and call `%<method>s` on the ' \
|
|
56
|
+
'subject inside the expectation.'
|
|
57
|
+
|
|
58
|
+
def on_block(node)
|
|
59
|
+
return unless let?(node) || let_it_be?(node)
|
|
60
|
+
|
|
61
|
+
method_name = described_method_name(node)
|
|
62
|
+
return if method_name.nil? || allowed_methods.include?(method_name)
|
|
63
|
+
|
|
64
|
+
invocation = invocation_on_subject(node, method_name)
|
|
65
|
+
return unless invocation
|
|
66
|
+
|
|
67
|
+
add_offense(invocation, message: format(MSG, name: premise_name(node), method: method_name))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
alias on_numblock on_block
|
|
71
|
+
alias on_itblock on_block
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def invocation_on_subject(node, method_name)
|
|
76
|
+
names = subject_names(node)
|
|
77
|
+
|
|
78
|
+
call_in_chain(node.body) do |call|
|
|
79
|
+
call.method?(method_name) && subject_reference?(call.receiver, names)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `subject` always refers to the subject, declared or implicit; a named one answers to its name
|
|
84
|
+
# as well.
|
|
85
|
+
def subject_names(node)
|
|
86
|
+
names = Set[:subject]
|
|
87
|
+
|
|
88
|
+
node.each_ancestor(:any_block).each do |ancestor|
|
|
89
|
+
next unless example_group?(ancestor)
|
|
90
|
+
|
|
91
|
+
each_child_block(ancestor) { |child| names << subject_name(child) if subject?(child) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
names
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# An anonymous `subject!` is still read as `subject`.
|
|
98
|
+
def subject_name(node)
|
|
99
|
+
name = premise_name(node).to_sym
|
|
100
|
+
|
|
101
|
+
name == :subject! ? :subject : name
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def subject_reference?(receiver, names)
|
|
105
|
+
return false unless receiver&.send_type? && receiver.receiver.nil? && receiver.arguments.empty?
|
|
106
|
+
|
|
107
|
+
names.include?(receiver.method_name)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Vicenzo
|
|
6
|
+
module RSpec
|
|
7
|
+
# Shared helpers for the cops that read an example group naming a method - `#instance_method`,
|
|
8
|
+
# `.class_method`, `#method(signature)`, or the bare method name - and ask what the definitions
|
|
9
|
+
# under it are built from.
|
|
10
|
+
#
|
|
11
|
+
# Meant for cops inheriting from `RuboCop::Cop::RSpec::Base`: `example_group?` and `cop_config`
|
|
12
|
+
# come from there.
|
|
13
|
+
module DescribedMethod
|
|
14
|
+
# `#catch`, `.catch`, and the form carrying a signature such as `#catch(object:)`.
|
|
15
|
+
PREFIXED_METHOD_DESCRIPTION = /\A[#.](?<name>.+?)(?:\(.*\))?\z/
|
|
16
|
+
# `catch` - a description that is nothing but a method name.
|
|
17
|
+
BARE_METHOD_DESCRIPTION = /\A(?<name>\w+[?!=]?)\z/
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# The nearest enclosing example group naming a method: a definition sitting in a `context` still
|
|
22
|
+
# answers to the `describe '#method'` wrapping it.
|
|
23
|
+
def described_method_name(node)
|
|
24
|
+
node
|
|
25
|
+
.each_ancestor(:any_block)
|
|
26
|
+
.lazy
|
|
27
|
+
.filter_map { |ancestor| method_name_from(ancestor) if example_group?(ancestor) }
|
|
28
|
+
.first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def method_name_from(node)
|
|
32
|
+
description = node.send_node.first_argument
|
|
33
|
+
return unless description.respond_to?(:str_type?) && description.str_type?
|
|
34
|
+
|
|
35
|
+
match = PREFIXED_METHOD_DESCRIPTION.match(description.value) ||
|
|
36
|
+
BARE_METHOD_DESCRIPTION.match(description.value)
|
|
37
|
+
|
|
38
|
+
match&.[](:name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def allowed_methods
|
|
42
|
+
cop_config.fetch('AllowedMethods', [])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Walks the expression a definition returns down its receiver chain, yielding each call, so that
|
|
46
|
+
# `Cat.new.catch(ball).to_s` is seen as built on `catch` while `Cat.new(catches: other.catch)`
|
|
47
|
+
# is not - there, nothing of `catch` reaches the value.
|
|
48
|
+
def call_in_chain(body)
|
|
49
|
+
node = returned_expression(body)
|
|
50
|
+
|
|
51
|
+
while node
|
|
52
|
+
node = node.send_node if node.any_block_type?
|
|
53
|
+
break unless node.type?(:call)
|
|
54
|
+
return node if yield(node)
|
|
55
|
+
|
|
56
|
+
node = node.receiver
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def returned_expression(body)
|
|
63
|
+
return if body.nil?
|
|
64
|
+
|
|
65
|
+
body.begin_type? ? body.children.last : body
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Vicenzo
|
|
6
|
+
module RSpec
|
|
7
|
+
# Shared helpers to tell which definitions an example group's expectations are *about*.
|
|
8
|
+
#
|
|
9
|
+
# Only the left-hand side of an expectation counts - `expect(order)` and `expect { order }`.
|
|
10
|
+
# What sits inside the matcher is the expected answer, not the subject: in
|
|
11
|
+
# `expect(described_class.recent).to contain_exactly(recent_order)`, `recent_order` is part of the
|
|
12
|
+
# answer, and reading it as the subject would turn the correct way of specifying a scope into an
|
|
13
|
+
# offense.
|
|
14
|
+
#
|
|
15
|
+
# Meant for cops inheriting from `RuboCop::Cop::RSpec::Base` and including `PremiseTracking`:
|
|
16
|
+
# `let?`, `example_group?` and `premise_name` come from there.
|
|
17
|
+
module ExpectationTarget
|
|
18
|
+
EXPECTATION_RUNNERS = %i[to not_to to_not].freeze
|
|
19
|
+
|
|
20
|
+
# Verifying a message is a statement about a collaborator, never about the subject.
|
|
21
|
+
MESSAGE_MATCHERS = %i[receive have_received receive_messages receive_message_chain].freeze
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The definitions declared directly in this group that its expectations assert on.
|
|
26
|
+
def asserted_definitions(group)
|
|
27
|
+
names = []
|
|
28
|
+
|
|
29
|
+
each_child_block(group) do |child|
|
|
30
|
+
next unless let?(child) || let_it_be?(child)
|
|
31
|
+
|
|
32
|
+
name = premise_name(child)&.to_sym
|
|
33
|
+
|
|
34
|
+
names << name if name && asserted_on?(group, name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
names
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def asserted_on?(group, name)
|
|
41
|
+
return false if group.nil?
|
|
42
|
+
|
|
43
|
+
group.each_descendant(:send).any? { |send_node| expectation_on?(send_node, name) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def expectation_on?(node, name)
|
|
47
|
+
return false unless node.method?(:expect) && node.receiver.nil?
|
|
48
|
+
return false unless reference?(node.first_argument || node.block_node&.body, name)
|
|
49
|
+
|
|
50
|
+
!message_expectation?(node)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reference?(node, name)
|
|
54
|
+
return false unless node&.send_type? && node.receiver.nil? && node.arguments.empty?
|
|
55
|
+
|
|
56
|
+
node.method?(name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def message_expectation?(node)
|
|
60
|
+
MESSAGE_MATCHERS.include?(matcher_name(expectation_matcher(node)))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def expectation_matcher(node)
|
|
64
|
+
runner = node.block_node&.parent || node.parent
|
|
65
|
+
|
|
66
|
+
return unless runner&.send_type? && EXPECTATION_RUNNERS.include?(runner.method_name)
|
|
67
|
+
|
|
68
|
+
runner.first_argument
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The matcher a chained expectation starts from: `receive(:set).with(key)` starts at `receive`.
|
|
72
|
+
def matcher_name(node)
|
|
73
|
+
node = node.receiver while node&.receiver
|
|
74
|
+
|
|
75
|
+
node&.method_name
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def enclosing_example_group(node)
|
|
79
|
+
node.each_ancestor(:any_block).find { |ancestor| example_group?(ancestor) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def subject_declared?(node)
|
|
83
|
+
node.each_ancestor(:any_block).any? do |ancestor|
|
|
84
|
+
next false unless example_group?(ancestor)
|
|
85
|
+
|
|
86
|
+
declares_subject?(ancestor)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def declares_subject?(group)
|
|
91
|
+
declared = false
|
|
92
|
+
|
|
93
|
+
each_child_block(group) { |child| declared ||= subject?(child) }
|
|
94
|
+
|
|
95
|
+
declared
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'premise_tracking'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Vicenzo
|
|
8
|
+
module RSpec
|
|
9
|
+
# Shared machinery for the cops that read what a spec declares straight inside the top-level
|
|
10
|
+
# example group - the widest scope a file has, and the one every example written later inherits
|
|
11
|
+
# whether it wanted to or not.
|
|
12
|
+
#
|
|
13
|
+
# The including cop says which children it reads, through `#definition?`, and how to word the
|
|
14
|
+
# offense, through `MSG`. A cop that names what it found overrides `#message_for`, and may read
|
|
15
|
+
# the name through `#declared_name`.
|
|
16
|
+
#
|
|
17
|
+
# Meant for cops inheriting from `RuboCop::Cop::RSpec::Base`: `example_group?` comes from there.
|
|
18
|
+
module TopLevelDefinition
|
|
19
|
+
include PremiseTracking
|
|
20
|
+
|
|
21
|
+
def on_block(node)
|
|
22
|
+
return unless top_level_example_group?(node)
|
|
23
|
+
|
|
24
|
+
each_child_block(node) do |child|
|
|
25
|
+
add_offense(child.send_node, message: message_for(child)) if definition?(child)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
alias on_numblock on_block
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def top_level_example_group?(node)
|
|
34
|
+
example_group?(node) && outermost_example_group?(node)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def message_for(_node)
|
|
38
|
+
self.class::MSG
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The name the definition was given, or `nil` when there is none to read: an anonymous
|
|
42
|
+
# `subject { ... }`, or a name only known at runtime.
|
|
43
|
+
def declared_name(node)
|
|
44
|
+
argument = node.send_node.first_argument
|
|
45
|
+
|
|
46
|
+
argument.value.to_sym if argument&.type?(:sym, :str)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/described_method'
|
|
4
|
+
require_relative 'mixin/expectation_target'
|
|
5
|
+
require_relative 'mixin/premise_tracking'
|
|
6
|
+
|
|
7
|
+
module RuboCop
|
|
8
|
+
module Cop
|
|
9
|
+
module Vicenzo
|
|
10
|
+
module RSpec
|
|
11
|
+
# A `let` the expectations assert on, in an example group that declares no subject, is the
|
|
12
|
+
# subject wearing another name. Declare it as one.
|
|
13
|
+
#
|
|
14
|
+
# The subject of a specification is what the sentences are about. When every expectation in a
|
|
15
|
+
# group reads `expect(service)`, `service` is that - and calling it a `let` costs the group the
|
|
16
|
+
# one declaration that says so out loud, leaves `is_expected` unavailable, and hides the
|
|
17
|
+
# redefinitions from the cops that watch subjects for a living.
|
|
18
|
+
#
|
|
19
|
+
# This says nothing about what the subject should hold - that is
|
|
20
|
+
# `Vicenzo/RSpec/SubjectIsMethodResult`'s question, and it already knows the conventions the
|
|
21
|
+
# project declared. The two compose without arguing: declare the subject, and if the value it
|
|
22
|
+
# holds is not fit to be the subject either, the sibling cop says so next.
|
|
23
|
+
#
|
|
24
|
+
# `AllowedMethods` exempts a `let` whose value comes from calling one of the listed methods, for
|
|
25
|
+
# the projects where such a definition is deliberately a `let`. It ships empty.
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# # bad - every expectation is about `service`, yet the group declares no subject
|
|
29
|
+
# describe '.call' do
|
|
30
|
+
# context 'when the order is unknown' do
|
|
31
|
+
# let(:service) { described_class.(order_id: 42) }
|
|
32
|
+
#
|
|
33
|
+
# it { expect(service).to be_failure }
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# # good - the subject is declared, and `is_expected` reads the sentence back
|
|
38
|
+
# describe '.call' do
|
|
39
|
+
# context 'when the order is unknown' do
|
|
40
|
+
# subject(:service) { described_class.(order_id: 42) }
|
|
41
|
+
#
|
|
42
|
+
# it { is_expected.to be_failure }
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# @example a let the expectations only read through is a premise
|
|
47
|
+
# # good - the expectation is about the book, not about the author
|
|
48
|
+
# describe '#author' do
|
|
49
|
+
# subject(:book) { create(:book, author:) }
|
|
50
|
+
#
|
|
51
|
+
# let(:author) { create(:author) }
|
|
52
|
+
#
|
|
53
|
+
# it { expect(book.author).to eq(author) }
|
|
54
|
+
# end
|
|
55
|
+
class SubjectDefinedAsLet < RuboCop::Cop::RSpec::Base
|
|
56
|
+
include DescribedMethod
|
|
57
|
+
include ExpectationTarget
|
|
58
|
+
include PremiseTracking
|
|
59
|
+
|
|
60
|
+
MSG = 'Let `:%<name>s` is what the expectations assert on, so it is the subject. ' \
|
|
61
|
+
'Declare it with `subject(:%<name>s)`.'
|
|
62
|
+
|
|
63
|
+
def on_block(node)
|
|
64
|
+
name = subject_in_disguise(node)
|
|
65
|
+
|
|
66
|
+
return if name.nil?
|
|
67
|
+
|
|
68
|
+
add_offense(node.send_node, message: format(MSG, name:))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
alias on_numblock on_block
|
|
72
|
+
alias on_itblock on_block
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# The name a definition should have been declared under, or `nil` when it is a `let` like any
|
|
77
|
+
# other: not a definition at all, one the group already has a subject for, one whose value the
|
|
78
|
+
# project exempted, or one no expectation is about.
|
|
79
|
+
def subject_in_disguise(node)
|
|
80
|
+
return unless candidate?(node)
|
|
81
|
+
|
|
82
|
+
group = enclosing_example_group(node)
|
|
83
|
+
|
|
84
|
+
return if group.nil?
|
|
85
|
+
|
|
86
|
+
name = premise_name(node)&.to_sym
|
|
87
|
+
|
|
88
|
+
# Two definitions asserted side by side cannot both be this group's subject: they are
|
|
89
|
+
# hidden contexts, which `Vicenzo/RSpec/CompetingSubjects` reads.
|
|
90
|
+
return unless asserted_definitions(group) == [name]
|
|
91
|
+
|
|
92
|
+
name
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def candidate?(node)
|
|
96
|
+
(let?(node) || let_it_be?(node)) && !subject_declared?(node) && !allowed_value?(node)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def allowed_value?(node)
|
|
100
|
+
return false if allowed_methods.empty?
|
|
101
|
+
|
|
102
|
+
!call_in_chain(node.body) { |call| allowed_methods.include?(call.method_name.to_s) }.nil?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/described_method'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Vicenzo
|
|
8
|
+
module RSpec
|
|
9
|
+
# The subject of an example group that describes a method must be the object under test,
|
|
10
|
+
# not what the method returned.
|
|
11
|
+
#
|
|
12
|
+
# A specification is read as a sentence. In "the cat caught the ball", the subject is the
|
|
13
|
+
# cat, not the ball. Storing the return value in the subject inverts that: the object the
|
|
14
|
+
# example is about disappears from the text, the subject's name starts to lie (a
|
|
15
|
+
# `subject(:cat)` holding a `Symbol`), and every scenario that would need the same object
|
|
16
|
+
# in a different state has to redefine the whole call.
|
|
17
|
+
#
|
|
18
|
+
# An example group names a method when its description reads `#instance_method`,
|
|
19
|
+
# `.class_method`, or the bare method name. The cop then looks at the subjects declared
|
|
20
|
+
# under it - including the ones nested in a `context` - and reports the ones whose value
|
|
21
|
+
# comes from calling that method.
|
|
22
|
+
#
|
|
23
|
+
# Some layers legitimately specify a single entry point, where the result is the subject
|
|
24
|
+
# (a service object always invoked through `call`, for instance). `AllowedMethods` exists
|
|
25
|
+
# for those and ships empty: the convention belongs to the project that adopts it, not to
|
|
26
|
+
# this gem. Declaring it there - or excluding a whole directory through the standard
|
|
27
|
+
# `Exclude` - keeps the exemption visible and reviewable in `.rubocop.yml`.
|
|
28
|
+
#
|
|
29
|
+
# An inline disable directive is not that: it is an exemption nobody reviewed. Dropping
|
|
30
|
+
# the `#` from the description is not either - the cop reads bare descriptions too, and the
|
|
31
|
+
# only thing lost is the reader's clue that a method is being specified there.
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# # bad - the subject is the ball
|
|
35
|
+
# describe '#catch' do
|
|
36
|
+
# subject(:catch) { Cat.new(name: 'Bixano').catch(object: Ball.new) }
|
|
37
|
+
#
|
|
38
|
+
# it { is_expected.to eq(:success) }
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# # good - the subject is the cat
|
|
42
|
+
# describe '#catch' do
|
|
43
|
+
# subject(:cat) { Cat.new(name: 'Bixano') }
|
|
44
|
+
#
|
|
45
|
+
# it { expect(cat.catch(object: Ball.new)).to eq(:success) }
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# @example a description without a prefix names a method too
|
|
49
|
+
# # bad - dropping the '#' silences nothing and costs the reader the
|
|
50
|
+
# # one clue that a method is being specified here
|
|
51
|
+
# describe 'catch' do
|
|
52
|
+
# subject(:catch) { Cat.new(name: 'Bixano').catch(object: Ball.new) }
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# @example AllowedMethods: ['call']
|
|
56
|
+
# # good - a layer whose convention is a single entry point. Declared in
|
|
57
|
+
# # .rubocop.yml, the exemption stays visible and reviewable, which an
|
|
58
|
+
# # inline disable directive never is
|
|
59
|
+
# describe '.call' do
|
|
60
|
+
# subject(:result) { described_class.(object: Ball.new) }
|
|
61
|
+
# end
|
|
62
|
+
class SubjectIsMethodResult < RuboCop::Cop::RSpec::Base
|
|
63
|
+
include DescribedMethod
|
|
64
|
+
|
|
65
|
+
MSG = 'Subject holds what `%<method>s` returned, not the object under test. ' \
|
|
66
|
+
'Make the subject the object that receives `%<method>s`, and call it inside the example.'
|
|
67
|
+
|
|
68
|
+
def on_block(node)
|
|
69
|
+
return unless subject?(node)
|
|
70
|
+
|
|
71
|
+
method_name = described_method_name(node)
|
|
72
|
+
return if method_name.nil? || allowed_methods.include?(method_name)
|
|
73
|
+
|
|
74
|
+
invocation = call_in_chain(node.body) { |call| call.method?(method_name) }
|
|
75
|
+
return unless invocation
|
|
76
|
+
|
|
77
|
+
add_offense(invocation, message: format(MSG, method: method_name))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
alias on_numblock on_block
|
|
81
|
+
alias on_itblock on_block
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/top_level_definition'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Vicenzo
|
|
8
|
+
module RSpec
|
|
9
|
+
# A context declared straight inside the top-level example group is a scenario for the whole
|
|
10
|
+
# file. Nest it in the group whose behaviour it is a scenario of.
|
|
11
|
+
#
|
|
12
|
+
# A context answers "under which circumstances?", and the question only means something once a
|
|
13
|
+
# behaviour has been named. Declared at the root, the scenario applies to every example in the
|
|
14
|
+
# file at once - the same overreach a root subject or a root let has, since the premises the
|
|
15
|
+
# context declares are handed to everything written under it later.
|
|
16
|
+
#
|
|
17
|
+
# It is also how the file loses the describe it never had: the first behaviour goes under
|
|
18
|
+
# `context 'when ...'`, the second one needs the same circumstances worded slightly differently,
|
|
19
|
+
# and what should have been two groups naming two behaviours becomes a tree of scenarios naming
|
|
20
|
+
# none.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# # bad - the scenario is about `#adult?`, yet it covers the whole file
|
|
24
|
+
# RSpec.describe Person do
|
|
25
|
+
# context 'when the person is a minor' do
|
|
26
|
+
# subject(:person) { described_class.new(age: 17) }
|
|
27
|
+
#
|
|
28
|
+
# it { is_expected.not_to be_adult }
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # good - the behaviour is named first, the scenario belongs to it
|
|
33
|
+
# RSpec.describe Person do
|
|
34
|
+
# describe '#adult?' do
|
|
35
|
+
# context 'when the person is a minor' do
|
|
36
|
+
# subject(:person) { described_class.new(age: 17) }
|
|
37
|
+
#
|
|
38
|
+
# it { is_expected.not_to be_adult }
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
class TopLevelContext < RuboCop::Cop::RSpec::Base
|
|
43
|
+
include TopLevelDefinition
|
|
44
|
+
|
|
45
|
+
MSG = 'This context is declared in the top-level example group, so it is a scenario for every ' \
|
|
46
|
+
'example in the file. Nest it in the group whose behaviour it is a scenario of.'
|
|
47
|
+
|
|
48
|
+
CONTEXTS = %i[context fcontext xcontext].freeze
|
|
49
|
+
|
|
50
|
+
# @!method context_definition?(node)
|
|
51
|
+
def_node_matcher :context_definition?, <<~PATTERN
|
|
52
|
+
(any_block (send nil? {#{CONTEXTS.map(&:inspect).join(' ')}} ...) ...)
|
|
53
|
+
PATTERN
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def definition?(node) = context_definition?(node)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/top_level_definition'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Vicenzo
|
|
8
|
+
module RSpec
|
|
9
|
+
# An example declared straight inside the top-level example group is about no behaviour in
|
|
10
|
+
# particular. Move it into the group describing the behaviour it exercises.
|
|
11
|
+
#
|
|
12
|
+
# The top-level group names the object under test, not one of the things it does. An example
|
|
13
|
+
# hanging directly off it therefore reads as a statement about the object as a whole, when it is
|
|
14
|
+
# always a statement about one behaviour - the group naming that behaviour is simply missing, and
|
|
15
|
+
# with it the place where the next example about the same behaviour would go.
|
|
16
|
+
#
|
|
17
|
+
# It is also the shape that keeps premises in the top-level group alive: as long as examples live
|
|
18
|
+
# at the root, the subject and the lets they read have nowhere else to be declared, which is what
|
|
19
|
+
# `Vicenzo/RSpec/TopLevelSubject` and `Vicenzo/RSpec/TopLevelLet` report.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# # bad - the example is about `#adult?`, but no group says so
|
|
23
|
+
# RSpec.describe Person do
|
|
24
|
+
# it { is_expected.to be_adult }
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# # good - the behaviour the example is about has a name, and a home
|
|
28
|
+
# RSpec.describe Person do
|
|
29
|
+
# describe '#adult?' do
|
|
30
|
+
# subject(:person) { described_class.new(age: 18) }
|
|
31
|
+
#
|
|
32
|
+
# it { is_expected.to be_adult }
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
class TopLevelExample < RuboCop::Cop::RSpec::Base
|
|
36
|
+
include TopLevelDefinition
|
|
37
|
+
|
|
38
|
+
MSG = 'This example is declared in the top-level example group, which names no behaviour. ' \
|
|
39
|
+
'Move it into the group describing the behaviour it exercises.'
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def definition?(node) = example?(node)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/top_level_definition'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Vicenzo
|
|
8
|
+
module RSpec
|
|
9
|
+
# A hook declared straight inside the top-level example group runs for every example in the
|
|
10
|
+
# file. Declare it in the group whose examples need it.
|
|
11
|
+
#
|
|
12
|
+
# A hook is a premise like any other - it just builds the state through side effects instead of
|
|
13
|
+
# a name. Declared at the root it reaches every example, including the ones written later that
|
|
14
|
+
# never asked for that state, and the group that needs the state built differently can only
|
|
15
|
+
# undo it, override it, or work around it.
|
|
16
|
+
#
|
|
17
|
+
# It is also the escape hatch left open once the root cannot hold a `subject` or a `let` any
|
|
18
|
+
# more: the premise stops being declared and starts being performed, which is harder to read and
|
|
19
|
+
# invisible to the cops that watch definitions. `Vicenzo/RSpec/TopLevelSubject` and
|
|
20
|
+
# `Vicenzo/RSpec/TopLevelLet` close the front door; this one closes the back.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# # bad - every example in the file runs inside this state
|
|
24
|
+
# RSpec.describe Person do
|
|
25
|
+
# before { travel_to(Time.zone.local(2026, 1, 1)) }
|
|
26
|
+
#
|
|
27
|
+
# describe '#adult?' do
|
|
28
|
+
# it { is_expected.to be_adult }
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # good - the state belongs to the examples that need it
|
|
33
|
+
# RSpec.describe Person do
|
|
34
|
+
# describe '#adult?' do
|
|
35
|
+
# before { travel_to(Time.zone.local(2026, 1, 1)) }
|
|
36
|
+
#
|
|
37
|
+
# it { is_expected.to be_adult }
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
class TopLevelHook < RuboCop::Cop::RSpec::Base
|
|
41
|
+
include TopLevelDefinition
|
|
42
|
+
|
|
43
|
+
MSG = 'Hook `%<name>s` is declared in the top-level example group, so it runs for every ' \
|
|
44
|
+
'example in the file. Declare it in the group whose examples need it.'
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def message_for(node) = format(MSG, name: node.method_name)
|
|
49
|
+
|
|
50
|
+
def definition?(node) = hook?(node)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/top_level_definition'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Vicenzo
|
|
8
|
+
module RSpec
|
|
9
|
+
# A let declared straight inside the top-level example group is the root of the nested
|
|
10
|
+
# redefinition problem. Declare it in the group whose examples read it.
|
|
11
|
+
#
|
|
12
|
+
# The top-level group is the widest scope a file has: a let born there is handed to every
|
|
13
|
+
# example, including all the ones written later that were never considered when it was written.
|
|
14
|
+
# The first sibling group that needs the value to be slightly different has nowhere to go but
|
|
15
|
+
# over the top of it - a redefinition, a near-copy under another name, or a `before` that mutates
|
|
16
|
+
# the value back into shape. None of those read as a specification any more, and each one is a
|
|
17
|
+
# scenario nobody named.
|
|
18
|
+
#
|
|
19
|
+
# Declaring the let in the group that reads it costs one extra line per group and buys back the
|
|
20
|
+
# naming: each group states the premises its examples start from, and a sibling that starts from
|
|
21
|
+
# a different value is a different group with its own declaration, not an override of someone
|
|
22
|
+
# else's.
|
|
23
|
+
#
|
|
24
|
+
# This is the preventive half of `Vicenzo/RSpec/NestedLetRedefinition`, which reports the
|
|
25
|
+
# redefinition once it exists. Keeping the top-level group free of lets means there is nothing to
|
|
26
|
+
# redefine.
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# # bad - the let reaches every example, so `#adult?` can only override it
|
|
30
|
+
# RSpec.describe Person do
|
|
31
|
+
# let(:age) { 42 }
|
|
32
|
+
#
|
|
33
|
+
# describe '#tall?' do
|
|
34
|
+
# it { is_expected.to be_tall }
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# describe '#adult?' do
|
|
38
|
+
# let(:age) { 17 }
|
|
39
|
+
#
|
|
40
|
+
# it { is_expected.not_to be_adult }
|
|
41
|
+
# end
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# # good - each group declares the premises its examples start from
|
|
45
|
+
# RSpec.describe Person do
|
|
46
|
+
# describe '#tall?' do
|
|
47
|
+
# subject(:person) { described_class.new(height: 1.75) }
|
|
48
|
+
#
|
|
49
|
+
# it { is_expected.to be_tall }
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# describe '#adult?' do
|
|
53
|
+
# subject(:person) { described_class.new(age: 17) }
|
|
54
|
+
#
|
|
55
|
+
# it { is_expected.not_to be_adult }
|
|
56
|
+
# end
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
# @example a context is as good a home as a describe
|
|
60
|
+
# # good - the premise belongs to the scenario, and the scenario is a context
|
|
61
|
+
# RSpec.describe Person do
|
|
62
|
+
# describe '#adult?' do
|
|
63
|
+
# context 'when the person is a minor' do
|
|
64
|
+
# subject(:person) { described_class.new(age:) }
|
|
65
|
+
#
|
|
66
|
+
# let(:age) { 17 }
|
|
67
|
+
#
|
|
68
|
+
# it { is_expected.not_to be_adult }
|
|
69
|
+
# end
|
|
70
|
+
# end
|
|
71
|
+
# end
|
|
72
|
+
class TopLevelLet < RuboCop::Cop::RSpec::Base
|
|
73
|
+
include TopLevelDefinition
|
|
74
|
+
|
|
75
|
+
MSG = 'Let `:%<name>s` is declared in the top-level example group, where it reaches every ' \
|
|
76
|
+
'example in the file. Declare it in the group whose examples read it.'
|
|
77
|
+
MSG_UNNAMED = 'A let is declared in the top-level example group, where it reaches every ' \
|
|
78
|
+
'example in the file. Declare it in the group whose examples read it.'
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def message_for(node)
|
|
83
|
+
name = declared_name(node)
|
|
84
|
+
|
|
85
|
+
name.nil? ? MSG_UNNAMED : format(MSG, name:)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def definition?(node) = let?(node) || let_it_be?(node)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'mixin/top_level_definition'
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Vicenzo
|
|
8
|
+
module RSpec
|
|
9
|
+
# A subject declared straight inside the top-level example group is the root of the nested
|
|
10
|
+
# redefinition problem. Declare it in the group whose examples read it.
|
|
11
|
+
#
|
|
12
|
+
# The top-level group is the widest scope a file has: a subject born there is handed to every
|
|
13
|
+
# example, including all the ones written later that were never considered when it was written.
|
|
14
|
+
# The first sibling group that needs the object built slightly differently has nowhere to go but
|
|
15
|
+
# over the top of it - a redefinition, a second subject under another name, or a `before` that
|
|
16
|
+
# patches the object back into shape. None of those read as a specification any more, and each
|
|
17
|
+
# one is a scenario nobody named.
|
|
18
|
+
#
|
|
19
|
+
# Declaring the subject in the group that asserts on it costs one extra line per group and buys
|
|
20
|
+
# back the naming: each group states the object it is about, and a sibling that needs a different
|
|
21
|
+
# object is a different group with its own declaration, not an override of someone else's.
|
|
22
|
+
#
|
|
23
|
+
# This is the preventive half of `Vicenzo/RSpec/NestedSubjectRedefinition`, which reports the
|
|
24
|
+
# redefinition once it exists. Keeping the top-level group free of subjects means there is
|
|
25
|
+
# nothing to redefine.
|
|
26
|
+
#
|
|
27
|
+
# @example
|
|
28
|
+
# # bad - the subject reaches every example, so `#tall?` can only override it
|
|
29
|
+
# RSpec.describe Person do
|
|
30
|
+
# subject(:person) { described_class.new }
|
|
31
|
+
#
|
|
32
|
+
# describe '#adult?' do
|
|
33
|
+
# before { person.age = 18 }
|
|
34
|
+
#
|
|
35
|
+
# it { is_expected.to be_adult }
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# describe '#tall?' do
|
|
39
|
+
# subject(:person) { described_class.new(height: 1.75) }
|
|
40
|
+
#
|
|
41
|
+
# it { is_expected.to be_tall }
|
|
42
|
+
# end
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# # good - each group declares the person its examples are about
|
|
46
|
+
# RSpec.describe Person do
|
|
47
|
+
# describe '#adult?' do
|
|
48
|
+
# subject(:person) { described_class.new(age: 18) }
|
|
49
|
+
#
|
|
50
|
+
# it { is_expected.to be_adult }
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# describe '#tall?' do
|
|
54
|
+
# subject(:person) { described_class.new(height: 1.75) }
|
|
55
|
+
#
|
|
56
|
+
# it { is_expected.to be_tall }
|
|
57
|
+
# end
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# @example a context is as good a home as a describe
|
|
61
|
+
# # good - the subject belongs to the scenario, and the scenario is a context
|
|
62
|
+
# RSpec.describe Person do
|
|
63
|
+
# describe '#adult?' do
|
|
64
|
+
# context 'when the person is of age' do
|
|
65
|
+
# subject(:person) { described_class.new(age: 18) }
|
|
66
|
+
#
|
|
67
|
+
# it { is_expected.to be_adult }
|
|
68
|
+
# end
|
|
69
|
+
# end
|
|
70
|
+
# end
|
|
71
|
+
class TopLevelSubject < RuboCop::Cop::RSpec::Base
|
|
72
|
+
include TopLevelDefinition
|
|
73
|
+
|
|
74
|
+
MSG = 'Subject `:%<name>s` is declared in the top-level example group, where it reaches every ' \
|
|
75
|
+
'example in the file. Declare it in the group whose examples read it.'
|
|
76
|
+
MSG_UNNAMED = 'The subject is declared in the top-level example group, where it reaches every ' \
|
|
77
|
+
'example in the file. Declare it in the group whose examples read it.'
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def message_for(node)
|
|
82
|
+
name = declared_name(node)
|
|
83
|
+
|
|
84
|
+
name.nil? ? MSG_UNNAMED : format(MSG, name:)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def definition?(node) = subject?(node)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'vicenzo/rspec/competing_subjects'
|
|
3
4
|
require_relative 'vicenzo/rspec/conditional_in_spec'
|
|
4
5
|
require_relative 'vicenzo/rspec/derived_premises'
|
|
5
6
|
require_relative 'vicenzo/rspec/dynamic_example_generation'
|
|
6
7
|
require_relative 'vicenzo/rspec/inconsistent_sibling_structure'
|
|
7
8
|
require_relative 'vicenzo/rspec/iteration_inside_example'
|
|
9
|
+
require_relative 'vicenzo/rspec/let_is_method_result'
|
|
8
10
|
require_relative 'vicenzo/rspec/mutated_premises'
|
|
9
11
|
require_relative 'vicenzo/rspec/nested_context_improper_start'
|
|
10
12
|
require_relative 'vicenzo/rspec/nested_let_redefinition'
|
|
11
13
|
require_relative 'vicenzo/rspec/nested_subject_redefinition'
|
|
12
14
|
require_relative 'vicenzo/rspec/leaky_definition'
|
|
15
|
+
require_relative 'vicenzo/rspec/subject_defined_as_let'
|
|
16
|
+
require_relative 'vicenzo/rspec/subject_is_method_result'
|
|
17
|
+
require_relative 'vicenzo/rspec/top_level_context'
|
|
18
|
+
require_relative 'vicenzo/rspec/top_level_example'
|
|
19
|
+
require_relative 'vicenzo/rspec/top_level_hook'
|
|
20
|
+
require_relative 'vicenzo/rspec/top_level_let'
|
|
21
|
+
require_relative 'vicenzo/rspec/top_level_subject'
|
|
13
22
|
require_relative 'vicenzo/rails/enum_inclusion_of_validation'
|
|
14
23
|
require_relative 'vicenzo/layout/multiline_method_call_line_breaks'
|
|
15
24
|
require_relative 'vicenzo/style/json_parse_symbolize_names'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-vicenzo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Vicenzo
|
|
@@ -75,17 +75,29 @@ files:
|
|
|
75
75
|
- lib/rubocop-vicenzo.rb
|
|
76
76
|
- lib/rubocop/cop/vicenzo/layout/multiline_method_call_line_breaks.rb
|
|
77
77
|
- lib/rubocop/cop/vicenzo/rails/enum_inclusion_of_validation.rb
|
|
78
|
+
- lib/rubocop/cop/vicenzo/rspec/competing_subjects.rb
|
|
78
79
|
- lib/rubocop/cop/vicenzo/rspec/conditional_in_spec.rb
|
|
79
80
|
- lib/rubocop/cop/vicenzo/rspec/derived_premises.rb
|
|
80
81
|
- lib/rubocop/cop/vicenzo/rspec/dynamic_example_generation.rb
|
|
81
82
|
- lib/rubocop/cop/vicenzo/rspec/inconsistent_sibling_structure.rb
|
|
82
83
|
- lib/rubocop/cop/vicenzo/rspec/iteration_inside_example.rb
|
|
83
84
|
- lib/rubocop/cop/vicenzo/rspec/leaky_definition.rb
|
|
85
|
+
- lib/rubocop/cop/vicenzo/rspec/let_is_method_result.rb
|
|
86
|
+
- lib/rubocop/cop/vicenzo/rspec/mixin/described_method.rb
|
|
87
|
+
- lib/rubocop/cop/vicenzo/rspec/mixin/expectation_target.rb
|
|
84
88
|
- lib/rubocop/cop/vicenzo/rspec/mixin/premise_tracking.rb
|
|
89
|
+
- lib/rubocop/cop/vicenzo/rspec/mixin/top_level_definition.rb
|
|
85
90
|
- lib/rubocop/cop/vicenzo/rspec/mutated_premises.rb
|
|
86
91
|
- lib/rubocop/cop/vicenzo/rspec/nested_context_improper_start.rb
|
|
87
92
|
- lib/rubocop/cop/vicenzo/rspec/nested_let_redefinition.rb
|
|
88
93
|
- lib/rubocop/cop/vicenzo/rspec/nested_subject_redefinition.rb
|
|
94
|
+
- lib/rubocop/cop/vicenzo/rspec/subject_defined_as_let.rb
|
|
95
|
+
- lib/rubocop/cop/vicenzo/rspec/subject_is_method_result.rb
|
|
96
|
+
- lib/rubocop/cop/vicenzo/rspec/top_level_context.rb
|
|
97
|
+
- lib/rubocop/cop/vicenzo/rspec/top_level_example.rb
|
|
98
|
+
- lib/rubocop/cop/vicenzo/rspec/top_level_hook.rb
|
|
99
|
+
- lib/rubocop/cop/vicenzo/rspec/top_level_let.rb
|
|
100
|
+
- lib/rubocop/cop/vicenzo/rspec/top_level_subject.rb
|
|
89
101
|
- lib/rubocop/cop/vicenzo/style/json_parse_symbolize_names.rb
|
|
90
102
|
- lib/rubocop/cop/vicenzo/style/multiline_method_call_parentheses.rb
|
|
91
103
|
- lib/rubocop/cop/vicenzo_cops.rb
|