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
@@ -6,19 +6,38 @@ module Necropsy
6
6
  class NameResolution < Analyzer
7
7
  def analyze(graph, _project)
8
8
  edge_evidences = []
9
+ resolutions = []
9
10
  uncertainties = {}
11
+ blockers = []
10
12
  graph.call_sites.each do |site|
11
- candidates = graph.resolve_call_site(site)
12
- fallback = graph.fallback_resolution?(site, resolved: candidates)
13
- edge_evidences.concat(edge_evidences_for(site, candidates, fallback))
14
- record_unresolved_uncertainty(graph, site, candidates, uncertainties)
13
+ lookup = graph.method_lookup(site)
14
+ candidates = lookup.targets
15
+ fallback = %w[dynamic_message unknown_receiver unknown_self_owner self_lookup_fallback].include?(
16
+ lookup.reason
17
+ )
18
+ site_edges = edge_evidences_for(site, candidates, fallback, lookup)
19
+ edge_evidences.concat(site_edges)
20
+ resolutions << resolution_record(
21
+ site,
22
+ candidates,
23
+ site_edges,
24
+ status: lookup.status,
25
+ rejected_targets: lookup.rejected_targets,
26
+ unknown_scope: graph.residual_scope_for(site)
27
+ )
28
+ record_unresolved_uncertainty(graph, site, lookup, uncertainties)
29
+ blocker = unresolved_blocker(graph, site, lookup)
30
+ blockers << blocker if blocker
15
31
  end
16
32
 
17
33
  AnalyzerResult.new(
18
34
  edge_evidences: edge_evidences,
19
35
  alive_evidences: [],
20
36
  uncertainties: uncertainties,
21
- observation: {}
37
+ observation: {},
38
+ blockers: blockers,
39
+ resolutions: resolutions,
40
+ evidences: result_evidences(edge_evidences)
22
41
  )
23
42
  end
24
43
 
@@ -27,35 +46,137 @@ module Necropsy
27
46
  name: :name_resolution,
28
47
  kind: :static,
29
48
  soundness: :unsound,
30
- description: 'Resolves Ruby call sites by exact receiver and method name, falling back to same-name candidates.'
49
+ description: 'Resolves Ruby call sites by exact receiver and method name, falling back to same-name candidates.',
50
+ version: Necropsy::VERSION,
51
+ assumptions: %w[bounded_same_name_fallback scanned_receiver_hints]
31
52
  )
32
53
  end
33
54
 
55
+ def capabilities
56
+ [:complete_resolution].freeze
57
+ end
58
+
34
59
  private
35
60
 
36
- def edge_evidences_for(site, candidates, fallback)
61
+ def edge_evidences_for(site, candidates, fallback, lookup)
37
62
  candidates.map do |candidate|
63
+ grade = if lookup.complete?
64
+ :exact
65
+ elsif fallback
66
+ :heuristic
67
+ else
68
+ :conservative
69
+ end
38
70
  EdgeEvidence.new(
39
71
  caller_id: site.caller_id,
40
- callee_id: candidate.id,
72
+ callee_id: candidate.graph_id,
41
73
  evidence: evidence(
42
74
  kind: :call_edge,
43
75
  details: "Name resolution#{' fallback' if fallback} at #{site.file}:#{site.line}",
44
76
  weight: fallback ? 0.35 : 1.0,
45
- metadata: site.to_h
77
+ metadata: site.to_h.merge(
78
+ 'target_definition_id' => candidate.graph_id,
79
+ 'lookup_status' => lookup.status.to_s,
80
+ 'lookup_chain' => lookup.lookup_chain
81
+ ),
82
+ grade: grade,
83
+ relation: :call_edge,
84
+ source: call_site_evidence_source(site).merge('target_definition_id' => candidate.graph_id),
85
+ scope: call_site_evidence_scope(site).merge('target_definition_id' => candidate.graph_id)
46
86
  )
47
87
  )
48
88
  end
49
89
  end
50
90
 
