necropsy 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- metadata +85 -1
|
@@ -4,50 +4,60 @@ require 'securerandom'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'time'
|
|
6
6
|
require 'yaml'
|
|
7
|
+
require_relative '../../clock'
|
|
8
|
+
require_relative 'runtime_reference'
|
|
9
|
+
require_relative 'observation_policy'
|
|
7
10
|
|
|
8
11
|
module Necropsy
|
|
9
12
|
module Analyzers
|
|
10
13
|
module Dynamic
|
|
11
14
|
class TracePointCollector
|
|
12
|
-
def self.record(root:, output:, sample_rate: 1.0, &)
|
|
13
|
-
new(root: root, output: output, sample_rate: sample_rate).record(&)
|
|
15
|
+
def self.record(root:, output:, sample_rate: 1.0, clock: nil, random: nil, &)
|
|
16
|
+
new(root: root, output: output, sample_rate: sample_rate, clock: clock, random: random).record(&)
|
|
14
17
|
end
|
|
15
18
|
|
|
16
|
-
def self.install_at_exit(root:, output:, sample_rate: 1.0, merge: false, run_id: nil)
|
|
17
|
-
new(
|
|
19
|
+
def self.install_at_exit(root:, output:, sample_rate: 1.0, merge: false, run_id: nil, clock: nil, random: nil)
|
|
20
|
+
new(
|
|
21
|
+
root: root, output: output, sample_rate: sample_rate, merge: merge, run_id: run_id,
|
|
22
|
+
clock: clock, random: random
|
|
23
|
+
).install_at_exit
|
|
18
24
|
end
|
|
19
25
|
|
|
20
|
-
def initialize(root:, output:, sample_rate: 1.0, merge: false, run_id: nil)
|
|
26
|
+
def initialize(root:, output:, sample_rate: 1.0, merge: false, run_id: nil, clock: nil, random: nil)
|
|
21
27
|
@root = File.expand_path(root)
|
|
22
28
|
@output = output
|
|
23
29
|
@sample_rate = sample_rate.to_f
|
|
24
30
|
raise Error, 'sample_rate must be between 0.0 and 1.0' unless @sample_rate.between?(0.0, 1.0)
|
|
25
31
|
|
|
26
32
|
@nodes = {}
|
|
33
|
+
@node_references = {}
|
|
27
34
|
@edges = {}
|
|
35
|
+
@edge_references = {}
|
|
28
36
|
@stacks = {}.compare_by_identity
|
|
29
37
|
@lock = Mutex.new
|
|
30
38
|
@merge = merge
|
|
31
39
|
@run_id = run_id
|
|
40
|
+
@clock = clock || -> { Clock.new.time }
|
|
41
|
+
@random = random || default_random
|
|
32
42
|
end
|
|
33
43
|
|
|
34
44
|
def record
|
|
35
|
-
started_at =
|
|
45
|
+
started_at = current_time
|
|
36
46
|
tracer = TracePoint.new(:call, :return) { |event| capture(event) }
|
|
37
47
|
tracer.enable
|
|
38
48
|
yield
|
|
39
49
|
ensure
|
|
40
50
|
tracer&.disable
|
|
41
|
-
write_payload(started_at: started_at, finished_at:
|
|
51
|
+
write_payload(started_at: started_at, finished_at: current_time)
|
|
42
52
|
end
|
|
43
53
|
|
|
44
54
|
def install_at_exit
|
|
45
|
-
started_at =
|
|
55
|
+
started_at = current_time
|
|
46
56
|
tracer = TracePoint.new(:call, :return) { |event| capture(event) }
|
|
47
57
|
tracer.enable
|
|
48
58
|
at_exit do
|
|
49
59
|
tracer.disable
|
|
50
|
-
write_payload(started_at: started_at, finished_at:
|
|
60
|
+
write_payload(started_at: started_at, finished_at: current_time)
|
|
51
61
|
rescue StandardError => e
|
|
52
62
|
warn "Necropsy TracePoint collector failed: #{e.message}"
|
|
53
63
|
end
|
|
@@ -55,7 +65,8 @@ module Necropsy
|
|
|
55
65
|
|
|
56
66
|
private
|
|
57
67
|
|
|
58
|
-
attr_reader :root, :output, :sample_rate, :nodes, :edges, :stacks, :lock,
|
|
68
|
+
attr_reader :root, :output, :sample_rate, :nodes, :node_references, :edges, :edge_references, :stacks, :lock,
|
|
69
|
+
:run_id, :clock, :random
|
|
59
70
|
|
|
60
71
|
def merge?
|
|
61
72
|
@merge
|
|
@@ -64,34 +75,52 @@ module Necropsy
|
|
|
64
75
|
def capture(event)
|
|
65
76
|
return unless project_path?(event.path)
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
return unless
|
|
78
|
+
reference = node_reference_for(event)
|
|
79
|
+
return unless reference
|
|
69
80
|
|
|
70
81
|
lock.synchronize do
|
|
71
82
|
stack = stacks[Thread.current] ||= []
|
|
72
83
|
if event.event == :return
|
|
73
|
-
unwind_stack(stack,
|
|
84
|
+
unwind_stack(stack, reference)
|
|
74
85
|
stacks.delete(Thread.current) if stack.empty?
|
|
75
86
|
return
|
|
76
87
|
end
|
|
77
88
|
|
|
78
|
-
sampled = sample_rate >= 1.0 || (sample_rate.positive? &&
|
|
79
|
-
|
|
89
|
+
sampled = sample_rate >= 1.0 || (sample_rate.positive? && random.call < sample_rate)
|
|
90
|
+
caller_reference = stack.last&.first if stack.last&.last
|
|
80
91
|
if sampled
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
record_node(reference)
|
|
93
|
+
record_edge(caller_reference, reference) if caller_reference
|
|
83
94
|
end
|
|
84
|
-
stack << [
|
|
95
|
+
stack << [reference, sampled]
|
|
85
96
|
end
|
|
86
97
|
end
|
|
87
98
|
|
|
88
|
-
def
|
|
89
|
-
|
|
99
|
+
def current_time
|
|
100
|
+
clock.call.utc
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def default_random
|
|
104
|
+
epoch = ENV.fetch(Clock::SOURCE_DATE_EPOCH, nil)
|
|
105
|
+
return SecureRandom.method(:random_number) if epoch.nil? || epoch.empty?
|
|
106
|
+
|
|
107
|
+
Random.new(Integer(epoch)).method(:rand)
|
|
108
|
+
rescue ArgumentError
|
|
109
|
+
SecureRandom.method(:random_number)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def unwind_stack(stack, reference)
|
|
113
|
+
reference_key = invocation_key(reference)
|
|
114
|
+
index = stack.rindex { |frame| invocation_key(frame.first) == reference_key }
|
|
90
115
|
return unless index
|
|
91
116
|
|
|
92
117
|
stack.slice!(index..)
|
|
93
118
|
end
|
|
94
119
|
|
|
120
|
+
def invocation_key(reference)
|
|
121
|
+
reference.values_at('definition_id', 'symbol_id', 'file')
|
|
122
|
+
end
|
|
123
|
+
|
|
95
124
|
def project_path?(path)
|
|
96
125
|
return false unless path
|
|
97
126
|
|
|
@@ -105,6 +134,28 @@ module Necropsy
|
|
|
105
134
|
"#{owner}#{separator}#{event.method_id}"
|
|
106
135
|
end
|
|
107
136
|
|
|
137
|
+
def node_reference_for(event)
|
|
138
|
+
symbol_id = node_id_for(event)
|
|
139
|
+
return unless symbol_id
|
|
140
|
+
|
|
141
|
+
RuntimeReference.build(
|
|
142
|
+
symbol_id: symbol_id,
|
|
143
|
+
file: RuntimeReference.relative_file(root, event.path),
|
|
144
|
+
line: event.respond_to?(:lineno) ? event.lineno : nil
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def record_node(reference)
|
|
149
|
+
nodes[reference.fetch('symbol_id')] = true
|
|
150
|
+
node_references[RuntimeReference.key(reference)] = reference
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def record_edge(caller, callee)
|
|
154
|
+
edges[[caller.fetch('symbol_id'), callee.fetch('symbol_id')]] = true
|
|
155
|
+
key = [RuntimeReference.key(caller), RuntimeReference.key(callee)]
|
|
156
|
+
edge_references[key] = { 'caller_id' => caller.dup, 'callee_id' => callee.dup }
|
|
157
|
+
end
|
|
158
|
+
|
|
108
159
|
def owner_and_separator(defined_class)
|
|
109
160
|
name = defined_class.name
|
|
110
161
|
return [name, '#'] if name
|
|
@@ -117,13 +168,24 @@ module Necropsy
|
|
|
117
168
|
|
|
118
169
|
def write_payload(started_at:, finished_at:)
|
|
119
170
|
payload = {
|
|
171
|
+
'schema_version' => 2,
|
|
172
|
+
'collector' => { 'name' => 'necropsy-trace-point', 'version' => Necropsy::VERSION },
|
|
173
|
+
'scope' => { 'sample_unit' => 'event', 'sample_rate' => sample_rate },
|
|
174
|
+
'quality' => { 'dropped_events' => 0, 'overflowed' => false },
|
|
120
175
|
'nodes' => nodes.keys.sort,
|
|
121
|
-
'
|
|
176
|
+
'node_references' => node_references.values.sort_by { |reference| RuntimeReference.sort_key(reference) },
|
|
177
|
+
'edges' => legacy_edges,
|
|
178
|
+
'edge_references' => structured_edges,
|
|
122
179
|
'observation' => {
|
|
123
180
|
'started_at' => started_at.iso8601,
|
|
124
181
|
'finished_at' => finished_at.iso8601,
|
|
125
182
|
'days' => [((finished_at - started_at) / 86_400.0).ceil, 1].max,
|
|
126
|
-
'sample_rate' => sample_rate
|
|
183
|
+
'sample_rate' => sample_rate,
|
|
184
|
+
'collector_overhead' => {
|
|
185
|
+
'wall_time_seconds' => [finished_at - started_at, 0].max.round(6),
|
|
186
|
+
'observed_nodes' => nodes.length,
|
|
187
|
+
'observed_edges' => edges.length
|
|
188
|
+
}
|
|
127
189
|
}
|
|
128
190
|
}
|
|
129
191
|
payload['observation']['run_id'] = run_id if run_id
|
|
@@ -167,24 +229,72 @@ module Necropsy
|
|
|
167
229
|
end
|
|
168
230
|
left_observation = left.fetch('observation', {})
|
|
169
231
|
right_observation = right.fetch('observation', {})
|
|
170
|
-
observation =
|
|
232
|
+
observation = ObservationPolicy.compatible_merge(left_observation, right_observation)
|
|
171
233
|
observation['started_at'] = [left_observation['started_at'], right_observation['started_at']].compact.min
|
|
172
234
|
observation['finished_at'] = [left_observation['finished_at'], right_observation['finished_at']].compact.max
|
|
173
235
|
observation['days'] = [left_observation['days'].to_i, right_observation['days'].to_i, 1].max
|
|
236
|
+
observation['collector_overhead'] = merge_overhead(left_observation, right_observation)
|
|
174
237
|
observation['processes'] = process_count(left_observation) + process_count(right_observation)
|
|
175
238
|
{
|
|
239
|
+
'schema_version' => [left['schema_version'], right['schema_version'], 2].compact.max,
|
|
240
|
+
'collector' => right['collector'] || left['collector'],
|
|
241
|
+
'scope' => right['scope'] || left['scope'],
|
|
242
|
+
'quality' => right['quality'] || left['quality'],
|
|
176
243
|
'nodes' => (Array(left['nodes']) + Array(right['nodes'])).uniq.sort,
|
|
244
|
+
'node_references' => merge_node_references(left['node_references'], right['node_references']),
|
|
177
245
|
'edges' => edges,
|
|
246
|
+
'edge_references' => merge_edge_references(left['edge_references'], right['edge_references']),
|
|
178
247
|
'observation' => observation
|
|
179
248
|
}
|
|
180
249
|
end
|
|
181
250
|
|
|
251
|
+
def legacy_edges
|
|
252
|
+
edges.keys.sort.map { |caller_id, callee_id| { 'caller_id' => caller_id, 'callee_id' => callee_id } }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def structured_edges
|
|
256
|
+
edge_references.values.sort_by do |edge|
|
|
257
|
+
[RuntimeReference.sort_key(edge.fetch('caller_id')), RuntimeReference.sort_key(edge.fetch('callee_id'))]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def merge_node_references(left, right)
|
|
262
|
+
(Array(left) + Array(right))
|
|
263
|
+
.filter_map { |reference| RuntimeReference.normalize(reference) }
|
|
264
|
+
.uniq { |reference| RuntimeReference.key(reference) }
|
|
265
|
+
.sort_by { |reference| RuntimeReference.sort_key(reference) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def merge_edge_references(left, right)
|
|
269
|
+
(Array(left) + Array(right)).filter_map do |edge|
|
|
270
|
+
next unless edge.is_a?(Hash)
|
|
271
|
+
|
|
272
|
+
caller = RuntimeReference.normalize(edge['caller_id'] || edge[:caller_id])
|
|
273
|
+
callee = RuntimeReference.normalize(edge['callee_id'] || edge[:callee_id])
|
|
274
|
+
{ 'caller_id' => caller, 'callee_id' => callee } if caller && callee
|
|
275
|
+
end.uniq do |edge|
|
|
276
|
+
[RuntimeReference.key(edge.fetch('caller_id')), RuntimeReference.key(edge.fetch('callee_id'))]
|
|
277
|
+
end.sort_by do |edge|
|
|
278
|
+
[RuntimeReference.sort_key(edge.fetch('caller_id')), RuntimeReference.sort_key(edge.fetch('callee_id'))]
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
182
282
|
def process_count(observation)
|
|
183
283
|
return observation['processes'].to_i if observation['processes']
|
|
184
284
|
return 0 if observation.empty?
|
|
185
285
|
|
|
186
286
|
1
|
|
187
287
|
end
|
|
288
|
+
|
|
289
|
+
def merge_overhead(left, right)
|
|
290
|
+
left_overhead = left.fetch('collector_overhead', {})
|
|
291
|
+
right_overhead = right.fetch('collector_overhead', {})
|
|
292
|
+
{
|
|
293
|
+
'wall_time_seconds' => left_overhead['wall_time_seconds'].to_f + right_overhead['wall_time_seconds'].to_f,
|
|
294
|
+
'observed_nodes' => left_overhead['observed_nodes'].to_i + right_overhead['observed_nodes'].to_i,
|
|
295
|
+
'observed_edges' => left_overhead['observed_edges'].to_i + right_overhead['observed_edges'].to_i
|
|
296
|
+
}
|
|
297
|
+
end
|
|
188
298
|
end
|
|
189
299
|
end
|
|
190
300
|
end
|
|
@@ -9,7 +9,9 @@ module Necropsy
|
|
|
9
9
|
name: :trace_point,
|
|
10
10
|
kind: :dynamic,
|
|
11
11
|
soundness: :observational,
|
|
12
|
-
description: 'Imports method execution and caller-callee edges captured by Necropsy TracePoint recording.'
|
|
12
|
+
description: 'Imports method execution and caller-callee edges captured by Necropsy TracePoint recording.',
|
|
13
|
+
version: Necropsy::VERSION,
|
|
14
|
+
assumptions: %w[positive_observations_only tracepoint_call_events]
|
|
13
15
|
)
|
|
14
16
|
end
|
|
15
17
|
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
class LegacyResultAdapter
|
|
6
|
+
MISSING = Object.new.freeze
|
|
7
|
+
private_constant :MISSING
|
|
8
|
+
|
|
9
|
+
def initialize(graph:, profile:)
|
|
10
|
+
@graph = graph
|
|
11
|
+
@profile = profile
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def adapt(result)
|
|
15
|
+
return result unless legacy_static_result?(result)
|
|
16
|
+
|
|
17
|
+
edge_evidences = normalize_edge_evidences(result.edge_evidences)
|
|
18
|
+
alive_evidences = normalize_alive_evidences(result.alive_evidences)
|
|
19
|
+
wrapped_evidences = {}.compare_by_identity
|
|
20
|
+
result.edge_evidences.each { |edge| wrapped_evidences[edge.evidence] = true }
|
|
21
|
+
result.alive_evidences.each { |alive| wrapped_evidences[alive.evidence] = true }
|
|
22
|
+
evidences = result.evidences.reject { |record| wrapped_evidences.key?(record) }.map do |record|
|
|
23
|
+
normalize_evidence(
|
|
24
|
+
record,
|
|
25
|
+
relation: record.kind,
|
|
26
|
+
source: { 'type' => 'legacy_adapter', 'producer' => profile.name.to_s },
|
|
27
|
+
scope: {}
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
result.with(
|
|
31
|
+
edge_evidences: edge_evidences,
|
|
32
|
+
alive_evidences: alive_evidences,
|
|
33
|
+
resolutions: graph.call_sites.map { |site| resolution_for(site) },
|
|
34
|
+
evidences: EvidenceCollection.collect(evidences, edge_evidences, alive_evidences)
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :graph, :profile
|
|
41
|
+
|
|
42
|
+
def legacy_static_result?(result)
|
|
43
|
+
profile.kind.to_sym == :static && result.resolutions.nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def normalize_edge_evidences(edges)
|
|
47
|
+
@edges_by_call_site_id = Hash.new { |hash, key| hash[key] = [] }
|
|
48
|
+
edges.map do |edge|
|
|
49
|
+
site = matching_call_site(edge)
|
|
50
|
+
normalized = EdgeEvidence.new(
|
|
51
|
+
caller_id: edge.caller_id,
|
|
52
|
+
callee_id: edge.callee_id,
|
|
53
|
+
evidence: normalize_evidence(
|
|
54
|
+
edge.evidence,
|
|
55
|
+
relation: :call_edge,
|
|
56
|
+
source: legacy_edge_source(edge, site),
|
|
57
|
+
scope: legacy_edge_scope(edge, site)
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
@edges_by_call_site_id[site.call_site_id] << normalized if site
|
|
61
|
+
normalized
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_alive_evidences(alive_evidences)
|
|
66
|
+
alive_evidences.map do |alive|
|
|
67
|
+
AliveEvidence.new(
|
|
68
|
+
node_id: alive.node_id,
|
|
69
|
+
evidence: normalize_evidence(
|
|
70
|
+
alive.evidence,
|
|
71
|
+
relation: :alive,
|
|
72
|
+
source: { 'type' => 'legacy_adapter', 'producer' => profile.name.to_s },
|
|
73
|
+
scope: { 'node_reference' => alive.node_id.to_s }
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize_evidence(record, relation:, source:, scope:)
|
|
80
|
+
normalized = if record.grade
|
|
81
|
+
record
|
|
82
|
+
else
|
|
83
|
+
Evidence.new(
|
|
84
|
+
analyzer: record.analyzer,
|
|
85
|
+
kind: record.kind,
|
|
86
|
+
weight: record.weight,
|
|
87
|
+
details: record.details,
|
|
88
|
+
metadata: record.metadata,
|
|
89
|
+
producer: profile.name,
|
|
90
|
+
producer_version: profile.version || 'unversioned',
|
|
91
|
+
grade: legacy_evidence_grade,
|
|
92
|
+
relation: relation,
|
|
93
|
+
source: source,
|
|
94
|
+
assumptions: profile.assumptions,
|
|
95
|
+
scope: scope
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
return normalized if normalized.evidence_id
|
|
99
|
+
|
|
100
|
+
normalized.with(
|
|
101
|
+
evidence_id: EvidenceIdentity.generate(normalized.to_h.except('evidence_id'))
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def legacy_evidence_grade
|
|
106
|
+
profile.soundness.to_sym == :conservative ? :conservative : :heuristic
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def legacy_edge_source(edge, site)
|
|
110
|
+
{
|
|
111
|
+
'type' => 'legacy_adapter',
|
|
112
|
+
'producer' => profile.name.to_s,
|
|
113
|
+
'file' => site&.file,
|
|
114
|
+
'line' => site&.line,
|
|
115
|
+
'caller_reference' => edge.caller_id.to_s,
|
|
116
|
+
'callee_reference' => edge.callee_id.to_s
|
|
117
|
+
}.compact
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def legacy_edge_scope(edge, site)
|
|
121
|
+
if site
|
|
122
|
+
return {
|
|
123
|
+
'call_site_id' => site.call_site_id,
|
|
124
|
+
'caller_definition_id' => site.caller_id,
|
|
125
|
+
'target_reference' => edge.callee_id.to_s
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
{ 'caller_reference' => edge.caller_id.to_s, 'target_reference' => edge.callee_id.to_s }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def matching_call_site(edge)
|
|
133
|
+
metadata = edge.evidence.metadata
|
|
134
|
+
call_site_id = value(metadata, :call_site_id)
|
|
135
|
+
unless call_site_id.equal?(MISSING)
|
|
136
|
+
caller_id = physical_definition_id(edge.caller_id)
|
|
137
|
+
return unless caller_id
|
|
138
|
+
|
|
139
|
+
return unique_site do |site|
|
|
140
|
+
site.call_site_id == call_site_id.to_s && site.caller_id == caller_id
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
matching_legacy_call_site(edge, metadata)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def matching_legacy_call_site(edge, metadata)
|
|
148
|
+
caller_id = physical_definition_id(edge.caller_id)
|
|
149
|
+
return unless caller_id
|
|
150
|
+
|
|
151
|
+
metadata_caller = value(metadata, :caller_definition_id, :caller_id)
|
|
152
|
+
return if metadata_caller.equal?(MISSING)
|
|
153
|
+
return unless physical_definition_id(metadata_caller) == caller_id
|
|
154
|
+
|
|
155
|
+
signature = legacy_signature(metadata, caller_id)
|
|
156
|
+
return unless signature
|
|
157
|
+
|
|
158
|
+
unique_site do |site|
|
|
159
|
+
site.caller_id == caller_id && legacy_signature(site.to_h, caller_id) == signature
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def legacy_signature(attributes, caller_id)
|
|
164
|
+
message = value(attributes, :message)
|
|
165
|
+
receiver_kind = value(attributes, :receiver_kind)
|
|
166
|
+
receiver_name = value(attributes, :receiver_name)
|
|
167
|
+
file = value(attributes, :file)
|
|
168
|
+
line = value(attributes, :line)
|
|
169
|
+
test = value(attributes, :test)
|
|
170
|
+
dynamic = value(attributes, :dynamic)
|
|
171
|
+
metadata = value(attributes, :metadata)
|
|
172
|
+
values = [message, receiver_kind, receiver_name, file, line, test, dynamic, metadata]
|
|
173
|
+
return if values.any? { |item| item.equal?(MISSING) }
|
|
174
|
+
|
|
175
|
+
CallSiteIdentity.legacy_id(
|
|
176
|
+
caller_definition_id: caller_id,
|
|
177
|
+
message: message,
|
|
178
|
+
receiver_kind: receiver_kind,
|
|
179
|
+
receiver_name: receiver_name,
|
|
180
|
+
file: file,
|
|
181
|
+
line: line,
|
|
182
|
+
test: test,
|
|
183
|
+
dynamic: dynamic,
|
|
184
|
+
metadata: metadata
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def unique_site(&)
|
|
189
|
+
matches = graph.call_sites.select(&)
|
|
190
|
+
matches.first if matches.one?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def physical_definition_id(reference)
|
|
194
|
+
graph.nodes.lookup(reference.to_s).node&.graph_id
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def resolution_for(site)
|
|
198
|
+
edges = @edges_by_call_site_id.fetch(site.call_site_id, [])
|
|
199
|
+
targets = edges.filter_map { |edge| physical_definition_id(edge.callee_id) }.uniq.sort
|
|
200
|
+
status = targets.empty? ? :unknown : :partial
|
|
201
|
+
ResolutionRecord.new(
|
|
202
|
+
resolution: Resolution.new(
|
|
203
|
+
call_site_id: site.call_site_id,
|
|
204
|
+
target_definition_ids: targets,
|
|
205
|
+
status: status,
|
|
206
|
+
unknown_scope: UnknownScope.new(scope_kind: :message, scope_value: site.message, match: :exact),
|
|
207
|
+
evidence_ids: edges.filter_map { |edge| edge.evidence.evidence_id }.uniq.sort
|
|
208
|
+
),
|
|
209
|
+
producer: profile.name,
|
|
210
|
+
producer_version: profile.version || 'unversioned',
|
|
211
|
+
assumptions: profile.assumptions
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def value(attributes, *keys)
|
|
216
|
+
return MISSING unless attributes.respond_to?(:key?)
|
|
217
|
+
|
|
218
|
+
keys.each do |key|
|
|
219
|
+
return attributes[key.to_s] if attributes.key?(key.to_s)
|
|
220
|
+
return attributes[key] if attributes.key?(key)
|
|
221
|
+
end
|
|
222
|
+
MISSING
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -5,25 +5,48 @@ module Necropsy
|
|
|
5
5
|
module Static
|
|
6
6
|
class CHA < Analyzer
|
|
7
7
|
def analyze(graph, _project)
|
|
8
|
-
edge_evidences =
|
|
9
|
-
|
|
8
|
+
edge_evidences = []
|
|
9
|
+
resolutions = graph.call_sites.map do |site|
|
|
10
|
+
lookup = graph.cha_method_lookup(site)
|
|
11
|
+
targets = lookup.targets
|
|
12
|
+
site_edges = targets.map do |candidate|
|
|
10
13
|
EdgeEvidence.new(
|
|
11
14
|
caller_id: site.caller_id,
|
|
12
|
-
callee_id: candidate.
|
|
15
|
+
callee_id: candidate.graph_id,
|
|
13
16
|
evidence: evidence(
|
|
14
17
|
kind: :call_edge,
|
|
15
18
|
details: "CHA candidate at #{site.file}:#{site.line}",
|
|
16
|
-
metadata: site.to_h
|
|
19
|
+
metadata: site.to_h.merge('target_definition_id' => candidate.graph_id),
|
|
20
|
+
grade: :conservative,
|
|
21
|
+
relation: :call_edge,
|
|
22
|
+
source: call_site_evidence_source(site).merge('target_definition_id' => candidate.graph_id),
|
|
23
|
+
scope: call_site_evidence_scope(site).merge('target_definition_id' => candidate.graph_id)
|
|
17
24
|
)
|
|
18
25
|
)
|
|
19
26
|
end
|
|
27
|
+
edge_evidences.concat(site_edges)
|
|
28
|
+
status = if lookup.complete?
|
|
29
|
+
:complete
|
|
30
|
+
else
|
|
31
|
+
(targets.empty? ? :unknown : :partial)
|
|
32
|
+
end
|
|
33
|
+
resolution_record(
|
|
34
|
+
site,
|
|
35
|
+
targets,
|
|
36
|
+
site_edges,
|
|
37
|
+
status: status,
|
|
38
|
+
rejected_targets: lookup.complete? ? lookup.rejected_targets : [],
|
|
39
|
+
unknown_scope: graph.residual_scope_for(site)
|
|
40
|
+
)
|
|
20
41
|
end
|
|
21
42
|
|
|
22
43
|
AnalyzerResult.new(
|
|
23
44
|
edge_evidences: edge_evidences,
|
|
24
45
|
alive_evidences: [],
|
|
25
46
|
uncertainties: {},
|
|
26
|
-
observation: {}
|
|
47
|
+
observation: {},
|
|
48
|
+
resolutions: resolutions,
|
|
49
|
+
evidences: result_evidences(edge_evidences)
|
|
27
50
|
)
|
|
28
51
|
end
|
|
29
52
|
|
|
@@ -32,77 +55,14 @@ module Necropsy
|
|
|
32
55
|
name: :cha,
|
|
33
56
|
kind: :static,
|
|
34
57
|
soundness: :conservative,
|
|
35
|
-
description: 'Expands call targets through class hierarchy, descendants, and included/prepended modules.'
|
|
58
|
+
description: 'Expands call targets through class hierarchy, descendants, and included/prepended modules.',
|
|
59
|
+
version: Necropsy::VERSION,
|
|
60
|
+
assumptions: %w[closed_scanned_hierarchy loaded_ancestry]
|
|
36
61
|
)
|
|
37
62
|
end
|
|
38
63
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
when :constant
|
|
42
|
-
constant_targets(graph, site)
|
|
43
|
-
when :instance
|
|
44
|
-
instance_targets(graph, site)
|
|
45
|
-
when :self, :implicit
|
|
46
|
-
self_targets(graph, site)
|
|
47
|
-
else
|
|
48
|
-
graph.resolve_call_site(site)
|
|
49
|
-
end.uniq(&:id)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
private
|
|
53
|
-
|
|
54
|
-
def constant_targets(graph, site)
|
|
55
|
-
receiver_candidates(site).flat_map do |owner|
|
|
56
|
-
targets = owners_for_lookup(graph, owner, include_descendants: false).filter_map do |candidate_owner|
|
|
57
|
-
graph.nodes["#{candidate_owner}.#{site.message}"]
|
|
58
|
-
end
|
|
59
|
-
extended = Array(graph.class_info(owner)&.extends).filter_map do |mod|
|
|
60
|
-
graph.nodes["#{mod}##{site.message}"]
|
|
61
|
-
end
|
|
62
|
-
targets + extended
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def instance_targets(graph, site)
|
|
67
|
-
receiver_candidates(site).flat_map do |owner|
|
|
68
|
-
owners_for_lookup(graph, owner, include_descendants: true).filter_map do |candidate_owner|
|
|
69
|
-
graph.nodes["#{candidate_owner}##{site.message}"]
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def self_targets(graph, site)
|
|
75
|
-
caller = graph.nodes[site.caller_id]
|
|
76
|
-
return [] unless caller&.owner
|
|
77
|
-
|
|
78
|
-
separator = caller.kind == :singleton_method ? '.' : '#'
|
|
79
|
-
owners_for_lookup(graph, caller.owner, include_descendants: false).filter_map do |owner|
|
|
80
|
-
graph.nodes["#{owner}#{separator}#{site.message}"] || graph.nodes["#{owner}##{site.message}"]
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def owners_for_lookup(graph, owner, include_descendants:)
|
|
85
|
-
owners = include_descendants ? graph.descendants_of(owner) : [owner]
|
|
86
|
-
owners.flat_map do |candidate|
|
|
87
|
-
info = graph.class_info(candidate)
|
|
88
|
-
modules = info ? info.prepends + info.includes + info.extends : []
|
|
89
|
-
[candidate, *modules, *ancestors(graph, candidate)]
|
|
90
|
-
end.uniq
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def ancestors(graph, owner)
|
|
94
|
-
chain = []
|
|
95
|
-
current = graph.class_info(owner)&.superclass
|
|
96
|
-
while current && !chain.include?(current)
|
|
97
|
-
chain << current
|
|
98
|
-
current = graph.class_info(current)&.superclass
|
|
99
|
-
end
|
|
100
|
-
chain
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def receiver_candidates(site)
|
|
104
|
-
candidates = site.metadata['receiver_candidates'] || site.metadata[:receiver_candidates]
|
|
105
|
-
Array(candidates).compact.empty? ? [site.receiver_name].compact : Array(candidates).compact
|
|
64
|
+
def capabilities
|
|
65
|
+
[:complete_resolution].freeze
|
|
106
66
|
end
|
|
107
67
|
end
|
|
108
68
|
end
|