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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- 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
|
-
|
|
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:
|
|
25
|
-
evidence: evidence(
|
|
26
|
-
|
|
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' =>
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|