51
- def record_unresolved_uncertainty(graph, site, candidates, uncertainties)
52
- return if site.dynamic || candidates.any?
53
- return if graph.candidate_nodes(site.message).empty? && site.receiver_kind != :unknown
91
+ def record_unresolved_uncertainty(graph, site, lookup, uncertainties)
92
+ return if site.dynamic || lookup.targets.any? || lookup.complete?
93
+
94
+ domain = site.test ? :test : :runtime
95
+ return if graph.candidate_nodes(site.message, domain: domain).empty? && site.receiver_kind != :unknown
54
96
 
55
97
  uncertainties[site.caller_id] ||= []
56
98
  uncertainties[site.caller_id] <<
57
99
  "Unknown receiver for #{site.message} at #{site.file}:#{site.line}; dispatch may be ambiguous"
58
100
  end
101
+
102
+ def unresolved_blocker(graph, site, lookup)
103
+ return if lookup.targets.any? || lookup.complete?
104
+
105
+ domain = site.test ? :test : :runtime
106
+ candidate_count = graph.candidate_nodes(site.message, domain: domain).size
107
+ limit_exceeded = graph.ambiguity_exceeded?(site.message, domain: domain)
108
+ return unless limit_exceeded || site.receiver_kind == :unknown
109
+
110
+ reason_code = limit_exceeded ? :ambiguity_limit_exceeded : :unknown_receiver
111
+ scope_kind, scope_value = blocker_scope(graph, site)
112
+ Blocker.new(
113
+ kind: :unknown_dispatch,
114
+ scope_kind: scope_kind,
115
+ scope_value: scope_value,
116
+ source: :name_resolution,
117
+ reason: blocker_reason(site, candidate_count, reason_code),
118
+ suggested_action: :review_receiver_flow,
119
+ metadata: blocker_metadata(graph, site, candidate_count, reason_code)
120
+ )
121
+ end
122
+
123
+ def blocker_scope(graph, site)
124
+ return [:message, site.message] if site.receiver_kind == :unknown
125
+
126
+ owners = receiver_hints(site)
127
+ owners = [graph.nodes[site.caller_id]&.owner].compact if owners.empty?
128
+ owners.empty? ? [:message, site.message] : [:owner, owners.sort]
129
+ end
130
+
131
+ def blocker_reason(site, candidate_count, reason_code)
132
+ if reason_code == :ambiguity_limit_exceeded
133
+ "Dispatch #{site.message} has #{candidate_count} candidates, exceeding the configured ambiguity limit"
134
+ else
135
+ "Receiver for #{site.message} is unknown and no candidate target could be materialized"
136
+ end
137
+ end
138
+
139
+ def blocker_metadata(graph, site, candidate_count, reason_code)
140
+ caller = graph.nodes[site.caller_id]
141
+ owner_scope = receiver_hints(site)
142
+ owner_scope = [caller&.owner].compact if owner_scope.empty? && site.receiver_kind != :unknown
143
+ metadata = {
144
+ 'caller_id' => site.caller_id,
145
+ 'caller_kind' => caller&.kind&.to_s,
146
+ 'caller_domain' => site.test ? 'test' : 'runtime',
147
+ 'message' => site.message,
148
+ 'receiver_kind' => site.receiver_kind.to_s,
149
+ 'receiver_name' => site.receiver_name,
150
+ 'receiver_hints' => receiver_hints(site),
151
+ 'owner_scope' => owner_scope,
152
+ 'namespace_scope' => namespace_scope(owner_scope, caller&.owner),
153
+ 'file' => site.file,
154
+ 'line' => site.line,
155
+ 'candidate_count' => candidate_count,
156
+ 'ambiguity_limit' => finite_limit(graph.ambiguity_limit),
157
+ 'reason_code' => reason_code.to_s,
158
+ 'original_message' => site.metadata['original_message'] || site.metadata[:original_message]
159
+ }
160
+ return metadata unless site.metadata.key?('include_private') || site.metadata.key?(:include_private)
161
+
162
+ metadata.merge('include_private' => site.metadata.fetch('include_private') { site.metadata[:include_private] })
163
+ end
164
+
165
+ def receiver_hints(site)
166
+ candidates = site.metadata['receiver_candidates'] || site.metadata[:receiver_candidates]
167
+ values = Array(candidates).compact
168
+ values = [site.receiver_name].compact if values.empty?
169
+ values.map(&:to_s).uniq
170
+ end
171
+
172
+ def namespace_scope(owner_scope, caller_owner)
173
+ values = owner_scope.empty? ? [caller_owner].compact : owner_scope
174
+ values.filter_map { |owner| owner.to_s.split('::')[0...-1].then { |parts| parts.join('::') unless parts.empty? } }.uniq
175
+ end
176
+
177
+ def finite_limit(limit)
178
+ limit.finite? ? limit : 'unlimited'
179
+ end
59
180
  end
