necropsy 0.1.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. metadata +106 -2
@@ -2,41 +2,163 @@
2
2
 
3
3
  module Necropsy
4
4
  class CallGraph
5
+ include DynamicEvidenceTracking
6
+ include EvidenceStore
7
+ include BlockerMatching
8
+ include ResolutionStore
9
+
5
10
  attr_reader :nodes, :call_sites, :instantiated_classes, :entry_points, :profiles, :observation, :class_infos,
6
- :entrypoint_hints
11
+ :entrypoint_hints, :ambiguity_limit, :file_statuses, :source_errors, :source_domains,
12
+ :scope_diagnostics, :method_signatures
7
13
 
8
- def initialize(scan_result)
9
- @nodes = {}
10
- @edges = Hash.new { |hash, key| hash[key] = {} }
14
+ def initialize(scan_result, ambiguity_limit: 4)
15
+ @nodes = DefinitionIndex.new
16
+ @edges = {}
17
+ @incoming_edges = {}
18
+ initialize_evidence_store
11
19
  @call_sites = scan_result.call_sites
12
20
  @instantiated_classes = scan_result.instantiated_classes.dup
13
21
  @class_infos = scan_result.class_infos.to_h { |info| [info.id, info] }
22
+ @direct_subclasses = Hash.new { |hash, key| hash[key] = [] }
23
+ @class_infos.each_value do |info|
24
+ @direct_subclasses[info.superclass] << info.id if info.superclass
25
+ end
26
+ @direct_subclasses.each_value(&:sort!)
14
27
  @entrypoint_hints = scan_result.entrypoint_hints
28
+ @file_statuses = scan_result.file_statuses.to_h do |file, status|
29
+ [file.to_s, status.to_sym]
30
+ end
31
+ @source_errors = scan_result.source_errors.dup
32
+ @source_domains = scan_result.source_domains.to_h do |file, domain|
33
+ [file.to_s, domain.to_sym]
34
+ end
35
+ @scope_diagnostics = scan_result.scope_diagnostics.dup
36
+ @method_signatures = scan_result.method_signatures.dup
37
+ @ambiguity_limit = ambiguity_limit
38
+ @blockers = []
39
+ initialize_blocker_indexes
15
40
  @entry_points = []
16
41
  @profiles = []
17
- @uncertainties = scan_result.uncertainties
42
+ @uncertainties = scan_result.uncertainties.to_h do |node_id, messages|
43
+ [node_id, Array(messages).dup]
44
+ end
18
45
  @dynamic_alive = {}
19
46
  @observation = {}
47
+ record_generated_macro_observation(scan_result.nodes)
48
+ initialize_resolution_store
49
+ @descendants = {}
50
+ @rta_instantiated_owner_cache = {}
51
+ @duplicate_blockers_initialized = false
20
52
  scan_result.nodes.each { |node| add_node(node) }
53
+ register_duplicate_definition_blockers
54
+ @duplicate_blockers_initialized = true
55
+ register_incomplete_source_blockers
56
+ scan_result.semantic_blockers.each { |blocker| add_blocker(blocker) }
21
57
  retain_known_instantiated_classes
22
58
  end
23
59
 
24
60
  def add_node(node)
25
- nodes[node.id] ||= node
61
+ existing = nodes.exact(node.graph_id)
62
+ return existing if existing
63
+
64
+ @method_nodes = nil
65
+ @nodes_by_name = nil
66
+ @runtime_nodes_by_name = nil
67
+ @dispatch_cache = nil
68
+ @lookup_chain_cache = nil
69
+ @singleton_lookup_chain_cache = nil
70
+ @owner_ancestor_cache = nil
71
+ @flow_lookup_cache = nil
72
+ @rta_instantiated_owner_cache = {}
73
+ @dynamic_ancestry_cache = {}
74
+ added = nodes.add(node)
75
+ register_duplicate_definition_blocker(node.symbol_id) if @duplicate_blockers_initialized
76
+ refresh_resolution_derived_state
77
+ added
78
+ end
79
+
80
+ def record_generated_macro_observation(scanned_nodes)
81
+ groups = scanned_nodes.select { |node| node.defined_via.to_s.start_with?('rails_') }.group_by do |node|
82
+ [node.file, node.line, node.owner, node.defined_via.to_s]
83
+ end
84
+ return if groups.empty?
85
+
86
+ observation['rails_generated_macros'] = {
87
+ 'count' => groups.length,
88
+ 'generated_method_count' => groups.values.sum(&:length),
89
+ 'macros' => groups.sort_by(&:first).map do |(file, line, owner, macro), nodes|
90
+ {
91
+ 'file' => file,
92
+ 'line' => line,
93
+ 'owner' => owner,
94
+ 'macro' => macro.delete_prefix('rails_'),
95
+ 'generated_methods' => nodes.map(&:name).uniq.sort
96
+ }
97
+ end
98
+ }
99
+ end
100
+ private :record_generated_macro_observation
101
+
102
+ def add_entry_point(node_id, reason, domain: nil, evidence: nil)
103
+ resolve_definitions(node_id).each do |definition|
104
+ entry = Root.new(
105
+ definition_id: definition.graph_id,
106
+ domain: domain,
107
+ reason: reason,
108
+ evidence: evidence
109
+ )
110
+ entry_points << entry unless entry_points.include?(entry)
111
+ end
26
112
  end
27
113
 
28
- def add_entry_point(node_id, reason)
29
- return unless nodes.key?(node_id)
114
+ def definitions_for(symbol_id)
115
+ nodes.definitions_for(symbol_id)
116
+ end
30
117
 
31
- entry = EntryPoint.new(node_id: node_id, reason: reason)
32
- entry_points << entry unless entry_points.include?(entry)
118
+ def call_sites_for_message(message)
119
+ @call_sites_by_message ||= call_sites.group_by { |site| site.message.to_s }.transform_values(&:freeze).freeze
120
+ @call_sites_by_message.fetch(message.to_s, [])
33
121
  end
34
122
 
35
- def apply_result(result)
36
- result.edge_evidences.each { |edge| add_edge(edge.caller_id, edge.callee_id, edge.evidence) }
37
- result.alive_evidences.each { |alive| add_alive(alive.node_id, alive.evidence) }
38
- result.uncertainties.each { |node_id, messages| uncertainties[node_id].concat(Array(messages)) }
123
+ def apply_result(result, refresh: true)
124
+ staged = transactional_copy
125
+ staged.send(:apply_result!, result, refresh: refresh)
126
+ staged.instance_variables.each do |name|
127
+ instance_variable_set(name, staged.instance_variable_get(name))
128
+ end
129
+ nil
130
+ end
131
+
132
+ def apply_result!(result, refresh: true)
133
+ register_result_call_sites(result)
134
+ dynamic_result = dynamic_result?(result)
135
+ edge_matches = result.edge_evidences.map do |edge|
136
+ next apply_dynamic_edge(edge) if dynamic_result
137
+
138
+ add_edge(edge.caller_id, edge.callee_id, edge.evidence)
139
+ end
140
+ alive_matches = result.alive_evidences.map do |alive|
141
+ dynamic_result ? apply_dynamic_alive(alive.node_id, alive.evidence) : add_alive(alive.node_id, alive.evidence)
142
+ end
143
+ register_result_evidences(result)
144
+ result.uncertainties.each do |node_id, messages|
145
+ resolved_ids = resolve_definitions(node_id).map(&:graph_id)
146
+ resolved_ids = [node_id] if resolved_ids.empty?
147
+ resolved_ids.each do |resolved_id|
148
+ @uncertainties[resolved_id] ||= []
149
+ @uncertainties[resolved_id].concat(Array(messages))
150
+ end
151
+ end
152
+ Array(result.respond_to?(:blockers) ? result.blockers : []).each { |blocker| add_blocker(blocker) }
39
153
  observation.merge!(result.observation) { |_key, left, right| merge_observation(left, right) }
154
+ record_dynamic_evidence(result, alive_matches, edge_matches) if dynamic_result
155
+ register_result_resolutions(result, refresh: refresh)
156
+ refresh_resolution_derived_state if refresh && (!result.respond_to?(:resolutions) || result.resolutions.nil?)
157
+ end
158
+ private :apply_result!
159
+
160
+ def refresh_derived_state
161
+ refresh_resolution_derived_state
40
162
  end
