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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module Necropsy
4
6
  module Analyzers
5
7
  module Static
@@ -11,67 +13,289 @@ module Necropsy
11
13
  reverse_each select sort sort_by take take_while to_a to_h
12
14
  ].freeze
13
15
  COMPARISON_MESSAGES = %w[< <= > >= between? clamp sort sort_by max min].freeze
16
+ CORE_ENUMERABLE_RECEIVERS = %w[Array Enumerator Hash Range Set].freeze
17
+ CORE_COMPARISON_RECEIVERS = %w[Array].freeze
18
+ KERNEL_OUTPUT_MESSAGES = %w[print puts warn].freeze
19
+ REFLECTION_HOOK_MESSAGES = %w[method respond_to?].freeze
20
+ BLOCK_DEPENDENT_ENUMERABLE_MESSAGES = %w[
21
+ all? any? chunk collect cycle detect drop_while each_cons each_entry each_slice
22
+ each_with_index each_with_object filter find find_all flat_map group_by
23
+ map none? one? partition reject reverse_each select sort_by take_while
24
+ ].freeze
25
+ BLOCK_COMPARISON_MESSAGES = %w[max min sort].freeze
26
+
27
+ attr_reader :pruning, :emit_redundant_edges
28
+
29
+ def initialize(pruning: :rank_only, emit_redundant_edges: true)
30
+ mode = pruning.to_s
31
+ unless Configuration::RTA_PRUNING_MODES.include?(mode)
32
+ raise Error, "RTA pruning must be one of: #{Configuration::RTA_PRUNING_MODES.join(', ')}"
33
+ end
34
+
35
+ @pruning = mode.to_sym
36
+ @emit_redundant_edges = emit_redundant_edges == true
37
+ end
38
+
39
+ def with_pruning(mode, emit_redundant_edges: @emit_redundant_edges)
40
+ self.class.new(pruning: mode, emit_redundant_edges: emit_redundant_edges)
41
+ end
42
+
43
+ def without_redundant_edges
44
+ self.class.new(pruning: pruning, emit_redundant_edges: false)
45
+ end
14
46
 
15
47
  def analyze(graph, _project)
16
- edge_evidences = expanded_call_sites(graph).flat_map do |site|
17
- graph.resolve_call_site(site, rta: true).map do |candidate|
48
+ sites = expanded_call_sites(graph)
49
+ original_call_site_ids = graph.call_sites.to_set(&:call_site_id)
50
+ derived_sites = sites.reject { |site| original_call_site_ids.include?(site.call_site_id) }
51
+ instantiated_metadata = instantiated_classes_metadata(graph)
52
+ analyses = sites.map do |site|
53
+ targets = rta_candidates(graph, site)
54
+ site_edges = targets.filter_map do |candidate|
55
+ # In rank-only mode CHA/name resolution already materialize the
56
+ # same conservative edge. Keep the RTA resolution record, but
57
+ # avoid allocating a duplicate evidence object for an edge that
58
+ # cannot change reachability. Legacy pruning still emits every
59
+ # candidate because reconciliation uses those edge identities.
60
+ next if pruning == :rank_only && !emit_redundant_edges &&
61
+ graph.edge_present?(site.caller_id, candidate.graph_id)
62
+
18
63
  EdgeEvidence.new(
19
64
  caller_id: site.caller_id,
20
- callee_id: candidate.id,
65
+ callee_id: candidate.graph_id,
21
66
  evidence: evidence(
22
67
  kind: :call_edge,
23
68
  details: "RTA candidate at #{site.file}:#{site.line}",
24
- metadata: site.to_h.merge('instantiated_classes' => graph.instantiated_classes.to_a.sort)
69
+ metadata: site.to_h.merge(instantiated_metadata).merge('target_definition_id' => candidate.graph_id),
70
+ grade: :heuristic,
71
+ relation: :call_edge,
72
+ source: call_site_evidence_source(site).merge('target_definition_id' => candidate.graph_id),
73
+ scope: call_site_evidence_scope(site).merge('target_definition_id' => candidate.graph_id)
25
74
  )
26
75
  )
