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,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'openssl'
5
- require 'socket'
6
- require 'uri'
7
4
  require 'yaml'
8
5
 
6
+ require_relative 'redis_payload_loader'
7
+ require_relative 'runtime_reference'
8
+
9
9
  module Necropsy
10
10
  module Analyzers
11
11
  module Dynamic
@@ -19,11 +19,23 @@ module Necropsy
19
19
  return AnalyzerResult.empty unless source
20
20
 
21
21
  payload = load_payload(source, project.root)
22
- alive = alive_from_payload(graph, payload).map do |node|
22
+ observation = ObservationPolicy.metadata(payload, expected_revision: config['expected_source_revision'])
23
+ node_ids, line_diagnostics = alive_ids_from_payload(graph, payload)
24
+ observation = observation.merge(line_diagnostics) unless line_diagnostics.empty?
25
+ alive = node_ids.map do |node_id|
26
+ node = graph.nodes.lookup(node_id).node
27
+ details = node ? "Coverband executed #{node.file}:#{node.line}" : "Coverband marked #{node_id} as executed"
23
28
  AliveEvidence.new(
24
- node_id: node.id,
25
- evidence: evidence(kind: :alive, details: "Coverband executed #{node.file}:#{node.line}",
26
- metadata: payload['observation'] || {})
29
+ node_id: node_id,
30
+ evidence: evidence(
31
+ kind: :alive,
32
+ details: details,
33
+ metadata: observation.merge('node_reference' => node_id),
34
+ grade: :observed,
35
+ relation: :execution,
36
+ source: { 'type' => 'coverband', 'node_reference' => node_id },
37
+ scope: ObservationPolicy.evidence_scope(observation).merge('node_reference' => node_id)
38
+ )
27
39
  )
28
40
  end
29
41
 
@@ -31,7 +43,9 @@ module Necropsy
31
43
  edge_evidences: [],
32
44
  alive_evidences: alive,
33
45
  uncertainties: {},
34
- observation: { 'coverband' => payload['observation'] || {} }
46
+ observation: { 'coverband' => observation },
47
+ resolutions: [],
48
+ evidences: result_evidences(alive)
35
49
  )
36
50
  end
37
51
 
@@ -40,7 +54,9 @@ module Necropsy
40
54
  name: :coverband,
41
55
  kind: :dynamic,
42
56
  soundness: :observational,
43
- description: 'Imports file and line execution data compatible with coverband-style exports.'
57
+ description: 'Imports file and line execution data compatible with coverband-style exports.',
58
+ version: Necropsy::VERSION,
59
+ assumptions: %w[line_execution_mapping positive_observations_only]
44
60
  )
45
61
  end
46
62
 
@@ -68,17 +84,25 @@ module Necropsy
68
84
  RedisPayloadLoader.new(source: source, config: config).load
69
85
  end
70
86
 
71
- def alive_from_payload(graph, payload)
72
- executed_nodes = Array(payload['executed'] || payload['nodes'])
73
- by_id = executed_nodes.filter_map { |id| graph.nodes[id] }
87
+ def alive_ids_from_payload(graph, payload)
88
+ structured = payload['node_references'] if payload.key?('node_references')
89
+ malformed = []
90
+ executed_nodes = RuntimeReference.preferred(
91
+ structured: structured,
92
+ legacy: payload['executed'] || payload['nodes']
93
+ ).map do |reference|
94
+ normalized = RuntimeReference.normalize(reference)
95
+ next normalized if normalized
74
96
 
75
- files = coverage_files(payload)
76
- by_line = graph.method_nodes.select do |node|
77
- executed_lines = lines_for(files, node.file)
78
- executed_lines.any? { |line| line.between?(node.line, node.end_line) }
97
+ malformed << { 'kind' => 'node', 'reference' => reference }
98
+ reference
79
99
  end
80
100
 
81
- (by_id + by_line).uniq(&:id)
101
+ files = coverage_files(payload)
102
+ by_line, diagnostics = line_references(graph, files)
103
+ diagnostics['malformed_references'] = malformed unless malformed.empty?
104
+
105
+ [(executed_nodes + by_line).uniq, diagnostics]
82
106
  end