41
163
 
42
164
  def add_profile(profile)
@@ -44,55 +166,167 @@ module Necropsy
44
166
  end
45
167
 
46
168
  def add_edge(caller_id, callee_id, evidence)
47
- return unless nodes.key?(caller_id) && nodes.key?(callee_id)
169
+ callers = resolve_definitions(caller_id)
170
+ callees = resolve_definitions(callee_id)
171
+ if callers.empty? || callees.empty?
172
+ domain = callers.any? && callers.all?(&:test) ? :test : :runtime
173
+ register_evidence(evidence, domain: domain)
174
+ return false
175
+ end
48
176
 
49
- @edges[caller_id][callee_id] ||= []
50
- @edges[caller_id][callee_id] << evidence
177
+ record_ambiguous_input(:edge_caller, caller_id, callers)
178
+ record_ambiguous_input(:edge_callee, callee_id, callees)
179
+ callers.product(callees).each do |caller, callee|
180
+ add_physical_edge(caller.graph_id, callee.graph_id, evidence)
181
+ end
182
+ true
51
183
  end
52
184
 
53
185
  def add_alive(node_id, evidence)
54
- return unless nodes.key?(node_id)
186
+ definitions = resolve_definitions(node_id)
187
+ if definitions.empty?
188
+ register_evidence(evidence)
189
+ return false
190
+ end
191
+
192
+ record_ambiguous_input(:alive, node_id, definitions)
193
+ definitions.each do |definition|
194
+ evidence_id = register_evidence(evidence, domain: definition.test ? :test : :runtime)
195
+ next unless evidence_id
55
196
 
56
- @dynamic_alive[node_id] ||= []
57
- @dynamic_alive[node_id] << evidence
197
+ @dynamic_alive[definition.graph_id] ||= Set.new
198
+ @dynamic_alive[definition.graph_id] << evidence_id
199
+ end
200
+ true
58
201
  end
59
202
 
60
203
  def dynamic_alive?(node_id)
61
- @dynamic_alive.key?(node_id)
204
+ resolve_definitions(node_id).any? do |definition|
205
+ @dynamic_alive.fetch(definition.graph_id, Set.new).any? { |evidence_id| evidence_record(evidence_id) }
206
+ end
62
207
  end
63
208
 
64
- def alive_evidences(node_id)
65
- @dynamic_alive[node_id] || []
209
+ def alive_evidences(node_id, projection: :conservative, scope: nil)
210
+ projection = normalize_projection(projection)
211
+ evidence_ids = resolve_definitions(node_id).flat_map do |definition|
212
+ @dynamic_alive.fetch(definition.graph_id, Set.new).to_a
213
+ end
214
+ projected_evidence_records(evidence_ids.uniq, projection: projection, scope: scope)
66
215
  end
67
216
 
68
217
  def dynamic_enabled?
69
- @dynamic_alive.any? || observation.any?
218
+ @dynamic_alive.any?
70
219
  end
71
220
 
72
- def edges_from(node_id)
73
- @edges[node_id] || {}
221
+ def runtime_feedback(observed_targets:, max_fixtures: RuntimeFeedback::DEFAULT_FIXTURE_LIMIT)
222
+ RuntimeFeedback.from_graph(
223
+ self,
224
+ observed_targets: observed_targets,
225
+ max_fixtures: max_fixtures
226
+ ).call
74
227
  end
75
228
 
76
- def edges
77
- @edges.flat_map do |caller_id, callees|
78
- callees.map do |callee_id, evidences|
79
- Edge.new(caller_id: caller_id, callee_id: callee_id, evidences: evidences)
229
+ def performance_counts
230
+ {
231
+ 'definitions' => nodes.length,
232
+ 'call_sites' => call_sites.length,
233
+ 'edges' => edges.length,
234
+ 'blockers' => blockers.length,
235
+ 'resolution_cache_hits' => @resolution_cache_hits.to_i,
236
+ 'resolution_cache_misses' => @resolution_cache_misses.to_i
237
+ }
238
+ end
239
+
240
+ def edges_from(node_id, projection: :conservative, scope: nil)
241
+ projection = normalize_projection(projection)
242
+ evidence_ids_by_callee = resolve_definitions(node_id).each_with_object(Hash.new { |hash, key| hash[key] = Set.new }) do |definition, merged|
243
+ @edges.fetch(definition.graph_id, {}).each do |callee_id, evidence_ids|
244
+ merged[callee_id].merge(evidence_ids)
80
245
  end
81
246
  end
247
+ evidence_ids_by_callee.each_with_object({}) do |(callee_id, evidence_ids), projected|
248
+ records = projected_evidence_records(evidence_ids, projection: projection, scope: scope)
249
+ projected[callee_id] = records unless records.empty?
250
+ end
251
+ end
252
+
253
+ def edge_present?(caller_id, callee_id)
254
+ @edges.dig(caller_id, callee_id)&.any? { |evidence_id| evidence_record(evidence_id) }
82
255
  end
83
256
 
84
- def incoming_edges(node_id)
85
- edges.select { |edge| edge.callee_id == node_id }
257
+ def edges(projection: :conservative, scope: nil)
258
+ projection = normalize_projection(projection)
259
+ @edges.flat_map do |caller_id, callees|
260
+ callees.filter_map do |callee_id, evidence_ids|
261
+ evidences = projected_evidence_records(evidence_ids, projection: projection, scope: scope)
262
+ Edge.new(caller_id: caller_id, callee_id: callee_id, evidences: evidences) unless evidences.empty?
263
+ end
264
+ end.sort_by { |edge| [edge.caller_id, edge.callee_id] }
265
+ end
266
+
267
+ def edge_relations(projection: :conservative, scope: nil)
268
+ projection = normalize_projection(projection)
269
+ @edges.flat_map do |caller_id, callees|
270
+ callees.filter_map do |callee_id, evidence_ids|
271
+ projected_ids = projected_evidence_ids(evidence_ids, projection: projection, scope: scope)
272
+ next if projected_ids.empty?
273
+
274
+ EdgeRelation.new(
275
+ caller_id: caller_id,
276
+ callee_id: callee_id,
277
+ evidence_ids: projected_ids,
278
+ projection: projection
279
+ )
280
+ end
281
+ end.sort_by { |edge| [edge.caller_id, edge.callee_id] }
282
+ end
283
+
284
+ def incoming_edges(node_id, projection: :conservative, scope: nil)
285
+ projection = normalize_projection(projection)
286
+ resolve_definitions(node_id).flat_map do |definition|
287
+ @incoming_edges.fetch(definition.graph_id, {}).filter_map do |caller_id, evidence_ids|
288
+ evidences = projected_evidence_records(evidence_ids, projection: projection, scope: scope)
289
+ Edge.new(caller_id: caller_id, callee_id: definition.graph_id, evidences: evidences) unless evidences.empty?
290
+ end
291
+ end.sort_by { |edge| [edge.caller_id, edge.callee_id] }
86
292
  end
87
293
 
88
294
  def uncertainties(node_id = nil)
89
295
  return @uncertainties unless node_id
90
296
 
91
- @uncertainties[node_id] || []
297
+ resolved = resolve_definitions(node_id)
298
+ return @uncertainties.fetch(node_id, []) if resolved.empty?
299
+
300
+ resolved.flat_map { |definition| @uncertainties.fetch(definition.graph_id, []) }.uniq
301
+ end
302
+
303
+ def incomplete_files
304
+ file_statuses.filter_map { |file, status| file unless status == :complete }.sort
305
+ end
306
+
307
+ def analyze_source?(file)
308
+ source_domains.fetch(file.to_s, :analyze) == :analyze
309
+ end
310
+
311
+ def source_incompleteness
312
+ {
313
+ 'incomplete_files' => incomplete_files.length,
314
+ 'files' => incomplete_files.map do |file|
315
+ {
316
+ 'file' => file,
317
+ 'status' => file_statuses.fetch(file).to_s,
318
+ 'errors' => source_errors.select { |error| error.file == file }.map(&:to_h)
319
+ }
320
+ end
321
+ }
92
322
  end
