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
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module ResolutionStore
|
|
8
|
+
DIAGNOSTIC_SAMPLE_LIMIT = 5
|
|
9
|
+
INVALID_SCOPE_LIMIT = 8
|
|
10
|
+
RESOLUTION_STATUSES = %i[complete partial unknown].freeze
|
|
11
|
+
EMPTY_RESOLUTION_RECORDS = [].freeze
|
|
12
|
+
|
|
13
|
+
def resolution_records(call_site_id = nil)
|
|
14
|
+
records = sorted_resolution_records
|
|
15
|
+
return records unless call_site_id
|
|
16
|
+
|
|
17
|
+
resolution_records_by_call_site.fetch(call_site_id.to_s, EMPTY_RESOLUTION_RECORDS)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolution_status_counts
|
|
21
|
+
counts = RESOLUTION_STATUSES.to_h { |status| [status, 0] }
|
|
22
|
+
sorted_resolution_records.each { |record| counts[record.resolution.status] += 1 }
|
|
23
|
+
counts.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call_sites_resolving_definition(definition_id)
|
|
27
|
+
ids = resolution_call_site_ids_by_definition.fetch(definition_id.to_s, EMPTY_RESOLUTION_RECORDS)
|
|
28
|
+
ids.flat_map { |call_site_id| @call_sites_by_id.fetch(call_site_id, EMPTY_RESOLUTION_RECORDS) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resolution_conflicts
|
|
32
|
+
compute_resolution_conflicts
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolution_issues
|
|
36
|
+
compute_resolution_issues
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def initialize_resolution_store
|
|
42
|
+
@resolution_records_by_key = {}
|
|
43
|
+
@malformed_resolution_issues = {}
|
|
44
|
+
@call_sites_by_id = call_sites.group_by(&:call_site_id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register_result_resolutions(result, refresh: true)
|
|
48
|
+
return unless result.respond_to?(:resolutions)
|
|
49
|
+
return if result.resolutions.nil?
|
|
50
|
+
|
|
51
|
+
Array(result.resolutions).each { |record| store_resolution_record(record) }
|
|
52
|
+
rebuild_resolution_derived_state if refresh
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def store_resolution_record(record)
|
|
56
|
+
record = ResolutionRecord.from_h(record) unless record.is_a?(ResolutionRecord)
|
|
57
|
+
@resolution_records_by_key[resolution_record_key(record)] = record
|
|
58
|
+
@sorted_resolution_records = nil
|
|
59
|
+
@resolution_records_by_call_site = nil
|
|
60
|
+
@resolution_call_site_ids_by_definition = nil
|
|
61
|
+
rescue KeyError, ArgumentError, NoMethodError, BoundedCanonicalizer::Error, SystemStackError => e
|
|
62
|
+
store_malformed_resolution_issue(record, e)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def store_malformed_resolution_issue(record, error)
|
|
66
|
+
payload = {
|
|
67
|
+
'kind' => 'malformed_resolution',
|
|
68
|
+
'record' => malformed_record_payload(record),
|
|
69
|
+
'error_class' => error.class.name
|
|
70
|
+
}
|
|
71
|
+
@malformed_resolution_issues[canonical_payload(payload)] = payload
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def malformed_record_payload(record)
|
|
75
|
+
value = record.respond_to?(:to_h) ? record.to_h : record
|
|
76
|
+
canonical = BoundedCanonicalizer.dump(
|
|
77
|
+
value,
|
|
78
|
+
max_depth: 32,
|
|
79
|
+
max_items: 2_000,
|
|
80
|
+
max_string_bytes: 16_384,
|
|
81
|
+
max_total_bytes: 65_536
|
|
82
|
+
)
|
|
83
|
+
{
|
|
84
|
+
'type' => record.class.name.to_s,
|
|
85
|
+
'canonical_bytes' => canonical.bytesize,
|
|
86
|
+
'canonical_sha256' => Digest::SHA256.hexdigest(canonical)
|
|
87
|
+
}
|
|
88
|
+
rescue StandardError, SystemStackError => e
|
|
89
|
+
{
|
|
90
|
+
'unavailable_type' => record.class.name.to_s,
|
|
91
|
+
'canonicalization_error' => e.class.name,
|
|
92
|
+
'canonicalization_code' => canonicalization_error_code(e)
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def canonicalization_error_code(error)
|
|
97
|
+
return 'cycle' if error.is_a?(BoundedCanonicalizer::CycleError)
|
|
98
|
+
return 'unsupported_type' if error.is_a?(BoundedCanonicalizer::UnsupportedTypeError)
|
|
99
|
+
return 'canonicalization_failure' unless error.is_a?(BoundedCanonicalizer::LimitError)
|
|
100
|
+
return 'depth_limit' if error.message.include?('depth')
|
|
101
|
+
return 'item_limit' if error.message.include?('item count')
|
|
102
|
+
return 'string_limit' if error.message.include?('string')
|
|
103
|
+
|
|
104
|
+
'size_limit'
|
|
105
|
+
rescue StandardError, SystemStackError
|
|
106
|
+
'canonicalization_failure'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolution_record_key(record)
|
|
110
|
+
resolution = record.resolution
|
|
111
|
+
scope = resolution.unknown_scope
|
|
112
|
+
rejected_targets = resolution.rejected_targets.map do |target|
|
|
113
|
+
[target.definition_id, target.reason, target.evidence_ids]
|
|
114
|
+
end
|
|
115
|
+
[
|
|
116
|
+
resolution.call_site_id,
|
|
117
|
+
record.producer.to_s,
|
|
118
|
+
record.producer_version.to_s,
|
|
119
|
+
record.assumptions,
|
|
120
|
+
resolution.status.to_s,
|
|
121
|
+
resolution.target_definition_ids,
|
|
122
|
+
rejected_targets,
|
|
123
|
+
resolution.evidence_ids,
|
|
124
|
+
scope && [scope.scope_kind.to_s, scope.scope_value, scope.match.to_s]
|
|
125
|
+
]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resolution_record_identity(record)
|
|
129
|
+
"resolution:v1:#{Digest::SHA256.hexdigest(JSON.generate(record.to_h))}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def sorted_resolution_records
|
|
133
|
+
@sorted_resolution_records ||= @resolution_records_by_key.values.sort_by do |record|
|
|
134
|
+
resolution_record_key(record)
|
|
135
|
+
end.freeze
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def resolution_records_by_call_site
|
|
139
|
+
@resolution_records_by_call_site ||= sorted_resolution_records.group_by do |record|
|
|
140
|
+
record.resolution.call_site_id
|
|
141
|
+
end.transform_values(&:freeze).freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def resolution_call_site_ids_by_definition
|
|
145
|
+
@resolution_call_site_ids_by_definition ||= begin
|
|
146
|
+
index = Hash.new { |hash, key| hash[key] = [] }
|
|
147
|
+
sorted_resolution_records.each do |record|
|
|
148
|
+
resolution = record.resolution
|
|
149
|
+
definition_ids = resolution.target_definition_ids + resolution.rejected_targets.map(&:definition_id)
|
|
150
|
+
definition_ids.uniq.each { |definition_id| index[definition_id] << resolution.call_site_id }
|
|
151
|
+
end
|
|
152
|
+
index.transform_values { |ids| ids.uniq.sort.freeze }.freeze
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def rebuild_resolution_derived_state
|
|
157
|
+
remove_blockers_matching(validate_retained: false) { |blocker| resolution_store_blocker?(blocker) }
|
|
158
|
+
resolution_blockers.each { |blocker| add_blocker(blocker) }
|
|
159
|
+
observation['call_site_resolutions'] = resolution_diagnostic
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def refresh_resolution_derived_state
|
|
163
|
+
return if @resolution_records_by_key.empty? && @malformed_resolution_issues.empty?
|
|
164
|
+
|
|
165
|
+
rebuild_resolution_derived_state
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def resolution_blockers
|
|
169
|
+
blockers = sorted_resolution_records.filter_map { |record| residual_resolution_blocker(record) }
|
|
170
|
+
blockers.concat(resolution_conflicts.flat_map { |conflict| resolution_conflict_blockers(conflict) })
|
|
171
|
+
blockers.concat(resolution_issues.flat_map { |issue| invalid_resolution_blockers(issue) })
|
|
172
|
+
blockers.sort_by { |blocker| canonical_payload(blocker.to_h) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def residual_resolution_blocker(record)
|
|
176
|
+
resolution = record.resolution
|
|
177
|
+
return if resolution.status == :complete
|
|
178
|
+
|
|
179
|
+
site = unique_call_site(resolution.call_site_id)
|
|
180
|
+
return unless site
|
|
181
|
+
|
|
182
|
+
scope = resolution.unknown_scope
|
|
183
|
+
kind = resolution.status == :partial ? :partial_dispatch : :unknown_dispatch
|
|
184
|
+
Blocker.new(
|
|
185
|
+
kind: kind,
|
|
186
|
+
scope_kind: scope.scope_kind,
|
|
187
|
+
scope_value: scope.scope_value,
|
|
188
|
+
source: record.producer,
|
|
189
|
+
reason: resolution_blocker_reason(record),
|
|
190
|
+
suggested_action: :review_receiver_flow,
|
|
191
|
+
metadata: resolution_metadata(record, site, scope)
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def resolution_blocker_reason(record)
|
|
196
|
+
resolution = record.resolution
|
|
197
|
+
if resolution.status == :partial
|
|
198
|
+
"#{record.producer} resolved known targets but left a residual target scope"
|
|
199
|
+
else
|
|
200
|
+
"#{record.producer} could not enumerate a usable target"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def resolution_metadata(record, site, scope)
|
|
205
|
+
caller = nodes.exact(site.caller_id)
|
|
206
|
+
metadata = {
|
|
207
|
+
'resolution_store' => true,
|
|
208
|
+
'call_site_id' => site.call_site_id,
|
|
209
|
+
'producer' => record.producer,
|
|
210
|
+
'producer_version' => record.producer_version,
|
|
211
|
+
'resolution_record_id' => resolution_record_identity(record),
|
|
212
|
+
'assumptions' => record.assumptions,
|
|
213
|
+
'known_target_definition_ids' => record.resolution.target_definition_ids,
|
|
214
|
+
'caller_id' => site.caller_id,
|
|
215
|
+
'caller_kind' => caller&.kind&.to_s,
|
|
216
|
+
'caller_domain' => site.test ? 'test' : 'runtime',
|
|
217
|
+
'receiver_kind' => site.receiver_kind.to_s,
|
|
218
|
+
'receiver_name' => site.receiver_name,
|
|
219
|
+
'file' => site.file,
|
|
220
|
+
'line' => site.line,
|
|
221
|
+
'scope_match' => scope.match.to_s
|
|
222
|
+
}.merge(call_site_visibility_metadata(site))
|
|
223
|
+
metadata['message'] = site.message unless %i[message symbol].include?(scope.scope_kind)
|
|
224
|
+
metadata
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def call_site_visibility_metadata(site)
|
|
228
|
+
metadata = site&.metadata || {}
|
|
229
|
+
result = {}
|
|
230
|
+
original_message = metadata['original_message'] || metadata[:original_message]
|
|
231
|
+
result['original_message'] = original_message.to_s if original_message
|
|
232
|
+
if metadata.key?('include_private') || metadata.key?(:include_private)
|
|
233
|
+
result['include_private'] = metadata.fetch('include_private') { metadata[:include_private] }
|
|
234
|
+
end
|
|
235
|
+
result
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def compute_resolution_conflicts
|
|
239
|
+
conflicts = same_producer_conflicts + comparable_resolution_conflicts
|
|
240
|
+
conflicts.uniq { |conflict| canonical_payload(conflict) }
|
|
241
|
+
.sort_by { |conflict| canonical_payload(conflict) }
|
|
242
|
+
.freeze
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def same_producer_conflicts
|
|
246
|
+
sorted_resolution_records.group_by(&:identity_key).filter_map do |identity_key, records|
|
|
247
|
+
next unless records.map { |record| record.resolution.to_h }.uniq.length > 1
|
|
248
|
+
|
|
249
|
+
conflict_payload('same_producer_divergence', records, identity_key.first)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def comparable_resolution_conflicts
|
|
254
|
+
sorted_resolution_records.group_by do |record|
|
|
255
|
+
[record.resolution.call_site_id, record.assumptions]
|
|
256
|
+
end.flat_map do |(call_site_id, _assumptions), records|
|
|
257
|
+
complete = records.select { |record| record.resolution.status == :complete }
|
|
258
|
+
partial = records.select { |record| record.resolution.status == :partial }
|
|
259
|
+
complete_conflicts(call_site_id, complete) + complete_partial_conflicts(call_site_id, complete, partial)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def complete_conflicts(call_site_id, records)
|
|
264
|
+
records.combination(2).filter_map do |left, right|
|
|
265
|
+
next if left.resolution.target_definition_ids == right.resolution.target_definition_ids
|
|
266
|
+
|
|
267
|
+
conflict_payload('complete_target_mismatch', [left, right], call_site_id)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def complete_partial_conflicts(call_site_id, complete, partial)
|
|
272
|
+
complete.product(partial).filter_map do |complete_record, partial_record|
|
|
273
|
+
outside = partial_record.resolution.target_definition_ids - complete_record.resolution.target_definition_ids
|
|
274
|
+
next if outside.empty?
|
|
275
|
+
|
|
276
|
+
conflict_payload(
|
|
277
|
+
'partial_target_outside_complete', [complete_record, partial_record], call_site_id,
|
|
278
|
+
'outside_target_definition_ids' => outside.sort
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def conflict_payload(kind, records, call_site_id, details = {})
|
|
284
|
+
{
|
|
285
|
+
'kind' => kind,
|
|
286
|
+
'call_site_id' => call_site_id,
|
|
287
|
+
'producers' => records.map(&:producer).uniq.sort,
|
|
288
|
+
'producer_keys' => records.map(&:identity_key).uniq.sort_by { |key| canonical_payload(key) },
|
|
289
|
+
'target_definition_ids' => records.flat_map do |record|
|
|
290
|
+
record.resolution.target_definition_ids
|
|
291
|
+
end.uniq.sort
|
|
292
|
+
}.merge(details)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def compute_resolution_issues
|
|
296
|
+
issues = sorted_resolution_records.flat_map do |record|
|
|
297
|
+
resolution_reference_issues(record)
|
|
298
|
+
end
|
|
299
|
+
issues.concat(@malformed_resolution_issues.values)
|
|
300
|
+
issues.uniq { |issue| canonical_payload(issue) }
|
|
301
|
+
.sort_by { |issue| canonical_payload(issue) }
|
|
302
|
+
.freeze
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def resolution_reference_issues(record)
|
|
306
|
+
resolution = record.resolution
|
|
307
|
+
issues = []
|
|
308
|
+
sites = @call_sites_by_id.fetch(resolution.call_site_id, [])
|
|
309
|
+
if sites.length != 1
|
|
310
|
+
issues << resolution_issue(
|
|
311
|
+
sites.empty? ? 'unknown_call_site' : 'ambiguous_call_site', record,
|
|
312
|
+
'matching_call_site_count' => sites.length
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
resolution.target_definition_ids.each do |definition_id|
|
|
316
|
+
unless nodes.exact(definition_id)
|
|
317
|
+
issues << resolution_issue('unknown_target_definition', record, 'definition_id' => definition_id)
|
|
318
|
+
next
|
|
319
|
+
end
|
|
320
|
+
next unless sites.one?
|
|
321
|
+
next if resolution_edge_present?(sites.first.caller_id, definition_id)
|
|
322
|
+
|
|
323
|
+
issues << resolution_issue('missing_target_edge', record, 'definition_id' => definition_id)
|
|
324
|
+
end
|
|
325
|
+
issues
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Resolution validation only needs to know whether a surviving physical
|
|
329
|
+
# edge exists. Calling the public edges_from projection here allocates a
|
|
330
|
+
# Set and projects every evidence record for every resolution, which is
|
|
331
|
+
# quadratic on repositories with many call sites. Read the physical edge
|
|
332
|
+
# index directly while still ignoring quarantined evidence IDs.
|
|
333
|
+
def resolution_edge_present?(caller_id, callee_id)
|
|
334
|
+
@edges.dig(caller_id, callee_id)&.any? { |evidence_id| evidence_record(evidence_id) }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def resolution_issue(kind, record, details)
|
|
338
|
+
{
|
|
339
|
+
'kind' => kind,
|
|
340
|
+
'call_site_id' => record.resolution.call_site_id,
|
|
341
|
+
'producer' => record.producer,
|
|
342
|
+
'producer_version' => record.producer_version
|
|
343
|
+
}.merge(details)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def resolution_conflict_blockers(conflict)
|
|
347
|
+
target_ids = Array(conflict['target_definition_ids']).map(&:to_s).uniq.sort
|
|
348
|
+
return [] if target_ids.empty?
|
|
349
|
+
|
|
350
|
+
blocker_contexts(conflict.fetch('call_site_id')).map do |context|
|
|
351
|
+
Blocker.new(
|
|
352
|
+
kind: :resolution_conflict,
|
|
353
|
+
scope_kind: :definition,
|
|
354
|
+
scope_value: target_ids,
|
|
355
|
+
source: :resolution_store,
|
|
356
|
+
reason: "Conflicting call-site resolutions: #{conflict.fetch('kind')}",
|
|
357
|
+
suggested_action: :review_analyzers,
|
|
358
|
+
metadata: resolution_store_metadata(context, include_message: false).merge(
|
|
359
|
+
'conflict_kind' => conflict.fetch('kind'),
|
|
360
|
+
'producers' => conflict.fetch('producers'),
|
|
361
|
+
'target_definition_ids' => target_ids
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def invalid_resolution_blockers(issue)
|
|
368
|
+
blocker_contexts(issue['call_site_id']).map do |context|
|
|
369
|
+
context = context.merge(visibility_mode: :unrestricted) if issue['kind'] == 'missing_target_edge'
|
|
370
|
+
scope_kind, scope_value = invalid_resolution_scope(issue, context)
|
|
371
|
+
include_message = scope_kind == :message
|
|
372
|
+
Blocker.new(
|
|
373
|
+
kind: :resolution_invalid,
|
|
374
|
+
scope_kind: scope_kind,
|
|
375
|
+
scope_value: scope_value,
|
|
376
|
+
source: :resolution_store,
|
|
377
|
+
reason: "Invalid call-site resolution reference: #{issue.fetch('kind')}",
|
|
378
|
+
suggested_action: :fix_analyzer,
|
|
379
|
+
metadata: resolution_store_metadata(context, include_message: include_message).merge(
|
|
380
|
+
'resolution_issue' => issue
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def invalid_resolution_scope(issue, context)
|
|
387
|
+
return [:definition, [issue.fetch('definition_id')]] if issue['kind'] == 'missing_target_edge'
|
|
388
|
+
|
|
389
|
+
[context.fetch(:scope_kind), context.fetch(:scope_value)]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def blocker_contexts(call_site_id)
|
|
393
|
+
sites = @call_sites_by_id.fetch(call_site_id.to_s, [])
|
|
394
|
+
return [unknown_blocker_context] if sites.empty?
|
|
395
|
+
|
|
396
|
+
grouped = sites.group_by do |site|
|
|
397
|
+
[site.test ? :test : :runtime, site.message.to_s, site_visibility_mode(site)]
|
|
398
|
+
end
|
|
399
|
+
if grouped.length <= INVALID_SCOPE_LIMIT
|
|
400
|
+
return grouped.sort_by { |key, _sites| key.map(&:to_s) }
|
|
401
|
+
.map { |key, values| site_blocker_context(key, values) }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
sites.group_by { |site| site.test ? :test : :runtime }.sort_by { |domain, _| domain.to_s }.map do |domain, values|
|
|
405
|
+
{
|
|
406
|
+
site: deterministic_site(values), domain: domain, scope_kind: :global, scope_value: '*',
|
|
407
|
+
visibility_mode: :unrestricted
|
|
408
|
+
}
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def unknown_blocker_context
|
|
413
|
+
{
|
|
414
|
+
site: nil, domain: :runtime, scope_kind: :global, scope_value: '*', visibility_mode: :unrestricted
|
|
415
|
+
}
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def site_blocker_context(key, sites)
|
|
419
|
+
domain, message, visibility_mode = key
|
|
420
|
+
{
|
|
421
|
+
site: deterministic_site(sites), domain: domain, scope_kind: :message, scope_value: message,
|
|
422
|
+
visibility_mode: visibility_mode
|
|
423
|
+
}
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def deterministic_site(sites)
|
|
427
|
+
sites.min_by { |site| [site.caller_id.to_s, site.file.to_s, site.line.to_i, site.call_site_id.to_s] }
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def site_visibility_mode(site)
|
|
431
|
+
metadata = site.metadata || {}
|
|
432
|
+
original_message = (metadata['original_message'] || metadata[:original_message])&.to_s
|
|
433
|
+
include_private = metadata.fetch('include_private') { metadata[:include_private] }
|
|
434
|
+
return :unrestricted if %w[send __send__ method].include?(original_message)
|
|
435
|
+
return :unrestricted if original_message == 'respond_to?' && include_private
|
|
436
|
+
return :unrestricted if %i[implicit self super].include?(site.receiver_kind.to_sym)
|
|
437
|
+
return :public_only if original_message == 'public_send'
|
|
438
|
+
|
|
439
|
+
:non_private
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def resolution_store_metadata(context, include_message: true)
|
|
443
|
+
site = context.fetch(:site)
|
|
444
|
+
metadata = {
|
|
445
|
+
'resolution_store' => true,
|
|
446
|
+
'call_site_id' => site&.call_site_id,
|
|
447
|
+
'caller_id' => site&.caller_id,
|
|
448
|
+
'caller_domain' => context.fetch(:domain).to_s,
|
|
449
|
+
'receiver_kind' => site&.receiver_kind&.to_s,
|
|
450
|
+
'file' => site&.file,
|
|
451
|
+
'line' => site&.line,
|
|
452
|
+
'scope_match' => 'exact'
|
|
453
|
+
}.merge(call_site_visibility_metadata(site))
|
|
454
|
+
apply_context_visibility!(metadata, context[:visibility_mode])
|
|
455
|
+
metadata['message'] = site.message if include_message && site
|
|
456
|
+
metadata
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def apply_context_visibility!(metadata, visibility_mode)
|
|
460
|
+
metadata.delete('original_message')
|
|
461
|
+
metadata.delete('include_private')
|
|
462
|
+
case visibility_mode
|
|
463
|
+
when :unrestricted
|
|
464
|
+
metadata['receiver_kind'] = 'implicit'
|
|
465
|
+
when :public_only
|
|
466
|
+
metadata['original_message'] = 'public_send'
|
|
467
|
+
when :non_private
|
|
468
|
+
metadata['receiver_kind'] = 'constant'
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def resolution_store_blocker?(blocker)
|
|
473
|
+
blocker.metadata['resolution_store'] || blocker.metadata[:resolution_store]
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def unique_call_site(call_site_id)
|
|
477
|
+
sites = @call_sites_by_id.fetch(call_site_id.to_s, [])
|
|
478
|
+
sites.first if sites.one?
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def resolution_diagnostic
|
|
482
|
+
conflicts = resolution_conflicts
|
|
483
|
+
issues = resolution_issues
|
|
484
|
+
{
|
|
485
|
+
'status_counts' => resolution_status_counts.transform_keys(&:to_s),
|
|
486
|
+
'conflict_count' => conflicts.length,
|
|
487
|
+
'conflicts' => conflicts.first(DIAGNOSTIC_SAMPLE_LIMIT),
|
|
488
|
+
'issue_count' => issues.length,
|
|
489
|
+
'issues' => issues.first(DIAGNOSTIC_SAMPLE_LIMIT)
|
|
490
|
+
}
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def canonical_payload(value)
|
|
494
|
+
BoundedCanonicalizer.dump(value)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
class GraphSelfCheck
|
|
5
|
+
class Failure < StandardError; end
|
|
6
|
+
|
|
7
|
+
def initialize(report)
|
|
8
|
+
@report = report
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
[
|
|
13
|
+
*resolution_issues,
|
|
14
|
+
*derived_site_issues,
|
|
15
|
+
*edge_issues,
|
|
16
|
+
*finding_issues
|
|
17
|
+
].sort_by { |issue| BoundedCanonicalizer.dump(issue) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate!
|
|
21
|
+
issues = call
|
|
22
|
+
return true if issues.empty?
|
|
23
|
+
|
|
24
|
+
summary = issues.first(10).map do |issue|
|
|
25
|
+
"#{issue.fetch('code')}: #{issue.fetch('subject')}"
|
|
26
|
+
end.join('; ')
|
|
27
|
+
raise Failure, "Graph self-check failed with #{issues.length} issue(s): #{summary}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :report
|
|
33
|
+
|
|
34
|
+
def resolution_issues
|
|
35
|
+
graph.resolution_records.filter_map do |record|
|
|
36
|
+
resolution = record.resolution
|
|
37
|
+
if resolution.status == :complete && resolution.unknown_scope
|
|
38
|
+
issue('complete_resolution_has_unknown_scope', resolution.call_site_id)
|
|
39
|
+
elsif graph.call_sites.none? { |site| site.call_site_id == resolution.call_site_id }
|
|
40
|
+
issue('resolution_call_site_missing', resolution.call_site_id)
|
|
41
|
+
elsif resolution.target_definition_ids.any? { |definition_id| !graph.nodes.exact(definition_id) }
|
|
42
|
+
issue('resolution_target_missing', resolution.call_site_id)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def derived_site_issues
|
|
48
|
+
graph.call_sites.filter_map do |site|
|
|
49
|
+
next unless site.metadata['derived_from_call_site_id']
|
|
50
|
+
next unless graph.resolution_records(site.call_site_id).empty?
|
|
51
|
+
|
|
52
|
+
issue('derived_call_site_has_no_resolution', site.call_site_id)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def edge_issues
|
|
57
|
+
graph.edges.filter_map do |edge|
|
|
58
|
+
issue('edge_has_no_evidence', "#{edge.caller_id}->#{edge.callee_id}") if edge.evidences.empty?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def finding_issues
|
|
63
|
+
report.actionable_candidates.filter_map do |finding|
|
|
64
|
+
blockers = graph.matching_blockers(finding.node)
|
|
65
|
+
next if finding.blockers.empty? && blockers.empty?
|
|
66
|
+
|
|
67
|
+
issue('actionable_finding_has_matching_blocker', finding.node.graph_id)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def graph
|
|
72
|
+
report.graph
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def issue(code, subject)
|
|
76
|
+
{ 'code' => code, 'subject' => subject.to_s }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|