Skip to content

Commit d12bf9d

Browse files
committed
Improve TooManyMigrationRuns cop and add spec
- Fix empty file handling (nil AST guard) - Replace fragile string matching with AST-based detection - Consolidate AST traversals into fewer passes - Remove unused parameter and fix method naming - Add comprehensive spec covering all detection patterns
1 parent 0997843 commit d12bf9d

File tree

2 files changed

+210
-84
lines changed

2 files changed

+210
-84
lines changed

spec/linters/migration/too_many_migration_runs.rb

Lines changed: 96 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,132 +2,144 @@ module RuboCop
22
module Cop
33
module Migration
44
class TooManyMigrationRuns < Base
5-
MSG = 'Too many migration runs (%d). Combine tests to reduce migrations. See spec/migrations/README.md for further guidance.'.freeze
6-
MAX_CALLS = 4
7-
85
def on_new_investigation
9-
calls = 0
10-
migrator_subject_names = []
11-
migrator_method_names = []
12-
migrator_let_names = []
13-
migrator_before_after_blocks = Set.new
6+
return unless processed_source.ast
7+
8+
definitions = extract_migrator_definitions
9+
call_count = count_migrator_calls(definitions)
1410

15-
extract_migrator_definitions(migrator_subject_names, migrator_method_names,
16-
migrator_let_names, migrator_before_after_blocks)
11+
return unless call_count > 4
1712

18-
count_migrator_calls(calls, migrator_subject_names, migrator_method_names,
19-
migrator_let_names, migrator_before_after_blocks)
13+
add_offense(processed_source.ast,
14+
message: "Too many migration runs (#{call_count}). Combine tests to reduce migrations. See spec/migrations/README.md for further guidance.")
2015
end
2116

22-
def extract_migrator_definitions(subject_names, method_names, let_names, before_after_blocks)
23-
processed_source.ast.each_descendant(:def) do |node|
24-
method_name = extract_migrator_method_name(node)
25-
method_names << method_name if method_name
17+
private
18+
19+
def extract_migrator_definitions
20+
definitions = {
21+
subject_names: [],
22+
method_names: [],
23+
let_names: [],
24+
before_after_blocks: Set.new
25+
}
26+
27+
# Single pass through the AST to collect all definitions
28+
processed_source.ast.each_descendant(:def, :block) do |node|
29+
case node.type
30+
when :def
31+
extract_migrator_method(node, definitions[:method_names])
32+
when :block
33+
extract_block_definitions(node, definitions)
34+
end
2635
end
2736

28-
processed_source.ast.each_descendant(:block) do |node|
29-
subject_name = extract_migrator_subject_name(node)
30-
subject_names << subject_name if subject_name
37+
definitions
38+
end
3139

32-
let_name = extract_migrator_let_name(node)
33-
let_names << let_name if let_name
40+
def extract_migrator_method(node, method_names)
41+
return unless contains_migrator_run?(node)
3442

35-
before_after_blocks.add(node.object_id) if is_before_after_around_with_migrator?(node)
36-
end
43+
method_names << node.method_name
3744
end
3845

39-
def count_migrator_calls(_calls, subjects, methods, lets, before_after_blocks)
40-
call_count = count_before_after_migrations(before_after_blocks)
41-
call_count += count_send_node_migrations(subjects, methods, lets, before_after_blocks)
46+
def extract_block_definitions(node, definitions)
47+
return unless node.send_node
4248

43-
add_offense(processed_source.ast, message: sprintf(MSG, call_count)) if call_count > MAX_CALLS
44-
end
49+
method_name = node.send_node.method_name
4550

46-
def count_before_after_migrations(before_after_blocks)
47-
call_count = 0
48-
processed_source.ast.each_descendant(:block) do |node|
49-
call_count += count_direct_migrations_in_node(node) if before_after_blocks.include?(node.object_id)
51+
case method_name
52+
when :subject
53+
extract_named_migrator(node, definitions[:subject_names])
54+
when :let, :let!
55+
extract_named_migrator(node, definitions[:let_names])
56+
when :before, :after, :around
57+
definitions[:before_after_blocks].add(node.object_id) if contains_migrator_run?(node)
5058
end
51-
call_count
5259
end
5360

54-
def count_send_node_migrations(subjects, methods, lets, before_after_blocks)
61+
def extract_named_migrator(node, names)
62+
return unless contains_migrator_run?(node)
63+
64+
first_arg = node.send_node.first_argument
65+
names << first_arg.value if first_arg&.sym_type?
66+
end
67+
68+
def count_migrator_calls(definitions)
5569
call_count = 0
70+
71+
# Single pass through send nodes to count all migration calls
5672
processed_source.ast.each_descendant(:send) do |node|
57-
next if node.each_ancestor(:block).any? { |a| before_after_blocks.include?(a.object_id) }
73+
next unless migration_call?(node, definitions)
5874

