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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a5a75ffed6c9e25926117f6412a8a12bd0bdf73824ae4683a75520dfd8eab28d
4
+ data.tar.gz: 14ae2f4a6ca4296a51f61f7a4acc75575c02b47fff5d7b6bdb0fa16c6f31ba47
5
+ SHA512:
6
+ metadata.gz: 3d4c1d18085b7e31231d5376a6accde175352b34dd84722f344d1972d16673dd72f6b369ed340daa4dcd42701a1d5da474634677875e8b6847135c702af374a5
7
+ data.tar.gz: a6f0c225e2d97a56c1f3bd5c1d0788db9a30f3f6a91f6a30cc85183bce52b20944cfa023d8f5a687d0e090d9b08cbb735c2374a1c53012aadc46a81e5c10cef6
data/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # RSpec Let Analyzer
2
+
3
+ A Ruby gem that analyzes RSpec test files to identify opportunities for refactoring `let` and `let!` declarations to use [test-prof](https://github.com/test-prof/test-prof)'s `let_it_be` helper for improved test performance.
4
+
5
+ ## Background
6
+
7
+ The `let_it_be` helper from the test-prof gem creates test data once per example group instead of once per example, which can significantly speed up test suites. This tool helps you identify which `let` declarations are good candidates for conversion to `let_it_be`.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'rspec-let-analyzer'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ ```bash
20
+ bundle install
21
+ ```
22
+
23
+ Or install it yourself as:
24
+
25
+ ```bash
26
+ gem install rspec-let-analyzer
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Run the analyzer on your RSpec test suite:
32
+
33
+ ```bash
34
+ # Analyze specs in current directory
35
+ rspec-let-analyzer
36
+
37
+ # Analyze specs in a different directory
38
+ rspec-let-analyzer /path/to/project
39
+ rspec-let-analyzer --directory /path/to/project
40
+ ```
41
+
42
+ This will scan all files matching `spec/**/*_spec.rb` in the specified directory (or current directory if not specified) and display a table showing:
43
+
44
+ - **Total**: Combined score of all metrics (Root + it blocks + Nest columns + Redef + Before Creates)
45
+ - **Root**: Number of `let`/`let!` declarations at the root level of each describe block
46
+ - **it blocks**: Number of test examples in each file
47
+ - **Nest 1, Nest 2, etc.**: Number of `let` declarations at different nesting depths
48
+ - **Redef**: Number of `let` redefinitions (where a nested context redefines a `let` from a parent scope)
49
+ - **Bef Cr**: Number of `create` calls in `before` blocks
50
+
51
+ ### Command Line Options
52
+
53
+ ```bash
54
+ # Show top 20 files instead of default 10
55
+ rspec-let-analyzer -n 20
56
+
57
+ # Output in different formats
58
+ rspec-let-analyzer --format ascii # Default: ASCII table
59
+ rspec-let-analyzer --format json # JSON for further processing
60
+ rspec-let-analyzer --format html # HTML report with Bootstrap styling
61
+ rspec-let-analyzer --format llm # LLM-friendly JSON for AI-assisted refactoring
62
+
63
+ # Disable progress indicator
64
+ rspec-let-analyzer --no-progress
65
+
66
+ # Track nesting depth up to 5 levels (default: 3)
67
+ rspec-let-analyzer --nesting 5
68
+
69
+ # Disable nesting tracking entirely
70
+ rspec-let-analyzer --no-nesting
71
+
72
+ # Sort by different fields
73
+ rspec-let-analyzer --sort total # Sort by total score (default)
74
+ rspec-let-analyzer --sort root # Sort by root lets
75
+ rspec-let-analyzer --sort it # Sort by number of it blocks
76
+ rspec-let-analyzer --sort redef # Sort by number of redefinitions
77
+ rspec-let-analyzer --sort before # Sort by before creates
78
+
79
+ # Track FactoryBot usage statistics
80
+ rspec-let-analyzer --factories
81
+
82
+ # Analyze a different directory
83
+ rspec-let-analyzer --directory /path/to/project
84
+ rspec-let-analyzer /path/to/project # Positional argument also works
85
+
86
+ # Display runtime performance metrics
87
+ rspec-let-analyzer --runtime-metrics
88
+
89
+ # Use a different adapter (default: prism)
90
+ rspec-let-analyzer --adapter parser # Requires 'gem install parser'
91
+
92
+ # Generate reports and save to file
93
+ rspec-let-analyzer --format html --output report.html
94
+ rspec-let-analyzer --format llm --output refactor-guide.json
95
+
96
+ # Show help
97
+ rspec-let-analyzer --help
98
+ ```
99
+
100
+ ### LLM-Assisted Refactoring
101
+
102
+ The `--format llm` option generates AI-friendly output designed for use with Large Language Models (like Claude, ChatGPT, etc.) to assist with refactoring:
103
+
104
+ ```bash
105
+ # Generate refactoring guide
106
+ rspec-let-analyzer --format llm --output refactor-guide.json -n 10
107
+
108
+ # Then provide this to your AI assistant with a prompt like:
109
+ # "Review the top 5 files in this report and suggest let_it_be refactorings"
110
+ ```
111
+
112
+ The LLM format includes:
113
+ - Prioritized list of refactoring candidates with scores
114
+ - Step-by-step refactoring workflow
115
+ - Decision criteria (when to keep/ask/revert changes)
116
+ - Guidelines for safe conversions and common fixes
117
+ - Context about factory usage patterns
118
+
119
+ ### Understanding the Output
120
+
121
+ **High Total score** indicates files with the most opportunities for optimization, combining all metrics for a comprehensive view of complexity.
122
+
123
+ **High numbers in the Root column** indicate files with many `let` declarations at the top level. These are prime candidates for `let_it_be` if:
124
+ - The data doesn't change between tests
125
+ - The tests don't modify the created objects
126
+
127
+ **High numbers in Redef column** indicate files where `let` declarations are frequently overridden in nested contexts. These may be more complex to refactor since the redefinitions might be intentional for test isolation.
128
+
129
+ **High numbers in Bef Cr column** indicate files with many `create` calls in `before` blocks, which can be converted to `let_it_be` or `before(:all)` blocks.
130
+
131
+ **Factory tracking** shows which FactoryBot factories are used most frequently across your test suite, helping identify optimization opportunities.
132
+
133
+ ## Example Output
134
+
135
+ ```
136
+ +------------------------------------------------------------+----------+----------+----------+----------+----------+----------+----------+----------+
137
+ | File | Total | Root | it blocks| Nest 1 | Nest 2 | Nest 3+| Redef | Bef Cr |
138
+ +------------------------------------------------------------+----------+----------+----------+----------+----------+----------+----------+----------+
139
+ | spec/models/user_spec.rb | 50 | 12 | 25 | 8 | 3 | 0 | 2 | 0 |
140
+ | spec/controllers/posts_controller_spec.rb | 38 | 10 | 18 | 6 | 2 | 1 | 1 | 0 |
141
+ +------------------------------------------------------------+----------+----------+----------+----------+----------+----------+----------+----------+
142
+ | TOTAL (all 50 files) | 600 | 150 | 300 | | | | 15 | 0 |
143
+ +------------------------------------------------------------+----------+----------+----------+----------+----------+----------+----------+----------+
144
+ ```
145
+
146
+ ## Development
147
+
148
+ After checking out the repo, run:
149
+
150
+ ```bash
151
+ bundle install
152
+ ```
153
+
154
+ To run the test suite:
155
+
156
+ ```bash
157
+ bundle exec rspec
158
+ ```
159
+
160
+ To install this gem onto your local machine:
161
+
162
+ ```bash
163
+ bundle exec rake install
164
+ ```
165
+
166
+ ## How It Works
167
+
168
+ The gem uses Ruby's [Prism](https://github.com/ruby/prism) parser to analyze RSpec files and identify:
169
+
170
+ 1. `let` and `let!` declarations at various nesting levels
171
+ 2. Context nesting depth for each declaration
172
+ 3. Cases where `let` declarations are redefined in nested scopes
173
+ 4. FactoryBot usage patterns (when enabled)
174
+
175
+ This information helps you prioritize which test files to refactor for better performance using `let_it_be`.
176
+
177
+ ## Contributing
178
+
179
+ Bug reports and pull requests are welcome on GitHub.
180
+
181
+ ## License
182
+
183
+ The gem is available as open source under the terms of the MIT License.
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/rspec_let_analyzer'
5
+ require 'optparse'
6
+
7
+ # Parse command line options
8
+ options = { limit: 10, format: 'ascii', progress: true, nesting: 3, sort: 'total', factories: false, directory: nil,
9
+ runtime_metrics: false, adapter: 'prism', output: nil }
10
+ OptionParser.new do |opts|
11
+ opts.banner = 'Usage: rspec-let-analyzer [options] [DIRECTORY]'
12
+
13
+ opts.on('-n', '--number N', Integer, 'Number of spec files to show (default: 10)') do |n|
14
+ options[:limit] = n
15
+ end
16
+
17
+ opts.on('-f', '--format FORMAT', %w[ascii json html llm], 'Output format: ascii, json, html, llm (default: ascii)') do |f|
18
+ options[:format] = f
19
+ end
20
+
21
+ opts.on('--[no-]progress', 'Show progress while parsing (default: true)') do |p|
22
+ options[:progress] = p
23
+ end
24
+
25
+ opts.on('--nesting DEPTH', Integer, 'Max nesting depth to track (default: 3)') do |d|
26
+ if d <= 0
27
+ puts 'Error: nesting depth must be greater than 0'
28
+ puts 'Use --no-nesting to disable nesting tracking'
29
+ exit 1
30
+ end
31
+ options[:nesting] = d
32
+ end
33
+
34
+ opts.on('--no-nesting', 'Disable nesting tracking') do
35
+ options[:nesting] = nil
36
+ end
37
+
38
+ opts.on('-s', '--sort FIELD', %w[root it redef before total], 'Sort by field: root, it, redef, before, total (default: total)') do |s|
39
+ options[:sort] = s
40
+ end
41
+
42
+ opts.on('--factories', 'Track FactoryBot usage statistics') do
43
+ options[:factories] = true
44
+ end
45
+
46
+ opts.on('-d', '--directory DIR', 'Directory to analyze (default: current directory)') do |d|
47
+ options[:directory] = d
48
+ end
49
+
50
+ opts.on('--runtime-metrics', 'Display runtime performance metrics') do
51
+ options[:runtime_metrics] = true
52
+ end
53
+
54
+ opts.on('-a', '--adapter ADAPTER', %w[prism parser], 'Adapter to use: prism, parser (default: prism)') do |a|
55
+ options[:adapter] = a
56
+ end
57
+
58
+ opts.on('-o', '--output FILE', 'Write output to FILE instead of stdout') do |o|
59
+ options[:output] = o
60
+ end
61
+
62
+ opts.on('-h', '--help', 'Show this help message') do
63
+ puts opts
64
+ exit
65
+ end
66
+ end.parse!
67
+
68
+ # Accept directory as positional argument if not specified with --directory
69
+ options[:directory] = ARGV[0] if ARGV.length.positive? && options[:directory].nil?
70
+
71
+ # Run analysis
72
+ progress_reporter = RSpecLetAnalyzer::ProgressReporter.new(options[:progress])
73
+ analyzer = RSpecLetAnalyzer::Analyzer.new(
74
+ progress_reporter: progress_reporter,
75
+ nesting_depth: options[:nesting],
76
+ track_factories: options[:factories],
77
+ parser_type: options[:adapter].to_sym
78
+ )
79
+
80
+ analyze_start = Time.now
81
+ analyzer.analyze(options[:directory])
82
+ analyze_time = Time.now - analyze_start
83
+
84
+ # Format and output
85
+ formatter = case options[:format]
86
+ when 'json'
87
+ RSpecLetAnalyzer::Formatters::JsonFormatter.new
88
+ when 'html'
89
+ RSpecLetAnalyzer::Formatters::HtmlFormatter.new
90
+ when 'llm'
91
+ RSpecLetAnalyzer::Formatters::LlmFormatter.new
92
+ else
93
+ RSpecLetAnalyzer::Formatters::AsciiFormatter.new
94
+ end
95
+
96
+ format_start = Time.now
97
+ output = formatter.format(analyzer: analyzer, limit: options[:limit], sort_by: options[:sort])
98
+ format_time = Time.now - format_start
99
+
100
+ # Add runtime metrics if requested
101
+ if options[:runtime_metrics]
102
+ runtime_metrics = {
103
+ analyze_time: analyze_time,
104
+ format_time: format_time,
105
+ total_time: analyze_time + format_time
106
+ }
107
+
108
+ output = formatter.add_runtime_metrics(output, runtime_metrics)
109
+ end
110
+
111
+ # Write output to file or stdout
112
+ if options[:output]
113
+ File.write(options[:output], output)
114
+ puts "Report written to #{options[:output]}"
115
+ else
116
+ puts output
117
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecLetAnalyzer
4
+ # Base class for parser adapters
5
+ # Each adapter must implement: parse_file, visit_tree
6
+ class Adapter
7
+ class << self
8
+ def create(type)
9
+ case type
10
+ when 'prism', :prism
11
+ require_relative 'adapters/prism_adapter'
12
+ Adapters::PrismAdapter.new
13
+ when 'parser', :parser
14
+ require_relative 'adapters/parser_adapter'
15
+ Adapters::ParserAdapter.new
16
+ else
17
+ # :nocov:
18
+ raise ArgumentError, "Unknown parser type: #{type}"
19
+ # :nocov:
20
+ end
21
+ end
22
+ end
23
+
24
+ # :nocov:
25
+ # Parse a file and return an AST
26
+ # @param file_path [String] path to the file to parse
27
+ # @return [Object] AST representation
28
+ def parse_file(_file_path)
29
+ raise NotImplementedError, "#{self.class} must implement #parse_file"
30
+ end
31
+
32
+ # Parse code string and return an AST
33
+ # @param code [String] Ruby code to parse
34
+ # @return [Object] AST representation
35
+ def parse(_code)
36
+ raise NotImplementedError, "#{self.class} must implement #parse"
37
+ end
38
+
39
+ # Visit the AST and collect statistics
40
+ # @param ast [Object] the AST to visit
41
+ # @param visitor [Visitor] the visitor to use
42
+ # @return [void]
43
+ def visit_tree(_ast, _visitor)
44
+ raise NotImplementedError, "#{self.class} must implement #visit_tree"
45
+ end
46
+ # :nocov:
47
+ end
48
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'parser/current'
5
+ rescue LoadError
6
+ # :nocov:
7
+ raise LoadError, "The 'parser' gem is not installed. Install it with: gem install parser"
8
+ # :nocov:
9
+ end
10
+
11
+ module RSpecLetAnalyzer
12
+ module Adapters
13
+ class ParserAdapter < Adapter
14
+ def parse_file(file_path)
15
+ Parser::CurrentRuby.parse_file(file_path)
16
+ end
17
+
18
+ def parse(code)
19
+ Parser::CurrentRuby.parse(code)
20
+ end
21
+
22
+ def visit_tree(ast, visitor)
23
+ visitor.process(ast)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module RSpecLetAnalyzer
6
+ module Adapters
7
+ class PrismAdapter < Adapter
8
+ def parse_file(file_path)
9
+ Prism.parse_file(file_path)
10
+ end
11
+
12
+ def parse(code)
13
+ Prism.parse(code)
14
+ end
15
+
16
+ def visit_tree(ast, visitor)
17
+ ast.value.accept(visitor)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecLetAnalyzer
4
+ class Analyzer
5
+ attr_reader :results, :totals, :nesting_depth, :factory_stats
6
+
7
+ def initialize(progress_reporter:, nesting_depth:, track_factories:, parser_type: :prism)
8
+ @results = []
9
+ @nesting_depth = nesting_depth
10
+ @track_factories = track_factories
11
+ @totals = build_totals_hash
12
+ @factory_stats = Hash.new(0) if track_factories
13
+ @progress_reporter = progress_reporter
14
+ @adapter = Adapter.create(parser_type)
15
+ @parser_type = parser_type
16
+ end
17
+
18
+ def analyze(path = nil)
19
+ # Build pattern based on path
20
+ # If path looks like a glob pattern (contains * or ends with .rb), use it directly
21
+ # Otherwise treat it as a directory and append spec/**/*_spec.rb
22
+ pattern = if path.nil?
23
+ 'spec/**/*_spec.rb'
24
+ elsif path.include?('*') || path.end_with?('.rb')
25
+ # It's a glob pattern or specific file, use as-is
26
+ path
27
+ else
28
+ # It's a directory, append spec pattern
29
+ File.join(path, 'spec/**/*_spec.rb')
30
+ end
31
+
32
+ spec_files = Dir.glob(pattern)
33
+ total_files = spec_files.size
34
+
35
+ @results = spec_files.each_with_index.map do |file, index|
36
+ @progress_reporter&.update(current: index + 1, total: total_files, file: file)
37
+
38
+ ast = @adapter.parse_file(file)
39
+ visitor = create_visitor
40
+ @adapter.visit_tree(ast, visitor)
41
+
42
+ # Aggregate factory usage
43
+ if @track_factories && visitor.factory_usage
44
+ visitor.factory_usage.each do |factory, count|
45
+ @factory_stats[factory] += count
46
+ end
47
+ end
48
+
49
+ file_result = {
50
+ file: file,
51
+ root_lets: visitor.root_lets,
52
+ it_blocks: visitor.total_its,
53
+ redefinitions: visitor.redefinitions,
54
+ before_creates: visitor.before_creates
55
+ }
56
+
57
+ if @nesting_depth
58
+ visitor.nesting_counts.each_with_index do |count, i|
59
+ file_result[:"nesting_#{i + 1}"] = count
60
+ end
61
+ end
62
+
63
+ # Calculate total score
64
+ file_result[:total_score] = calculate_file_total_score(file_result, visitor)
65
+
66
+ file_result
67
+ end
68
+
69
+ @progress_reporter&.clear
70
+ calculate_totals
71
+ end
72
+
73
+ def top(limit, sort_by)
74
+ sort_key = case sort_by
75
+ when 'root'
76
+ :root_lets
77
+ when 'it'
78
+ :it_blocks
79
+ when 'redef'
80
+ :redefinitions
81
+ when 'before'
82
+ :before_creates
83
+ when 'total'
84
+ :total_score
85
+ end
86
+
87
+ @results.sort_by { |r| -r[sort_key] }.take(limit)
88
+ end
89
+
90
+ def top_factories(limit)
91
+ return nil unless @track_factories
92
+
93
+ @factory_stats.sort_by { |_factory, count| -count }.take(limit)
94
+ end
95
+
96
+ private
97
+
98
+ def create_visitor
99
+ case @parser_type
100
+ when :prism, 'prism'
101
+ Visitors::PrismVisitor.new(@nesting_depth, @track_factories)
102
+ when :parser, 'parser'
103
+ Visitors::ParserVisitor.new(@nesting_depth, @track_factories)
104
+ else
105
+ raise ArgumentError, "Unknown parser type: #{@parser_type}"
106
+ end
107
+ end
108
+
109
+ def build_totals_hash
110
+ totals = { root_lets: 0, it_blocks: 0, redefinitions: 0, before_creates: 0, total_score: 0 }
111
+ @nesting_depth&.times do |i|
112
+ totals[:"nesting_#{i + 1}"] = 0
113
+ end
114
+ totals
115
+ end
116
+
117
+ def calculate_totals
118
+ @totals = @results.each_with_object(build_totals_hash) do |r, acc|
119
+ acc[:root_lets] += r[:root_lets]
120
+ acc[:it_blocks] += r[:it_blocks]
121
+ acc[:redefinitions] += r[:redefinitions]
122
+ acc[:before_creates] += r[:before_creates]
123
+ acc[:total_score] += r[:total_score]
124
+
125
+ @nesting_depth&.times do |i|
126
+ acc[:"nesting_#{i + 1}"] += r[:"nesting_#{i + 1}"]
127
+ end
128
+ end
129
+ end
130
+
131
+ def calculate_file_total_score(file_result, visitor)
132
+ total = file_result[:root_lets] + file_result[:it_blocks] +
133
+ file_result[:redefinitions] + file_result[:before_creates]
134
+
135
+ if @nesting_depth
136
+ visitor.nesting_counts.each do |count|
137
+ total += count
138
+ end
139
+ end
140
+
141
+ total
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpecLetAnalyzer
4
+ module Formatters
5
+ class AsciiFormatter
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
+ # Calculate column widths
13
+ base_width = 60
14
+ col_width = 10
15
+
16
+ # Build header
17
+ output = []
18
+
19
+ # Calculate total table width
20
+ num_cols = 5 + (nesting_depth || 0) # Root, it blocks, Redef, Before Creates, Total + nesting columns
21
+ separator = "+#{(['-' * base_width] + (['-' * col_width] * num_cols)).join('+')}+"
22
+
23
+ output << separator
24
+
25
+ # Header row
26
+ headers = ['File', 'Total', 'Root', 'it blocks']
27
+ if nesting_depth
28
+ (1...nesting_depth).each { |i| headers << "Nest #{i}" }
29
+ headers << "Nest #{nesting_depth}+"
30
+ end
31
+ headers << 'Redef'
32
+ headers << 'Bef Cr'
33
+
34
+ header_format = "| %-58s |#{' %8s |' * (headers.size - 1)}"
35
+ output << (header_format % headers)
36
+ output << separator
37
+
38
+ # Data rows
39
+ sorted.each do |result|
40
+ file_display = result[:file].size > 58 ? "...#{result[:file][-55..]}" : result[:file]
41
+ values = [file_display, result[:total_score], result[:root_lets], result[:it_blocks]]
42
+
43
+ nesting_depth&.times do |i|
44
+ values << result[:"nesting_#{i + 1}"]
45
+ end
46
+
47
+ values << result[:redefinitions]
48
+ values << result[:before_creates]
49
+
50
+ row_format = "| %-58s |#{' %8d |' * (values.size - 1)}"
51
+ output << (row_format % values)
52
+ end
53
+
54
+ # Totals row
55
+ output << separator
56
+ total_values = ["TOTAL (all #{total_files} files)", totals[:total_score], totals[:root_lets], totals[:it_blocks]]
57
+
58
+ nesting_depth&.times { total_values << '' }
59
+
60
+ total_values << totals[:redefinitions]
61
+ total_values << totals[:before_creates]
62
+
63
+ total_format = '| %-58s | %8d | %8d | %8d |'
64
+ total_format += (' %8s |' * nesting_depth) if nesting_depth
65
+ total_format += ' %8d | %8d |'
66
+
67
+ output << (total_format % total_values)
68
+ output << separator
69
+
70
+ formatted_output = output.join("\n")
71
+
72
+ # Add FactoryBot section if enabled
73
+ formatted_output += "\n\n#{format_factory_stats(analyzer, limit)}" if analyzer.factory_stats
74
+
75
+ formatted_output
76
+ end
77
+
78
+ private
79
+
80
+ def format_factory_stats(analyzer, limit)
81
+ top_factories = analyzer.top_factories(limit)
82
+ return '' if top_factories.nil? || top_factories.empty?
83
+
84
+ output = []
85
+ output << "FactoryBot Usage (Top #{limit})"
86
+ output << "+#{'-' * 40}+#{'-' * 15}+"
87
+ output << '| Factory | Usage Count |'
88
+ output << "+#{'-' * 40}+#{'-' * 15}+"
89
+
90
+ top_factories.each do |factory, count|
91
+ factory_display = factory.to_s.size > 38 ? "#{factory.to_s[0..34]}..." : factory.to_s
92
+ output << Kernel.format('| %-38s | %13d |', factory_display, count)
93
+ end
94
+
95
+ output << "+#{'-' * 40}+#{'-' * 15}+"
96
+
97
+ total_usage = analyzer.factory_stats.values.sum
98
+ total_unique = analyzer.factory_stats.size
99
+ output << ''
100
+ output << "Total unique factories: #{total_unique}"
101
+ output << "Total factory usage: #{total_usage}"
102
+
103
+ output.join("\n")
104
+ end
105
+
106
+ public
107
+
108
+ def add_runtime_metrics(output, metrics)
109
+ output += "\n\n"
110
+ output += "Runtime Metrics:\n"
111
+ output += " Analysis: #{format_duration(metrics[:analyze_time])}\n"
112
+ output += " Formatting: #{format_duration(metrics[:format_time])}\n"
113
+ output += " Total: #{format_duration(metrics[:total_time])}"
114
+ output
115
+ end
116
+
117
+ private
118
+
119
+ def format_duration(seconds)
120
+ if seconds < 1
121
+ "#{(seconds * 1000).round(2)}ms"
122
+ else
123
+ "#{seconds.round(2)}s"
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end