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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require_relative 'bounded_canonicalizer'
4
5
 
5
6
  module Necropsy
6
7
  CONFIDENCE_LEVELS = {
@@ -10,18 +11,345 @@ module Necropsy
10
11
  certain: 3
11
12
  }.freeze
12
13
 
13
- Node = Data.define(:id, :kind, :file, :line, :end_line, :defined_via, :owner, :name, :test, :visibility) do
14
+ UNKNOWN_SCOPE_KINDS = %i[definition symbol message owner namespace file global].freeze
15
+ UNKNOWN_SCOPE_MATCHES = %i[exact glob].freeze
16
+ RESOLUTION_STATUSES = %i[complete partial unknown].freeze
17
+ EVIDENCE_GRADES = %i[exact conservative observed heuristic].freeze
18
+ ANALYZER_KINDS = %i[static dynamic type].freeze
19
+ ANALYZER_SOUNDNESS = %i[unsound conservative partial observational hint].freeze
20
+ EVIDENCE_MAX_DETAILS_BYTES = 4_096
21
+ EVIDENCE_MAX_METADATA_DEPTH = 32
22
+ EVIDENCE_MAX_METADATA_ITEMS = 10_000
23
+ EVIDENCE_MAX_METADATA_STRING_BYTES = 65_536
24
+ EVIDENCE_MAX_METADATA_BYTES = 1_048_576
25
+ PROFILE_MAX_DESCRIPTION_BYTES = 4_096
26
+ PROFILE_MAX_VERSION_BYTES = 128
27
+ PROFILE_MAX_ASSUMPTIONS = 64
28
+ PROFILE_MAX_ASSUMPTION_BYTES = 512
29
+ ROOT_DOMAINS = %i[runtime test external].freeze
30
+ private_constant :UNKNOWN_SCOPE_KINDS, :UNKNOWN_SCOPE_MATCHES, :RESOLUTION_STATUSES, :EVIDENCE_GRADES,
31
+ :ANALYZER_KINDS, :ANALYZER_SOUNDNESS, :ROOT_DOMAINS
32
+ private_constant :EVIDENCE_MAX_DETAILS_BYTES, :EVIDENCE_MAX_METADATA_DEPTH, :EVIDENCE_MAX_METADATA_ITEMS,
33
+ :EVIDENCE_MAX_METADATA_STRING_BYTES, :EVIDENCE_MAX_METADATA_BYTES,
34
+ :PROFILE_MAX_DESCRIPTION_BYTES, :PROFILE_MAX_VERSION_BYTES, :PROFILE_MAX_ASSUMPTIONS,
35
+ :PROFILE_MAX_ASSUMPTION_BYTES
36
+
37
+ module ModelNormalization
38
+ module_function
39
+
40
+ def attributes(value, model_name)
41
+ raise ArgumentError, "#{model_name} must be loaded from a Hash" unless value.is_a?(Hash)
42
+
43
+ value.to_h { |key, item| [key.to_s, item] }
44
+ end
45
+
46
+ def identifier(value, field)
47
+ normalized = value.to_s
48
+ raise ArgumentError, "#{field} must not be empty" if normalized.empty?
49
+
50
+ normalized.freeze
51
+ end
52
+
53
+ def string_list(values, field)
54
+ list(values).map { |value| identifier(value, field) }.uniq.sort.freeze
55
+ end
56
+
57
+ def scope_value(value)
58
+ normalized = if value.is_a?(Array)
59
+ string_list(value, 'scope_value')
60
+ else
61
+ identifier(value, 'scope_value')
62
+ end
63
+ raise ArgumentError, 'scope_value must not be empty' if normalized.respond_to?(:empty?) && normalized.empty?
64
+
65
+ normalized
66
+ end
67
+
68
+ def list(values)
69
+ return [] if values.nil?
70
+
71
+ values.is_a?(Array) ? values : [values]
72
+ end
73
+
74
+ def canonical(value)
75
+ case value
76
+ when nil
77
+ 'nil'
78
+ when true, false, Numeric
79
+ "#{value.class}:#{value}"
80
+ when Symbol
81
+ "symbol:#{canonical_string(value)}"
82
+ when String
83
+ "string:#{canonical_string(value)}"
84
+ when Array
85
+ "array:[#{value.map { |item| canonical(item) }.join(',')}]"
86
+ when Hash
87
+ pairs = value.map { |key, item| [canonical(key), canonical(item)] }.sort_by(&:first)
88
+ "hash:{#{pairs.map { |key, item| "#{key}=#{item}" }.join(',')}}"
89
+ else
90
+ return canonical(value.to_h) if value.respond_to?(:to_h)
91
+
92
+ "#{value.class}:#{canonical_string(value)}"
93
+ end
94
+ end
95
+
96
+ def canonical_string(value)
97
+ string = value.to_s
98
+ "#{string.bytesize}:#{string}"
99
+ end
100
+ end
101
+ private_constant :ModelNormalization
102
+
103
+ UnknownScope = Data.define(:scope_kind, :scope_value, :match) do
104
+ def initialize(scope_kind:, scope_value:, match:)
105
+ scope_kind = scope_kind.to_sym if scope_kind.respond_to?(:to_sym)
106
+ match = match.to_sym if match.respond_to?(:to_sym)
107
+ raise ArgumentError, "invalid unknown scope kind: #{scope_kind.inspect}" unless UNKNOWN_SCOPE_KINDS.include?(scope_kind)
108
+ raise ArgumentError, "invalid unknown scope match: #{match.inspect}" unless UNKNOWN_SCOPE_MATCHES.include?(match)
109
+
110
+ scope_value = ModelNormalization.scope_value(scope_value)
111
+ super
112
+ end
113
+
114
+ def self.from_h(attributes)
115
+ data = ModelNormalization.attributes(attributes, name)
116
+ new(
117
+ scope_kind: data.fetch('scope_kind'),
118
+ scope_value: data.fetch('scope_value'),
119
+ match: data.fetch('match')
120
+ )
121
+ end
122
+
123
+ def to_h
124
+ {
125
+ 'scope_kind' => scope_kind.to_s,
126
+ 'scope_value' => scope_value,
127
+ 'match' => match.to_s
128
+ }
129
+ end
130
+ end
131
+
132
+ RejectedTarget = Data.define(:definition_id, :reason, :evidence_ids) do
133
+ def initialize(definition_id:, reason:, evidence_ids: [])
134
+ definition_id = ModelNormalization.identifier(definition_id, 'definition_id')
135
+ reason = ModelNormalization.identifier(reason, 'reason')
136
+ evidence_ids = ModelNormalization.string_list(evidence_ids, 'evidence_id')
137
+ super
138
+ end
139
+
140
+ def self.from_h(attributes)
141
+ data = ModelNormalization.attributes(attributes, name)
142
+ new(
143
+ definition_id: data.fetch('definition_id'),
144
+ reason: data.fetch('reason'),
145
+ evidence_ids: data.fetch('evidence_ids', [])
146
+ )
147
+ end
148
+
149
+ def to_h
150
+ {
151
+ 'definition_id' => definition_id,
152
+ 'reason' => reason,
153
+ 'evidence_ids' => evidence_ids
154
+ }
155
+ end
156
+ end
157
+
158
+ Resolution = Data.define(
159
+ :call_site_id,
160
+ :target_definition_ids,
161
+ :status,
162
+ :unknown_scope,
163
+ :rejected_targets,
164
+ :evidence_ids
165
+ ) do
166
+ def initialize(call_site_id:, target_definition_ids:, status:, unknown_scope: nil, rejected_targets: [],
167
+ evidence_ids: [])
168
+ call_site_id = ModelNormalization.identifier(call_site_id, 'call_site_id')
169
+ target_definition_ids = ModelNormalization.string_list(target_definition_ids, 'target_definition_id')
170
+ status = status.to_sym if status.respond_to?(:to_sym)
171
+ raise ArgumentError, "invalid resolution status: #{status.inspect}" unless RESOLUTION_STATUSES.include?(status)
172
+
173
+ unknown_scope = normalize_unknown_scope(unknown_scope)
174
+ rejected_targets = normalize_rejected_targets(rejected_targets)
175
+ evidence_ids = ModelNormalization.string_list(evidence_ids, 'evidence_id')
176
+ validate_state!(status, target_definition_ids, unknown_scope)
177
+ super
178
+ end
179
+
180
+ def self.from_h(attributes)
181
+ data = ModelNormalization.attributes(attributes, name)
182
+ new(
183
+ call_site_id: data.fetch('call_site_id'),
184
+ target_definition_ids: data.fetch('target_definition_ids', []),
185
+ status: data.fetch('status'),
186
+ unknown_scope: data['unknown_scope'],
187
+ rejected_targets: data.fetch('rejected_targets', []),
188
+ evidence_ids: data.fetch('evidence_ids', [])
189
+ )
190
+ end
191
+
192
+ def to_h
193
+ {
194
+ 'call_site_id' => call_site_id,
195
+ 'target_definition_ids' => target_definition_ids,
196
+ 'status' => status.to_s,
197
+ 'unknown_scope' => unknown_scope&.to_h,
198
+ 'rejected_targets' => rejected_targets.map(&:to_h),
199
+ 'evidence_ids' => evidence_ids
200
+ }
201
+ end
202
+
203
+ private
204
+
205
+ def normalize_unknown_scope(value)
206
+ return if value.nil?
207
+ return value if value.is_a?(UnknownScope)
208
+
209
+ UnknownScope.from_h(value)
210
+ rescue NoMethodError
211
+ raise ArgumentError, 'unknown_scope must be an UnknownScope or Hash'
212
+ end
213
+
214
+ def normalize_rejected_targets(values)
215
+ ModelNormalization.list(values).map do |value|
216
+ value.is_a?(RejectedTarget) ? value : RejectedTarget.from_h(value)
217
+ end.uniq.sort_by do |target|
218
+ [target.definition_id, target.reason, target.evidence_ids]
219
+ end.freeze
220
+ rescue NoMethodError
221
+ raise ArgumentError, 'rejected_targets must contain RejectedTarget values or Hashes'
222
+ end
223
+
224
+ def validate_state!(resolution_status, targets, scope)
225
+ if resolution_status == :complete
226
+ raise ArgumentError, 'complete resolution must not have an unknown scope' if scope
227
+
228
+ return
229
+ end
230
+
231
+ raise ArgumentError, "#{resolution_status} resolution requires an unknown scope" unless scope
232
+
233
+ if resolution_status == :partial
234
+ raise ArgumentError, 'partial resolution requires at least one target' if targets.empty?
235
+ elsif targets.any?
236
+ raise ArgumentError, 'unknown resolution must not have targets'
237
+ end
238
+ end
239
+ end
240
+
241
+ ResolutionRecord = Data.define(:resolution, :producer, :producer_version, :assumptions) do
242
+ def initialize(resolution:, producer:, producer_version: nil, assumptions: [])
243
+ resolution = normalize_resolution(resolution)
244
+ producer = ModelNormalization.identifier(producer, 'producer')
245
+ producer_version = ModelNormalization.identifier(producer_version, 'producer_version') if producer_version
246
+ assumptions = ModelNormalization.string_list(assumptions, 'assumption')
247
+ super
248
+ end
249
+
250
+ def self.from_h(attributes)
251
+ data = ModelNormalization.attributes(attributes, name)
252
+ new(
253
+ resolution: data.fetch('resolution'),
254
+ producer: data.fetch('producer'),
255
+ producer_version: data['producer_version'],
256
+ assumptions: data.fetch('assumptions', [])
257
+ )
258
+ end
259
+
260
+ def identity_key
261
+ [resolution.call_site_id, producer, producer_version, assumptions].freeze
262
+ end
263
+
264
+ def to_h
265
+ {
266
+ 'resolution' => resolution.to_h,
267
+ 'producer' => producer,
268
+ 'producer_version' => producer_version,
269
+ 'assumptions' => assumptions
270
+ }
271
+ end
272
+
273
+ private
274
+
275
+ def normalize_resolution(value)
276
+ return value if value.is_a?(Resolution)
277
+
278
+ Resolution.from_h(value)
279
+ rescue NoMethodError
280
+ raise ArgumentError, 'resolution must be a Resolution or Hash'
281
+ end
282
+ end
283
+
284
+ Node = Data.define(
285
+ :id,
286
+ :symbol_id,
287
+ :definition_id,
288
+ :body_digest,
289
+ :ordinal,
290
+ :kind,
291
+ :file,
292
+ :line,
293
+ :end_line,
294
+ :defined_via,
295
+ :owner,
296
+ :name,
297
+ :test,
298
+ :visibility
299
+ ) do
300
+ class << self
301
+ alias_method :data_new, :new
302
+
303
+ def new(*values, **attributes)
304
+ compatible_new(*values, **attributes)
305
+ end
306
+ alias_method :[], :new
307
+
308
+ private
309
+
310
+ def compatible_new(*values, **attributes)
311
+ return data_new(*values, **attributes) unless values.length == 10 && attributes.empty?
312
+
313
+ id, kind, file, line, end_line, defined_via, owner, name, test, visibility = values
314
+ data_new(
315
+ id: id, kind: kind, file: file, line: line, end_line: end_line, defined_via: defined_via,
316
+ owner: owner, name: name, test: test, visibility: visibility
317
+ )
318
+ end
319
+
320
+ private :data_new, :compatible_new
321
+ end
322
+
323
+ def initialize(id:, kind:, file:, line:, end_line:, defined_via:, owner:, name:, test:, visibility:,
324
+ symbol_id: id, definition_id: id, body_digest: nil, ordinal: 0)
325
+ super
326
+ end
327
+
14
328
  def method?
