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
@@ -2,18 +2,41 @@
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
14
  def initialize(scan_result, ambiguity_limit: 4)
9
- @nodes = {}
15
+ @nodes = DefinitionIndex.new
10
16
  @edges = {}
11
17
  @incoming_edges = {}
18
+ initialize_evidence_store
12
19
  @call_sites = scan_result.call_sites
13
20
  @instantiated_classes = scan_result.instantiated_classes.dup
14
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!)
15
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
16
37
  @ambiguity_limit = ambiguity_limit
38
+ @blockers = []
39
+ initialize_blocker_indexes
17
40
  @entry_points = []
18
41
  @profiles = []
19
42
  @uncertainties = scan_result.uncertainties.to_h do |node_id, messages|
@@ -21,38 +44,121 @@ module Necropsy
21
44
  end
22
45
  @dynamic_alive = {}
23
46
  @observation = {}
47
+ record_generated_macro_observation(scan_result.nodes)
48
+ initialize_resolution_store
24
49
  @descendants = {}
50
+ @rta_instantiated_owner_cache = {}
51
+ @duplicate_blockers_initialized = false
25
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) }
26
57
  retain_known_instantiated_classes
27
58
  end
28
59
 
29
60
  def add_node(node)
30
- return nodes[node.id] if nodes.key?(node.id)
61
+ existing = nodes.exact(node.graph_id)
62
+ return existing if existing
31
63
 
32
64
  @method_nodes = nil
33
65
  @nodes_by_name = nil
66
+ @runtime_nodes_by_name = nil
34
67
  @dispatch_cache = nil
35
68
  @lookup_chain_cache = nil
69
+ @singleton_lookup_chain_cache = nil
36
70
  @owner_ancestor_cache = nil
37
- nodes[node.id] = node
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
38
112
  end
39
113
 
40
- def add_entry_point(node_id, reason)
41
- return unless nodes.key?(node_id)
114
+ def definitions_for(symbol_id)
115
+ nodes.definitions_for(symbol_id)
116
+ end
42
117
 
43
- entry = EntryPoint.new(node_id: node_id, reason: reason)
44
- 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, [])
45
121
  end
46
122
 
47
- def apply_result(result)
48
- result.edge_evidences.each { |edge| add_edge(edge.caller_id, edge.callee_id, edge.evidence) }
49
- matched_alive = result.alive_evidences.count { |alive| add_alive(alive.node_id, alive.evidence) }
50
- warn_unmatched_dynamic_evidence(result.alive_evidences.length) if matched_alive.zero?
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)
51
144
  result.uncertainties.each do |node_id, messages|
52
- @uncertainties[node_id] ||= []
53
- @uncertainties[node_id].concat(Array(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
54
151
  end
152
+ Array(result.respond_to?(:blockers) ? result.blockers : []).each { |blocker| add_blocker(blocker) }
55
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
56
162
  end
57
163
 
58
164
  def add_profile(profile)
@@ -60,57 +166,159 @@ module Necropsy
60
166
  end
61
167
 
62
168
  def add_edge(caller_id, callee_id, evidence)
63
- 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
64
176
 
65
- @edges[caller_id] ||= {}
66
- @edges[caller_id][callee_id] ||= []
67
- @edges[caller_id][callee_id] << evidence
68
- @incoming_edges[callee_id] ||= {}
69
- @incoming_edges[callee_id][caller_id] = @edges[caller_id][callee_id]
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
70
183
  end
71
184
 
72
185
  def add_alive(node_id, evidence)
73
- return false 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
74
196
 
75
- @dynamic_alive[node_id] ||= []
76
- @dynamic_alive[node_id] << evidence
197
+ @dynamic_alive[definition.graph_id] ||= Set.new
198
+ @dynamic_alive[definition.graph_id] << evidence_id
199
+ end
77
200
  true
78
201
  end
79
202
 
80
203
  def dynamic_alive?(node_id)
81
- @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
82
207
  end
83
208
 
84
- def alive_evidences(node_id)
85
- @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)
86
215
  end
87
216
 
88
217
  def dynamic_enabled?
89
218
  @dynamic_alive.any?
90
219
  end
91
220
 
92
- def edges_from(node_id)
93
- @edges.fetch(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
94
227
  end
95
228
 
96
- def edges
97
- @edges.flat_map do |caller_id, callees|
98
- callees.map do |callee_id, evidences|
99
- 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)
100
245
  end
101
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
102
251
  end
103
252
 
104
- def incoming_edges(node_id)
105
- @incoming_edges.fetch(node_id, {}).map do |caller_id, evidences|
106
- Edge.new(caller_id: caller_id, callee_id: node_id, evidences: evidences)
107
- end
253
+ def edge_present?(caller_id, callee_id)
254
+ @edges.dig(caller_id, callee_id)&.any? { |evidence_id| evidence_record(evidence_id) }
255
+ end
256
+
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] }
108
292
  end