27
76
  end
77
+ [site, targets, site_edges]
78
+ end
79
+ edge_evidences = analyses.flat_map(&:last)
80
+ analyses_by_call_site_id = analyses.to_h { |site, targets, edges| [site.call_site_id, [targets, edges]] }
81
+ resolutions = sites.map do |site|
82
+ targets, edges = analyses_by_call_site_id.fetch(site.call_site_id)
83
+ resolution_record(site, targets, edges, unknown_scope: graph.residual_scope_for(site))
28
84
  end
29
85
 
30
86
  AnalyzerResult.new(
31
87
  edge_evidences: edge_evidences,
32
88
  alive_evidences: [],
33
89
  uncertainties: {},
34
- observation: {}
90
+ observation: { 'rta' => { 'pruning' => pruning.to_s, 'analyzed_sites' => sites.map(&:to_h) } },
91
+ resolutions: resolutions,
92
+ evidences: result_evidences(edge_evidences),
93
+ derived_call_sites: derived_sites
35
94
  )
36
95
  end
37
96
 
38
97
  def profile
98
+ description = if pruning == :legacy
99
+ 'RTA pruning mode legacy removes static candidates without scanned construction evidence.'
100
+ else
101
+ 'RTA pruning mode rank_only records constructed-class hints without removing static edges.'
102
+ end
39
103
  AnalyzerProfile.new(
40
104
  name: :rta,
41
105
  kind: :static,
42
106
  soundness: :partial,
43
- description: 'Narrows instance dispatch to classes that are constructed in the scanned program.'
107
+ description: description,
108
+ version: Necropsy::VERSION,
109
+ assumptions: ['scanned_allocations', "pruning=#{pruning}"]
44
110
  )
45
111
  end
46
112
 
113
+ def capabilities
114
+ [:complete_resolution].freeze
115
+ end
116
+
47
117
  def expanded_call_sites(graph)
48
118
  graph.call_sites.flat_map do |site|
49
- [site, *implicit_sites(site)]
119
+ [site, *implicit_sites(site, graph: graph)]
120
+ end
121
+ end
122
+
123
+ def implicit_sites(site, graph: nil)
124
+ summaries = []
125
+ summaries << ['each', :same_receiver] if core_enumerable_receiver?(site) && enumerable_protocol?(site)
126
+ summaries.concat(comparison_summaries(site)) if core_comparison_receiver?(site)
127
+ summaries.concat(output_summaries(site)) if kernel_output_call?(site, graph)
128
+ summaries << ['respond_to_missing?', :same_receiver] if reflection_hook_call?(site, graph)
129
+ summaries.map.with_index do |(message, receiver), index|
130
+ derived_protocol_site(site, message, receiver, index: index)
50
131
  end
51
132
  end
52
133
 
