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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: str = str + other -> str << other
|
|
6
|
+
#
|
|
7
|
+
# When reassigning a local variable to itself plus another string,
|
|
8
|
+
# using the shovel operator (<<) avoids creating a new string object.
|
|
9
|
+
# The + operator always creates a new string, while << mutates in place.
|
|
10
|
+
#
|
|
11
|
+
# Example:
|
|
12
|
+
# # Before - creates new string
|
|
13
|
+
# str = str + other
|
|
14
|
+
# str = str + "suffix"
|
|
15
|
+
#
|
|
16
|
+
# # After - mutates in place
|
|
17
|
+
# str << other
|
|
18
|
+
# str << "suffix"
|
|
19
|
+
#
|
|
20
|
+
# Note: Only safe when the original string can be mutated (not frozen,
|
|
21
|
+
# not shared with other references that expect the original value).
|
|
22
|
+
#
|
|
23
|
+
# Impact: Avoids allocating a new string object
|
|
24
|
+
class StringShovel < Base
|
|
25
|
+
self.pattern_id = :string_shovel
|
|
26
|
+
self.optimization_type = :allocation
|
|
27
|
+
|
|
28
|
+
def visit_local_variable_write_node(node)
|
|
29
|
+
super
|
|
30
|
+
|
|
31
|
+
check_string_reassignment(node, node.name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def check_string_reassignment(node, var_name)
|
|
37
|
+
value = node.value
|
|
38
|
+
return unless value.is_a?(Prism::CallNode)
|
|
39
|
+
return unless value.name == :+
|
|
40
|
+
|
|
41
|
+
receiver = value.receiver
|
|
42
|
+
return unless matches_variable?(receiver, var_name)
|
|
43
|
+
|
|
44
|
+
args = value.arguments&.arguments
|
|
45
|
+
return unless args&.size == 1
|
|
46
|
+
return unless likely_string?(args.first)
|
|
47
|
+
|
|
48
|
+
add_finding(
|
|
49
|
+
node,
|
|
50
|
+
message: "Use `#{var_name} << ...` instead of `#{var_name} = #{var_name} + ...` to avoid creating a new string",
|
|
51
|
+
speedup: "Avoids creating new string"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def matches_variable?(node, var_name)
|
|
56
|
+
node.is_a?(Prism::LocalVariableReadNode) && node.name == var_name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def likely_string?(node)
|
|
60
|
+
case node
|
|
61
|
+
when Prism::StringNode, Prism::InterpolatedStringNode
|
|
62
|
+
true
|
|
63
|
+
when Prism::CallNode
|
|
64
|
+
%i[to_s to_str inspect].include?(node.name)
|
|
65
|
+
when Prism::LocalVariableReadNode
|
|
66
|
+
# Could be a string, we'll suggest the optimization
|
|
67
|
+
# User can decide if it's appropriate
|
|
68
|
+
true
|
|
69
|
+
else
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: str[0] == 'x' or str.match?(/^x/) -> str.start_with?('x')
|
|
6
|
+
#
|
|
7
|
+
# Indexing at position 0 for comparison or using regex with ^ anchor
|
|
8
|
+
# is less clear and potentially slower than using start_with?.
|
|
9
|
+
#
|
|
10
|
+
# @example Bad
|
|
11
|
+
# str[0] == 'x'
|
|
12
|
+
# str.match?(/^foo/)
|
|
13
|
+
#
|
|
14
|
+
# @example Good
|
|
15
|
+
# str.start_with?('x')
|
|
16
|
+
# str.start_with?('foo')
|
|
17
|
+
class StringStartWith < Base
|
|
18
|
+
self.pattern_id = :string_start_with
|
|
19
|
+
self.optimization_type = :cpu
|
|
20
|
+
|
|
21
|
+
def visit_call_node(node)
|
|
22
|
+
super
|
|
23
|
+
|
|
24
|
+
if index_zero_comparison?(node)
|
|
25
|
+
add_finding(
|
|
26
|
+
node,
|
|
27
|
+
message: "Use `start_with?` instead of `str[0] == ...` for cleaner code",
|
|
28
|
+
speedup: "Cleaner and avoids substring/regex overhead"
|
|
29
|
+
)
|
|
30
|
+
elsif regex_start_anchor?(node)
|
|
31
|
+
add_finding(
|
|
32
|
+
node,
|
|
33
|
+
message: "Use `start_with?` instead of `match?(/^.../)` to avoid regex overhead",
|
|
34
|
+
speedup: "Cleaner and avoids substring/regex overhead"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Detects: str[0] == 'x' or 'x' == str[0]
|
|
42
|
+
def index_zero_comparison?(node)
|
|
43
|
+
return false unless node.name == :==
|
|
44
|
+
return false unless node.arguments&.arguments&.size == 1
|
|
45
|
+
|
|
46
|
+
receiver = node.receiver
|
|
47
|
+
arg = node.arguments.arguments[0]
|
|
48
|
+
|
|
49
|
+
# Check receiver[0] == arg or arg == receiver[0]
|
|
50
|
+
index_at_zero?(receiver) || index_at_zero?(arg)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Detects: str.match?(/^.../)
|
|
54
|
+
def regex_start_anchor?(node)
|
|
55
|
+
return false unless node.name == :match?
|
|
56
|
+
return false unless node.arguments&.arguments&.size == 1
|
|
57
|
+
|
|
58
|
+
arg = node.arguments.arguments[0]
|
|
59
|
+
return false unless arg.is_a?(Prism::RegularExpressionNode)
|
|
60
|
+
|
|
61
|
+
# Check if regex starts with ^ anchor
|
|
62
|
+
arg.content.start_with?("^")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if node is a [] call with index 0
|
|
66
|
+
def index_at_zero?(node)
|
|
67
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
68
|
+
return false unless node.name == :[]
|
|
69
|
+
return false unless node.arguments&.arguments&.size == 1
|
|
70
|
+
|
|
71
|
+
index_arg = node.arguments.arguments[0]
|
|
72
|
+
integer_zero?(index_arg)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def integer_zero?(node)
|
|
76
|
+
node.is_a?(Prism::IntegerNode) && node.value == 0
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Base class for patterns that need to track data flow through variables.
|
|
6
|
+
#
|
|
7
|
+
# Provides "taint tracking" - the ability to mark variables with metadata
|
|
8
|
+
# about their origin and propagate that information through assignments.
|
|
9
|
+
#
|
|
10
|
+
# Subclasses should:
|
|
11
|
+
# 1. Override `taint_from_call` to mark variables when assigned from specific calls
|
|
12
|
+
# 2. Override `check_tainted_usage` to detect problematic uses of tainted variables
|
|
13
|
+
#
|
|
14
|
+
# @example Tracking .chars allocations
|
|
15
|
+
# class CharsTracking < TaintTrackingBase
|
|
16
|
+
# def taint_from_call(call_node)
|
|
17
|
+
# return nil unless call_node.name == :chars
|
|
18
|
+
# { type: :chars, source: call_node.receiver&.slice }
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# def check_tainted_usage(call_node, taint_info)
|
|
22
|
+
# if call_node.name == :[] && taint_info[:type] == :chars
|
|
23
|
+
# report_finding(...)
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
class TaintTrackingBase < Base
|
|
29
|
+
# Taint info structure
|
|
30
|
+
TaintInfo = Data.define(:type, :source, :source_code, :origin_line, :metadata) do
|
|
31
|
+
def initialize(type:, source: nil, source_code: nil, origin_line: nil, metadata: {})
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(file_path)
|
|
37
|
+
super
|
|
38
|
+
@taint_scopes = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# === Scope Management ===
|
|
42
|
+
|
|
43
|
+
def visit_def_node(node)
|
|
44
|
+
with_taint_scope { super }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def visit_block_node(node)
|
|
48
|
+
with_taint_scope(inherit: true) { super }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def visit_lambda_node(node)
|
|
52
|
+
with_taint_scope { super }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def visit_class_node(node)
|
|
56
|
+
with_taint_scope { super }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def visit_module_node(node)
|
|
60
|
+
with_taint_scope { super }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# === Variable Tracking ===
|
|
64
|
+
|
|
65
|
+
# Track local variable assignments
|
|
66
|
+
def visit_local_variable_write_node(node)
|
|
67
|
+
super
|
|
68
|
+
track_assignment(node.name, node.value, node)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Track instance variable assignments
|
|
72
|
+
def visit_instance_variable_write_node(node)
|
|
73
|
+
super
|
|
74
|
+
track_assignment(node.name, node.value, node, scope: :instance)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Track multiple assignment (a, b = x, y)
|
|
78
|
+
def visit_multi_write_node(node)
|
|
79
|
+
super
|
|
80
|
+
# For simplicity, clear taints for multi-assigned variables
|
|
81
|
+
node.lefts.each do |target|
|
|
82
|
+
case target
|
|
83
|
+
when Prism::LocalVariableTargetNode
|
|
84
|
+
clear_taint(target.name)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Track call nodes for both tainting and usage checking
|
|
90
|
+
def visit_call_node(node)
|
|
91
|
+
super
|
|
92
|
+
check_tainted_call(node)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Track variable reads (for propagation detection)
|
|
96
|
+
def visit_local_variable_read_node(node)
|
|
97
|
+
super
|
|
98
|
+
# Subclasses can override to track reads
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
protected
|
|
102
|
+
|
|
103
|
+
# === Override Points for Subclasses ===
|
|
104
|
+
|
|
105
|
+
# Override to define what calls create taints
|
|
106
|
+
# @param call_node [Prism::CallNode] The call being assigned
|
|
107
|
+
# @return [TaintInfo, nil] Taint info if this call should taint, nil otherwise
|
|
108
|
+
def taint_from_call(call_node)
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Override to check if a tainted variable is being used problematically
|
|
113
|
+
# @param call_node [Prism::CallNode] The call on a tainted variable
|
|
114
|
+
# @param var_name [Symbol] The variable name
|
|
115
|
+
# @param taint_info [TaintInfo] Information about the taint
|
|
116
|
+
def check_tainted_usage(call_node, var_name, taint_info)
|
|
117
|
+
# Subclasses implement this
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# === Taint Query Methods ===
|
|
121
|
+
|
|
122
|
+
# Get taint info for a variable
|
|
123
|
+
def get_taint(var_name, scope: :local)
|
|
124
|
+
case scope
|
|
125
|
+
when :local
|
|
126
|
+
current_taint_scope[:locals][var_name]
|
|
127
|
+
when :instance
|
|
128
|
+
current_taint_scope[:instance][var_name]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Check if a variable is tainted
|
|
133
|
+
def tainted?(var_name, scope: :local)
|
|
134
|
+
!get_taint(var_name, scope: scope).nil?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Set taint for a variable
|
|
138
|
+
def set_taint(var_name, taint_info, scope: :local)
|
|
139
|
+
case scope
|
|
140
|
+
when :local
|
|
141
|
+
current_taint_scope[:locals][var_name] = taint_info
|
|
142
|
+
when :instance
|
|
143
|
+
current_taint_scope[:instance][var_name] = taint_info
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Clear taint for a variable (e.g., on reassignment)
|
|
148
|
+
def clear_taint(var_name, scope: :local)
|
|
149
|
+
case scope
|
|
150
|
+
when :local
|
|
151
|
+
current_taint_scope[:locals].delete(var_name)
|
|
152
|
+
when :instance
|
|
153
|
+
current_taint_scope[:instance].delete(var_name)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Get all tainted variables in current scope
|
|
158
|
+
def all_taints(scope: :local)
|
|
159
|
+
case scope
|
|
160
|
+
when :local
|
|
161
|
+
current_taint_scope[:locals].dup
|
|
162
|
+
when :instance
|
|
163
|
+
current_taint_scope[:instance].dup
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def with_taint_scope(inherit: false)
|
|
170
|
+
new_scope = {
|
|
171
|
+
locals: {},
|
|
172
|
+
instance: (inherit && @taint_scopes.any?) ? current_taint_scope[:instance].dup : {}
|
|
173
|
+
}
|
|
174
|
+
@taint_scopes.push(new_scope)
|
|
175
|
+
yield
|
|
176
|
+
ensure
|
|
177
|
+
@taint_scopes.pop
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def current_taint_scope
|
|
181
|
+
@taint_scopes.last || {locals: {}, instance: {}}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def track_assignment(var_name, value_node, assignment_node, scope: :local)
|
|
185
|
+
# If assigned from a call, check if it should be tainted
|
|
186
|
+
if value_node.is_a?(Prism::CallNode)
|
|
187
|
+
taint_info = taint_from_call(value_node)
|
|
188
|
+
if taint_info
|
|
189
|
+
# Ensure it's a TaintInfo
|
|
190
|
+
taint_info = if taint_info.is_a?(TaintInfo)
|
|
191
|
+
taint_info
|
|
192
|
+
else
|
|
193
|
+
TaintInfo.new(**taint_info)
|
|
194
|
+
end
|
|
195
|
+
set_taint(var_name, taint_info, scope: scope)
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# If assigned from another variable, propagate taint
|
|
201
|
+
if value_node.is_a?(Prism::LocalVariableReadNode)
|
|
202
|
+
source_taint = get_taint(value_node.name)
|
|
203
|
+
if source_taint
|
|
204
|
+
set_taint(var_name, source_taint, scope: scope)
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Otherwise, clear any existing taint (variable reassigned to non-tainted value)
|
|
210
|
+
clear_taint(var_name, scope: scope)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def check_tainted_call(node)
|
|
214
|
+
# Check if this is a call on a tainted local variable
|
|
215
|
+
if node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
216
|
+
var_name = node.receiver.name
|
|
217
|
+
taint_info = get_taint(var_name)
|
|
218
|
+
check_tainted_usage(node, var_name, taint_info) if taint_info
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Check if this is a call on a tainted instance variable
|
|
222
|
+
if node.receiver.is_a?(Prism::InstanceVariableReadNode)
|
|
223
|
+
var_name = node.receiver.name
|
|
224
|
+
taint_info = get_taint(var_name, scope: :instance)
|
|
225
|
+
check_tainted_usage(node, var_name, taint_info) if taint_info
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: n.times.map { } -> Array.new(n) { }
|
|
6
|
+
#
|
|
7
|
+
# times.map creates an Enumerator then maps over it.
|
|
8
|
+
# Array.new(n) { } directly creates the array with the block values,
|
|
9
|
+
# avoiding the Enumerator overhead.
|
|
10
|
+
#
|
|
11
|
+
# Examples:
|
|
12
|
+
# # Bad: creates Enumerator then maps
|
|
13
|
+
# 5.times.map { |i| i * 2 }
|
|
14
|
+
# # Good: direct array creation
|
|
15
|
+
# Array.new(5) { |i| i * 2 }
|
|
16
|
+
class TimesMap < Base
|
|
17
|
+
self.pattern_id = :times_map
|
|
18
|
+
self.optimization_type = :allocation
|
|
19
|
+
|
|
20
|
+
def visit_call_node(node)
|
|
21
|
+
super
|
|
22
|
+
|
|
23
|
+
# Look for: .map { } where receiver is .times
|
|
24
|
+
return unless node.name == :map && block_attached?(node)
|
|
25
|
+
|
|
26
|
+
receiver = node.receiver
|
|
27
|
+
return unless receiver.is_a?(Prism::CallNode)
|
|
28
|
+
return unless receiver.name == :times
|
|
29
|
+
|
|
30
|
+
add_finding(
|
|
31
|
+
node,
|
|
32
|
+
message: "Use `Array.new(n) { }` instead of `n.times.map { }` to avoid Enumerator overhead",
|
|
33
|
+
speedup: "Avoids Enumerator overhead"
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: array.map { }.uniq -> consider array.uniq { }
|
|
6
|
+
#
|
|
7
|
+
# map { }.uniq creates an intermediate array from map, then deduplicates.
|
|
8
|
+
# If deduplicating on the transformed value, uniq { } avoids the intermediate.
|
|
9
|
+
class UniqBy < Base
|
|
10
|
+
self.pattern_id = :uniq_by
|
|
11
|
+
self.optimization_type = :allocation
|
|
12
|
+
|
|
13
|
+
def visit_call_node(node)
|
|
14
|
+
super
|
|
15
|
+
|
|
16
|
+
# Look for: .uniq where receiver is .map with a block
|
|
17
|
+
return unless node.name == :uniq
|
|
18
|
+
return unless node.arguments.nil? && !block_attached?(node)
|
|
19
|
+
|
|
20
|
+
receiver = node.receiver
|
|
21
|
+
return unless receiver.is_a?(Prism::CallNode)
|
|
22
|
+
return unless receiver.name == :map && block_attached?(receiver)
|
|
23
|
+
|
|
24
|
+
add_finding(
|
|
25
|
+
node,
|
|
26
|
+
message: "Consider `.uniq { }` instead of `.map { }.uniq` if deduping on transform",
|
|
27
|
+
speedup: "May avoid intermediate array if deduping on transform"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hone
|
|
4
|
+
module Patterns
|
|
5
|
+
# Pattern: block.call -> yield when method has &block parameter
|
|
6
|
+
#
|
|
7
|
+
# When a method accepts a block with `&block`, calling `block.call` is slower
|
|
8
|
+
# than using `yield`. The `&block` syntax converts the block to a Proc object,
|
|
9
|
+
# which has overhead. Using `yield` avoids this Proc allocation when the block
|
|
10
|
+
# is only called (not stored or passed elsewhere).
|
|
11
|
+
#
|
|
12
|
+
# Example:
|
|
13
|
+
# # Before - allocates Proc
|
|
14
|
+
# def process(&block)
|
|
15
|
+
# block.call(value)
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# # After - no Proc allocation
|
|
19
|
+
# def process
|
|
20
|
+
# yield(value)
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Impact: Avoids Proc allocation
|
|
24
|
+
class YieldVsBlock < Base
|
|
25
|
+
self.pattern_id = :yield_vs_block
|
|
26
|
+
self.optimization_type = :cpu
|
|
27
|
+
|
|
28
|
+
def initialize(file_path)
|
|
29
|
+
super
|
|
30
|
+
@block_param_name = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def visit_def_node(node)
|
|
34
|
+
block_param = find_block_parameter(node.parameters)
|
|
35
|
+
|
|
36
|
+
if block_param
|
|
37
|
+
with_context(:@block_param_name, block_param) { super }
|
|
38
|
+
else
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def visit_call_node(node)
|
|
44
|
+
super
|
|
45
|
+
|
|
46
|
+
return unless @block_param_name
|
|
47
|
+
return unless node.name == :call
|
|
48
|
+
|
|
49
|
+
receiver = node.receiver
|
|
50
|
+
return unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
51
|
+
return unless receiver.name == @block_param_name
|
|
52
|
+
|
|
53
|
+
add_finding(
|
|
54
|
+
node,
|
|
55
|
+
message: "Use `yield` instead of `#{@block_param_name}.call` to avoid Proc allocation",
|
|
56
|
+
speedup: "Avoids Proc allocation"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def find_block_parameter(params)
|
|
63
|
+
return nil unless params
|
|
64
|
+
|
|
65
|
+
block = params.block
|
|
66
|
+
return nil unless block.is_a?(Prism::BlockParameterNode)
|
|
67
|
+
|
|
68
|
+
block.name
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hone
|
|
6
|
+
module Profilers
|
|
7
|
+
# Shared data structure for hotspot information across all profilers
|
|
8
|
+
HotspotInfo = Data.define(:name, :file, :line, :cpu_percent, :samples)
|
|
9
|
+
|
|
10
|
+
# Shared method matching logic for profiler implementations.
|
|
11
|
+
# Provides utilities to find and match methods/frames by name and file.
|
|
12
|
+
module MethodMatching
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def find_matching_frame(method_info)
|
|
16
|
+
name, file, line, end_line = extract_search_criteria(method_info)
|
|
17
|
+
|
|
18
|
+
# First try to match by line range (for allocation profilers)
|
|
19
|
+
if line && file
|
|
20
|
+
match = @frames.find do |frame|
|
|
21
|
+
next unless frame[:file] && frame[:line]
|
|
22
|
+
next unless file_matches?(frame[:file], file)
|
|
23
|
+
|
|
24
|
+
frame_line = frame[:line].to_i
|
|
25
|
+
if end_line
|
|
26
|
+
# Method has a range, check if frame line is within it
|
|
27
|
+
frame_line >= line && frame_line <= end_line
|
|
28
|
+
else
|
|
29
|
+
# Exact line match
|
|
30
|
+
frame_line == line
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
return match if match
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Fall back to name-based matching
|
|
37
|
+
@frames.find do |frame|
|
|
38
|
+
matches_method?(frame, name, file)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def extract_search_criteria(method_info)
|
|
43
|
+
case method_info
|
|
44
|
+
when String
|
|
45
|
+
[method_info, nil, nil, nil]
|
|
46
|
+
when Hash
|
|
47
|
+
extract_from_hash(method_info)
|
|
48
|
+
else
|
|
49
|
+
extract_from_object(method_info)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extracts name, file, line, and end_line from a Hash with symbol or string keys.
|
|
54
|
+
#
|
|
55
|
+
# @param hash [Hash] Hash containing :name/:file/:line/:end_line keys
|
|
56
|
+
# @return [Array] [name, file, line, end_line] tuple
|
|
57
|
+
#
|
|
58
|
+
def extract_from_hash(hash)
|
|
59
|
+
[
|
|
60
|
+
hash[:name] || hash["name"],
|
|
61
|
+
hash[:file] || hash["file"],
|
|
62
|
+
hash[:line] || hash["line"],
|
|
63
|
+
hash[:end_line] || hash["end_line"]
|
|
64
|
+
]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Extracts name, file, line, end_line from objects with method accessors (like MethodInfo).
|
|
68
|
+
#
|
|
69
|
+
# @param obj [Object] Object responding to :qualified_name/:name and optionally :file/:line/:end_line
|
|
70
|
+
# @return [Array] [name, file, line, end_line] tuple
|
|
71
|
+
#
|
|
72
|
+
def extract_from_object(obj)
|
|
73
|
+
name = if obj.respond_to?(:qualified_name)
|
|
74
|
+
obj.qualified_name
|
|
75
|
+
elsif obj.respond_to?(:name)
|
|
76
|
+
obj.name
|
|
77
|
+
else
|
|
78
|
+
obj.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
file = obj.respond_to?(:file) ? obj.file : nil
|
|
82
|
+
# Support both :line and :start_line for compatibility
|
|
83
|
+
line = if obj.respond_to?(:start_line)
|
|
84
|
+
obj.start_line
|
|
85
|
+
elsif obj.respond_to?(:line)
|
|
86
|
+
obj.line
|
|
87
|
+
end
|
|
88
|
+
end_line = obj.respond_to?(:end_line) ? obj.end_line : nil
|
|
89
|
+
|
|
90
|
+
[name, file, line, end_line]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def matches_method?(frame, name, file)
|
|
94
|
+
return false unless name
|
|
95
|
+
|
|
96
|
+
name_matches = method_name_matches?(frame[:name], name)
|
|
97
|
+
return name_matches unless file && name_matches
|
|
98
|
+
|
|
99
|
+
# If file is provided, also check file match
|
|
100
|
+
file_matches?(frame[:file], file)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def method_name_matches?(frame_name, search_name)
|
|
104
|
+
return false unless frame_name
|
|
105
|
+
|
|
106
|
+
# Exact match
|
|
107
|
+
return true if frame_name == search_name
|
|
108
|
+
|
|
109
|
+
# Match without class prefix (e.g., "method_name" matches "ClassName#method_name")
|
|
110
|
+
return true if frame_name.end_with?("##{search_name}")
|
|
111
|
+
|
|
112
|
+
# Match with class prefix when searching for just method name
|
|
113
|
+
frame_method = frame_name.split("#").last
|
|
114
|
+
frame_method == search_name
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def file_matches?(frame_file, search_file)
|
|
118
|
+
return false unless frame_file && search_file
|
|
119
|
+
|
|
120
|
+
# Exact match or path suffix match
|
|
121
|
+
frame_file == search_file || frame_file.end_with?(search_file) || search_file.end_with?(File.basename(frame_file))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def build_hotspot_info(frame)
|
|
125
|
+
HotspotInfo.new(
|
|
126
|
+
name: frame[:name],
|
|
127
|
+
file: frame[:file],
|
|
128
|
+
line: frame[:line],
|
|
129
|
+
cpu_percent: frame[:cpu_percent],
|
|
130
|
+
samples: frame[:samples]
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class Base
|
|
136
|
+
def initialize(profile_path)
|
|
137
|
+
@profile_path = profile_path
|
|
138
|
+
@data = load_profile(profile_path)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Returns CPU percentage for a method (0.0-100.0)
|
|
142
|
+
def cpu_percent_for(method_info)
|
|
143
|
+
raise NotImplementedError
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns all hotspots above threshold
|
|
147
|
+
def hotspots(threshold: 1.0)
|
|
148
|
+
raise NotImplementedError
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def load_profile(path)
|
|
154
|
+
raise Hone::Error, "Profile file not found: #{path}" unless File.exist?(path)
|
|
155
|
+
|
|
156
|
+
JSON.parse(File.read(path))
|
|
157
|
+
rescue JSON::ParserError => e
|
|
158
|
+
raise Hone::Error, "Invalid JSON in profile file #{path}: #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|