60
181
  end
61
182
  end
@@ -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,83 +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
48
  sites = expanded_call_sites(graph)
17
- edge_evidences = sites.flat_map do |site|
18
- rta_candidates(graph, site).map do |candidate|
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
+
19
63
  EdgeEvidence.new(
20
64
  caller_id: site.caller_id,
21
- callee_id: candidate.id,
65
+ callee_id: candidate.graph_id,
22
66
  evidence: evidence(
23
67
  kind: :call_edge,
24
68
  details: "RTA candidate at #{site.file}:#{site.line}",
25
- 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)
26
74
  )
27
75
  )
28
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))
29
84
  end
30
85
 
31
86
  AnalyzerResult.new(
32
87
  edge_evidences: edge_evidences,
33
88
  alive_evidences: [],
34
89
  uncertainties: {},
35
- observation: { 'rta' => { 'analyzed_sites' => sites.map(&:to_h) } }
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
36
94
  )
37
95
  end
38
96
 
39
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
40
103
  AnalyzerProfile.new(
41
104
  name: :rta,
42
105
  kind: :static,
43
106
  soundness: :partial,
44
- 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}"]
45
110
  )
46
111
  end
47
112
 
113
+ def capabilities
114
+ [:complete_resolution].freeze
115
+ end
116
+
48
117
  def expanded_call_sites(graph)
49
118
  graph.call_sites.flat_map do |site|
50
- [site, *implicit_sites(site)]
119
+ [site, *implicit_sites(site, graph: graph)]
51
120
  end
52
121
  end
53
122
 
54
- def implicit_sites(site)
55
- implicit_messages(site.message).map do |message|
56
- CallSite.new(
57
- caller_id: site.caller_id,
58
- message: message,
59
- receiver_kind: site.receiver_kind,
60
- receiver_name: site.receiver_name,
61
- file: site.file,
62
- line: site.line,
63
- test: site.test,
64
- dynamic: site.dynamic,
65
- metadata: site.metadata.merge('implicit_from' => site.message)
66
- )
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)
67
131
  end
68
132
  end
69
133
 
70
- def implicit_messages(message)
71
- messages = []
72
- messages << 'each' if ENUMERABLE_MESSAGES.include?(message) && message != 'each'
73
- messages << '<=>' if COMPARISON_MESSAGES.include?(message)
74
- messages << 'to_s' if %w[puts print warn].include?(message)
75
- messages
134
+ def implicit_messages(site, graph: nil)
135
+ implicit_sites(site, graph: graph).map(&:message)
76
136
  end
77
137
 
78
138
  private
79
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,
148
+ message: message,
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]]
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
240
+ end
241
+
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
+
80
270
  def rta_candidates(graph, site)
81
- candidates = Analyzers::Static::CHA.new.candidates(graph, site)
271
+ candidates = graph.cha_method_lookup(site).targets
82
272
  candidates = fallback_candidates(graph, site) if candidates.empty?
83
273
  graph.retain_rta_candidates(candidates, site)
84
274
  end
85
275
 
86
276
  def fallback_candidates(graph, site)
87
- return graph.candidate_nodes(site.message) if site.receiver_kind == :unknown
277
+ domain = site.test ? :test : :runtime
278
+ return graph.candidate_nodes(site.message, domain: domain) if site.receiver_kind == :unknown
88
279
  return [] unless graph.ambiguous_resolution?
89
280
 
90
- graph.ambiguous_fallback_candidates(site.message)
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
+ }
91
299
  end
92
300
  end
93
301
  end