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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +125 -0
  4. data/Rakefile +8 -0
  5. data/exe/necropsy +6 -0
  6. data/lib/necropsy/analyzer.rb +19 -0
  7. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
  8. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
  9. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
  10. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
  11. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
  12. data/lib/necropsy/analyzers/static/cha.rb +106 -0
  13. data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
  14. data/lib/necropsy/analyzers/static/rta.rb +79 -0
  15. data/lib/necropsy/ast_scanner.rb +616 -0
  16. data/lib/necropsy/bench/evaluator.rb +127 -0
  17. data/lib/necropsy/cache/scan_cache.rb +158 -0
  18. data/lib/necropsy/cli.rb +287 -0
  19. data/lib/necropsy/confidence/scorer.rb +158 -0
  20. data/lib/necropsy/configuration.rb +116 -0
  21. data/lib/necropsy/coverage_runtime.rb +13 -0
  22. data/lib/necropsy/entry_points/plain.rb +36 -0
  23. data/lib/necropsy/entry_points/rails.rb +335 -0
  24. data/lib/necropsy/entry_points/test.rb +13 -0
  25. data/lib/necropsy/graph/call_graph.rb +199 -0
  26. data/lib/necropsy/guardrail/baseline.rb +47 -0
  27. data/lib/necropsy/guardrail/diff.rb +16 -0
  28. data/lib/necropsy/guardrail/quarantine.rb +46 -0
  29. data/lib/necropsy/models.rb +164 -0
  30. data/lib/necropsy/project.rb +65 -0
  31. data/lib/necropsy/reachability/engine.rb +42 -0
  32. data/lib/necropsy/report.rb +50 -0
  33. data/lib/necropsy/reporter.rb +112 -0
  34. data/lib/necropsy/runner.rb +74 -0
  35. data/lib/necropsy/version.rb +5 -0
  36. data/lib/necropsy.rb +46 -0
  37. 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