83
107
 
84
108
  def coverage_files(payload)
@@ -135,294 +159,40 @@ module Necropsy
135
159
  left.merge(right) { |_file, left_lines, right_lines| (left_lines + right_lines).uniq }
136
160
  end
137
161
 
138
- def lines_for(files, relative_file)
139
- exact = files[relative_file] || files[File.join('.', relative_file)]
140
- return exact if exact
141
-
142
- files.each do |file, lines|
143
- return lines if file.end_with?("/#{relative_file}")
144
- end
145
-
146
- []
147
- end
148
- end
149
-
150
- class RedisPayloadLoader
151
- DEFAULT_PATTERN = 'coverband*'
152
- DEFAULT_CONNECT_TIMEOUT = 5.0
153
- DEFAULT_READ_TIMEOUT = 5.0
154
-
155
- def initialize(source:, config:)
156
- @uri = URI(source)
157
- @config = config
158
- @socket = nil
159
- end
160
-
161
- def load
162
- connect
163
- authenticate
164
- select_database
165
- merge_payloads(keys.filter_map { |key| payload_for_key(key) })
166
- ensure
167
- socket&.close
168
- end
169
-
170
- private
171
-
172
- attr_reader :uri, :config, :socket
173
-
174
- def connect
175
- tcp_socket = Socket.tcp(uri.host, uri.port || 6379, connect_timeout: connect_timeout)
176
- apply_read_timeout(tcp_socket)
177
- @socket = uri.scheme == 'rediss' ? tls_socket(tcp_socket) : tcp_socket
178
- rescue SystemCallError, SocketError, IO::TimeoutError => e
179
- tcp_socket&.close
180
- raise Error, "Could not connect to Redis at #{uri.host}:#{uri.port || 6379}: #{e.message}"
181
- end
182
-
183
- def tls_socket(tcp_socket)
184
- context = OpenSSL::SSL::SSLContext.new
185
- socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, context)
186
- socket.hostname = uri.host if socket.respond_to?(:hostname=)
187
- socket.connect
188
- socket
189
- end
190
-
191
- def authenticate
192
- return unless uri.password
193
-
194
- password = URI.decode_www_form_component(uri.password)
195
- return command('AUTH', password) unless uri.user
196
-
197
- command('AUTH', URI.decode_www_form_component(uri.user), password)
198
- end
199
-
200
- def select_database
201
- db = uri.path.to_s.delete_prefix('/')
202
- command('SELECT', db) unless db.empty?
203
- end
204
-
205
- def keys
206
- configured = Array(config['keys'] || config['key'] || query['key']).compact
207
- return configured unless configured.empty?
208
-
209
- scan(pattern: config['pattern'] || query['pattern'] || DEFAULT_PATTERN)
210
- end
211
-
212
- def query
213
- @query ||= URI.decode_www_form(uri.query.to_s).to_h
214
- end
215
-
216
- def scan(pattern:)
217
- cursor = '0'
218
- found = []
219
- loop do
220
- cursor, keys = command('SCAN', cursor, 'MATCH', pattern)
221
- found.concat(keys)
222
- break if cursor == '0'
223
- end
224
- found
225
- end
226
-
227
- def command(*parts)
228
- socket.write(redis_command(parts))
229
- read_response
230
- rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
231
- raise Error, "Redis connection failed: #{e.message}"
232
- end
233
-
234
- def payload_for_key(key)
235
- parse_payload(command('GET', key))
236
- rescue Error => e
237
- raise unless e.message.include?('WRONGTYPE')
238
-
239
- parse_hash_payload(command('HGETALL', key))
240
- end
241
-
242
- def redis_command(parts)
243
- "#{["*#{parts.length}", *parts.flat_map { |part| ["$#{part.to_s.bytesize}", part.to_s] }].join("\r\n")}\r\n"
244
- end
245
-
246
- def read_response
247
- prefix = read_exact(1)
248
- case prefix
249
- when '+'
250
- read_line
251
- when '-'
252
- raise Error, read_line
253
- when ':'
254
- read_line.to_i
255
- when '$'
256
- read_bulk_string
257
- when '*'
258
- length = read_line.to_i
259
- return nil if length.negative?
260
-
261
- Array.new(length) { read_response }
262
- else
263
- raise Error, "Unsupported Redis response prefix #{prefix.inspect}"
264
- end
265
- end
266
-
267
- def read_bulk_string
268
- length = read_line.to_i
269
- return nil if length.negative?
270
-
271
- value = read_exact(length)
272
- read_exact(2)
273
- value
274
- end
275
-
276
- def read_line
277
- line = socket.gets("\r\n")
278
- raise Error, 'Redis closed the connection while reading a response' unless line
279
-
280
- line.delete_suffix("\r\n")
281
- end
282
-
283
- def read_exact(length)
284
- value = socket.read(length)
285
- raise Error, 'Redis closed the connection while reading a response' unless value&.bytesize == length
286
-
287
- value
288
- end
289
-
290
- def connect_timeout
291
- Float(config.fetch('connect_timeout', DEFAULT_CONNECT_TIMEOUT))
292
- rescue ArgumentError, TypeError
293
- DEFAULT_CONNECT_TIMEOUT
294
- end
295
-
296
- def read_timeout
297
- Float(config.fetch('read_timeout', DEFAULT_READ_TIMEOUT))
298
- rescue ArgumentError, TypeError
299
- DEFAULT_READ_TIMEOUT
300
- end
301
-
302
- def apply_read_timeout(tcp_socket)
303
- seconds = read_timeout
304
- timeout = [seconds.to_i, ((seconds % 1) * 1_000_000).to_i].pack('l_2')
305
- tcp_socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeout)
306
- end
307
-
308
- def parse_payload(value)
309
- return nil if value.nil? || value.empty?
310
-
311
- JSON.parse(value)
312
- rescue JSON::ParserError
313
- parse_yaml_payload(value)
314
- end
315
-
316
- def parse_yaml_payload(value)
317
- YAML.safe_load(value, aliases: false)
318
- rescue Psych::Exception, ArgumentError, EncodingError => e
319
- raise Error, "Invalid Redis coverage payload: #{e.message}"
320
- end
321
-
322
- def parse_hash_payload(entries)
323
- hash = Array(entries).each_slice(2).to_h
324
- return coverband_hash_payload(hash) if coverband_hash_payload?(hash)
325
-
326
- payloads = []
327
- files = {}
328
- hash.each do |field, value|
329
- parsed = parse_payload(value)
330
- if payload_shape?(parsed)
331
- payloads << parsed
332
- else
333
- files[field.to_s] = parsed
162
+ def line_references(graph, files)
163
+ definition_ids = []
164
+ line_ambiguities = []
165
+ path_ambiguities = []
166
+ graph.method_nodes.group_by(&:file).sort.each do |relative_file, definitions|
167
+ paths = matching_coverage_paths(files, relative_file)
168
+ next if paths.empty?
169
+
170
+ path_ambiguities << { 'file' => relative_file, 'coverage_paths' => paths } if paths.length > 1
171
+ paths.flat_map { |path| files.fetch(path) }.uniq.sort.each do |line|
172
+ matches = definitions.select { |definition| line.between?(definition.line, definition.end_line) }
173
+ definition_ids.concat(matches.map(&:graph_id))
174
+ next unless matches.length > 1
175
+
176
+ line_ambiguities << {
177
+ 'file' => relative_file,
178
+ 'line' => line,
179
+ 'definition_ids' => matches.map(&:graph_id).sort
180
+ }
334
181
  end
