necropsy 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/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/exe/necropsy +6 -0
- data/lib/necropsy/analyzer.rb +19 -0
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
- data/lib/necropsy/analyzers/static/cha.rb +106 -0
- data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
- data/lib/necropsy/analyzers/static/rta.rb +79 -0
- data/lib/necropsy/ast_scanner.rb +616 -0
- data/lib/necropsy/bench/evaluator.rb +127 -0
- data/lib/necropsy/cache/scan_cache.rb +158 -0
- data/lib/necropsy/cli.rb +287 -0
- data/lib/necropsy/confidence/scorer.rb +158 -0
- data/lib/necropsy/configuration.rb +116 -0
- data/lib/necropsy/coverage_runtime.rb +13 -0
- data/lib/necropsy/entry_points/plain.rb +36 -0
- data/lib/necropsy/entry_points/rails.rb +335 -0
- data/lib/necropsy/entry_points/test.rb +13 -0
- data/lib/necropsy/graph/call_graph.rb +199 -0
- data/lib/necropsy/guardrail/baseline.rb +47 -0
- data/lib/necropsy/guardrail/diff.rb +16 -0
- data/lib/necropsy/guardrail/quarantine.rb +46 -0
- data/lib/necropsy/models.rb +164 -0
- data/lib/necropsy/project.rb +65 -0
- data/lib/necropsy/reachability/engine.rb +42 -0
- data/lib/necropsy/report.rb +50 -0
- data/lib/necropsy/reporter.rb +112 -0
- data/lib/necropsy/runner.rb +74 -0
- data/lib/necropsy/version.rb +5 -0
- data/lib/necropsy.rb +46 -0
- metadata +95 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'socket'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'yaml'
|
|
8
|
+
|
|
9
|
+
module Necropsy
|
|
10
|
+
module Analyzers
|
|
11
|
+
module Dynamic
|
|
12
|
+
class CoverbandImporter < Analyzer
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config || {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def analyze(graph, project)
|
|
18
|
+
source = config['source']
|
|
19
|
+
return AnalyzerResult.empty unless source
|
|
20
|
+
|
|
21
|
+
payload = load_payload(source, project.root)
|
|
22
|
+
alive = alive_from_payload(graph, payload).map do |node|
|
|
23
|
+
AliveEvidence.new(
|
|
24
|
+
node_id: node.id,
|
|
25
|
+
evidence: evidence(kind: :alive, details: "Coverband executed #{node.file}:#{node.line}",
|
|
26
|
+
metadata: payload['observation'] || {})
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
AnalyzerResult.new(
|
|
31
|
+
edge_evidences: [],
|
|
32
|
+
alive_evidences: alive,
|
|
33
|
+
uncertainties: {},
|
|
34
|
+
observation: { 'coverband' => payload['observation'] || {} }
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def profile
|
|
39
|
+
AnalyzerProfile.new(
|
|
40
|
+
name: :coverband,
|
|
41
|
+
kind: :dynamic,
|
|
42
|
+
soundness: :observational,
|
|
43
|
+
description: 'Imports file and line execution data compatible with coverband-style exports.'
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :config
|
|
50
|
+
|
|
51
|
+
def load_payload(source, root)
|
|
52
|
+
return load_redis_payload(source) if source.start_with?('redis://', 'rediss://')
|
|
53
|
+
|
|
54
|
+
path = File.expand_path(source, root)
|
|
55
|
+
case File.extname(path)
|
|
56
|
+
when '.json'
|
|
57
|
+
JSON.parse(File.read(path))
|
|
58
|
+
else
|
|
59
|
+
YAML.load_file(path) || {}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def load_redis_payload(source)
|
|
64
|
+
RedisPayloadLoader.new(source: source, config: config).load
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def alive_from_payload(graph, payload)
|
|
68
|
+
executed_nodes = Array(payload['executed'] || payload['nodes'])
|
|
69
|
+
by_id = executed_nodes.filter_map { |id| graph.nodes[id] }
|
|
70
|
+
|
|
71
|
+
files = coverage_files(payload)
|
|
72
|
+
by_line = graph.method_nodes.select do |node|
|
|
73
|
+
executed_lines = lines_for(files, node.file)
|
|
74
|
+
executed_lines.any? { |line| line.between?(node.line, node.end_line) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
(by_id + by_line).uniq(&:id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def coverage_files(payload)
|
|
81
|
+
candidates = %w[files coverage runtime production].filter_map { |key| payload[key] }
|
|
82
|
+
candidates << payload if candidates.empty? && file_coverage_map?(payload)
|
|
83
|
+
|
|
84
|
+
candidates.reduce({}) do |merged, candidate|
|
|
85
|
+
merge_line_maps(merged, normalize_file_map(candidate))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def file_coverage_map?(value)
|
|
90
|
+
value.is_a?(Hash) && value.any? do |key, child|
|
|
91
|
+
key.to_s.end_with?('.rb') || key.to_s.include?('/') || line_coverage?(child)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_file_map(value)
|
|
96
|
+
return {} unless value.is_a?(Hash)
|
|
97
|
+
return normalize_file_map(value['files']) if value.is_a?(Hash) && value['files'].is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
value.each_with_object({}) do |(file, lines), normalized|
|
|
100
|
+
next unless line_coverage?(lines)
|
|
101
|
+
|
|
102
|
+
normalized[file.to_s] = executed_line_numbers(lines)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def line_coverage?(value)
|
|
107
|
+
value.is_a?(Array) || value.is_a?(Hash)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def executed_line_numbers(value)
|
|
111
|
+
case value
|
|
112
|
+
when Hash
|
|
113
|
+
line_hash = value['lines'] || value[:lines] || value['coverage'] || value[:coverage]
|
|
114
|
+
return executed_line_numbers(line_hash) if line_hash
|
|
115
|
+
|
|
116
|
+
value.filter_map { |line, count| line.to_i if positive_integer?(count) }
|
|
117
|
+
when Array
|
|
118
|
+
return value.map(&:to_i).select(&:positive?).uniq if value.all? { |item| positive_integer?(item) }
|
|
119
|
+
|
|
120
|
+
value.each_with_index.filter_map { |count, index| index + 1 if positive_integer?(count) }
|
|
121
|
+
else
|
|
122
|
+
[]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def positive_integer?(value)
|
|
127
|
+
Integer(value, exception: false)&.positive?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def merge_line_maps(left, right)
|
|
131
|
+
left.merge(right) { |_file, left_lines, right_lines| (left_lines + right_lines).uniq }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def lines_for(files, relative_file)
|
|
135
|
+
exact = files[relative_file] || files[File.join('.', relative_file)]
|
|
136
|
+
return exact if exact
|
|
137
|
+
|
|
138
|
+
files.each do |file, lines|
|
|
139
|
+
return lines if file.end_with?("/#{relative_file}")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
[]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class RedisPayloadLoader
|
|
147
|
+
DEFAULT_PATTERN = 'coverband*'
|
|
148
|
+
|
|
149
|
+
def initialize(source:, config:)
|
|
150
|
+
@uri = URI(source)
|
|
151
|
+
@config = config
|
|
152
|
+
@socket = nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def load
|
|
156
|
+
connect
|
|
157
|
+
authenticate
|
|
158
|
+
select_database
|
|
159
|
+
merge_payloads(keys.filter_map { |key| payload_for_key(key) })
|
|
160
|
+
ensure
|
|
161
|
+
socket&.close
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
attr_reader :uri, :config, :socket
|
|
167
|
+
|
|
168
|
+
def connect
|
|
169
|
+
tcp_socket = TCPSocket.new(uri.host, uri.port || 6379)
|
|
170
|
+
@socket = uri.scheme == 'rediss' ? tls_socket(tcp_socket) : tcp_socket
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def tls_socket(tcp_socket)
|
|
174
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
175
|
+
socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, context)
|
|
176
|
+
socket.hostname = uri.host if socket.respond_to?(:hostname=)
|
|
177
|
+
socket.connect
|
|
178
|
+
socket
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def authenticate
|
|
182
|
+
return unless uri.password
|
|
183
|
+
|
|
184
|
+
command('AUTH', uri.password)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def select_database
|
|
188
|
+
db = uri.path.to_s.delete_prefix('/')
|
|
189
|
+
command('SELECT', db) unless db.empty?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def keys
|
|
193
|
+
configured = Array(config['keys'] || config['key'] || query['key']).compact
|
|
194
|
+
return configured unless configured.empty?
|
|
195
|
+
|
|
196
|
+
scan(pattern: config['pattern'] || query['pattern'] || DEFAULT_PATTERN)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def query
|
|
200
|
+
@query ||= URI.decode_www_form(uri.query.to_s).to_h
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def scan(pattern:)
|
|
204
|
+
cursor = '0'
|
|
205
|
+
found = []
|
|
206
|
+
loop do
|
|
207
|
+
cursor, keys = command('SCAN', cursor, 'MATCH', pattern)
|
|
208
|
+
found.concat(keys)
|
|
209
|
+
break if cursor == '0'
|
|
210
|
+
end
|
|
211
|
+
found
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def command(*parts)
|
|
215
|
+
socket.write(redis_command(parts))
|
|
216
|
+
read_response
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def payload_for_key(key)
|
|
220
|
+
parse_payload(command('GET', key))
|
|
221
|
+
rescue Error => e
|
|
222
|
+
raise unless e.message.include?('WRONGTYPE')
|
|
223
|
+
|
|
224
|
+
parse_hash_payload(command('HGETALL', key))
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def redis_command(parts)
|
|
228
|
+
["*#{parts.length}", *parts.flat_map { |part| ["$#{part.to_s.bytesize}", part.to_s] }].join("\r\n") + "\r\n"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def read_response
|
|
232
|
+
prefix = socket.read(1)
|
|
233
|
+
case prefix
|
|
234
|
+
when '+'
|
|
235
|
+
socket.gets("\r\n").delete_suffix("\r\n")
|
|
236
|
+
when '-'
|
|
237
|
+
raise Error, socket.gets("\r\n").delete_suffix("\r\n")
|
|
238
|
+
when ':'
|
|
239
|
+
socket.gets("\r\n").to_i
|
|
240
|
+
when '$'
|
|
241
|
+
read_bulk_string
|
|
242
|
+
when '*'
|
|
243
|
+
Array.new(socket.gets("\r\n").to_i) { read_response }
|
|
244
|
+
else
|
|
245
|
+
raise Error, "Unsupported Redis response prefix #{prefix.inspect}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def read_bulk_string
|
|
250
|
+
length = socket.gets("\r\n").to_i
|
|
251
|
+
return nil if length.negative?
|
|
252
|
+
|
|
253
|
+
value = socket.read(length)
|
|
254
|
+
socket.read(2)
|
|
255
|
+
value
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def parse_payload(value)
|
|
259
|
+
return nil if value.nil? || value.empty?
|
|
260
|
+
|
|
261
|
+
JSON.parse(value)
|
|
262
|
+
rescue JSON::ParserError
|
|
263
|
+
YAML.load(value)
|
|
264
|
+
rescue Psych::SyntaxError
|
|
265
|
+
Marshal.load(value)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def parse_hash_payload(entries)
|
|
269
|
+
hash = Array(entries).each_slice(2).to_h
|
|
270
|
+
return coverband_hash_payload(hash) if coverband_hash_payload?(hash)
|
|
271
|
+
|
|
272
|
+
payloads = []
|
|
273
|
+
files = {}
|
|
274
|
+
hash.each do |field, value|
|
|
275
|
+
parsed = parse_payload(value)
|
|
276
|
+
if payload_shape?(parsed)
|
|
277
|
+
payloads << parsed
|
|
278
|
+
else
|
|
279
|
+
files[field.to_s] = parsed
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
payloads << { 'files' => files } unless files.empty?
|
|
284
|
+
merge_payloads(payloads)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def coverband_hash_payload?(hash)
|
|
288
|
+
hash.key?('file') && hash.key?('file_length')
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def coverband_hash_payload(hash)
|
|
292
|
+
{
|
|
293
|
+
'files' => { hash.fetch('file') => coverband_line_counts(hash) },
|
|
294
|
+
'observation' => coverband_observation(hash)
|
|
295
|
+
}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def coverband_line_counts(hash)
|
|
299
|
+
length = hash.fetch('file_length').to_i
|
|
300
|
+
Array.new(length) do |index|
|
|
301
|
+
count = hash[index.to_s]
|
|
302
|
+
count&.to_i
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def coverband_observation(hash)
|
|
307
|
+
%w[first_updated_at last_updated_at file_hash].each_with_object({}) do |key, observation|
|
|
308
|
+
observation[key] = hash[key] if hash[key]
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def payload_shape?(value)
|
|
313
|
+
value.is_a?(Hash) && value.keys.any? do |key|
|
|
314
|
+
%w[files coverage runtime production nodes executed observation].include?(key.to_s)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def merge_payloads(payloads)
|
|
319
|
+
payloads.compact.reduce({}) { |merged, payload| merge_payload(merged, payload) }
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def merge_payload(merged, payload)
|
|
323
|
+
merged.merge(payload) do |key, left, right|
|
|
324
|
+
case key
|
|
325
|
+
when 'files', 'coverage', 'runtime', 'production'
|
|
326
|
+
merge_files(left, right)
|
|
327
|
+
when 'nodes', 'executed'
|
|
328
|
+
(Array(left) + Array(right)).uniq
|
|
329
|
+
when 'edges'
|
|
330
|
+
Array(left) + Array(right)
|
|
331
|
+
when 'observation'
|
|
332
|
+
left.merge(right)
|
|
333
|
+
else
|
|
334
|
+
right
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def merge_files(left, right)
|
|
340
|
+
return right unless left.is_a?(Hash)
|
|
341
|
+
return left unless right.is_a?(Hash)
|
|
342
|
+
|
|
343
|
+
left.merge(right) do |_file, left_lines, right_lines|
|
|
344
|
+
merge_file_coverage(left_lines, right_lines)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def merge_file_coverage(left, right)
|
|
349
|
+
return merge_count_hashes(left, right) if left.is_a?(Hash) && right.is_a?(Hash)
|
|
350
|
+
return merge_count_arrays(left, right) if count_array?(left) || count_array?(right)
|
|
351
|
+
|
|
352
|
+
(Array(left) + Array(right)).uniq
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def merge_count_hashes(left, right)
|
|
356
|
+
left.merge(right) { |_line, left_count, right_count| left_count.to_i + right_count.to_i }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def merge_count_arrays(left, right)
|
|
360
|
+
length = [Array(left).length, Array(right).length].max
|
|
361
|
+
Array.new(length) do |index|
|
|
362
|
+
left_value = Array(left)[index]
|
|
363
|
+
right_value = Array(right)[index]
|
|
364
|
+
next if left_value.nil? && right_value.nil?
|
|
365
|
+
|
|
366
|
+
left_value.to_i + right_value.to_i
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def count_array?(value)
|
|
371
|
+
value.is_a?(Array) && value.any? { |item| item.nil? || item.to_i.zero? }
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module Necropsy
|
|
8
|
+
module Analyzers
|
|
9
|
+
module Dynamic
|
|
10
|
+
class TracePointCollector
|
|
11
|
+
def self.record(root:, output:, sample_rate: 1.0, &)
|
|
12
|
+
new(root: root, output: output, sample_rate: sample_rate).record(&)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(root:, output:, sample_rate: 1.0)
|
|
16
|
+
@root = File.expand_path(root)
|
|
17
|
+
@output = output
|
|
18
|
+
@sample_rate = sample_rate.to_f
|
|
19
|
+
@nodes = {}
|
|
20
|
+
@edges = {}
|
|
21
|
+
@stack = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def record
|
|
25
|
+
started_at = Time.now.utc
|
|
26
|
+
tracer = TracePoint.new(:call, :return) { |event| capture(event) }
|
|
27
|
+
tracer.enable
|
|
28
|
+
yield
|
|
29
|
+
ensure
|
|
30
|
+
tracer&.disable
|
|
31
|
+
write_payload(started_at: started_at, finished_at: Time.now.utc)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :root, :output, :sample_rate, :nodes, :edges, :stack
|
|
37
|
+
|
|
38
|
+
def capture(event)
|
|
39
|
+
return unless project_path?(event.path)
|
|
40
|
+
return if sample_rate < 1.0 && SecureRandom.random_number > sample_rate
|
|
41
|
+
|
|
42
|
+
node_id = node_id_for(event)
|
|
43
|
+
return unless node_id
|
|
44
|
+
|
|
45
|
+
if event.event == :return
|
|
46
|
+
stack.pop if stack.last == node_id
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
caller_id = stack.last
|
|
51
|
+
nodes[node_id] = true
|
|
52
|
+
edges[[caller_id, node_id]] = true if caller_id
|
|
53
|
+
|
|
54
|
+
stack << node_id
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def project_path?(path)
|
|
58
|
+
return false unless path
|
|
59
|
+
|
|
60
|
+
File.expand_path(path).start_with?("#{root}/")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def node_id_for(event)
|
|
64
|
+
owner, separator = owner_and_separator(event.defined_class)
|
|
65
|
+
return nil unless owner
|
|
66
|
+
|
|
67
|
+
"#{owner}#{separator}#{event.method_id}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def owner_and_separator(defined_class)
|
|
71
|
+
name = defined_class.name
|
|
72
|
+
return [name, '#'] if name
|
|
73
|
+
|
|
74
|
+
singleton_owner = defined_class.inspect[/\A#<Class:(.+)>\z/, 1]
|
|
75
|
+
return [singleton_owner, '.'] if singleton_owner
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write_payload(started_at:, finished_at:)
|
|
81
|
+
payload = {
|
|
82
|
+
'nodes' => nodes.keys.sort,
|
|
83
|
+
'edges' => edges.keys.map { |caller_id, callee_id| { 'caller_id' => caller_id, 'callee_id' => callee_id } },
|
|
84
|
+
'observation' => {
|
|
85
|
+
'started_at' => started_at.iso8601,
|
|
86
|
+
'finished_at' => finished_at.iso8601,
|
|
87
|
+
'days' => [((finished_at - started_at) / 86_400.0).ceil, 1].max,
|
|
88
|
+
'sample_rate' => sample_rate
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
File.write(output, payload.to_yaml)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Dynamic
|
|
6
|
+
class TracePointImporter < CoverageImporter
|
|
7
|
+
def profile
|
|
8
|
+
AnalyzerProfile.new(
|
|
9
|
+
name: :trace_point,
|
|
10
|
+
kind: :dynamic,
|
|
11
|
+
soundness: :observational,
|
|
12
|
+
description: 'Imports method execution and caller-callee edges captured by Necropsy TracePoint recording.'
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Static
|
|
6
|
+
class CHA < Analyzer
|
|
7
|
+
def analyze(graph, _project)
|
|
8
|
+
edge_evidences = graph.call_sites.flat_map do |site|
|
|
9
|
+
resolve(graph, site).map do |candidate|
|
|
10
|
+
EdgeEvidence.new(
|
|
11
|
+
caller_id: site.caller_id,
|
|
12
|
+
callee_id: candidate.id,
|
|
13
|
+
evidence: evidence(
|
|
14
|
+
kind: :call_edge,
|
|
15
|
+
details: "CHA candidate at #{site.file}:#{site.line}",
|
|
16
|
+
metadata: site.to_h
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
AnalyzerResult.new(
|
|
23
|
+
edge_evidences: edge_evidences,
|
|
24
|
+
alive_evidences: [],
|
|
25
|
+
uncertainties: {},
|
|
26
|
+
observation: {}
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def profile
|
|
31
|
+
AnalyzerProfile.new(
|
|
32
|
+
name: :cha,
|
|
33
|
+
kind: :static,
|
|
34
|
+
soundness: :conservative,
|
|
35
|
+
description: 'Expands call targets through class hierarchy, descendants, and included/prepended modules.'
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def resolve(graph, site)
|
|
42
|
+
case site.receiver_kind
|
|
43
|
+
when :constant
|
|
44
|
+
constant_targets(graph, site)
|
|
45
|
+
when :instance
|
|
46
|
+
instance_targets(graph, site)
|
|
47
|
+
when :self, :implicit
|
|
48
|
+
self_targets(graph, site)
|
|
49
|
+
else
|
|
50
|
+
graph.candidate_nodes(site.message)
|
|
51
|
+
end.uniq(&:id)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def constant_targets(graph, site)
|
|
55
|
+
receiver_candidates(site).flat_map do |owner|
|
|
56
|
+
owners_for_lookup(graph, owner, include_descendants: false).filter_map do |candidate_owner|
|
|
57
|
+
graph.nodes["#{candidate_owner}.#{site.message}"]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def instance_targets(graph, site)
|
|
63
|
+
receiver_candidates(site).flat_map do |owner|
|
|
64
|
+
owners_for_lookup(graph, owner, include_descendants: true).filter_map do |candidate_owner|
|
|
65
|
+
graph.nodes["#{candidate_owner}##{site.message}"]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self_targets(graph, site)
|
|
71
|
+
caller = graph.nodes[site.caller_id]
|
|
72
|
+
return [] unless caller&.owner
|
|
73
|
+
|
|
74
|
+
separator = caller.kind == :singleton_method ? '.' : '#'
|
|
75
|
+
owners_for_lookup(graph, caller.owner, include_descendants: false).filter_map do |owner|
|
|
76
|
+
graph.nodes["#{owner}#{separator}#{site.message}"] || graph.nodes["#{owner}##{site.message}"]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def owners_for_lookup(graph, owner, include_descendants:)
|
|
81
|
+
owners = include_descendants ? graph.descendants_of(owner) : [owner]
|
|
82
|
+
owners.flat_map do |candidate|
|
|
83
|
+
info = graph.class_info(candidate)
|
|
84
|
+
modules = info ? info.prepends + info.includes + info.extends : []
|
|
85
|
+
[candidate, *modules, *ancestors(graph, candidate)]
|
|
86
|
+
end.uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ancestors(graph, owner)
|
|
90
|
+
chain = []
|
|
91
|
+
current = graph.class_info(owner)&.superclass
|
|
92
|
+
while current && !chain.include?(current)
|
|
93
|
+
chain << current
|
|
94
|
+
current = graph.class_info(current)&.superclass
|
|
95
|
+
end
|
|
96
|
+
chain
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def receiver_candidates(site)
|
|
100
|
+
candidates = site.metadata['receiver_candidates'] || site.metadata[:receiver_candidates]
|
|
101
|
+
Array(candidates).compact.empty? ? [site.receiver_name].compact : Array(candidates).compact
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Static
|
|
6
|
+
class NameResolution < Analyzer
|
|
7
|
+
def analyze(graph, _project)
|
|
8
|
+
edge_evidences = graph.call_sites.flat_map do |site|
|
|
9
|
+
graph.resolve_call_site(site).map do |candidate|
|
|
10
|
+
EdgeEvidence.new(
|
|
11
|
+
caller_id: site.caller_id,
|
|
12
|
+
callee_id: candidate.id,
|
|
13
|
+
evidence: evidence(
|
|
14
|
+
kind: :call_edge,
|
|
15
|
+
details: "Name resolution at #{site.file}:#{site.line}",
|
|
16
|
+
metadata: site.to_h
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
AnalyzerResult.new(
|
|
23
|
+
edge_evidences: edge_evidences,
|
|
24
|
+
alive_evidences: [],
|
|
25
|
+
uncertainties: unresolved_uncertainties(graph),
|
|
26
|
+
observation: {}
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def profile
|
|
31
|
+
AnalyzerProfile.new(
|
|
32
|
+
name: :name_resolution,
|
|
33
|
+
kind: :static,
|
|
34
|
+
soundness: :unsound,
|
|
35
|
+
description: 'Resolves Ruby call sites by exact receiver and method name, falling back to same-name candidates.'
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def unresolved_uncertainties(graph)
|
|
42
|
+
graph.call_sites.each_with_object({}) do |site, memo|
|
|
43
|
+
next if site.dynamic
|
|
44
|
+
next unless graph.resolve_call_site(site).empty? && site.receiver_kind == :unknown
|
|
45
|
+
|
|
46
|
+
memo[site.caller_id] ||= []
|
|
47
|
+
memo[site.caller_id] << "Unknown receiver for #{site.message} at #{site.file}:#{site.line}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|