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
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Analyzers
5
+ class LegacyResultAdapter
6
+ MISSING = Object.new.freeze
7
+ private_constant :MISSING
8
+
9
+ def initialize(graph:, profile:)
10
+ @graph = graph
11
+ @profile = profile
12
+ end
13
+
14
+ def adapt(result)
15
+ return result unless legacy_static_result?(result)
16
+
17
+ edge_evidences = normalize_edge_evidences(result.edge_evidences)
18
+ alive_evidences = normalize_alive_evidences(result.alive_evidences)
19
+ wrapped_evidences = {}.compare_by_identity
20
+ result.edge_evidences.each { |edge| wrapped_evidences[edge.evidence] = true }
21
+ result.alive_evidences.each { |alive| wrapped_evidences[alive.evidence] = true }
22
+ evidences = result.evidences.reject { |record| wrapped_evidences.key?(record) }.map do |record|
23
+ normalize_evidence(
24
+ record,
25
+ relation: record.kind,
26
+ source: { 'type' => 'legacy_adapter', 'producer' => profile.name.to_s },
27
+ scope: {}
28
+ )
29
+ end
30
+ result.with(
31
+ edge_evidences: edge_evidences,
32
+ alive_evidences: alive_evidences,
33
+ resolutions: graph.call_sites.map { |site| resolution_for(site) },
34
+ evidences: EvidenceCollection.collect(evidences, edge_evidences, alive_evidences)
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :graph, :profile
41
+
42
+ def legacy_static_result?(result)
43
+ profile.kind.to_sym == :static && result.resolutions.nil?
44
+ end
45
+
46
+ def normalize_edge_evidences(edges)
47
+ @edges_by_call_site_id = Hash.new { |hash, key| hash[key] = [] }
48
+ edges.map do |edge|
49
+ site = matching_call_site(edge)
50
+ normalized = EdgeEvidence.new(
51
+ caller_id: edge.caller_id,
52
+ callee_id: edge.callee_id,
53
+ evidence: normalize_evidence(
54
+ edge.evidence,
55
+ relation: :call_edge,
56
+ source: legacy_edge_source(edge, site),
57
+ scope: legacy_edge_scope(edge, site)
58
+ )
59
+ )
60
+ @edges_by_call_site_id[site.call_site_id] << normalized if site
61
+ normalized
62
+ end
63
+ end
64
+
65
+ def normalize_alive_evidences(alive_evidences)
66
+ alive_evidences.map do |alive|
67
+ AliveEvidence.new(
68
+ node_id: alive.node_id,
69
+ evidence: normalize_evidence(
70
+ alive.evidence,
71
+ relation: :alive,
72
+ source: { 'type' => 'legacy_adapter', 'producer' => profile.name.to_s },
73
+ scope: { 'node_reference' => alive.node_id.to_s }
74
+ )
75
+ )
76
+ end
77
+ end
78
+
79
+ def normalize_evidence(record, relation:, source:, scope:)
80
+ normalized = if record.grade
81
+ record
82
+ else
83
+ Evidence.new(
84
+ analyzer: record.analyzer,
85
+ kind: record.kind,
86
+ weight: record.weight,
87
+ details: record.details,
88
+ metadata: record.metadata,
89
+ producer: profile.name,
90
+ producer_version: profile.version || 'unversioned',
91
+ grade: legacy_evidence_grade,
92
+ relation: relation,
93
+ source: source,
94
+ assumptions: profile.assumptions,
95
+ scope: scope
96
+ )
97
+ end
98
+ return normalized if normalized.evidence_id
99
+
100
+ normalized.with(
101
+ evidence_id: EvidenceIdentity.generate(normalized.to_h.except('evidence_id'))
102
+ )
103
+ end
104
+
105
+ def legacy_evidence_grade
106
+ profile.soundness.to_sym == :conservative ? :conservative : :heuristic
107
+ end
108
+
109
+ def legacy_edge_source(edge, site)
110
+ {
111
+ 'type' => 'legacy_adapter',
112
+ 'producer' => profile.name.to_s,
113
+ 'file' => site&.file,
114
+ 'line' => site&.line,
115
+ 'caller_reference' => edge.caller_id.to_s,
116
+ 'callee_reference' => edge.callee_id.to_s
117
+ }.compact
118
+ end
119
+
120
+ def legacy_edge_scope(edge, site)
121
+ if site
122
+ return {
123
+ 'call_site_id' => site.call_site_id,
124
+ 'caller_definition_id' => site.caller_id,
125
+ 'target_reference' => edge.callee_id.to_s
126
+ }
127
+ end
128
+
129
+ { 'caller_reference' => edge.caller_id.to_s, 'target_reference' => edge.callee_id.to_s }
130
+ end
131
+
132
+ def matching_call_site(edge)
133
+ metadata = edge.evidence.metadata
134
+ call_site_id = value(metadata, :call_site_id)
135
+ unless call_site_id.equal?(MISSING)
136
+ caller_id = physical_definition_id(edge.caller_id)
137
+ return unless caller_id
138
+
139
+ return unique_site do |site|
140
+ site.call_site_id == call_site_id.to_s && site.caller_id == caller_id
141
+ end
142
+ end
143
+
144
+ matching_legacy_call_site(edge, metadata)
145
+ end
146
+
147
+ def matching_legacy_call_site(edge, metadata)
148
+ caller_id = physical_definition_id(edge.caller_id)
149
+ return unless caller_id
150
+
151
+ metadata_caller = value(metadata, :caller_definition_id, :caller_id)
152
+ return if metadata_caller.equal?(MISSING)
153
+ return unless physical_definition_id(metadata_caller) == caller_id
154
+
155
+ signature = legacy_signature(metadata, caller_id)
156
+ return unless signature
157
+
158
+ unique_site do |site|
159
+ site.caller_id == caller_id && legacy_signature(site.to_h, caller_id) == signature
160
+ end
161
+ end
162
+
163
+ def legacy_signature(attributes, caller_id)
164
+ message = value(attributes, :message)
165
+ receiver_kind = value(attributes, :receiver_kind)
166
+ receiver_name = value(attributes, :receiver_name)
167
+ file = value(attributes, :file)
168
+ line = value(attributes, :line)
169
+ test = value(attributes, :test)
170
+ dynamic = value(attributes, :dynamic)
171
+ metadata = value(attributes, :metadata)
172
+ values = [message, receiver_kind, receiver_name, file, line, test, dynamic, metadata]
173
+ return if values.any? { |item| item.equal?(MISSING) }
174
+
175
+ CallSiteIdentity.legacy_id(
176
+ caller_definition_id: caller_id,
177
+ message: message,
178
+ receiver_kind: receiver_kind,
179
+ receiver_name: receiver_name,
180
+ file: file,
181
+ line: line,
182
+ test: test,
183
+ dynamic: dynamic,
184
+ metadata: metadata
185
+ )
186
+ end
187
+
188
+ def unique_site(&)
189
+ matches = graph.call_sites.select(&)
190
+ matches.first if matches.one?
191
+ end
192
+
193
+ def physical_definition_id(reference)
194
+ graph.nodes.lookup(reference.to_s).node&.graph_id
195
+ end
196
+
197
+ def resolution_for(site)
198
+ edges = @edges_by_call_site_id.fetch(site.call_site_id, [])
199
+ targets = edges.filter_map { |edge| physical_definition_id(edge.callee_id) }.uniq.sort
200
+ status = targets.empty? ? :unknown : :partial
201
+ ResolutionRecord.new(
202
+ resolution: Resolution.new(
203
+ call_site_id: site.call_site_id,
204
+ target_definition_ids: targets,
205
+ status: status,
206
+ unknown_scope: UnknownScope.new(scope_kind: :message, scope_value: site.message, match: :exact),
207
+ evidence_ids: edges.filter_map { |edge| edge.evidence.evidence_id }.uniq.sort
208
+ ),
209
+ producer: profile.name,
210
+ producer_version: profile.version || 'unversioned',
211
+ assumptions: profile.assumptions
212
+ )
213
+ end
214
+
215
+ def value(attributes, *keys)
216
+ return MISSING unless attributes.respond_to?(:key?)
217
+
218
+ keys.each do |key|
219
+ return attributes[key.to_s] if attributes.key?(key.to_s)
220
+ return attributes[key] if attributes.key?(key)
221
+ end
222
+ MISSING
223
+ end
224
+ end
225
+ end
226
+ end
@@ -5,25 +5,48 @@ module Necropsy
5
5
  module Static
6
6
  class CHA < Analyzer
7
7
  def analyze(graph, _project)
8
- edge_evidences = graph.call_sites.flat_map do |site|
9
- resolve(graph, site).map do |candidate|
8
+ edge_evidences = []
9
+ resolutions = graph.call_sites.map do |site|
10
+ lookup = graph.cha_method_lookup(site)
11
+ targets = lookup.targets
12
+ site_edges = targets.map do |candidate|
10
13
  EdgeEvidence.new(
11
14
  caller_id: site.caller_id,
12
- callee_id: candidate.id,
15
+ callee_id: candidate.graph_id,
13
16
  evidence: evidence(
14
17
  kind: :call_edge,
15
18
  details: "CHA candidate at #{site.file}:#{site.line}",
16
- metadata: site.to_h
19
+ metadata: site.to_h.merge('target_definition_id' => candidate.graph_id),
20
+ grade: :conservative,
21
+ relation: :call_edge,
22
+ source: call_site_evidence_source(site).merge('target_definition_id' => candidate.graph_id),
23
+ scope: call_site_evidence_scope(site).merge('target_definition_id' => candidate.graph_id)
17
24
  )
18
25
  )
19
26
  end
27
+ edge_evidences.concat(site_edges)
28
+ status = if lookup.complete?
29
+ :complete
30
+ else
31
+ (targets.empty? ? :unknown : :partial)
32
+ end
33
+ resolution_record(
34
+ site,
35
+ targets,
36
+ site_edges,
37
+ status: status,
38
+ rejected_targets: lookup.complete? ? lookup.rejected_targets : [],
39
+ unknown_scope: graph.residual_scope_for(site)
40
+ )
20
41
  end
21
42
 
22
43
  AnalyzerResult.new(
23
44
  edge_evidences: edge_evidences,
24
45
  alive_evidences: [],
25
46
  uncertainties: {},
26
- observation: {}
47
+ observation: {},
48
+ resolutions: resolutions,
49
+ evidences: result_evidences(edge_evidences)
27
50
  )
28
51
  end
29
52
 
@@ -32,73 +55,14 @@ module Necropsy
32
55
  name: :cha,
33
56
  kind: :static,
34
57
  soundness: :conservative,
35
- description: 'Expands call targets through class hierarchy, descendants, and included/prepended modules.'
58
+ description: 'Expands call targets through class hierarchy, descendants, and included/prepended modules.',
59
+ version: Necropsy::VERSION,
60
+ assumptions: %w[closed_scanned_hierarchy loaded_ancestry]
36
61
  )