335
182
  end
336
183
 
337
- payloads << { 'files' => files } unless files.empty?
338
- merge_payloads(payloads)
339
- end
340
-
341
- def coverband_hash_payload?(hash)
342
- hash.key?('file') && hash.key?('file_length')
343
- end
344
-
345
- def coverband_hash_payload(hash)
346
- {
347
- 'files' => { hash.fetch('file') => coverband_line_counts(hash) },
348
- 'observation' => coverband_observation(hash)
349
- }
350
- end
351
-
352
- def coverband_line_counts(hash)
353
- length = hash.fetch('file_length').to_i
354
- Array.new(length) do |index|
355
- count = hash[index.to_s]
356
- count&.to_i
357
- end
184
+ diagnostics = {}
185
+ diagnostics['path_ambiguities'] = path_ambiguities unless path_ambiguities.empty?
186
+ diagnostics['line_ambiguities'] = line_ambiguities unless line_ambiguities.empty?
187
+ [definition_ids.uniq.sort, diagnostics]
358
188
  end
359
189
 
360
- def coverband_observation(hash)
361
- %w[first_updated_at last_updated_at file_hash].each_with_object({}) do |key, observation|
362
- observation[key] = hash[key] if hash[key]
363
- end
364
- end
365
-
366
- def payload_shape?(value)
367
- value.is_a?(Hash) && value.keys.any? do |key|
368
- %w[files coverage runtime production nodes executed observation].include?(key.to_s)
369
- end
370
- end
371
-
372
- def merge_payloads(payloads)
373
- payloads.compact.reduce({}) { |merged, payload| merge_payload(merged, payload) }
374
- end
375
-
376
- def merge_payload(merged, payload)
377
- merged.merge(payload) do |key, left, right|
378
- case key
379
- when 'files', 'coverage', 'runtime', 'production'
380
- merge_files(left, right)
381
- when 'nodes', 'executed'
382
- (Array(left) + Array(right)).uniq
383
- when 'edges'
384
- Array(left) + Array(right)
385
- when 'observation'
386
- left.merge(right)
387
- else
388
- right
389
- end
390
- end
391
- end
392
-
393
- def merge_files(left, right)
394
- return right unless left.is_a?(Hash)
395
- return left unless right.is_a?(Hash)
396
-
397
- left.merge(right) do |_file, left_lines, right_lines|
398
- merge_file_coverage(left_lines, right_lines)
399
- end
400
- end
401
-
402
- def merge_file_coverage(left, right)
403
- return merge_count_hashes(left, right) if left.is_a?(Hash) && right.is_a?(Hash)
404
- return merge_count_arrays(left, right) if count_array?(left) || count_array?(right)
405
-
406
- (Array(left) + Array(right)).uniq
407
- end
408
-
409
- def merge_count_hashes(left, right)
410
- left.merge(right) { |_line, left_count, right_count| left_count.to_i + right_count.to_i }
411
- end
412
-
413
- def merge_count_arrays(left, right)
414
- length = [Array(left).length, Array(right).length].max
415
- Array.new(length) do |index|
416
- left_value = Array(left)[index]
417
- right_value = Array(right)[index]
418
- next if left_value.nil? && right_value.nil?
419
-
420
- left_value.to_i + right_value.to_i
421
- end
422
- end
190
+ def matching_coverage_paths(files, relative_file)
191
+ exact = [relative_file, File.join('.', relative_file)].select { |path| files.key?(path) }
192
+ return exact.sort unless exact.empty?
423
193
 
