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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. 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(root: root, output: output, sample_rate: sample_rate, merge: merge, run_id: run_id).install_at_exit
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 = Time.now.utc
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: Time.now.utc)
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 = Time.now.utc
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: Time.now.utc)
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, :run_id
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
- node_id = node_id_for(event)
68
- return unless node_id
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, node_id)
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? && SecureRandom.random_number < sample_rate)
79
- caller_id = stack.last&.first if stack.last&.last
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
- nodes[node_id] = true
82
- edges[[caller_id, node_id]] = true if caller_id
92
+ record_node(reference)
93
+ record_edge(caller_reference, reference) if caller_reference
83
94
  end
84
- stack << [node_id, sampled]
95
+ stack << [reference, sampled]
85
96
  end
86
97
  end
87
98
 
88
- def unwind_stack(stack, node_id)
89
- index = stack.rindex { |frame| frame.first == node_id }
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
- 'edges' => edges.keys.map { |caller_id, callee_id| { 'caller_id' => caller_id, 'callee_id' => callee_id } },
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 = left_observation.merge(right_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 = graph.call_sites.flat_map do |site|
9
- candidates(graph, site).map do |candidate|
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.id,
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 candidates(graph, site)
40
- case site.receiver_kind
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