37
62
  end
38
63
 
39
- private
40
-
41
- def resolve(graph, site)
42
- case site.receiver_kind
43
- when :constant
44
- constant_targets(graph, site)
45
- when :instance
46
- instance_targets(graph, site)
47
- when :self, :implicit
48
- self_targets(graph, site)
49
- else
50
- graph.candidate_nodes(site.message)
51
- end.uniq(&:id)
52
- end
53
-
54
- def constant_targets(graph, site)
55
- receiver_candidates(site).flat_map do |owner|
56
- owners_for_lookup(graph, owner, include_descendants: false).filter_map do |candidate_owner|
57
- graph.nodes["#{candidate_owner}.#{site.message}"]
58
- end
59
- end
60
- end
61
-
62
- def instance_targets(graph, site)
63
- receiver_candidates(site).flat_map do |owner|
64
- owners_for_lookup(graph, owner, include_descendants: true).filter_map do |candidate_owner|
65
- graph.nodes["#{candidate_owner}##{site.message}"]
66
- end
67
- end
68
- end
69
-
70
- def self_targets(graph, site)
71
- caller = graph.nodes[site.caller_id]
72
- return [] unless caller&.owner
73
-
74
- separator = caller.kind == :singleton_method ? '.' : '#'
75
- owners_for_lookup(graph, caller.owner, include_descendants: false).filter_map do |owner|
76
- graph.nodes["#{owner}#{separator}#{site.message}"] || graph.nodes["#{owner}##{site.message}"]
77
- end
78
- end
79
-
80
- def owners_for_lookup(graph, owner, include_descendants:)
81
- owners = include_descendants ? graph.descendants_of(owner) : [owner]
82
- owners.flat_map do |candidate|
83
- info = graph.class_info(candidate)
84
- modules = info ? info.prepends + info.includes + info.extends : []
85
- [candidate, *modules, *ancestors(graph, candidate)]
86
- end.uniq
87
- end
88
-
89
- def ancestors(graph, owner)
90
- chain = []
91
- current = graph.class_info(owner)&.superclass
92
- while current && !chain.include?(current)
93
- chain << current
94
- current = graph.class_info(current)&.superclass
95
- end
96
- chain
97
- end
98
-
99
- def receiver_candidates(site)
100
- candidates = site.metadata['receiver_candidates'] || site.metadata[:receiver_candidates]
101
- Array(candidates).compact.empty? ? [site.receiver_name].compact : Array(candidates).compact
64
+ def capabilities
65
+ [:complete_resolution].freeze
102
66
  end
