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.
- checksums.yaml +4 -4
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- 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 +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- 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 +225 -20
- 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 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- 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 +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- 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 +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- 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 +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- 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 +1234 -62
- 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 +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -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 +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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',
|
|
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
|
|
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')
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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]
|
|
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 =
|
|
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.
|
|
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
|