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