93
323
 
94
324
  def method_nodes
95
- nodes.values.select(&:method?)
325
+ @method_nodes ||= nodes.values.select(&:method?)
326
+ end
327
+
328
+ def nodes_by_name
329
+ @nodes_by_name ||= method_nodes.group_by(&:name).freeze
96
330
  end
97
331
 
98
332
  def class_info(owner)
@@ -100,7 +334,21 @@ module Necropsy
100
334
  end
101
335
 
102
336
  def descendants_of(owner)
103
- class_infos.keys.select { |candidate| candidate == owner || ancestor_chain(candidate).include?(owner) }
337
+ @descendants[owner] ||= begin
338
+ descendants = []
339
+ queue = class_infos.key?(owner) ? [owner] : []
340
+ seen = Set.new
341
+ head = 0
342
+ while head < queue.length
343
+ candidate = queue.fetch(head)
344
+ head += 1
345
+ next unless seen.add?(candidate)
346
+
347
+ descendants << candidate
348
+ queue.concat(@direct_subclasses.fetch(candidate, []))
349
+ end
350
+ descendants.freeze
351
+ end
104
352
  end
105
353
 
106
354
  def modules_for(owner)
@@ -110,56 +358,868 @@ module Necropsy
110
358
  (info.prepends + info.includes + info.extends).uniq
111
359
  end
112
360
 
113
- def candidate_nodes(message)
114
- method_nodes.select { |node| node.name == message }
361
+ def candidate_nodes(message, domain: nil)
362
+ index = domain&.to_sym == :runtime ? runtime_nodes_by_name : nodes_by_name
363
+ index.fetch(message, [])
364
+ end
365
+
366
+ def ambiguous_fallback_candidates(message, domain: nil)
367
+ candidates = candidate_nodes(message, domain: domain)
368
+ return candidates if candidates.one?
369
+ return [] if candidates.size > @ambiguity_limit
370
+
371
+ candidates
372
+ end
373
+
374
+ def ambiguity_exceeded?(message, domain: nil)
375
+ candidate_nodes(message, domain: domain).size > ambiguity_limit
376
+ end
377
+
378
+ def ambiguous_resolution?
379
+ @ambiguity_limit > 1
380
+ end
381
+
382
+ def owner_reachable_from_ancestor?(owner, ancestor)
383
+ @owner_ancestor_cache ||= {}
384
+ key = [owner, ancestor]
385
+ return @owner_ancestor_cache[key] if @owner_ancestor_cache.key?(key)
386
+
387
+ @owner_ancestor_cache[key] = descendants_of(ancestor).any? do |descendant|
388
+ cached_lookup_chain(descendant).include?(owner)
389
+ end
115
390
  end
116
391
 
117
392
  def resolve_call_site(site, rta: false)
118
- candidates = candidates_for_receiver(site)
393
+ candidates = rta ? rta_candidates_for_receiver(site) : method_lookup(site).targets
119
394
  candidates = candidates.select { |node| rta_candidate?(node, site) } if rta
120
395
  candidates
121
396
  end
122
397
 
398
+ def cha_method_lookup(site)
399
+ primary = method_lookup(site)
400
+ return primary if primary.complete?
401
+
402
+ lookups = case site.receiver_kind
403
+ when :constant
404
+ receiver_candidates(site).map { |owner| canonical_receiver_lookup(site, owner, :constant) }
405
+ when :instance
406
+ receiver_candidates(site).flat_map do |owner|
407
+ descendants_of(owner).map { |candidate| canonical_receiver_lookup(site, candidate, :instance) }
408
+ end
409
+ else
410
+ []
411
+ end
412
+ targets = [primary, *lookups].flat_map(&:targets).uniq(&:graph_id).sort_by(&:graph_id)
413
+ chain = [primary, *lookups].flat_map(&:lookup_chain).uniq
414
+ incomplete_method_lookup(targets, chain, 'cha_canonical_lookup')
415
+ end
416
+
417
+ def residual_scope_for(site)
418
+ return UnknownScope.new(scope_kind: :message, scope_value: site.message, match: :exact) if site.receiver_kind == :unknown
419
+
420
+ owners = receiver_candidates(site)
421
+ owners = [nodes.exact(site.caller_id)&.owner].compact if owners.empty?
422
+ return UnknownScope.new(scope_kind: :message, scope_value: site.message, match: :exact) if owners.empty?
423
+
424
+ UnknownScope.new(scope_kind: :owner, scope_value: owners.sort, match: :exact)
425
+ end
426
+
427
+ def method_lookup(site)
428
+ return fallback_method_lookup(site, reason: 'dynamic_message') if site.dynamic
429
+ return physical_target_lookup(site) if physical_target_id(site)
430
+ return unproven_initialize_lookup(site) if unproven_initialize_dispatch?(site)
431
+ return callable_method_lookup(site) if flow_callable?(site)
432
+ return flow_instance_method_lookup(site) if flow_instance_types(site)
433
+
434
+ case site.receiver_kind
435
+ when :constant
436
+ owner = resolved_receiver_owner(site)
437
+ ordered_method_lookup(
438
+ site,
439
+ singleton_lookup_entries(owner),
440
+ reason: 'singleton_lookup',
441
+ completeness_entries: [[owner, '.']]
442
+ )
443
+ when :instance
444
+ owner = resolved_receiver_owner(site)
445
+ ordered_method_lookup(
446
+ site,
447
+ instance_lookup_entries(owner),
448
+ reason: 'instance_lookup',
449
+ completeness_entries: [[owner, '#']]
450
+ )
451
+ when :self, :implicit
452
+ self_method_lookup(site)
453
+ when :super
454
+ super_method_lookup(site)
455
+ else
456
+ fallback_method_lookup(site, reason: 'unknown_receiver')
457
+ end
458
+ end
459
+
460
+ def flow_instance_method_lookup(site)
461
+ @flow_lookup_cache ||= {}
462
+ cache_key = [site.call_site_id, flow_instance_types(site)]
463
+ return @flow_lookup_cache[cache_key] if @flow_lookup_cache.key?(cache_key)
464
+
465
+ results = flow_instance_types(site).map do |owner|
466
+ ordered_method_lookup(
467
+ site,
468
+ instance_lookup_entries(owner),
469
+ reason: 'flow_instance_lookup',
470
+ completeness_entries: [[owner, '#']]
471
+ )
472
+ end
473
+ result = results.first if results.one?
474
+ return @flow_lookup_cache[cache_key] = result if result
475
+
476
+ @flow_lookup_cache[cache_key] = merge_flow_method_lookups(results)
477
+ end
478
+
479
+ def physical_target_id(site)
480
+ hash_value(site.metadata, 'physical_target_definition_id')
481
+ end
482
+
483
+ def physical_target_lookup(site)
484
+ target = nodes.exact(physical_target_id(site).to_s)
485
+ return incomplete_method_lookup([], [], 'physical_target_missing') unless target
486
+
487
+ MethodLookup.new(
488
+ targets: [target],
489
+ status: :complete,
490
+ lookup_chain: [target.owner],
491
+ reason: 'physical_definition_relation'
492
+ )
493
+ end
494
+
495
+ def merge_flow_method_lookups(results)
496
+ targets = results.flat_map(&:targets).uniq(&:graph_id).sort_by(&:graph_id)
497
+ chain = results.flat_map(&:lookup_chain).uniq
498
+ return incomplete_method_lookup(targets, chain, 'flow_instance_lookup_incomplete') unless results.all?(&:complete?)
499
+
500
+ accepted_ids = targets.to_set(&:graph_id)
501
+ rejections = results.flat_map(&:rejected_targets).reject do |rejection|
502
+ accepted_ids.include?(rejection.definition_id)
503
+ end.uniq { |rejection| [rejection.definition_id, rejection.reason] }
504
+ MethodLookup.new(
505
+ targets: targets,
506
+ status: :complete,
507
+ rejected_targets: rejections,
508
+ lookup_chain: chain,
509
+ reason: 'flow_instance_lookup'
510
+ )
511
+ end
512
+
513
+ def flow_instance_types(site)
514
+ fact = site.metadata['receiver_value_fact'] || site.metadata[:receiver_value_fact]
515
+ return nil unless fact.is_a?(Hash)
516
+ return nil unless hash_value(fact, 'kind').to_s == 'instance_types'
517
+ return nil unless hash_value(fact, 'exact')
518
+
519
+ values = Array(hash_value(fact, 'values')).map(&:to_s).reject(&:empty?).uniq.sort
520
+ return nil if values.empty?
521
+ return nil unless values.all? { |owner| constructor_dispatch_exact?(owner, site) }
522
+
523
+ values
524
+ end
525
+
526
+ def unproven_initialize_dispatch?(site)
527
+ metadata = site.metadata
528
+ return false unless hash_value(metadata, 'implicit_from').to_s == 'new'
529
+
530
+ !constructor_dispatch_exact?(resolved_receiver_owner(site), site)
531
+ end
532
+
533
+ def unproven_initialize_lookup(site)
534
+ owner = resolved_receiver_owner(site)
535
+ incomplete_method_lookup([], instance_lookup_entries(owner), 'constructor_dispatch_unproven')
536
+ end
537
+
538
+ def constructor_dispatch_exact?(owner, site)
539
+ info = class_info(owner)
540
+ return false unless info && !info.dynamic
541
+ return false if dynamic_ancestry_uncertain?(site)
542
+
543
+ entries = singleton_lookup_entries(owner)
544
+ return false if dynamic_lookup_chain?(entries)
545
+ return false if entries.any? { |candidate, separator| definitions_for("#{candidate}#{separator}new").any? }
546
+
547
+ !constructor_mutation_blocker?(owner, entries, site)
548
+ end
549
+
550
+ def constructor_mutation_blocker?(owner, entries, site)
551
+ owners = [owner, *entries.map(&:first)].to_set
552
+ @blockers.any? do |blocker|
553
+ next false unless %i[dynamic_ancestry unsupported_refinement variable_eval].include?(blocker.kind)
554
+ next false unless blocker.caller_domain == :runtime || site.test
555
+
556
+ blocker.scope_kind == :global || (blocker.scope_kind == :owner && owners.include?(blocker.scope_value))
557
+ end
558
+ end
559
+
560
+ def flow_callable?(site)
561
+ fact = site.metadata['receiver_value_fact'] || site.metadata[:receiver_value_fact]
562
+ fact.is_a?(Hash) && hash_value(fact, 'kind').to_s == 'callable_set' && hash_value(fact, 'exact') == true
563
+ end
564
+
565
+ def callable_method_lookup(_site)
566
+ incomplete_method_lookup([], [], 'callable_invocation')
567
+ end
568
+
569
+ def retain_rta_candidates(candidates, site)
570
+ candidates.select { |node| rta_candidate?(node, site) }
571
+ end
572
+
573
+ def reconcile_rta_result(result)
574
+ analyzed_sites = result.observation.dig('rta', 'analyzed_sites')
575
+ return unless analyzed_sites
576
+
577
+ analyzed_keys = analyzed_sites.to_set { |site| call_site_key(site) }
578
+ allowed = result.edge_evidences.each_with_object(Hash.new { |hash, key| hash[key] = Set.new }) do |edge, memo|
579
+ memo[call_site_key(edge.evidence.metadata)] << edge.callee_id
580
+ end
581
+
582
+ @edges.each_value do |callees|
583
+ callees.each do |callee_id, evidence_ids|
584
+ evidence_ids.delete_if do |evidence_id|
585
+ item = evidence_record(evidence_id)
586
+ next false unless item
587
+
588
+ producer = item.producer || item.analyzer
589
+ next false unless %w[name_resolution cha].include?(producer.to_s)
590
+
591
+ key = call_site_key(item.metadata)
592
+ analyzed_keys.include?(key) && !allowed[key].include?(callee_id)
593
+ end
594
+ end
595
+ callees.delete_if { |_callee_id, evidence_ids| evidence_ids.empty? }
596
+ end
597
+ @edges.delete_if { |_caller_id, callees| callees.empty? }
598
+ rebuild_incoming_edges
599
+ refresh_resolution_derived_state
600
+ end
601
+
602
+ def fallback_resolution?(site, resolved: nil)
603
+ resolved ||= resolve_call_site(site)
604
+ return false if resolved.empty?
605
+
606
+ lookup = method_lookup(site)
607
+ domain = site.test ? :test : :runtime
608
+ fallback = ambiguous_fallback_candidates(site.message, domain: domain)
609
+ !lookup.complete? && resolved.map(&:graph_id).sort == fallback.map(&:graph_id).sort
610
+ end
611
+
123
612
  def to_h
