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
data/lib/necropsy/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'date'
4
+ require 'digest'
4
5
  require 'English'
5
6
  require 'fileutils'
6
7
  require 'json'
@@ -12,10 +13,17 @@ require 'necropsy'
12
13
 
13
14
  module Necropsy
14
15
  class CLI
16
+ HEALTH_FAILURE_STATUS = 3
17
+
15
18
  def self.run(argv)
16
19
  new.run(argv)
17
20
  end
18
21
 
22
+ def initialize(run_id_generator: nil, environment: ENV)
23
+ @run_id_generator = run_id_generator
24
+ @environment = environment
25
+ end
26
+
19
27
  def run(argv)
20
28
  command = argv.first&.start_with?('-') ? 'analyze' : argv.shift || 'analyze'
21
29
  options = default_options
@@ -29,23 +37,15 @@ module Necropsy
29
37
  puts Necropsy::VERSION
30
38
  return 0
31
39
  end
32
- apply_config_defaults(options)
40
+ apply_config_defaults(options) unless command == 'semantics'
33
41
 
34
42
  case command
35
43
  when 'analyze'
36
44
  report = analyze(options)
37
- puts Reporter.new(report).render(
38
- format: options[:format],
39
- min_confidence: options[:min_confidence],
40
- include_graph: options[:include_graph]
41
- )
42
- 0
45
+ emit_report(report, options, min_confidence: options[:min_confidence])
46
+ health_acceptable?(report, options, strict: false) ? 0 : HEALTH_FAILURE_STATUS
43
47
  when 'baseline'
44
- report = analyze(options)
45
- path = File.expand_path(options[:baseline], options[:root])
46
- Guardrail::Baseline.write(report, path: path)
47
- puts "Wrote #{path}"
48
- 0
48
+ baseline(options, argv)
49
49
  when 'check'
50
50
  check(options)
51
51
  when 'quarantine'
@@ -56,14 +56,16 @@ module Necropsy
56
56
  record(options, argv)
57
57
  when 'coverage'
58
58
  coverage(options, argv)
59
- when 'why', 'explain'
59
+ when 'semantics'
60
+ semantics(options, argv)
61
+ when 'why', 'why-not', 'explain'
60
62
  diagnose(command, options, argv)
61
63
  else
62
64
  warn "Unknown command: #{command}"
63
65
  warn parser
64
66
  2
65
67
  end
66
- rescue OptionParser::ParseError, Psych::Exception, Error => e
68
+ rescue OptionParser::ParseError, Psych::Exception, Error, GraphSelfCheck::Failure => e
67
69
  warn e.message
68
70
  2
69
71
  end
@@ -85,11 +87,16 @@ module Necropsy
85
87
  output: nil,
86
88
  sample_rate: 1.0,
87
89
  ablation: false,
90
+ bench_check: false,
88
91
  precision_threshold: nil,
89
92
  recall_threshold: nil,
93
+ as_of: nil,
94
+ strict_health: false,
95
+ allow_degraded: [],
90
96
  help: false,
91
97
  version: false,
92
- include_graph: false
98
+ include_graph: false,
99
+ self_check: false
93
100
  }
94
101
  end
95
102
 
@@ -102,6 +109,7 @@ module Necropsy
102
109
  options[:format] = value.to_sym
103
110
  end
104
111
  parser.on('--include-graph', 'Include nodes and edges in JSON/YAML output') { options[:include_graph] = true }
112
+ parser.on('--self-check', 'Validate graph invariants after analysis') { options[:self_check] = true }
105
113
  parser.on('--min-confidence LEVEL', 'low, medium, high, or certain') do |value|
106
114
  options[:min_confidence] = confidence_level(value)
107
115
  end
@@ -122,12 +130,30 @@ module Necropsy
122
130
  options[:sample_rate] = value
123
131
  end
124
132
  parser.on('--ablation', 'Run bench across analyzer combinations') { options[:ablation] = true }