103
67
  end
104
68
  end
@@ -5,25 +5,39 @@ module Necropsy
5
5
  module Static
6
6
  class NameResolution < Analyzer
7
7
  def analyze(graph, _project)
8
- edge_evidences = graph.call_sites.flat_map do |site|
9
- graph.resolve_call_site(site).map do |candidate|
10
- EdgeEvidence.new(
11
- caller_id: site.caller_id,
12
- callee_id: candidate.id,
13
- evidence: evidence(
14
- kind: :call_edge,
15
- details: "Name resolution at #{site.file}:#{site.line}",
16
- metadata: site.to_h
17
- )
18
- )
19
- end
8
+ edge_evidences = []
9
+ resolutions = []
10
+ uncertainties = {}
11
+ blockers = []
12
+ graph.call_sites.each do |site|
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
20
31
  end
21
32
 
22
33
  AnalyzerResult.new(
23
34
  edge_evidences: edge_evidences,
24
35
  alive_evidences: [],
25
- uncertainties: unresolved_uncertainties(graph),
26
- observation: {}
36
+ uncertainties: uncertainties,
37
+ observation: {},
38
+ blockers: blockers,
39
+ resolutions: resolutions,
40
+ evidences: result_evidences(edge_evidences)
27
41
  )
28
42
  end
