rubocop-vicenzo 0.6.0 → 0.7.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 +8 -0
- data/README.md +1 -1
- data/config/default.yml +48 -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/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_cops.rb +4 -0
- data/lib/rubocop/vicenzo/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f2fb87b8d1bfd10a66d52fcd5ede5f1eea78c7c2a8b7b8ea8a692d0efce54b6
|
|
4
|
+
data.tar.gz: aa25bb974ce70ab9ef752a1e36b83d779abded472022258a86ff8c2fdc483d77
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 57918353d578d8c2a5840617ccfefe7a996aad212dbce8921ae18a1bf302bf89fb6a5e40f6b9cdd97d082c4fb74d2e26fb2a9148d92e83fbb074e6c094da996b
|
|
7
|
+
data.tar.gz: be26bf4c722a8e351112c02d58bba96d06fd62fa230e8feb20e3f80675c82f6ca377875a53281df99d39ccabf8fc5de2822f9eff985a6c7a3cbe188dedd3ff6d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.0](https://github.com/bvicenzo/rubocop-vicenzo/compare/v0.6.0...v0.7.0) (2026-08-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* 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))
|
|
9
|
+
* add Vicenzo/RSpec/SubjectIsMethodResult cop ([#28](https://github.com/bvicenzo/rubocop-vicenzo/issues/28)) ([b69245a](https://github.com/bvicenzo/rubocop-vicenzo/commit/b69245af973f3341bbe62bca628333c5acb909d7))
|
|
10
|
+
|
|
3
11
|
## [0.6.0](https://github.com/bvicenzo/rubocop-vicenzo/compare/v0.5.0...v0.6.0) (2026-08-19)
|
|
4
12
|
|
|
5
13
|
|
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.7.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,42 @@ 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
|
+
|
|
94
142
|
Vicenzo/Style/JsonParseSymbolizeNames:
|
|
95
143
|
Description: 'Enforces passing `symbolize_names: true` to `JSON.parse` so keys are symbols.'
|
|
96
144
|
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,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
|
|
@@ -1,15 +1,19 @@
|
|
|
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'
|
|
13
17
|
require_relative 'vicenzo/rails/enum_inclusion_of_validation'
|
|
14
18
|
require_relative 'vicenzo/layout/multiline_method_call_line_breaks'
|
|
15
19
|
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Vicenzo
|
|
@@ -75,17 +75,23 @@ 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
|
|
85
89
|
- lib/rubocop/cop/vicenzo/rspec/mutated_premises.rb
|
|
86
90
|
- lib/rubocop/cop/vicenzo/rspec/nested_context_improper_start.rb
|
|
87
91
|
- lib/rubocop/cop/vicenzo/rspec/nested_let_redefinition.rb
|
|
88
92
|
- lib/rubocop/cop/vicenzo/rspec/nested_subject_redefinition.rb
|
|
93
|
+
- lib/rubocop/cop/vicenzo/rspec/subject_defined_as_let.rb
|
|
94
|
+
- lib/rubocop/cop/vicenzo/rspec/subject_is_method_result.rb
|
|
89
95
|
- lib/rubocop/cop/vicenzo/style/json_parse_symbolize_names.rb
|
|
90
96
|
- lib/rubocop/cop/vicenzo/style/multiline_method_call_parentheses.rb
|
|
91
97
|
- lib/rubocop/cop/vicenzo_cops.rb
|