53
- def implicit_sites(site)
54
- implicit_messages(site.message).map do |message|
55
- CallSite.new(
56
- caller_id: site.caller_id,
134
+ def implicit_messages(site, graph: nil)
135
+ implicit_sites(site, graph: graph).map(&:message)
136
+ end
137
+
138
+ private
139
+
140
+ def derived_protocol_site(site, message, receiver, index: 0)
141
+ receiver_kind, receiver_name, receiver_metadata = protocol_receiver(site, receiver)
142
+ receiver_label = protocol_receiver_label(receiver)
143
+ CallSite.new(
144
+ call_site_id: CallSiteIdentity.derived_id(
145
+ parent_call_site_id: site.call_site_id,
146
+ derivation: :rta_implicit,
147
+ caller_definition_id: site.caller_id,
57
148
  message: message,
58
- receiver_kind: site.receiver_kind,
59
- receiver_name: site.receiver_name,
60
- file: site.file,
61
- line: site.line,
62
- test: site.test,
63
- dynamic: site.dynamic,
64
- metadata: site.metadata.merge('implicit_from' => site.message)
65
- )
149
+ discriminator: index.positive? ? "#{receiver_label}:#{index}" : nil
150
+ ),
151
+ caller_id: site.caller_id,
152
+ message: message,
153
+ receiver_kind: receiver_kind,
154
+ receiver_name: receiver_name,
155
+ file: site.file,
156
+ line: site.line,
157
+ test: site.test,
158
+ dynamic: false,
159
+ metadata: site.metadata.except('receiver_candidates', 'receiver_value_fact').merge(
160
+ receiver_metadata,
161
+ 'implicit_from' => site.message,
162
+ 'protocol_receiver' => receiver_label,
163
+ 'derived_from_call_site_id' => site.call_site_id,
164
+ 'derived_via' => 'rta_implicit'
165
+ ).tap do |metadata|
166
+ metadata['implicit_private_dispatch'] = true if message == 'respond_to_missing?'
167
+ end
168
+ )
169
+ end
170
+
171
+ def protocol_receiver(site, receiver)
172
+ if receiver == :same_receiver
173
+ metadata = site.metadata.slice('receiver_candidates', 'receiver_value_fact')
174
+ return [site.receiver_kind, site.receiver_name, metadata]
175
+ end
176
+
177
+ argument_index = receiver[1] if receiver.is_a?(Array) && receiver.first == :argument
178
+ fact = Array(site.metadata['argument_value_facts'])[argument_index] if argument_index
179
+ fact = site.metadata.dig('receiver_value_fact', 'summary', 'element_fact') if receiver == :element_receiver
180
+ return receiver_from_fact(fact) if fact
181
+
182
+ [:unknown, nil, { 'receiver_candidates' => [] }]
183
+ end
184
+
185
+ def receiver_from_fact(fact)
186
+ return [:unknown, nil, { 'receiver_candidates' => [] }] unless fact.is_a?(Hash)
187
+ return [:unknown, nil, { 'receiver_candidates' => [] }] unless fact['kind'].to_s == 'instance_types'
188
+ return [:unknown, nil, { 'receiver_candidates' => [] }] unless fact['exact'] == true
189
+
190
+ values = Array(fact['values']).map(&:to_s).reject(&:empty?).uniq.sort
191
+ return [:unknown, nil, { 'receiver_candidates' => [] }] if values.empty?
192
+
193
+ [:instance, values.first, { 'receiver_candidates' => values, 'receiver_value_fact' => fact }]
194
+ end
195
+
196
+ def core_enumerable_receiver?(site)
197
+ receiver_owners(site).intersect?(CORE_ENUMERABLE_RECEIVERS)
198
+ end
199
+
200
+ def core_comparison_receiver?(site)
201
+ receiver_owners(site).intersect?(CORE_COMPARISON_RECEIVERS)
202
+ end
203
+
204
+ def enumerable_protocol?(site)
205
+ return false unless ENUMERABLE_MESSAGES.include?(site.message) && site.message != 'each'
206
+ return true unless BLOCK_DEPENDENT_ENUMERABLE_MESSAGES.include?(site.message)
207
+
208
+ site.metadata['block_kind'] != 'none'
209
+ end
210
+
211
+ def comparison_protocol?(site)
212
+ return false unless COMPARISON_MESSAGES.include?(site.message)
213
+ return true unless BLOCK_COMPARISON_MESSAGES.include?(site.message)
214
+
215
+ !site.metadata.key?('block_kind') || %w[none nil dynamic].include?(site.metadata['block_kind'])
216
+ end
217
+
218
+ def comparison_summaries(site)
219
+ if site.message == 'sort_by'
220
+ return [] if %w[none nil].include?(site.metadata['block_kind'])
221
+
222
+ return [['<=>', :block_result_receiver]]
66
223
  end
224
+
225
+ comparison_protocol?(site) ? [['<=>', :element_receiver]] : []
226
+ end
227
+
228
+ def output_summaries(site)
229
+ facts = Array(site.metadata['argument_value_facts'])
230
+ count = [facts.length, site.metadata.dig('arguments', 'positional_count').to_i].max
231
+ count = 1 if count.zero? && !site.metadata.key?('arguments')
232
+ count.times.map { |index| ['to_s', [:argument, index]] }
233
+ end
234
+
235
+ def protocol_receiver_label(receiver)
236
+ return 'first_argument' if receiver == [:argument, 0]
237
+ return "argument_#{receiver[1]}" if receiver.is_a?(Array) && receiver.first == :argument
238
+
239
+ receiver.to_s
67
240
  end