133
+ parser.on('--check', 'Fail bench when release criteria do not pass') { options[:bench_check] = true }
125
134
  parser.on('--precision-threshold N', Float, 'Bench release precision threshold') do |value|
126
135
  options[:precision_threshold] = value
127
136
  end
128
137
  parser.on('--recall-threshold N', Float, 'Bench release recall threshold') do |value|
129
138
  options[:recall_threshold] = value
130
139
  end
140
+ parser.on('--as-of DATE', 'Use a reproducible UTC date for time-dependent analysis') do |value|
141
+ options[:as_of] = Date.iso8601(value)
142
+ rescue Date::Error
143
+ raise OptionParser::InvalidArgument, 'as-of must be an ISO 8601 date'
144
+ end
145
+ parser.on('--strict-health', 'Return status 3 unless analysis health is complete') do
146
+ options[:strict_health] = true
147
+ end
148
+ parser.on('--allow-degraded=REASONS', 'Comma-separated degraded reason codes to allow explicitly') do |value|
149
+ reasons = value.split(',').map(&:strip).reject(&:empty?)
150
+ raise OptionParser::InvalidArgument, 'allow-degraded requires at least one reason code' if reasons.empty?
151
+ unless reasons.all? { |reason| reason.match?(/\A[a-z][a-z0-9_]*\z/) }
152
+ raise OptionParser::InvalidArgument, 'allow-degraded reason codes must use lowercase letters, numbers, and _'
153
+ end
154
+
155
+ options[:allow_degraded] |= reasons
156
+ end
131
157
  parser.on('-h', '--help', 'Show help') do
132
158
  options[:help] = true
133
159
  end
@@ -135,17 +161,31 @@ module Necropsy
135
161
  end
136
162
  end
137
163
 
138
- def analyze(options)
139
- Necropsy.analyze(root: options[:root], config_path: options[:config])
164
+ def analyze(options, ignored_reference_paths: [])
165
+ report = Necropsy.analyze(
166
+ root: options[:root],
167
+ config_path: options[:config],
168
+ ignored_reference_paths: ignored_reference_paths,
169
+ as_of: options[:as_of]
170
+ )
171
+ GraphSelfCheck.new(report).validate! if options[:self_check]
172
+ report
140
173
  end
141
174
 
142
175
  def diagnose(command, options, argv)
143
176
  node_id = argv.shift
144
- raise Error, "#{command} requires a symbol ID" unless node_id
177
+ raise Error, "#{command} requires a symbol or definition ID" unless node_id
145
178
  raise Error, "Unexpected arguments for #{command}: #{argv.join(' ')}" unless argv.empty?
146
179
 
147
- diagnostics = Diagnostics.new(analyze(options))
148
- payload = command == 'why' ? diagnostics.why(node_id) : diagnostics.explain(node_id)
180
+ report = analyze(options)
181
+ return health_failure(report, options) unless health_acceptable?(report, options, strict: false)
182
+
183
+ diagnostics = Diagnostics.new(report)
184
+ payload = case command
185
+ when 'why' then diagnostics.why(node_id)
186
+ when 'why-not' then diagnostics.why_not(node_id)
187
+ else diagnostics.explain(node_id)
188
+ end
149
189
  puts diagnostics.render(payload, format: options[:format])
150
190
  0
151
191
  end
@@ -157,11 +197,20 @@ module Necropsy
157
197
  end
158
198
 
159
199
  def check(options)
160
- report = analyze(options)
200
+ report = analyze(options, ignored_reference_paths: [options[:baseline]])
201
+ return health_failure(report, options) unless health_acceptable?(report, options, strict: true)
202
+
203
+ report_invalid_quarantine_dates(report)
204
+ expiry_failure = apply_quarantine_expiry_policy(report, report_config(options))
161
205
  findings = filtered_findings(report, options)
162
206
  baseline_path = File.expand_path(options[:baseline], options[:root])
