necropsy 0.1.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. metadata +106 -2
data/lib/necropsy/cli.rb CHANGED
@@ -1,38 +1,51 @@
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'
7
8
  require 'optparse'
9
+ require 'rbconfig'
8
10
  require 'securerandom'
9
11
  require 'yaml'
10
12
  require 'necropsy'
11
13
 
12
14
  module Necropsy
13
15
  class CLI
16
+ HEALTH_FAILURE_STATUS = 3
17
+
14
18
  def self.run(argv)
15
19
  new.run(argv)
16
20
  end
17
21
 
22
+ def initialize(run_id_generator: nil, environment: ENV)
23
+ @run_id_generator = run_id_generator
24
+ @environment = environment
25
+ end
26
+
18
27
  def run(argv)
19
28
  command = argv.first&.start_with?('-') ? 'analyze' : argv.shift || 'analyze'
20
29
  options = default_options
21
30
  parser = build_parser(options)
22
31
  parser.parse!(argv)
23
- apply_config_defaults(options)
32
+ if options[:help]
33
+ puts parser
34
+ return 0
35
+ end
36
+ if options[:version]
37
+ puts Necropsy::VERSION
38
+ return 0
39
+ end
40
+ apply_config_defaults(options) unless command == 'semantics'
24
41
 
25
42
  case command
26
43
  when 'analyze'
27
44
  report = analyze(options)
28
- puts Reporter.new(report).render(format: options[:format], min_confidence: options[:min_confidence])
29
- 0
45
+ emit_report(report, options, min_confidence: options[:min_confidence])
46
+ health_acceptable?(report, options, strict: false) ? 0 : HEALTH_FAILURE_STATUS
30
47
  when 'baseline'
31
- report = analyze(options)
32
- path = File.expand_path(options[:baseline], options[:root])
33
- Guardrail::Baseline.write(report, path: path)
34
- puts "Wrote #{path}"
35
- 0
48
+ baseline(options, argv)
36
49
  when 'check'
37
50
  check(options)
38
51
  when 'quarantine'
@@ -43,12 +56,16 @@ module Necropsy
43
56
  record(options, argv)
44
57
  when 'coverage'
45
58
  coverage(options, argv)
59
+ when 'semantics'
60
+ semantics(options, argv)
61
+ when 'why', 'why-not', 'explain'
62
+ diagnose(command, options, argv)
46
63
  else
47
64
  warn "Unknown command: #{command}"
48
65
  warn parser
49
66
  2
50
67
  end
51
- rescue OptionParser::ParseError, Error => e
68
+ rescue OptionParser::ParseError, Psych::Exception, Error, GraphSelfCheck::Failure => e
52
69
  warn e.message
53
70
  2
54
71
  end
@@ -60,18 +77,26 @@ module Necropsy
60
77
  root: '.',
61
78
  config: nil,
62
79
  format: :human,
63
- min_confidence: :low,
80
+ min_confidence: Reporter::DEFAULT_MIN_CONFIDENCE,
64
81
  baseline: nil,
65
82
  fail_on: nil,
66
83
  diff_base: nil,
67
84
  ratchet: false,
68
85
  write: false,
69
86
  gold_standard: nil,
70
- output: 'tmp/necropsy_trace_point.yml',
87
+ output: nil,
71
88
  sample_rate: 1.0,
72
89
  ablation: false,
90
+ bench_check: false,
73
91
  precision_threshold: nil,
74
- recall_threshold: nil
92
+ recall_threshold: nil,
93
+ as_of: nil,
94
+ strict_health: false,
95
+ allow_degraded: [],
96
+ help: false,
97
+ version: false,
98
+ include_graph: false,
99
+ self_check: false
75
100
  }
76
101
  end
77
102
 
@@ -80,12 +105,18 @@ module Necropsy
80
105
  parser.banner = 'Usage: necropsy COMMAND [options]'
81
106
  parser.on('--root PATH', 'Project root') { |value| options[:root] = value }
82
107
  parser.on('--config PATH', 'Configuration file') { |value| options[:config] = value }
83
- parser.on('--format FORMAT', 'human, json, yaml, sarif, or github') { |value| options[:format] = value.to_sym }
108
+ parser.on('--format FORMAT', Reporter::FORMATS.map(&:to_s), 'Output format') do |value|
109
+ options[:format] = value.to_sym
110
+ end
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 }
84
113
  parser.on('--min-confidence LEVEL', 'low, medium, high, or certain') do |value|
85
- options[:min_confidence] = value.to_sym
114
+ options[:min_confidence] = confidence_level(value)
86
115
  end
87
116
  parser.on('--baseline PATH', 'Baseline path') { |value| options[:baseline] = value }
