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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- 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 +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- 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 +133 -23
- 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 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- 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 +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- 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 +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- 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 +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- 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 +1099 -109
- 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 +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -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 +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- metadata +85 -1
data/lib/necropsy/runner.rb
CHANGED
|
@@ -1,48 +1,265 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module Necropsy
|
|
4
6
|
class Runner
|
|
5
|
-
|
|
7
|
+
ANALYZER_ERROR_MESSAGE_BYTES = 500
|
|
8
|
+
INVALID_BLOCKER_KINDS = %i[
|
|
9
|
+
analyzer_failure blocker_invalid evidence_collision rails_route_health resolution_invalid unsound_rta_pruning
|
|
10
|
+
].freeze
|
|
11
|
+
DEGRADED_BLOCKER_KINDS = %i[
|
|
12
|
+
reference_scan_incomplete reference_scope_incomplete source_discovery_incomplete
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :root, :config, :analyzers, :ignored_reference_paths, :clock
|
|
6
16
|
|
|
7
|
-
def initialize(root:, config_path: nil, analyzers: nil)
|
|
17
|
+
def initialize(root:, config_path: nil, analyzers: nil, ignored_reference_paths: [], as_of: nil)
|
|
8
18
|
@root = File.expand_path(root)
|
|
9
19
|
@config = Configuration.load(root: @root, path: config_path)
|
|
10
20
|
@analyzers = analyzers
|
|
21
|
+
@ignored_reference_paths = ignored_reference_paths
|
|
22
|
+
@clock = Clock.new(as_of: as_of)
|
|
11
23
|
end
|
|
12
24
|
|
|
13
|
-
def analyze
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
def analyze(rta_pruning: config.rta_pruning, profile: false)
|
|
26
|
+
rta_pruning = normalize_rta_pruning(rta_pruning)
|
|
27
|
+
profiler = profile ? PerformanceProfiler.new : nil
|
|
28
|
+
project = measure_phase(profiler, 'project') { Project.new(root: root, config: config) }
|
|
29
|
+
source_snapshot = measure_phase(profiler, 'source_snapshot') { project.source_snapshot }
|
|
30
|
+
graph = measure_phase(profiler, 'scan') do
|
|
31
|
+
CallGraph.new(project.scan_result, ambiguity_limit: config.ambiguity_limit)
|
|
32
|
+
end
|
|
33
|
+
project.scope_blockers.each { |blocker| graph.add_blocker(blocker) }
|
|
34
|
+
graph.add_blocker(unsound_rta_pruning_blocker) if rta_pruning == :legacy
|
|
16
35
|
rta_results = []
|
|
17
36
|
|
|
18
|
-
apply_entry_points(graph, project)
|
|
19
|
-
configured_analyzers.each do |
|
|
20
|
-
|
|
21
|
-
result =
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
measure_phase(profiler, 'entry_points') { apply_entry_points(graph, project) }
|
|
38
|
+
configured_analyzers.each do |configured_analyzer|
|
|
39
|
+
analyzer_name = configured_analyzer.profile.name
|
|
40
|
+
analyzer_profile, result = measure_phase(profiler, "analyzer:#{analyzer_name}") do
|
|
41
|
+
run_analyzer(graph, project, configured_analyzer, rta_pruning)
|
|
42
|
+
end
|
|
43
|
+
rta_results << result if analyzer_profile&.name == :rta && result
|
|
44
|
+
end
|
|
45
|
+
graph.refresh_derived_state
|
|
46
|
+
measure_phase(profiler, 'reachability') do
|
|
47
|
+
rta_results.each { |result| graph.reconcile_rta_result(result) } if rta_pruning == :legacy
|
|
24
48
|
end
|
|
25
|
-
rta_results.each { |result| graph.reconcile_rta_result(result) }
|
|
26
49
|
|
|
27
|
-
reachability = Reachability::Engine.new(graph).call
|
|
28
|
-
|
|
50
|
+
reachability = measure_phase(profiler, 'reachability_engine') { Reachability::Engine.new(graph).call }
|
|
51
|
+
LoadGraph.record_unrooted_units(graph: graph, reachability: reachability)
|
|
52
|
+
findings = measure_phase(profiler, 'scoring') do
|
|
53
|
+
scorer = Confidence::Scorer.new(graph: graph, reachability: reachability, project: project, clock: clock)
|
|
54
|
+
scorer.findings
|
|
55
|
+
end
|
|
56
|
+
barrier_matches = measure_phase(profiler, 'reference_barrier') do
|
|
57
|
+
ReferenceBarrier.new(
|
|
58
|
+
graph: graph,
|
|
59
|
+
project: project,
|
|
60
|
+
ignored_paths: ignored_reference_paths
|
|
61
|
+
).apply(findings)
|
|
62
|
+
end
|
|
63
|
+
if barrier_matches.positive?
|
|
64
|
+
findings = Confidence::Scorer.new(
|
|
65
|
+
graph: graph, reachability: reachability, project: project, clock: clock
|
|
66
|
+
).findings
|
|
67
|
+
end
|
|
68
|
+
final_source_snapshot = measure_phase(profiler, 'source_snapshot_verification') { project.fresh_source_snapshot }
|
|
69
|
+
analysis_health = build_analysis_health(graph, source_snapshot, final_source_snapshot)
|
|
70
|
+
source_snapshot = verified_source_snapshot(source_snapshot, final_source_snapshot)
|
|
71
|
+
performance = profiler&.report(
|
|
72
|
+
counts: graph.performance_counts,
|
|
73
|
+
report_index_size_bytes: report_index_size_bytes(graph)
|
|
74
|
+
)
|
|
29
75
|
Report.new(
|
|
30
76
|
root: root,
|
|
31
77
|
graph: graph,
|
|
32
78
|
findings: findings,
|
|
33
79
|
reachability: reachability,
|
|
80
|
+
project: project,
|
|
81
|
+
source_snapshot: source_snapshot,
|
|
82
|
+
analysis_health: analysis_health,
|
|
34
83
|
report_include_paths: config.report_include_paths,
|
|
35
|
-
report_exclude_paths: config.report_exclude_paths
|
|
84
|
+
report_exclude_paths: config.report_exclude_paths,
|
|
85
|
+
performance_profile: performance
|
|
36
86
|
)
|
|
37
87
|
end
|
|
38
88
|
|
|
39
89
|
private
|
|
40
90
|
|
|
91
|
+
def build_analysis_health(graph, initial_snapshot, final_snapshot)
|
|
92
|
+
reasons = snapshot_health_reasons(initial_snapshot, final_snapshot)
|
|
93
|
+
graph.blockers.each do |blocker|
|
|
94
|
+
severity = blocker_health_severity(blocker)
|
|
95
|
+
next unless severity
|
|
96
|
+
|
|
97
|
+
reasons << blocker_health_reason(blocker, severity)
|
|
98
|
+
end
|
|
99
|
+
AnalysisHealth.from_reasons(reasons.uniq { |reason| BoundedCanonicalizer.dump(reason) })
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def snapshot_health_reasons(initial_snapshot, final_snapshot)
|
|
103
|
+
snapshots = { 'initial' => initial_snapshot, 'final' => final_snapshot }
|
|
104
|
+
unavailable = snapshots.filter_map do |phase, snapshot|
|
|
105
|
+
next if snapshot['status'] == 'complete'
|
|
106
|
+
|
|
107
|
+
{ 'phase' => phase, 'reason' => snapshot['reason'], 'details' => snapshot.except('sha256') }
|
|
108
|
+
end
|
|
109
|
+
return [{ 'severity' => 'invalid', 'code' => 'source_snapshot_unavailable', 'snapshots' => unavailable }] if unavailable.any?
|
|
110
|
+
return [] if initial_snapshot['sha256'] == final_snapshot['sha256']
|
|
111
|
+
|
|
112
|
+
[{
|
|
113
|
+
'severity' => 'invalid',
|
|
114
|
+
'code' => 'source_changed_during_analysis',
|
|
115
|
+
'initial_sha256' => initial_snapshot['sha256'],
|
|
116
|
+
'final_sha256' => final_snapshot['sha256']
|
|
117
|
+
}]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def blocker_health_severity(blocker)
|
|
121
|
+
return :invalid if blocker.kind == :parse_incomplete && blocker.caller_domain == :runtime
|
|
122
|
+
return :invalid if INVALID_BLOCKER_KINDS.include?(blocker.kind)
|
|
123
|
+
|
|
124
|
+
:degraded if DEGRADED_BLOCKER_KINDS.include?(blocker.kind)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def blocker_health_reason(blocker, severity)
|
|
128
|
+
metadata = blocker.metadata
|
|
129
|
+
{
|
|
130
|
+
'severity' => severity.to_s,
|
|
131
|
+
'code' => blocker.kind.to_s,
|
|
132
|
+
'message' => blocker.reason,
|
|
133
|
+
'source' => blocker.source.respond_to?(:to_h) ? blocker.source.to_h : blocker.source.to_s,
|
|
134
|
+
'file' => metadata['file'] || metadata[:file],
|
|
135
|
+
'line' => metadata['line'] || metadata[:line]
|
|
136
|
+
}.compact
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def verified_source_snapshot(initial_snapshot, final_snapshot)
|
|
140
|
+
status = if initial_snapshot['status'] != 'complete' || final_snapshot['status'] != 'complete'
|
|
141
|
+
'unavailable'
|
|
142
|
+
elsif initial_snapshot['sha256'] == final_snapshot['sha256']
|
|
143
|
+
'match'
|
|
144
|
+
else
|
|
145
|
+
'mismatch'
|
|
146
|
+
end
|
|
147
|
+
initial_snapshot.merge(
|
|
148
|
+
'verification' => {
|
|
149
|
+
'status' => status,
|
|
150
|
+
'final_status' => final_snapshot['status'],
|
|
151
|
+
'final_sha256' => final_snapshot['sha256']
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def measure_phase(profiler, name, &block)
|
|
157
|
+
return block.call unless profiler
|
|
158
|
+
|
|
159
|
+
profiler.measure(name, &block)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def report_index_size_bytes(graph)
|
|
163
|
+
node_bytes = graph.nodes.values.sum { |node| JSON.generate(node.to_h).bytesize }
|
|
164
|
+
call_site_bytes = graph.call_sites.sum { |site| JSON.generate(site.to_h).bytesize }
|
|
165
|
+
edge_bytes = graph.edges.sum { |edge| JSON.generate(edge.to_h).bytesize }
|
|
166
|
+
node_bytes + call_site_bytes + edge_bytes
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def normalize_rta_pruning(value)
|
|
170
|
+
mode = value.to_s
|
|
171
|
+
return mode.to_sym if Configuration::RTA_PRUNING_MODES.include?(mode)
|
|
172
|
+
|
|
173
|
+
raise Error, "RTA pruning must be one of: #{Configuration::RTA_PRUNING_MODES.join(', ')}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def analyzer_for_pruning(analyzer, mode)
|
|
177
|
+
return analyzer unless analyzer.is_a?(Analyzers::Static::RTA)
|
|
178
|
+
|
|
179
|
+
configured = analyzer.with_pruning(mode)
|
|
180
|
+
return configured unless mode == :rank_only
|
|
181
|
+
|
|
182
|
+
configured.without_redundant_edges
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def run_analyzer(graph, project, configured_analyzer, rta_pruning)
|
|
186
|
+
analyzer = analyzer_for_pruning(configured_analyzer, rta_pruning)
|
|
187
|
+
profile = analyzer.profile
|
|
188
|
+
raise TypeError, "#{analyzer.class} returned an invalid analyzer profile" unless profile.is_a?(AnalyzerProfile)
|
|
189
|
+
|
|
190
|
+
graph.add_profile(profile)
|
|
191
|
+
result = analyzer.analyze(graph, project)
|
|
192
|
+
result = Analyzers::LegacyResultAdapter.new(graph: graph, profile: profile).adapt(result)
|
|
193
|
+
validate_result_capabilities!(analyzer, result)
|
|
194
|
+
graph.apply_result(result, refresh: false)
|
|
195
|
+
[profile, result]
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
graph.add_blocker(analyzer_failure_blocker(analyzer || configured_analyzer, profile, e))
|
|
198
|
+
[profile, nil]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def analyzer_failure_blocker(analyzer, profile, error)
|
|
202
|
+
analyzer_name = profile&.name&.to_s || analyzer.class.name
|
|
203
|
+
error_message = safe_error_message(error)
|
|
204
|
+
Blocker.new(
|
|
205
|
+
kind: :analyzer_failure,
|
|
206
|
+
scope_kind: :global,
|
|
207
|
+
scope_value: '*',
|
|
208
|
+
source: analyzer_name,
|
|
209
|
+
reason: "Analyzer #{analyzer_name} failed: #{error.class}: #{error_message}",
|
|
210
|
+
suggested_action: :fix_analyzer,
|
|
211
|
+
metadata: {
|
|
212
|
+
'analyzer' => analyzer_name,
|
|
213
|
+
'analyzer_class' => analyzer.class.name,
|
|
214
|
+
'caller_domain' => 'runtime',
|
|
215
|
+
'error_class' => error.class.name,
|
|
216
|
+
'error_message' => error_message
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def validate_result_capabilities!(analyzer, result)
|
|
222
|
+
complete = Array(result.resolutions).any? { |record| record.resolution.status == :complete }
|
|
223
|
+
return unless complete
|
|
224
|
+
return if Array(analyzer.capabilities).map(&:to_sym).include?(:complete_resolution)
|
|
225
|
+
|
|
226
|
+
raise TypeError, "#{analyzer.class} emitted complete resolution without complete_resolution capability"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def unsound_rta_pruning_blocker
|
|
230
|
+
Blocker.new(
|
|
231
|
+
kind: :unsound_rta_pruning,
|
|
232
|
+
scope_kind: :global,
|
|
233
|
+
scope_value: '*',
|
|
234
|
+
source: :configuration,
|
|
235
|
+
reason: 'Legacy RTA pruning can remove reachable targets without complete allocation evidence',
|
|
236
|
+
suggested_action: :use_rank_only_rta,
|
|
237
|
+
metadata: { 'caller_domain' => 'runtime', 'rta_pruning' => 'legacy' }
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def safe_error_message(error)
|
|
242
|
+
message = error.message.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
243
|
+
return message if message.bytesize <= ANALYZER_ERROR_MESSAGE_BYTES
|
|
244
|
+
|
|
245
|
+
"#{message.byteslice(0, ANALYZER_ERROR_MESSAGE_BYTES).to_s.scrub}\u2026"
|
|
246
|
+
rescue StandardError, SystemStackError
|
|
247
|
+
'unavailable'
|
|
248
|
+
end
|
|
249
|
+
|
|
41
250
|
def apply_entry_points(graph, project)
|
|
42
251
|
EntryPoints::Plain.new.apply(graph, project)
|
|
43
252
|
EntryPoints::Rails.new.apply(graph, project)
|
|
44
253
|
EntryPoints::Test.new.apply(graph, project)
|
|
45
|
-
graph.entrypoint_hints.each
|
|
254
|
+
graph.entrypoint_hints.each do |entry|
|
|
255
|
+
graph.add_entry_point(
|
|
256
|
+
entry.node_id,
|
|
257
|
+
entry.reason,
|
|
258
|
+
domain: entry.domain,
|
|
259
|
+
evidence: entry.evidence
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
WorldPolicy.new(graph: graph, project: project).apply
|
|
46
263
|
end
|
|
47
264
|
|
|
48
265
|
def configured_analyzers
|
|
@@ -85,8 +302,6 @@ module Necropsy
|
|
|
85
302
|
end
|
|
86
303
|
|
|
87
304
|
def custom_analyzer_definition(entry)
|
|
88
|
-
return [entry, nil] unless entry.is_a?(Hash)
|
|
89
|
-
|
|
90
305
|
[entry.fetch('class'), entry['require']]
|
|
91
306
|
end
|
|
92
307
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
# Compares positive runtime target observations with the static target set.
|
|
8
|
+
#
|
|
9
|
+
# Runtime observations may discover a target that the static resolver did not
|
|
10
|
+
# enumerate. That is a safety signal and a useful regression fixture, but an
|
|
11
|
+
# unobserved static target is never removed from the model.
|
|
12
|
+
class RuntimeFeedback
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
DEFAULT_FIXTURE_LIMIT = 32
|
|
15
|
+
|
|
16
|
+
def initialize(static_targets:, observed_targets:, max_fixtures: DEFAULT_FIXTURE_LIMIT)
|
|
17
|
+
@static_targets = normalize_static_targets(static_targets)
|
|
18
|
+
@observed_targets = normalize_observed_targets(observed_targets)
|
|
19
|
+
@max_fixtures = Integer(max_fixtures)
|
|
20
|
+
raise ArgumentError, 'max_fixtures must be non-negative' if @max_fixtures.negative?
|
|
21
|
+
rescue ArgumentError, TypeError => e
|
|
22
|
+
raise e unless e.message.start_with?('invalid value for Integer()', 'no implicit conversion of')
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, 'max_fixtures must be a non-negative integer'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
observed_by_site = @observed_targets.group_by { |target| target.fetch('call_site_id') }
|
|
29
|
+
static_by_site = @static_targets
|
|
30
|
+
missing_static = @observed_targets.filter_map do |target|
|
|
31
|
+
static = static_by_site.fetch(target.fetch('call_site_id'), [])
|
|
32
|
+
next if static.include?(target.fetch('target_definition_id'))
|
|
33
|
+
|
|
34
|
+
target.merge(
|
|
35
|
+
'classification' => 'missing_static_target',
|
|
36
|
+
'safety' => 'safety_bug'
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
fixtures = missing_static.first(@max_fixtures).map { |target| fixture_candidate(target) }
|
|
40
|
+
unobserved_static = static_by_site.flat_map do |call_site_id, targets|
|
|
41
|
+
observed = observed_by_site.fetch(call_site_id, []).map { |target| target.fetch('target_definition_id') }
|
|
42
|
+
targets.reject { |target| observed.include?(target) }.map do |target|
|
|
43
|
+
{ 'call_site_id' => call_site_id, 'target_definition_id' => target, 'informational' => true }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
'schema_version' => SCHEMA_VERSION,
|
|
49
|
+
'policy' => 'positive_only',
|
|
50
|
+
'comparison' => {
|
|
51
|
+
'call_sites' => (static_by_site.keys | observed_by_site.keys).length,
|
|
52
|
+
'static_target_count' => static_by_site.values.sum(&:length),
|
|
53
|
+
'observed_target_count' => @observed_targets.length
|
|
54
|
+
},
|
|
55
|
+
'missing_static_targets' => missing_static,
|
|
56
|
+
'unexpected_targets' => missing_static,
|
|
57
|
+
'unobserved_static_targets' => unobserved_static,
|
|
58
|
+
'fixture_candidates' => fixtures,
|
|
59
|
+
'fixture_exported' => false
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def write_fixture_candidates(path)
|
|
64
|
+
payload = call
|
|
65
|
+
candidates = payload.fetch('fixture_candidates')
|
|
66
|
+
destination = File.expand_path(path)
|
|
67
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
68
|
+
temporary = "#{destination}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
69
|
+
File.write(temporary, "#{JSON.pretty_generate(candidates)}\n")
|
|
70
|
+
File.rename(temporary, destination)
|
|
71
|
+
payload.merge('fixture_exported' => true, 'fixture_path' => destination)
|
|
72
|
+
rescue SystemCallError => e
|
|
73
|
+
raise Error, "Could not export runtime feedback fixtures: #{e.message}"
|
|
74
|
+
ensure
|
|
75
|
+
FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.from_graph(graph, observed_targets:, max_fixtures: DEFAULT_FIXTURE_LIMIT)
|
|
79
|
+
static_targets = graph.resolution_records.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |record, result|
|
|
80
|
+
result[record.resolution.call_site_id].concat(record.resolution.target_definition_ids)
|
|
81
|
+
end
|
|
82
|
+
new(static_targets: static_targets, observed_targets: observed_targets, max_fixtures: max_fixtures)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def normalize_static_targets(value)
|
|
88
|
+
raise ArgumentError, 'static_targets must be a Hash' unless value.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
value.each_with_object({}) do |(call_site_id, targets), result|
|
|
91
|
+
site = identifier(call_site_id, 'call_site_id')
|
|
92
|
+
result[site] = Array(targets).map { |target| identifier(target, 'target_definition_id') }.uniq.sort
|
|
93
|
+
end.sort.to_h.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_observed_targets(value)
|
|
97
|
+
Array(value).map do |target|
|
|
98
|
+
raise ArgumentError, 'observed target must be a Hash' unless target.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
data = target.to_h { |key, item| [key.to_s, item] }
|
|
101
|
+
call_site_id = identifier(data['call_site_id'], 'call_site_id')
|
|
102
|
+
target_definition_id = identifier(
|
|
103
|
+
data['target_definition_id'] || data['target_id'], 'target_definition_id'
|
|
104
|
+
)
|
|
105
|
+
normalized = {
|
|
106
|
+
'call_site_id' => call_site_id,
|
|
107
|
+
'target_definition_id' => target_definition_id
|
|
108
|
+
}
|
|
109
|
+
%w[receiver_class file line].each do |key|
|
|
110
|
+
normalized[key] = data[key] unless data[key].nil?
|
|
111
|
+
end
|
|
112
|
+
normalized
|
|
113
|
+
end.uniq.sort_by { |target| [target['call_site_id'], target['target_definition_id']] }.freeze
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def fixture_candidate(target)
|
|
117
|
+
{
|
|
118
|
+
'call_site_id' => target.fetch('call_site_id'),
|
|
119
|
+
'target_definition_id' => target.fetch('target_definition_id'),
|
|
120
|
+
'receiver_class' => target['receiver_class'],
|
|
121
|
+
'source' => {
|
|
122
|
+
'file' => target['file'],
|
|
123
|
+
'line' => target['line']
|
|
124
|
+
}.compact,
|
|
125
|
+
'reason' => 'runtime target was not present in the static target set'
|
|
126
|
+
}.compact
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def identifier(value, field)
|
|
130
|
+
normalized = value.to_s
|
|
131
|
+
raise ArgumentError, "#{field} must not be empty" if normalized.empty?
|
|
132
|
+
|
|
133
|
+
normalized
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'prism'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module Necropsy
|
|
8
|
+
class SemanticsMatrix
|
|
9
|
+
SCHEMA_VERSION = 'necropsy.semantics.v1'
|
|
10
|
+
STATUSES = %w[exact partial blocked unsupported].freeze
|
|
11
|
+
|
|
12
|
+
EXACT_PRISM_NODES = %i[
|
|
13
|
+
AliasMethodNode ArgumentsNode ArrayNode AssocNode BeginNode BlockArgumentNode BlockLocalVariableNode
|
|
14
|
+
BlockParameterNode BlockParametersNode ClassNode ConstantPathNode ConstantReadNode ConstantTargetNode
|
|
15
|
+
DefNode FalseNode FloatNode ForwardingArgumentsNode ForwardingParameterNode HashNode ImaginaryNode
|
|
16
|
+
ImplicitNode ImplicitRestNode IntegerNode ItLocalVariableReadNode ItParametersNode KeywordHashNode
|
|
17
|
+
KeywordRestParameterNode LocalVariableReadNode LocalVariableTargetNode ModuleNode NilNode
|
|
18
|
+
NoKeywordsParameterNode NumberedParametersNode NumberedReferenceReadNode OptionalKeywordParameterNode
|
|
19
|
+
OptionalParameterNode ParametersNode ParenthesesNode RationalNode RegularExpressionNode
|
|
20
|
+
RequiredKeywordParameterNode RequiredParameterNode RestParameterNode SelfNode SourceEncodingNode
|
|
21
|
+
SourceFileNode SourceLineNode StatementsNode StringNode SymbolNode TrueNode
|
|
22
|
+
].to_set.freeze
|
|
23
|
+
|
|
24
|
+
PARTIAL_PRISM_NODES = %i[
|
|
25
|
+
AliasGlobalVariableNode AlternationPatternNode AndNode ArrayPatternNode AssocSplatNode
|
|
26
|
+
BackReferenceReadNode BlockNode BreakNode CallAndWriteNode CallNode CallOperatorWriteNode CallOrWriteNode
|
|
27
|
+
CallTargetNode CapturePatternNode CaseMatchNode CaseNode ClassVariableAndWriteNode
|
|
28
|
+
ClassVariableOperatorWriteNode ClassVariableOrWriteNode ClassVariableReadNode ClassVariableTargetNode
|
|
29
|
+
ClassVariableWriteNode ConstantAndWriteNode ConstantOperatorWriteNode ConstantOrWriteNode
|
|
30
|
+
ConstantPathAndWriteNode ConstantPathOperatorWriteNode ConstantPathOrWriteNode ConstantPathTargetNode
|
|
31
|
+
ConstantPathWriteNode ConstantWriteNode DefinedNode ElseNode EmbeddedStatementsNode EmbeddedVariableNode
|
|
32
|
+
EnsureNode FindPatternNode FlipFlopNode ForNode ForwardingSuperNode GlobalVariableAndWriteNode
|
|
33
|
+
GlobalVariableOperatorWriteNode GlobalVariableOrWriteNode GlobalVariableReadNode GlobalVariableTargetNode
|
|
34
|
+
GlobalVariableWriteNode HashPatternNode IfNode InNode IndexAndWriteNode IndexOperatorWriteNode IndexOrWriteNode
|
|
35
|
+
IndexTargetNode InstanceVariableAndWriteNode InstanceVariableOperatorWriteNode InstanceVariableOrWriteNode
|
|
36
|
+
InstanceVariableReadNode InstanceVariableTargetNode InstanceVariableWriteNode InterpolatedMatchLastLineNode
|
|
37
|
+
InterpolatedRegularExpressionNode InterpolatedStringNode InterpolatedSymbolNode InterpolatedXStringNode
|
|
38
|
+
LambdaNode LocalVariableAndWriteNode LocalVariableOperatorWriteNode LocalVariableOrWriteNode
|
|
39
|
+
LocalVariableWriteNode MatchLastLineNode MatchPredicateNode MatchRequiredNode MatchWriteNode MultiTargetNode
|
|
40
|
+
MultiWriteNode NextNode OrNode PinnedExpressionNode PinnedVariableNode PostExecutionNode PreExecutionNode
|
|
41
|
+
ProgramNode RangeNode RedoNode RescueModifierNode RescueNode RetryNode ReturnNode ShareableConstantNode
|
|
42
|
+
SingletonClassNode SplatNode SuperNode UndefNode UnlessNode UntilNode WhenNode WhileNode XStringNode YieldNode
|
|
43
|
+
].to_set.freeze
|
|
44
|
+
|
|
45
|
+
BLOCKED_PRISM_NODES = %i[MissingNode].to_set.freeze
|
|
46
|
+
|
|
47
|
+
RUBY_HOOK_STATUS = {
|
|
48
|
+
'inherited' => 'partial',
|
|
49
|
+
'included' => 'partial',
|
|
50
|
+
'extended' => 'partial',
|
|
51
|
+
'prepended' => 'partial',
|
|
52
|
+
'method_added' => 'partial',
|
|
53
|
+
'singleton_method_added' => 'partial',
|
|
54
|
+
'const_missing' => 'partial',
|
|
55
|
+
'method_missing' => 'partial',
|
|
56
|
+
'respond_to_missing?' => 'partial'
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
def to_h
|
|
60
|
+
{
|
|
61
|
+
'schema_version' => SCHEMA_VERSION,
|
|
62
|
+
'runtime' => { 'ruby' => RUBY_VERSION, 'prism' => Prism::VERSION },
|
|
63
|
+
'statuses' => STATUSES,
|
|
64
|
+
'prism_nodes' => prism_nodes,
|
|
65
|
+
'ruby_hooks' => ruby_hooks,
|
|
66
|
+
'rails_dsl' => rails_dsl
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render(format: :human)
|
|
71
|
+
case format.to_sym
|
|
72
|
+
when :json then JSON.pretty_generate(to_h)
|
|
73
|
+
when :yaml then YAML.dump(to_h)
|
|
74
|
+
when :human then render_human
|
|
75
|
+
else raise Error, "semantics supports human, json, or yaml output (got #{format})"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def prism_nodes
|
|
82
|
+
prism_node_classes.map do |name|
|
|
83
|
+
status = prism_status(name)
|
|
84
|
+
{ 'name' => name.to_s, 'status' => status, 'reason' => prism_reason(status) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def prism_node_classes
|
|
89
|
+
Prism.constants.filter_map do |name|
|
|
90
|
+
value = Prism.const_get(name)
|
|
91
|
+
name if value.is_a?(Class) && value < Prism::Node
|
|
92
|
+
end.sort_by(&:to_s)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def prism_status(name)
|
|
96
|
+
return 'exact' if EXACT_PRISM_NODES.include?(name)
|
|
97
|
+
return 'partial' if PARTIAL_PRISM_NODES.include?(name)
|
|
98
|
+
return 'blocked' if BLOCKED_PRISM_NODES.include?(name)
|
|
99
|
+
|
|
100
|
+
'unsupported'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def prism_reason(status)
|
|
104
|
+
case status
|
|
105
|
+
when 'exact' then 'scanner preserves the node structure without narrowing dispatch unsafely'
|
|
106
|
+
when 'partial' then 'calls are traversed conservatively but full Ruby runtime semantics are not claimed'
|
|
107
|
+
when 'blocked' then 'analysis health or a semantic blocker prevents an actionable conclusion'
|
|
108
|
+
else 'no reviewed scanner semantics are registered for this Prism node version'
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ruby_hooks
|
|
113
|
+
Confidence::Scorer::RUBY_HOOKS.sort.map do |name|
|
|
114
|
+
{
|
|
115
|
+
'name' => name,
|
|
116
|
+
'status' => RUBY_HOOK_STATUS.fetch(name, 'unsupported'),
|
|
117
|
+
'reason' => 'recognized as an implicit VM callback; confidence is lowered without claiming a runtime root'
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def rails_dsl
|
|
123
|
+
entries = {}
|
|
124
|
+
AstScanner::RAILS_CALLBACK_MACROS.each { |name| entries[name.to_s] = dsl_entry(name, 'callback') }
|
|
125
|
+
AstScanner::RAILS_GENERATED_METHOD_MACROS.each do |name|
|
|
126
|
+
entries[name.to_s] = dsl_entry(name, 'generated_method')
|
|
127
|
+
end
|
|
128
|
+
EntryPoints::Rails::ROUTE_DSL_CALLS.each { |name| entries[name.to_s] = dsl_entry(name, 'route') }
|
|
129
|
+
entries.keys.sort.map { |name| entries.fetch(name) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def dsl_entry(name, family)
|
|
133
|
+
reasons = {
|
|
134
|
+
'callback' => 'literal callback methods and conditions are rooted; dynamic registration is blocked',
|
|
135
|
+
'generated_method' => 'literal APIs are grouped by macro source; version-dependent surfaces remain conservative',
|
|
136
|
+
'route' => 'Prism verifies route DSL calls and literal targets; dynamic targets create scoped blockers'
|
|
137
|
+
}
|
|
138
|
+
{ 'name' => name.to_s, 'family' => family, 'status' => 'partial', 'reason' => reasons.fetch(family) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def render_human
|
|
142
|
+
data = to_h
|
|
143
|
+
counts = data.fetch('prism_nodes').tally { |entry| entry.fetch('status') }
|
|
144
|
+
[
|
|
145
|
+
"Semantics matrix #{SCHEMA_VERSION}",
|
|
146
|
+
"Ruby #{RUBY_VERSION}; Prism #{Prism::VERSION}",
|
|
147
|
+
"Prism nodes: #{STATUSES.map { |status| "#{status}=#{counts.fetch(status, 0)}" }.join(', ')}",
|
|
148
|
+
"Ruby hooks: #{data.fetch('ruby_hooks').length}",
|
|
149
|
+
"Rails DSL entries: #{data.fetch('rails_dsl').length}"
|
|
150
|
+
].join("\n")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
TYPE_FACT_TRUST_LEVELS = %i[authoritative hint conflicting].freeze
|
|
5
|
+
|
|
6
|
+
TypeFact = Data.define(:subject, :types, :trust, :source, :complete) do
|
|
7
|
+
def initialize(subject:, types:, source:, trust: :hint, complete: false)
|
|
8
|
+
subject = subject.to_s
|
|
9
|
+
raise ArgumentError, 'type fact subject must not be empty' if subject.empty?
|
|
10
|
+
|
|
11
|
+
types = Array(types).map(&:to_s).reject(&:empty?).uniq.sort.freeze
|
|
12
|
+
raise ArgumentError, 'type fact requires at least one type' if types.empty?
|
|
13
|
+
|
|
14
|
+
trust = trust.to_sym
|
|
15
|
+
raise ArgumentError, "invalid type fact trust: #{trust.inspect}" unless TYPE_FACT_TRUST_LEVELS.include?(trust)
|
|
16
|
+
|
|
17
|
+
source = source.to_s
|
|
18
|
+
raise ArgumentError, 'type fact source must not be empty' if source.empty?
|
|
19
|
+
|
|
20
|
+
super(subject: subject.freeze, types: types, trust: trust, source: source.freeze, complete: complete == true)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def safe_for_resolution?
|
|
24
|
+
trust == :authoritative && complete
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{
|
|
29
|
+
'subject' => subject,
|
|
30
|
+
'types' => types,
|
|
31
|
+
'trust' => trust.to_s,
|
|
32
|
+
'source' => source,
|
|
33
|
+
'complete' => complete
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module TypeProvider
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def profile
|
|
42
|
+
AnalyzerProfile.new(
|
|
43
|
+
name: 'none', kind: :type, soundness: :hint,
|
|
44
|
+
description: 'No external type provider is enabled; core analysis remains independent.', version: '1',
|
|
45
|
+
assumptions: ['type facts are optional', 'hints never refute a target']
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def facts(_project)
|
|
50
|
+
[]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/necropsy/version.rb
CHANGED