necropsy 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1234 -62
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- metadata +106 -2
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module DefinitionIdentity
|
|
8
|
+
class CanonicalizationError < StandardError; end
|
|
9
|
+
class LimitExceeded < CanonicalizationError; end
|
|
10
|
+
class CycleError < CanonicalizationError; end
|
|
11
|
+
class UnsupportedTypeError < CanonicalizationError; end
|
|
12
|
+
|
|
13
|
+
class CanonicalDigest
|
|
14
|
+
MAX_DEPTH = 256
|
|
15
|
+
MAX_ITEMS = 1_000_000
|
|
16
|
+
MAX_SCALAR_BYTES = 64 * 1024 * 1024
|
|
17
|
+
MAX_TOTAL_BYTES = 256 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
def initialize(max_depth: MAX_DEPTH, max_items: MAX_ITEMS, max_scalar_bytes: MAX_SCALAR_BYTES,
|
|
20
|
+
max_total_bytes: MAX_TOTAL_BYTES)
|
|
21
|
+
@max_depth = positive_limit(max_depth, :max_depth)
|
|
22
|
+
@max_items = positive_limit(max_items, :max_items)
|
|
23
|
+
@max_scalar_bytes = positive_limit(max_scalar_bytes, :max_scalar_bytes)
|
|
24
|
+
@max_total_bytes = positive_limit(max_total_bytes, :max_total_bytes)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def hexdigest(value)
|
|
28
|
+
reset_state
|
|
29
|
+
output = DigestOutput.new(byte_budget)
|
|
30
|
+
process([[:value, value, 0, output]])
|
|
31
|
+
output.hexdigest
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def hexdigest_payload(values)
|
|
35
|
+
reset_state
|
|
36
|
+
output = DigestOutput.new(byte_budget)
|
|
37
|
+
output.write('[')
|
|
38
|
+
values.each_with_index do |value, index|
|
|
39
|
+
count_item!
|
|
40
|
+
output.write(',') if index.positive?
|
|
41
|
+
output.write(payload_scalar(value))
|
|
42
|
+
end
|
|
43
|
+
output.write(']')
|
|
44
|
+
output.hexdigest
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :max_depth, :max_items, :max_scalar_bytes, :max_total_bytes, :active_containers, :byte_budget
|
|
50
|
+
|
|
51
|
+
def reset_state
|
|
52
|
+
@active_containers = {}
|
|
53
|
+
@items = 0
|
|
54
|
+
@byte_budget = ByteBudget.new(max_total_bytes)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def process(stack)
|
|
58
|
+
until stack.empty?
|
|
59
|
+
action, *arguments = stack.pop
|
|
60
|
+
send("process_#{action}", stack, *arguments)
|
|
61
|
+
end
|
|
62
|
+
rescue CanonicalizationError
|
|
63
|
+
raise
|
|
64
|
+
rescue SystemStackError => e
|
|
65
|
+
raise CanonicalizationError, "Could not canonicalize definition: #{e.message}"
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
raise CanonicalizationError, "Could not canonicalize definition: #{e.class}: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def process_value(stack, value, depth, output)
|
|
71
|
+
count_item!
|
|
72
|
+
check_depth!(depth)
|
|
73
|
+
case value
|
|
74
|
+
when Prism::Node then start_node(stack, value, depth, output)
|
|
75
|
+
when Prism::Location then output.write('null')
|
|
76
|
+
when Array then start_array(stack, value, depth, output)
|
|
77
|
+
when Hash then start_hash(stack, value, depth, output)
|
|
78
|
+
when Symbol then tagged_scalar(output, 'symbol', value.to_s)
|
|
79
|
+
when String then tagged_scalar(output, 'string', value)
|
|
80
|
+
when Integer then tagged_scalar(output, 'integer', value.to_s)
|
|
81
|
+
when Float then write_float(output, value)
|
|
82
|
+
when true, false then output.write(%(["boolean",#{value}]))
|
|
83
|
+
when nil then output.write('["nil"]')
|
|
84
|
+
else raise UnsupportedTypeError, "Unsupported definition payload type: #{value.class}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def start_node(stack, node, depth, output)
|
|
89
|
+
enter_container!(node)
|
|
90
|
+
fields = node.deconstruct_keys(nil).filter_map do |key, value|
|
|
91
|
+
[key.to_s, value] unless DefinitionIdentity.send(:excluded_key?, key)
|
|
92
|
+
end.sort_by(&:first)
|
|
93
|
+
type = checked_string(node.type.to_s)
|
|
94
|
+
flags = node.send(:flags) & ~Prism::NodeFlags::NEWLINE
|
|
95
|
+
output.write("[\"node\",#{json_string(type)},#{flags},[")
|
|
96
|
+
stack << [:node_field, node, fields, 0, depth, output]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def process_node_field(stack, node, fields, index, depth, output)
|
|
100
|
+
if index >= fields.length
|
|
101
|
+
output.write(']]')
|
|
102
|
+
leave_container(node)
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
key, value = fields.fetch(index)
|
|
107
|
+
output.write(',') if index.positive?
|
|
108
|
+
output.write("[#{json_string(checked_string(key))},")
|
|
109
|
+
stack << [:node_field_complete, node, fields, index, depth, output]
|
|
110
|
+
stack << [:value, value, depth + 1, output]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def process_node_field_complete(stack, node, fields, index, depth, output)
|
|
114
|
+
output.write(']')
|
|
115
|
+
stack << [:node_field, node, fields, index + 1, depth, output]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def start_array(stack, value, depth, output)
|
|
119
|
+
enter_container!(value)
|
|
120
|
+
output.write('["array",[')
|
|
121
|
+
stack << [:array_next, value, 0, depth, output]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def process_array_next(stack, value, index, depth, output)
|
|
125
|
+
if index >= value.length
|
|
126
|
+
output.write(']]')
|
|
127
|
+
leave_container(value)
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
output.write(',') if index.positive?
|
|
132
|
+
stack << [:array_next, value, index + 1, depth, output]
|
|
133
|
+
stack << [:value, value.fetch(index), depth + 1, output]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def start_hash(stack, value, depth, output)
|
|
137
|
+
enter_container!(value)
|
|
138
|
+
stack << [:hash_next, value, value.each_pair, [], depth, output]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def process_hash_next(stack, value, iterator, pairs, depth, output)
|
|
142
|
+
key, item = iterator.next
|
|
143
|
+
pair = StringOutput.new(byte_budget)
|
|
144
|
+
pair.write('[')
|
|
145
|
+
stack << [:hash_pair_complete, value, iterator, pairs, depth, output, pair]
|
|
146
|
+
stack << [:raw, pair, ']']
|
|
147
|
+
stack << [:value, item, depth + 1, pair]
|
|
148
|
+
stack << [:raw, pair, ',']
|
|
149
|
+
stack << [:value, key, depth + 1, pair]
|
|
150
|
+
rescue StopIteration
|
|
151
|
+
output.write('["hash",[')
|
|
152
|
+
pairs.sort.each_with_index do |pair, index|
|
|
153
|
+
output.write(',') if index.positive?
|
|
154
|
+
output.write(pair)
|
|
155
|
+
end
|
|
156
|
+
output.write(']]')
|
|
157
|
+
leave_container(value)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def process_hash_pair_complete(stack, value, iterator, pairs, depth, output, pair)
|
|
161
|
+
pairs << pair.to_s
|
|
162
|
+
stack << [:hash_next, value, iterator, pairs, depth, output]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def process_raw(_stack, output, bytes)
|
|
166
|
+
output.write(bytes)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def tagged_scalar(output, tag, value)
|
|
170
|
+
output.write(%([#{json_string(tag)},#{json_string_or_fingerprint(value)}]))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def write_float(output, value)
|
|
174
|
+
raise UnsupportedTypeError, 'Definition payload contains a non-finite float' unless value.finite?
|
|
175
|
+
|
|
176
|
+
tagged_scalar(output, 'float', value.to_s)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def payload_scalar(value)
|
|
180
|
+
case value
|
|
181
|
+
when String then json_string_or_fingerprint(value)
|
|
182
|
+
when Integer then value.to_s
|
|
183
|
+
else raise UnsupportedTypeError, "Unsupported definition identity component: #{value.class}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def json_string_or_fingerprint(value)
|
|
188
|
+
checked_string(value)
|
|
189
|
+
JSON.generate(value)
|
|
190
|
+
rescue JSON::GeneratorError, EncodingError
|
|
191
|
+
JSON.generate(['invalid_string', value.encoding.name, value.bytesize, Digest::SHA256.hexdigest(value.b)])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def json_string(value)
|
|
195
|
+
JSON.generate(value)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def checked_string(value)
|
|
199
|
+
return value if value.bytesize <= max_scalar_bytes
|
|
200
|
+
|
|
201
|
+
raise LimitExceeded, "Canonical scalar exceeds maximum size #{max_scalar_bytes}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def enter_container!(value)
|
|
205
|
+
object_id = value.object_id
|
|
206
|
+
raise CycleError, "Definition payload contains a cycle at #{value.class}" if active_containers.key?(object_id)
|
|
207
|
+
|
|
208
|
+
active_containers[object_id] = true
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def leave_container(value)
|
|
212
|
+
active_containers.delete(value.object_id)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def check_depth!(depth)
|
|
216
|
+
return if depth <= max_depth
|
|
217
|
+
|
|
218
|
+
raise LimitExceeded, "Canonical payload exceeds maximum depth #{max_depth}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def count_item!
|
|
222
|
+
@items += 1
|
|
223
|
+
raise LimitExceeded, "Canonical payload exceeds maximum item count #{max_items}" if @items > max_items
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def positive_limit(value, name)
|
|
227
|
+
value = Integer(value)
|
|
228
|
+
raise ArgumentError, "#{name} must be positive" unless value.positive?
|
|
229
|
+
|
|
230
|
+
value
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
class DigestOutput
|
|
234
|
+
def initialize(budget)
|
|
235
|
+
@budget = budget
|
|
236
|
+
@digest = Digest::SHA256.new
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def write(value)
|
|
240
|
+
@budget.consume(value.bytesize)
|
|
241
|
+
@digest.update(value.b)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def hexdigest
|
|
245
|
+
@digest.hexdigest
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
class StringOutput
|
|
250
|
+
def initialize(budget)
|
|
251
|
+
@budget = budget
|
|
252
|
+
@value = String.new(encoding: Encoding::BINARY)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def write(value)
|
|
256
|
+
@budget.consume(value.bytesize)
|
|
257
|
+
@value << value.b
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def to_s
|
|
261
|
+
@value
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
class ByteBudget
|
|
266
|
+
def initialize(limit)
|
|
267
|
+
@limit = limit
|
|
268
|
+
@bytes = 0
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def consume(bytes)
|
|
272
|
+
@bytes += bytes
|
|
273
|
+
raise LimitExceeded, "Canonical payload exceeds maximum size #{@limit}" if @bytes > @limit
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require_relative 'definition_identity/canonical_digest'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module DefinitionIdentity
|
|
8
|
+
VERSION = 1
|
|
9
|
+
PREFIX = "def:v#{VERSION}".freeze
|
|
10
|
+
EXCLUDED_KEYS = %i[node_id location flags].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def body_digest(node, **limits)
|
|
15
|
+
CanonicalDigest.new(**limits).hexdigest(node)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def definition_id(kind:, symbol_id:, relative_path:, body_digest:, ordinal:)
|
|
19
|
+
ordinal = Integer(ordinal)
|
|
20
|
+
raise ArgumentError, 'definition ordinal must be positive' unless ordinal.positive?
|
|
21
|
+
|
|
22
|
+
payload = [kind.to_s, symbol_id.to_s, relative_path.to_s, body_digest.to_s, ordinal]
|
|
23
|
+
"#{PREFIX}:#{CanonicalDigest.new.hexdigest_payload(payload)}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def file_root_id(relative_path:)
|
|
27
|
+
payload = ['file_root', relative_path.to_s]
|
|
28
|
+
"#{PREFIX}:#{CanonicalDigest.new.hexdigest_payload(payload)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def excluded_key?(key)
|
|
32
|
+
name = key.to_s
|
|
33
|
+
EXCLUDED_KEYS.include?(key.to_sym) || name.end_with?('_loc', 'comment', 'comments')
|
|
34
|
+
end
|
|
35
|
+
private_class_method :excluded_key?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
class Diagnostics
|
|
8
|
+
FORMATS = %i[human json].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(report)
|
|
11
|
+
raise Error, 'Reachability witnesses are unavailable for this report' unless report.reachability
|
|
12
|
+
|
|
13
|
+
@report = report
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def why(node_id)
|
|
17
|
+
lookup = graph.nodes.lookup(node_id)
|
|
18
|
+
return with_source_incompleteness(ambiguous_payload(lookup)) if lookup.ambiguous?
|
|
19
|
+
return with_source_incompleteness(missing_payload(node_id)) if lookup.missing?
|
|
20
|
+
|
|
21
|
+
node = lookup.node
|
|
22
|
+
|
|
23
|
+
graph_id = node.graph_id
|
|
24
|
+
runtime_path = reachability.witness(graph_id)
|
|
25
|
+
return with_source_incompleteness(alive_payload(node, runtime_path, :runtime)) if runtime_path
|
|
26
|
+
|
|
27
|
+
external_path = reachability.witness(graph_id, kind: :external)
|
|
28
|
+
return with_source_incompleteness(alive_payload(node, external_path, :external)) if external_path
|
|
29
|
+
|
|
30
|
+
finding = finding_for(graph_id)
|
|
31
|
+
return with_source_incompleteness(dead_payload(node, finding)) if finding&.classification == :blocked
|
|
32
|
+
|
|
33
|
+
test_path = reachability.witness(graph_id, kind: :test)
|
|
34
|
+
return with_source_incompleteness(alive_payload(node, test_path, :test)) if test_path
|
|
35
|
+
|
|
36
|
+
with_source_incompleteness(dead_payload(node, finding))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def explain(node_id)
|
|
40
|
+
lookup = graph.nodes.lookup(node_id)
|
|
41
|
+
return with_source_incompleteness(ambiguous_payload(lookup)) if lookup.ambiguous?
|
|
42
|
+
return with_source_incompleteness(missing_payload(node_id)) if lookup.missing?
|
|
43
|
+
|
|
44
|
+
node = lookup.node
|
|
45
|
+
|
|
46
|
+
finding = finding_for(node.graph_id)
|
|
47
|
+
return with_source_incompleteness({ 'status' => 'alive', 'node' => node.to_h }) unless finding
|
|
48
|
+
|
|
49
|
+
payload = {
|
|
50
|
+
'status' => 'finding',
|
|
51
|
+
'node' => node.to_h,
|
|
52
|
+
'classification' => finding.classification.to_s,
|
|
53
|
+
'confidence' => finding.confidence.to_s,
|
|
54
|
+
'score' => finding.score,
|
|
55
|
+
'components' => finding.score_components.map(&:to_h),
|
|
56
|
+
'reasons' => finding.reasons,
|
|
57
|
+
'blockers' => finding.blockers.map(&:to_h)
|
|
58
|
+
}
|
|
59
|
+
with_source_incompleteness(payload)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def why_not(node_id)
|
|
63
|
+
lookup = graph.nodes.lookup(node_id)
|
|
64
|
+
return with_source_incompleteness(ambiguous_payload(lookup)) if lookup.ambiguous?
|
|
65
|
+
return with_source_incompleteness(missing_payload(node_id)) if lookup.missing?
|
|
66
|
+
|
|
67
|
+
WhyNotExplanation.new(report).call(lookup.node)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render(payload, format: :human)
|
|
71
|
+
normalized = format.to_sym
|
|
72
|
+
raise Error, "Diagnostics support only human or json output, not #{format}" unless FORMATS.include?(normalized)
|
|
73
|
+
|
|
74
|
+
return JSON.pretty_generate(payload) if normalized == :json
|
|
75
|
+
|
|
76
|
+
render_human(payload)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
attr_reader :report
|
|
82
|
+
|
|
83
|
+
def graph
|
|
84
|
+
report.graph
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def reachability
|
|
88
|
+
report.reachability
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def alive_payload(node, path, kind)
|
|
92
|
+
{
|
|
93
|
+
'status' => 'alive',
|
|
94
|
+
'kind' => kind.to_s,
|
|
95
|
+
'node' => node.to_h,
|
|
96
|
+
'path' => path.each_with_index.map do |node_id, index|
|
|
97
|
+
caller_id = index.positive? ? path[index - 1] : nil
|
|
98
|
+
path_step(node_id, caller_id, kind)
|
|
99
|
+
end
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def path_step(node_id, caller_id, root_domain)
|
|
104
|
+
step = { 'node' => graph.nodes.fetch(node_id).to_h }
|
|
105
|
+
roots = graph.entry_points.select do |candidate|
|
|
106
|
+
candidate.node_id == node_id && candidate.domain == root_domain
|
|
107
|
+
end.sort_by { |candidate| [candidate.reason.to_s, BoundedCanonicalizer.dump(candidate.evidence)] }
|
|
108
|
+
if roots.any?
|
|
109
|
+
step['entry_reason'] = roots.first.reason.to_s
|
|
110
|
+
step['root_domain'] = root_domain.to_s
|
|
111
|
+
step['root_provenance'] = roots.map(&:to_h)
|
|
112
|
+
end
|
|
113
|
+
return step unless caller_id
|
|
114
|
+
|
|
115
|
+
evidences = graph.edges_from(caller_id).fetch(node_id, [])
|
|
116
|
+
step['edge'] = {
|
|
117
|
+
'caller_id' => caller_id,
|
|
118
|
+
'callee_id' => node_id,
|
|
119
|
+
'caller' => graph.nodes.fetch(caller_id).to_h,
|
|
120
|
+
'callee' => graph.nodes.fetch(node_id).to_h,
|
|
121
|
+
'evidences' => evidences.map(&:to_h)
|
|
122
|
+
}
|
|
123
|
+
step
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def dead_payload(node, finding = finding_for(node.graph_id))
|
|
127
|
+
blockers = finding ? finding.blockers : graph.matching_blockers(node)
|
|
128
|
+
{
|
|
129
|
+
'status' => finding&.classification == :blocked ? 'blocked' : 'dead',
|
|
130
|
+
'node' => node.to_h,
|
|
131
|
+
'classification' => finding&.classification&.to_s,
|
|
132
|
+
'nearest_alive' => nearest_alive(node.graph_id),
|
|
133
|
+
'uncertainties' => graph.uncertainties(node.graph_id),
|
|
134
|
+
'blockers' => blockers.map(&:to_h)
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def finding_for(node_id)
|
|
139
|
+
report.findings.find { |candidate| candidate.node.graph_id == node_id }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def nearest_alive(node_id)
|
|
143
|
+
kinds = reachability.runtime_alive.to_h { |id| [id, 'runtime'] }
|
|
144
|
+
reachability.external_alive.each { |id| kinds[id] ||= 'external' }
|
|
145
|
+
reachability.test_alive.each { |id| kinds[id] ||= 'test' }
|
|
146
|
+
visited = { node_id => 0 }
|
|
147
|
+
queue = [node_id]
|
|
148
|
+
|
|
149
|
+
until queue.empty?
|
|
150
|
+
current = queue.shift
|
|
151
|
+
neighbors(current).each do |neighbor|
|
|
152
|
+
next if visited.key?(neighbor)
|
|
153
|
+
|
|
154
|
+
distance = visited.fetch(current) + 1
|
|
155
|
+
if kinds.key?(neighbor)
|
|
156
|
+
node = graph.nodes.fetch(neighbor)
|
|
157
|
+
return {
|
|
158
|
+
'node_id' => neighbor,
|
|
159
|
+
'symbol_id' => node.symbol_id,
|
|
160
|
+
'definition_id' => node.definition_id,
|
|
161
|
+
'node' => node.to_h,
|
|
162
|
+
'kind' => kinds.fetch(neighbor),
|
|
163
|
+
'distance' => distance
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
visited[neighbor] = distance
|
|
168
|
+
queue << neighbor
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def ambiguous_payload(lookup)
|
|
175
|
+
{
|
|
176
|
+
'status' => 'ambiguous',
|
|
177
|
+
'node_id' => lookup.identifier,
|
|
178
|
+
'definitions' => lookup.definitions.map do |definition|
|
|
179
|
+
{
|
|
180
|
+
'symbol_id' => definition.symbol_id,
|
|
181
|
+
'definition_id' => definition.definition_id,
|
|
182
|
+
'file' => definition.file,
|
|
183
|
+
'line' => definition.line,
|
|
184
|
+
'commands' => {
|
|
185
|
+
'why' => diagnostic_command('why', definition.definition_id),
|
|
186
|
+
'why_not' => diagnostic_command('why-not', definition.definition_id),
|
|
187
|
+
'explain' => diagnostic_command('explain', definition.definition_id)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def diagnostic_command(command, definition_id)
|
|
195
|
+
"bundle exec necropsy #{command} #{Shellwords.escape(definition_id)} --root #{Shellwords.escape(report.root)}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def neighbors(node_id)
|
|
199
|
+
outgoing = graph.edges_from(node_id).keys
|
|
200
|
+
incoming = graph.incoming_edges(node_id).map(&:caller_id)
|
|
201
|
+
(outgoing + incoming).uniq.sort
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def missing_payload(node_id)
|
|
205
|
+
terms = [node_id, node_id.split(/[.#]/).last].compact.map(&:downcase).reject { |term| term.length < 2 }
|
|
206
|
+
suggestions = graph.nodes.values.map(&:symbol_id).uniq.select do |candidate|
|
|
207
|
+
terms.any? { |term| candidate.downcase[term] }
|
|
208
|
+
end.sort.first(10)
|
|
209
|
+
{ 'status' => 'not_found', 'node_id' => node_id, 'suggestions' => suggestions }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def render_human(payload)
|
|
213
|
+
rendered = case payload.fetch('status')
|
|
214
|
+
when 'alive' then render_alive(payload)
|
|
215
|
+
when 'dead', 'blocked' then render_dead(payload)
|
|
216
|
+
when 'finding' then render_explanation(payload)
|
|
217
|
+
when 'why_not' then WhyNotRenderer.new(payload).render
|
|
218
|
+
when 'ambiguous' then render_ambiguous(payload)
|
|
219
|
+
when 'not_found' then render_missing(payload)
|
|
220
|
+
else "#{node_reference(payload['node'])} is alive and has no dead-code finding."
|
|
221
|
+
end
|
|
222
|
+
append_source_incompleteness(rendered, payload)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def render_alive(payload)
|
|
226
|
+
lines = ["Alive (#{payload.fetch('kind')}): #{node_reference(payload.fetch('node'))}"]
|
|
227
|
+
payload.fetch('path').each_with_index do |step, index|
|
|
228
|
+
suffix = step['entry_reason'] ? " entry=#{step['entry_reason']}" : ''
|
|
229
|
+
suffix += " domain=#{step['root_domain']}" if step['root_domain']
|
|
230
|
+
lines << " #{index}. #{node_reference(step.fetch('node'))}#{suffix}"
|
|
231
|
+
Array(step.dig('edge', 'evidences')).each { |evidence| lines << render_evidence(evidence) }
|
|
232
|
+
end
|
|
233
|
+
lines.join("\n")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def render_evidence(evidence)
|
|
237
|
+
metadata = evidence.fetch('metadata', {})
|
|
238
|
+
location = [metadata['file'], metadata['line']].compact.join(':')
|
|
239
|
+
suffix = location.empty? ? '' : " at #{location}"
|
|
240
|
+
" via #{evidence['analyzer']} weight=#{evidence['weight']}#{suffix}: #{evidence['details']}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def render_dead(payload)
|
|
244
|
+
heading = payload['status'] == 'blocked' ? 'Blocked' : 'Dead'
|
|
245
|
+
lines = ["#{heading}: #{node_reference(payload.fetch('node'))}"]
|
|
246
|
+
lines << "Classification: #{payload['classification']}" if payload['classification']
|
|
247
|
+
nearest = payload['nearest_alive']
|
|
248
|
+
lines << if nearest
|
|
249
|
+
"Nearest alive: #{node_reference(nearest['node'])} " \
|
|
250
|
+
"(#{nearest['kind']}, distance #{nearest['distance']})"
|
|
251
|
+
else
|
|
252
|
+
'Nearest alive: none'
|
|
253
|
+
end
|
|
254
|
+
lines << 'Uncertainties: none' if payload.fetch('uncertainties').empty?
|
|
255
|
+
payload.fetch('uncertainties').each { |message| lines << "Uncertainty: #{message}" }
|
|
256
|
+
append_blockers(lines, payload.fetch('blockers', []))
|
|
257
|
+
lines.join("\n")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def render_explanation(payload)
|
|
261
|
+
lines = [
|
|
262
|
+
"#{node_reference(payload.fetch('node'))}: #{payload['classification']}",
|
|
263
|
+
"Confidence: #{payload['confidence']} (score #{format('%.2f', payload['score'])})"
|
|
264
|
+
]
|
|
265
|
+
payload.fetch('components').each do |component|
|
|
266
|
+
lines << format(
|
|
267
|
+
' %<name>-28s %<value>+0.2f %<details>s',
|
|
268
|
+
name: component['name'], value: component['value'], details: component['details']
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
lines << format(' %<name>-28s %<value>0.2f', name: 'total', value: payload['score'])
|
|
272
|
+
append_blockers(lines, payload.fetch('blockers', []))
|
|
273
|
+
lines.join("\n")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def render_ambiguous(payload)
|
|
277
|
+
definitions = payload.fetch('definitions')
|
|
278
|
+
lines = [
|
|
279
|
+
"Ambiguous symbol ID: #{payload['node_id']}",
|
|
280
|
+
"Matched #{definitions.length} physical definitions:"
|
|
281
|
+
]
|
|
282
|
+
definitions.each do |definition|
|
|
283
|
+
lines << " #{definition['symbol_id']} [#{definition['definition_id']}] " \
|
|
284
|
+
"#{definition['file']}:#{definition['line']}"
|
|
285
|
+
lines << " why: #{definition.dig('commands', 'why')}"
|
|
286
|
+
lines << " why-not: #{definition.dig('commands', 'why_not')}"
|
|
287
|
+
lines << " explain: #{definition.dig('commands', 'explain')}"
|
|
288
|
+
end
|
|
289
|
+
lines.join("\n")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def append_blockers(lines, blockers)
|
|
293
|
+
blockers.each do |blocker|
|
|
294
|
+
metadata = blocker.fetch('metadata', {})
|
|
295
|
+
location = [metadata['file'], metadata['line']].compact.join(':')
|
|
296
|
+
caller = metadata['caller_id'] ? " caller=#{metadata['caller_id']}" : ''
|
|
297
|
+
lines << "Blocker: #{blocker['kind']} at #{location}#{caller}"
|
|
298
|
+
lines << " Scope: #{blocker['scope_kind']}=#{blocker['scope_value'].inspect} message=#{metadata['message']}"
|
|
299
|
+
lines << " Reason: #{blocker['reason']}"
|
|
300
|
+
lines << " Match: #{metadata['snippet']}" if metadata['snippet']
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def render_missing(payload)
|
|
305
|
+
lines = ["Node not found: #{payload['node_id']}"]
|
|
306
|
+
lines << "Suggestions: #{payload['suggestions'].join(', ')}" unless payload['suggestions'].empty?
|
|
307
|
+
lines.join("\n")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def node_reference(node)
|
|
311
|
+
return 'unknown definition' unless node
|
|
312
|
+
|
|
313
|
+
symbol_id = node['symbol_id'] || node['id']
|
|
314
|
+
definition_id = node['definition_id'] || node['id']
|
|
315
|
+
"#{symbol_id} [#{definition_id}]"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def with_source_incompleteness(payload)
|
|
319
|
+
return payload if graph.incomplete_files.empty?
|
|
320
|
+
|
|
321
|
+
payload.merge('source_incompleteness' => graph.source_incompleteness)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def append_source_incompleteness(rendered, payload)
|
|
325
|
+
diagnostic = payload['source_incompleteness']
|
|
326
|
+
return rendered unless diagnostic
|
|
327
|
+
|
|
328
|
+
lines = [rendered, "Incomplete source files: #{diagnostic['incomplete_files']}"]
|
|
329
|
+
diagnostic.fetch('files').each do |file|
|
|
330
|
+
errors = file.fetch('errors')
|
|
331
|
+
if errors.empty?
|
|
332
|
+
lines << " #{file['file']}:1 [#{file['status']}]"
|
|
333
|
+
else
|
|
334
|
+
errors.each do |error|
|
|
335
|
+
lines << " #{error['file']}:#{error['line']} [#{error['type']}] #{error['message']}"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
lines.join("\n")
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|