68
241
 
69
- def implicit_messages(message)
70
- messages = []
71
- messages << 'each' if ENUMERABLE_MESSAGES.include?(message) && message != 'each'
72
- messages << '<=>' if COMPARISON_MESSAGES.include?(message)
73
- messages << 'to_s' if %w[puts print warn].include?(message)
74
- messages
242
+ def receiver_owners(site)
243
+ values = Array(site.metadata['receiver_candidates'])
244
+ values = [container_receiver_owner(site.metadata['receiver_value_fact'])] if values.empty?
245
+ values = [site.receiver_name] if values.empty?
246
+ values.compact.map(&:to_s)
247
+ end
248
+
249
+ def container_receiver_owner(fact)
250
+ return unless fact.is_a?(Hash) && fact['kind'].to_s == 'container' && fact['exact'] == true
251
+
252
+ { 'array' => 'Array', 'hash' => 'Hash' }[fact.dig('summary', 'type').to_s]
253
+ end
254
+
255
+ def kernel_output_call?(site, graph)
256
+ return false unless KERNEL_OUTPUT_MESSAGES.include?(site.message)
257
+ return false unless %i[implicit self].include?(site.receiver_kind)
258
+ return true unless graph
259
+
260
+ graph.method_lookup(site).targets.empty?
261
+ end
262
+
263
+ def reflection_hook_call?(site, graph)
264
+ return false unless REFLECTION_HOOK_MESSAGES.include?(site.message)
265
+ return true unless graph
266
+
267
+ graph.cha_method_lookup(site).targets.none? { |target| target.name == site.message }
268
+ end
269
+
270
+ def rta_candidates(graph, site)
271
+ candidates = graph.cha_method_lookup(site).targets
272
+ candidates = fallback_candidates(graph, site) if candidates.empty?
273
+ graph.retain_rta_candidates(candidates, site)
274
+ end
275
+
276
+ def fallback_candidates(graph, site)
277
+ domain = site.test ? :test : :runtime
278
+ return graph.candidate_nodes(site.message, domain: domain) if site.receiver_kind == :unknown
279
+ return [] unless graph.ambiguous_resolution?
280
+
281
+ graph.ambiguous_fallback_candidates(site.message, domain: domain)
282
+ end
283
+
284
+ # A full allocation list in every RTA evidence record makes evidence
285
+ # identity canonicalization quadratic for a repository with many
286
+ # scanned constructors. Keep the complete list for small fixtures
287
+ # (preserving the existing diagnostic shape), and use a deterministic
288
+ # bounded sample plus digest for larger projects.
289
+ def instantiated_classes_metadata(graph)
290
+ classes = graph.instantiated_classes.to_a.sort
291
+ return { 'instantiated_classes' => classes } if classes.length <= 64
292
+
293
+ {
294
+ 'instantiated_classes' => classes.first(16),
295
+ 'instantiated_classes_count' => classes.length,
296
+ 'instantiated_classes_truncated' => true,
297
+ 'instantiated_classes_sha256' => Digest::SHA256.hexdigest(classes.join("\0"))
298
+ }
75
299
  end
76
300
  end
77
301
  end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def build_call_sites(node, context)