29
43
 
@@ -32,21 +46,137 @@ module Necropsy
32
46
  name: :name_resolution,
33
47
  kind: :static,
34
48
  soundness: :unsound,
35
- 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]
36
52
  )
37
53
  end
38
54
 
55
+ def capabilities
56
+ [:complete_resolution].freeze
57
+ end
58
+
39
59
  private
40
60
 
41
- def unresolved_uncertainties(graph)
42
- graph.call_sites.each_with_object({}) do |site, memo|
43
- next if site.dynamic
44
- next unless graph.resolve_call_site(site).empty? && site.receiver_kind == :unknown
61
+ def edge_evidences_for(site, candidates, fallback, lookup)
62
+ candidates.map do |candidate|
63
+ grade = if lookup.complete?
64
+ :exact
65
+ elsif fallback
66
+ :heuristic
67
+ else
68
+ :conservative
69
+ end
70
+ EdgeEvidence.new(
71
+ caller_id: site.caller_id,
72
+ callee_id: candidate.graph_id,
73
+ evidence: evidence(
74
+ kind: :call_edge,
75
+ details: "Name resolution#{' fallback' if fallback} at #{site.file}:#{site.line}",
76
+ weight: fallback ? 0.35 : 1.0,
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)
86
+ )
87
+ )
88
+ end
89
+ end
90
+
91
+ def record_unresolved_uncertainty(graph, site, lookup, uncertainties)
92
+ return if site.dynamic || lookup.targets.any? || lookup.complete?
45
93
 
46
- memo[site.caller_id] ||= []
47
- memo[site.caller_id] << "Unknown receiver for #{site.message} at #{site.file}:#{site.line}"
94
+ domain = site.test ? :test : :runtime
95
+ return if graph.candidate_nodes(site.message, domain: domain).empty? && site.receiver_kind != :unknown
96
+
97
+ uncertainties[site.caller_id] ||= []
98
+ uncertainties[site.caller_id] <<
99
+ "Unknown receiver for #{site.message} at #{site.file}:#{site.line}; dispatch may be ambiguous"
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"
48
136
  end
49
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
50
180
  end
51
181
  end
52
182
  end