59-
call_count += count_migration_call(node, subjects, methods, lets)
75+
call_count += 1
6076
end
77+
6178
call_count
6279
end
6380

64-
def count_migration_call(node, subjects, methods, lets)
65-
return 1 if direct_migrator_call?(node)
66-
return 1 if helper_migration_call?(node, subjects, methods, lets)
81+
def migration_call?(node, definitions)
82+
in_before_after_block = node.each_ancestor(:block).any? do |ancestor|
83+
definitions[:before_after_blocks].include?(ancestor.object_id)
84+
end
6785

68-
0
86+
if in_before_after_block
87+
# Count direct Migrator.run calls inside before/after/around blocks
88+
migrator_run_call?(node)
89+
else
90+
# Count direct calls (not in definitions) or helper invocations
91+
direct_migrator_call?(node) || helper_migration_call?(node, definitions)
92+
end
6993
end
7094

7195
def direct_migrator_call?(node)
72-
return false unless node.method_name == :run && node.receiver&.source&.include?('Migrator')
96+
return false unless migrator_run_call?(node)
7397

7498
!inside_definition?(node)
7599
end
76100

77-
def helper_migration_call?(node, subjects, methods, lets)
78-
subjects.include?(node.method_name) ||
79-
lets.include?(node.method_name) ||
80-
methods.include?(node.method_name)
81-
end
82-
83-
private
84-
85-
def extract_migrator_method_name(node)
86-
return nil unless node.type == :def
87-
return nil unless node.source.include?('Sequel::Migrator.run')
88-
89-
node.method_name
90-
end
91-
92-
def extract_migrator_subject_name(node)
93-
return nil unless node.send_node.method_name == :subject
94-
return nil unless node.source.include?('Sequel::Migrator.run')
101+
def migrator_run_call?(node)
102+
return false unless node.method_name == :run
95103

96-
first_arg = node.send_node.first_argument
97-
first_arg&.sym_type? ? first_arg.value : nil
98-
end
104+
receiver = node.receiver
105+
return false unless receiver
99106

100-
def extract_migrator_let_name(node)
101-
return nil unless %i[let let!].include?(node.send_node.method_name)
102-
return nil unless node.source.include?('Sequel::Migrator.run')
107+
# Check for Sequel::Migrator.run or just Migrator.run
108+
if receiver.const_type?
109+
receiver_name = receiver.const_name
110+
return ['Migrator', 'Sequel::Migrator'].include?(receiver_name)
111+
end
103112

104-
first_arg = node.send_node.first_argument
105-
first_arg&.sym_type? ? first_arg.value : nil
113+
false
106114
end
107115

108-
def is_before_after_around_with_migrator?(node)
109-
return false unless node.send_node
110-
return false unless %i[before after around].include?(node.send_node.method_name)
111-
112-
node.source.include?('Sequel::Migrator.run')
116+
def helper_migration_call?(node, definitions)
117+
method = node.method_name
118+
definitions[:subject_names].include?(method) ||
119+
definitions[:let_names].include?(method) ||
120+
definitions[:method_names].include?(method)
113121
end
114122

115-
def count_direct_migrations_in_node(node)
116-
count = 0
117-
node.each_descendant(:send) do |descendant|
118-
count += 1 if descendant.method_name == :run && descendant.receiver&.source&.include?('Migrator')
119-
end
120-
count
123+
def contains_migrator_run?(node)
124+
node.each_descendant(:send).any? { |send_node| migrator_run_call?(send_node) }
121125
end
122126