109
293
 
110
294
  def uncertainties(node_id = nil)
111
295
  return @uncertainties unless node_id
112
296
 
113
- @uncertainties.fetch(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
+ }
114
322
  end
115
323
 
116
324
  def method_nodes
@@ -126,8 +334,20 @@ module Necropsy
126
334
  end
127
335
 
128
336
  def descendants_of(owner)
129
- @descendants[owner] ||= class_infos.keys.select do |candidate|
130
- 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
131
351
  end
132
352
  end
133
353
 
@@ -138,18 +358,23 @@ module Necropsy
138
358
  (info.prepends + info.includes + info.extends).uniq
139
359
  end
140
360
 
141
- def candidate_nodes(message)
142
- nodes_by_name.fetch(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, [])
143
364
  end
144
365
 
145
- def ambiguous_fallback_candidates(message)
146
- candidates = candidate_nodes(message)
366
+ def ambiguous_fallback_candidates(message, domain: nil)
367
+ candidates = candidate_nodes(message, domain: domain)
147
368
  return candidates if candidates.one?
148
369
  return [] if candidates.size > @ambiguity_limit
149
370
 
150
371
  candidates
151
372
  end
152
373
 
374
+ def ambiguity_exceeded?(message, domain: nil)
375
+ candidate_nodes(message, domain: domain).size > ambiguity_limit
376
+ end
377
+
153
378
  def ambiguous_resolution?
154
379
  @ambiguity_limit > 1
155
380
  end
@@ -165,11 +390,182 @@ module Necropsy
165
390
  end
166
391
 
167
392
  def resolve_call_site(site, rta: false)
168
- candidates = rta ? rta_candidates_for_receiver(site) : candidates_for_receiver(site)
393
+ candidates = rta ? rta_candidates_for_receiver(site) : method_lookup(site).targets
169
394
  candidates = candidates.select { |node| rta_candidate?(node, site) } if rta
170
395
  candidates
171
396
  end
172
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
+
173
569
  def retain_rta_candidates(candidates, site)
174
570
  candidates.select { |node| rta_candidate?(node, site) }
175
571
  end
@@ -184,104 +580,646 @@ module Necropsy
184
580
  end
185
581
 
186
582
  @edges.each_value do |callees|
187
- callees.each do |callee_id, evidences|
188
- evidences.reject! do |item|
189
- next false unless %i[name_resolution cha].include?(item.analyzer)
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)
190
590
 
191
591
  key = call_site_key(item.metadata)
192
592
  analyzed_keys.include?(key) && !allowed[key].include?(callee_id)
193
593
  end
194
594
  end
195
- callees.delete_if { |_callee_id, evidences| evidences.empty? }
595
+ callees.delete_if { |_callee_id, evidence_ids| evidence_ids.empty? }
196
596
  end
197
597
  @edges.delete_if { |_caller_id, callees| callees.empty? }
198
598
  rebuild_incoming_edges
599
+ refresh_resolution_derived_state
199
600
  end
200
601
 
201
602
  def fallback_resolution?(site, resolved: nil)
202
603
  resolved ||= resolve_call_site(site)
203
604
  return false if resolved.empty?
204
605
 
205
- case site.receiver_kind
206
- when :constant
207
- receiver_candidates(site).none? { |name| nodes.key?("#{name}.#{site.message}") }
208
- when :instance
209
- receiver_candidates(site).none? { |name| nodes.key?("#{name}##{site.message}") }
210
- when :implicit
211
- same_owner_candidates(site).empty?
212
- when :unknown
213
- true
214
- else
215
- false
216
- end
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
217
610
  end
218
611
 
219
612
  def to_h
220
613
  {
221
614
  'nodes' => nodes.values.map(&:to_h),
615
+ 'call_sites' => call_sites.map(&:to_h),
222
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,
223
621
  'entry_points' => entry_points.map(&:to_h),
224
622
  'class_infos' => class_infos.values.map(&:to_h),
225
623
  'instantiated_classes' => instantiated_classes.to_a.sort,
226
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,
227
632
  'observation' => observation
228
633
  }
229
634
  end
230
635
 
231
636
  private
232
637
 
233
- def candidates_for_receiver(site)
234
- case site.receiver_kind
235
- when :constant
236
- exact = receiver_candidates(site).filter_map { |name| nodes["#{name}.#{site.message}"] }.first
237
- exact ? [exact] : ambiguous_fallback_candidates(site.message)
238
- when :instance
239
- exact = receiver_candidates(site).filter_map { |name| nodes["#{name}##{site.message}"] }.first
240
- exact ? [exact] : ambiguous_fallback_candidates(site.message)
241
- when :self
242
- same_owner_candidates(site)
243
- when :super
244
- super_candidates(site)
245
- when :implicit
246
- same_owner_candidates(site).then do |matches|
247
- matches.empty? ? ambiguous_fallback_candidates(site.message) : matches
248
- end
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)
249
678
  else
