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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'rbconfig'
|
|
7
|
+
|
|
8
|
+
module Necropsy
|
|
9
|
+
module Bench
|
|
10
|
+
class ReleaseAudit
|
|
11
|
+
class RunProvenance
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
def initialize(root:, manifest_path:, config_path:, output_dir:, command:)
|
|
15
|
+
@root = File.expand_path(root)
|
|
16
|
+
@manifest_path = File.expand_path(manifest_path)
|
|
17
|
+
@config_path = File.expand_path(config_path)
|
|
18
|
+
@output_dir = File.expand_path(output_dir)
|
|
19
|
+
@command = command
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def capture_source!
|
|
23
|
+
ensure_clean!
|
|
24
|
+
{
|
|
25
|
+
'schema_version' => SCHEMA_VERSION,
|
|
26
|
+
'git_ref' => git_head,
|
|
27
|
+
'clean' => true,
|
|
28
|
+
'manifest_sha256' => digest(manifest_path),
|
|
29
|
+
'audit_config_sha256' => digest(config_path)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def complete(source, summary)
|
|
34
|
+
source.merge(
|
|
35
|
+
'environment' => environment(summary),
|
|
36
|
+
'artifacts' => artifact_digests
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write(path, payload)
|
|
41
|
+
File.write(path, "#{JSON.pretty_generate(payload)}\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def load_and_validate!(path)
|
|
45
|
+
payload = JSON.parse(File.read(path))
|
|
46
|
+
expected = capture_source!
|
|
47
|
+
expected.each do |key, value|
|
|
48
|
+
raise Error, "Benchmark provenance mismatch for #{key}" unless payload[key] == value
|
|
49
|
+
end
|
|
50
|
+
validate_environment!(payload.fetch('environment'))
|
|
51
|
+
raise Error, 'Benchmark provenance mismatch for artifacts' unless payload['artifacts'] == artifact_digests
|
|
52
|
+
|
|
53
|
+
payload
|
|
54
|
+
rescue Errno::ENOENT, JSON::ParserError => e
|
|
55
|
+
raise Error, "Could not load benchmark provenance: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
attr_reader :root, :manifest_path, :config_path, :output_dir, :command
|
|
61
|
+
|
|
62
|
+
def ensure_clean!
|
|
63
|
+
output, status = Open3.capture2e('git', 'status', '--porcelain', chdir: root)
|
|
64
|
+
raise Error, "Could not inspect audit worktree: #{output.strip}" unless status.success?
|
|
65
|
+
raise Error, 'Release audit requires a clean tracked worktree' unless output.strip.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def git_head
|
|
69
|
+
output, status = Open3.capture2e('git', 'rev-parse', 'HEAD', chdir: root)
|
|
70
|
+
raise Error, "Could not resolve audit HEAD: #{output.strip}" unless status.success?
|
|
71
|
+
|
|
72
|
+
output.strip
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def digest(path)
|
|
76
|
+
Digest::SHA256.file(path).hexdigest
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def environment(summary)
|
|
80
|
+
performances = summary.fetch('corpora').filter_map { |corpus| corpus['performance'] }
|
|
81
|
+
rss_kinds = performances.filter_map { |item| item['rss_kind'] }.uniq
|
|
82
|
+
rss_scopes = performances.filter_map { |item| item['rss_scope'] }.uniq
|
|
83
|
+
{
|
|
84
|
+
'ruby' => RUBY_DESCRIPTION,
|
|
85
|
+
'os' => operating_system,
|
|
86
|
+
'command' => command,
|
|
87
|
+
'rss_kind' => rss_kinds.one? ? rss_kinds.first : 'unavailable_or_mixed',
|
|
88
|
+
'rss_scope' => rss_scopes.one? ? rss_scopes.first : 'unavailable_or_mixed'
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_environment!(recorded)
|
|
93
|
+
actual = {
|
|
94
|
+
'ruby' => RUBY_DESCRIPTION,
|
|
95
|
+
'os' => operating_system,
|
|
96
|
+
'command' => command
|
|
97
|
+
}
|
|
98
|
+
mismatch = actual.find { |key, value| recorded[key] != value }
|
|
99
|
+
raise Error, "Benchmark provenance mismatch for environment #{mismatch.first}" if mismatch
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def artifact_digests
|
|
103
|
+
paths = [File.join(output_dir, 'summary.json'), *Dir.glob(File.join(output_dir, 'reports', '*.json'))]
|
|
104
|
+
raise Error, 'Benchmark provenance requires a summary and normalized reports' unless paths.length > 1
|
|
105
|
+
|
|
106
|
+
paths.sort.to_h do |path|
|
|
107
|
+
relative = path.delete_prefix("#{output_dir}/")
|
|
108
|
+
[relative, digest(path)]
|
|
109
|
+
end
|
|
110
|
+
rescue Errno::ENOENT => e
|
|
111
|
+
raise Error, "Could not hash benchmark artifacts: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def operating_system
|
|
115
|
+
version = capture('sw_vers', '-productVersion')
|
|
116
|
+
build = capture('sw_vers', '-buildVersion')
|
|
117
|
+
kernel = capture('uname', '-r')
|
|
118
|
+
machine = capture('uname', '-m')
|
|
119
|
+
return "macOS #{version} (#{build}), Darwin #{kernel} #{machine}" if version && build && kernel && machine
|
|
120
|
+
|
|
121
|
+
[RbConfig::CONFIG['host_os'], RbConfig::CONFIG['host_cpu']].compact.join(' ')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def capture(*command_parts)
|
|
125
|
+
output, status = Open3.capture2e(*command_parts)
|
|
126
|
+
status.success? ? output.strip : nil
|
|
127
|
+
rescue SystemCallError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'release_audit/config_validator'
|
|
4
|
+
require_relative 'release_audit/performance_gate'
|
|
5
|
+
require_relative 'precision_gate'
|
|
6
|
+
require_relative 'claim_gate'
|
|
7
|
+
|
|
8
|
+
module Necropsy
|
|
9
|
+
module Bench
|
|
10
|
+
class ReleaseAudit
|
|
11
|
+
HIGH_CONFIDENCES = %w[high certain].freeze
|
|
12
|
+
FALSE_POSITIVE_LABELS = %w[alive external].freeze
|
|
13
|
+
REVIEW_FIELDS = %w[outcome rationale reviewer].freeze
|
|
14
|
+
REVIEW_OUTCOMES = %w[expected_safety_change false_positive true_positive].freeze
|
|
15
|
+
MAX_ENTRIES = 100_000
|
|
16
|
+
MAX_STRING_BYTES = 4_096
|
|
17
|
+
|
|
18
|
+
def initialize(inputs)
|
|
19
|
+
@inputs = inputs
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
validate_inputs!
|
|
24
|
+
comparisons = compare_reports
|
|
25
|
+
new_high = newly_high_candidates(comparisons)
|
|
26
|
+
review = review_status(comparisons)
|
|
27
|
+
performance = performance_status
|
|
28
|
+
precision = precision_gate_status
|
|
29
|
+
claim = claim_gate_status
|
|
30
|
+
gates = build_gates(new_high, review, performance, precision, claim)
|
|
31
|
+
{
|
|
32
|
+
'schema_version' => 1,
|
|
33
|
+
'release' => config.fetch('release'),
|
|
34
|
+
'baseline' => config.fetch('baseline'),
|
|
35
|
+
'corpora' => comparisons,
|
|
36
|
+
'new_high_candidates' => new_high,
|
|
37
|
+
'review' => review,
|
|
38
|
+
'performance' => performance,
|
|
39
|
+
'precision_gate' => precision,
|
|
40
|
+
'claim_gate' => claim,
|
|
41
|
+
'performance_provenance' => performance_provenance,
|
|
42
|
+
'adversarial_suites' => adversarial_results,
|
|
43
|
+
'gates' => gates,
|
|
44
|
+
'status' => gates.values.all? { |gate| gate['passed'] } ? 'pass' : 'fail'
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :inputs
|
|
51
|
+
|
|
52
|
+
def config = inputs.fetch(:config)
|
|
53
|
+
def baseline_reports = inputs.fetch(:baseline_reports)
|
|
54
|
+
def current_reports = inputs.fetch(:current_reports)
|
|
55
|
+
def current_summary = inputs.fetch(:current_summary)
|
|
56
|
+
def labels = inputs.fetch(:labels)
|
|
57
|
+
def reviews = inputs.fetch(:reviews)
|
|
58
|
+
def baseline_performance = inputs.fetch(:baseline_performance)
|
|
59
|
+
def adversarial_results = inputs.fetch(:adversarial_results)
|
|
60
|
+
def current_provenance = inputs.fetch(:current_provenance)
|
|
61
|
+
|
|
62
|
+
def validate_inputs!
|
|
63
|
+
ConfigValidator.new(config, strict_release: false).validate!
|
|
64
|
+
validate_mapping!(baseline_reports, 'baseline reports')
|
|
65
|
+
validate_mapping!(current_reports, 'current reports')
|
|
66
|
+
config.fetch('corpora').each do |corpus|
|
|
67
|
+
validate_report!(baseline_reports.fetch(corpus), "baseline report #{corpus}")
|
|
68
|
+
validate_report!(current_reports.fetch(corpus), "current report #{corpus}")
|
|
69
|
+
end
|
|
70
|
+
validate_mapping!(current_summary, 'current summary')
|
|
71
|
+
validate_mapping!(labels, 'labels')
|
|
72
|
+
validate_array!(reviews, 'reviews')
|
|
73
|
+
reviews.each { |review| validate_mapping!(review, 'review') }
|
|
74
|
+
validate_array!(adversarial_results, 'adversarial results')
|
|
75
|
+
adversarial_results.each { |result| validate_mapping!(result, 'adversarial result') }
|
|
76
|
+
expected = config.fetch('adversarial_suites').keys.sort
|
|
77
|
+
actual = adversarial_results.map { |result| result.fetch('name') }.sort
|
|
78
|
+
return if actual == expected
|
|
79
|
+
|
|
80
|
+
raise Error, "Adversarial results do not match configured suites: #{actual.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def compare_reports
|
|
84
|
+
config.fetch('corpora').sort.to_h do |corpus|
|
|
85
|
+
baseline = index_findings(baseline_reports.fetch(corpus))
|
|
86
|
+
current = index_findings(current_reports.fetch(corpus))
|
|
87
|
+
added_ids = current.keys - baseline.keys
|
|
88
|
+
removed_ids = baseline.keys - current.keys
|
|
89
|
+
common_ids = baseline.keys & current.keys
|
|
90
|
+
state_changed_ids = common_ids.reject { |id| baseline[id]['state'] == current[id]['state'] }
|
|
91
|
+
confidence_changed_ids = common_ids.reject do |id|
|
|
92
|
+
baseline[id]['confidence'] == current[id]['confidence']
|
|
93
|
+
end
|
|
94
|
+
[corpus, {
|
|
95
|
+
'baseline_metrics' => baseline_reports.fetch(corpus).fetch('metrics'),
|
|
96
|
+
'current_metrics' => current_reports.fetch(corpus).fetch('metrics'),
|
|
97
|
+
'added' => change_list(added_ids, nil, current),
|
|
98
|
+
'removed' => change_list(removed_ids, baseline, nil),
|
|
99
|
+
'state_changed' => change_list(state_changed_ids, baseline, current),
|
|
100
|
+
'confidence_changed' => change_list(confidence_changed_ids, baseline, current)
|
|
101
|
+
}]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def index_findings(report)
|
|
106
|
+
report.fetch('findings').each_with_object({}) do |finding, index|
|
|
107
|
+
identity = finding_identity(finding)
|
|
108
|
+
raise Error, "Duplicate release-audit finding identity #{identity}" if index.key?(identity)
|
|
109
|
+
|
|
110
|
+
index[identity] = finding
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def change_list(ids, baseline, current)
|
|
115
|
+
ids.sort.map do |identity|
|
|
116
|
+
finding = current&.fetch(identity) || baseline&.fetch(identity)
|
|
117
|
+
{
|
|
118
|
+
'id' => finding.fetch('id'),
|
|
119
|
+
'definition_id' => finding['definition_id'],
|
|
120
|
+
'identity' => identity,
|
|
121
|
+
'before' => baseline&.fetch(identity),
|
|
122
|
+
'after' => current&.fetch(identity)
|
|
123
|
+
}.compact
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def newly_high_candidates(comparisons)
|
|
128
|
+
comparisons.flat_map do |corpus, _comparison|
|
|
129
|
+
baseline = index_findings(baseline_reports.fetch(corpus))
|
|
130
|
+
current = index_findings(current_reports.fetch(corpus))
|
|
131
|
+
current.filter_map do |identity, finding|
|
|
132
|
+
next unless high_actionable?(finding) && !high_actionable?(baseline[identity])
|
|
133
|
+
|
|
134
|
+
label, identity_match = label_for(corpus, finding)
|
|
135
|
+
finding.slice('id', 'definition_id', 'path', 'line', 'state', 'confidence').merge(
|
|
136
|
+
'corpus' => corpus,
|
|
137
|
+
'identity' => identity,
|
|
138
|
+
'label' => label,
|
|
139
|
+
'label_identity_match' => identity_match
|
|
140
|
+
).compact
|
|
141
|
+
end
|
|
142
|
+
end.sort_by { |candidate| [candidate['corpus'], candidate['id'], candidate['definition_id'].to_s] }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def label_for(corpus, finding)
|
|
146
|
+
definition_id = finding['definition_id']
|
|
147
|
+
physical = labels[[corpus, definition_id]] if definition_id
|
|
148
|
+
return [physical, 'physical'] if physical
|
|
149
|
+
|
|
150
|
+
legacy = labels[[corpus, finding.fetch('id')]]
|
|
151
|
+
[legacy, legacy ? 'legacy_logical_fallback' : nil]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def high?(finding)
|
|
155
|
+
finding && HIGH_CONFIDENCES.include?(finding['confidence'])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def high_actionable?(finding)
|
|
159
|
+
high?(finding) && finding.fetch('candidate') { %w[unreachable unused candidate].include?(finding['state']) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def review_status(comparisons)
|
|
163
|
+
required = required_reviews(comparisons)
|
|
164
|
+
supplied = review_indexes
|
|
165
|
+
missing = required.reject { |item| valid_review?(review_for(item, supplied)) }
|
|
166
|
+
reviewed = required.filter_map do |item|
|
|
167
|
+
supplied_review = review_for(item, supplied)
|
|
168
|
+
item.merge(supplied_review) if valid_review?(supplied_review)
|
|
169
|
+
end
|
|
170
|
+
invalid = required.filter_map do |item|
|
|
171
|
+
supplied_review = review_for(item, supplied)
|
|
172
|
+
item.merge(supplied_review) if supplied_review && !valid_review?(supplied_review)
|
|
173
|
+
end
|
|
174
|
+
{
|
|
175
|
+
'required' => required,
|
|
176
|
+
'completed' => reviewed.sort_by { |item| review_key(item) },
|
|
177
|
+
'missing' => missing,
|
|
178
|
+
'invalid' => invalid.sort_by { |item| review_key(item) },
|
|
179
|
+
'coverage' => review_coverage(comparisons, required, reviewed),
|
|
180
|
+
'confirmed_false_positives' => reviewed.select { |item| item['outcome'] == 'false_positive' }
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def review_indexes
|
|
185
|
+
reviews.each_with_object({ physical: {}, legacy: {} }) do |review, indexes|
|
|
186
|
+
base = [review['corpus'], review['change_type']]
|
|
187
|
+
identity = review['definition_id']
|
|
188
|
+
target = identity ? indexes[:physical] : indexes[:legacy]
|
|
189
|
+
key = base + [identity || review['id']]
|
|
190
|
+
raise Error, "Duplicate release-audit review identity #{key.join(':')}" if target.key?(key)
|
|
191
|
+
|
|
192
|
+
target[key] = review
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def review_for(item, indexes)
|
|
197
|
+
base = [item['corpus'], item['change_type']]
|
|
198
|
+
definition_id = review_definition_id(item)
|
|
199
|
+
physical = indexes[:physical][base + [definition_id]] if definition_id
|
|
200
|
+
physical || indexes[:legacy][base + [item['id']]]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def valid_review?(review)
|
|
204
|
+
review && REVIEW_FIELDS.all? { |field| !review[field].to_s.strip.empty? } &&
|
|
205
|
+
REVIEW_OUTCOMES.include?(review['outcome'])
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def review_coverage(comparisons, required, reviewed)
|
|
209
|
+
config.fetch('review').fetch('corpora').sort.to_h do |corpus, policy|
|
|
210
|
+
changes = review_records(corpus, comparisons.fetch(corpus))
|
|
211
|
+
[corpus, {
|
|
212
|
+
'strategy' => policy.fetch('strategy'),
|
|
213
|
+
'changes' => changes.length,
|
|
214
|
+
'required' => required.count { |item| item['corpus'] == corpus },
|
|
215
|
+
'completed' => reviewed.count { |item| item['corpus'] == corpus },
|
|
216
|
+
'zero_difference' => changes.empty?
|
|
217
|
+
}]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def required_reviews(comparisons)
|
|
222
|
+
config.fetch('review').fetch('corpora').sort.flat_map do |corpus, policy|
|
|
223
|
+
records = review_records(corpus, comparisons.fetch(corpus))
|
|
224
|
+
next records if policy.fetch('strategy') == 'all'
|
|
225
|
+
|
|
226
|
+
records.group_by { |record| review_stratum(record) }.sort.flat_map do |_stratum, items|
|
|
227
|
+
items.sort_by { |item| [item['id'], review_definition_id(item).to_s] }
|
|
228
|
+
.first(Integer(policy.fetch('minimum_per_stratum')))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def review_records(corpus, comparison)
|
|
234
|
+
%w[added removed state_changed].flat_map do |type|
|
|
235
|
+
comparison.fetch(type).map do |change|
|
|
236
|
+
change.merge('corpus' => corpus, 'change_type' => type)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def review_stratum(record)
|
|
242
|
+
[record['change_type'], record.dig('before', 'state'), record.dig('after', 'state')].join(':')
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def review_key(record)
|
|
246
|
+
[record['corpus'], record['change_type'], review_definition_id(record) || record['id']]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def review_definition_id(record)
|
|
250
|
+
record['definition_id'] || record.dig('after', 'definition_id') || record.dig('before', 'definition_id')
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def performance_status
|
|
254
|
+
PerformanceGate.new(
|
|
255
|
+
config: config,
|
|
256
|
+
baseline: baseline_performance,
|
|
257
|
+
current_summary: current_summary,
|
|
258
|
+
current_provenance: current_provenance
|
|
259
|
+
).call
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def performance_provenance
|
|
263
|
+
{
|
|
264
|
+
'baseline' => baseline_performance.except('corpora'),
|
|
265
|
+
'current' => current_provenance
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def precision_gate_status
|
|
270
|
+
policy = config['precision_gate']
|
|
271
|
+
unless policy
|
|
272
|
+
return {
|
|
273
|
+
'schema_version' => 1,
|
|
274
|
+
'enforced' => false,
|
|
275
|
+
'compatibility' => 'releases before 0.4 retain the safety-only release policy',
|
|
276
|
+
'passed' => true
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
PrecisionGate.new(
|
|
281
|
+
policy: policy,
|
|
282
|
+
candidate_union_summary: current_summary['candidate_union'],
|
|
283
|
+
feature_ablation: current_summary['feature_ablation']
|
|
284
|
+
).call
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def claim_gate_status
|
|
288
|
+
ClaimGate.new(
|
|
289
|
+
config: config,
|
|
290
|
+
reports: current_reports,
|
|
291
|
+
summary: current_summary,
|
|
292
|
+
adversarial_results: adversarial_results
|
|
293
|
+
).call
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def build_gates(new_high, review, performance, precision, claim)
|
|
297
|
+
invalid_labels = new_high.reject { |candidate| valid_label?(candidate['label']) }
|
|
298
|
+
false_positive_labels = new_high.select do |candidate|
|
|
299
|
+
FALSE_POSITIVE_LABELS.include?(candidate.dig('label', 'value'))
|
|
300
|
+
end
|
|
301
|
+
unresolved_labels = new_high.select { |candidate| candidate.dig('label', 'value') == 'unknown' }
|
|
302
|
+
gates = {
|
|
303
|
+
'new_high_reviewed' => gate(invalid_labels.empty?, invalid_labels.length),
|
|
304
|
+
'new_high_false_positives' => gate(false_positive_labels.empty?, false_positive_labels.length),
|
|
305
|
+
'new_high_unresolved' => gate(unresolved_labels.empty?, unresolved_labels.length),
|
|
306
|
+
'difference_review' => gate(review['missing'].empty?, review['missing'].length),
|
|
307
|
+
'review_false_positives' => gate(review['confirmed_false_positives'].empty?,
|
|
308
|
+
review['confirmed_false_positives'].length),
|
|
309
|
+
'performance' => gate(performance.values.all? { |result| result['passed'] },
|
|
310
|
+
performance.count { |_corpus, result| !result['passed'] }),
|
|
311
|
+
'adversarial' => gate(adversarial_results.all? { |result| result['passed'] },
|
|
312
|
+
adversarial_results.count { |result| !result['passed'] })
|
|
313
|
+
}
|
|
314
|
+
gates['precision_quality'] = gate(precision['passed'], precision['passed'] ? 0 : 1) if precision['enforced']
|
|
315
|
+
gates['public_claim'] = gate(claim['passed'], claim['passed'] ? 0 : 1) if claim['enforced']
|
|
316
|
+
gates
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def valid_label?(label)
|
|
320
|
+
label && %w[dead alive external unknown].include?(label['value']) &&
|
|
321
|
+
!label['rationale'].to_s.strip.empty? && !label['reviewer'].to_s.strip.empty?
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def finding_identity(finding)
|
|
325
|
+
finding['definition_id'] || finding.fetch('id')
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def validate_report!(report, label)
|
|
329
|
+
validate_mapping!(report, label)
|
|
330
|
+
validate_mapping!(report['metrics'], "#{label} metrics")
|
|
331
|
+
validate_array!(report['findings'], "#{label} findings")
|
|
332
|
+
report['findings'].each do |finding|
|
|
333
|
+
validate_mapping!(finding, "#{label} finding")
|
|
334
|
+
validate_identifier!(finding['id'], "#{label} finding id")
|
|
335
|
+
validate_identifier!(finding['definition_id'], "#{label} finding definition_id") if
|
|
336
|
+
finding.key?('definition_id')
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def validate_mapping!(value, label)
|
|
341
|
+
raise Error, "#{label} must be a mapping" unless value.is_a?(Hash)
|
|
342
|
+
raise Error, "#{label} exceeds #{MAX_ENTRIES} entries" if value.length > MAX_ENTRIES
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def validate_array!(value, label)
|
|
346
|
+
raise Error, "#{label} must be an array" unless value.is_a?(Array)
|
|
347
|
+
raise Error, "#{label} exceeds #{MAX_ENTRIES} entries" if value.length > MAX_ENTRIES
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def validate_identifier!(value, label)
|
|
351
|
+
raise Error, "#{label} must be a non-empty string" unless value.is_a?(String) && !value.empty?
|
|
352
|
+
raise Error, "#{label} exceeds #{MAX_STRING_BYTES} bytes" if value.bytesize > MAX_STRING_BYTES
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def gate(passed, failures)
|
|
356
|
+
{ 'passed' => passed, 'failures' => failures }
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'finding_facts'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module Bench
|
|
8
|
+
class ReportNormalizer
|
|
9
|
+
VERSION = 1
|
|
10
|
+
|
|
11
|
+
def initialize(report:, corpus:)
|
|
12
|
+
@report = report
|
|
13
|
+
@corpus = corpus
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
{
|
|
18
|
+
'schema_version' => VERSION,
|
|
19
|
+
'corpus' => corpus,
|
|
20
|
+
'metrics' => metrics,
|
|
21
|
+
'states' => state_counts,
|
|
22
|
+
'quality' => quality,
|
|
23
|
+
'findings' => normalized_findings
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dump
|
|
28
|
+
"#{JSON.pretty_generate(call)}\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :report, :corpus
|
|
34
|
+
|
|
35
|
+
def metrics
|
|
36
|
+
{
|
|
37
|
+
'analysis_graph' => {
|
|
38
|
+
'scope' => 'analysis',
|
|
39
|
+
'nodes' => report.graph.nodes.length,
|
|
40
|
+
'definitions' => report.graph.method_nodes.length,
|
|
41
|
+
'call_sites' => report.graph.call_sites.length,
|
|
42
|
+
'edges' => report.graph.edges.length
|
|
43
|
+
},
|
|
44
|
+
'findings' => reportable_findings.length,
|
|
45
|
+
'actionable_candidates' => actionable_findings.length,
|
|
46
|
+
'candidate_loc' => actionable_findings.sum { |finding| FindingFacts.loc(finding) },
|
|
47
|
+
'diagnostic_findings' => diagnostic_findings.length
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def state_counts
|
|
52
|
+
reportable_findings.group_by(&:classification).transform_values(&:length).transform_keys(&:to_s).sort.to_h
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalized_findings
|
|
56
|
+
reportable_findings.sort_by do |finding|
|
|
57
|
+
[finding.node.id, finding.node.file, finding.node.line, finding.node.definition_id]
|
|
58
|
+
end.map do |finding|
|
|
59
|
+
{
|
|
60
|
+
'id' => finding.node.id,
|
|
61
|
+
'definition_id' => finding.node.definition_id,
|
|
62
|
+
'path' => finding.node.file,
|
|
63
|
+
'line' => finding.node.line,
|
|
64
|
+
'end_line' => finding.node.end_line,
|
|
65
|
+
'loc' => FindingFacts.loc(finding),
|
|
66
|
+
'state' => finding.classification.to_s,
|
|
67
|
+
'confidence' => finding.confidence.to_s,
|
|
68
|
+
'candidate' => FindingFacts.actionable?(finding),
|
|
69
|
+
'diagnostic' => !FindingFacts.actionable?(finding),
|
|
70
|
+
'category' => FindingFacts.category(finding),
|
|
71
|
+
'unknown' => FindingFacts.unknown?(finding),
|
|
72
|
+
'rule_hits' => FindingFacts.rule_hits(finding),
|
|
73
|
+
'risk_flags' => FindingFacts.risk_flags(finding),
|
|
74
|
+
'blocker_kinds' => finding.blockers.map { |blocker| blocker.kind.to_s }.uniq.sort,
|
|
75
|
+
'reasons' => finding.reasons.map(&:to_s).sort
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def quality
|
|
81
|
+
resolutions = FindingFacts.resolution_counts(report)
|
|
82
|
+
grouped = reportable_findings.group_by { |finding| FindingFacts.category(finding) }
|
|
83
|
+
{
|
|
84
|
+
'scope' => 'report',
|
|
85
|
+
'candidate_count' => actionable_findings.length,
|
|
86
|
+
'candidate_loc' => actionable_findings.sum { |finding| FindingFacts.loc(finding) },
|
|
87
|
+
'diagnostic_count' => diagnostic_findings.length,
|
|
88
|
+
'blocked_count' => blocked_findings.length,
|
|
89
|
+
'blocked_rate' => FindingFacts.ratio(blocked_findings.length, reportable_findings.length),
|
|
90
|
+
'unknown_finding_count' => unknown_findings.length,
|
|
91
|
+
'unknown_finding_rate' => FindingFacts.ratio(unknown_findings.length, reportable_findings.length),
|
|
92
|
+
'resolution_counts' => resolutions,
|
|
93
|
+
'unknown_resolution_rate' => FindingFacts.ratio(resolutions.fetch('unknown'), resolutions.fetch('total')),
|
|
94
|
+
'rule_counts' => FindingFacts.report_rule_counts(report),
|
|
95
|
+
'risk_counts' => FindingFacts.report_risk_counts(report),
|
|
96
|
+
'by_category' => grouped.sort.to_h do |category, findings|
|
|
97
|
+
[category, category_quality(findings)]
|
|
98
|
+
end
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def category_quality(findings)
|
|
103
|
+
candidates = findings.select { |finding| FindingFacts.actionable?(finding) }
|
|
104
|
+
blocked = findings.select { |finding| finding.classification == :blocked }
|
|
105
|
+
unknown = findings.select { |finding| FindingFacts.unknown?(finding) }
|
|
106
|
+
{
|
|
107
|
+
'findings' => findings.length,
|
|
108
|
+
'candidate_count' => candidates.length,
|
|
109
|
+
'candidate_loc' => candidates.sum { |finding| FindingFacts.loc(finding) },
|
|
110
|
+
'blocked_count' => blocked.length,
|
|
111
|
+
'blocked_rate' => FindingFacts.ratio(blocked.length, findings.length),
|
|
112
|
+
'unknown_count' => unknown.length,
|
|
113
|
+
'unknown_rate' => FindingFacts.ratio(unknown.length, findings.length),
|
|
114
|
+
'rule_counts' => FindingFacts.tally(findings.flat_map { |finding| FindingFacts.rule_hits(finding) }),
|
|
115
|
+
'risk_counts' => FindingFacts.tally(findings.flat_map { |finding| FindingFacts.risk_flags(finding) })
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def actionable_findings
|
|
120
|
+
@actionable_findings ||= reportable_findings.select { |finding| FindingFacts.actionable?(finding) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def diagnostic_findings
|
|
124
|
+
@diagnostic_findings ||= reportable_findings.reject { |finding| FindingFacts.actionable?(finding) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def blocked_findings
|
|
128
|
+
@blocked_findings ||= reportable_findings.select { |finding| finding.classification == :blocked }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def unknown_findings
|
|
132
|
+
@unknown_findings ||= reportable_findings.select { |finding| FindingFacts.unknown?(finding) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def reportable_findings
|
|
136
|
+
@reportable_findings ||= report.reportable_findings
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|