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,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
# Correlates static analysis findings with runtime profiler data to prioritize
|
|
5
|
+
# optimizations based on actual CPU and allocation impact.
|
|
6
|
+
#
|
|
7
|
+
# Hone's core value proposition is that not all optimization opportunities are
|
|
8
|
+
# equal. A pattern detected in a hot method that consumes 10% of CPU time is
|
|
9
|
+
# far more valuable to fix than the same pattern in cold initialization code.
|
|
10
|
+
#
|
|
11
|
+
# The Correlator bridges static and dynamic analysis by:
|
|
12
|
+
# 1. Looking up which method contains each finding
|
|
13
|
+
# 2. Retrieving CPU percentage from profiler data (if available)
|
|
14
|
+
# 3. Retrieving allocation percentage from memory profile (if available)
|
|
15
|
+
# 4. Assigning priority based on thresholds and optimization type
|
|
16
|
+
# 5. Sorting findings by impact (hottest first)
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage with profile data
|
|
19
|
+
# method_map = Hone::MethodMap.new.add_file("app.rb")
|
|
20
|
+
# cpu_profile = Hone::ProfileData.load("cpu_profile.json")
|
|
21
|
+
# memory_profile = Hone::MemoryProfile.load("memory_profile.json")
|
|
22
|
+
# correlator = Hone::Correlator.new(method_map:, cpu_profile:, memory_profile:)
|
|
23
|
+
#
|
|
24
|
+
# scanner = Hone::Scanner.new
|
|
25
|
+
# findings = scanner.scan_file("app.rb")
|
|
26
|
+
#
|
|
27
|
+
# prioritized = correlator.correlate(findings)
|
|
28
|
+
# prioritized.each do |finding|
|
|
29
|
+
# puts "#{finding.priority}: #{finding.message} (#{finding.cpu_percent}% CPU, #{finding.alloc_percent}% alloc)"
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @example Usage without profile data (static-only mode)
|
|
33
|
+
# method_map = Hone::MethodMap.new.add_file("app.rb")
|
|
34
|
+
# correlator = Hone::Correlator.new(method_map:)
|
|
35
|
+
#
|
|
36
|
+
# findings = scanner.scan_file("app.rb")
|
|
37
|
+
# enriched = correlator.correlate(findings)
|
|
38
|
+
# # All findings will have priority: :unknown
|
|
39
|
+
#
|
|
40
|
+
# @see MethodMap For building the method-to-line mapping
|
|
41
|
+
# @see Scanner For generating findings from static analysis
|
|
42
|
+
#
|
|
43
|
+
class Correlator
|
|
44
|
+
# Threshold for hot methods: >5% of CPU/allocation time
|
|
45
|
+
# These represent critical optimization targets
|
|
46
|
+
HOT_THRESHOLD = 5.0
|
|
47
|
+
|
|
48
|
+
# Threshold for warm methods: 1-5% of CPU/allocation time
|
|
49
|
+
# These are worth optimizing but less urgent than hot
|
|
50
|
+
WARM_THRESHOLD = 1.0
|
|
51
|
+
|
|
52
|
+
# Multi-dimension priority levels assigned to findings based on CPU/allocation usage
|
|
53
|
+
# @return [Array<Symbol>] Valid priority values
|
|
54
|
+
PRIORITIES = %i[hot_cpu hot_alloc jit_unfriendly warm cold unknown].freeze
|
|
55
|
+
|
|
56
|
+
# Creates a new Correlator with method mapping and optional profiler data.
|
|
57
|
+
#
|
|
58
|
+
# @param method_map [MethodMap] Mapping of source locations to method definitions
|
|
59
|
+
# @param cpu_profile [#cpu_percent_for, nil] CPU profiler data responding to
|
|
60
|
+
# #cpu_percent_for(method) returning Float or nil. When nil, CPU-based
|
|
61
|
+
# priorities cannot be calculated.
|
|
62
|
+
# @param memory_profile [#alloc_percent_for, nil] Memory profiler data responding to
|
|
63
|
+
# #alloc_percent_for(method) returning Float or nil. When nil, allocation-based
|
|
64
|
+
# priorities cannot be calculated.
|
|
65
|
+
# @param profile_data [#cpu_percent_for, nil] DEPRECATED: Use cpu_profile instead.
|
|
66
|
+
# Kept for backward compatibility.
|
|
67
|
+
# @param hot_threshold [Float] Threshold for hot methods (default: HOT_THRESHOLD).
|
|
68
|
+
# Methods with CPU or allocation usage above this are marked :hot_cpu or :hot_alloc.
|
|
69
|
+
# @param warm_threshold [Float] Threshold for warm methods (default: WARM_THRESHOLD).
|
|
70
|
+
# Methods with usage between warm and hot thresholds are marked :warm.
|
|
71
|
+
#
|
|
72
|
+
def initialize(method_map:, cpu_profile: nil, memory_profile: nil, profile_data: nil,
|
|
73
|
+
hot_threshold: HOT_THRESHOLD, warm_threshold: WARM_THRESHOLD)
|
|
74
|
+
@method_map = method_map
|
|
75
|
+
# Support backward compatibility: profile_data is treated as cpu_profile
|
|
76
|
+
@cpu_profile = cpu_profile || profile_data
|
|
77
|
+
@memory_profile = memory_profile
|
|
78
|
+
@hot_threshold = hot_threshold
|
|
79
|
+
@warm_threshold = warm_threshold
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Float] Threshold for hot methods
|
|
83
|
+
attr_reader :hot_threshold
|
|
84
|
+
|
|
85
|
+
# @return [Float] Threshold for warm methods
|
|
86
|
+
attr_reader :warm_threshold
|
|
87
|
+
|
|
88
|
+
# Correlates findings with runtime profile data.
|
|
89
|
+
#
|
|
90
|
+
# For each finding, this method:
|
|
91
|
+
# 1. Looks up the containing method using the MethodMap
|
|
92
|
+
# 2. Queries the profiler for CPU percentage (if cpu_profile present)
|
|
93
|
+
# 3. Queries the profiler for allocation percentage (if memory_profile present)
|
|
94
|
+
# 4. Calculates priority based on thresholds and optimization type
|
|
95
|
+
# 5. Returns enriched findings sorted by impact (descending)
|
|
96
|
+
#
|
|
97
|
+
# @param findings [Array<Finding>] Raw findings from Scanner
|
|
98
|
+
# @return [Array<Finding>] Enriched findings with method_name, cpu_percent,
|
|
99
|
+
# alloc_percent, and priority populated, sorted by max impact descending
|
|
100
|
+
#
|
|
101
|
+
# @example
|
|
102
|
+
# raw_findings = scanner.scan_file("hot_code.rb")
|
|
103
|
+
# prioritized = correlator.correlate(raw_findings)
|
|
104
|
+
#
|
|
105
|
+
# # Process hot CPU findings first
|
|
106
|
+
# prioritized.select { |f| f.priority == :hot_cpu }.each do |finding|
|
|
107
|
+
# puts "URGENT: #{finding.message}"
|
|
108
|
+
# end
|
|
109
|
+
#
|
|
110
|
+
def correlate(findings)
|
|
111
|
+
enriched = findings.map do |finding|
|
|
112
|
+
enrich_finding(finding)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sort_by_impact(enriched)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Returns findings filtered by priority level.
|
|
119
|
+
#
|
|
120
|
+
# @param findings [Array<Finding>] Correlated findings
|
|
121
|
+
# @param priority [Symbol] One of :hot, :warm, :cold, :unknown
|
|
122
|
+
# @return [Array<Finding>] Findings matching the given priority
|
|
123
|
+
#
|
|
124
|
+
def self.filter_by_priority(findings, priority)
|
|
125
|
+
findings.select { |f| f.priority == priority }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Groups findings by priority for reporting.
|
|
129
|
+
#
|
|
130
|
+
# @param findings [Array<Finding>] Correlated findings
|
|
131
|
+
# @return [Hash<Symbol, Array<Finding>>] Findings grouped by priority
|
|
132
|
+
#
|
|
133
|
+
def self.group_by_priority(findings)
|
|
134
|
+
findings.group_by(&:priority)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Returns summary statistics for correlated findings.
|
|
138
|
+
#
|
|
139
|
+
# @param findings [Array<Finding>] Correlated findings
|
|
140
|
+
# @return [Hash] Statistics including counts by priority
|
|
141
|
+
#
|
|
142
|
+
def self.summary(findings)
|
|
143
|
+
by_priority = group_by_priority(findings)
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
total: findings.size,
|
|
147
|
+
hot_cpu: by_priority[:hot_cpu]&.size || 0,
|
|
148
|
+
hot_alloc: by_priority[:hot_alloc]&.size || 0,
|
|
149
|
+
jit_unfriendly: by_priority[:jit_unfriendly]&.size || 0,
|
|
150
|
+
warm: by_priority[:warm]&.size || 0,
|
|
151
|
+
cold: by_priority[:cold]&.size || 0,
|
|
152
|
+
unknown: by_priority[:unknown]&.size || 0,
|
|
153
|
+
max_cpu_percent: findings.filter_map(&:cpu_percent).max || 0.0,
|
|
154
|
+
total_cpu_percent: findings.filter_map(&:cpu_percent).sum,
|
|
155
|
+
max_alloc_percent: findings.filter_map(&:alloc_percent).max || 0.0,
|
|
156
|
+
total_alloc_percent: findings.filter_map(&:alloc_percent).sum
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
# Enriches a single finding with method and profiling information.
|
|
163
|
+
#
|
|
164
|
+
# @param finding [Finding] Raw finding from scanner
|
|
165
|
+
# @return [Finding] New finding with method_name, cpu_percent, alloc_percent, priority set
|
|
166
|
+
#
|
|
167
|
+
def enrich_finding(finding)
|
|
168
|
+
method = @method_map.method_at(finding.file, finding.line)
|
|
169
|
+
cpu_percent = lookup_cpu_percent(method)
|
|
170
|
+
alloc_percent = lookup_alloc_percent(method)
|
|
171
|
+
priority = calculate_priority(finding.optimization_type, cpu_percent, alloc_percent)
|
|
172
|
+
|
|
173
|
+
finding.with(
|
|
174
|
+
method_name: method&.qualified_name,
|
|
175
|
+
cpu_percent: cpu_percent,
|
|
176
|
+
alloc_percent: alloc_percent,
|
|
177
|
+
priority: priority
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Looks up CPU percentage for a method from profile data.
|
|
182
|
+
#
|
|
183
|
+
# @param method [MethodMap::MethodInfo, nil] Method to look up
|
|
184
|
+
# @return [Float, nil] CPU percentage or nil if unavailable
|
|
185
|
+
#
|
|
186
|
+
def lookup_cpu_percent(method)
|
|
187
|
+
return nil unless method && @cpu_profile
|
|
188
|
+
|
|
189
|
+
@cpu_profile.cpu_percent_for(method)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Looks up allocation percentage for a method from memory profile data.
|
|
193
|
+
#
|
|
194
|
+
# @param method [MethodMap::MethodInfo, nil] Method to look up
|
|
195
|
+
# @return [Float, nil] Allocation percentage or nil if unavailable
|
|
196
|
+
#
|
|
197
|
+
def lookup_alloc_percent(method)
|
|
198
|
+
return nil unless method && @memory_profile
|
|
199
|
+
|
|
200
|
+
@memory_profile.alloc_percent_for(method)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Calculates priority based on optimization type and CPU/allocation thresholds.
|
|
204
|
+
#
|
|
205
|
+
# Priority levels:
|
|
206
|
+
# - :jit_unfriendly - JIT-related patterns (highest priority regardless of metrics)
|
|
207
|
+
# - :hot_cpu - More than 5% CPU (critical path, high-value optimization)
|
|
208
|
+
# - :hot_alloc - More than 5% allocations (memory-intensive, high-value optimization)
|
|
209
|
+
# - :warm - 1-5% CPU or allocation (noticeable impact, worth fixing)
|
|
210
|
+
# - :cold - Less than 1% CPU and allocation (low priority, fix when convenient)
|
|
211
|
+
# - :unknown - No profile data available
|
|
212
|
+
#
|
|
213
|
+
# @param optimization_type [Symbol, nil] Type of optimization (e.g., :jit)
|
|
214
|
+
# @param cpu_percent [Float, nil] CPU percentage for the method
|
|
215
|
+
# @param alloc_percent [Float, nil] Allocation percentage for the method
|
|
216
|
+
# @return [Symbol] One of :jit_unfriendly, :hot_cpu, :hot_alloc, :warm, :cold, :unknown
|
|
217
|
+
#
|
|
218
|
+
def calculate_priority(optimization_type, cpu_percent, alloc_percent)
|
|
219
|
+
# JIT patterns get special priority regardless of CPU/alloc
|
|
220
|
+
return :jit_unfriendly if optimization_type == :jit
|
|
221
|
+
|
|
222
|
+
# Check for hot CPU first (most important)
|
|
223
|
+
return :hot_cpu if cpu_percent && cpu_percent >= @hot_threshold
|
|
224
|
+
|
|
225
|
+
# Check for hot allocation
|
|
226
|
+
return :hot_alloc if alloc_percent && alloc_percent >= @hot_threshold
|
|
227
|
+
|
|
228
|
+
# Check for warm (either dimension)
|
|
229
|
+
max_percent = [cpu_percent || 0, alloc_percent || 0].max
|
|
230
|
+
return :warm if max_percent >= @warm_threshold
|
|
231
|
+
|
|
232
|
+
# Cold if we have any data
|
|
233
|
+
return :cold if cpu_percent || alloc_percent
|
|
234
|
+
|
|
235
|
+
# Unknown if no profile data
|
|
236
|
+
:unknown
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Sorts findings by impact (max of CPU or allocation), hottest first.
|
|
240
|
+
#
|
|
241
|
+
# Findings with nil cpu_percent and alloc_percent are sorted last (treated as 0).
|
|
242
|
+
#
|
|
243
|
+
# @param findings [Array<Finding>] Enriched findings
|
|
244
|
+
# @return [Array<Finding>] Sorted findings (descending by max impact)
|
|
245
|
+
#
|
|
246
|
+
def sort_by_impact(findings)
|
|
247
|
+
findings.sort_by { |f| -[f.cpu_percent || 0, f.alloc_percent || 0].max }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
data/lib/hone/finding.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
# Represents a single optimization finding from static analysis.
|
|
5
|
+
#
|
|
6
|
+
# @example Creating a finding
|
|
7
|
+
# finding = Finding.new(
|
|
8
|
+
# file: "app/models/user.rb",
|
|
9
|
+
# line: 42,
|
|
10
|
+
# column: 8,
|
|
11
|
+
# pattern_id: :positive_predicate,
|
|
12
|
+
# optimization_type: :cpu,
|
|
13
|
+
# source: :hone,
|
|
14
|
+
# message: "Use > 0 instead of .positive?",
|
|
15
|
+
# speedup: "Minor",
|
|
16
|
+
# code: "count.positive?"
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @!attribute [r] file
|
|
20
|
+
# @return [String] Path to the source file
|
|
21
|
+
# @!attribute [r] line
|
|
22
|
+
# @return [Integer] Line number (1-indexed)
|
|
23
|
+
# @!attribute [r] column
|
|
24
|
+
# @return [Integer] Column number (0-indexed)
|
|
25
|
+
# @!attribute [r] pattern_id
|
|
26
|
+
# @return [Symbol] Identifier for the pattern that matched
|
|
27
|
+
# @!attribute [r] optimization_type
|
|
28
|
+
# @return [Symbol] One of :cpu, :allocation, :jit
|
|
29
|
+
# @!attribute [r] source
|
|
30
|
+
# @return [Symbol] One of :hone, :fasterer, :rubocop
|
|
31
|
+
# @!attribute [r] message
|
|
32
|
+
# @return [String] Human-readable description of the optimization
|
|
33
|
+
# @!attribute [r] speedup
|
|
34
|
+
# @return [String, nil] Expected performance improvement
|
|
35
|
+
# @!attribute [r] code
|
|
36
|
+
# @return [String] Source code snippet that triggered the finding
|
|
37
|
+
# @!attribute [r] method_name
|
|
38
|
+
# @return [String, nil] Qualified method name (set by Correlator)
|
|
39
|
+
# @!attribute [r] cpu_percent
|
|
40
|
+
# @return [Float, nil] CPU percentage from profiler (set by Correlator)
|
|
41
|
+
# @!attribute [r] alloc_percent
|
|
42
|
+
# @return [Float, nil] Allocation percentage (set by Correlator)
|
|
43
|
+
# @!attribute [r] priority
|
|
44
|
+
# @return [Symbol, nil] One of :hot, :warm, :cold, :unknown (set by Correlator)
|
|
45
|
+
Finding = Data.define(
|
|
46
|
+
:file,
|
|
47
|
+
:line,
|
|
48
|
+
:column,
|
|
49
|
+
:pattern_id,
|
|
50
|
+
:optimization_type, # :cpu, :allocation, :jit
|
|
51
|
+
:source, # :hone, :fasterer, :rubocop
|
|
52
|
+
:message,
|
|
53
|
+
:speedup, # Optional: "1.5x faster"
|
|
54
|
+
:code, # Source code snippet
|
|
55
|
+
:method_name, # Populated by correlator
|
|
56
|
+
:cpu_percent, # Populated by correlator
|
|
57
|
+
:alloc_percent, # Populated by correlator (Phase 2)
|
|
58
|
+
:priority # :hot, :warm, :cold
|
|
59
|
+
) do
|
|
60
|
+
def initialize(method_name: nil, cpu_percent: nil, alloc_percent: nil, priority: nil, **kwargs)
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Hone
|
|
7
|
+
class FindingFilter
|
|
8
|
+
def initialize(findings, options = {})
|
|
9
|
+
@findings = findings
|
|
10
|
+
@diff = options[:diff]
|
|
11
|
+
@baseline = options[:baseline]
|
|
12
|
+
@hot_only = options[:hot_only]
|
|
13
|
+
@show_cold = options[:show_cold]
|
|
14
|
+
@profile_path = options[:profile_path]
|
|
15
|
+
@top = options[:top]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def apply
|
|
19
|
+
filtered = @findings
|
|
20
|
+
filtered = filter_by_diff(filtered) if @diff
|
|
21
|
+
filtered = filter_by_baseline(filtered) if @baseline
|
|
22
|
+
filtered = filter_by_priority(filtered)
|
|
23
|
+
apply_top_limit(filtered)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def filter_by_diff(findings)
|
|
29
|
+
changed_files = `git diff --name-only #{@diff}`.split("\n").map { |f| File.expand_path(f) }
|
|
30
|
+
findings.select { |f| changed_files.include?(File.expand_path(f.file)) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def filter_by_baseline(findings)
|
|
34
|
+
baseline_data = JSON.parse(File.read(@baseline))
|
|
35
|
+
baseline_findings = baseline_data["findings"] || []
|
|
36
|
+
|
|
37
|
+
baseline_keys = baseline_findings.map do |bf|
|
|
38
|
+
[bf["file"], bf["line"], bf["pattern_id"]].join(":")
|
|
39
|
+
end.to_set
|
|
40
|
+
|
|
41
|
+
findings.reject do |f|
|
|
42
|
+
key = [f.file, f.line, f.pattern_id].join(":")
|
|
43
|
+
baseline_keys.include?(key)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def filter_by_priority(findings)
|
|
48
|
+
return findings.select { |f| f.priority == :hot } if @hot_only
|
|
49
|
+
return findings.reject { |f| f.priority == :cold } if @profile_path && !@show_cold
|
|
50
|
+
findings
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_top_limit(findings)
|
|
54
|
+
(@top && @top > 0) ? findings.first(@top) : findings
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Formatters
|
|
5
|
+
class Base
|
|
6
|
+
include Filterable
|
|
7
|
+
|
|
8
|
+
def initialize(findings, options = {})
|
|
9
|
+
@findings = findings
|
|
10
|
+
@options = options
|
|
11
|
+
@show_cold = options.fetch(:show_cold, false)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def format
|
|
15
|
+
raise NotImplementedError, "Subclasses must implement #format"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def filtered_findings
|
|
21
|
+
filter_cold(@findings, show_cold: @show_cold)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Formatters
|
|
5
|
+
module Filterable
|
|
6
|
+
PRIORITY_LABELS = {
|
|
7
|
+
hot_cpu: "HOT-CPU",
|
|
8
|
+
hot_alloc: "HOT-ALLOC",
|
|
9
|
+
jit_unfriendly: "JIT-UNFRIENDLY",
|
|
10
|
+
warm: "WARM",
|
|
11
|
+
cold: "COLD",
|
|
12
|
+
unknown: "?"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def filter_cold(findings, show_cold:)
|
|
16
|
+
return findings if show_cold
|
|
17
|
+
|
|
18
|
+
findings.reject { |f| f.priority == :cold }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def priority_label(priority)
|
|
22
|
+
PRIORITY_LABELS.fetch(priority, priority.to_s.upcase)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def format_percent(value)
|
|
26
|
+
return "" if value.nil?
|
|
27
|
+
"%.1f%%" % value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Formatters
|
|
5
|
+
class GitHub < Base
|
|
6
|
+
PRIORITY_TO_LEVEL = {
|
|
7
|
+
hot_cpu: "error",
|
|
8
|
+
hot_alloc: "error",
|
|
9
|
+
jit_unfriendly: "warning",
|
|
10
|
+
warm: "notice",
|
|
11
|
+
cold: "notice",
|
|
12
|
+
unknown: "notice"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def format
|
|
16
|
+
filtered_findings.map { |finding| format_annotation(finding) }.join("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def format_annotation(finding)
|
|
22
|
+
level = annotation_level(finding)
|
|
23
|
+
file = finding.file
|
|
24
|
+
line = finding.line
|
|
25
|
+
title = build_title(finding)
|
|
26
|
+
message = escape_message(finding.message)
|
|
27
|
+
|
|
28
|
+
"::#{level} file=#{file},line=#{line},title=#{title}::#{message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def annotation_level(finding)
|
|
32
|
+
priority = finding.priority || :unknown
|
|
33
|
+
PRIORITY_TO_LEVEL.fetch(priority, "notice")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_title(finding)
|
|
37
|
+
heat = priority_label(finding.priority || :unknown)
|
|
38
|
+
|
|
39
|
+
cpu_info = finding.cpu_percent ? "#{format_percent(finding.cpu_percent)} CPU" : nil
|
|
40
|
+
alloc_info = finding.alloc_percent ? "#{format_percent(finding.alloc_percent)} alloc" : nil
|
|
41
|
+
metrics = [cpu_info, alloc_info].compact.join(", ")
|
|
42
|
+
|
|
43
|
+
metrics.empty? ? heat : "#{heat} #{metrics}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def escape_message(message)
|
|
47
|
+
# GitHub Actions annotation messages need specific escaping:
|
|
48
|
+
# - %0A for newlines
|
|
49
|
+
# - %25 for %
|
|
50
|
+
# - %0D for carriage returns
|
|
51
|
+
message
|
|
52
|
+
.gsub("%", "%25")
|
|
53
|
+
.gsub("\r", "%0D")
|
|
54
|
+
.gsub("\n", "%0A")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def priority_label(priority)
|
|
58
|
+
case priority
|
|
59
|
+
when :hot_cpu then "HOT-CPU"
|
|
60
|
+
when :hot_alloc then "HOT-ALLOC"
|
|
61
|
+
when :jit_unfriendly then "JIT-UNFRIENDLY"
|
|
62
|
+
else priority.to_s.upcase
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def format_percent(percent)
|
|
67
|
+
"%.1f%%" % percent
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hone
|
|
6
|
+
module Formatters
|
|
7
|
+
class JSON < Base
|
|
8
|
+
FORMAT_VERSION = "1.0.0"
|
|
9
|
+
|
|
10
|
+
def initialize(findings, options = {})
|
|
11
|
+
super
|
|
12
|
+
@profile_path = options[:profile_path]
|
|
13
|
+
@mode = options.fetch(:mode, "correlated")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def format
|
|
17
|
+
::JSON.pretty_generate(build_output)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def build_output
|
|
23
|
+
{
|
|
24
|
+
version: FORMAT_VERSION,
|
|
25
|
+
hone_version: Hone::VERSION,
|
|
26
|
+
analysis: build_analysis,
|
|
27
|
+
summary: build_summary,
|
|
28
|
+
findings: build_findings
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_analysis
|
|
33
|
+
{
|
|
34
|
+
mode: @mode,
|
|
35
|
+
profile_source: @profile_path
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def build_summary
|
|
40
|
+
{
|
|
41
|
+
total: @findings.size,
|
|
42
|
+
by_priority: count_by_priority
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def count_by_priority
|
|
47
|
+
@findings.map { |f| f.priority || :unknown }.tally
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_findings
|
|
51
|
+
@findings.map { |finding| format_finding(finding) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def format_finding(finding)
|
|
55
|
+
{
|
|
56
|
+
heat: (finding.priority || :unknown).to_s,
|
|
57
|
+
cpu_percent: finding.cpu_percent,
|
|
58
|
+
location: {
|
|
59
|
+
path: finding.file,
|
|
60
|
+
line: finding.line,
|
|
61
|
+
column: finding.column
|
|
62
|
+
},
|
|
63
|
+
pattern_id: finding.pattern_id.to_s,
|
|
64
|
+
optimization_type: finding.optimization_type.to_s,
|
|
65
|
+
message: finding.message,
|
|
66
|
+
method_name: finding.method_name,
|
|
67
|
+
code: finding.code,
|
|
68
|
+
speedup: finding.speedup,
|
|
69
|
+
source: finding.source.to_s,
|
|
70
|
+
alloc_percent: finding.alloc_percent
|
|
71
|
+
}.compact
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|