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.
- checksums.yaml +7 -0
- data/README.md +183 -0
- data/bin/rspec-let-analyzer +117 -0
- data/lib/rspec_let_analyzer/adapter.rb +48 -0
- data/lib/rspec_let_analyzer/adapters/parser_adapter.rb +27 -0
- data/lib/rspec_let_analyzer/adapters/prism_adapter.rb +21 -0
- data/lib/rspec_let_analyzer/analyzer.rb +144 -0
- data/lib/rspec_let_analyzer/formatters/ascii_formatter.rb +128 -0
- data/lib/rspec_let_analyzer/formatters/html_formatter.rb +211 -0
- data/lib/rspec_let_analyzer/formatters/json_formatter.rb +42 -0
- data/lib/rspec_let_analyzer/formatters/llm_formatter.rb +145 -0
- data/lib/rspec_let_analyzer/progress_reporter.rb +32 -0
- data/lib/rspec_let_analyzer/version.rb +5 -0
- data/lib/rspec_let_analyzer/visitors/parser_visitor.rb +172 -0
- data/lib/rspec_let_analyzer/visitors/prism_visitor.rb +125 -0
- data/lib/rspec_let_analyzer.rb +16 -0
- metadata +103 -0
@@ -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('&', '&')
|
204
|
+
.gsub('<', '<')
|
205
|
+
.gsub('>', '>')
|
206
|
+
.gsub('"', '"')
|
207
|
+
.gsub("'", ''')
|
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,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
|