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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/.standard.yml +8 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +201 -0
  6. data/Rakefile +10 -0
  7. data/examples/.hone/harness.rb +41 -0
  8. data/examples/README.md +22 -0
  9. data/examples/allocation_patterns.rb +66 -0
  10. data/examples/cpu_patterns.rb +50 -0
  11. data/examples/jit_patterns.rb +69 -0
  12. data/exe/hone +7 -0
  13. data/lib/hone/adapters/base.rb +35 -0
  14. data/lib/hone/adapters/fasterer.rb +38 -0
  15. data/lib/hone/adapters/rubocop_performance.rb +85 -0
  16. data/lib/hone/analyzer.rb +258 -0
  17. data/lib/hone/cli.rb +247 -0
  18. data/lib/hone/config.rb +93 -0
  19. data/lib/hone/correlator.rb +250 -0
  20. data/lib/hone/exit_codes.rb +10 -0
  21. data/lib/hone/finding.rb +64 -0
  22. data/lib/hone/finding_filter.rb +57 -0
  23. data/lib/hone/formatters/base.rb +25 -0
  24. data/lib/hone/formatters/filterable.rb +31 -0
  25. data/lib/hone/formatters/github.rb +71 -0
  26. data/lib/hone/formatters/json.rb +75 -0
  27. data/lib/hone/formatters/junit.rb +154 -0
  28. data/lib/hone/formatters/sarif.rb +179 -0
  29. data/lib/hone/formatters/tsv.rb +49 -0
  30. data/lib/hone/harness.rb +57 -0
  31. data/lib/hone/harness_generator.rb +128 -0
  32. data/lib/hone/harness_runner.rb +172 -0
  33. data/lib/hone/method_map.rb +140 -0
  34. data/lib/hone/patterns/README.md +174 -0
  35. data/lib/hone/patterns/array_compact.rb +105 -0
  36. data/lib/hone/patterns/array_include_set.rb +34 -0
  37. data/lib/hone/patterns/base.rb +90 -0
  38. data/lib/hone/patterns/block_to_proc.rb +109 -0
  39. data/lib/hone/patterns/bsearch_vs_find.rb +80 -0
  40. data/lib/hone/patterns/chars_map_ord.rb +42 -0
  41. data/lib/hone/patterns/chars_to_variable.rb +136 -0
  42. data/lib/hone/patterns/chars_to_variable_tainted.rb +136 -0
  43. data/lib/hone/patterns/constant_regexp.rb +74 -0
  44. data/lib/hone/patterns/count_vs_size.rb +35 -0
  45. data/lib/hone/patterns/divmod.rb +92 -0
  46. data/lib/hone/patterns/dynamic_ivar.rb +44 -0
  47. data/lib/hone/patterns/dynamic_ivar_get.rb +33 -0
  48. data/lib/hone/patterns/each_with_index.rb +116 -0
  49. data/lib/hone/patterns/each_with_object.rb +63 -0
  50. data/lib/hone/patterns/flatten_once.rb +28 -0
  51. data/lib/hone/patterns/gsub_to_tr.rb +48 -0
  52. data/lib/hone/patterns/hash_each_key.rb +41 -0
  53. data/lib/hone/patterns/hash_each_value.rb +31 -0
  54. data/lib/hone/patterns/hash_keys_include.rb +30 -0
  55. data/lib/hone/patterns/hash_merge_bang.rb +33 -0
  56. data/lib/hone/patterns/hash_values_include.rb +31 -0
  57. data/lib/hone/patterns/inject_sum.rb +48 -0
  58. data/lib/hone/patterns/kernel_loop.rb +27 -0
  59. data/lib/hone/patterns/lazy_ivar.rb +39 -0
  60. data/lib/hone/patterns/map_compact.rb +32 -0
  61. data/lib/hone/patterns/map_flatten.rb +31 -0
  62. data/lib/hone/patterns/map_select_chain.rb +32 -0
  63. data/lib/hone/patterns/parallel_assignment.rb +127 -0
  64. data/lib/hone/patterns/positive_predicate.rb +27 -0
  65. data/lib/hone/patterns/range_include.rb +34 -0
  66. data/lib/hone/patterns/redundant_string_chars.rb +82 -0
  67. data/lib/hone/patterns/regexp_match.rb +126 -0
  68. data/lib/hone/patterns/reverse_each.rb +30 -0
  69. data/lib/hone/patterns/reverse_first.rb +40 -0
  70. data/lib/hone/patterns/select_count.rb +32 -0
  71. data/lib/hone/patterns/select_first.rb +31 -0
  72. data/lib/hone/patterns/select_map.rb +32 -0
  73. data/lib/hone/patterns/shuffle_first.rb +30 -0
  74. data/lib/hone/patterns/slice_with_length.rb +48 -0
  75. data/lib/hone/patterns/sort_by_first.rb +31 -0
  76. data/lib/hone/patterns/sort_by_last.rb +31 -0
  77. data/lib/hone/patterns/sort_first.rb +52 -0
  78. data/lib/hone/patterns/sort_last.rb +30 -0
  79. data/lib/hone/patterns/sort_reverse.rb +53 -0
  80. data/lib/hone/patterns/string_casecmp.rb +54 -0
  81. data/lib/hone/patterns/string_chars_each.rb +56 -0
  82. data/lib/hone/patterns/string_concat_in_loop.rb +116 -0
  83. data/lib/hone/patterns/string_delete_prefix.rb +53 -0
  84. data/lib/hone/patterns/string_delete_suffix.rb +53 -0
  85. data/lib/hone/patterns/string_empty.rb +64 -0
  86. data/lib/hone/patterns/string_end_with.rb +81 -0
  87. data/lib/hone/patterns/string_shovel.rb +75 -0
  88. data/lib/hone/patterns/string_start_with.rb +80 -0
  89. data/lib/hone/patterns/taint_tracking_base.rb +230 -0
  90. data/lib/hone/patterns/times_map.rb +38 -0
  91. data/lib/hone/patterns/uniq_by.rb +32 -0
  92. data/lib/hone/patterns/yield_vs_block.rb +72 -0
  93. data/lib/hone/profilers/base.rb +162 -0
  94. data/lib/hone/profilers/factory.rb +31 -0
  95. data/lib/hone/profilers/memory_profiler.rb +213 -0
  96. data/lib/hone/profilers/stackprof.rb +99 -0
  97. data/lib/hone/profilers/vernier.rb +147 -0
  98. data/lib/hone/reporter.rb +371 -0
  99. data/lib/hone/scanner.rb +75 -0
  100. data/lib/hone/suggestion_generator.rb +23 -0
  101. data/lib/hone/version.rb +5 -0
  102. data/lib/hone.rb +108 -0
  103. data/logo.png +0 -0
  104. data/sig/hone.rbs +4 -0
  105. 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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hone
4
+ module ExitCodes
5
+ SUCCESS = 0
6
+ HOT = 1
7
+ WARM = 2
8
+ ERROR = 3
9
+ end
10
+ end
@@ -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