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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5b82e09fef23aac921adb4f653bfbdc1117af279326d24339f68db8abe25ffd
4
- data.tar.gz: f5f4d255aacd3851e4f8b7968b663d22db0b0ed9be99bab01a17df43d819c80f
3
+ metadata.gz: 4f2fb87b8d1bfd10a66d52fcd5ede5f1eea78c7c2a8b7b8ea8a692d0efce54b6
4
+ data.tar.gz: aa25bb974ce70ab9ef752a1e36b83d779abded472022258a86ff8c2fdc483d77
5
5
  SHA512:
6
- metadata.gz: 79d36dbf73503ca38cca4d5ffd77fd84d56cf4b8d320ff389035c359116e36d66d15a3aad4fd3de10d9787fe29f4b4af66b0b669ce98754415fac8ad954106fa
7
- data.tar.gz: 84055ca6301311a752670e04fa5fcd9646fc38844993428a5f952be17a7384cfa5842b9a2daaa7f3be3242d70e0641b7ba02fdd718fee111f995bd1cd4a8b3e0
6
+ metadata.gz: 57918353d578d8c2a5840617ccfefe7a996aad212dbce8921ae18a1bf302bf89fb6a5e40f6b9cdd97d082c4fb74d2e26fb2a9148d92e83fbb074e6c094da996b
7
+ data.tar.gz: be26bf4c722a8e351112c02d58bba96d06fd62fa230e8feb20e3f80675c82f6ca377875a53281df99d39ccabf8fc5de2822f9eff985a6c7a3cbe188dedd3ff6d
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.6.0"
2
+ ".": "0.7.0"
3
3
  }
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.6.0', require: false
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'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Vicenzo
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
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.6.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