124
613
  {
125
614
  'nodes' => nodes.values.map(&:to_h),
615
+ 'call_sites' => call_sites.map(&:to_h),
126
616
  'edges' => edges.map(&:to_h),
617
+ 'edge_projection' => 'conservative',
618
+ 'edge_relations' => edge_relations.map(&:to_h),
619
+ 'evidence_records' => evidence_records.map(&:to_h),
620
+ 'evidence_collisions' => evidence_collisions,
127
621
  'entry_points' => entry_points.map(&:to_h),
128
622
  'class_infos' => class_infos.values.map(&:to_h),
129
623
  'instantiated_classes' => instantiated_classes.to_a.sort,
130
624
  'profiles' => profiles.map(&:to_h),
625
+ 'resolutions' => resolution_records.map(&:to_h),
626
+ 'resolution_conflicts' => resolution_conflicts,
627
+ 'blockers' => blockers.map(&:to_h),
628
+ 'file_statuses' => file_statuses.transform_values(&:to_s),
629
+ 'source_errors' => source_errors.map(&:to_h),
630
+ 'source_domains' => source_domains.transform_values(&:to_s),
631
+ 'scope_diagnostics' => scope_diagnostics,
131
632
  'observation' => observation
132
633
  }
133
634
  end
134
635
 
135
636
  private
136
637
 
137
- def candidates_for_receiver(site)
138
- case site.receiver_kind
139
- when :constant
140
- exact = receiver_candidates(site).filter_map { |name| nodes["#{name}.#{site.message}"] }.first
141
- exact ? [exact] : candidate_nodes(site.message)
142
- when :instance
143
- exact = receiver_candidates(site).filter_map { |name| nodes["#{name}##{site.message}"] }.first
144
- exact ? [exact] : candidate_nodes(site.message)
145
- when :self
146
- same_owner_candidates(site)
147
- when :implicit
148
- same_owner_candidates(site).then { |matches| matches.empty? ? candidate_nodes(site.message) : matches }
638
+ def runtime_nodes_by_name
639
+ @runtime_nodes_by_name ||= method_nodes.reject(&:test).group_by(&:name).freeze
640
+ end
641
+
642
+ def canonical_receiver_lookup(site, owner, kind)
643
+ entries = kind == :constant ? singleton_lookup_entries(owner) : instance_lookup_entries(owner)
644
+ separator = kind == :constant ? '.' : '#'
645
+ scoped_site = site.with(
646
+ receiver_kind: kind,
647
+ receiver_name: owner,
648
+ metadata: site.metadata.merge('receiver_candidates' => [owner])
649
+ )
650
+ ordered_method_lookup(
651
+ scoped_site,
652
+ entries,
653
+ reason: "cha_#{kind}_lookup",
654
+ completeness_entries: [[owner, separator]]
655
+ )
656
+ end
657
+
658
+ def transactional_copy
659
+ copy = dup
660
+ memo = {}.compare_by_identity
661
+ instance_variables.each do |name|
662
+ value = instance_variable_get(name)
663
+ copy.instance_variable_set(name, duplicate_transaction_value(value, memo))
664
+ end
665
+ copy
666
+ end
667
+
668
+ def duplicate_transaction_value(value, memo)
669
+ return memo.fetch(value) if memo.key?(value)
670
+
671
+ case value
672
+ when Hash
673
+ duplicate_transaction_hash(value, memo)
674
+ when Array
675
+ duplicate_transaction_array(value, memo)
676
+ when Set
677
+ duplicate_transaction_set(value, memo)
149
678
  else