88
- parser.on('--fail-on LEVEL', 'CI failure threshold') { |value| options[:fail_on] = value.to_sym }
117
+ parser.on('--fail-on LEVEL', 'CI failure threshold') do |value|
118
+ options[:fail_on] = confidence_level(value)
119
+ end
89
120
  parser.on('--diff-base REV', 'Restrict reported findings to files changed since REV') do |value|
90
121
  options[:diff_base] = value
91
122
  end
@@ -94,24 +125,69 @@ module Necropsy
94
125
  parser.on('--gold-standard PATH', 'Gold standard YAML for bench') { |value| options[:gold_standard] = value }
95
126
  parser.on('--output PATH', 'Output path for record') { |value| options[:output] = value }
96
127
  parser.on('--sample-rate RATE', Float, 'TracePoint sample rate for record') do |value|
128
+ raise OptionParser::InvalidArgument, 'sample rate must be between 0.0 and 1.0' unless value.between?(0.0, 1.0)
129
+
97
130
  options[:sample_rate] = value
98
131
  end
99
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 }
100
134
  parser.on('--precision-threshold N', Float, 'Bench release precision threshold') do |value|
101
135
  options[:precision_threshold] = value
102
136
  end
103
137
  parser.on('--recall-threshold N', Float, 'Bench release recall threshold') do |value|
104
138
  options[:recall_threshold] = value
105
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
106
157
  parser.on('-h', '--help', 'Show help') do
107
- puts parser
108
- exit 0
158
+ options[:help] = true
109
159
  end
160
+ parser.on('-v', '--version', 'Show version') { options[:version] = true }
110
161
  end
111
162
  end
112
163
 
113
- def analyze(options)
114
- 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
173
+ end
174
+
175
+ def diagnose(command, options, argv)
176
+ node_id = argv.shift
177
+ raise Error, "#{command} requires a symbol or definition ID" unless node_id
178
+ raise Error, "Unexpected arguments for #{command}: #{argv.join(' ')}" unless argv.empty?
179
+
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
189
+ puts diagnostics.render(payload, format: options[:format])
190
+ 0
115
191
  end
116
192
 
117
193
  def apply_config_defaults(options)
@@ -121,31 +197,115 @@ module Necropsy
121
197
  end
122
198
 
123
199
  def check(options)
124
- 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))
125
205
  findings = filtered_findings(report, options)
126
206
  baseline_path = File.expand_path(options[:baseline], options[:root])
127
207
  baseline = Guardrail::Baseline.load(baseline_path)
128
- 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
129
214
 
130
- if options[:ratchet] && findings.length > baseline.fingerprints.length
131
- puts "Ratchet failed: #{findings.length} findings exceed baseline count #{baseline.fingerprints.length}"
215
+ baseline_count = baseline.count_at_least(options[:fail_on])
216
+ if options[:ratchet] && findings.length > baseline_count
217
+ puts "Ratchet failed: #{findings.length} findings exceed baseline count #{baseline_count}"
132
218
  return 1
133
219
  end
134
220
 
135
221
  if failures.any?
136
- puts Reporter.new(Report.new(root: report.root, graph: report.graph, findings: failures)).render(
137
- format: :human,
138
- min_confidence: options[:fail_on]
139
- )
222
+ emit_report(report_with_findings(report, failures), options, min_confidence: options[:fail_on])
140
223
  return 1
141
224
  end
142
225
 
226
+ return 1 if expiry_failure
227
+
143
228
  puts 'Necropsy check passed'
144
229
  0
145
230
  end
146
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
+
147
307
  def filtered_findings(report, options)
148
- findings = report.dead_methods(min_confidence: options[:fail_on])
308
+ findings = report.actionable_candidates(min_confidence: options[:fail_on])
149
309
  return findings unless options[:diff_base]
150
310
 
151
311
  project = Project.new(root: File.expand_path(options[:root]), config: report_config(options))
@@ -157,9 +317,60 @@ module Necropsy
157
317
  Configuration.load(root: File.expand_path(options[:root]), path: options[:config])
158
318
  end
159
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
+
160
365
  def quarantine(options)
161
366
  report = analyze(options)
162
- 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
+ )
163
374
  if options[:write]
164
375
  quarantine.write(min_confidence: options[:min_confidence])
165
376
  puts 'Wrote quarantine annotations'
@@ -175,11 +386,14 @@ module Necropsy
175
386
  def bench(options)
176
387
  raise Error, '--gold-standard is required for bench' unless options[:gold_standard]
177
388
 
178
- 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
+
179
393
  config = report_config(options)
180
394
  result = Bench::Evaluator.new(
181
395
  report: report,
182
- gold_standard_path: options[:gold_standard],
396
+ gold_standard_path: gold_standard_path,
183
397
  min_confidence: options[:min_confidence],
184
398
  root: options[:root],
185
399
  config_path: options[:config],
@@ -188,38 +402,29 @@ module Necropsy
188
402
  recall_threshold: options[:recall_threshold] || config.bench_recall_threshold
189
403
  ).call
