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,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vibe
6
+ # Enforces alignment of consecutive variable assignments at the `=` operator.
7
+ #
8
+ # Consecutive assignments (with no blank lines between) should align their
9
+ # `=` operators for better readability. Groups are broken by blank lines.
10
+ #
11
+ # @example
12
+ # # bad
13
+ # user = create(:user)
14
+ # character = create(:character)
15
+ # input = "test"
16
+ #
17
+ # # good
18
+ # user = create(:user)
19
+ # character = create(:character)
20
+ # input = "test"
21
+ #
22
+ # # good - blank line breaks the group
23
+ # user = create(:user)
24
+ # character = create(:character)
25
+ #
26
+ # service = Users::Activate.new # Separate group, not aligned
27
+ class ConsecutiveAssignmentAlignment < Base
28
+ extend AutoCorrector
29
+
30
+ MSG = "Align consecutive assignments at the = operator."
31
+
32
+ # Check block nodes for assignment alignment.
33
+ #
34
+ # @param [RuboCop::AST::Node] node The block node.
35
+ # @return [void]
36
+ def on_block(node)
37
+ if node.body
38
+ check_assignments_in_body(node.body)
39
+ end
40
+ end
41
+ alias on_numblock on_block
42
+
43
+ # Check method definitions for assignment alignment.
44
+ #
45
+ # @param [RuboCop::AST::Node] node The def node.
46
+ # @return [void]
47
+ def on_def(node)
48
+ if node.body
49
+ check_assignments_in_body(node.body)
50
+ end
51
+ end
52
+ alias on_defs on_def
53
+
54
+ private
55
+
56
+ # Check assignments in a body node.
57
+ #
58
+ # @param [RuboCop::AST::Node] body The body node.
59
+ # @return [void]
60
+ def check_assignments_in_body(body)
61
+ statements = extract_statements(body)
62
+ return if statements.size < 2
63
+
64
+ groups = group_consecutive_assignments(statements)
65
+ groups.each { |group| check_group_alignment(group) }
66
+ end
67
+
68
+ # Extract statements from a body node.
69
+ #
70
+ # @param [RuboCop::AST::Node] body The body node.
71
+ # @return [Array<RuboCop::AST::Node>]
72
+ def extract_statements(body)
73
+ if body.begin_type?
74
+ body.children
75
+ else
76
+ [body]
77
+ end
78
+ end
79
+
80
+ # Group consecutive assignments together.
81
+ #
82
+ # @param [Array<RuboCop::AST::Node>] statements The statements.
83
+ # @return [Array<Array<RuboCop::AST::Node>>] Groups of consecutive assignments.
84
+ def group_consecutive_assignments(statements)
85
+ groups = []
86
+ current_group = []
87
+ previous_line = nil
88
+
89
+ statements.each do |statement|
90
+ current_group, previous_line = process_statement(statement, current_group, previous_line, groups)
91
+ end
92
+
93
+ finalize_groups(groups, current_group)
94
+ end
95
+
96
+ # Process a single statement for grouping.
97
+ #
98
+ # @param [RuboCop::AST::Node] statement The statement.
99
+ # @param [Array<RuboCop::AST::Node>] current_group The current group.
100
+ # @param [Integer, nil] previous_line The previous line number.
101
+ # @param [Array<Array<RuboCop::AST::Node>>] groups The groups.
102
+ # @return [Array] The updated current_group and previous_line.
103
+ def process_statement(statement, current_group, previous_line, groups)
104
+ if local_variable_assignment?(statement)
105
+ current_group = handle_assignment(statement, current_group, previous_line, groups)
106
+ else
107
+ save_group_if_valid(groups, current_group)
108
+ current_group = []
109
+ end
110
+ [current_group, statement.loc.last_line]
111
+ end
112
+
113
+ # Handle an assignment statement.
114
+ #
115
+ # @param [RuboCop::AST::Node] statement The assignment statement.
116
+ # @param [Array<RuboCop::AST::Node>] current_group The current group.
117
+ # @param [Integer, nil] previous_line The previous line number.
118
+ # @param [Array<Array<RuboCop::AST::Node>>] groups The groups.
119
+ # @return [Array<RuboCop::AST::Node>] The updated current group.
120
+ def handle_assignment(statement, current_group, previous_line, groups)
121
+ if previous_line && statement.loc.line - previous_line > 1
122
+ save_group_if_valid(groups, current_group)
123
+ current_group = []
124
+ end
125
+ current_group << statement
126
+ current_group
127
+ end
128
+
129
+ # Save group if it has multiple assignments.
130
+ #
131
+ # @param [Array<Array<RuboCop::AST::Node>>] groups The groups.
132
+ # @param [Array<RuboCop::AST::Node>] group The group to potentially save.
133
+ # @return [void]
134
+ def save_group_if_valid(groups, group)
135
+ groups << group if group.size > 1
136
+ end
137
+
138
+ # Finalize groups by adding any remaining valid group.
139
+ #
140
+ # @param [Array<Array<RuboCop::AST::Node>>] groups The groups.
141
+ # @param [Array<RuboCop::AST::Node>] current_group The current group.
142
+ # @return [Array<Array<RuboCop::AST::Node>>] The finalized groups.
143
+ def finalize_groups(groups, current_group)
144
+ save_group_if_valid(groups, current_group)
145
+ groups
146
+ end
147
+
148
+ # Check if node is a local variable assignment.
149
+ #
150
+ # @param [RuboCop::AST::Node] node The node.
151
+ # @return [Boolean]
152
+ def local_variable_assignment?(node)
153
+ node.lvasgn_type?
154
+ end
155
+
156
+ # Check alignment for a group of assignments.
157
+ #
158
+ # @param [Array<RuboCop::AST::Node>] group The assignment group.
159
+ # @return [void]
160
+ def check_group_alignment(group)
161
+ columns = group.map { |asgn| asgn.loc.operator.column }
162
+ target_column = columns.max
163
+
164
+ group.each do |asgn|
165
+ current_column = asgn.loc.operator.column
166
+ next if current_column == target_column
167
+
168
+ add_offense(asgn.loc.name) do |corrector|
169
+ autocorrect_alignment(corrector, asgn, target_column)
170
+ end
171
+ end
172
+ end
173
+
174
+ # Auto-correct the alignment of an assignment.
175
+ #
176
+ # @param [RuboCop::AST::Corrector] corrector The corrector.
177
+ # @param [RuboCop::AST::Node] asgn The assignment node.
178
+ # @param [Integer] target_column The target column for alignment.
179
+ # @return [void]
180
+ def autocorrect_alignment(corrector, asgn, target_column)
181
+ variable_name_end = asgn.loc.name.end_pos
182
+ operator_start = asgn.loc.operator.begin_pos
183
+ total_spaces = calculate_total_spaces(asgn, target_column)
184
+
185
+ corrector.replace(
186
+ range_between(variable_name_end, operator_start),
187
+ " " * total_spaces
188
+ )
189
+ end
190
+
191
+ # Calculate total spaces needed for alignment.
192
+ #
193
+ # @param [RuboCop::AST::Node] asgn The assignment node.
194
+ # @param [Integer] target_column The target column for alignment.
195
+ # @return [Integer] The number of spaces (minimum 1).
196
+ def calculate_total_spaces(asgn, target_column)
197
+ current_column = asgn.loc.operator.column
198
+ current_spaces = asgn.loc.operator.begin_pos - asgn.loc.name.end_pos
199
+ spaces_needed = target_column - current_column
200
+
201
+ [1, current_spaces + spaces_needed].max
202
+ end
203
+
204
+ # Create a source range between two positions.
205
+ #
206
+ # @param [Integer] start_pos The start position.
207
+ # @param [Integer] end_pos The end position.
208
+ # @return [Parser::Source::Range]
209
+ def range_between(start_pos, end_pos)
210
+ Parser::Source::Range.new(
211
+ processed_source.buffer,
212
+ start_pos,
213
+ end_pos
214
+ )
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vibe
6
+ # Enforces consistent ordering of describe blocks in RSpec files.
7
+ #
8
+ # Universal order: "class" → "constants" → ".class_method" → "#instance_method"
9
+ # Models add: "associations" and "validations" between "constants" and methods.
10
+ # Controllers add: RESTful actions between "constants" and methods.
11
+ #
12
+ # @example
13
+ # # bad
14
+ # describe User do
15
+ # describe "#name" do
16
+ # end
17
+ #
18
+ # describe "class" do
19
+ # end
20
+ # end
21
+ #
22
+ # # good (universal)
23
+ # describe Service do
24
+ # describe "class" do
25
+ # end
26
+ #
27
+ # describe ".call" do
28
+ # end
29
+ #
30
+ # describe "#process" do
31
+ # end
32
+ # end
33
+ #
34
+ # # good (model with custom sections)
35
+ # describe User do
36
+ # describe "class" do
37
+ # end
38
+ #
39
+ # describe "associations" do
40
+ # end
41
+ #
42
+ # describe "validations" do
43
+ # end
44
+ #
45
+ # describe ".find_active" do
46
+ # end
47
+ #
48
+ # describe "#name" do
49
+ # end
50
+ # end
51
+ class DescribeBlockOrder < Base
52
+ extend AutoCorrector
53
+ include SpecFileHelper
54
+
55
+ MSG = "Describe blocks should be ordered: class → constants → .class_method → #instance_method."
56
+
57
+ # Priority for non-special, non-method descriptions (e.g., "callbacks", "scopes")
58
+ NON_SPECIAL_DESCRIPTION_PRIORITY = 300
59
+ # Default priority for descriptions that can't be categorized (e.g., constants, variables)
60
+ DEFAULT_PRIORITY = 999
61
+
62
+ MODEL_ORDER = %w(class associations validations).freeze
63
+ CONTROLLER_ACTIONS = %w(index show new create edit update destroy).freeze
64
+ SPECIAL_SECTIONS = {
65
+ "class" => 0,
66
+ "constants" => 5,
67
+ "associations" => 10,
68
+ "validations" => 20
69
+ }.freeze
70
+
71
+ # @!method describe_block?(node)
72
+ # Check if node is a describe block (matches both `describe` and `RSpec.describe`).
73
+ def_node_matcher :describe_block?, <<~PATTERN
74
+ (block (send _ :describe ...) ...)
75
+ PATTERN
76
+
77
+ # Check block nodes for describe calls.
78
+ #
79
+ # @param [RuboCop::AST::Node] node The block node.
80
+ # @return [void]
81
+ def on_block(node)
82
+ return unless spec_file?
83
+ return unless top_level_describe?(node)
84
+
85
+ describe_blocks = extract_describe_blocks(node)
86
+ return if describe_blocks.size < 2
87
+
88
+ violations = find_ordering_violations(describe_blocks)
89
+ violations.each do |block_info|
90
+ add_offense(block_info[:node]) do |corrector|
91
+ autocorrect(corrector, describe_blocks)
92
+ end
93
+ end
94
+ end
95
+ alias on_numblock on_block
96
+
97
+ private
98
+
99
+ # Check if this is a top-level describe block.
100
+ #
101
+ # @param [RuboCop::AST::Node] node The block node.
102
+ # @return [Boolean]
103
+ def top_level_describe?(node)
104
+ describe_block?(node) &&
105
+ node.each_ancestor(:block).none? { |ancestor| describe_block?(ancestor) }
106
+ end
107
+
108
+ # Extract second-level describe blocks.
109
+ #
110
+ # @param [RuboCop::AST::Node] top_node The top-level describe block.
111
+ # @return [Array<Hash>] Array of describe block info.
112
+ def extract_describe_blocks(top_node)
113
+ if top_node.body
114
+ block_nodes(top_node).map.with_index { |node, index| build_block_info(node, index) }
115
+ else
116
+ []
117
+ end
118
+ end
119
+
120
+ # Get block nodes from the top-level describe.
121
+ #
122
+ # @param [RuboCop::AST::Node] top_node The top-level describe block.
123
+ # @return [Array<RuboCop::AST::Node>]
124
+ def block_nodes(top_node)
125
+ children = top_node.body.begin_type? ? top_node.body.children : [top_node.body]
126
+
127
+ children.select { |child| child.block_type? && child.method?(:describe) }
128
+ end
129
+
130
+ # Build block information hash.
131
+ #
132
+ # @param [RuboCop::AST::Node] node The block node.
133
+ # @param [Integer] index The original index.
134
+ # @return [Hash] Block information.
135
+ def build_block_info(node, index)
136
+ description = extract_description(node)
137
+
138
+ {
139
+ node: node,
140
+ original_index: index,
141
+ description: description,
142
+ priority: categorize_description(description)
143
+ }
144
+ end
145
+
146
+ # Extract description string from describe block.
147
+ #
148
+ # Only string and symbol literals are supported for ordering.
149
+ # Constants and variables return nil and will be assigned DEFAULT_PRIORITY.
150
+ #
151
+ # @param [RuboCop::AST::Node] node The describe block node.
152
+ # @return [String] The description string from a string or symbol literal.
153
+ # @return [nil] When description is not a string/symbol literal.
154
+ def extract_description(node)
155
+ first_arg = node.send_node.first_argument
156
+ return unless first_arg
157
+
158
+ if first_arg.str_type?
159
+ first_arg.value
160
+ elsif first_arg.sym_type?
161
+ first_arg.value.to_s
162
+ # Intentionally returns nil for constants/variables.
163
+ # These will get DEFAULT_PRIORITY.
164
+ end
165
+ end
166
+
167
+ # Categorize description and assign priority.
168
+ #
169
+ # Universal order: class (0) → constants (5) → .class_method (100) → #instance_method (200)
170
+ # Models insert: associations (10), validations (20) between class and methods.
171
+ # Controllers insert: RESTful actions (30-36) between class and methods.
172
+ # Non-special descriptions get NON_SPECIAL_DESCRIPTION_PRIORITY (300).
173
+ # nil descriptions get DEFAULT_PRIORITY (999).
174
+ #
175
+ # @param [String, nil] description The describe block description.
176
+ # @return [Integer] Priority number (lower = earlier).
177
+ def categorize_description(description)
178
+ if description
179
+ special_section_priority(description) ||
180
+ controller_action_priority(description) ||
181
+ method_priority(description) ||
182
+ NON_SPECIAL_DESCRIPTION_PRIORITY
183
+ else
184
+ DEFAULT_PRIORITY
185
+ end
186
+ end
187
+
188
+ # Get priority for special sections.
189
+ #
190
+ # @param [String] description The describe block description.
191
+ # @return [Integer]
192
+ # @return [nil] When not a special section.
193
+ def special_section_priority(description)
194
+ SPECIAL_SECTIONS[description]
195
+ end
196
+
197
+ # Get priority for controller actions.
198
+ #
199
+ # @param [String] description The describe block description.
200
+ # @return [Integer]
201
+ # @return [nil] When not a controller action.
202
+ def controller_action_priority(description)
203
+ # Strip the # prefix for controller actions.
204
+ action_name = description.start_with?("#") ? description[1..] : description
205
+ if controller_action?(action_name)
206
+ 30 + CONTROLLER_ACTIONS.index(action_name)
207
+ end
208
+ end
209
+
210
+ # Get priority for method descriptions.
211
+ #
212
+ # @param [String] description The describe block description.
213
+ # @return [Integer]
214
+ # @return [nil] When not a method description.
215
+ def method_priority(description)
216
+ return 100 if description.start_with?(".")
217
+ return 200 if description.start_with?("#")
218
+
219
+ nil
220
+ end
221
+
222
+ # Check if description is a controller action.
223
+ #
224
+ # @param [String] description The describe block description.
225
+ # @return [Boolean]
226
+ def controller_action?(description)
227
+ CONTROLLER_ACTIONS.include?(description)
228
+ end
229
+
230
+ # Find describe blocks that are out of order.
231
+ #
232
+ # @param [Array<Hash>] blocks The list of describe blocks.
233
+ # @return [Array<Hash>] Blocks that violate ordering.
234
+ def find_ordering_violations(blocks)
235
+ violations = []
236
+
237
+ blocks.each_cons(2) do |current, following|
238
+ violations << following if current[:priority] > following[:priority]
239
+ end
240
+
241
+ violations.uniq
242
+ end
243
+
244
+ # Auto-correct by reordering describe blocks.
245
+ #
246
+ # @param [RuboCop::AST::Corrector] corrector The corrector.
247
+ # @param [Array<Hash>] blocks The list of describe blocks.
248
+ # @return [void]
249
+ def autocorrect(corrector, blocks)
250
+ sorted_blocks = blocks.sort_by { |b| [b[:priority], b[:original_index]] }
251
+
252
+ blocks.each_with_index do |block, index|
253
+ sorted_block = sorted_blocks[index]
254
+ next if block == sorted_block
255
+
256
+ corrector.replace(block[:node], sorted_block[:node].source)
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vibe
6
+ # Enforces that `is_expected` is only used in one-liner `it { }` blocks.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # it "returns true" do
11
+ # is_expected.to be(true)
12
+ # end
13
+ #
14
+ # # good
15
+ # it { is_expected.to be(true) }
16
+ #
17
+ # # good - expect with description is allowed
18
+ # it "returns the user" do
19
+ # expect(result).to eq(user)
20
+ # end
21
+ class IsExpectedOneLiner < Base
22
+ extend AutoCorrector
23
+ include SpecFileHelper
24
+
25
+ MSG = "Use one-liner `it { is_expected.to ... }` syntax when using `is_expected`."
26
+
27
+ # @!method is_expected_call?(node)
28
+ # Check if node is an is_expected call.
29
+ def_node_matcher :is_expected_call?, <<~PATTERN
30
+ (send nil? :is_expected)
31
+ PATTERN
32
+
33
+ # @!method example_block_with_description?(node)
34
+ # Check if block is an example block with a description.
35
+ def_node_matcher :example_block_with_description?, <<~PATTERN
36
+ (block (send nil? {:it :specify} (str _) ...) ...)
37
+ PATTERN
38
+
39
+ # Check send nodes for is_expected calls inside described blocks.
40
+ #
41
+ # @param [RuboCop::AST::Node] node The send node.
42
+ # @return [void]
43
+ def on_send(node)
44
+ return unless spec_file?
45
+ return unless is_expected_call?(node)
46
+
47
+ example_block = find_example_block(node)
48
+ return unless example_block
49
+ return unless example_block_with_description?(example_block)
50
+ return if complex_expectation?(example_block)
51
+
52
+ add_offense(example_block.send_node) do |corrector|
53
+ autocorrect(corrector, example_block)
54
+ end
55
+ end
56
+ alias on_csend on_send
57
+
58
+ private
59
+
60
+ # Find the enclosing example block for a node.
61
+ #
62
+ # @param [RuboCop::AST::Node] node The node to search from.
63
+ # @return [RuboCop::AST::Node]
64
+ # @return [nil] When no example block is found.
65
+ def find_example_block(node)
66
+ node.each_ancestor(:block).find do |ancestor|
67
+ send_node = ancestor.send_node
68
+ send_node.method?(:it) || send_node.method?(:specify)
69
+ end
70
+ end
71
+
72
+ # Check if the expectation is too complex for one-liner conversion.
73
+ #
74
+ # @param [RuboCop::AST::Node] node The block node.
75
+ # @return [Boolean]
76
+ def complex_expectation?(node)
77
+ expectation_source = node.body.source
78
+
79
+ # Multi-line expectations are complex.
80
+ return true if expectation_source.include?("\n")
81
+
82
+ # Expectations with compound matchers (.and, .or) are complex.
83
+ compound_matcher?(node.body)
84
+ end
85
+
86
+ # Check if the expectation uses compound matchers.
87
+ #
88
+ # @param [RuboCop::AST::Node] node The expectation node.
89
+ # @return [Boolean]
90
+ def compound_matcher?(node)
91
+ node.each_descendant(:send).any? do |send_node|
92
+ send_node.method?(:and) || send_node.method?(:or)
93
+ end
94
+ end
95
+
96
+ # Autocorrect the offense by converting to one-liner syntax.
97
+ #
98
+ # @param [RuboCop::Cop::Corrector] corrector The corrector.
99
+ # @param [RuboCop::AST::Node] node The block node.
100
+ # @return [void]
101
+ def autocorrect(corrector, node)
102
+ expectation_source = node.body.source
103
+ corrector.replace(node, "it { #{expectation_source} }")
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vibe
6
+ module SpecFileHelper
7
+ SPEC_FILE_PATTERN = %r{spec/.*_spec\.rb}
8
+
9
+ private
10
+
11
+ # Check if file is a spec file.
12
+ #
13
+ # @return [Boolean]
14
+ def spec_file?
15
+ processed_source.file_path.match?(SPEC_FILE_PATTERN)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Vibe
6
+ # Enforces that controller specs only test assignment identity, not attributes or associations.
7
+ #
8
+ # Testing model attributes or associations from assigns is considered bad practice
9
+ # because it couples the controller spec to the model implementation.
10
+ # Controller specs should only verify that the correct object is assigned,
11
+ # not test the object's internal state or associations.
12
+ #
13
+ # @example
14
+ # # bad
15
+ # expect(assigns(:user).email).to eq('test@example.com')
16
+ # expect(assigns(:user).posts.count).to eq(5)
17
+ # expect(assigns(:post).author.name).to eq('John')
18
+ #
19
+ # # good
20
+ # expect(assigns(:user)).to eq(user)
21
+ # expect(assigns(:post)).to eq(post)
22
+ #
23
+ class NoAssignsAttributeTesting < Base
24
+ include SpecFileHelper
25
+
26
+ MSG = "Do not test attributes or associations from assigns. " \
27
+ "Only test the assignment itself: `expect(assigns(:var)).to eq(object)`"
28
+
29
+ CONTROLLER_SPEC_PATTERN = %r{spec/controllers/.*_spec\.rb}
30
+
31
+ # Matches assigns(:variable).method_call patterns.
32
+ # @!method assigns_with_method?(node)
33
+ def_node_matcher :assigns_with_method?, <<~PATTERN
34
+ (send
35
+ (send nil? :assigns ...)
36
+ $_)
37
+ PATTERN
38
+
39
+ # Checks if assigns is being called with a method in controller specs.
40
+ #
41
+ # @param node [RuboCop::AST::SendNode] the node being checked.
42
+ # @return [void]
43
+ def on_send(node)
44
+ return unless controller_spec_file?
45
+ return unless assigns_with_method?(node)
46
+
47
+ add_offense(node.loc.selector)
48
+ end
49
+ alias on_csend on_send
50
+
51
+ private
52
+
53
+ # Checks if the current file is a controller spec.
54
+ #
55
+ # @return [Boolean] true if the file is a controller spec.
56
+ def controller_spec_file?
57
+ processed_source.file_path.match?(CONTROLLER_SPEC_PATTERN)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end