250
- ambiguous_fallback_candidates(site.message)
679
+ value
680
+ end
681
+ end
682
+
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
692
+
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
+ ))
251
906
  end
252
907
  end
253
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
+
254
916
  def rta_candidates_for_receiver(site)
255
- exact = candidates_for_receiver(site)
917
+ exact = method_lookup(site).targets
256
918
  return exact unless exact.empty?
257
919
 
258
- candidate_nodes(site.message)
920
+ candidate_nodes(site.message, domain: site.test ? :test : :runtime)
259
921
  end
260
922
 
261
- def same_owner_candidates(site)
262
- caller = nodes[site.caller_id]
263
- return [] unless caller&.owner
264
-
265
- ids = [
266
- "#{caller.owner}##{site.message}",
267
- "#{caller.owner}.#{site.message}"
268
- ]
269
- ids.filter_map { |id| nodes[id] }
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')
270
941
  end
271
942
 
272
- def super_candidates(site)
273
- caller = nodes[site.caller_id]
274
- return [] unless caller&.owner
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
275
946
 
276
947
  separator = caller.kind == :singleton_method ? '.' : '#'
277
- owner = class_info(caller.owner)&.superclass
278
- while owner
279
- candidate = nodes["#{owner}#{separator}#{site.message}"]
280
- return [candidate] if candidate
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
281
1183
 
282
- owner = class_info(owner)&.superclass
1184
+ Array(blocker.scope_value).map(&:to_s).intersect?(owners)
283
1185
  end
284
- []
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
285
1223
  end
286
1224
 
287
1225
  def receiver_candidates(site)
@@ -299,15 +1237,37 @@ module Necropsy
299
1237
  return true if node.owner == caller_owner
300
1238
  return true if class_info(node.owner)&.dynamic
301
1239
 
302
- instantiated_classes.any? { |owner| dispatched_instance_owner(owner, site.message) == 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
303
1256
  end
304
1257
 
305
1258
  def dispatched_instance_owner(owner, message)
306
1259
  @dispatch_cache ||= {}
307
1260
  key = [owner, message]
308
- return @dispatch_cache[key] if @dispatch_cache.key?(key)
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
309
1267
 
310
- @dispatch_cache[key] = cached_lookup_chain(owner).find { |candidate| nodes.key?("#{candidate}##{message}") }
1268
+ @dispatch_cache[key] = cached_lookup_chain(owner).find do |candidate|
1269
+ definitions_for("#{candidate}##{message}").any?
1270
+ end
311
1271
  end
312
1272
 
313
1273
  def cached_lookup_chain(owner)
@@ -339,13 +1299,16 @@ module Necropsy
339
1299
  def merge_observation(left, right)
340
1300
  return right unless left.is_a?(Hash) && right.is_a?(Hash)
341
1301
 
342
- left.merge(right)
1302
+ Analyzers::Dynamic::ObservationPolicy.compatible_merge(left, right)
343
1303
  end
344
1304
 
345
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
+
346
1309
  metadata = site['metadata'] || site[:metadata] || {}
347
1310
  [
348
- site['caller_id'] || site[:caller_id],
1311
+ site['caller_definition_id'] || site[:caller_definition_id] || site['caller_id'] || site[:caller_id],
349
1312
  site['message'] || site[:message],
350
1313
  (site['receiver_kind'] || site[:receiver_kind])&.to_s,
351
1314
  site['receiver_name'] || site[:receiver_name],
@@ -366,16 +1329,43 @@ module Necropsy
366
1329
  end
367
1330
  end
368
1331
 
369
- def retain_known_instantiated_classes
370
- known_owners = method_nodes.map(&:owner).compact.to_set
371
- instantiated_classes.select! { |name| known_owners.include?(name) }
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]
372
1346
  end
373
1347
 
374
- def warn_unmatched_dynamic_evidence(attempted)
375
- return if attempted.zero?
1348
+ def record_ambiguous_input(kind, identifier, definitions)
1349
+ return unless definitions.length > 1 && definitions_for(identifier).length > 1
376
1350
 
377
- warn "Necropsy ignored #{attempted} dynamic node IDs because none matched the scanned project; " \
378
- 'dynamic absence will not be used for unused classification.'
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)
1364
+ end
1365
+
1366
+ def retain_known_instantiated_classes
1367
+ known_owners = method_nodes.map(&:owner).compact.to_set
1368
+ instantiated_classes.select! { |name| known_owners.include?(name) }
379
1369
  end
380
1370
  end
381
1371
  end