163
207
  baseline = Guardrail::Baseline.load(baseline_path)
164
- failures = findings.reject { |finding| baseline.include?(finding) }
208
+ comparison = baseline.compare(report.actionable_candidates(min_confidence: :low))
209
+ if comparison.review_required?
210
+ puts Reporter.render_baseline_review(comparison.review_report)
211
+ return 1
212
+ end
213
+ failures = findings - comparison.matched_findings
165
214
 
166
215
  baseline_count = baseline.count_at_least(options[:fail_on])
167
216
  if options[:ratchet] && findings.length > baseline_count
@@ -170,19 +219,93 @@ module Necropsy
170
219
  end
171
220
 
172
221
  if failures.any?
173
- puts Reporter.new(Report.new(root: report.root, graph: report.graph, findings: failures)).render(
174
- format: :human,
175
- min_confidence: options[:fail_on]
176
- )
222
+ emit_report(report_with_findings(report, failures), options, min_confidence: options[:fail_on])
177
223
  return 1
178
224
  end
179
225
 
226
+ return 1 if expiry_failure
227
+
180
228
  puts 'Necropsy check passed'
181
229
  0
182
230
  end
183
231
 
232
+ def report_with_findings(report, findings)
233
+ Report.new(
234
+ root: report.root,
235
+ graph: report.graph,
236
+ findings: findings,
237
+ reachability: report.reachability,
238
+ project: report.project,
239
+ source_snapshot: report.source_snapshot,
240
+ performance_profile: report.performance_profile,
241
+ analysis_health: report.analysis_health
242
+ )
243
+ end
244
+
245
+ def emit_report(report, options, min_confidence:)
246
+ reporter = Reporter.new(report)
247
+ if options[:format] == :ndjson
248
+ reporter.each_ndjson { |line| puts line }
249
+ else
250
+ puts reporter.render(
251
+ format: options[:format],
252
+ min_confidence: min_confidence,
253
+ include_graph: options[:include_graph]
254
+ )
255
+ end
256
+ end
257
+
258
+ def semantics(options, argv)
259
+ raise Error, "Unexpected semantics arguments: #{argv.join(' ')}" unless argv.empty?
260
+
261
+ puts SemanticsMatrix.new.render(format: options[:format])
262
+ 0
263
+ end
264
+
265
+ def apply_quarantine_expiry_policy(report, config)
266
+ policy = config.quarantine_expiry
267
+ return false if policy == :ignore
268
+
269
+ findings = findings_with_quarantine_components(
270
+ report,
271
+ %w[quarantine_review_required quarantine_fingerprint_required quarantine_stale_fingerprint]
272
+ )
273
+ return false if findings.empty?
274
+
275
+ noun = findings.length == 1 ? 'annotation requires' : 'annotations require'
276
+ lines = findings.map { |finding| " #{finding.node.file}:#{finding.node.line} #{finding.node.id}" }
277
+ message = "Quarantine expiry #{policy == :fail ? 'failed' : 'warning'}: " \
278
+ "#{findings.length} #{noun} review\n#{lines.join("\n")}"
279
+ if policy == :warn
280
+ warn message
281
+ return false
282
+ end
283
+
284
+ puts message
285
+ true
286
+ end
287
+
288
+ def report_invalid_quarantine_dates(report)
289
+ findings = findings_with_quarantine_component(report, 'quarantine_invalid_date')
290
+ return if findings.empty?
291
+
292
+ noun = findings.length == 1 ? 'annotation has' : 'annotations have'
293
+ lines = findings.map { |finding| " #{finding.node.file}:#{finding.node.line} #{finding.node.id}" }
294
+ warn "Invalid quarantine date warning: #{findings.length} #{noun} an invalid since date\n#{lines.join("\n")}"
295
+ end
296
+
297
+ def findings_with_quarantine_component(report, name)
298
+ findings_with_quarantine_components(report, [name])
299
+ end
300
+
301
+ def findings_with_quarantine_components(report, names)
302
+ report.dead_methods(min_confidence: :low).select do |finding|
303
+ finding.score_components.any? { |component| names.include?(component.name) }
304
+ end
305
+ end
306
+
184
307
  def filtered_findings(report, options)
