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
|
@@ -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
|
-
|
|
17
|
-
|
|
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.
|
|
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('
|
|
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:
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|