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
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 '
|
|
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(
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|