123127
def inside_definition?(node)
124-
node.each_ancestor(:def).any? { |a| a.source.include?('Sequel::Migrator.run') } ||
125-
node.each_ancestor(:block).any? do |a|
126-
%i[subject let let!].include?(a.send_node&.method_name) && a.source.include?('Sequel::Migrator.run')
127-
end ||
128-
node.each_ancestor(:block).any? do |a|
129-
%i[before after around].include?(a.send_node&.method_name)
128+
node.each_ancestor(:def, :block).any? do |ancestor|
129+
case ancestor.type
130+
when :def
131+
contains_migrator_run?(ancestor)
132+
when :block
133+
next false unless ancestor.send_node
134+
135+
method = ancestor.send_node.method_name
136+
if %i[subject let let!].include?(method)
137+
contains_migrator_run?(ancestor)
138+
else
139+
%i[before after around].include?(method)
140+
end
130141
end
142+
end
131143
end
132144
end
133145
end
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
require 'rubocop'
2+
require 'rubocop/rspec/cop_helper'
3+
require 'rubocop/config'
4+
require 'linters/migration/too_many_migration_runs'
5+
6+
RSpec.describe RuboCop::Cop::Migration::TooManyMigrationRuns do
7+
include CopHelper
8+
9+
subject(:cop) { described_class.new(RuboCop::Config.new({})) }
10+
11+
def migration_call(target)
12+
"Sequel::Migrator.run(db, path, target: #{target})"
13+
end
14+
15+
def it_blocks(count)
16+
(1..count).map { |n| "it('test #{n}') { #{migration_call(n)} }" }.join("\n")
17+
end
18+
19+
it 'does not register an offense for 4 or fewer direct migration calls' do
20+
result = inspect_source("RSpec.describe('m') do\n#{it_blocks(4)}\nend")
21+
expect(result).to be_empty
22+
end
23+
24+
it 'registers an offense for more than 4 direct migration calls' do
25+
result = inspect_source("RSpec.describe('m') do\n#{it_blocks(5)}\nend")
26+
expect(result.size).to eq(1)
27+
expect(result.first.message).to include('(5)')
28+
end
29+
30+
it 'counts multiple migrations in a single it block' do
31+
source = <<~RUBY
32+
RSpec.describe('m') do
33+
it('test') do
34+
#{(1..5).map { |n| migration_call(n) }.join("\n")}
35+
end
36+
end
37+
RUBY
38+
result = inspect_source(source)
39+
expect(result.size).to eq(1)
40+
expect(result.first.message).to include('(5)')
41+
end
42+
43+
it 'counts migrations via subject, let, let!, and helper methods' do
44+
source = <<~RUBY
45+
RSpec.describe('m') do
46+
subject(:migrate_subj) { #{migration_call(1)} }
47+
let(:migrate_let) { #{migration_call(2)} }
48+
let!(:migrate_let_bang) { #{migration_call(3)} }
49+
def migrate_method; #{migration_call(4)}; end
50+
51+
it('t1') { migrate_subj }
52+
it('t2') { migrate_let }
53+
it('t3') { migrate_let_bang }
54+
it('t4') { migrate_method }
55+
it('t5') { migrate_subj }
56+
end
57+
RUBY
58+
result = inspect_source(source)
59+
expect(result.size).to eq(1)
60+
expect(result.first.message).to include('(5)')
61+
end
62+
63+
it 'does not double-count definitions - only invocations' do
64+
source = <<~RUBY
65+
RSpec.describe('m') do
66+
subject(:migrate) { #{migration_call(1)} }
67+
it('t1') { migrate }
68+
it('t2') { migrate }
69+
it('t3') { migrate }
70+
it('t4') { migrate }
71+
end
72+
RUBY
73+
result = inspect_source(source)
74+
expect(result).to be_empty
75+
end
76+
77+
it 'counts migrations in before/after blocks' do
78+
source = <<~RUBY
79+
RSpec.describe('m') do
80+
before { #{migration_call(1)}; #{migration_call(2)} }
81+
after { #{migration_call(3)} }
82+
it('t1') { #{migration_call(4)} }
83+
it('t2') { #{migration_call(5)} }
84+
end
85+
RUBY
86+
result = inspect_source(source)
87+
expect(result.size).to eq(1)
88+
expect(result.first.message).to include('(5)')
89+
end
90+
91+
it 'does not count non-migration let invocations' do
92+
source = <<~RUBY
93+
RSpec.describe('m') do
94+
let(:value) { 'not a migration' }
95+
#{(1..4).map { |n| "it('t#{n}') { value; #{migration_call(n)} }" }.join("\n")}
96+
end
97+
RUBY
98+
result = inspect_source(source)
99+
expect(result).to be_empty
100+
end
101+
102+
it 'handles empty files and files without migrations' do
103+
expect(inspect_source('')).to be_empty
104+
expect(inspect_source("RSpec.describe('x') { it('y') { expect(1).to eq(1) } }")).to be_empty
105+
end
106+
107+
it 'detects ::Sequel::Migrator.run and bare Migrator.run' do
108+
%w[::Sequel::Migrator Migrator].each do |const|
109+
source = "RSpec.describe('m') do\n#{(1..5).map { |n| "it('t#{n}') { #{const}.run(db, path, target: #{n}) }" }.join("\n")}\nend"
110+
result = inspect_source(source)
111+
expect(result.size).to eq(1), "Expected offense for #{const}.run"
112+
end
113+
end
114+
end

0 commit comments

Comments
 (0)