150
- candidate_nodes(site.message)
679
+ value
151
680
  end
152
681
  end
153
682
 
154
- def same_owner_candidates(site)
155
- caller = nodes[site.caller_id]
156
- return [] unless caller&.owner
683
+ def duplicate_transaction_hash(value, memo)
684
+ copy = {}
685
+ copy.compare_by_identity if value.compare_by_identity?
686
+ copy.default_proc = value.default_proc if value.default_proc
687
+ copy.default = value.default unless value.default_proc
688
+ memo[value] = copy
689
+ value.each { |key, item| copy[key] = duplicate_transaction_value(item, memo) }
690
+ copy
691
+ end
157
692
 
158
- ids = [
159
- "#{caller.owner}##{site.message}",
160
- "#{caller.owner}.#{site.message}"
161
- ]
162
- ids.filter_map { |id| nodes[id] }
693
+ def duplicate_transaction_array(value, memo)
694
+ copy = []
695
+ memo[value] = copy
696
+ value.each { |item| copy << duplicate_transaction_value(item, memo) }
697
+ copy
698
+ end
699
+
700
+ def duplicate_transaction_set(value, memo)
701
+ copy = Set.new
702
+ memo[value] = copy
703
+ value.each { |item| copy.add(duplicate_transaction_value(item, memo)) }
704
+ copy
705
+ end
706
+
707
+ def register_result_evidences(result)
708
+ records = result.respond_to?(:evidences) ? result.evidences : []
709
+ Array(records).each do |evidence|
710
+ record = evidence_with_identity(evidence)
711
+ register_evidence(record) unless evidence_payload_registered?(record)
712
+ end
713
+ end
714
+
715
+ def register_result_call_sites(result)
716
+ sites = result.respond_to?(:derived_call_sites) ? result.derived_call_sites : []
717
+ Array(sites).each do |site|
718
+ raise TypeError, 'derived call sites must be CallSite values' unless site.is_a?(CallSite)
719
+
720
+ existing = call_sites.select { |candidate| candidate.call_site_id == site.call_site_id }
721
+ next if existing.include?(site)
722
+ raise Error, "Conflicting derived call site identity: #{site.call_site_id}" if existing.any?
723
+
724
+ call_sites << site
725
+ @call_sites_by_id[site.call_site_id] = [site]
726
+ @call_sites_by_message = nil
727
+ end
728
+ end
729
+
730
+ def register_duplicate_definition_blockers
731
+ method_nodes.reject(&:test).map(&:symbol_id).uniq.sort.each do |symbol_id|
732
+ register_duplicate_definition_blocker(symbol_id)
733
+ end
734
+ end
735
+
736
+ def register_duplicate_definition_blocker(symbol_id)
737
+ definitions = definitions_for(symbol_id).reject(&:test)
738
+ remove_blockers_matching do |blocker|
739
+ blocker.kind == :duplicate_definition && blocker.scope_kind == :symbol && blocker.scope_value == symbol_id
740
+ end
741
+ return unless definitions.length > 1
742
+
743
+ add_blocker(Blocker.new(
744
+ kind: :duplicate_definition,
745
+ scope_kind: :symbol,
746
+ scope_value: symbol_id,
747
+ source: :definition_index,
748
+ reason: 'Multiple production definitions have no proven activation order',
749
+ suggested_action: :review_definitions,
750
+ metadata: {
751
+ 'caller_domain' => 'runtime',
752
+ 'definition_count' => definitions.length,
753
+ 'definition_ids' => definitions.map(&:graph_id).sort,
754
+ 'locations' => definitions.map do |definition|
755
+ {
756
+ 'definition_id' => definition.graph_id,
757
+ 'file' => definition.file,
758
+ 'line' => definition.line
759
+ }
760
+ end
761
+ }
762
+ ))
763
+ end
764
+
765
+ def apply_dynamic_edge(edge)
766
+ caller = resolve_dynamic_reference(edge.caller_id)
767
+ callee = resolve_dynamic_reference(edge.callee_id)
768
+ record_dynamic_ambiguous_input(:edge_caller, caller)
769
+ record_dynamic_ambiguous_input(:edge_callee, callee)
770
+ mark_dynamic_alive(caller, edge.evidence)
771
+ mark_dynamic_alive(callee, edge.evidence)
772
+ materialized = precise_dynamic_resolution?(caller) && precise_dynamic_resolution?(callee)
773
+ if materialized
774
+ add_physical_edge(caller.fetch(:definitions).first.graph_id, callee.fetch(:definitions).first.graph_id,
775
+ edge.evidence)
776
+ end
777
+ register_evidence(edge.evidence) if caller.fetch(:definitions).empty? && callee.fetch(:definitions).empty?
778
+
779
+ { caller: caller, callee: callee, materialized: materialized }
780
+ end
781
+
782
+ def apply_dynamic_alive(reference, evidence)
783
+ resolution = resolve_dynamic_reference(reference)
784
+ record_dynamic_ambiguous_input(:alive, resolution)
785
+ mark_dynamic_alive(resolution, evidence)
786
+ register_evidence(evidence) if resolution.fetch(:definitions).empty?
787
+ resolution
788
+ end
789
+
790
+ def mark_dynamic_alive(resolution, evidence)
791
+ resolution.fetch(:definitions).each do |definition|
792
+ evidence_id = register_evidence(evidence, domain: definition.test ? :test : :runtime)
793
+ next unless evidence_id
794
+
795
+ @dynamic_alive[definition.graph_id] ||= Set.new
796
+ @dynamic_alive[definition.graph_id] << evidence_id
797
+ end
798
+ end
799
+
800
+ def precise_dynamic_resolution?(resolution)
801
+ %i[exact unique].include?(resolution.fetch(:status))
802
+ end
803
+
804
+ def resolve_dynamic_reference(reference)
805
+ normalized = normalize_dynamic_reference(reference)
806
+ return resolve_legacy_dynamic_reference(normalized) if normalized.key?('identifier')
807
+ return dynamic_resolution(normalized, :missing, []) unless normalized['symbol_id']
808
+
809
+ definition = nodes.exact(normalized['definition_id']) if normalized['definition_id']
810
+ exact_match = definition && dynamic_reference_matches?(definition, normalized)
811
+ return dynamic_resolution(normalized, :exact, [definition]) if exact_match
812
+
813
+ resolve_dynamic_reference_hints(normalized, definition_id_supplied: normalized.key?('definition_id'))
814
+ end
815
+
816
+ def normalize_dynamic_reference(reference)
817
+ return { 'identifier' => reference.to_s } unless reference.is_a?(Hash)
818
+
819
+ normalized = {}
820
+ %w[definition_id symbol_id file].each do |key|
821
+ value = reference[key] || reference[key.to_sym]
822
+ normalized[key] = value.to_s unless value.nil? || value.to_s.empty?
823
+ end
824
+ line = reference['line'] || reference[:line]
825
+ normalized['line'] = line.to_i unless line.nil?
826
+ normalized
827
+ end
828
+
829
+ def resolve_legacy_dynamic_reference(normalized)
830
+ lookup = nodes.lookup(normalized.fetch('identifier'))
831
+ dynamic_resolution(normalized, lookup.status, lookup.definitions)
832
+ end
833
+
834
+ def resolve_dynamic_reference_hints(normalized, definition_id_supplied:)
835
+ symbol_id = normalized['symbol_id']
836
+ location_supplied = normalized.key?('file') || normalized.key?('line')
837
+ return dynamic_resolution(normalized, :missing, []) unless symbol_id
838
+ return dynamic_resolution(normalized, :missing, []) if definition_id_supplied && !location_supplied
839
+
840
+ definitions = definitions_for(symbol_id)
841
+ definitions = definitions.select { |node| node.file == normalized['file'] } if normalized.key?('file')
842
+ definitions = definitions.select { |node| node.line.to_i == normalized['line'] } if normalized.key?('line')
843
+ status = if definitions.empty?
844
+ :missing
845
+ elsif definitions.one?
846
+ :unique
847
+ else
848
+ :ambiguous
849
+ end
850
+ dynamic_resolution(normalized, status, definitions)
851
+ end
852
+
853
+ def dynamic_reference_matches?(definition, normalized)
854
+ return false if normalized['symbol_id'] && definition.symbol_id != normalized['symbol_id']
855
+ return false if normalized['file'] && definition.file != normalized['file']
856
+ return false if normalized.key?('line') && definition.line.to_i != normalized['line']
857
+
858
+ true
859
+ end
860
+
861
+ def dynamic_resolution(reference, status, definitions)
862
+ { reference: reference.freeze, status: status, definitions: definitions.freeze }.freeze
863
+ end
864
+
865
+ def record_dynamic_ambiguous_input(kind, resolution)
866
+ return unless resolution.fetch(:status) == :ambiguous
867
+
868
+ record_definition_ambiguity(
869
+ kind,
870
+ { 'reference' => resolution.fetch(:reference) },
871
+ resolution.fetch(:definitions)
872
+ )
873
+ end
874
+
875
+ def register_incomplete_source_blockers
876
+ incomplete_files.each do |file|
877
+ errors = source_errors.select { |error| error.file == file }
878
+ first_error = errors.first
879
+ status = file_statuses.fetch(file)
880
+ domain = source_domain(file)
881
+ metadata = {
882
+ 'file' => file,
883
+ 'line' => first_error&.line || 1,
884
+ 'status' => status.to_s,
885
+ 'caller_domain' => domain.to_s,
886
+ 'source_errors' => errors.map(&:to_h)
887
+ }
888
+ add_blocker(Blocker.new(
889
+ kind: :parse_incomplete,
890
+ scope_kind: :file,
891
+ scope_value: file,
892
+ source: first_error || :ast_scanner,
893
+ reason: "Source file was #{status}; dead-code conclusions are incomplete",
894
+ metadata: metadata
895
+ ))
896
+ next unless domain == :runtime
897
+
898
+ add_blocker(Blocker.new(
899
+ kind: :parse_incomplete,
900
+ scope_kind: :global,
901
+ scope_value: '*',
902
+ source: first_error || :ast_scanner,
903
+ reason: "Runtime source file was #{status}; missing calls can affect any dead-code conclusion",
904
+ metadata: metadata.merge('impact' => 'missing_outgoing_calls')
905
+ ))
906
+ end
907
+ end
908
+
909
+ def source_domain(file)
910
+ source_nodes = nodes.values.select { |node| node.file == file }
911
+ return file.start_with?('spec/', 'test/') ? :test : :runtime if source_nodes.empty?
912
+
913
+ source_nodes.all?(&:test) ? :test : :runtime
914
+ end
915
+
916
+ def rta_candidates_for_receiver(site)
917
+ exact = method_lookup(site).targets
918
+ return exact unless exact.empty?
919
+
920
+ candidate_nodes(site.message, domain: site.test ? :test : :runtime)
921
+ end
922
+
923
+ def self_method_lookup(site)
924
+ caller = nodes.exact(site.caller_id)
925
+ owner = caller&.owner || site.receiver_name
926
+ return fallback_method_lookup(site, reason: 'unknown_self_owner') unless owner
927
+
928
+ singleton = caller&.kind == :singleton_method || !caller&.method?
929
+ entries = singleton ? singleton_lookup_entries(owner) : instance_lookup_entries(owner)
930
+ separator = singleton ? '.' : '#'
931
+ result = ordered_method_lookup(
932
+ site,
933
+ entries,
934
+ reason: singleton ? 'singleton_self_lookup' : 'instance_self_lookup',
935
+ completeness_entries: [[owner, separator]]
936
+ )
937
+ return result unless result.unknown?
938
+ return result if singleton
939
+
940
+ fallback_method_lookup(site, reason: 'self_lookup_fallback')
941
+ end
942
+
943
+ def super_method_lookup(site)
944
+ caller = nodes.exact(site.caller_id)
945
+ return fallback_method_lookup(site, reason: 'unknown_super_owner') unless caller&.owner
946
+
947
+ separator = caller.kind == :singleton_method ? '.' : '#'
948
+ info = class_info(caller.owner)
949
+ return module_super_method_lookup(site, caller, separator) if info&.kind == :module && separator == '#'
950
+
951
+ entries = separator == '.' ? singleton_lookup_entries(caller.owner) : instance_lookup_entries(caller.owner)
952
+ current_index = entries.index([caller.owner, separator])
953
+ return incomplete_method_lookup([], entries, 'current_implementation_not_in_lookup_chain') unless current_index
954
+
955
+ ordered_method_lookup(
956
+ site,
957
+ entries.drop(current_index + 1),
958
+ reason: 'super_next_implementation',
959
+ completeness_entries: entries.first(current_index + 1)
960
+ )
961
+ end
962
+
963
+ def module_super_method_lookup(site, caller, separator)
964
+ hosts = class_infos.values.select { |info| info.kind == :class }.sort_by(&:id).flat_map do |host|
965
+ [instance_lookup_entries(host.id), singleton_lookup_entries(host.id)].filter_map do |entries|
966
+ index = entries.index([caller.owner, separator])
967
+ entries.drop(index + 1) if index
968
+ end
969
+ end
970
+ own_entries = instance_lookup_entries(caller.owner)
971
+ own_index = own_entries.index([caller.owner, separator])
972
+ hosts << own_entries.drop(own_index + 1) if own_index
973
+ results = hosts.map do |entries|
974
+ ordered_method_lookup(site, entries, reason: 'module_super_host_lookup', force_partial: true)
975
+ end
976
+ targets = results.flat_map(&:targets).uniq(&:graph_id).sort_by(&:graph_id)
977
+ chain = results.flat_map(&:lookup_chain).uniq
978
+ incomplete_method_lookup(targets, chain.map { |owner| [owner, '#'] }, 'module_super_open_hosts')
979
+ end
980
+
981
+ def ordered_method_lookup(site, entries, reason:, completeness_entries: [], force_partial: false)
982
+ entries = Array(entries)
983
+ target_index = entries.index do |owner, separator|
984
+ definitions_for("#{owner}#{separator}#{site.message}").any?
985
+ end
986
+ unless target_index
987
+ missing = method_missing_lookup(site, entries, reason, completeness_entries, force_partial)
988
+ return missing if missing
989
+
990
+ targets = dynamic_ancestry_candidates(site, [])
991
+ return incomplete_method_lookup(targets, entries, "#{reason}_missing")
992
+ end
993
+
994
+ target_entry = entries.fetch(target_index)
995
+ targets = definitions_for("#{target_entry.first}#{target_entry.last}#{site.message}")
996
+ prefix = completeness_entries + entries.first(target_index + 1)
997
+ complete = !force_partial && complete_lookup_prefix?(prefix) && !dynamic_lookup_chain?(entries) &&
998
+ !dynamic_ancestry_uncertain?(site)
999
+ targets = dynamic_ancestry_candidates(site, targets) if dynamic_ancestry_uncertain?(site)
1000
+ return incomplete_method_lookup(targets, entries, "#{reason}_incomplete") unless complete
1001
+
1002
+ if targets.any? { |target| protected_visibility_outcome(site, target) == :unknown }
1003
+ return incomplete_method_lookup(targets, entries, "#{reason}_protected_context_unknown")
1004
+ end
1005
+
1006
+ accepted, target_rejections = apply_complete_target_constraints(site, targets)
1007
+ shadowed = shadowed_rejections(site.message, entries.drop(target_index + 1))
1008
+ MethodLookup.new(
1009
+ targets: accepted,
1010
+ status: :complete,
1011
+ rejected_targets: target_rejections + shadowed,
1012
+ lookup_chain: entries.map(&:first),
1013
+ reason: reason
1014
+ )
1015
+ end
1016
+
1017
+ def method_missing_lookup(site, entries, reason, completeness_entries, force_partial)
1018
+ missing_index = entries.index do |owner, separator|
1019
+ definitions_for("#{owner}#{separator}method_missing").any?
1020
+ end
1021
+ return unless missing_index
1022
+
1023
+ entry = entries.fetch(missing_index)
1024
+ targets = definitions_for("#{entry.first}#{entry.last}method_missing")
1025
+ prefix = completeness_entries + entries.first(missing_index + 1)
1026
+ complete = !force_partial && complete_lookup_prefix?(prefix) && !dynamic_lookup_chain?(entries) &&
1027
+ !dynamic_ancestry_uncertain?(site)
1028
+ return incomplete_method_lookup(targets, entries, "#{reason}_method_missing_incomplete") unless complete
1029
+
1030
+ MethodLookup.new(
1031
+ targets: targets,
1032
+ status: :complete,
1033
+ lookup_chain: entries.map(&:first),
1034
+ reason: "#{reason}_method_missing"
1035
+ )
1036
+ end
1037
+
1038
+ def incomplete_method_lookup(targets, entries, reason)
1039
+ targets = Array(targets)
1040
+ MethodLookup.new(
1041
+ targets: targets,
1042
+ status: targets.empty? ? :unknown : :partial,
1043
+ lookup_chain: Array(entries).map { |entry| entry.is_a?(Array) ? entry.first : entry },
1044
+ reason: reason
1045
+ )
1046
+ end
1047
+
1048
+ def fallback_method_lookup(site, reason:)
1049
+ domain = site.test ? :test : :runtime
1050
+ incomplete_method_lookup(ambiguous_fallback_candidates(site.message, domain: domain), [], reason)
1051
+ end
1052
+
1053
+ def apply_complete_target_constraints(site, targets)
1054
+ accepted = []
1055
+ rejected = []
1056
+ targets.each do |target|
1057
+ visibility_reason = visibility_rejection_reason(site, target)
1058
+ if visibility_reason
1059
+ rejected << RejectedTarget.new(definition_id: target.graph_id, reason: visibility_reason)
1060
+ elsif arity_target_rejected?(site, target)
1061
+ rejected << RejectedTarget.new(definition_id: target.graph_id, reason: 'arity_mismatch')
1062
+ else
1063
+ accepted << target
1064
+ end
1065
+ end
1066
+ [accepted, rejected]
1067
+ end
1068
+
1069
+ def visibility_rejection_reason(site, target)
1070
+ return 'protected_visibility' if protected_visibility_outcome(site, target) == :reject
1071
+
1072
+ 'private_visibility' if private_target_rejected?(site, target)
1073
+ end
1074
+
1075
+ def protected_visibility_outcome(site, target)
1076
+ return :allow unless target.visibility == :protected
1077
+
1078
+ original = site.metadata['original_message'] || site.metadata[:original_message]
1079
+ return :reject if original.to_s == 'public_send'
1080
+ return :allow if %w[send __send__ method].include?(original.to_s)
1081
+ return :allow if %i[implicit self super].include?(site.receiver_kind)
1082
+
1083
+ caller_owner = nodes.exact(site.caller_id)&.owner
1084
+ receiver_owner = resolved_receiver_owner(site)
1085
+ return :unknown unless protected_context_known?(caller_owner, receiver_owner, target.owner)
1086
+
1087
+ caller_family = cached_lookup_chain(caller_owner).include?(target.owner)
1088
+ receiver_family = cached_lookup_chain(receiver_owner).include?(target.owner)
1089
+ caller_family && receiver_family ? :allow : :reject
1090
+ end
1091
+
1092
+ def protected_context_known?(*owners)
1093
+ owners.all? do |owner|
1094
+ info = class_info(owner)
1095
+ info && !info.dynamic
1096
+ end
1097
+ end
1098
+
1099
+ def private_target_rejected?(site, target)
1100
+ return false unless target.visibility == :private
1101
+ return false if site.metadata['implicit_private_dispatch'] == true
1102
+ return false if %i[implicit self super].include?(site.receiver_kind)
1103
+
1104
+ original = site.metadata['original_message'] || site.metadata[:original_message]
1105
+ return false if %w[send __send__ method].include?(original.to_s)
1106
+
1107
+ if original.to_s == 'respond_to?'
1108
+ include_private = if site.metadata.key?('include_private')
1109
+ site.metadata['include_private']
1110
+ else
1111
+ site.metadata[:include_private]
1112
+ end
1113
+ return include_private == false
1114
+ end
1115
+
1116
+ true
1117
+ end
1118
+
1119
+ def arity_target_rejected?(site, target)
1120
+ arguments = site.metadata['arguments'] || site.metadata[:arguments]
1121
+ signature = method_signatures[target.graph_id]
1122
+ return false unless arguments.is_a?(Hash) && signature.is_a?(Hash)
1123
+ return false unless hash_value(arguments, 'complete') && hash_value(signature, 'complete')
1124
+
1125
+ keywords = Array(hash_value(arguments, 'keywords')).map(&:to_s)
1126
+ required = Array(hash_value(signature, 'required_keywords')).map(&:to_s)
1127
+ accepted = Array(hash_value(signature, 'accepted_keywords')).map(&:to_s)
1128
+ keyword_rest = hash_value(signature, 'keyword_rest')
1129
+ accepts_keywords = hash_value(signature, 'accepts_keywords')
1130
+ accepts_keywords = required.any? || accepted.any? || keyword_rest if accepts_keywords.nil?
1131
+ no_keywords = hash_value(signature, 'no_keywords')
1132
+
1133
+ count = hash_value(arguments, 'positional_count').to_i
1134
+ if keywords.any? && !accepts_keywords
1135
+ return true if no_keywords
1136
+
1137
+ count += 1
1138
+ end
1139
+
1140
+ minimum = hash_value(signature, 'minimum_positionals').to_i
1141
+ maximum = hash_value(signature, 'maximum_positionals')
1142
+ return true if count < minimum || (!maximum.nil? && count > maximum.to_i)
1143
+ return false unless accepts_keywords
1144
+ return true unless (required - keywords).empty?
1145
+
1146
+ !keyword_rest && !(keywords - accepted).empty?
1147
+ end
1148
+
1149
+ def hash_value(hash, key)
1150
+ hash.key?(key) ? hash[key] : hash[key.to_sym]
1151
+ end
1152
+
1153
+ def shadowed_rejections(message, entries)
1154
+ entries.flat_map do |owner, separator|
1155
+ definitions_for("#{owner}#{separator}#{message}").map do |target|
1156
+ RejectedTarget.new(definition_id: target.graph_id, reason: 'shadowed_by_lookup_order')
1157
+ end
1158
+ end
1159
+ end
1160
+
1161
+ def complete_lookup_prefix?(entries)
1162
+ entries.all? do |owner, _separator|
1163
+ info = class_info(owner)
1164
+ info && !info.dynamic
1165
+ end
1166
+ end
1167
+
1168
+ def dynamic_lookup_chain?(entries)
1169
+ entries.any? { |owner, _separator| class_info(owner)&.dynamic }
1170
+ end
1171
+
1172
+ def dynamic_ancestry_uncertain?(site)
1173
+ owners = receiver_candidates(site)
1174
+ owners = [nodes.exact(site.caller_id)&.owner].compact if owners.empty?
1175
+ key = [site.test ? :test : :runtime, owners.sort]
1176
+ return @dynamic_ancestry_cache[key] if @dynamic_ancestry_cache.key?(key)
1177
+
1178
+ @dynamic_ancestry_cache[key] = @blockers.any? do |blocker|
1179
+ next false unless blocker.kind == :dynamic_ancestry
1180
+ next false unless blocker.caller_domain == :runtime || site.test
1181
+ next true if blocker.scope_kind == :global
1182
+ next false unless blocker.scope_kind == :owner
1183
+
1184
+ Array(blocker.scope_value).map(&:to_s).intersect?(owners)
1185
+ end
1186
+ end
1187
+
1188
+ def dynamic_ancestry_candidates(site, targets)
1189
+ return targets unless dynamic_ancestry_uncertain?(site)
1190
+
1191
+ domain = site.test ? :test : :runtime
1192
+ (Array(targets) + ambiguous_fallback_candidates(site.message, domain: domain)).uniq(&:graph_id).sort_by(&:graph_id)
1193
+ end
1194
+
1195
+ def resolved_receiver_owner(site)
1196
+ candidates = receiver_candidates(site)
1197
+ candidates.find { |owner| class_info(owner) } || candidates.first
1198
+ end
1199
+
1200
+ def instance_lookup_entries(owner)
1201
+ cached_lookup_chain(owner).map { |candidate| [candidate, '#'] }
1202
+ end
1203
+
1204
+ def singleton_lookup_entries(owner, seen = Set.new)
1205
+ top_level = seen.empty?
1206
+ @singleton_lookup_chain_cache ||= {}
1207
+ return @singleton_lookup_chain_cache[owner] if top_level && @singleton_lookup_chain_cache.key?(owner)
1208
+ return [] unless owner && seen.add?(owner)
1209
+
1210
+ info = class_info(owner)
1211
+ result = if info
1212
+ prepends = info.singleton_prepends.reverse.flat_map do |extension|
1213
+ method_lookup_chain(extension).map { |candidate| [candidate, '#'] }
1214
+ end
1215
+ extensions = info.singleton_includes.reverse.flat_map do |extension|
1216
+ method_lookup_chain(extension).map { |candidate| [candidate, '#'] }
1217
+ end
1218
+ [*prepends, [owner, '.'], *extensions, *singleton_lookup_entries(info.superclass, seen)]
1219
+ else
1220
+ [[owner, '.']]
1221
+ end
1222
+ top_level ? (@singleton_lookup_chain_cache[owner] = result.freeze) : result
163
1223
  end