190
404
  puts JSON.pretty_generate(result)
191
- 0
405
+ options[:bench_check] && !result.dig('release_criteria', 'passed') ? 1 : 0
192
406
  end
193
407
 
194
408
  def record(options, argv)
195
- script_argv = argv.dup
196
- script_argv.shift if script_argv.first == 'ruby'
197
- script = script_argv.shift
198
- raise Error, 'record requires a Ruby script after --' unless script
409
+ command = argv.dup
410
+ raise Error, 'record requires a Ruby script or command after --' if command.empty?
199
411
 
200
- output = File.expand_path(options[:output], options[:root])
412
+ output = File.expand_path(options[:output] || 'tmp/necropsy_trace_point.yml', options[:root])
201
413
  FileUtils.mkdir_p(File.dirname(output))
414
+ command = trace_command(options, command)
415
+ run_id = artifact_run_id(options, output, 'trace_point')
416
+ status = system(trace_runtime_env(options, output, run_id), *command)
417
+ puts "Wrote #{output}" if output_for_run?(output, run_id)
418
+ return 0 if status
202
419
 
203
- previous_argv = ARGV.dup
204
- ARGV.replace(script_argv)
205
- Analyzers::Dynamic::TracePointCollector.record(
206
- root: File.expand_path(options[:root]),
207
- output: output,
208
- sample_rate: options[:sample_rate]
209
- ) do
210
- load File.expand_path(script, options[:root])
211
- end
212
- puts "Wrote #{output}"
213
- 0
214
- ensure
215
- ARGV.replace(previous_argv) if previous_argv
420
+ $CHILD_STATUS&.exitstatus || 1
216
421
  end
217
422
 
218
423
  def coverage(options, argv)
219
424
  script_argv = argv.dup
220
425
  raise Error, 'coverage requires a Ruby script or command after --' if script_argv.empty?
221
426
 
222
- output = File.expand_path(options[:output].sub('trace_point', 'coverage'), options[:root])
427
+ output = File.expand_path(options[:output] || 'tmp/necropsy_coverage.yml', options[:root])
223
428
  FileUtils.mkdir_p(File.dirname(output))
224
429
 
225
430
  return record_coverage_script(options, output, script_argv) if local_ruby_script?(options, script_argv)
@@ -241,7 +446,7 @@ module Necropsy
241
446
  end
242
447
 
243
448
  def run_coverage_command(options, output, script_argv)
244
- run_id = SecureRandom.hex(16)
449
+ run_id = artifact_run_id(options, output, 'coverage')
245
450
  status = system(coverage_runtime_env(options, output, run_id), *script_argv)
246
451
  puts "Wrote #{output}" if output_for_run?(output, run_id)
247
452
  return 0 if status
@@ -272,16 +477,54 @@ module Necropsy
272
477
  }
273
478
  end
274
479
 
480
+ def trace_runtime_env(options, output, run_id)
481
+ rubyopt = [ENV.fetch('RUBYOPT', nil), '-rnecropsy/trace_point_runtime'].compact.reject(&:empty?).join(' ')
482
+ {
483
+ 'NECROPSY_TRACE_ROOT' => File.expand_path(options[:root]),
484
+ 'NECROPSY_TRACE_OUTPUT' => output,
485
+ 'NECROPSY_TRACE_SAMPLE_RATE' => options[:sample_rate].to_s,
486
+ 'NECROPSY_TRACE_MERGE' => '1',
487
+ 'NECROPSY_TRACE_RUN_ID' => run_id,
488
+ 'RUBYOPT' => rubyopt,
489
+ 'RUBYLIB' => rubylib
490
+ }
491
+ end
492
+
493
+ def trace_command(options, command)
494
+ script = command.first
495
+ path = File.expand_path(script, options[:root])
496
+ return [RbConfig.ruby, path, *command.drop(1)] if script.end_with?('.rb') && File.file?(path)
497
+
498
+ command
499
+ end
500
+
275
501
  def rubylib
276
502
  paths = [File.expand_path('..', __dir__), ENV.fetch('RUBYLIB', nil)].compact.reject(&:empty?)
277
503
  paths.join(File::PATH_SEPARATOR)
278
504
  end
279
505
 
280
506
  def output_for_run?(output, run_id)
281
- payload = YAML.load_file(output) || {}
507
+ payload = YAML.safe_load_file(output, aliases: false) || {}
282
508
  payload.dig('observation', 'run_id') == run_id
283
509
  rescue SystemCallError, Psych::Exception
284
510
  false
285
511
  end
512
+
513
+ def confidence_level(value)
514
+ level = value.to_sym
515
+ return level if CONFIDENCE_LEVELS.key?(level)
516
+
517
+ raise OptionParser::InvalidArgument, "unknown confidence level: #{value}"
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
286
529
  end
287
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