hone 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/.standard.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/Rakefile +10 -0
- data/examples/.hone/harness.rb +41 -0
- data/examples/README.md +22 -0
- data/examples/allocation_patterns.rb +66 -0
- data/examples/cpu_patterns.rb +50 -0
- data/examples/jit_patterns.rb +69 -0
- data/exe/hone +7 -0
- data/lib/hone/adapters/base.rb +35 -0
- data/lib/hone/adapters/fasterer.rb +38 -0
- data/lib/hone/adapters/rubocop_performance.rb +85 -0
- data/lib/hone/analyzer.rb +258 -0
- data/lib/hone/cli.rb +247 -0
- data/lib/hone/config.rb +93 -0
- data/lib/hone/correlator.rb +250 -0
- data/lib/hone/exit_codes.rb +10 -0
- data/lib/hone/finding.rb +64 -0
- data/lib/hone/finding_filter.rb +57 -0
- data/lib/hone/formatters/base.rb +25 -0
- data/lib/hone/formatters/filterable.rb +31 -0
- data/lib/hone/formatters/github.rb +71 -0
- data/lib/hone/formatters/json.rb +75 -0
- data/lib/hone/formatters/junit.rb +154 -0
- data/lib/hone/formatters/sarif.rb +179 -0
- data/lib/hone/formatters/tsv.rb +49 -0
- data/lib/hone/harness.rb +57 -0
- data/lib/hone/harness_generator.rb +128 -0
- data/lib/hone/harness_runner.rb +172 -0
- data/lib/hone/method_map.rb +140 -0
- data/lib/hone/patterns/README.md +174 -0
- data/lib/hone/patterns/array_compact.rb +105 -0
- data/lib/hone/patterns/array_include_set.rb +34 -0
- data/lib/hone/patterns/base.rb +90 -0
- data/lib/hone/patterns/block_to_proc.rb +109 -0
- data/lib/hone/patterns/bsearch_vs_find.rb +80 -0
- data/lib/hone/patterns/chars_map_ord.rb +42 -0
- data/lib/hone/patterns/chars_to_variable.rb +136 -0
- data/lib/hone/patterns/chars_to_variable_tainted.rb +136 -0
- data/lib/hone/patterns/constant_regexp.rb +74 -0
- data/lib/hone/patterns/count_vs_size.rb +35 -0
- data/lib/hone/patterns/divmod.rb +92 -0
- data/lib/hone/patterns/dynamic_ivar.rb +44 -0
- data/lib/hone/patterns/dynamic_ivar_get.rb +33 -0
- data/lib/hone/patterns/each_with_index.rb +116 -0
- data/lib/hone/patterns/each_with_object.rb +63 -0
- data/lib/hone/patterns/flatten_once.rb +28 -0
- data/lib/hone/patterns/gsub_to_tr.rb +48 -0
- data/lib/hone/patterns/hash_each_key.rb +41 -0
- data/lib/hone/patterns/hash_each_value.rb +31 -0
- data/lib/hone/patterns/hash_keys_include.rb +30 -0
- data/lib/hone/patterns/hash_merge_bang.rb +33 -0
- data/lib/hone/patterns/hash_values_include.rb +31 -0
- data/lib/hone/patterns/inject_sum.rb +48 -0
- data/lib/hone/patterns/kernel_loop.rb +27 -0
- data/lib/hone/patterns/lazy_ivar.rb +39 -0
- data/lib/hone/patterns/map_compact.rb +32 -0
- data/lib/hone/patterns/map_flatten.rb +31 -0
- data/lib/hone/patterns/map_select_chain.rb +32 -0
- data/lib/hone/patterns/parallel_assignment.rb +127 -0
- data/lib/hone/patterns/positive_predicate.rb +27 -0
- data/lib/hone/patterns/range_include.rb +34 -0
- data/lib/hone/patterns/redundant_string_chars.rb +82 -0
- data/lib/hone/patterns/regexp_match.rb +126 -0
- data/lib/hone/patterns/reverse_each.rb +30 -0
- data/lib/hone/patterns/reverse_first.rb +40 -0
- data/lib/hone/patterns/select_count.rb +32 -0
- data/lib/hone/patterns/select_first.rb +31 -0
- data/lib/hone/patterns/select_map.rb +32 -0
- data/lib/hone/patterns/shuffle_first.rb +30 -0
- data/lib/hone/patterns/slice_with_length.rb +48 -0
- data/lib/hone/patterns/sort_by_first.rb +31 -0
- data/lib/hone/patterns/sort_by_last.rb +31 -0
- data/lib/hone/patterns/sort_first.rb +52 -0
- data/lib/hone/patterns/sort_last.rb +30 -0
- data/lib/hone/patterns/sort_reverse.rb +53 -0
- data/lib/hone/patterns/string_casecmp.rb +54 -0
- data/lib/hone/patterns/string_chars_each.rb +56 -0
- data/lib/hone/patterns/string_concat_in_loop.rb +116 -0
- data/lib/hone/patterns/string_delete_prefix.rb +53 -0
- data/lib/hone/patterns/string_delete_suffix.rb +53 -0
- data/lib/hone/patterns/string_empty.rb +64 -0
- data/lib/hone/patterns/string_end_with.rb +81 -0
- data/lib/hone/patterns/string_shovel.rb +75 -0
- data/lib/hone/patterns/string_start_with.rb +80 -0
- data/lib/hone/patterns/taint_tracking_base.rb +230 -0
- data/lib/hone/patterns/times_map.rb +38 -0
- data/lib/hone/patterns/uniq_by.rb +32 -0
- data/lib/hone/patterns/yield_vs_block.rb +72 -0
- data/lib/hone/profilers/base.rb +162 -0
- data/lib/hone/profilers/factory.rb +31 -0
- data/lib/hone/profilers/memory_profiler.rb +213 -0
- data/lib/hone/profilers/stackprof.rb +99 -0
- data/lib/hone/profilers/vernier.rb +147 -0
- data/lib/hone/reporter.rb +371 -0
- data/lib/hone/scanner.rb +75 -0
- data/lib/hone/suggestion_generator.rb +23 -0
- data/lib/hone/version.rb +5 -0
- data/lib/hone.rb +108 -0
- data/logo.png +0 -0
- data/sig/hone.rbs +4 -0
- metadata +176 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
# Orchestrates the full analysis pipeline:
|
|
5
|
+
# Scanner -> MethodMap -> Profiler -> Correlator -> Reporter
|
|
6
|
+
#
|
|
7
|
+
# @example Basic usage (static analysis only)
|
|
8
|
+
# analyzer = Hone::Analyzer.new("app.rb")
|
|
9
|
+
# result = analyzer.run
|
|
10
|
+
# puts result.output
|
|
11
|
+
#
|
|
12
|
+
# @example With profile correlation
|
|
13
|
+
# analyzer = Hone::Analyzer.new("app.rb", profile: "profile.json")
|
|
14
|
+
# result = analyzer.run
|
|
15
|
+
#
|
|
16
|
+
# @example Directory analysis
|
|
17
|
+
# analyzer = Hone::Analyzer.new("lib/", format: :json)
|
|
18
|
+
# result = analyzer.run
|
|
19
|
+
#
|
|
20
|
+
class Analyzer
|
|
21
|
+
# Default directory for cached profile files
|
|
22
|
+
PROFILE_DIR = "tmp/hone"
|
|
23
|
+
|
|
24
|
+
# Result of analysis containing findings, output, and exit code
|
|
25
|
+
Result = Data.define(:findings, :output, :exit_code)
|
|
26
|
+
|
|
27
|
+
# @param path [String] File or directory to analyze
|
|
28
|
+
# @param profile [String, nil] Path to StackProf JSON profile
|
|
29
|
+
# @param memory_profile [String, nil] Path to MemoryProfiler JSON profile
|
|
30
|
+
# @param format [Symbol] Output format: :text, :json, :github
|
|
31
|
+
# @param color [Boolean, nil] Force color output (nil = auto-detect)
|
|
32
|
+
# @param quiet [Boolean] Minimal output
|
|
33
|
+
# @param verbose [Boolean] Extended output with pattern details
|
|
34
|
+
# @param top [Integer, nil] Show only top N findings
|
|
35
|
+
# @param hot_only [Boolean] Only show HOT findings
|
|
36
|
+
# @param show_cold [Boolean] Include COLD findings
|
|
37
|
+
# @param fail_on [Symbol] Exit non-zero for: :hot, :warm, :any, :none
|
|
38
|
+
# @param diff [String, nil] Git ref to compare against for changed files
|
|
39
|
+
# @param baseline [String, nil] Path to baseline JSON file to suppress findings
|
|
40
|
+
# @param rails [Boolean] Enable Rails-specific analysis
|
|
41
|
+
def initialize(path, profile: nil, memory_profile: nil, format: :text, color: nil, quiet: false, verbose: false,
|
|
42
|
+
top: nil, hot_only: false, show_cold: false, fail_on: :hot, diff: nil, baseline: nil, rails: false)
|
|
43
|
+
@path = path
|
|
44
|
+
@profile_path = profile || auto_detect_profile(:cpu)
|
|
45
|
+
@memory_profile_path = memory_profile || auto_detect_profile(:memory)
|
|
46
|
+
@format = format.to_sym
|
|
47
|
+
@color = color
|
|
48
|
+
@quiet = quiet
|
|
49
|
+
@verbose = verbose
|
|
50
|
+
@top = top
|
|
51
|
+
@hot_only = hot_only
|
|
52
|
+
@show_cold = show_cold
|
|
53
|
+
@fail_on = fail_on.to_sym
|
|
54
|
+
@diff = diff
|
|
55
|
+
@baseline = baseline
|
|
56
|
+
@rails = rails
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Run the analysis pipeline
|
|
60
|
+
# @return [Result] Analysis result with findings, output, and exit code
|
|
61
|
+
def run
|
|
62
|
+
validate_input!
|
|
63
|
+
|
|
64
|
+
# Step 1: Scan for patterns
|
|
65
|
+
findings = scan_path
|
|
66
|
+
|
|
67
|
+
# Step 2: Build method map for correlation
|
|
68
|
+
method_map = build_method_map
|
|
69
|
+
|
|
70
|
+
# Step 3: Load profile data (if available)
|
|
71
|
+
profiles = load_profiles
|
|
72
|
+
|
|
73
|
+
# Step 4: Correlate findings with profile
|
|
74
|
+
correlator = Correlator.new(
|
|
75
|
+
method_map: method_map,
|
|
76
|
+
cpu_profile: profiles[:cpu],
|
|
77
|
+
memory_profile: profiles[:memory]
|
|
78
|
+
)
|
|
79
|
+
correlated = correlator.correlate(findings)
|
|
80
|
+
|
|
81
|
+
# Step 5: Apply filtering
|
|
82
|
+
filtered = apply_filters(correlated)
|
|
83
|
+
|
|
84
|
+
# Step 6: Generate output
|
|
85
|
+
output = generate_output(filtered)
|
|
86
|
+
|
|
87
|
+
# Step 7: Calculate exit code
|
|
88
|
+
exit_code = calculate_exit_code(filtered)
|
|
89
|
+
|
|
90
|
+
Result.new(findings: filtered, output: output, exit_code: exit_code)
|
|
91
|
+
rescue Error => e
|
|
92
|
+
Result.new(findings: [], output: "Error: #{e.message}", exit_code: ExitCodes::ERROR)
|
|
93
|
+
rescue => e
|
|
94
|
+
Result.new(findings: [], output: "Error: #{e.message}\n#{e.backtrace.first(5).join("\n")}", exit_code: ExitCodes::ERROR)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def validate_input!
|
|
100
|
+
raise Error, "Path not found: #{@path}" unless File.exist?(@path)
|
|
101
|
+
|
|
102
|
+
raise Error, "Profile not found: #{@profile_path}" if @profile_path && !File.exist?(@profile_path)
|
|
103
|
+
|
|
104
|
+
raise Error, "Baseline not found: #{@baseline}" if @baseline && !File.exist?(@baseline)
|
|
105
|
+
|
|
106
|
+
raise Error, "Memory profile not found: #{@memory_profile_path}" if @memory_profile_path && !File.exist?(@memory_profile_path)
|
|
107
|
+
|
|
108
|
+
unless %i[text json github sarif junit tsv].include?(@format)
|
|
109
|
+
raise Error, "Invalid format: #{@format}. Use text, json, github, sarif, junit, or tsv."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return if %i[hot warm any none].include?(@fail_on)
|
|
113
|
+
|
|
114
|
+
raise Error, "Invalid fail_on: #{@fail_on}. Use hot, warm, any, or none."
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def scan_path
|
|
118
|
+
scanner = Scanner.new(rails: @rails)
|
|
119
|
+
|
|
120
|
+
if File.directory?(@path)
|
|
121
|
+
scanner.scan_directory(@path)
|
|
122
|
+
else
|
|
123
|
+
scanner.scan_file(@path)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def build_method_map
|
|
128
|
+
method_map = MethodMap.new
|
|
129
|
+
|
|
130
|
+
if File.directory?(@path)
|
|
131
|
+
Dir.glob(File.join(@path, "**/*.rb")).each do |file|
|
|
132
|
+
method_map.add_file(file)
|
|
133
|
+
end
|
|
134
|
+
else
|
|
135
|
+
method_map.add_file(@path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
method_map
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def load_profiles
|
|
142
|
+
{
|
|
143
|
+
cpu: load_cpu_profile,
|
|
144
|
+
memory: load_memory_profile
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def load_cpu_profile
|
|
149
|
+
Profilers::Factory.create(@profile_path)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def load_memory_profile
|
|
153
|
+
return nil unless @memory_profile_path
|
|
154
|
+
|
|
155
|
+
Profilers::MemoryProfiler.new(@memory_profile_path)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def apply_filters(findings)
|
|
159
|
+
FindingFilter.new(findings, filter_options).apply
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def filter_options
|
|
163
|
+
{
|
|
164
|
+
diff: @diff,
|
|
165
|
+
baseline: @baseline,
|
|
166
|
+
hot_only: @hot_only,
|
|
167
|
+
show_cold: @show_cold,
|
|
168
|
+
profile_path: @profile_path,
|
|
169
|
+
top: @top
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def generate_output(findings)
|
|
174
|
+
case @format
|
|
175
|
+
when :json
|
|
176
|
+
Formatters::JSON.new(findings, reporter_options).format
|
|
177
|
+
when :github
|
|
178
|
+
Formatters::GitHub.new(findings, reporter_options).format
|
|
179
|
+
when :sarif
|
|
180
|
+
Formatters::SARIF.new(findings, reporter_options).format
|
|
181
|
+
when :junit
|
|
182
|
+
Formatters::JUnit.new(findings, reporter_options).format
|
|
183
|
+
when :tsv
|
|
184
|
+
Formatters::TSV.new(findings, reporter_options).format
|
|
185
|
+
else
|
|
186
|
+
Reporter.new(findings, reporter_options).report
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def reporter_options
|
|
191
|
+
{
|
|
192
|
+
show_cold: @show_cold,
|
|
193
|
+
quiet: @quiet,
|
|
194
|
+
verbose: @verbose,
|
|
195
|
+
color: determine_color,
|
|
196
|
+
profile_path: @profile_path,
|
|
197
|
+
memory_profile_path: @memory_profile_path,
|
|
198
|
+
file_path: @path,
|
|
199
|
+
mode: @profile_path ? "correlated" : "static"
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def determine_color
|
|
204
|
+
return @color unless @color.nil?
|
|
205
|
+
|
|
206
|
+
# Auto-detect: color if TTY and NO_COLOR not set
|
|
207
|
+
$stdout.tty? && !ENV["NO_COLOR"]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def calculate_exit_code(findings)
|
|
211
|
+
return ExitCodes::SUCCESS if findings.empty?
|
|
212
|
+
|
|
213
|
+
case @fail_on
|
|
214
|
+
when :none
|
|
215
|
+
ExitCodes::SUCCESS
|
|
216
|
+
when :any
|
|
217
|
+
ExitCodes::HOT
|
|
218
|
+
when :hot
|
|
219
|
+
has_hot = findings.any? { |f| f.priority == :hot }
|
|
220
|
+
has_hot ? ExitCodes::HOT : ExitCodes::SUCCESS
|
|
221
|
+
when :warm
|
|
222
|
+
has_hot = findings.any? { |f| f.priority == :hot }
|
|
223
|
+
has_warm = findings.any? { |f| f.priority == :warm }
|
|
224
|
+
|
|
225
|
+
if has_hot
|
|
226
|
+
ExitCodes::HOT
|
|
227
|
+
elsif has_warm
|
|
228
|
+
ExitCodes::WARM
|
|
229
|
+
else
|
|
230
|
+
ExitCodes::SUCCESS
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
ExitCodes::SUCCESS
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def auto_detect_profile(type)
|
|
238
|
+
filename = case type
|
|
239
|
+
when :cpu then "cpu_profile.json"
|
|
240
|
+
when :memory then "memory_profile.json"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
path = File.join(PROFILE_DIR, filename)
|
|
244
|
+
|
|
245
|
+
if File.exist?(path)
|
|
246
|
+
metadata_path = File.join(PROFILE_DIR, "metadata.json")
|
|
247
|
+
if File.exist?(metadata_path)
|
|
248
|
+
metadata = JSON.parse(File.read(metadata_path))
|
|
249
|
+
generated = metadata["generated_at"]
|
|
250
|
+
warn "Using cached #{type} profile from #{path} (generated: #{generated})"
|
|
251
|
+
else
|
|
252
|
+
warn "Using cached #{type} profile from #{path}"
|
|
253
|
+
end
|
|
254
|
+
path
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
data/lib/hone/cli.rb
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "harness"
|
|
5
|
+
require_relative "harness_generator"
|
|
6
|
+
require_relative "harness_runner"
|
|
7
|
+
|
|
8
|
+
module Hone
|
|
9
|
+
# Command-line interface for Hone Ruby performance analyzer.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# hone analyze FILE # Static analysis (no profile)
|
|
13
|
+
# hone analyze FILE --profile FILE # Correlation mode (prioritized)
|
|
14
|
+
# hone analyze DIR # Analyze directory
|
|
15
|
+
#
|
|
16
|
+
# @example Output control
|
|
17
|
+
# hone analyze FILE --format json # JSON output
|
|
18
|
+
# hone analyze FILE --format github # GitHub Actions annotations
|
|
19
|
+
# hone analyze FILE --quiet # Minimal output
|
|
20
|
+
# hone analyze FILE --no-color # Disable colors
|
|
21
|
+
#
|
|
22
|
+
# @example Filtering
|
|
23
|
+
# hone analyze FILE --top=10 # Show only top 10 findings
|
|
24
|
+
# hone analyze FILE --hot-only # Only HOT findings
|
|
25
|
+
# hone analyze FILE --show-cold # Include COLD findings
|
|
26
|
+
#
|
|
27
|
+
# @example CI integration
|
|
28
|
+
# hone analyze FILE --fail-on hot # Exit 1 for HOT (default)
|
|
29
|
+
# hone analyze FILE --fail-on warm # Exit 2 for WARM, 1 for HOT
|
|
30
|
+
# hone analyze FILE --fail-on any # Exit 1 for any finding
|
|
31
|
+
# hone analyze FILE --fail-on none # Always exit 0
|
|
32
|
+
#
|
|
33
|
+
class CLI < Thor
|
|
34
|
+
def self.exit_on_failure?
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "version", "Print version"
|
|
39
|
+
def version
|
|
40
|
+
puts "Hone v#{Hone::VERSION}"
|
|
41
|
+
end
|
|
42
|
+
map %w[-v --version] => :version
|
|
43
|
+
|
|
44
|
+
desc "analyze PATH", "Analyze Ruby file(s) for optimization opportunities"
|
|
45
|
+
long_desc <<~DESC
|
|
46
|
+
Analyze Ruby source files for performance optimization opportunities.
|
|
47
|
+
|
|
48
|
+
When run with --profile, findings are correlated with runtime profiling data
|
|
49
|
+
to prioritize optimizations that will have the greatest impact. Findings are
|
|
50
|
+
categorized as HOT (>5% CPU), WARM (1-5% CPU), or COLD (<1% CPU).
|
|
51
|
+
|
|
52
|
+
Without --profile, all findings are reported without prioritization.
|
|
53
|
+
|
|
54
|
+
Exit codes:
|
|
55
|
+
0 - No findings, or only COLD findings
|
|
56
|
+
1 - HOT findings exist
|
|
57
|
+
2 - WARM findings exist (no HOT)
|
|
58
|
+
3 - Error occurred
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
hone analyze app.rb
|
|
62
|
+
hone analyze lib/ --profile profile.json
|
|
63
|
+
hone analyze . --format github --fail-on warm
|
|
64
|
+
DESC
|
|
65
|
+
option :profile,
|
|
66
|
+
type: :string,
|
|
67
|
+
desc: "StackProf JSON profile for correlation",
|
|
68
|
+
aliases: "-p"
|
|
69
|
+
option :memory_profile,
|
|
70
|
+
type: :string,
|
|
71
|
+
desc: "MemoryProfiler JSON for allocation correlation",
|
|
72
|
+
aliases: "-m"
|
|
73
|
+
option :format,
|
|
74
|
+
type: :string,
|
|
75
|
+
default: "text",
|
|
76
|
+
desc: "Output format: text, json, github, sarif, junit, tsv",
|
|
77
|
+
aliases: "-f"
|
|
78
|
+
option :color,
|
|
79
|
+
type: :boolean,
|
|
80
|
+
default: nil,
|
|
81
|
+
desc: "Force color output (default: auto-detect TTY)"
|
|
82
|
+
option :quiet,
|
|
83
|
+
type: :boolean,
|
|
84
|
+
default: false,
|
|
85
|
+
desc: "Minimal output (one line per finding)",
|
|
86
|
+
aliases: "-q"
|
|
87
|
+
option :verbose,
|
|
88
|
+
type: :boolean,
|
|
89
|
+
default: false,
|
|
90
|
+
desc: "Extended output with pattern details",
|
|
91
|
+
aliases: "-V"
|
|
92
|
+
option :top,
|
|
93
|
+
type: :numeric,
|
|
94
|
+
desc: "Show only top N findings"
|
|
95
|
+
option :hot_only,
|
|
96
|
+
type: :boolean,
|
|
97
|
+
default: false,
|
|
98
|
+
desc: "Only show HOT findings"
|
|
99
|
+
option :show_cold,
|
|
100
|
+
type: :boolean,
|
|
101
|
+
default: false,
|
|
102
|
+
desc: "Include COLD findings in output"
|
|
103
|
+
option :fail_on,
|
|
104
|
+
type: :string,
|
|
105
|
+
default: "hot",
|
|
106
|
+
desc: "Exit non-zero for: hot (default), warm, any, none"
|
|
107
|
+
option :diff,
|
|
108
|
+
type: :string,
|
|
109
|
+
desc: "Only analyze files changed since BASE ref"
|
|
110
|
+
option :baseline,
|
|
111
|
+
type: :string,
|
|
112
|
+
desc: "Suppress findings in baseline JSON file"
|
|
113
|
+
option :rails,
|
|
114
|
+
type: :boolean,
|
|
115
|
+
default: false,
|
|
116
|
+
desc: "Include Rails/ActiveSupport-specific optimizations"
|
|
117
|
+
def analyze(path)
|
|
118
|
+
analyzer = Analyzer.new(
|
|
119
|
+
path,
|
|
120
|
+
profile: options[:profile],
|
|
121
|
+
memory_profile: options[:memory_profile],
|
|
122
|
+
format: options[:format],
|
|
123
|
+
color: options[:color],
|
|
124
|
+
quiet: options[:quiet],
|
|
125
|
+
verbose: options[:verbose],
|
|
126
|
+
top: options[:top],
|
|
127
|
+
hot_only: options[:hot_only],
|
|
128
|
+
show_cold: options[:show_cold],
|
|
129
|
+
fail_on: options[:fail_on],
|
|
130
|
+
diff: options[:diff],
|
|
131
|
+
baseline: options[:baseline],
|
|
132
|
+
rails: options[:rails]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
result = analyzer.run
|
|
136
|
+
|
|
137
|
+
puts result.output unless result.output.empty?
|
|
138
|
+
|
|
139
|
+
exit result.exit_code
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
desc "init COMPONENT", "Initialize Hone components"
|
|
143
|
+
long_desc <<~DESC
|
|
144
|
+
Initialize Hone components in your project.
|
|
145
|
+
|
|
146
|
+
Components:
|
|
147
|
+
harness - Generate a performance harness template
|
|
148
|
+
|
|
149
|
+
Examples:
|
|
150
|
+
hone init harness # Generate basic Ruby harness
|
|
151
|
+
hone init harness --rails # Generate Rails-specific harness
|
|
152
|
+
DESC
|
|
153
|
+
option :rails,
|
|
154
|
+
type: :boolean,
|
|
155
|
+
default: false,
|
|
156
|
+
desc: "Generate Rails-specific harness template"
|
|
157
|
+
def init(component)
|
|
158
|
+
case component
|
|
159
|
+
when "harness"
|
|
160
|
+
generator = HarnessGenerator.new(rails: options[:rails])
|
|
161
|
+
generator.generate
|
|
162
|
+
else
|
|
163
|
+
puts "Unknown component: #{component}"
|
|
164
|
+
puts "Available components: harness"
|
|
165
|
+
exit 1
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
desc "profile", "Run harness and generate performance profiles"
|
|
170
|
+
long_desc <<~DESC
|
|
171
|
+
Run the performance harness and generate CPU (and optionally memory) profiles.
|
|
172
|
+
|
|
173
|
+
The harness file should define setup, exercise, and teardown blocks.
|
|
174
|
+
Profiles are saved to tmp/hone/ by default.
|
|
175
|
+
|
|
176
|
+
Examples:
|
|
177
|
+
hone profile # Run default harness
|
|
178
|
+
hone profile --memory # Include memory profiling
|
|
179
|
+
hone profile --analyze # Profile then analyze
|
|
180
|
+
hone profile --harness custom.rb # Use custom harness file
|
|
181
|
+
DESC
|
|
182
|
+
option :harness,
|
|
183
|
+
type: :string,
|
|
184
|
+
default: ".hone/harness.rb",
|
|
185
|
+
desc: "Path to harness file"
|
|
186
|
+
option :profiler,
|
|
187
|
+
type: :string,
|
|
188
|
+
enum: %w[auto stackprof vernier],
|
|
189
|
+
default: "auto",
|
|
190
|
+
desc: "CPU profiler to use"
|
|
191
|
+
option :memory,
|
|
192
|
+
type: :boolean,
|
|
193
|
+
default: false,
|
|
194
|
+
desc: "Include memory profiling"
|
|
195
|
+
option :warmup,
|
|
196
|
+
type: :numeric,
|
|
197
|
+
default: 10,
|
|
198
|
+
desc: "Warmup iterations before profiling"
|
|
199
|
+
option :output,
|
|
200
|
+
type: :string,
|
|
201
|
+
default: "tmp/hone",
|
|
202
|
+
desc: "Output directory for profiles"
|
|
203
|
+
option :analyze,
|
|
204
|
+
type: :boolean,
|
|
205
|
+
default: false,
|
|
206
|
+
desc: "Run analysis after profiling"
|
|
207
|
+
def profile
|
|
208
|
+
unless File.exist?(options[:harness])
|
|
209
|
+
puts "Harness file not found: #{options[:harness]}"
|
|
210
|
+
puts "Run 'hone init harness' to generate a template."
|
|
211
|
+
exit 1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
profiler_opt = (options[:profiler] == "auto") ? nil : options[:profiler].to_sym
|
|
215
|
+
|
|
216
|
+
runner = HarnessRunner.new(
|
|
217
|
+
options[:harness],
|
|
218
|
+
profiler: profiler_opt,
|
|
219
|
+
memory: options[:memory],
|
|
220
|
+
warmup: options[:warmup],
|
|
221
|
+
output_dir: options[:output]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
puts "Loading harness from #{options[:harness]}..."
|
|
225
|
+
puts "Warmup: #{options[:warmup]} iterations"
|
|
226
|
+
puts "Profiler: #{profiler_opt || "auto-detect"}"
|
|
227
|
+
puts
|
|
228
|
+
|
|
229
|
+
profiles = runner.run
|
|
230
|
+
|
|
231
|
+
puts "Profiles generated:"
|
|
232
|
+
puts " CPU: #{profiles[:cpu]}"
|
|
233
|
+
puts " Memory: #{profiles[:memory]}" if profiles[:memory]
|
|
234
|
+
puts
|
|
235
|
+
|
|
236
|
+
if options[:analyze]
|
|
237
|
+
puts "Running analysis..."
|
|
238
|
+
invoke :analyze, ["."], profile: profiles[:cpu], memory_profile: profiles[:memory]
|
|
239
|
+
else
|
|
240
|
+
puts "Run analysis with:"
|
|
241
|
+
puts " hone analyze . --profile #{profiles[:cpu]}"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
default_task :analyze
|
|
246
|
+
end
|
|
247
|
+
end
|
data/lib/hone/config.rb
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Hone
|
|
6
|
+
class Config
|
|
7
|
+
CONFIG_FILE = ".hone/config.yml"
|
|
8
|
+
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
harness: {
|
|
11
|
+
warmup_iterations: 10,
|
|
12
|
+
profile_iterations: 100,
|
|
13
|
+
path: ".hone/harness.rb"
|
|
14
|
+
},
|
|
15
|
+
profilers: {
|
|
16
|
+
cpu: "auto",
|
|
17
|
+
memory: false
|
|
18
|
+
},
|
|
19
|
+
output: {
|
|
20
|
+
dir: "tmp/hone"
|
|
21
|
+
},
|
|
22
|
+
analysis: {
|
|
23
|
+
rails: false,
|
|
24
|
+
show_cold: false,
|
|
25
|
+
top: nil
|
|
26
|
+
}
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(path = CONFIG_FILE)
|
|
30
|
+
@path = path
|
|
31
|
+
@data = load_config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def harness
|
|
35
|
+
@data[:harness]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def profilers
|
|
39
|
+
@data[:profilers]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def output
|
|
43
|
+
@data[:output]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def analysis
|
|
47
|
+
@data[:analysis]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def [](key)
|
|
51
|
+
@data[key.to_sym]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def exist?
|
|
55
|
+
File.exist?(@path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def load_config
|
|
61
|
+
return deep_symbolize(DEFAULTS.dup) unless File.exist?(@path)
|
|
62
|
+
|
|
63
|
+
user_config = YAML.safe_load_file(@path, symbolize_names: true) || {}
|
|
64
|
+
deep_merge(DEFAULTS, user_config)
|
|
65
|
+
rescue Psych::SyntaxError => e
|
|
66
|
+
warn "Warning: Invalid config file #{@path}: #{e.message}"
|
|
67
|
+
deep_symbolize(DEFAULTS.dup)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def deep_merge(base, override)
|
|
71
|
+
result = {}
|
|
72
|
+
base.each do |key, value|
|
|
73
|
+
result[key] = if value.is_a?(Hash) && override[key].is_a?(Hash)
|
|
74
|
+
deep_merge(value, override[key])
|
|
75
|
+
elsif override.key?(key)
|
|
76
|
+
override[key]
|
|
77
|
+
else
|
|
78
|
+
value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
override.each do |key, value|
|
|
82
|
+
result[key] = value unless result.key?(key)
|
|
83
|
+
end
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def deep_symbolize(hash)
|
|
88
|
+
hash.transform_keys(&:to_sym).transform_values do |v|
|
|
89
|
+
v.is_a?(Hash) ? deep_symbolize(v) : v
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|