164
1224
 
165
1225
  def receiver_candidates(site)
@@ -169,10 +1229,61 @@ module Necropsy
169
1229
 
170
1230
  def rta_candidate?(node, site)
171
1231
  return true unless node.kind == :instance_method
172
- return true if node.owner == nodes[site.caller_id]&.owner
1232
+
1233
+ caller_owner = nodes[site.caller_id]&.owner
1234
+ return true if site.receiver_kind == :super
1235
+ return dispatched_instance_owner(caller_owner, site.message) == node.owner if %i[self implicit].include?(site.receiver_kind)
1236
+
1237
+ return true if node.owner == caller_owner
173
1238
  return true if class_info(node.owner)&.dynamic
174
1239
 
175
- instantiated_classes.include?(node.owner)
1240
+ rta_instantiated_owners(site.message).include?(node.owner)
1241
+ end
1242
+
1243
+ # RTA evaluates every candidate for a call site against the same set of
1244
+ # scanned allocations. Computing that set inside the candidate predicate
1245
+ # turns a single call site into O(candidates * instantiated_classes)
1246
+ # dispatch checks. Cache the dispatched owners once per message instead;
1247
+ # this preserves the conservative owner test while making the hot path
1248
+ # linear in the number of candidates.
1249
+ def rta_instantiated_owners(message)
1250
+ @rta_instantiated_owner_cache.fetch(message) do
1251
+ @rta_instantiated_owner_cache[message] = instantiated_classes.each_with_object(Set.new) do |owner, owners|
1252
+ dispatched = dispatched_instance_owner(owner, message)
1253
+ owners << dispatched if dispatched
1254
+ end
1255
+ end
1256
+ end
1257
+
1258
+ def dispatched_instance_owner(owner, message)
1259
+ @dispatch_cache ||= {}
1260
+ key = [owner, message]
1261
+ if @dispatch_cache.key?(key)
1262
+ @resolution_cache_hits = @resolution_cache_hits.to_i + 1
1263
+ return @dispatch_cache[key]
1264
+ end
1265
+
1266
+ @resolution_cache_misses = @resolution_cache_misses.to_i + 1
1267
+
1268
+ @dispatch_cache[key] = cached_lookup_chain(owner).find do |candidate|
1269
+ definitions_for("#{candidate}##{message}").any?
1270
+ end
1271
+ end
1272
+
1273
+ def cached_lookup_chain(owner)
1274
+ @lookup_chain_cache ||= {}
1275
+ @lookup_chain_cache[owner] ||= method_lookup_chain(owner)
1276
+ end
1277
+
1278
+ def method_lookup_chain(owner, seen = Set.new)
1279
+ return [] unless owner && seen.add?(owner)
1280
+
1281
+ info = class_info(owner)
1282
+ return [owner] unless info
1283
+
1284
+ prepends = info.prepends.reverse.flat_map { |name| method_lookup_chain(name, seen) }
1285
+ includes = info.includes.reverse.flat_map { |name| method_lookup_chain(name, seen) }
1286
+ prepends + [owner] + includes + method_lookup_chain(info.superclass, seen)
176
1287
  end
