rspec-let-analyzer 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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecLetAnalyzer
4
+ module Formatters
5
+ class HtmlFormatter
6
+ def format(analyzer:, limit:, sort_by:)
7
+ sorted = analyzer.top(limit, sort_by)
8
+ totals = analyzer.totals
9
+ total_files = analyzer.results.size
10
+ nesting_depth = analyzer.nesting_depth
11
+
12
+ html = []
13
+ html << '<!DOCTYPE html>'
14
+ html << '<html lang="en">'
15
+ html << '<head>'
16
+ html << ' <meta charset="UTF-8">'
17
+ html << ' <meta name="viewport" content="width=device-width, initial-scale=1.0">'
18
+ html << ' <title>RSpec Stats Report</title>'
19
+ html << ' <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">'
20
+ html << ' <style>'
21
+ html << ' body { padding: 2rem; background-color: #f8f9fa; }'
22
+ html << ' .table-container { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 2rem; }'
23
+ html << ' h1 { color: #212529; margin-bottom: 1.5rem; }'
24
+ html << ' h2 { color: #495057; margin-top: 2rem; margin-bottom: 1rem; font-size: 1.5rem; }'
25
+ html << ' .stats-summary { display: flex; gap: 1rem; margin-bottom: 2rem; }'
26
+ html << ' .stat-card { flex: 1; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 1.5rem; border-radius: 8px; }'
27
+ html << ' .stat-card h3 { font-size: 0.875rem; margin: 0; opacity: 0.9; }'
28
+ html << ' .stat-card .value { font-size: 2rem; font-weight: bold; margin-top: 0.5rem; }'
29
+ html << ' .table { margin-bottom: 0; }'
30
+ html << ' .table thead { background-color: #f8f9fa; }'
31
+ html << ' </style>'
32
+ html << '</head>'
33
+ html << '<body>'
34
+ html << ' <div class="container-fluid">'
35
+ html << ' <h1>RSpec Stats Report</h1>'
36
+
37
+ # Summary cards
38
+ html << ' <div class="stats-summary">'
39
+ html << ' <div class="stat-card">'
40
+ html << ' <h3>Total Files</h3>'
41
+ html << " <div class=\"value\">#{total_files}</div>"
42
+ html << ' </div>'
43
+ html << ' <div class="stat-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">'
44
+ html << ' <h3>Root Lets</h3>'
45
+ html << " <div class=\"value\">#{totals[:root_lets]}</div>"
46
+ html << ' </div>'
47
+ html << ' <div class="stat-card" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">'
48
+ html << ' <h3>It Blocks</h3>'
49
+ html << " <div class=\"value\">#{totals[:it_blocks]}</div>"
50
+ html << ' </div>'
51
+ html << ' <div class="stat-card" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">'
52
+ html << ' <h3>Redefinitions</h3>'
53
+ html << " <div class=\"value\">#{totals[:redefinitions]}</div>"
54
+ html << ' </div>'
55
+ html << ' </div>'
56
+
57
+ # Determine sort indicator
58
+ sort_column = case sort_by
59
+ when 'total' then 'Total'
60
+ when 'root' then 'Root'
61
+ when 'it' then 'it blocks'
62
+ when 'redef' then 'Redefinitions'
63
+ when 'before' then 'Before Creates'
64
+ else sort_by
65
+ end
66
+
67
+ # Main table
68
+ html << ' <div class="table-container">'
69
+ html << " <h2>Top #{limit} Files (sorted by #{sort_column})</h2>"
70
+ html << ' <table class="table table-striped table-hover">'
71
+ html << ' <thead>'
72
+ html << ' <tr>'
73
+ html << ' <th>File</th>'
74
+ html << (sort_by == 'total' ? ' <th class="text-end table-primary">Total ▼</th>' : ' <th class="text-end">Total</th>')
75
+ html << (sort_by == 'root' ? ' <th class="text-end table-primary">Root ▼</th>' : ' <th class="text-end">Root</th>')
76
+ html << (sort_by == 'it' ? ' <th class="text-end table-primary">it blocks ▼</th>' : ' <th class="text-end">it blocks</th>')
77
+
78
+ if nesting_depth
79
+ (1...nesting_depth).each { |i| html << " <th class=\"text-end\">Nest #{i}</th>" }
80
+ html << " <th class=\"text-end\">Nest #{nesting_depth}+</th>"
81
+ end
82
+
83
+ html << (sort_by == 'redef' ? ' <th class="text-end table-primary">Redefinitions ▼</th>' : ' <th class="text-end">Redefinitions</th>')
84
+ html << (sort_by == 'before' ? ' <th class="text-end table-primary">Before Creates ▼</th>' : ' <th class="text-end">Before Creates</th>')
85
+ html << ' </tr>'
86
+ html << ' </thead>'
87
+ html << ' <tbody>'
88
+
89
+ sorted.each do |result|
90
+ html << ' <tr>'
91
+ html << " <td><code>#{escape_html(result[:file])}</code></td>"
92
+ html << " <td class=\"text-end\">#{result[:total_score]}</td>"
93
+ html << " <td class=\"text-end\">#{result[:root_lets]}</td>"
94
+ html << " <td class=\"text-end\">#{result[:it_blocks]}</td>"
95
+
96
+ nesting_depth&.times do |i|
97
+ html << " <td class=\"text-end\">#{result[:"nesting_#{i + 1}"]}</td>"
98
+ end
99
+
100
+ html << " <td class=\"text-end\">#{result[:redefinitions]}</td>"
101
+ html << " <td class=\"text-end\">#{result[:before_creates]}</td>"
102
+ html << ' </tr>'
103
+ end
104
+
105
+ html << ' </tbody>'
106
+ html << ' <tfoot>'
107
+ html << ' <tr class="table-secondary fw-bold">'
108
+ html << " <td>TOTAL (all #{total_files} files)</td>"
109
+ html << " <td class=\"text-end\">#{totals[:total_score]}</td>"
110
+ html << " <td class=\"text-end\">#{totals[:root_lets]}</td>"
111
+ html << " <td class=\"text-end\">#{totals[:it_blocks]}</td>"
112
+
113
+ nesting_depth&.times { html << ' <td class="text-end">-</td>' }
114
+
115
+ html << " <td class=\"text-end\">#{totals[:redefinitions]}</td>"
116
+ html << " <td class=\"text-end\">#{totals[:before_creates]}</td>"
117
+ html << ' </tr>'
118
+ html << ' </tfoot>'
119
+ html << ' </table>'
120
+ html << ' </div>'
121
+
122
+ # FactoryBot section
123
+ if analyzer.factory_stats
124
+ html << format_factory_stats_html(analyzer, limit)
125
+ end
126
+
127
+ html << ' </div>'
128
+ html << '</body>'
129
+ html << '</html>'
130
+
131
+ html.join("\n")
132
+ end
133
+
134
+ def add_runtime_metrics(output, metrics)
135
+ # Insert runtime metrics before closing body tag
136
+ metrics_html = []
137
+ metrics_html << ' <div class="table-container">'
138
+ metrics_html << ' <h2>Runtime Metrics</h2>'
139
+ metrics_html << ' <table class="table table-sm" style="width: auto;">'
140
+ metrics_html << ' <tbody>'
141
+ metrics_html << " <tr><td class=\"fw-bold\">Analysis:</td><td>#{format_duration(metrics[:analyze_time])}</td></tr>"
142
+ metrics_html << " <tr><td class=\"fw-bold\">Formatting:</td><td>#{format_duration(metrics[:format_time])}</td></tr>"
143
+ metrics_html << " <tr class=\"table-secondary\"><td class=\"fw-bold\">Total:</td><td class=\"fw-bold\">#{format_duration(metrics[:total_time])}</td></tr>"
144
+ metrics_html << ' </tbody>'
145
+ metrics_html << ' </table>'
146
+ metrics_html << ' </div>'
147
+
148
+ output.sub(' </div>', "#{metrics_html.join("\n")}\n </div>")
149
+ end
150
+
151
+ private
152
+
153
+ def format_factory_stats_html(analyzer, limit)
154
+ top_factories = analyzer.top_factories(limit)
155
+ return '' if top_factories.nil? || top_factories.empty?
156
+
157
+ html = []
158
+ html << ' <div class="table-container">'
159
+ html << " <h2>FactoryBot Usage (Top #{limit})</h2>"
160
+ html << ' <table class="table table-striped table-hover" style="width: auto;">'
161
+ html << ' <thead>'
162
+ html << ' <tr>'
163
+ html << ' <th>Factory</th>'
164
+ html << ' <th class="text-end">Usage Count</th>'
165
+ html << ' </tr>'
166
+ html << ' </thead>'
167
+ html << ' <tbody>'
168
+
169
+ top_factories.each do |factory, count|
170
+ html << ' <tr>'
171
+ html << " <td><code>#{escape_html(factory.to_s)}</code></td>"
172
+ html << " <td class=\"text-end\">#{count}</td>"
173
+ html << ' </tr>'
174
+ end
175
+
176
+ html << ' </tbody>'
177
+ html << ' <tfoot>'
178
+ html << ' <tr class="table-secondary">'
179
+ html << " <td class=\"fw-bold\">Total unique factories:</td>"
180
+ html << " <td class=\"text-end fw-bold\">#{analyzer.factory_stats.size}</td>"
181
+ html << ' </tr>'
182
+ html << ' <tr class="table-secondary">'
183
+ html << " <td class=\"fw-bold\">Total factory usage:</td>"
184
+ html << " <td class=\"text-end fw-bold\">#{analyzer.factory_stats.values.sum}</td>"
185
+ html << ' </tr>'
186
+ html << ' </tfoot>'
187
+ html << ' </table>'
188
+ html << ' </div>'
189
+
190
+ html.join("\n")
191
+ end
192
+
193
+ def format_duration(seconds)
194
+ if seconds < 1
195
+ "#{(seconds * 1000).round(2)}ms"
196
+ else
197
+ "#{seconds.round(2)}s"
198
+ end
199
+ end
200
+
201
+ def escape_html(text)
202
+ text.to_s
203
+ .gsub('&', '&amp;')
204
+ .gsub('<', '&lt;')
205
+ .gsub('>', '&gt;')
206
+ .gsub('"', '&quot;')
207
+ .gsub("'", '&#39;')
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RSpecLetAnalyzer
6
+ module Formatters
7
+ class JsonFormatter
8
+ def format(analyzer:, limit:, sort_by:)
9
+ sorted = analyzer.top(limit, sort_by)
10
+
11
+ result = {
12
+ top_files: sorted,
13
+ totals: analyzer.totals,
14
+ total_files: analyzer.results.size,
15
+ nesting_depth: analyzer.nesting_depth,
16
+ sort_by: sort_by
17
+ }
18
+
19
+ if analyzer.factory_stats
20
+ top_factories = analyzer.top_factories(limit)
21
+ result[:factorybot] = {
22
+ top_factories: top_factories.map { |factory, count| { factory: factory, count: count } },
23
+ total_unique: analyzer.factory_stats.size,
24
+ total_usage: analyzer.factory_stats.values.sum
25
+ }
26
+ end
27
+
28
+ result.to_json
29
+ end
30
+
31
+ def add_runtime_metrics(output, metrics)
32
+ parsed = JSON.parse(output)
33
+ parsed['runtime_metrics'] = {
34
+ analyze_time: metrics[:analyze_time].round(4),
35
+ format_time: metrics[:format_time].round(4),
36
+ total_time: metrics[:total_time].round(4)
37
+ }
38
+ parsed.to_json
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RSpecLetAnalyzer
6
+ module Formatters
7
+ class LlmFormatter
8
+ def format(analyzer:, limit:, sort_by:)
9
+ sorted = analyzer.top(limit, sort_by)
10
+
11
+ sort_context = case sort_by
12
+ when 'total'
13
+ 'Files sorted by total score (highest combined complexity)'
14
+ when 'root'
15
+ 'Files sorted by root lets (most shared fixture setup)'
16
+ when 'it'
17
+ 'Files sorted by it blocks (most test repetition)'
18
+ when 'redef'
19
+ 'Files sorted by redefinitions (most complex refactoring)'
20
+ when 'before'
21
+ 'Files sorted by before creates (most factory usage in before blocks)'
22
+ else
23
+ "Files sorted by #{sort_by}"
24
+ end
25
+
26
+ result = {
27
+ mission: 'Refactor let/let! to let_it_be for performance improvements',
28
+ sort_context: sort_context,
29
+ instructions: {
30
+ workflow: [
31
+ '1. Run the spec file FIRST to establish baseline: rspec <file>',
32
+ '2. Record the baseline runtime (excluding load time - shown in rspec output)',
33
+ '3. Review file and identify let_it_be conversion candidates',
34
+ '4. Make changes to ONLY let/let! declarations',
35
+ '5. Run specs again and compare runtime',
36
+ "6. If tests fail, try adding 'refind: true' option",
37
+ '7. Calculate performance improvement percentage',
38
+ '8. If improvement is <20%, ask user if change is worth keeping',
39
+ '9. Report: original time vs new time and % improvement'
40
+ ],
41
+ decision_criteria: {
42
+ auto_keep: 'Performance improvement >= 20%',
43
+ ask_user: 'Performance improvement < 20% (marginal gains may not justify refactor)',
44
+ revert: 'Tests fail even with refind: true, or performance degrades'
45
+ },
46
+ requirements: [
47
+ 'MUST preserve all existing test examples',
48
+ 'MUST NOT reduce test coverage',
49
+ 'MUST NOT change test behavior',
50
+ 'Only refactor fixture setup (let/let! declarations)'
51
+ ]
52
+ },
53
+ top_candidates: build_candidates(sorted, analyzer),
54
+ refactoring_guidelines: {
55
+ let_refactoring: 'let(:user) { create(:user) } → let_it_be(:user) { create(:user) }',
56
+ before_block_refactoring: 'before blocks with create() calls can be converted to before_all or extracted to let_it_be',
57
+ if_tests_fail: "Add refind option: let_it_be(:user, refind: true) { create(:user) }",
58
+ docs: 'https://test-prof.evilmartians.io/#/let_it_be'
59
+ }
60
+ }
61
+
62
+ JSON.pretty_generate(result)
63
+ end
64
+
65
+ def add_runtime_metrics(output, metrics)
66
+ # LLM formatter doesn't need runtime metrics for the analysis itself
67
+ # The metrics are about analyzing the codebase, not the specs being refactored
68
+ output
69
+ end
70
+
71
+ private
72
+
73
+ def build_candidates(sorted, analyzer)
74
+ sorted.map do |result|
75
+ candidate = {
76
+ file: result[:file],
77
+ total_score: result[:total_score],
78
+ priority_score: calculate_priority_score(result),
79
+ root_lets: result[:root_lets],
80
+ it_blocks: result[:it_blocks],
81
+ redefinitions: result[:redefinitions],
82
+ before_creates: result[:before_creates],
83
+ rationale: build_rationale(result)
84
+ }
85
+
86
+ # Add factory usage if available
87
+ if analyzer.factory_stats && analyzer.factory_stats.any?
88
+ # Get factories used in this file (we don't track per-file, so this is aspirational)
89
+ # For now, show top factories across the suite as context
90
+ candidate[:factory_usage_context] = analyzer.top_factories(5)&.to_h
91
+ end
92
+
93
+ candidate
94
+ end
95
+ end
96
+
97
+ def calculate_priority_score(result)
98
+ score = 0
99
+
100
+ # High root lets is good (more opportunities)
101
+ score += result[:root_lets] * 5
102
+
103
+ # before creates are also good refactoring opportunities
104
+ score += result[:before_creates] * 4
105
+
106
+ # More it blocks means more potential speedup
107
+ score += result[:it_blocks] * 2
108
+
109
+ # Low redefinitions is good (easier to refactor)
110
+ score -= result[:redefinitions] * 10
111
+
112
+ # Normalize to 0-100 scale
113
+ [[score, 0].max, 100].min
114
+ end
115
+
116
+ def build_rationale(result)
117
+ parts = []
118
+
119
+ if result[:root_lets] > 5
120
+ parts << "High root let count (#{result[:root_lets]})"
121
+ elsif result[:root_lets] > 0
122
+ parts << "#{result[:root_lets]} root let(s)"
123
+ end
124
+
125
+ if result[:before_creates] > 0
126
+ parts << "#{result[:before_creates]} before block create(s) to refactor"
127
+ end
128
+
129
+ if result[:redefinitions] == 0
130
+ parts << 'no redefinitions (clean refactor)'
131
+ elsif result[:redefinitions] <= 2
132
+ parts << "low redefinitions (#{result[:redefinitions]})"
133
+ else
134
+ parts << "#{result[:redefinitions]} redefinitions (check carefully)"
135
+ end
136
+
137
+ if result[:it_blocks] > 20
138
+ parts << "many tests (#{result[:it_blocks]}) suggest high factory usage"
139
+ end
140
+
141
+ parts.join(', ').capitalize + '.'
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecLetAnalyzer
4
+ class ProgressReporter
5
+ def initialize(enabled)
6
+ @enabled = enabled
7
+ @last_message_length = 0
8
+ end
9
+
10
+ def update(current:, total:, file:)
11
+ return unless @enabled
12
+
13
+ percentage = (current.to_f / total * 100).round
14
+ message = format('[%3d%%] %s', percentage, file)
15
+
16
+ # Clear previous line by overwriting with spaces, then print new message
17
+ clear_length = [@last_message_length, message.length].max
18
+ print "\r#{' ' * clear_length}\r#{message}"
19
+ $stdout.flush
20
+
21
+ @last_message_length = message.length
22
+ end
23
+
24
+ def clear
25
+ return unless @enabled
26
+
27
+ # Clear the progress line completely
28
+ print "\r#{' ' * @last_message_length}\r"
29
+ $stdout.flush
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecLetAnalyzer
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'parser/current'
5
+ require 'ast'
6
+ rescue LoadError
7
+ # Parser gem not available, that's ok
8
+ end
9
+
10
+ module RSpecLetAnalyzer
11
+ module Visitors
12
+ # Visitor for the parser gem (uses AST::Processor)
13
+ class ParserVisitor < ::Parser::AST::Processor
14
+ attr_reader :root_lets, :total_its, :redefinitions, :nesting_counts, :factory_usage, :before_creates
15
+
16
+ def initialize(max_nesting_depth, track_factories)
17
+ super()
18
+ @root_lets = 0
19
+ @total_its = 0
20
+ @redefinitions = 0
21
+ @depth = 0
22
+ @let_names_by_depth = {}
23
+ @max_nesting_depth = max_nesting_depth
24
+ @nesting_counts = Array.new(max_nesting_depth, 0) if max_nesting_depth
25
+ @track_factories = track_factories
26
+ @factory_usage = Hash.new(0) if track_factories
27
+ @before_creates = 0
28
+ @in_before_block = false
29
+ end
30
+
31
+ def on_send(node)
32
+ method_name = node.children[1]
33
+
34
+ case method_name
35
+ when :create, :build, :build_stubbed
36
+ # Track if create is inside a before block
37
+ if @in_before_block && method_name == :create
38
+ @before_creates += 1
39
+ end
40
+
41
+ if @track_factories
42
+ factory_name = extract_factory_name(node)
43
+ @factory_usage[factory_name] += 1 if factory_name
44
+ end
45
+ node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
46
+ else
47
+ node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
48
+ end
49
+ end
50
+
51
+ # For block nodes, we need to handle them specially
52
+ # Blocks in parser gem represent describe/context/let blocks
53
+ def on_block(node)
54
+ send_node = node.children[0]
55
+ return unless send_node.is_a?(::Parser::AST::Node)
56
+ return unless send_node.type == :send
57
+
58
+ method_name = send_node.children[1]
59
+
60
+ case method_name
61
+ when :describe, :context, :shared_examples, :shared_context
62
+ @depth += 1
63
+ # Process args
64
+ send_node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
65
+ # Process block body
66
+ body = node.children[2]
67
+ process(body) if body.is_a?(::Parser::AST::Node)
68
+ @depth -= 1
69
+ @let_names_by_depth.delete(@depth + 1)
70
+ when :before
71
+ # Check if this is before or before(:each)
72
+ arg = send_node.children[2]
73
+ is_before_each = arg.nil? || (arg.type == :sym && arg.children[0] == :each)
74
+
75
+ if is_before_each
76
+ @in_before_block = true
77
+ # Process args
78
+ send_node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
79
+ # Process block body
80
+ body = node.children[2]
81
+ process(body) if body.is_a?(::Parser::AST::Node)
82
+ @in_before_block = false
83
+ else
84
+ # Process args
85
+ send_node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
86
+ # Process block body
87
+ body = node.children[2]
88
+ process(body) if body.is_a?(::Parser::AST::Node)
89
+ end
90
+ when :it, :specify
91
+ @total_its += 1
92
+ # Process args
93
+ send_node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
94
+ # Process block body
95
+ body = node.children[2]
96
+ process(body) if body.is_a?(::Parser::AST::Node)
97
+ when :let, :let!
98
+ let_name = extract_let_name_from_block(node)
99
+
100
+ if let_name
101
+ @redefinitions += 1 if redefinition?(let_name)
102
+ @let_names_by_depth[@depth] ||= []
103
+ @let_names_by_depth[@depth] << let_name
104
+ end
105
+
106
+ if @depth == 1
107
+ @root_lets += 1
108
+ elsif @max_nesting_depth
109
+ nesting_index = @depth - 2
110
+ if nesting_index < @max_nesting_depth - 1
111
+ @nesting_counts[nesting_index] += 1
112
+ else
113
+ @nesting_counts[@max_nesting_depth - 1] += 1
114
+ end
115
+ end
116
+
117
+ # Process args and body
118
+ send_node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
119
+ body = node.children[2]
120
+ process(body) if body.is_a?(::Parser::AST::Node)
121
+ else
122
+ # Process all children
123
+ send_node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
124
+ body = node.children[2]
125
+ process(body) if body.is_a?(::Parser::AST::Node)
126
+ end
127
+ end
128
+
129
+ def handler_missing(node)
130
+ node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
131
+ end
132
+
133
+ private
134
+
135
+ def extract_let_name(node)
136
+ # node.children: [receiver, method_name, *args]
137
+ args = node.children[2..-1]
138
+ return nil if args.empty?
139
+
140
+ first_arg = args.first
141
+ return nil unless first_arg.is_a?(::Parser::AST::Node)
142
+ return nil unless first_arg.type == :sym
143
+
144
+ first_arg.children.first.to_s
145
+ end
146
+
147
+ def extract_let_name_from_block(block_node)
148
+ send_node = block_node.children[0]
149
+ return nil unless send_node.is_a?(::Parser::AST::Node)
150
+
151
+ extract_let_name(send_node)
152
+ end
153
+
154
+ def extract_factory_name(node)
155
+ args = node.children[2..-1]
156
+ return nil if args.empty?
157
+
158
+ first_arg = args.first
159
+ return nil unless first_arg.is_a?(::Parser::AST::Node)
160
+ return nil unless first_arg.type == :sym
161
+
162
+ first_arg.children.first
163
+ end
164
+
165
+ def redefinition?(let_name)
166
+ (1...@depth).any? do |outer_depth|
167
+ @let_names_by_depth[outer_depth]&.include?(let_name)
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end