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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. 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