185
- findings = report.dead_methods(min_confidence: options[:fail_on])
308
+ findings = report.actionable_candidates(min_confidence: options[:fail_on])
186
309
  return findings unless options[:diff_base]
187
310
 
188
311
  project = Project.new(root: File.expand_path(options[:root]), config: report_config(options))
@@ -194,9 +317,60 @@ module Necropsy
194
317
  Configuration.load(root: File.expand_path(options[:root]), path: options[:config])
195
318
  end
196
319
 
320
+ def health_failure(report, options)
321
+ if options[:format] == :human
322
+ puts Reporter.render_analysis_health(report.analysis_health)
323
+ else
324
+ emit_report(report, options, min_confidence: options[:fail_on] || options[:min_confidence])
325
+ end
326
+ HEALTH_FAILURE_STATUS
327
+ end
328
+
329
+ def health_acceptable?(report, options, strict:)
330
+ health = report.analysis_health
331
+ return true if health.complete?
332
+
333
+ allowed = Array(options[:allow_degraded])
334
+ degraded_codes = health.reasons.filter_map do |reason|
335
+ reason['code'].to_s if reason['severity'].to_s == 'degraded'
336
+ end.uniq
337
+ explicitly_allowed = health.status == :degraded && degraded_codes.any? &&
338
+ (degraded_codes - allowed).empty?
339
+ return true if explicitly_allowed
340
+
341
+ !(strict || options[:strict_health])
342
+ end
343
+
344
+ def baseline(options, argv)
345
+ migration = argv.first == 'migrate'
346
+ argv.shift if migration
347
+ raise Error, "Unexpected baseline arguments: #{argv.join(' ')}" unless argv.empty?
348
+
349
+ report = analyze(options, ignored_reference_paths: [options[:baseline]])
350
+ return health_failure(report, options) unless health_acceptable?(report, options, strict: true)
351
+
352
+ path = File.expand_path(options[:baseline], options[:root])
353
+ if migration
354
+ comparison = Guardrail::Baseline.load(path).migrate(report.actionable_candidates(min_confidence: :low))
355
+ if comparison.review_required?
356
+ puts Reporter.render_baseline_review(comparison.review_report)
357
+ return 1
358
+ end
359
+ end
360
+ Guardrail::Baseline.write(report, path: path, clock: Clock.new(as_of: options[:as_of]))
361
+ puts "#{migration ? 'Migrated' : 'Wrote'} #{path}"
362
+ 0
363
+ end
364
+
197
365
  def quarantine(options)
198
366
  report = analyze(options)
199
- quarantine = Guardrail::Quarantine.new(report: report, root: File.expand_path(options[:root]))
367
+ return health_failure(report, options) unless health_acceptable?(report, options, strict: options[:write])
368
+
369
+ quarantine = Guardrail::Quarantine.new(
370
+ report: report,
371
+ root: File.expand_path(options[:root]),
372
+ clock: Clock.new(as_of: options[:as_of])
373
+ )
200
374
  if options[:write]
201
375
  quarantine.write(min_confidence: options[:min_confidence])
202
376
  puts 'Wrote quarantine annotations'
@@ -212,11 +386,14 @@ module Necropsy
212
386
  def bench(options)
213
387
  raise Error, '--gold-standard is required for bench' unless options[:gold_standard]
214
388
 
215
- report = analyze(options)
389
+ gold_standard_path = File.expand_path(options[:gold_standard])
390
+ report = analyze(options, ignored_reference_paths: [gold_standard_path])
391
+ return health_failure(report, options) unless health_acceptable?(report, options, strict: true)
392
+
216
393
  config = report_config(options)