424
- def count_array?(value)
425
- value.is_a?(Array) && value.any? { |item| item.nil? || item.to_i.zero? }
194
+ suffix = "/#{relative_file}"
195
+ files.keys.select { |path| path.end_with?(suffix) }.sort
426
196
  end
427
197
  end
428
198
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Analyzers
5
+ module Dynamic
6
+ class CoverbandPayloadSet
7
+ PAYLOAD_KEYS = %w[files coverage runtime production nodes executed observation].freeze
8
+
9
+ def initialize(limits:, deadline:)
10
+ @limits = limits
11
+ @deadline = deadline
12
+ end
13
+
14
+ def merge(payloads)
15
+ payloads.compact.reduce({}) do |merged, payload|
16
+ validate_payload_hash!(payload)
17
+ deadline.check!
18
+ limits.validate_payload!(merge_payload(merged, payload))
19
+ end
20
+ end
21
+
22
+ def from_hash_entries(entries, &)
23
+ domain_error('Malformed Redis hash response') unless entries.is_a?(Array) && entries.length.even?
24
+
25
+ hash = entries.each_slice(2).to_h
26
+ return coverband_hash_payload(hash) if coverband_hash_payload?(hash)
27
+
28
+ generic_hash_payload(hash, &)
29
+ end
30
+
31
+ def payload_shape?(value)
32
+ value.is_a?(Hash) && value.keys.any? { |key| PAYLOAD_KEYS.include?(key.to_s) }
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :limits, :deadline
38
+
39
+ def generic_hash_payload(hash)
40
+ payloads = []
41
+ files = {}
42
+ hash.each do |field, value|
43
+ deadline.check!
44
+ parsed = yield(value)
45
+ if payload_shape?(parsed)
46
+ payloads << parsed
47
+ elsif parsed.is_a?(Hash) || parsed.is_a?(Array)
48
+ files[field.to_s] = parsed
49
+ else
50
+ domain_error('Invalid Redis coverage payload value')
51
+ end
52
+ end
53
+
54
+ payloads << { 'files' => files } unless files.empty?
55
+ merge(payloads)
56
+ end
57
+
58
+ def coverband_hash_payload?(hash)
59
+ hash.key?('file') && hash.key?('file_length')
60
+ end
61
+
62
+ def coverband_hash_payload(hash)
63
+ payload = {
64
+ 'files' => { hash.fetch('file') => coverband_line_counts(hash) },
65
+ 'observation' => coverband_observation(hash)
66
+ }
67
+ limits.validate_payload!(payload)
68
+ end
69
+
70
+ def coverband_line_counts(hash)
71
+ length = Integer(hash.fetch('file_length'), exception: false)
72
+ domain_error('Invalid Coverband file length') unless length && length >= 0
73
+ domain_error('Coverband file length exceeds configured limit') if length > limits.max_array_elements
74
+
75
+ Array.new(length) do |index|
76
+ deadline.check!
77
+ count = hash[index.to_s]
78
+ count&.to_i
79
+ end
80
+ end
81
+
82
+ def coverband_observation(hash)
83
+ %w[first_updated_at last_updated_at file_hash].each_with_object({}) do |key, observation|
84
+ observation[key] = hash[key] if hash[key]
85
+ end
86
+ end
87
+
88
+ def validate_payload_hash!(payload)
89
+ domain_error('Redis coverage payload must be a mapping') unless payload.is_a?(Hash)
90
+
91
+ limits.validate_payload!(payload)
92
+ end
93
+
94
+ def merge_payload(merged, payload)
95
+ merged.merge(payload) do |key, left, right|
96
+ case key.to_s
97
+ when 'files', 'coverage', 'runtime', 'production' then merge_files(left, right)
98
+ when 'nodes', 'executed' then (Array(left) + Array(right)).uniq
99
+ when 'edges' then Array(left) + Array(right)
100
+ when 'observation' then ObservationPolicy.compatible_merge(left, right)
101
+ else right
102
+ end
103
+ end
104
+ end
105
+
106
+ def merge_files(left, right)
107
+ return right unless left.is_a?(Hash)
108
+ return left unless right.is_a?(Hash)
109
+
110
+ left.merge(right) do |_file, left_lines, right_lines|
111
+ merge_file_coverage(left_lines, right_lines)
112
+ end
113
+ end
114
+
115
+ def merge_file_coverage(left, right)
116
+ return merge_count_hashes(left, right) if left.is_a?(Hash) && right.is_a?(Hash)
117
+ return merge_count_arrays(left, right) if count_array?(left) || count_array?(right)
118
+
119
+ (Array(left) + Array(right)).uniq
120
+ end
121
+
122
+ def merge_count_hashes(left, right)
123
+ left.merge(right) { |_line, left_count, right_count| left_count.to_i + right_count.to_i }
124
+ end
125
+
126
+ def merge_count_arrays(left, right)
127
+ length = [Array(left).length, Array(right).length].max
128
+ domain_error('Merged Redis coverage exceeds configured collection size') if length > limits.max_array_elements
129
+
130
+ Array.new(length) do |index|
131
+ left_value = Array(left)[index]
132
+ right_value = Array(right)[index]
133
+ next if left_value.nil? && right_value.nil?
134
+
135
+ left_value.to_i + right_value.to_i
136
+ end
137
+ end
138
+
139
+ def count_array?(value)
140
+ value.is_a?(Array) && value.any? { |item| item.nil? || item.to_i.zero? }
141
+ end
142
+
143
+ def domain_error(message)
144
+ raise Error, message
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Analyzers
5
+ module Dynamic
6
+ module ObservationPolicy
7
+ module_function
8
+
9
+ def metadata(payload = nil, expected_revision: nil, **keyword_payload)
10
+ payload ||= keyword_payload
11
+ observation = payload['observation']
12
+ observation = {} unless observation.is_a?(Hash)
13
+ schema_version = Integer(payload.fetch('schema_version', 1), exception: false)
14
+ raise Error, 'Dynamic observation schema version must be 1 or 2' unless [1, 2].include?(schema_version)
15
+
16
+ revision = source_revision(payload, observation)
17
+
18
+ status = source_revision_status(payload, observation, revision, expected_revision)
19
+ normalized = observation.merge(
20
+ 'schema_version' => schema_version,
21
+ 'positive_evidence_policy' => 'alive_only',
22
+ 'source_revision_status' => status,
23
+ 'source_revision_policy' => 'accepted_for_liveness_only'
24
+ )
25
+ normalized = normalized.merge('source_revision' => revision) if revision
26
+ normalized = normalized.merge('expected_source_revision' => expected_revision) if expected_revision
27
+ return normalized unless schema_version == 2
28
+
29
+ collector = payload['collector']
30
+ collector = {} unless collector.is_a?(Hash)
31
+ source = payload['source']
32
+ source = {} unless source.is_a?(Hash)
33
+ scope = payload['scope']
34
+ scope = {} unless scope.is_a?(Hash)
35
+ quality = payload['quality']
36
+ quality = {} unless quality.is_a?(Hash)
37
+ normalized.merge(
38
+ 'collector_name' => collector['name'] || payload['collector_name'],
39
+ 'collector_version' => collector['version'] || payload['collector_version'],
40
+ 'source_metadata' => source,
41
+ 'environment' => scope['environment'] || observation['environment'],
42
+ 'sample_unit' => scope['sample_unit'] || observation['sample_unit'],
43
+ 'sample_rate' => scope['sample_rate'] || observation['sample_rate'],
44
+ 'quality' => {
45
+ 'dropped_events' => quality.fetch('dropped_events', 0),
46
+ 'overflowed' => quality.fetch('overflowed', false)
47
+ }
48
+ ).compact
49
+ end
50
+
51
+ def compatible_merge(left, right)
52
+ return left.merge(right) unless left.is_a?(Hash) && right.is_a?(Hash)
53
+
54
+ %w[source_revision source_revision_status environment collector_name collector_version].each do |key|
55
+ next if left[key].nil? || right[key].nil? || left[key] == right[key]
56
+
57
+ raise Error, "Dynamic observation #{key} is incompatible"
58
+ end
59
+ left.merge(right)
60
+ end
61
+
62
+ def evidence_scope(observation)
63
+ declared_scope = observation['scope']
64
+ declared_scope = {} unless declared_scope.is_a?(Hash)
65
+ declared_scope = declared_scope.reject do |key, _value|
66
+ %w[revision source_revision].include?(key.to_s)
67
+ end
68
+ selectors = {
69
+ 'revision' => observation['source_revision'],
70
+ 'source_revision_status' => observation['source_revision_status'],
71
+ 'environment' => observation['environment'],
72
+ 'collector' => observation['collector']
73
+ }.compact
74
+ declared_scope.merge(selectors)
75
+ end
76
+
77
+ def source_revision(payload, observation)
78
+ source = payload['source']
79
+ source_revision = source['git_sha'] if source.is_a?(Hash)
80
+ source_revision || payload['source_revision'] || observation['source_revision']
81
+ end
82
+ private_class_method :source_revision
83
+
84
+ def source_revision_status(payload, observation, revision, expected_revision)
85
+ stale = payload['stale'] || observation['stale'] || payload['source_revision_status'] == 'stale'
86
+ return 'stale' if stale
87
+ return 'unknown' unless revision
88
+ return 'provided_unverified' unless expected_revision
89
+
90
+ revision.to_s == expected_revision.to_s ? 'match' : 'mismatch'
91
+ end
92
+ private_class_method :source_revision_status
93
+ end
94
+ end
95
+ end
96
+ end