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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. metadata +85 -1
@@ -0,0 +1,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class WhyNotExplanation
5
+ SCHEMA_VERSION = 'necropsy.why-not.v1'
6
+ ITEM_LIMIT = 100
7
+ RESOLUTIONS_PER_SITE_LIMIT = 20
8
+ NESTED_DEPTH_LIMIT = 12
9
+ STRING_BYTES_LIMIT = 4_096
10
+ BOUNDS_KEY = '_bounds'
11
+ RESOLUTION_STATUSES = %w[complete partial unknown unrecorded].freeze
12
+ RESOLUTION_BLOCKERS = %i[unknown_dispatch partial_dispatch resolution_conflict resolution_invalid].freeze
13
+
14
+ def initialize(report)
15
+ @report = report
16
+ @graph = report.graph
17
+ end
18
+
19
+ def call(node)
20
+ finding = report.finding_for_definition(node.graph_id)
21
+ blockers = canonical_sort(finding ? finding.blockers : graph.matching_blockers(node))
22
+ sites = incoming_call_sites(node)
23
+ site_records = sites.to_h { |site| [site.call_site_id, graph.resolution_records(site.call_site_id)] }
24
+ rejections = target_rejections(node, site_records)
25
+ definitions = graph.definitions_for(node.symbol_id)
26
+ state = state(node, finding)
27
+ risks = risk_flags(node, definitions, blockers)
28
+ assumption_values = assumptions(site_records.values.flatten)
29
+ suggestions = suggested_next_evidence(finding, blockers, sites)
30
+
31
+ payload = {
32
+ 'schema_version' => SCHEMA_VERSION,
33
+ 'status' => 'why_not',
34
+ 'state' => state,
35
+ 'action' => action(state, risks),
36
+ 'risk_flags' => risks,
37
+ 'classification' => finding&.classification&.to_s,
38
+ 'confidence' => finding&.confidence&.to_s,
39
+ 'artifact_context' => artifact_context(node),
40
+ 'physical_definition' => node.to_h,
41
+ 'same_name_definitions' => definitions.first(ITEM_LIMIT).map(&:to_h),
42
+ 'same_name_definition_summary' => bounded_summary(definitions.length, ITEM_LIMIT),
43
+ 'reachability_or_absence' => reachability_or_absence(node, sites, blockers),
44
+ 'incoming_call_sites_examined' => sites.first(ITEM_LIMIT).map do |site|
45
+ call_site_payload(site, node, site_records.fetch(site.call_site_id))
46
+ end,
47
+ 'incoming_call_site_summary' => bounded_summary(sites.length, ITEM_LIMIT),
48
+ 'resolution_status' => resolution_status(site_records),
49
+ 'target_rejection_reasons' => rejections.first(ITEM_LIMIT),
50
+ 'target_rejection_summary' => bounded_summary(rejections.length, ITEM_LIMIT),
51
+ 'blockers' => blockers.first(ITEM_LIMIT).map(&:to_h),
52
+ 'blocker_summary' => bounded_summary(blockers.length, ITEM_LIMIT),
53
+ 'unknown_or_partial_blockers' => bounded_blockers(
54
+ blockers.select { |blocker| resolution_blocker?(blocker) }
55
+ ),
56
+ 'unknown_or_partial_blocker_summary' => bounded_summary(
57
+ blockers.count { |blocker| resolution_blocker?(blocker) }, ITEM_LIMIT
58
+ ),
59
+ 'world_and_root_policy' => world_and_root_policy(node),
60
+ 'non_ruby_matches' => bounded_blockers(
61
+ blockers.select { |blocker| blocker.kind == :unparsed_external_reference }
62
+ ),
63
+ 'non_ruby_match_summary' => bounded_summary(
64
+ blockers.count { |blocker| blocker.kind == :unparsed_external_reference }, ITEM_LIMIT
65
+ ),
66
+ 'parse_and_analyzer_failures' => failure_payload(node, blockers),
67
+ 'enabled_rules_and_types' => enabled_rules_and_types,
68
+ 'assumptions' => bounded_values(assumption_values),
69
+ 'assumption_summary' => bounded_summary(assumption_values.length, ITEM_LIMIT),
70
+ 'suggested_next_evidence' => suggestions.first(ITEM_LIMIT),
71
+ 'suggested_next_evidence_summary' => bounded_summary(suggestions.length, ITEM_LIMIT),
72
+ 'limits' => {
73
+ 'items_per_collection' => ITEM_LIMIT,
74
+ 'resolutions_per_call_site' => RESOLUTIONS_PER_SITE_LIMIT
75
+ }
76
+ }
77
+ bounded_payload(payload)
78
+ end
79
+
80
+ private
81
+
82
+ attr_reader :report, :graph
83
+
84
+ def state(node, finding)
85
+ return 'blocked' if finding&.classification == :blocked
86
+ return 'test_only' if finding&.classification == :test_only_reachable
87
+ return 'candidate' if finding
88
+ return 'runtime_reachable' if report.reachability.runtime_paths.key?(node.graph_id)
89
+ return 'external_reachable' if report.reachability.external_paths.key?(node.graph_id)
90
+ return 'test_only' if report.reachability.test_paths.key?(node.graph_id)
91
+
92
+ 'not_classified'
93
+ end
94
+
95
+ def action(state, risks)
96
+ case state
97
+ when 'runtime_reachable', 'external_reachable' then 'keep'
98
+ when 'test_only' then 'observe'
99
+ when 'blocked' then 'review'
100
+ when 'candidate' then risks.empty? ? 'verify' : 'review'
101
+ else 'verify'
102
+ end
103
+ end
104
+
105
+ def risk_flags(node, definitions, blockers)
106
+ flags = []
107
+ flags << 'public_or_protected_visibility' if %i[public protected].include?(node.visibility)
108
+ flags << 'generated_method' unless %i[def defs define_method].include?(node.defined_via)
109
+ flags << 'duplicate_or_redefinition' if definitions.length > 1
110
+ flags << 'no_owner' if node.owner.nil? || node.owner.empty?
111
+ flags << 'test_definition' if node.test
112
+ flags << 'dynamic_owner' if graph.class_info(node.owner)&.dynamic
113
+ flags << 'analysis_incomplete' if blockers.any?
114
+ flags.sort
115
+ end
116
+
117
+ def incoming_call_sites(node)
118
+ sites = graph.call_sites_for_message(node.name) + graph.call_sites_resolving_definition(node.graph_id)
119
+ sites.uniq.sort_by { |site| [site.file.to_s, site.line.to_i, site.call_site_id.to_s] }
120
+ end
121
+
122
+ def call_site_payload(site, node, records)
123
+ displayed = records.first(RESOLUTIONS_PER_SITE_LIMIT)
124
+ {
125
+ 'call_site' => bounded_call_site(site),
126
+ 'resolution_statuses' => records.empty? ? ['unrecorded'] : records.map { |record| record.resolution.status.to_s }.uniq.sort,
127
+ 'resolutions' => displayed.map { |record| examined_resolution(record, node) },
128
+ 'resolution_summary' => bounded_summary(records.length, RESOLUTIONS_PER_SITE_LIMIT)
129
+ }
130
+ end
131
+
132
+ def examined_resolution(record, node)
133
+ resolution = record.resolution
134
+ rejected = canonical_sort(resolution.rejected_targets)
135
+ {
136
+ 'producer' => record.producer,
137
+ 'producer_version' => record.producer_version,
138
+ 'status' => resolution.status.to_s,
139
+ 'matches_physical_definition' => resolution.target_definition_ids.include?(node.graph_id),
140
+ 'target_definition_ids' => resolution.target_definition_ids.first(ITEM_LIMIT),
141
+ 'target_definition_summary' => bounded_summary(resolution.target_definition_ids.length, ITEM_LIMIT),
142
+ 'unknown_scope' => resolution.unknown_scope&.to_h,
143
+ 'rejected_targets' => rejected.first(ITEM_LIMIT).map(&:to_h),
144
+ 'rejected_target_summary' => bounded_summary(rejected.length, ITEM_LIMIT),
145
+ 'evidence_ids' => resolution.evidence_ids.first(ITEM_LIMIT),
146
+ 'evidence_summary' => bounded_summary(resolution.evidence_ids.length, ITEM_LIMIT),
147
+ 'assumptions' => record.assumptions.first(ITEM_LIMIT),
148
+ 'assumption_summary' => bounded_summary(record.assumptions.length, ITEM_LIMIT)
149
+ }
150
+ end
151
+
152
+ def bounded_call_site(site)
153
+ payload = site.to_h.except('metadata')
154
+ metadata = site.metadata
155
+ receiver_candidates = Array(metadata['receiver_candidates'] || metadata[:receiver_candidates])
156
+ payload['metadata'] = {
157
+ 'original_message' => metadata['original_message'] || metadata[:original_message],
158
+ 'implicit_from' => metadata['implicit_from'] || metadata[:implicit_from],
159
+ 'receiver_candidates' => receiver_candidates.first(ITEM_LIMIT),
160
+ 'receiver_candidate_summary' => bounded_summary(receiver_candidates.length, ITEM_LIMIT)
161
+ }.compact
162
+ payload
163
+ end
164
+
165
+ def resolution_status(resolutions)
166
+ counts = RESOLUTION_STATUSES.to_h { |status| [status, 0] }
167
+ resolutions.each_value do |records|
168
+ if records.empty?
169
+ counts['unrecorded'] += 1
170
+ else
171
+ records.each { |record| counts[record.resolution.status.to_s] += 1 }
172
+ end
173
+ end
174
+ counts
175
+ end
176
+
177
+ def target_rejections(node, resolutions)
178
+ values = resolutions.flat_map do |call_site_id, records|
179
+ records.flat_map do |record|
180
+ record.resolution.rejected_targets.filter_map do |target|
181
+ next unless target.definition_id == node.graph_id
182
+
183
+ target.to_h.merge('call_site_id' => call_site_id, 'producer' => record.producer)
184
+ end
185
+ end
186
+ end
187
+ canonical_sort(values)
188
+ end
189
+
190
+ def resolution_blocker?(blocker)
191
+ RESOLUTION_BLOCKERS.include?(blocker.kind.to_sym)
192
+ end
193
+
194
+ def world_and_root_policy(node)
195
+ policy = graph.observation.fetch('world_policy', { 'world' => 'unknown', 'load_roots' => 'unknown' })
196
+ roots = canonical_sort(graph.entry_points.select { |root| root.definition_id == node.graph_id })
197
+ root_rules = graph.entry_points.map { |root| root.reason.to_s }.uniq.sort
198
+ {
199
+ 'world' => policy.fetch('world'),
200
+ 'load_roots' => policy.fetch('load_roots'),
201
+ 'matching_roots' => roots.first(ITEM_LIMIT).map(&:to_h),
202
+ 'matching_root_summary' => bounded_summary(roots.length, ITEM_LIMIT),
203
+ 'enabled_root_rules' => root_rules.first(ITEM_LIMIT),
204
+ 'enabled_root_rule_summary' => bounded_summary(root_rules.length, ITEM_LIMIT),
205
+ 'reachable_domains' => reachable_domains(node)
206
+ }
207
+ end
208
+
209
+ def reachable_domains(node)
210
+ id = node.graph_id
211
+ { 'runtime' => report.reachability.runtime_paths, 'external' => report.reachability.external_paths,
212
+ 'test' => report.reachability.test_paths }.filter_map { |domain, paths| domain if paths.key?(id) }
213
+ end
214
+
215
+ def reachability_or_absence(node, sites, blockers)
216
+ domain = %i[runtime external test].find { |candidate| report.reachability.witness(node.graph_id, kind: candidate) }
217
+ unless domain
218
+ return {
219
+ 'kind' => 'absence',
220
+ 'runtime_witness' => false,
221
+ 'external_witness' => false,
222
+ 'examined_incoming_call_sites' => sites.length,
223
+ 'matching_blockers' => blockers.length
224
+ }
225
+ end
226
+
227
+ path = report.reachability.witness(node.graph_id, kind: domain)
228
+ root_id = path.first
229
+ roots = canonical_sort(graph.entry_points.select do |root|
230
+ root.definition_id == root_id && root.domain == domain
231
+ end)
232
+ {
233
+ 'kind' => 'witness',
234
+ 'domain' => domain.to_s,
235
+ 'definition_ids' => path.first(ITEM_LIMIT),
236
+ 'path_summary' => bounded_summary(path.length, ITEM_LIMIT),
237
+ 'root' => roots.first&.to_h,
238
+ 'path_length' => path.length
239
+ }
240
+ end
241
+
242
+ def failure_payload(node, blockers)
243
+ parse_blockers = blockers.select { |blocker| blocker.kind == :parse_incomplete }
244
+ affected_files = parse_blockers.filter_map { |blocker| blocker.metadata['file'] }.uniq
245
+ affected_files << node.file if graph.file_statuses.fetch(node.file, :complete) != :complete
246
+ parse_failures = canonical_sort(graph.source_errors.select { |error| affected_files.include?(error.file) })
247
+ analyzer_failures = blockers.select { |blocker| blocker.kind == :analyzer_failure }
248
+ {
249
+ 'parse_failures' => parse_failures.first(ITEM_LIMIT).map(&:to_h),
250
+ 'parse_failure_summary' => bounded_summary(parse_failures.length, ITEM_LIMIT),
251
+ 'parse_blockers' => parse_blockers.first(ITEM_LIMIT).map(&:to_h),
252
+ 'parse_blocker_summary' => bounded_summary(parse_blockers.length, ITEM_LIMIT),
253
+ 'analyzer_failures' => analyzer_failures.first(ITEM_LIMIT).map(&:to_h),
254
+ 'analyzer_failure_summary' => bounded_summary(analyzer_failures.length, ITEM_LIMIT)
255
+ }
256
+ end
257
+
258
+ def artifact_context(node)
259
+ policy = graph.observation.fetch('world_policy', {})
260
+ source_snapshot = report.source_snapshot || {
261
+ 'status' => 'unavailable', 'sha256' => 'unavailable', 'reason' => 'project_context_unavailable'
262
+ }
263
+ {
264
+ 'tool_version' => Necropsy::VERSION,
265
+ 'source_digest' => source_snapshot.fetch('sha256'),
266
+ 'source_snapshot' => source_snapshot,
267
+ 'definition_body_digest' => node.body_digest || 'unavailable',
268
+ 'configuration_sha256' => policy.fetch('configuration_sha256', 'unavailable')
269
+ }
270
+ end
271
+
272
+ def enabled_rules_and_types
273
+ profiles = canonical_sort(graph.profiles)
274
+ types = profiles.select { |profile| profile.kind.to_s == 'type' }
275
+ {
276
+ 'analyzers' => profiles.first(ITEM_LIMIT).map(&:to_h),
277
+ 'analyzer_summary' => bounded_summary(profiles.length, ITEM_LIMIT),
278
+ 'resolution_producers' => resolution_producers.first(ITEM_LIMIT),
279
+ 'resolution_producer_summary' => bounded_summary(resolution_producers.length, ITEM_LIMIT),
280
+ 'root_rules' => root_rules.first(ITEM_LIMIT),
281
+ 'root_rule_summary' => bounded_summary(root_rules.length, ITEM_LIMIT),
282
+ 'type_providers' => types.first(ITEM_LIMIT).map(&:to_h),
283
+ 'type_provider_summary' => bounded_summary(types.length, ITEM_LIMIT)
284
+ }
285
+ end
286
+
287
+ def resolution_producers
288
+ graph.resolution_records.map(&:producer).uniq.sort
289
+ end
290
+
291
+ def root_rules
292
+ graph.entry_points.map { |root| root.reason.to_s }.uniq.sort
293
+ end
294
+
295
+ def assumptions(records)
296
+ (graph.profiles.flat_map(&:assumptions) + records.flat_map(&:assumptions)).uniq.sort
297
+ end
298
+
299
+ def suggested_next_evidence(finding, blockers, sites)
300
+ suggestions = blockers.map { |blocker| blocker_suggestion(blocker) }
301
+ suggestions << state_suggestion(finding, sites) if suggestions.empty?
302
+ canonical_sort(suggestions.compact.uniq { |suggestion| BoundedCanonicalizer.dump(suggestion) })
303
+ end
304
+
305
+ def blocker_suggestion(blocker)
306
+ details = blocker.metadata
307
+ location = [details['file'], details['line']].compact.join(':')
308
+ {
309
+ 'kind' => blocker.suggested_action.to_s,
310
+ 'details' => [blocker.reason, location.empty? ? nil : "Inspect #{location}"].compact.join('. ')
311
+ }
312
+ end
313
+
314
+ def state_suggestion(finding, sites)
315
+ if finding&.classification == :test_only_reachable
316
+ { 'kind' => 'observe_runtime', 'details' => 'Exercise the definition through a representative runtime entry point.' }
317
+ elsif sites.empty?
318
+ { 'kind' => 'find_callers', 'details' => 'Add an explicit root/caller or representative runtime observation.' }
319
+ else
320
+ { 'kind' => 'observe_call_sites', 'details' => 'Capture runtime receiver and target identities for the examined call sites.' }
321
+ end
322
+ end
323
+
324
+ def bounded_blockers(values)
325
+ values.first(ITEM_LIMIT).map(&:to_h)
326
+ end
327
+
328
+ def bounded_values(values)
329
+ values.first(ITEM_LIMIT)
330
+ end
331
+
332
+ def bounded_summary(total, limit)
333
+ returned = [total, limit].min
334
+ { 'total' => total, 'returned' => returned, 'truncated' => total - returned }
335
+ end
336
+
337
+ def canonical_sort(values)
338
+ values.sort_by do |value|
339
+ payload = value.respond_to?(:to_h) ? value.to_h : value
340
+ BoundedCanonicalizer.dump(bounded_payload(payload))
341
+ end
342
+ end
343
+
344
+ def bounded_payload(value, depth = 0)
345
+ return { '_truncated' => 'depth_limit' } if depth >= NESTED_DEPTH_LIMIT
346
+
347
+ value = value.to_h if !value.is_a?(Hash) && !value.is_a?(Array) && value.respond_to?(:to_h)
348
+ case value
349
+ when Hash then bounded_hash(value, depth)
350
+ when Array then value.first(ITEM_LIMIT).map { |entry| bounded_payload(entry, depth + 1) }
351
+ when String then bounded_string(value)
352
+ when Numeric, TrueClass, FalseClass, NilClass then value
353
+ else bounded_string(value.to_s)
354
+ end
355
+ end
356
+
357
+ def bounded_hash(value, depth)
358
+ entries = resolved_hash_entries(value)
359
+ value_bounds = {}
360
+ entries.first(ITEM_LIMIT).each_with_object({}) do |(key, entry), result|
361
+ result[key] = bounded_payload(entry, depth + 1)
362
+ value_bounds[key] = { 'collection' => bounded_summary(entry.length, ITEM_LIMIT) } if entry.is_a?(Array)
363
+ if entry.is_a?(String) && entry.bytesize > STRING_BYTES_LIMIT
364
+ value_bounds[key] ||= {}
365
+ value_bounds[key]['bytes'] = { 'total' => entry.bytesize, 'returned' => STRING_BYTES_LIMIT }
366
+ end
367
+ end.tap do |result|
368
+ bounds = {}
369
+ bounds['fields'] = bounded_summary(entries.length, ITEM_LIMIT) if entries.length > ITEM_LIMIT
370
+ bounds['values'] = value_bounds unless value_bounds.empty?
371
+ result[BOUNDS_KEY] = bounds unless bounds.empty?
372
+ end
373
+ end
374
+
375
+ def resolved_hash_entries(value)
376
+ entries = value.map do |key, entry|
377
+ identity = hash_key_identity(key)
378
+ [bounded_key(key), identity, BoundedCanonicalizer.dump(bounded_payload(entry)), entry]
379
+ end
380
+ counts = entries.each_with_object(Hash.new(0)) { |(key, _identity, _value, _entry), result| result[key] += 1 }
381
+ occupied = entries.to_h { |key, _identity, _value, _entry| [key, true] }
382
+ occupied[BOUNDS_KEY] = true
383
+
384
+ entries.sort_by { |key, identity, entry_identity, _entry| [key, identity, entry_identity] }.map do |key, identity, _entry_identity, entry|
385
+ resolved = if counts[key] == 1 && key != BOUNDS_KEY
386
+ key
387
+ else
388
+ collision_safe_key(key, identity, occupied)
389
+ end
390
+ occupied[resolved] = true
391
+ [resolved, entry]
392
+ end.sort_by(&:first)
393
+ end
394
+
395
+ def hash_key_identity(key)
396
+ type = key.class.name || key.class.to_s
397
+ value = case key
398
+ when String then key.b
399
+ when Symbol then key.to_s.b
400
+ else BoundedCanonicalizer.dump(bounded_payload(key)).b
401
+ end
402
+ "#{type}\0".b + value
403
+ end
404
+
405
+ def collision_safe_key(key, identity, occupied)
406
+ digest = Digest::SHA256.hexdigest(identity)
407
+ attempt = 0
408
+ loop do
409
+ suffix = attempt.zero? ? "[#{digest}]" : "[#{digest}:#{attempt}]"
410
+ candidate = bounded_key("#{key}#{suffix}")
411
+ return candidate unless occupied.key?(candidate)
412
+
413
+ attempt += 1
414
+ end
415
+ end
416
+
417
+ def bounded_string(value)
418
+ normalized = value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
419
+ return normalized if normalized.bytesize <= STRING_BYTES_LIMIT
420
+
421
+ suffix = "\u2026:#{Digest::SHA256.hexdigest(value.b)}"
422
+ "#{utf8_prefix(normalized, STRING_BYTES_LIMIT - suffix.bytesize)}#{suffix}"
423
+ end
424
+
425
+ def bounded_key(value)
426
+ bounded_string(value.to_s)
427
+ end
428
+
429
+ def utf8_prefix(value, byte_limit)
430
+ prefix = value.byteslice(0, byte_limit)
431
+ prefix = prefix.byteslice(0, prefix.bytesize - 1) until prefix.valid_encoding?
432
+
433
+ prefix
434
+ end
435
+ end
436
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class WhyNotRenderer
5
+ def initialize(payload)
6
+ @payload = payload
7
+ end
8
+
9
+ def render
10
+ lines = heading
11
+ append_definitions(lines)
12
+ append_policy(lines)
13
+ append_call_sites(lines)
14
+ append_blockers(lines)
15
+ append_special_diagnostics(lines)
16
+ append_rules(lines)
17
+ append_suggestions(lines)
18
+ lines.join("\n")
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :payload
24
+
25
+ def heading
26
+ node = payload.fetch('physical_definition')
27
+ [
28
+ "Why-not (#{payload.fetch('state')}): #{node_reference(node)}",
29
+ "Physical definition: #{node['file']}:#{node['line']} #{node['kind']} visibility=#{node['visibility']}",
30
+ "Classification: #{payload['classification'] || 'none'} confidence=#{payload['confidence'] || 'none'}",
31
+ "Action: #{payload.fetch('action')}; risks=#{list_or_none(payload.fetch('risk_flags'))}",
32
+ "Schema: #{payload.fetch('schema_version')}",
33
+ artifact_context
34
+ ]
35
+ end
36
+
37
+ def artifact_context
38
+ artifact = payload.fetch('artifact_context')
39
+ "Artifact context: tool=#{artifact['tool_version']} source=#{artifact['source_digest']} " \
40
+ "config=#{artifact['configuration_sha256']}"
41
+ end
42
+
43
+ def append_definitions(lines)
44
+ definitions = payload.fetch('same_name_definitions')
45
+ summary = payload.fetch('same_name_definition_summary')
46
+ lines << "Same-name physical definitions: #{summary.fetch('total')} (shown #{summary.fetch('returned')})"
47
+ definitions.each do |definition|
48
+ lines << " #{node_reference(definition)} at #{definition['file']}:#{definition['line']}"
49
+ end
50
+ end
51
+
52
+ def append_policy(lines)
53
+ policy = payload.fetch('world_and_root_policy')
54
+ domains = policy.fetch('reachable_domains')
55
+ lines << "World/root policy: world=#{policy['world']} load_roots=#{policy['load_roots']} " \
56
+ "reachable=#{domains.empty? ? 'none' : domains.join(',')}"
57
+ roots = policy.fetch('matching_roots')
58
+ root_summary = policy.fetch('matching_root_summary')
59
+ lines << "Matching roots: #{roots.empty? ? 'none' : roots.map { |root| root['reason'] }.join(', ')} " \
60
+ "(#{root_summary.fetch('returned')}/#{root_summary.fetch('total')})"
61
+ lines << "Enabled root rules: #{list_or_none(policy.fetch('enabled_root_rules'))}"
62
+ rule_summary = policy.fetch('enabled_root_rule_summary')
63
+ lines << " ... #{rule_summary.fetch('truncated')} root rules omitted" if rule_summary.fetch('truncated').positive?
64
+ explanation = payload.fetch('reachability_or_absence')
65
+ line = if explanation.fetch('kind') == 'witness'
66
+ "Witness: #{explanation.fetch('domain')} #{explanation.fetch('definition_ids').join(' -> ')}"
67
+ else
68
+ "Absence: no runtime/external witness; examined=#{explanation.fetch('examined_incoming_call_sites')} " \
69
+ "blockers=#{explanation.fetch('matching_blockers')}"
70
+ end
71
+ lines << line
72
+ path_summary = explanation['path_summary']
73
+ lines << " ... #{path_summary.fetch('truncated')} witness steps omitted" if path_summary&.fetch('truncated')&.positive?
74
+ end
75
+
76
+ def append_call_sites(lines)
77
+ sites = payload.fetch('incoming_call_sites_examined')
78
+ summary = payload.fetch('incoming_call_site_summary')
79
+ lines << "Incoming call sites examined: #{summary.fetch('total')} (shown #{summary.fetch('returned')})"
80
+ sites.each do |examined|
81
+ site = examined.fetch('call_site')
82
+ lines << " #{site['file']}:#{site['line']} caller=#{site['caller_id']} " \
83
+ "receiver=#{site['receiver_kind']}:#{site['receiver_name']} message=#{site['message']}"
84
+ lines << " resolution=#{examined.fetch('resolution_statuses').join(',')}"
85
+ examined.fetch('resolutions').each { |resolution| append_resolution(lines, resolution) }
86
+ resolution_summary = examined.fetch('resolution_summary')
87
+ lines << " ... #{resolution_summary.fetch('truncated')} resolutions omitted" if resolution_summary.fetch('truncated').positive?
88
+ end
89
+ counts = payload.fetch('resolution_status')
90
+ lines << "Resolution status: #{counts.map { |status, count| "#{status}=#{count}" }.join(', ')}"
91
+ lines << " ... #{summary.fetch('truncated')} call sites omitted" if summary.fetch('truncated').positive?
92
+ end
93
+
94
+ def append_resolution(lines, resolution)
95
+ match = resolution['matches_physical_definition'] ? 'matched' : 'not-matched'
96
+ lines << " #{resolution['producer']} status=#{resolution['status']} target=#{match}"
97
+ Array(resolution['rejected_targets']).each do |target|
98
+ lines << " rejected #{target['definition_id']}: #{target['reason']}"
99
+ end
100
+ rejection_summary = resolution.fetch('rejected_target_summary')
101
+ lines << " ... #{rejection_summary.fetch('truncated')} rejected targets omitted" if rejection_summary.fetch('truncated').positive?
102
+ scope = resolution['unknown_scope']
103
+ lines << " residual #{scope['scope_kind']}=#{scope['scope_value'].inspect}" if scope
104
+ end
105
+
106
+ def append_blockers(lines)
107
+ blockers = payload.fetch('blockers')
108
+ unknown_summary = payload.fetch('unknown_or_partial_blocker_summary')
109
+ blocker_summary = payload.fetch('blocker_summary')
110
+ lines << "Unknown/partial blockers: #{unknown_summary.fetch('total')}"
111
+ lines << "Matching blockers: #{blocker_summary.fetch('total')} (shown #{blocker_summary.fetch('returned')})"
112
+ blockers.each do |blocker|
113
+ metadata = blocker.fetch('metadata', {})
114
+ location = [metadata['file'], metadata['line']].compact.join(':')
115
+ location = " at #{location}" unless location.empty?
116
+ lines << " blocker #{blocker['kind']}#{location}: #{blocker['reason']}"
117
+ end
118
+ unknown_ids = unknown_blocker_keys(blockers)
119
+ payload.fetch('unknown_or_partial_blockers').each do |blocker|
120
+ next if unknown_ids.include?(blocker_key(blocker))
121
+
122
+ lines << " unknown blocker #{blocker['kind']}: #{blocker['reason']}"
123
+ end
124
+ rejections = payload.fetch('target_rejection_reasons')
125
+ rejection_summary = payload.fetch('target_rejection_summary')
126
+ lines << "Target rejection reasons: #{rejections.empty? ? 'none' : rejection_summary.fetch('total')}"
127
+ rejections.each do |rejection|
128
+ lines << " rejected #{rejection['definition_id']} by #{rejection['producer']}: #{rejection['reason']}"
129
+ end
130
+ end
131
+
132
+ def append_special_diagnostics(lines)
133
+ matches = payload.fetch('non_ruby_matches')
134
+ match_summary = payload.fetch('non_ruby_match_summary')
135
+ lines << "Non-Ruby matches: #{match_summary.fetch('total')} (shown #{match_summary.fetch('returned')})"
136
+ matches.each do |match|
137
+ metadata = match.fetch('metadata')
138
+ lines << " #{metadata['file']}:#{metadata['line']} [#{metadata['match_kind']}] #{metadata['snippet']}"
139
+ end
140
+ failures = payload.fetch('parse_and_analyzer_failures')
141
+ lines << "Parse failures: #{summary_count(failures, 'parse_failure_summary')}; " \
142
+ "parse blockers=#{summary_count(failures, 'parse_blocker_summary')}; " \
143
+ "analyzer failures=#{summary_count(failures, 'analyzer_failure_summary')}"
144
+ failures.fetch('parse_failures').each do |failure|
145
+ lines << " #{failure['file']}:#{failure['line']} [#{failure['type']}] #{failure['message']}"
146
+ end
147
+ failures.fetch('analyzer_failures').each do |failure|
148
+ lines << " analyzer #{failure['source']}: #{failure['reason']}"
149
+ end
150
+ end
151
+
152
+ def append_rules(lines)
153
+ rules = payload.fetch('enabled_rules_and_types')
154
+ analyzers = rules.fetch('analyzers').map { |profile| "#{profile['name']}(#{profile['kind']})" }
155
+ types = rules.fetch('type_providers').map { |profile| profile['name'] }
156
+ lines << "Enabled analyzers: #{list_or_none(analyzers)}"
157
+ lines << "Enabled type providers: #{list_or_none(types)}"
158
+ analyzer_summary = rules.fetch('analyzer_summary')
159
+ type_summary = rules.fetch('type_provider_summary')
160
+ lines << " ... #{analyzer_summary.fetch('truncated')} analyzers omitted" if analyzer_summary.fetch('truncated').positive?
161
+ lines << " ... #{type_summary.fetch('truncated')} type providers omitted" if type_summary.fetch('truncated').positive?
162
+ lines << "Assumptions: #{list_or_none(payload.fetch('assumptions'))}"
163
+ assumption_summary = payload.fetch('assumption_summary')
164
+ lines << " ... #{assumption_summary.fetch('truncated')} assumptions omitted" if assumption_summary.fetch('truncated').positive?
165
+ end
166
+
167
+ def append_suggestions(lines)
168
+ lines << 'Suggested next evidence:'
169
+ payload.fetch('suggested_next_evidence').each do |suggestion|
170
+ lines << " #{suggestion['kind']}: #{suggestion['details']}"
171
+ end
172
+ summary = payload.fetch('suggested_next_evidence_summary')
173
+ lines << " ... #{summary.fetch('truncated')} suggestions omitted" if summary.fetch('truncated').positive?
174
+ end
175
+
176
+ def node_reference(node)
177
+ "#{node['symbol_id']} [#{node['definition_id']}]"
178
+ end
179
+
180
+ def list_or_none(items)
181
+ items.empty? ? 'none' : items.join(', ')
182
+ end
183
+
184
+ def summary_count(payload, key)
185
+ summary = payload.fetch(key)
186
+ "#{summary.fetch('total')} (shown #{summary.fetch('returned')})"
187
+ end
188
+
189
+ def unknown_blocker_keys(blockers)
190
+ blockers.to_set { |blocker| blocker_key(blocker) }
191
+ end
192
+
193
+ def blocker_key(blocker)
194
+ [blocker['kind'], blocker['scope_kind'], blocker['scope_value'], blocker['source'], blocker['reason']]
195
+ end
196
+ end
197
+ end