177
1288
 
178
1289
  def ancestor_chain(owner)
@@ -188,7 +1299,68 @@ module Necropsy
188
1299
  def merge_observation(left, right)
189
1300
  return right unless left.is_a?(Hash) && right.is_a?(Hash)
190
1301
 
191
- left.merge(right)
1302
+ Analyzers::Dynamic::ObservationPolicy.compatible_merge(left, right)
1303
+ end
1304
+
1305
+ def call_site_key(site)
1306
+ call_site_id = site['call_site_id'] || site[:call_site_id]
1307
+ return [:call_site_id, call_site_id] if call_site_id
1308
+
1309
+ metadata = site['metadata'] || site[:metadata] || {}
1310
+ [
1311
+ site['caller_definition_id'] || site[:caller_definition_id] || site['caller_id'] || site[:caller_id],
1312
+ site['message'] || site[:message],
1313
+ (site['receiver_kind'] || site[:receiver_kind])&.to_s,
1314
+ site['receiver_name'] || site[:receiver_name],
1315
+ site['file'] || site[:file],
1316
+ site['line'] || site[:line],
1317
+ Array(metadata['receiver_candidates'] || metadata[:receiver_candidates]).sort,
1318
+ metadata['implicit_from'] || metadata[:implicit_from]
1319
+ ]
1320
+ end
1321
+
1322
+ def rebuild_incoming_edges
1323
+ @incoming_edges = {}
1324
+ @edges.each do |caller_id, callees|
1325
+ callees.each do |callee_id, evidences|
1326
+ @incoming_edges[callee_id] ||= {}
1327
+ @incoming_edges[callee_id][caller_id] = evidences
1328
+ end
1329
+ end
1330
+ end
1331
+
1332
+ def resolve_definitions(identifier)
1333
+ nodes.resolve(identifier)
1334
+ end
1335
+
1336
+ def add_physical_edge(caller_id, callee_id, evidence)
1337
+ caller = nodes.exact(caller_id)
1338
+ evidence_id = register_evidence(evidence, domain: caller&.test ? :test : :runtime)
1339
+ return unless evidence_id
1340
+
1341
+ @edges[caller_id] ||= {}
1342
+ @edges[caller_id][callee_id] ||= Set.new
1343
+ @edges[caller_id][callee_id] << evidence_id
1344
+ @incoming_edges[callee_id] ||= {}
1345
+ @incoming_edges[callee_id][caller_id] = @edges[caller_id][callee_id]
1346
+ end
1347
+
1348
+ def record_ambiguous_input(kind, identifier, definitions)
1349
+ return unless definitions.length > 1 && definitions_for(identifier).length > 1
1350
+
1351
+ record_definition_ambiguity(kind, { 'identifier' => identifier }, definitions)
1352
+ end
1353
+
1354
+ def record_definition_ambiguity(kind, reference, definitions)
1355
+ resolution = observation['definition_resolution'] ||= { 'ambiguous_input_count' => 0, 'ambiguous_inputs' => [] }
1356
+ resolution['ambiguous_input_count'] += 1
1357
+ resolution['ambiguous_inputs'] << reference.merge(
1358
+ 'kind' => kind.to_s,
1359
+ 'definition_ids' => definitions.map(&:graph_id).sort
1360
+ )
1361
+ resolution['ambiguous_inputs'] = resolution['ambiguous_inputs'].uniq.sort_by do |sample|
1362
+ [sample.fetch('kind'), canonical_dynamic_sample(sample), sample.fetch('definition_ids')]
1363
+ end.first(DynamicEvidenceTracking::SAMPLE_LIMIT)
192
1364
  end
193
1365
 
194
1366
  def retain_known_instantiated_classes