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.
@@ -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"