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.
- checksums.yaml +4 -4
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1234 -62
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- 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 =
|
|
9
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|