8
+ messages = finite_dynamic_messages(node, context)
9
+ return [build_call_site(node, context)] if messages.empty?
10
+
11
+ messages.map { |message| build_call_site(node, context, message_override: message) }
12
+ end
13
+
14
+ def build_call_site(node, context, message_override: nil)
15
+ message = node.name&.to_s
16
+ dynamic = false
17
+ receiver = classify_receiver(node.receiver, context)
18
+ metadata = { 'original_message' => message, 'receiver_candidates' => receiver[:candidates] }
19
+ metadata['arguments'] = call_arguments(node, offset: DYNAMIC_SENDS.include?(node.name) ? 1 : 0)
20
+ metadata['block_kind'] = call_block_kind(node)
21
+ receiver_fact = context.flow_result&.fact_for(node.receiver)
22
+ compact_receiver_fact = compact_receiver_fact(receiver_fact)
23
+ metadata['receiver_value_fact'] = compact_receiver_fact if compact_receiver_fact
24
+ argument_facts = arguments(node).map do |argument|
25
+ compact_receiver_fact(context.flow_result&.fact_for(argument))
26
+ end
27
+ metadata['argument_value_facts'] = argument_facts if argument_facts.any?
28
+ load_reference = load_reference_metadata(node)
29
+ metadata['load_reference'] = load_reference if load_reference
30
+
31
+ if DYNAMIC_SENDS.include?(node.name)
32
+ literal = literal_argument(node, index: 0)
33
+ message = message_override || literal
34
+ dynamic = message.nil?
35
+ metadata['dynamic_dispatch'] = true
36
+ metadata['finite_dynamic_dispatch'] = true if message_override
37
+ end
38
+
39
+ return nil unless message
40
+
41
+ scanned_call_site(
42
+ source_node: node,
43
+ context: context,
44
+ role: :call,
45
+ message: message,
46
+ receiver_kind: receiver.fetch(:kind),
47
+ receiver_name: receiver[:name],
48
+ dynamic: dynamic,
49
+ metadata: metadata
50
+ )
51
+ end
52
+
53
+ def finite_dynamic_messages(node, context)
54
+ return [] unless DYNAMIC_SENDS.include?(node.name)
55
+ return [] if literal_argument(node, index: 0)
56
+
57
+ argument = Array(node.arguments&.arguments).first
58
+ fact = context.flow_result&.fact_for(argument)
59
+ return [] unless fact&.exact && %i[symbol_set string_set].include?(fact.kind)
60
+
61
+ Array(fact.values).map(&:to_s).uniq.sort.first(FlowInterpreter::MAX_ATOMS)
62
+ end
63
+
64
+ def load_reference_metadata(node)
65
+ argument_index = case node.name
66
+ when :require, :require_relative then 0
67
+ when :autoload then 1
68
+ else return
69
+ end
70
+ return unless load_primitive_receiver?(node)
71
+
72
+ path = literal_argument(node, index: argument_index)
73
+ { 'kind' => node.name.to_s, 'literal' => !path.nil?, 'path' => path }.compact
74
+ end
75
+
76
+ def load_primitive_receiver?(node)
77
+ return true unless node.receiver
78
+ return true if node.receiver.is_a?(Prism::SelfNode)
79
+
80
+ receiver = constant_name(node.receiver)
81
+ node.name == :autoload ? !receiver.nil? : receiver == 'Kernel'
82
+ end
83
+
84
+ def compact_receiver_fact(fact)
85
+ return unless fact&.exact
86
+ return unless %i[instance_types callable_set container].include?(fact.kind)
87
+
88
+ fact.to_h.merge('summary' => fact.kind == :container ? fact.summary : nil)
89
+ end
90
+
91
+ def call_block_kind(node)
92
+ case node.block
93
+ when nil
94
+ 'none'
95
+ when Prism::BlockNode
96
+ 'literal'
97
+ when Prism::BlockArgumentNode
98
+ case node.block.expression
99
+ when Prism::NilNode then 'nil'
100
+ when Prism::SymbolNode then 'symbol_to_proc'
101
+ else 'dynamic'
102
+ end
103
+ else
104
+ 'dynamic'
105
+ end
106
+ end
107
+
108
+ def record_instantiation(node, context)
109
+ return record_factory_instantiation(node, context) unless %i[new []].include?(node.name)
110
+
111
+ receiver = classify_receiver(node.receiver, context)
112
+ receiver = implicit_self_constructor(context) if implicit_constructor?(receiver, context)
113
+ return unless receiver && receiver[:kind] == :constant
114
+
115
+ Array(receiver[:candidates] || receiver[:name]).each { |name| instantiated_classes << name }
116
+ record_initialize_call(node, context, receiver)
117
+ end
118
+
119
+ def implicit_constructor?(receiver, context)
120
+ return false unless %i[implicit self].include?(receiver[:kind])
121
+ return false unless context.owner
122
+
123
+ context.current_kind == :singleton_method || context.current_caller_id == context.root_id
124
+ end
125
+
126
+ def implicit_self_constructor(context)
127
+ { kind: :constant, name: context.owner, candidates: [context.owner] }
128
+ end
129
+
130
+ def record_initialize_call(node, context, receiver)
131
+ return unless node.name == :new
132
+
133
+ add_scanned_call_site(
134
+ source_node: node,
135
+ context: context,
136
+ role: :initialize,
137
+ message: 'initialize',
138
+ receiver_kind: :instance,
139
+ receiver_name: receiver[:name],
140
+ dynamic: false,
141
+ metadata: { 'original_message' => 'new', 'receiver_candidates' => receiver[:candidates],
142
+ 'implicit_from' => 'new', 'arguments' => call_arguments(node) }
143
+ )
144
+ end
145
+
146
+ def record_factory_instantiation(node, context)
147
+ return unless @factory_methods.include?(node.name.to_s)
148
+
149
+ receiver = classify_receiver(node.receiver, context)
150
+ return unless receiver[:kind] == :constant
151
+
152
+ Array(receiver[:candidates] || receiver[:name]).each do |name|
153
+ instantiated_classes << name
154
+ end
155
+ end
156
+
157
+ def record_uncertainty(site)
158
+ uncertainties[site.caller_id] << "Dynamic dispatch at #{site.file}:#{site.line}"
159
+ end
160
+
161
+ def record_uncertainty_at(node, context)
162
+ uncertainties[context.current_caller_id] << "Dynamic dispatch at #{context.relative_file}:#{node.location.start_line}"
163
+ end
164
+
165
+ def unresolved_dynamic_dispatch?(node)
166
+ DYNAMIC_SENDS.include?(node.name) && literal_argument(node, index: 0).nil?
167
+ end
168
+
169
+ def record_parse_errors(root_id, relative, result)
170
+ result.errors.each do |error|
171
+ uncertainties[root_id] << "Parse warning at line #{error.location.start_line}: #{error.message}"
172
+ source_errors << SourceError.new(
173
+ file: relative,
174
+ line: error.location.start_line,
175
+ message: error.message,
176
+ type: error.type
177
+ )
178
+ end
179
+ end
180
+
181
+ def record_source_failure(root_id, relative, error)
182
+ file_statuses[relative] = :failed
183
+ uncertainties[root_id] << "Could not parse #{relative}: #{error.message}"
184
+ source_errors << SourceError.new(
185
+ file: relative,
186
+ line: 1,
187
+ message: error.message,
188
+ type: error.class.name.to_sym
189
+ )
190
+ end
191
+
192
+ def definition_owner(node, context)
193
+ return context.owner unless node.receiver
194
+ return context.owner if node.receiver.is_a?(Prism::SelfNode)
195
+
196
+ receiver = classify_receiver(node.receiver, context)
197
+ receiver[:name]
198
+ end
199
+
200
+ def definition_owner_for_call(node, context)
201
+ return context.owner unless node.receiver
202
+ return context.owner if node.receiver.is_a?(Prism::SelfNode)
203
+
204
+ classify_receiver(node.receiver, context)[:name]
205
+ end
206
+
207
+ def eval_owner(node, context)
208
+ return context.owner unless node.receiver
209
+
210
+ definition_owner_for_call(node, context)
211
+ end
212
+
213
+ def method_kind_and_separator(context)
214
+ context.singleton_scope ? [:singleton_method, '.'] : [:instance_method, '#']
215
+ end
216
+
217
+ def update_method_visibility(context, name, visibility, source_node, singleton: false)
218
+ separator = singleton ? '.' : method_kind_and_separator(context).last
219
+ id = "#{context.owner}#{separator}#{name}"
220
+ candidates = nodes.each_index.select { |index| nodes[index].symbol_id == id }
221
+ local = candidates.select do |index|
222
+ definition = nodes[index]
223
+ definition.file == context.relative_file && definition.line <= source_node.location.start_line
224
+ end
225
+ index = local.max_by { |candidate| [nodes[candidate].line, nodes[candidate].ordinal] }
226
+ nodes[index] = nodes[index].with(visibility: visibility) if index
227
+ return if index && candidates == [index]
228
+
229
+ semantic_blockers << Blocker.new(
230
+ kind: :visibility_activation,
231
+ scope_kind: :message,
232
+ scope_value: name,
233
+ source: 'ast_scanner',
234
+ reason: "visibility activation for #{id} depends on repository load order",
235
+ suggested_action: :review_load_order,
236
+ metadata: {
237
+ 'caller_domain' => context.test ? 'test' : 'runtime',
238
+ 'caller_id' => context.current_caller_id,
239
+ 'owner_scope' => [context.owner],
240
+ 'file' => context.relative_file,
241
+ 'line' => source_node.location.start_line,
242
+ 'message' => name,
243
+ 'reason_code' => 'visibility_activation'
244
+ }
245
+ )
246
+ end
247
+
248
+ def defer_module_function(context, name, source_node)
249
+ instance_id = "#{context.owner}##{name}"
250
+ privatize_current_module_function_source(instance_id, context.relative_file)
251
+ copy = add_module_function_definition(context, name, source_node)
252
+ deferred_module_functions[copy.graph_id] = instance_id
253
+ end
254
+
255
+ def privatize_current_module_function_source(instance_id, relative_file)
256
+ index = nodes.rindex do |definition|
257
+ definition.kind == :instance_method && definition.symbol_id == instance_id && definition.file == relative_file
258
+ end
259
+ nodes[index] = nodes[index].with(visibility: :private) if index
260
+ end
261
+
262
+ def add_module_function_definition(context, name, source_node)
263
+ add_definition(
264
+ symbol_id: "#{context.owner}.#{name}",
265
+ kind: :singleton_method,
266
+ source_node: source_node,
267
+ context: context,
268
+ defined_via: :module_function,
269
+ owner: context.owner,
270
+ name: name,
271
+ visibility: :public
272
+ )
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def scanned_call_site(source_node:, context:, role:, message:, receiver_kind:, receiver_name: nil,
8
+ caller_id: context.current_caller_id, dynamic: false, metadata: {})
9
+ structural_digest = DefinitionIdentity.body_digest(source_node)
10
+ ordinal_key = [caller_id, context.relative_file, role.to_sym, message.to_s, structural_digest]
11
+ ordinal = call_site_ordinals[ordinal_key] += 1
12
+ CallSite.new(
13
+ call_site_id: CallSiteIdentity.source_id(
14
+ caller_definition_id: caller_id,
15
+ relative_path: context.relative_file,
16
+ role: role,
17
+ message: message,
18
+ structural_digest: structural_digest,
19
+ ordinal: ordinal
20
+ ),
21
+ caller_id: caller_id,
22
+ message: message.to_s,
23
+ receiver_kind: receiver_kind,
24
+ receiver_name: receiver_name,
25
+ file: context.relative_file,
26
+ line: source_node.location.start_line,
27
+ test: context.test,
28
+ dynamic: dynamic,
29
+ metadata: metadata
30
+ )
31
+ end
32
+
33
+ def add_scanned_call_site(**attributes)
34
+ scanned_call_site(**attributes).tap { |site| call_sites << site }
35
+ end
36
+
37
+ def derived_call_site(site, derivation:, caller_id: site.caller_id, message: site.message, metadata: {})
38
+ site.with(
39
+ call_site_id: CallSiteIdentity.derived_id(
40
+ parent_call_site_id: site.call_site_id,
41
+ derivation: derivation,
42
+ caller_definition_id: caller_id,
43
+ message: message
44
+ ),
45
+ caller_id: caller_id,
46
+ message: message.to_s,
47
+ metadata: site.metadata.merge(
48
+ 'derived_from_call_site_id' => site.call_site_id,
49
+ 'derived_via' => derivation.to_s
50
+ ).merge(metadata)
51
+ )
52
+ end
53
+ end
54
+ end