15
329
  kind != :block_entry
16
330
  end
17
331
 
332
+ def graph_id
333
+ definition_id
334
+ end
335
+
18
336
  def fingerprint(classification)
19
- Digest::SHA256.hexdigest("#{classification}:#{id}")
337
+ Digest::SHA256.hexdigest("#{classification}:#{symbol_id}")
338
+ end
339
+
340
+ alias_method :logical_fingerprint, :fingerprint
341
+
342
+ def physical_fingerprint(classification)
343
+ Digest::SHA256.hexdigest("#{classification}:#{definition_id}")
20
344
  end
21
345
 
22
346
  def to_h
23
347
  {
24
348
  'id' => id,
349
+ 'symbol_id' => symbol_id,
350
+ 'definition_id' => definition_id,
351
+ 'body_digest' => body_digest,
352
+ 'ordinal' => ordinal,
25
353
  'kind' => kind.to_s,
26
354
  'file' => file,
27
355
  'line' => line,
@@ -35,16 +363,145 @@ module Necropsy
35
363
  end
36
364
  end
37
365
 
38
- Evidence = Data.define(:analyzer, :kind, :weight, :details, :metadata) do
366
+ MethodLookup = Data.define(
367
+ :targets,
368
+ :status,
369
+ :rejected_targets,
370
+ :lookup_chain,
371
+ :reason
372
+ ) do
373
+ def initialize(targets:, status:, rejected_targets: [], lookup_chain: [], reason: nil)
374
+ status = status.to_sym
375
+ raise ArgumentError, "invalid method lookup status: #{status.inspect}" unless RESOLUTION_STATUSES.include?(status)
376
+
377
+ targets = Array(targets).uniq(&:graph_id).sort_by(&:graph_id).freeze
378
+ rejected_targets = Array(rejected_targets).uniq.sort_by do |target|
379
+ [target.definition_id, target.reason, target.evidence_ids]
380
+ end.freeze
381
+ lookup_chain = Array(lookup_chain).map(&:to_s).freeze
382
+ super
383
+ end
384
+
385
+ def complete?
386
+ status == :complete
387
+ end
388
+
389
+ def partial?
390
+ status == :partial
391
+ end
392
+
393
+ def unknown?
394
+ status == :unknown
395
+ end
396
+
397
+ def to_h
398
+ {
399
+ 'target_definition_ids' => targets.map(&:graph_id),
400
+ 'status' => status.to_s,
401
+ 'rejected_targets' => rejected_targets.map(&:to_h),
402
+ 'lookup_chain' => lookup_chain,
403
+ 'reason' => reason
404
+ }
405
+ end
406
+ end
407
+
408
+ Evidence = Data.define(
409
+ :analyzer,
410
+ :kind,
411
+ :weight,
412
+ :details,
413
+ :metadata,
414
+ :evidence_id,
415
+ :producer,
416
+ :producer_version,
417
+ :grade,
418
+ :relation,
419
+ :source,
420
+ :assumptions,
421
+ :scope
422
+ ) do
423
+ class << self
424
+ alias_method :data_new, :new
425
+
426
+ def new(*values, **attributes)
427
+ compatible_new(*values, **attributes)
428
+ end
429
+ alias_method :[], :new
430
+
431
+ private
432
+
433
+ def compatible_new(*values, **attributes)
434
+ return data_new(*values, **attributes) unless values.length == 5 && attributes.empty?
435
+
436
+ analyzer, kind, weight, details, metadata = values
437
+ data_new(analyzer: analyzer, kind: kind, weight: weight, details: details, metadata: metadata)
438
+ end
439
+
440
+ private :data_new, :compatible_new
441
+ end
442
+
443
+ def initialize(analyzer:, kind:, weight:, details:, metadata:, evidence_id: nil, producer: nil,
444
+ producer_version: nil, grade: nil, relation: nil, source: nil, assumptions: [], scope: nil)
445
+ raise ArgumentError, 'evidence analyzer must not be empty' if analyzer.to_s.empty?
446
+ raise ArgumentError, 'evidence kind must not be empty' if kind.to_s.empty?
447
+
448
+ weight = Float(weight)
449
+ raise ArgumentError, 'evidence weight must be finite' unless weight.finite?
450
+
451
+ valid_details = details.is_a?(String) && details.bytesize <= EVIDENCE_MAX_DETAILS_BYTES
452
+ raise ArgumentError, 'evidence details must be a bounded string' unless valid_details
453
+ raise ArgumentError, 'evidence metadata must be a mapping' unless metadata.is_a?(Hash)
454
+
455
+ grade = grade.to_sym if grade.respond_to?(:to_sym)
456
+ raise ArgumentError, "invalid evidence grade: #{grade.inspect}" unless grade.nil? || EVIDENCE_GRADES.include?(grade)
457
+
458
+ evidence_id = ModelNormalization.identifier(evidence_id, 'evidence_id') unless evidence_id.nil?
459
+ assumptions = ModelNormalization.string_list(assumptions, 'assumption')
460
+ if grade.nil? && provenance_present?(producer, producer_version, relation, source, assumptions, scope)
461
+ raise ArgumentError, 'evidence grade may be nil only for legacy evidence'
462
+ end
463
+ if grade && [producer, producer_version, relation, source, scope].any?(&:nil?)
464
+ raise ArgumentError, 'graded evidence requires producer, producer_version, relation, source, and scope'
465
+ end
466
+
467
+ validate_bounded_payload!(metadata: metadata, source: source, scope: scope)
468
+
469
+ super
470
+ end
471
+
39
472
  def to_h
40
473
  {
41
474
  'analyzer' => analyzer.to_s,
42
475
  'kind' => kind.to_s,
43
476
  'weight' => weight,
44
477
  'details' => details,
45
- 'metadata' => metadata
478
+ 'metadata' => metadata,
479
+ 'evidence_id' => evidence_id,
480
+ 'producer' => producer&.to_s,
481
+ 'producer_version' => producer_version&.to_s,
482
+ 'grade' => grade&.to_s,
483
+ 'relation' => relation&.to_s,
484
+ 'source' => source.respond_to?(:to_h) ? source.to_h : source,
485
+ 'assumptions' => assumptions,
486
+ 'scope' => scope.respond_to?(:to_h) ? scope.to_h : scope
46
487
  }
47
488
  end
489
+
490
+ private
491
+
492
+ def validate_bounded_payload!(value)
493
+ BoundedCanonicalizer.dump(
494
+ value,
495
+ max_depth: EVIDENCE_MAX_METADATA_DEPTH,
496
+ max_items: EVIDENCE_MAX_METADATA_ITEMS,
497
+ max_string_bytes: EVIDENCE_MAX_METADATA_STRING_BYTES,
498
+ max_total_bytes: EVIDENCE_MAX_METADATA_BYTES
499
+ )
500
+ end
501
+
502
+ def provenance_present?(producer, producer_version, relation, source, assumptions, scope)
503
+ [producer, producer_version, relation, source, scope].any? { |value| !value.nil? } || assumptions.any?
504
+ end
48
505
  end
49
506
 
50
507
  Edge = Data.define(:caller_id, :callee_id, :evidences) do
@@ -52,20 +509,77 @@ module Necropsy
52
509
  {
53
510
  'caller_id' => caller_id,
54
511
  'callee_id' => callee_id,
512
+ 'evidence_ids' => evidences.filter_map(&:evidence_id).sort,
55
513
  'evidences' => evidences.map(&:to_h)
56
514
  }
57
515
  end
58
516
  end
59
517
 
60
- EntryPoint = Data.define(:node_id, :reason) do
518
+ EdgeRelation = Data.define(:caller_id, :callee_id, :evidence_ids, :projection) do
519
+ def to_h
520
+ {
521
+ 'caller_id' => caller_id,
522
+ 'callee_id' => callee_id,
523
+ 'evidence_ids' => evidence_ids,
524
+ 'projection' => projection.to_s
525
+ }
526
+ end
527
+ end
528
+
529
+ Root = Data.define(:definition_id, :domain, :reason, :evidence) do
530
+ class << self
531
+ alias_method :data_new, :new
532
+
533
+ def new(*values, **attributes)
534
+ return data_new(node_id: values[0], reason: values[1]) if values.length == 2 && attributes.empty?
535
+
536
+ data_new(*values, **attributes)
537
+ end
538
+ alias_method :[], :new
539
+
540
+ private :data_new
541
+ end
542
+
543
+ def initialize(reason:, definition_id: nil, node_id: nil, domain: nil, evidence: nil)
544
+ definition_id ||= node_id
545
+ definition_id = ModelNormalization.identifier(definition_id, 'definition_id')
546
+ reason = ModelNormalization.identifier(reason, 'reason').to_sym
547
+ domain ||= reason == :test_suite ? :test : :runtime
548
+ domain = domain.to_sym if domain.respond_to?(:to_sym)
549
+ raise ArgumentError, "invalid root domain: #{domain.inspect}" unless ROOT_DOMAINS.include?(domain)
550
+
551
+ evidence ||= { 'type' => 'entry_point', 'reason' => reason.to_s }
552
+ evidence = evidence.to_h if evidence.respond_to?(:to_h)
553
+ raise ArgumentError, 'root evidence must be a Hash' unless evidence.is_a?(Hash)
554
+
555
+ super(definition_id: definition_id, domain: domain, reason: reason, evidence: evidence.freeze)
556
+ end
557
+
558
+ alias_method :node_id, :definition_id
559
+
61
560
  def test?
62
- reason == :test_suite
561
+ domain == :test
562
+ end
563
+
564
+ def runtime?
565
+ domain == :runtime
566
+ end
567
+
568
+ def external?
569
+ domain == :external
63
570
  end
64
571
 
65
572
  def to_h
66
- { 'node_id' => node_id, 'reason' => reason.to_s }
573
+ {
574
+ 'node_id' => node_id,
575
+ 'definition_id' => definition_id,
576
+ 'domain' => domain.to_s,
577
+ 'reason' => reason.to_s,
578
+ 'evidence' => evidence
579
+ }
67
580
  end
68
581
  end
582
+ EntryPoint = Root
69
583
 
70
584
  ClassInfo = Data.define(
71
585
  :id,
@@ -77,8 +591,41 @@ module Necropsy
77
591
  :includes,
78
592
  :prepends,
79
593
  :extends,
594
+ :singleton_includes,
595
+ :singleton_prepends,
80
596
  :dynamic
81
597
  ) do
598
+ class << self
599
+ alias_method :data_new, :new
600
+
601
+ def new(*values, **attributes)
602
+ return data_new(*values, **attributes) unless values.length == 10 && attributes.empty?
603
+
604
+ id, kind, file, line, superclass, superclass_candidates, includes, prepends, extends, dynamic = values
605
+ data_new(
606
+ id: id,
607
+ kind: kind,
608
+ file: file,
609
+ line: line,
610
+ superclass: superclass,
611
+ superclass_candidates: superclass_candidates,
612
+ includes: includes,
613
+ prepends: prepends,
614
+ extends: extends,
615
+ dynamic: dynamic
616
+ )
617
+ end
618
+ alias_method :[], :new
619
+
620
+ private :data_new
621
+ end
622
+
623
+ def initialize(id:, kind:, file:, line:, superclass:, superclass_candidates:, includes:, prepends:, extends:,
624
+ dynamic:, singleton_includes: [], singleton_prepends: [])
625
+ singleton_includes = extends if singleton_includes.empty? && !extends.empty?
626
+ super
627
+ end
628
+
82
629
  def to_h
83
630
  {
84
631
  'id' => id,
@@ -90,12 +637,15 @@ module Necropsy
90
637
  'includes' => includes,
91
638
  'prepends' => prepends,
92
639
  'extends' => extends,
640
+ 'singleton_includes' => singleton_includes,
641
+ 'singleton_prepends' => singleton_prepends,
93
642
  'dynamic' => dynamic
94
643
  }
95
644
  end
96
645
  end
97
646
 
98
647
  CallSite = Data.define(
648
+ :call_site_id,
99
649
  :caller_id,
100
650
  :message,
101
651
  :receiver_kind,
@@ -106,9 +656,52 @@ module Necropsy
106
656
  :dynamic,
107
657
  :metadata
108
658
  ) do
659
+ class << self
660
+ alias_method :data_new, :new
661
+
662
+ def new(*values, **attributes)
663
+ compatible_new(*values, **attributes)
664
+ end
665
+ alias_method :[], :new
666
+
667
+ private
668
+
669
+ def compatible_new(*values, **attributes)
670
+ return data_new(*values, **attributes) unless values.length == 9 && attributes.empty?
671
+
672
+ caller_id, message, receiver_kind, receiver_name, file, line, test, dynamic, metadata = values
673
+ data_new(
674
+ caller_id: caller_id, message: message, receiver_kind: receiver_kind, receiver_name: receiver_name,
675
+ file: file, line: line, test: test, dynamic: dynamic, metadata: metadata
676
+ )
677
+ end
678
+
679
+ private :data_new, :compatible_new
680
+ end
681
+
682
+ def initialize(caller_id:, message:, receiver_kind:, receiver_name:, file:, line:, test:, dynamic:, metadata:,
683
+ call_site_id: nil)
684
+ call_site_id ||= CallSiteIdentity.legacy_id(
685
+ caller_definition_id: caller_id,
686
+ message: message,
687
+ receiver_kind: receiver_kind,
688
+ receiver_name: receiver_name,
689
+ file: file,
690
+ line: line,
691
+ test: test,
692
+ dynamic: dynamic,
693
+ metadata: metadata
694
+ )
695
+ super
696
+ end
697
+
698
+ alias_method :caller_definition_id, :caller_id
699
+
109
700
  def to_h
110
701
  {
702
+ 'call_site_id' => call_site_id,
111
703
  'caller_id' => caller_id,
704
+ 'caller_definition_id' => caller_definition_id,
112
705
  'message' => message,
113
706
  'receiver_kind' => receiver_kind.to_s,
114
707
  'receiver_name' => receiver_name,
@@ -121,13 +714,133 @@ module Necropsy
121
714
  end
122
715
  end
123
716
 
124
- AnalyzerProfile = Data.define(:name, :kind, :soundness, :description) do
717
+ VALUE_FACT_KINDS = %i[
718
+ class_object instance_types symbol_set string_set callable_set container nil boolean unknown
719
+ ].freeze
720
+
721
+ ValueFact = Data.define(:kind, :values, :exact, :nilable, :origin, :summary) do
722
+ def initialize(kind:, values: [], exact: true, nilable: false, origin: nil, summary: nil)
723
+ kind = kind.to_sym
724
+ raise ArgumentError, "invalid value fact kind: #{kind.inspect}" unless VALUE_FACT_KINDS.include?(kind)
725
+
726
+ values = Array(values).map(&:to_s).uniq.sort.freeze
727
+ exact = (exact == true)
728
+ nilable = (nilable == true)
729
+ origin = origin&.to_s
730
+ super
731
+ end
732
+
733
+ def self.instance_types(types, origin: :direct_constructor, nilable: false)
734
+ new(kind: :instance_types, values: types, exact: true, nilable: nilable, origin: origin)
735
+ end
736
+
737
+ def self.unknown(origin = :unsupported)
738
+ new(kind: :unknown, exact: false, origin: origin)
739
+ end
740
+
741
+ def self.from_h(attributes)
742
+ data = attributes.transform_keys(&:to_s)
743
+ new(
744
+ kind: data.fetch('kind'),
745
+ values: data.fetch('values', []),
746
+ exact: data.fetch('exact', false),
747
+ nilable: data.fetch('nilable', false),
748
+ origin: data['origin'],
749
+ summary: data['summary']
750
+ )
751
+ end
752
+
753
+ def exact_instance_types?
754
+ kind == :instance_types && exact && !values.empty?
755
+ end
756
+
757
+ def to_h
758
+ {
759
+ 'kind' => kind.to_s,
760
+ 'values' => values,
761
+ 'exact' => exact,
762
+ 'nilable' => nilable,
763
+ 'origin' => origin,
764
+ 'summary' => summary
765
+ }
766
+ end
767
+ end
768
+
769
+ FlowResult = Data.define(:receiver_facts, :value_facts, :return_fact, :issues, :steps) do
770
+ def initialize(receiver_facts: {}, value_facts: {}, return_fact: ValueFact.unknown(:no_direct_return), issues: [], steps: 0)
771
+ receiver_facts = receiver_facts.dup.compare_by_identity.freeze
772
+ value_facts = value_facts.dup.compare_by_identity.freeze
773
+ issues = Array(issues).map(&:to_s).uniq.sort.freeze
774
+ steps = Integer(steps)
775
+ super
776
+ end
777
+
778
+ def fact_for(node)
779
+ value_facts[node] || receiver_facts[node]
780
+ end
781
+
782
+ def to_h
783
+ {
784
+ 'receiver_facts' => receiver_facts.values.map(&:to_h),
785
+ 'value_fact_count' => value_facts.length,
786
+ 'return_fact' => return_fact.to_h,
787
+ 'issues' => issues,
788
+ 'steps' => steps
789
+ }
790
+ end
791
+ end
792
+
793
+ AnalyzerProfile = Data.define(:name, :kind, :soundness, :description, :version, :assumptions) do
794
+ class << self
795
+ alias_method :data_new, :new
796
+
797
+ def new(*values, **attributes)
798
+ compatible_new(*values, **attributes)
799
+ end
800
+ alias_method :[], :new
801
+
802
+ private
803
+
804
+ def compatible_new(*values, **attributes)
805
+ return data_new(*values, **attributes) unless values.length == 4 && attributes.empty?
806
+
807
+ name, kind, soundness, description = values
808
+ data_new(name: name, kind: kind, soundness: soundness, description: description)
809
+ end
810
+
811
+ private :data_new, :compatible_new
812
+ end
813
+
814
+ def initialize(name:, kind:, soundness:, description:, version: nil, assumptions: [])
815
+ ModelNormalization.identifier(name, 'analyzer profile name')
816
+ kind = kind.to_sym if kind.respond_to?(:to_sym)
817
+ soundness = soundness.to_sym if soundness.respond_to?(:to_sym)
818
+ raise ArgumentError, "invalid analyzer kind: #{kind.inspect}" unless ANALYZER_KINDS.include?(kind)
819
+ raise ArgumentError, "invalid analyzer soundness: #{soundness.inspect}" unless ANALYZER_SOUNDNESS.include?(soundness)
820
+ unless description.is_a?(String) && !description.empty? && description.bytesize <= PROFILE_MAX_DESCRIPTION_BYTES
821
+ raise ArgumentError, 'analyzer description must be a bounded non-empty string'
822
+ end
823
+
824
+ version = ModelNormalization.identifier(version, 'version') if version
825
+ raise ArgumentError, 'analyzer version is too long' if version&.bytesize.to_i > PROFILE_MAX_VERSION_BYTES
826
+
827
+ assumptions = ModelNormalization.string_list(assumptions, 'assumption')
828
+ if assumptions.length > PROFILE_MAX_ASSUMPTIONS ||
829
+ assumptions.any? { |assumption| assumption.bytesize > PROFILE_MAX_ASSUMPTION_BYTES }
830
+ raise ArgumentError, 'analyzer assumptions must be bounded'
831
+ end
832
+
833
+ super
834
+ end
835
+
125
836
  def to_h
126
837
  {
127
838
  'name' => name.to_s,
128
839
  'kind' => kind.to_s,
129
840
  'soundness' => soundness.to_s,
130
- 'description' => description
841
+ 'description' => description,
842
+ 'version' => version,
843
+ 'assumptions' => assumptions
131
844
  }
132
845
  end
133
846
  end
@@ -135,19 +848,162 @@ module Necropsy
135
848
  EdgeEvidence = Data.define(:caller_id, :callee_id, :evidence)
136
849
  AliveEvidence = Data.define(:node_id, :evidence)
137
850
 
851
+ SourceError = Data.define(:file, :line, :message, :type) do
852
+ def to_h
853
+ {
854
+ 'file' => file,
855
+ 'line' => line,
856
+ 'message' => message,
857
+ 'type' => type.to_s
858
+ }
859
+ end
860
+ end
861
+
862
+ AnalysisHealth = Data.define(:status, :reasons) do
863
+ def self.from_reasons(reasons)
864
+ normalized = Array(reasons).map { |reason| normalize_reason(reason) }
865
+ status = normalized.any? { |reason| reason.fetch('severity') == 'invalid' } ? :invalid : :degraded
866
+ status = :complete if normalized.empty?
867
+ new(status: status || :complete, reasons: normalized)
868
+ end
869
+
870
+ def self.complete
871
+ new(status: :complete, reasons: [])
872
+ end
873
+
874
+ def self.normalize_reason(reason)
875
+ normalized = reason.to_h.transform_keys(&:to_s)
876
+ severity = normalized.fetch('severity').to_sym
877
+ raise ArgumentError, "invalid analysis health severity: #{severity}" unless %i[degraded invalid].include?(severity)
878
+ raise ArgumentError, 'analysis health reason requires a code' if normalized.fetch('code', '').to_s.empty?
879
+
880
+ normalized.merge('severity' => severity.to_s)
881
+ end
882
+ private_class_method :normalize_reason
883
+
884
+ def initialize(status:, reasons: [])
885
+ normalized_status = status.to_sym
886
+ raise ArgumentError, "invalid analysis health status: #{status}" unless %i[complete degraded invalid].include?(normalized_status)
887
+
888
+ super(status: normalized_status, reasons: reasons.freeze)
889
+ end
890
+
891
+ def complete?
892
+ status == :complete
893
+ end
894
+
895
+ def to_h
896
+ { 'status' => status.to_s, 'reasons' => reasons }
897
+ end
898
+ end
899
+
900
+ Blocker = Data.define(:kind, :scope_kind, :scope_value, :source, :reason, :suggested_action, :metadata) do
901
+ def initialize(kind:, scope_kind:, scope_value:, source:, reason:, suggested_action: :review, metadata: {})
902
+ super
903
+ end
904
+
905
+ def message
906
+ metadata['message'] || metadata[:message] || (scope_value if %i[message symbol].include?(scope_kind.to_sym))
907
+ end
908
+
909
+ def caller_domain
910
+ (metadata['caller_domain'] || metadata[:caller_domain] || :runtime).to_sym
911
+ end
912
+
913
+ def to_h
914
+ {
915
+ 'kind' => kind.to_s,
916
+ 'scope_kind' => scope_kind.to_s,
917
+ 'scope_value' => scope_value,
918
+ 'source' => source.respond_to?(:to_h) ? source.to_h : source.to_s,
919
+ 'reason' => reason,
920
+ 'suggested_action' => suggested_action.to_s,
921
+ 'metadata' => metadata
922
+ }
923
+ end
924
+ end
925
+
138
926
  ScoreComponent = Data.define(:name, :value, :details) do
139
927
  def to_h
140
928
  { 'name' => name, 'value' => value, 'details' => details }
141
929
  end
142
930
  end
143
931
 
144
- AnalyzerResult = Data.define(:edge_evidences, :alive_evidences, :uncertainties, :observation) do
932
+ AnalyzerResult = Data.define(
933
+ :edge_evidences,
934
+ :alive_evidences,
935
+ :uncertainties,
936
+ :observation,
937
+ :blockers,
938
+ :resolutions,
939
+ :evidences,
940
+ :derived_call_sites
941
+ ) do
942
+ class << self
943
+ alias_method :data_new, :new
944
+
945
+ def new(*values, **attributes)
946
+ compatible_new(*values, **attributes)
947
+ end
948
+ alias_method :[], :new
949
+
950
+ private
951
+
952
+ def compatible_new(*values, **attributes)
953
+ return data_new(*values, []) if attributes.empty? && values.length == 7
954
+
955
+ return data_new(*values, **attributes) unless attributes.empty? && [4, 5].include?(values.length)
956
+
957
+ edge_evidences, alive_evidences, uncertainties, observation, blockers = values
958
+ blockers = [] if values.length == 4
959
+ data_new(edge_evidences: edge_evidences, alive_evidences: alive_evidences, uncertainties: uncertainties, observation: observation,
960
+ blockers: blockers)
961
+ end
962
+
963
+ private :data_new, :compatible_new
964
+ end
965
+
966
+ def initialize(edge_evidences:, alive_evidences:, uncertainties:, observation:, blockers: [], resolutions: nil,
967
+ evidences: [], derived_call_sites: [])
968
+ resolutions = normalize_resolutions(resolutions) unless resolutions.nil?
969
+ super
970
+ end
971
+
145
972
  def self.empty
146
- new(edge_evidences: [], alive_evidences: [], uncertainties: {}, observation: {})
973
+ new(
974
+ edge_evidences: [], alive_evidences: [], uncertainties: {}, observation: {}, blockers: [],
975
+ resolutions: [], evidences: [], derived_call_sites: []
976
+ )
977
+ end
978
+
979
+ private
980
+
981
+ def normalize_resolutions(values)
982
+ ModelNormalization.list(values).map do |value|
983
+ normalize_resolution_record(value)
984
+ end.sort_by { |record| ModelNormalization.canonical(record.to_h) }.freeze
985
+ end
986
+
987
+ def normalize_resolution_record(value)
988
+ return value if value.is_a?(ResolutionRecord)
989
+ return legacy_resolution_record(value) if value.is_a?(Resolution)
990
+
991
+ data = ModelNormalization.attributes(value, 'AnalyzerResult resolution')
992
+ return ResolutionRecord.from_h(data) if data.key?('resolution')
993
+
994
+ legacy_resolution_record(Resolution.from_h(data))
995
+ end
996
+
997
+ def legacy_resolution_record(resolution)
998
+ ResolutionRecord.new(resolution: resolution, producer: 'legacy')
147
999
  end
148
1000
  end
149
1001
 
150
- Finding = Data.define(:node, :classification, :confidence, :score, :score_components, :reasons, :evidences) do
1002
+ Finding = Data.define(:node, :classification, :confidence, :score, :score_components, :reasons, :evidences, :blockers) do
1003
+ def initialize(node:, classification:, confidence:, score:, score_components:, reasons:, evidences:, blockers: [])
1004
+ super
1005
+ end
1006
+
151
1007
  def at_least?(level)
152
1008
  CONFIDENCE_LEVELS.fetch(confidence) >= CONFIDENCE_LEVELS.fetch(level)
153
1009
  end
@@ -156,16 +1012,25 @@ module Necropsy
156
1012
  node.fingerprint(classification)
157
1013
  end
158
1014
 
1015
+ alias_method :logical_fingerprint, :fingerprint
1016
+
1017
+ def physical_fingerprint
1018
+ node.physical_fingerprint(classification)
1019
+ end
1020
+
159
1021
  def to_h
160
1022
  {
161
1023
  'fingerprint' => fingerprint,
1024
+ 'logical_fingerprint' => logical_fingerprint,
1025
+ 'physical_fingerprint' => physical_fingerprint,
162
1026
  'classification' => classification.to_s,
163
1027
  'confidence' => confidence.to_s,
164
1028
  'score' => score,
165
1029
  'score_components' => score_components.map(&:to_h),
166
1030
  'node' => node.to_h,
167
1031
  'reasons' => reasons,
168
- 'evidences' => evidences.map(&:to_h)
1032
+ 'evidences' => evidences.map(&:to_h),
1033
+ 'blockers' => blockers.map(&:to_h)
169
1034
  }
170
1035
  end
171
1036
  end