rubocop-vibe 0.1.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 +7 -0
- data/config/default.yml +126 -0
- data/lib/rubocop/cop/vibe/blank_line_before_expectation.rb +141 -0
- data/lib/rubocop/cop/vibe/class_organization.rb +609 -0
- data/lib/rubocop/cop/vibe/consecutive_assignment_alignment.rb +219 -0
- data/lib/rubocop/cop/vibe/describe_block_order.rb +262 -0
- data/lib/rubocop/cop/vibe/is_expected_one_liner.rb +108 -0
- data/lib/rubocop/cop/vibe/mixin/spec_file_helper.rb +20 -0
- data/lib/rubocop/cop/vibe/no_assigns_attribute_testing.rb +62 -0
- data/lib/rubocop/cop/vibe/no_rubocop_disable.rb +60 -0
- data/lib/rubocop/cop/vibe/no_skipped_tests.rb +115 -0
- data/lib/rubocop/cop/vibe/no_unless_guard_clause.rb +250 -0
- data/lib/rubocop/cop/vibe/prefer_one_liner_expectation.rb +185 -0
- data/lib/rubocop/cop/vibe/service_call_method.rb +82 -0
- data/lib/rubocop/cop/vibe_cops.rb +15 -0
- data/lib/rubocop/vibe/plugin.rb +41 -0
- data/lib/rubocop/vibe/version.rb +7 -0
- data/lib/rubocop/vibe.rb +9 -0
- data/lib/rubocop-vibe.rb +9 -0
- metadata +131 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Vibe
|
|
6
|
+
# Enforces that inline rubocop:disable directive comments are not used.
|
|
7
|
+
#
|
|
8
|
+
# This cop encourages fixing issues rather than disabling cops on a
|
|
9
|
+
# per-line basis. If a cop needs to be disabled, it should be configured
|
|
10
|
+
# globally in `.rubocop.yml` with proper justification.
|
|
11
|
+
#
|
|
12
|
+
# @example Bad - inline disable directive on a line
|
|
13
|
+
# def method
|
|
14
|
+
# do_something # rubocop_disable Style/SomeCop (triggers offense)
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example Bad - block disable directive
|
|
18
|
+
# # rubocop_disable Style/SomeCop (triggers offense)
|
|
19
|
+
# def method
|
|
20
|
+
# do_something
|
|
21
|
+
# end
|
|
22
|
+
# # rubocop_enable Style/SomeCop
|
|
23
|
+
#
|
|
24
|
+
# @example Good - fix the issue instead
|
|
25
|
+
# def method
|
|
26
|
+
# do_something_correctly
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @example Good - configure globally in .rubocop.yml
|
|
30
|
+
# # Style/SomeCop:
|
|
31
|
+
# # Enabled: false
|
|
32
|
+
class NoRubocopDisable < Base
|
|
33
|
+
MSG = "Do not use `# rubocop:disable`. Fix the issue or configure globally in `.rubocop.yml`."
|
|
34
|
+
|
|
35
|
+
DISABLE_PATTERN = /\A#\s*rubocop\s*:\s*disable\b/i
|
|
36
|
+
|
|
37
|
+
# Check for rubocop:disable comments.
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
def on_new_investigation
|
|
41
|
+
processed_source.comments.each do |comment|
|
|
42
|
+
next unless disable_comment?(comment)
|
|
43
|
+
|
|
44
|
+
add_offense(comment)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Check if the comment is a rubocop:disable directive.
|
|
51
|
+
#
|
|
52
|
+
# @param [Parser::Source::Comment] comment The comment to check.
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def disable_comment?(comment)
|
|
55
|
+
DISABLE_PATTERN.match?(comment.text)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Vibe
|
|
6
|
+
# Enforces that tests are not skipped or marked as pending.
|
|
7
|
+
#
|
|
8
|
+
# This cop encourages completing tests rather than leaving them
|
|
9
|
+
# skipped or pending. If a test cannot be implemented, it should
|
|
10
|
+
# be deleted rather than left as a placeholder.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # bad - skip call
|
|
14
|
+
# it "does something" do
|
|
15
|
+
# skip "not implemented yet"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# # bad - pending call
|
|
19
|
+
# it "does something" do
|
|
20
|
+
# pending "waiting on feature"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # bad - xit (skipped it)
|
|
24
|
+
# xit "does something" do
|
|
25
|
+
# expect(true).to be true
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# # bad - xdescribe/xcontext
|
|
29
|
+
# xdescribe "MyClass" do
|
|
30
|
+
# it "works" do
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# # good - implement the test
|
|
35
|
+
# it "does something" do
|
|
36
|
+
# expect(result).to eq(expected)
|
|
37
|
+
# end
|
|
38
|
+
class NoSkippedTests < Base
|
|
39
|
+
include SpecFileHelper
|
|
40
|
+
|
|
41
|
+
MSG_SKIP = "Do not skip tests. Implement or delete the test."
|
|
42
|
+
MSG_PENDING = "Do not mark tests as pending. Implement or delete the test."
|
|
43
|
+
MSG_XMETHOD = "Do not use `%<method>s`. Implement or delete the test."
|
|
44
|
+
|
|
45
|
+
SKIP_METHODS = %i(skip).freeze
|
|
46
|
+
PENDING_METHODS = %i(pending).freeze
|
|
47
|
+
X_METHODS = %i(xit xspecify xexample xscenario xdescribe xcontext xfeature).freeze
|
|
48
|
+
|
|
49
|
+
# @!method skip_call?(node)
|
|
50
|
+
# Check if node is a skip call.
|
|
51
|
+
def_node_matcher :skip_call?, <<~PATTERN
|
|
52
|
+
(send nil? {:skip} ...)
|
|
53
|
+
PATTERN
|
|
54
|
+
|
|
55
|
+
# @!method pending_call?(node)
|
|
56
|
+
# Check if node is a pending call.
|
|
57
|
+
def_node_matcher :pending_call?, <<~PATTERN
|
|
58
|
+
(send nil? {:pending} ...)
|
|
59
|
+
PATTERN
|
|
60
|
+
|
|
61
|
+
# @!method x_method_call?(node)
|
|
62
|
+
# Check if node is an x-prefixed test method.
|
|
63
|
+
def_node_matcher :x_method_call?, <<~PATTERN
|
|
64
|
+
(send nil? ${:xit :xspecify :xexample :xscenario :xdescribe :xcontext :xfeature} ...)
|
|
65
|
+
PATTERN
|
|
66
|
+
|
|
67
|
+
# Check for skip calls in spec files.
|
|
68
|
+
#
|
|
69
|
+
# @param [RuboCop::AST::Node] node The send node.
|
|
70
|
+
# @return [void]
|
|
71
|
+
def on_send(node)
|
|
72
|
+
if spec_file?
|
|
73
|
+
check_skip(node)
|
|
74
|
+
check_pending(node)
|
|
75
|
+
check_x_method(node)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
alias on_csend on_send
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Check for skip method calls.
|
|
83
|
+
#
|
|
84
|
+
# @param [RuboCop::AST::Node] node The send node.
|
|
85
|
+
# @return [void]
|
|
86
|
+
def check_skip(node)
|
|
87
|
+
if skip_call?(node)
|
|
88
|
+
add_offense(node, message: MSG_SKIP)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check for pending method calls.
|
|
93
|
+
#
|
|
94
|
+
# @param [RuboCop::AST::Node] node The send node.
|
|
95
|
+
# @return [void]
|
|
96
|
+
def check_pending(node)
|
|
97
|
+
if pending_call?(node)
|
|
98
|
+
add_offense(node, message: MSG_PENDING)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Check for x-prefixed test method calls.
|
|
103
|
+
#
|
|
104
|
+
# @param [RuboCop::AST::Node] node The send node.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def check_x_method(node)
|
|
107
|
+
method_name = x_method_call?(node)
|
|
108
|
+
if method_name
|
|
109
|
+
add_offense(node, message: format(MSG_XMETHOD, method: method_name))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Vibe
|
|
6
|
+
# Enforces using positive `if` conditions instead of `unless` for guard clauses.
|
|
7
|
+
#
|
|
8
|
+
# Guard clauses with `unless` can be harder to read because they introduce
|
|
9
|
+
# double negatives. This cop enforces converting guard clauses from negative
|
|
10
|
+
# `unless` conditions to positive `if` conditions with the logic in a block.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # bad - unless guard clause with implicit nil return
|
|
14
|
+
# def unless_example
|
|
15
|
+
# return unless valid?
|
|
16
|
+
#
|
|
17
|
+
# 4
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# # good - positive if condition
|
|
21
|
+
# def unless_example_valid
|
|
22
|
+
# if valid?
|
|
23
|
+
# 4
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# # bad - unless guard clause with explicit return value
|
|
28
|
+
# def unless_example
|
|
29
|
+
# return 32 unless valid?
|
|
30
|
+
#
|
|
31
|
+
# 4
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# # good - positive if/else condition
|
|
35
|
+
# def unless_example_valid
|
|
36
|
+
# if valid?
|
|
37
|
+
# 4
|
|
38
|
+
# else
|
|
39
|
+
# 32
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# # good - regular if guard clauses are allowed
|
|
44
|
+
# def if_example
|
|
45
|
+
# return if valid?
|
|
46
|
+
#
|
|
47
|
+
# 4
|
|
48
|
+
# end
|
|
49
|
+
class NoUnlessGuardClause < Base
|
|
50
|
+
extend AutoCorrector
|
|
51
|
+
|
|
52
|
+
MSG = "Use positive `if` condition instead of `unless` for guard clauses."
|
|
53
|
+
|
|
54
|
+
# @!method unless_modifier?(node)
|
|
55
|
+
# Check if node is a statement with an unless modifier.
|
|
56
|
+
def_node_matcher :unless_modifier?, <<~PATTERN
|
|
57
|
+
(if $_ $_ nil?)
|
|
58
|
+
PATTERN
|
|
59
|
+
|
|
60
|
+
# Check if nodes for unless guard clauses.
|
|
61
|
+
#
|
|
62
|
+
# @param [RuboCop::AST::Node] node The if node.
|
|
63
|
+
# @return [void]
|
|
64
|
+
def on_if(node)
|
|
65
|
+
return unless node.unless? && node.modifier_form? && guard_clause?(node)
|
|
66
|
+
return if part_of_guard_clause_sequence?(node)
|
|
67
|
+
return if would_create_nested_conditional?(node)
|
|
68
|
+
|
|
69
|
+
add_offense(node) do |corrector|
|
|
70
|
+
autocorrect(corrector, node)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Check if the unless statement is a guard clause (early return).
|
|
77
|
+
#
|
|
78
|
+
# @param [RuboCop::AST::Node] node The if node.
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def guard_clause?(node)
|
|
81
|
+
if node.body.return_type?
|
|
82
|
+
parent = node.parent
|
|
83
|
+
if parent.begin_type?
|
|
84
|
+
following_statements?(parent, node)
|
|
85
|
+
else
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
else
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Check if there are statements after the node in the parent block.
|
|
94
|
+
#
|
|
95
|
+
# @param [RuboCop::AST::Node] parent The parent begin block.
|
|
96
|
+
# @param [RuboCop::AST::Node] node The current node.
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
def following_statements?(parent, node)
|
|
99
|
+
siblings = parent.children
|
|
100
|
+
node_index = siblings.index(node)
|
|
101
|
+
siblings[(node_index + 1)..].any?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if this unless guard clause is part of a sequence of guard clauses.
|
|
105
|
+
# When there are multiple guard clauses together, they should stay at the same level.
|
|
106
|
+
#
|
|
107
|
+
# @param [RuboCop::AST::Node] node The if node.
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def part_of_guard_clause_sequence?(node)
|
|
110
|
+
siblings = node.parent.children
|
|
111
|
+
node_index = siblings.index(node)
|
|
112
|
+
|
|
113
|
+
# Check for other guard clauses before this one.
|
|
114
|
+
preceding_guards = siblings[0...node_index].any? { |sibling| any_guard_clause?(sibling) }
|
|
115
|
+
|
|
116
|
+
# Check for other guard clauses after this one.
|
|
117
|
+
following_guards = siblings[(node_index + 1)..].any? { |sibling| any_guard_clause?(sibling) }
|
|
118
|
+
|
|
119
|
+
preceding_guards || following_guards
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if a node is any type of guard clause (if or unless).
|
|
123
|
+
#
|
|
124
|
+
# @param [RuboCop::AST::Node] node The node to check.
|
|
125
|
+
# @return [Boolean]
|
|
126
|
+
def any_guard_clause?(node)
|
|
127
|
+
node.if_type? && node.modifier_form? && node.body.return_type?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if transforming this guard clause would create nested conditionals.
|
|
131
|
+
# If the remaining code is a conditional, we'd nest it inside our new if block.
|
|
132
|
+
#
|
|
133
|
+
# Note: This is only called after guard_clause? returns true, which means
|
|
134
|
+
# there are statements after this node, so remaining will never be empty.
|
|
135
|
+
#
|
|
136
|
+
# @param [RuboCop::AST::Node] node The if node.
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def would_create_nested_conditional?(node)
|
|
139
|
+
siblings = node.parent.children
|
|
140
|
+
node_index = siblings.index(node)
|
|
141
|
+
first_statement = siblings[node_index + 1]
|
|
142
|
+
|
|
143
|
+
first_statement.type?(:if, :case)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Autocorrect the offense by converting to positive if condition.
|
|
147
|
+
#
|
|
148
|
+
# @param [RuboCop::Cop::Corrector] corrector The corrector.
|
|
149
|
+
# @param [RuboCop::AST::Node] node The if node.
|
|
150
|
+
# @return [void]
|
|
151
|
+
def autocorrect(corrector, node)
|
|
152
|
+
replacement = build_replacement(node)
|
|
153
|
+
range = replacement_range(node)
|
|
154
|
+
corrector.replace(range, replacement)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Build the replacement code for the guard clause.
|
|
158
|
+
#
|
|
159
|
+
# @param [RuboCop::AST::Node] node The if node.
|
|
160
|
+
# @return [String] The replacement code.
|
|
161
|
+
def build_replacement(node)
|
|
162
|
+
condition = node.condition.source
|
|
163
|
+
return_value = extract_return_value(node.body)
|
|
164
|
+
base_indent = " " * node.loc.column
|
|
165
|
+
inner_indent = "#{base_indent} "
|
|
166
|
+
remaining_code = get_remaining_code(node)
|
|
167
|
+
|
|
168
|
+
if return_value
|
|
169
|
+
build_if_else_replacement(condition, remaining_code, return_value, base_indent, inner_indent)
|
|
170
|
+
else
|
|
171
|
+
build_if_replacement(condition, remaining_code, base_indent, inner_indent)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Build if/else replacement code.
|
|
176
|
+
#
|
|
177
|
+
# @param condition [String] The condition expression.
|
|
178
|
+
# @param remaining_code [String] The remaining code to execute.
|
|
179
|
+
# @param return_value [String] The return value for the else branch.
|
|
180
|
+
# @param base_indent [String] The base indentation level.
|
|
181
|
+
# @param inner_indent [String] The inner indentation level.
|
|
182
|
+
# @return [String]
|
|
183
|
+
def build_if_else_replacement(condition, remaining_code, return_value, base_indent, inner_indent)
|
|
184
|
+
[
|
|
185
|
+
"if #{condition}",
|
|
186
|
+
indent_lines(remaining_code, inner_indent),
|
|
187
|
+
"#{base_indent}else",
|
|
188
|
+
indent_lines(return_value, inner_indent),
|
|
189
|
+
"#{base_indent}end"
|
|
190
|
+
].join("\n")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Build if replacement code.
|
|
194
|
+
#
|
|
195
|
+
# @param condition [String] The condition expression.
|
|
196
|
+
# @param remaining_code [String] The remaining code to execute.
|
|
197
|
+
# @param base_indent [String] The base indentation level.
|
|
198
|
+
# @param inner_indent [String] The inner indentation level.
|
|
199
|
+
# @return [String]
|
|
200
|
+
def build_if_replacement(condition, remaining_code, base_indent, inner_indent)
|
|
201
|
+
[
|
|
202
|
+
"if #{condition}",
|
|
203
|
+
indent_lines(remaining_code, inner_indent),
|
|
204
|
+
"#{base_indent}end"
|
|
205
|
+
].join("\n")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Get the code remaining after the guard clause.
|
|
209
|
+
#
|
|
210
|
+
# @param [RuboCop::AST::Node] node The if node.
|
|
211
|
+
# @return [String] The remaining code.
|
|
212
|
+
def get_remaining_code(node)
|
|
213
|
+
siblings = node.parent.children
|
|
214
|
+
node_index = siblings.index(node)
|
|
215
|
+
siblings[(node_index + 1)..].map(&:source).join("\n")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Get the replacement range.
|
|
219
|
+
#
|
|
220
|
+
# @param [RuboCop::AST::Node] node The if node.
|
|
221
|
+
# @return [Parser::Source::Range] The range to replace.
|
|
222
|
+
def replacement_range(node)
|
|
223
|
+
siblings = node.parent.children
|
|
224
|
+
start_pos = node.source_range.begin_pos
|
|
225
|
+
end_pos = siblings.last.source_range.end_pos
|
|
226
|
+
Parser::Source::Range.new(node.source_range.source_buffer, start_pos, end_pos)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Extract the return value from a return node.
|
|
230
|
+
#
|
|
231
|
+
# @param [RuboCop::AST::Node] node The return node.
|
|
232
|
+
# @return [String, nil] The return value source or nil if no value.
|
|
233
|
+
def extract_return_value(node)
|
|
234
|
+
if node.children.any?
|
|
235
|
+
node.children.first.source
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Indent each line of code with the specified indentation.
|
|
240
|
+
#
|
|
241
|
+
# @param [String] code The code to indent.
|
|
242
|
+
# @param [String] indentation The indentation string.
|
|
243
|
+
# @return [String] The indented code.
|
|
244
|
+
def indent_lines(code, indentation)
|
|
245
|
+
code.lines.map { |line| "#{indentation}#{line}" }.join.chomp
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Vibe
|
|
6
|
+
# Enforces one-liner syntax for simple RSpec expectations with subject.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# # bad
|
|
10
|
+
# it "responds with ok" do
|
|
11
|
+
# expect(subject).to respond_with(:ok)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# # good
|
|
15
|
+
# it { is_expected.to respond_with(:ok) }
|
|
16
|
+
#
|
|
17
|
+
# # good - descriptions allowed for non-subject expectations
|
|
18
|
+
# it "inherits from base class" do
|
|
19
|
+
# expect(described_class.superclass).to eq(BaseClass)
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# # good - multi-line allowed for change expectations
|
|
23
|
+
# it "creates a record" do
|
|
24
|
+
# expect { subject }.to change(Resource, :count).by(1)
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# # good - multi-line allowed for setup
|
|
28
|
+
# it "processes the record" do
|
|
29
|
+
# record.process
|
|
30
|
+
#
|
|
31
|
+
# expect(record).to be_processed
|
|
32
|
+
# end
|
|
33
|
+
class PreferOneLinerExpectation < Base
|
|
34
|
+
extend AutoCorrector
|
|
35
|
+
include SpecFileHelper
|
|
36
|
+
|
|
37
|
+
MSG = "Use one-liner `it { is_expected.to }` syntax for simple expectations."
|
|
38
|
+
|
|
39
|
+
# @!method example_block_with_description?(node)
|
|
40
|
+
# Check if block is an example block (it, specify) with a description.
|
|
41
|
+
def_node_matcher :example_block_with_description?, <<~PATTERN
|
|
42
|
+
(block (send nil? {:it :specify} _ ...) ...)
|
|
43
|
+
PATTERN
|
|
44
|
+
|
|
45
|
+
# @!method expectation_method?(node)
|
|
46
|
+
# Check if node is an expect or is_expected call.
|
|
47
|
+
def_node_matcher :expectation_method?, <<~PATTERN
|
|
48
|
+
(send nil? {:expect :is_expected} ...)
|
|
49
|
+
PATTERN
|
|
50
|
+
|
|
51
|
+
# Check block nodes for multi-line it blocks with simple expectations.
|
|
52
|
+
#
|
|
53
|
+
# @param [RuboCop::AST::Node] node The block node.
|
|
54
|
+
# @return [void]
|
|
55
|
+
def on_block(node)
|
|
56
|
+
return unless processable_block?(node)
|
|
57
|
+
return unless single_statement?(node.body)
|
|
58
|
+
|
|
59
|
+
expectation = extract_expectation(node.body)
|
|
60
|
+
return unless simple_expectation?(expectation)
|
|
61
|
+
return if complex_expectation?(node)
|
|
62
|
+
|
|
63
|
+
add_offense(node.send_node) do |corrector|
|
|
64
|
+
autocorrect(corrector, node)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
alias on_numblock on_block
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Check if the expectation is too complex for one-liner conversion.
|
|
72
|
+
#
|
|
73
|
+
# @param [RuboCop::AST::Node] node The block node.
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def complex_expectation?(node)
|
|
76
|
+
expectation_source = node.body.source
|
|
77
|
+
|
|
78
|
+
# Multi-line expectations are complex.
|
|
79
|
+
return true if expectation_source.include?("\n")
|
|
80
|
+
|
|
81
|
+
# Expectations with compound matchers (.and, .or) are complex.
|
|
82
|
+
compound_matcher?(node.body)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Check if the expectation uses compound matchers.
|
|
86
|
+
#
|
|
87
|
+
# @param [RuboCop::AST::Node] node The expectation node.
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def compound_matcher?(node)
|
|
90
|
+
node.each_descendant(:send).any? do |send_node|
|
|
91
|
+
send_node.method?(:and) || send_node.method?(:or)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Autocorrect the offense by converting to one-liner syntax.
|
|
96
|
+
#
|
|
97
|
+
# @param [RuboCop::Cop::Corrector] corrector The corrector.
|
|
98
|
+
# @param [RuboCop::AST::Node] node The block node.
|
|
99
|
+
# @return [void]
|
|
100
|
+
def autocorrect(corrector, node)
|
|
101
|
+
expectation_source = node.body.source
|
|
102
|
+
corrector.replace(node, "it { #{expectation_source} }")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Check if the block should be processed.
|
|
106
|
+
#
|
|
107
|
+
# @param [RuboCop::AST::Node] node The block node.
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
def processable_block?(node)
|
|
110
|
+
spec_file? && example_block_with_description?(node)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Check if the body contains only a single statement.
|
|
114
|
+
#
|
|
115
|
+
# @param [RuboCop::AST::Node] body The block body.
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
def single_statement?(body)
|
|
118
|
+
if body
|
|
119
|
+
!body.begin_type?
|
|
120
|
+
else
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Extract the expectation node from the body.
|
|
126
|
+
#
|
|
127
|
+
# @param [RuboCop::AST::Node] body The block body.
|
|
128
|
+
# @return [RuboCop::AST::Node]
|
|
129
|
+
# @return [nil] When no expectation is found.
|
|
130
|
+
def extract_expectation(body)
|
|
131
|
+
if body.send_type?
|
|
132
|
+
find_expectation_in_chain(body)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Find expectation node in method chain.
|
|
137
|
+
#
|
|
138
|
+
# @param [RuboCop::AST::Node] node The node to search.
|
|
139
|
+
# @return [RuboCop::AST::Node]
|
|
140
|
+
# @return [nil] When no expectation is found.
|
|
141
|
+
def find_expectation_in_chain(node)
|
|
142
|
+
# Traverse up the chain looking for expect or is_expected.
|
|
143
|
+
current = node
|
|
144
|
+
while current&.send_type?
|
|
145
|
+
return current if expectation_method?(current)
|
|
146
|
+
|
|
147
|
+
current = current.receiver
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check if the expectation is simple (not a block expectation).
|
|
154
|
+
#
|
|
155
|
+
# Simple expectations use expect(subject).
|
|
156
|
+
# Block expectations use expect { ... } and are not simple.
|
|
157
|
+
# is_expected is handled by IsExpectedOneLiner cop.
|
|
158
|
+
# expect with non-subject receivers should keep descriptions.
|
|
159
|
+
#
|
|
160
|
+
# @param [RuboCop::AST::Node] node The expectation node.
|
|
161
|
+
# @return [Boolean] True if simple expectation with subject, false otherwise.
|
|
162
|
+
def simple_expectation?(node)
|
|
163
|
+
return false unless node
|
|
164
|
+
return false unless node.method?(:expect) && !node.block_node
|
|
165
|
+
|
|
166
|
+
# Only enforce one-liners for expect(subject).
|
|
167
|
+
# Other receivers (user.email, described_class, etc.) should keep descriptions.
|
|
168
|
+
expect_subject?(node)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Check if the expectation is using subject as the receiver.
|
|
172
|
+
#
|
|
173
|
+
# @param [RuboCop::AST::Node] node The expect node.
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def expect_subject?(node)
|
|
176
|
+
argument = node.first_argument
|
|
177
|
+
return false unless argument
|
|
178
|
+
return false unless argument.send_type?
|
|
179
|
+
|
|
180
|
+
argument.method?(:subject)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Vibe
|
|
6
|
+
# Enforces that service objects define both `self.call` and `call` methods.
|
|
7
|
+
#
|
|
8
|
+
# Service objects should provide a consistent public interface through
|
|
9
|
+
# a `self.call` class method that delegates to an instance `call` method.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# # bad - missing both methods
|
|
13
|
+
# class MyService
|
|
14
|
+
# def perform
|
|
15
|
+
# # ...
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# # bad - missing instance call
|
|
20
|
+
# class MyService
|
|
21
|
+
# def self.call(arg)
|
|
22
|
+
# # ...
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # bad - missing self.call
|
|
27
|
+
# class MyService
|
|
28
|
+
# def call
|
|
29
|
+
# # ...
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# # good
|
|
34
|
+
# class MyService
|
|
35
|
+
# def self.call(arg)
|
|
36
|
+
# new(arg).call
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# def call
|
|
40
|
+
# # implementation
|
|
41
|
+
# end
|
|
42
|
+
# end
|
|
43
|
+
class ServiceCallMethod < Base
|
|
44
|
+
MSG = "Service objects should define `self.call` and `call` methods."
|
|
45
|
+
|
|
46
|
+
SERVICE_FILE_PATTERN = %r{app/services/.*\.rb\z}
|
|
47
|
+
|
|
48
|
+
# @!method class_call_method?(node)
|
|
49
|
+
# Check if node is a self.call class method definition.
|
|
50
|
+
def_node_search :class_call_method?, <<~PATTERN
|
|
51
|
+
(defs _ :call ...)
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
# @!method instance_call_method?(node)
|
|
55
|
+
# Check if node is a call instance method definition.
|
|
56
|
+
def_node_search :instance_call_method?, <<~PATTERN
|
|
57
|
+
(def :call ...)
|
|
58
|
+
PATTERN
|
|
59
|
+
|
|
60
|
+
# Check class definitions for missing call methods.
|
|
61
|
+
#
|
|
62
|
+
# @param [RuboCop::AST::Node] node The class node.
|
|
63
|
+
# @return [void]
|
|
64
|
+
def on_class(node)
|
|
65
|
+
return unless service_file?
|
|
66
|
+
return if class_call_method?(node) && instance_call_method?(node)
|
|
67
|
+
|
|
68
|
+
add_offense(node.loc.name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Check if current file is a service file.
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def service_file?
|
|
77
|
+
processed_source.file_path.match?(SERVICE_FILE_PATTERN)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "vibe/mixin/spec_file_helper"
|
|
4
|
+
|
|
5
|
+
require_relative "vibe/blank_line_before_expectation"
|
|
6
|
+
require_relative "vibe/class_organization"
|
|
7
|
+
require_relative "vibe/consecutive_assignment_alignment"
|
|
8
|
+
require_relative "vibe/describe_block_order"
|
|
9
|
+
require_relative "vibe/is_expected_one_liner"
|
|
10
|
+
require_relative "vibe/no_assigns_attribute_testing"
|
|
11
|
+
require_relative "vibe/no_rubocop_disable"
|
|
12
|
+
require_relative "vibe/no_skipped_tests"
|
|
13
|
+
require_relative "vibe/no_unless_guard_clause"
|
|
14
|
+
require_relative "vibe/prefer_one_liner_expectation"
|
|
15
|
+
require_relative "vibe/service_call_method"
|