217
394
  result = Bench::Evaluator.new(
218
395
  report: report,
219
- gold_standard_path: options[:gold_standard],
396
+ gold_standard_path: gold_standard_path,
220
397
  min_confidence: options[:min_confidence],
221
398
  root: options[:root],
222
399
  config_path: options[:config],
@@ -225,7 +402,7 @@ module Necropsy
225
402
  recall_threshold: options[:recall_threshold] || config.bench_recall_threshold
226
403
  ).call
227
404
  puts JSON.pretty_generate(result)
228
- 0
405
+ options[:bench_check] && !result.dig('release_criteria', 'passed') ? 1 : 0
229
406
  end
230
407
 
231
408
  def record(options, argv)
@@ -235,7 +412,7 @@ module Necropsy
235
412
  output = File.expand_path(options[:output] || 'tmp/necropsy_trace_point.yml', options[:root])
236
413
  FileUtils.mkdir_p(File.dirname(output))
237
414
  command = trace_command(options, command)
238
- run_id = SecureRandom.hex(16)
415
+ run_id = artifact_run_id(options, output, 'trace_point')
239
416
  status = system(trace_runtime_env(options, output, run_id), *command)
240
417
  puts "Wrote #{output}" if output_for_run?(output, run_id)
241
418
  return 0 if status
@@ -269,7 +446,7 @@ module Necropsy
269
446
  end
270
447
 
271
448
  def run_coverage_command(options, output, script_argv)
272
- run_id = SecureRandom.hex(16)
449
+ run_id = artifact_run_id(options, output, 'coverage')
273
450
  status = system(coverage_runtime_env(options, output, run_id), *script_argv)
274
451
  puts "Wrote #{output}" if output_for_run?(output, run_id)
275
452
  return 0 if status
@@ -339,5 +516,15 @@ module Necropsy
339
516
 
340
517
  raise OptionParser::InvalidArgument, "unknown confidence level: #{value}"
341
518
  end
519
+
520
+ def artifact_run_id(options, output, kind)
521
+ return @run_id_generator.call if @run_id_generator
522
+
523
+ epoch = @environment.fetch(Clock::SOURCE_DATE_EPOCH, nil)
524
+ reproducible = options[:as_of]&.iso8601 || epoch
525
+ return SecureRandom.hex(16) unless reproducible
526
+
527
+ Digest::SHA256.hexdigest([kind, File.expand_path(options[:root]), output, reproducible].join("\0"))[0, 32]
528
+ end
342
529
  end
343
530
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Necropsy
6
+ class Clock
7
+ SOURCE_DATE_EPOCH = 'SOURCE_DATE_EPOCH'
8
+
9
+ attr_reader :time
10
+
11
+ def initialize(as_of: nil, environment: ENV, now: nil)
12
+ @time = resolve_time(as_of, environment, now).utc.freeze
13
+ rescue ArgumentError, TypeError, RangeError
14
+ raise Error, 'as-of time and SOURCE_DATE_EPOCH must identify a valid date'
15
+ end
16
+
17
+ def date
18
+ time.to_date
19
+ end
20
+
21
+ private
22
+
23
+ def resolve_time(as_of, environment, now)
24
+ return time_for_as_of(as_of) unless as_of.nil?
25
+
26
+ epoch = environment[SOURCE_DATE_EPOCH]
27
+ return Time.at(Integer(epoch), in: '+00:00') unless epoch.nil? || epoch.empty?
28
+
29
+ value = now.respond_to?(:call) ? now.call : now
30
+ value || Time.now.utc
31
+ end
32
+
33
+ def time_for_as_of(value)
34
+ return value if value.is_a?(Time)
35
+
36
+ date = value.is_a?(Date) ? value : Date.iso8601(value.to_s)
37
+ Time.utc(date.year, date.month, date.day)
38
+ end
39
+ end
40
+ end