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,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Bench
|
|
5
|
+
class PrecisionGate
|
|
6
|
+
MAX_FEATURES = 100
|
|
7
|
+
IMPROVEMENT_DIRECTIONS = {
|
|
8
|
+
'candidate_precision' => :increase,
|
|
9
|
+
'known_positive_recall' => :increase,
|
|
10
|
+
'candidate_count' => :increase,
|
|
11
|
+
'candidate_loc' => :increase,
|
|
12
|
+
'blocked_count' => :decrease,
|
|
13
|
+
'blocked_rate' => :decrease,
|
|
14
|
+
'unknown_finding_count' => :decrease,
|
|
15
|
+
'unknown_finding_rate' => :decrease,
|
|
16
|
+
'unknown_resolution_rate' => :decrease
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(policy:, candidate_union_summary:, feature_ablation:)
|
|
20
|
+
@policy = policy
|
|
21
|
+
@candidate_union_summary = candidate_union_summary
|
|
22
|
+
@feature_ablation = feature_ablation
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
validate!
|
|
27
|
+
quality = quality_status
|
|
28
|
+
features = feature_status
|
|
29
|
+
checks = {
|
|
30
|
+
'precision' => quality.fetch('precision_passed'),
|
|
31
|
+
'candidate_yield' => quality.fetch('yield_passed'),
|
|
32
|
+
'default_features_evaluated' => features.values.all? { |feature| feature['evaluated'] },
|
|
33
|
+
'default_features_improve' => features.values.all? { |feature| feature['passed'] }
|
|
34
|
+
}
|
|
35
|
+
{
|
|
36
|
+
'schema_version' => 1,
|
|
37
|
+
'enforced' => true,
|
|
38
|
+
'policy' => normalized_policy,
|
|
39
|
+
'quality' => quality,
|
|
40
|
+
'features' => features,
|
|
41
|
+
'checks' => checks,
|
|
42
|
+
'passed' => checks.values.all?
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :policy, :candidate_union_summary, :feature_ablation
|
|
49
|
+
|
|
50
|
+
def validate!
|
|
51
|
+
raise Error, 'Precision gate policy must be a mapping' unless policy.is_a?(Hash)
|
|
52
|
+
raise Error, 'Candidate-union summary must be a mapping' unless candidate_union_summary.is_a?(Hash)
|
|
53
|
+
raise Error, 'Feature ablation results must be a mapping' unless feature_ablation.is_a?(Hash)
|
|
54
|
+
raise Error, "Feature ablation exceeds #{MAX_FEATURES} entries" if feature_ablation.length > MAX_FEATURES
|
|
55
|
+
|
|
56
|
+
threshold = minimum_precision
|
|
57
|
+
raise Error, 'Precision gate minimum_precision must be between 0.0 and 1.0' unless threshold.between?(0.0, 1.0)
|
|
58
|
+
|
|
59
|
+
features = default_features
|
|
60
|
+
raise Error, 'Precision gate default_features must not be empty' if features.empty?
|
|
61
|
+
raise Error, "Precision gate default_features exceed #{MAX_FEATURES} entries" if features.length > MAX_FEATURES
|
|
62
|
+
raise Error, 'Precision gate default_features must be unique strings' unless
|
|
63
|
+
features.all? { |name| name.is_a?(String) && !name.empty? } && features.uniq == features
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def normalized_policy
|
|
67
|
+
{
|
|
68
|
+
'minimum_precision' => minimum_precision,
|
|
69
|
+
'default_features' => default_features.sort
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def minimum_precision
|
|
74
|
+
Float(policy.fetch('minimum_precision', 0.85))
|
|
75
|
+
rescue ArgumentError, TypeError
|
|
76
|
+
Float::NAN
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def default_features
|
|
80
|
+
value = policy['default_features']
|
|
81
|
+
raise Error, 'Precision gate default_features must be an array' unless value.is_a?(Array)
|
|
82
|
+
|
|
83
|
+
value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def quality_status
|
|
87
|
+
metrics = candidate_union_summary.dig('tool_metrics', 'necropsy')
|
|
88
|
+
metrics = {} unless metrics.is_a?(Hash)
|
|
89
|
+
precision = metrics['candidate_precision']
|
|
90
|
+
candidate_count = nonnegative_integer(metrics['candidate_count'])
|
|
91
|
+
candidate_loc = nonnegative_integer(metrics['candidate_loc'])
|
|
92
|
+
measured = metrics['precision_status'] == 'measured' && precision.is_a?(Numeric) && precision.finite?
|
|
93
|
+
{
|
|
94
|
+
'candidate_precision' => precision,
|
|
95
|
+
'minimum_precision' => minimum_precision,
|
|
96
|
+
'precision_status' => metrics['precision_status'] || 'missing',
|
|
97
|
+
'candidate_count' => candidate_count,
|
|
98
|
+
'candidate_loc' => candidate_loc,
|
|
99
|
+
'precision_passed' => measured && precision >= minimum_precision,
|
|
100
|
+
'yield_passed' => candidate_count&.positive? && candidate_loc&.positive?
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def feature_status
|
|
105
|
+
default_features.sort.to_h do |name|
|
|
106
|
+
result = feature_ablation[name]
|
|
107
|
+
difference = result['difference'] if result.is_a?(Hash)
|
|
108
|
+
evaluated = result.is_a?(Hash) && result['on'].is_a?(Hash) && result['off'].is_a?(Hash) &&
|
|
109
|
+
difference.is_a?(Hash)
|
|
110
|
+
precision_delta = numeric_delta(difference, 'candidate_precision')
|
|
111
|
+
improved_metrics = evaluated ? improved_metrics(difference) : []
|
|
112
|
+
precision_preserved = evaluated && !precision_delta.nil? && precision_delta >= 0
|
|
113
|
+
[name, {
|
|
114
|
+
'evaluated' => evaluated,
|
|
115
|
+
'precision_delta' => precision_delta,
|
|
116
|
+
'precision_preserved' => precision_preserved,
|
|
117
|
+
'improved_metrics' => improved_metrics,
|
|
118
|
+
'passed' => precision_preserved && !improved_metrics.empty?
|
|
119
|
+
}]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def improved_metrics(difference)
|
|
124
|
+
IMPROVEMENT_DIRECTIONS.filter_map do |metric, direction|
|
|
125
|
+
delta = numeric_delta(difference, metric)
|
|
126
|
+
next if delta.nil? || delta.zero?
|
|
127
|
+
|
|
128
|
+
metric if (direction == :increase && delta.positive?) || (direction == :decrease && delta.negative?)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def numeric_delta(difference, key)
|
|
133
|
+
return nil unless difference.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
value = difference[key]
|
|
136
|
+
value if value.is_a?(Numeric) && value.finite?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def nonnegative_integer(value)
|
|
140
|
+
value if value.is_a?(Integer) && value >= 0
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
module Bench
|
|
7
|
+
class ReleaseAudit
|
|
8
|
+
class AdversarialRunner
|
|
9
|
+
def initialize(root:, suites:, clock: Process.method(:clock_gettime))
|
|
10
|
+
@root = root
|
|
11
|
+
@suites = suites
|
|
12
|
+
@clock = clock
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
suites.sort.map do |name, definition|
|
|
17
|
+
command = definition.fetch('command').map(&:to_s)
|
|
18
|
+
started_at = monotonic_time
|
|
19
|
+
stdout, stderr, status = Open3.capture3(*command, chdir: root)
|
|
20
|
+
{
|
|
21
|
+
'name' => name,
|
|
22
|
+
'command' => command,
|
|
23
|
+
'passed' => status.success?,
|
|
24
|
+
'exit_status' => status.exitstatus,
|
|
25
|
+
'duration_seconds' => (monotonic_time - started_at).round(6),
|
|
26
|
+
'summary' => result_summary(stdout, stderr)
|
|
27
|
+
}
|
|
28
|
+
rescue SystemCallError => e
|
|
29
|
+
{
|
|
30
|
+
'name' => name,
|
|
31
|
+
'command' => command,
|
|
32
|
+
'passed' => false,
|
|
33
|
+
'exit_status' => nil,
|
|
34
|
+
'duration_seconds' => (monotonic_time - started_at).round(6),
|
|
35
|
+
'summary' => "#{e.class}: #{e.message}"
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :root, :suites, :clock
|
|
43
|
+
|
|
44
|
+
def monotonic_time
|
|
45
|
+
clock.call(Process::CLOCK_MONOTONIC)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def result_summary(stdout, stderr)
|
|
49
|
+
combined = [stdout, stderr].join("\n")
|
|
50
|
+
combined.lines.map(&:strip).reverse.find { |line| line.match?(/\d+ examples?, \d+ failures?/) } ||
|
|
51
|
+
combined.lines.map(&:strip).reject(&:empty?).last.to_s
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module Bench
|
|
8
|
+
class ReleaseAudit
|
|
9
|
+
class ArtifactWriter
|
|
10
|
+
def initialize(audit:, output_dir:)
|
|
11
|
+
@audit = audit
|
|
12
|
+
@output_dir = output_dir
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
FileUtils.mkdir_p(output_dir)
|
|
17
|
+
File.write(json_path, "#{JSON.pretty_generate(audit)}\n")
|
|
18
|
+
File.write(markdown_path, markdown)
|
|
19
|
+
{ json: json_path, markdown: markdown_path }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :audit, :output_dir
|
|
25
|
+
|
|
26
|
+
def json_path = File.join(output_dir, 'audit.json')
|
|
27
|
+
def markdown_path = File.join(output_dir, 'audit.md')
|
|
28
|
+
|
|
29
|
+
def markdown
|
|
30
|
+
lines = [
|
|
31
|
+
"# #{audit.fetch('release')} safety release audit",
|
|
32
|
+
'',
|
|
33
|
+
"Status: **#{audit.fetch('status').upcase}**",
|
|
34
|
+
'',
|
|
35
|
+
"Baseline: `#{audit.dig('baseline', 'git_ref')}` — #{audit.dig('baseline', 'reason')}",
|
|
36
|
+
'',
|
|
37
|
+
'## Candidate changes',
|
|
38
|
+
'',
|
|
39
|
+
'| corpus | baseline | current | added | removed | state changed | newly high |',
|
|
40
|
+
'|---|---:|---:|---:|---:|---:|---:|'
|
|
41
|
+
]
|
|
42
|
+
append_candidate_rows(lines)
|
|
43
|
+
append_review(lines)
|
|
44
|
+
append_performance(lines)
|
|
45
|
+
append_adversarial(lines)
|
|
46
|
+
append_claim_gate(lines)
|
|
47
|
+
append_gates(lines)
|
|
48
|
+
"#{lines.join("\n")}\n"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def append_review(lines)
|
|
52
|
+
lines.push('', '## Difference review', '',
|
|
53
|
+
'| corpus | strategy | changes | required | completed | zero difference |',
|
|
54
|
+
'|---|---|---:|---:|---:|:---:|')
|
|
55
|
+
audit.dig('review', 'coverage').sort.each do |corpus, coverage|
|
|
56
|
+
lines << "| #{corpus} | #{coverage['strategy']} | #{coverage['changes']} | #{coverage['required']} | " \
|
|
57
|
+
"#{coverage['completed']} | #{coverage['zero_difference'] ? 'yes' : 'no'} |"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def append_candidate_rows(lines)
|
|
62
|
+
new_high = audit.fetch('new_high_candidates').group_by { |candidate| candidate.fetch('corpus') }
|
|
63
|
+
audit.fetch('corpora').sort.each do |corpus, comparison|
|
|
64
|
+
lines << "| #{corpus} | #{comparison.dig('baseline_metrics', 'findings')} | " \
|
|
65
|
+
"#{comparison.dig('current_metrics', 'findings')} | #{comparison['added'].length} | " \
|
|
66
|
+
"#{comparison['removed'].length} | #{comparison['state_changed'].length} | " \
|
|
67
|
+
"#{new_high.fetch(corpus, []).length} |"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def append_performance(lines)
|
|
72
|
+
lines.push('', '## Performance', '',
|
|
73
|
+
'| corpus | wall baseline/current/limit (s) | RSS baseline/current/limit (KiB) | pass |',
|
|
74
|
+
'|---|---:|---:|:---:|')
|
|
75
|
+
audit.fetch('performance').sort.each do |corpus, result|
|
|
76
|
+
lines << "| #{corpus} | #{result['baseline_wall_time_seconds']} / " \
|
|
77
|
+
"#{result['current_wall_time_seconds']} / #{result['wall_time_limit_seconds']} | " \
|
|
78
|
+
"#{result['baseline_rss_kb']} / #{result['current_rss_kb']} / #{result['rss_limit_kb']} | " \
|
|
79
|
+
"#{result['passed'] ? 'yes' : 'no'} |"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def append_adversarial(lines)
|
|
84
|
+
lines.push('', '## Adversarial suites', '', '| suite | result | summary |', '|---|:---:|---|')
|
|
85
|
+
audit.fetch('adversarial_suites').each do |suite|
|
|
86
|
+
lines << "| #{suite['name']} | #{suite['passed'] ? 'pass' : 'fail'} | #{suite['summary']} |"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def append_claim_gate(lines)
|
|
91
|
+
claim = audit.fetch('claim_gate')
|
|
92
|
+
return unless claim['enforced']
|
|
93
|
+
|
|
94
|
+
lines.push('', '## Public claim gate', '',
|
|
95
|
+
"Result: **#{claim['passed'] ? 'PASS' : 'FAIL'}**",
|
|
96
|
+
"Unexplained high candidates: #{claim.fetch('unexplained_high_candidates').length}",
|
|
97
|
+
"Reviewed high candidates: #{claim.fetch('reviewed_high_candidates')}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def append_gates(lines)
|
|
101
|
+
lines.push('', '## Release gates', '', '| gate | result | failures |', '|---|:---:|---:|')
|
|
102
|
+
audit.fetch('gates').sort.each do |name, gate|
|
|
103
|
+
lines << "| #{name} | #{gate['passed'] ? 'pass' : 'fail'} | #{gate['failures']} |"
|
|
104
|
+
end
|
|
105
|
+
lines << ''
|
|
106
|
+
lines << "Reviewed RuboCop/Rails changes: #{audit.dig('review', 'completed').length}; " \
|
|
107
|
+
"missing: #{audit.dig('review', 'missing').length}."
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubygems'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
module Bench
|
|
7
|
+
class ReleaseAudit
|
|
8
|
+
class ConfigValidator
|
|
9
|
+
REQUIRED_CORPORA = %w[dynamic_evidence plain_ruby rails rubocop_1_75_0 self].freeze
|
|
10
|
+
REQUIRED_ADVERSARIAL_SUITES = %w[ambiguity dynamic parse remote_input].freeze
|
|
11
|
+
REQUIRED_REVIEW_POLICIES = %w[rails rubocop_1_75_0].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(config, strict_release: true)
|
|
14
|
+
@config = config
|
|
15
|
+
@strict_release = strict_release
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate!
|
|
19
|
+
raise Error, 'Release audit config must be a mapping' unless config.is_a?(Hash)
|
|
20
|
+
raise Error, 'Release audit schema_version must be 1' unless config['schema_version'] == 1
|
|
21
|
+
|
|
22
|
+
validate_nonempty_unique('corpora', config['corpora'])
|
|
23
|
+
validate_suites
|
|
24
|
+
validate_review_policies
|
|
25
|
+
validate_precision_gate
|
|
26
|
+
validate_strict_release if strict_release
|
|
27
|
+
config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :config, :strict_release
|
|
33
|
+
|
|
34
|
+
def validate_nonempty_unique(label, values)
|
|
35
|
+
items = Array(values)
|
|
36
|
+
raise Error, "Release audit #{label} must not be empty" if items.empty?
|
|
37
|
+
return if items.uniq.length == items.length
|
|
38
|
+
|
|
39
|
+
raise Error, "Release audit #{label} must not contain duplicates"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate_suites
|
|
43
|
+
suites = config['adversarial_suites']
|
|
44
|
+
raise Error, 'Release audit adversarial_suites must not be empty' unless suites.is_a?(Hash) && !suites.empty?
|
|
45
|
+
|
|
46
|
+
suites.each do |name, definition|
|
|
47
|
+
command = definition.is_a?(Hash) ? definition['command'] : nil
|
|
48
|
+
next unless Array(command).empty?
|
|
49
|
+
|
|
50
|
+
raise Error, "Adversarial suite #{name} must define a non-empty command"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_review_policies
|
|
55
|
+
policies = config.dig('review', 'corpora')
|
|
56
|
+
raise Error, 'Release audit review policies must not be empty' unless policies.is_a?(Hash) && !policies.empty?
|
|
57
|
+
|
|
58
|
+
policies.each do |corpus, policy|
|
|
59
|
+
strategy = policy['strategy']
|
|
60
|
+
raise Error, "Invalid review strategy for #{corpus}" unless %w[all stratified].include?(strategy)
|
|
61
|
+
next unless strategy == 'stratified'
|
|
62
|
+
|
|
63
|
+
minimum = Integer(policy['minimum_per_stratum'], exception: false)
|
|
64
|
+
raise Error, "Review sample size for #{corpus} must be positive" unless minimum&.positive?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_precision_gate
|
|
69
|
+
required = Gem::Version.new(config.fetch('release', '0')) >= Gem::Version.new('0.4.0')
|
|
70
|
+
policy = config['precision_gate']
|
|
71
|
+
raise Error, 'Release audit 0.4+ requires precision_gate policy' if required && !policy.is_a?(Hash)
|
|
72
|
+
return unless policy
|
|
73
|
+
raise Error, 'Release audit precision_gate must be a mapping' unless policy.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
threshold = Float(policy.fetch('minimum_precision', 0.85), exception: false)
|
|
76
|
+
raise Error, 'Release audit minimum_precision must be between 0.0 and 1.0' unless
|
|
77
|
+
threshold&.finite? && threshold.between?(0.0, 1.0)
|
|
78
|
+
|
|
79
|
+
features = policy['default_features']
|
|
80
|
+
raise Error, 'Release audit default_features must be a non-empty array' unless
|
|
81
|
+
features.is_a?(Array) && !features.empty?
|
|
82
|
+
return if features.all? { |feature| feature.is_a?(String) && !feature.empty? } && features.uniq == features
|
|
83
|
+
|
|
84
|
+
raise Error, 'Release audit default_features must contain unique strings'
|
|
85
|
+
rescue ArgumentError
|
|
86
|
+
raise Error, "Invalid release version #{config['release'].inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_strict_release
|
|
90
|
+
baseline_ref = config.dig('baseline', 'git_ref').to_s
|
|
91
|
+
raise Error, 'Release audit baseline must use a full Git SHA' unless baseline_ref.match?(/\A[0-9a-f]{40}\z/)
|
|
92
|
+
|
|
93
|
+
validate_exact_set('corpora', config.fetch('corpora'), REQUIRED_CORPORA)
|
|
94
|
+
validate_exact_set('adversarial suites', config.fetch('adversarial_suites').keys,
|
|
95
|
+
REQUIRED_ADVERSARIAL_SUITES)
|
|
96
|
+
policies = config.dig('review', 'corpora')
|
|
97
|
+
validate_exact_set('review policies', policies.keys, REQUIRED_REVIEW_POLICIES)
|
|
98
|
+
raise Error, 'Rails release review must inspect all changes' unless policies.dig('rails', 'strategy') == 'all'
|
|
99
|
+
return if policies.dig('rubocop_1_75_0', 'strategy') == 'stratified'
|
|
100
|
+
|
|
101
|
+
raise Error, 'RuboCop release review must use stratified sampling'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_exact_set(label, actual, expected)
|
|
105
|
+
return if Array(actual).sort == expected.sort
|
|
106
|
+
|
|
107
|
+
raise Error, "Release audit #{label} must be exactly #{expected.sort.inspect}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module Bench
|
|
8
|
+
class ReleaseAudit
|
|
9
|
+
class GitSnapshot
|
|
10
|
+
def initialize(root:, git_ref:, reports_path:)
|
|
11
|
+
@root = root
|
|
12
|
+
@git_ref = git_ref
|
|
13
|
+
@reports_path = reports_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def reports(corpora)
|
|
17
|
+
corpora.to_h { |corpus| [corpus, report(corpus)] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :root, :git_ref, :reports_path
|
|
23
|
+
|
|
24
|
+
def report(corpus)
|
|
25
|
+
object = "#{git_ref}:#{File.join(reports_path, "#{corpus}.json")}"
|
|
26
|
+
output, status = Open3.capture2e('git', 'show', object, chdir: root)
|
|
27
|
+
raise Error, "Could not read audit baseline #{object}: #{output.strip}" unless status.success?
|
|
28
|
+
|
|
29
|
+
JSON.parse(output)
|
|
30
|
+
rescue JSON::ParserError => e
|
|
31
|
+
raise Error, "Could not parse audit baseline #{object}: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Bench
|
|
5
|
+
class ReleaseAudit
|
|
6
|
+
class PerformanceGate
|
|
7
|
+
PROVENANCE_FIELDS = %w[ruby os command rss_kind rss_scope].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(config:, baseline:, current_summary:, current_provenance:)
|
|
10
|
+
@config = config
|
|
11
|
+
@baseline_document = baseline
|
|
12
|
+
@baseline = baseline.fetch('corpora')
|
|
13
|
+
@current = current_summary.fetch('corpora').to_h { |corpus| [corpus.fetch('id'), corpus] }
|
|
14
|
+
@current_provenance = current_provenance
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
config.fetch('corpora').sort.to_h do |corpus|
|
|
19
|
+
[corpus, compare_or_unavailable(corpus)]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :config, :baseline_document, :baseline, :current, :current_provenance
|
|
26
|
+
|
|
27
|
+
def compare_or_unavailable(corpus)
|
|
28
|
+
return unavailable(provenance_error) if provenance_error
|
|
29
|
+
|
|
30
|
+
baseline_result = baseline[corpus]
|
|
31
|
+
current_run = current[corpus]
|
|
32
|
+
return unavailable('baseline measurement unavailable') unless baseline_result
|
|
33
|
+
return unavailable('current corpus was not generated') unless current_run&.fetch('status', 'generated') == 'generated'
|
|
34
|
+
|
|
35
|
+
current_result = current_run['performance']
|
|
36
|
+
return unavailable('current performance measurement unavailable') unless current_result
|
|
37
|
+
return unavailable('current RSS measurement unavailable') unless rss_value(current_result)
|
|
38
|
+
return unavailable('current RSS kind differs from the baseline') unless compatible_rss?(current_result)
|
|
39
|
+
|
|
40
|
+
compare(corpus, baseline_result, current_result)
|
|
41
|
+
rescue KeyError, ArgumentError, TypeError => e
|
|
42
|
+
unavailable("invalid performance measurement: #{e.message}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def compare(corpus, baseline_result, current_result)
|
|
46
|
+
budget = config.fetch('performance')
|
|
47
|
+
baseline_wall = Float(baseline_result.fetch('wall_time_seconds'))
|
|
48
|
+
current_wall = Float(current_result.fetch('wall_time_seconds'))
|
|
49
|
+
baseline_p95 = Float(baseline_result.fetch('wall_time_p95_seconds', baseline_wall))
|
|
50
|
+
current_p95 = Float(current_result.fetch('wall_time_p95_seconds', current_wall))
|
|
51
|
+
baseline_max = Float(baseline_result.fetch('wall_time_max_seconds', baseline_wall))
|
|
52
|
+
current_max = Float(current_result.fetch('wall_time_max_seconds', current_wall))
|
|
53
|
+
baseline_rss = Integer(baseline_result.fetch('rss_kb'))
|
|
54
|
+
current_rss = rss_value(current_result)
|
|
55
|
+
wall_limit = relative_limit(
|
|
56
|
+
baseline_wall,
|
|
57
|
+
ratio: budget.fetch('wall_time_ratio'),
|
|
58
|
+
allowance: budget.fetch('wall_time_allowance_seconds'),
|
|
59
|
+
absolute: budget.fetch('max_wall_time_seconds').fetch(corpus)
|
|
60
|
+
)
|
|
61
|
+
p95_limit = relative_limit(
|
|
62
|
+
baseline_p95,
|
|
63
|
+
ratio: budget.fetch('wall_time_ratio'),
|
|
64
|
+
allowance: budget.fetch('wall_time_allowance_seconds'),
|
|
65
|
+
absolute: budget.fetch('max_wall_time_seconds').fetch(corpus)
|
|
66
|
+
)
|
|
67
|
+
max_limit = relative_limit(
|
|
68
|
+
baseline_max,
|
|
69
|
+
ratio: budget.fetch('wall_time_ratio'),
|
|
70
|
+
allowance: budget.fetch('wall_time_allowance_seconds'),
|
|
71
|
+
absolute: budget.fetch('max_wall_time_seconds').fetch(corpus)
|
|
72
|
+
)
|
|
73
|
+
rss_limit = relative_limit(
|
|
74
|
+
baseline_rss,
|
|
75
|
+
ratio: budget.fetch('rss_ratio'),
|
|
76
|
+
allowance: budget.fetch('rss_allowance_kb'),
|
|
77
|
+
absolute: budget.fetch('max_rss_kb')
|
|
78
|
+
)
|
|
79
|
+
allocations = bounded_metric(
|
|
80
|
+
current_result, 'allocated_objects_max', budget['max_allocated_objects'], corpus
|
|
81
|
+
)
|
|
82
|
+
artifact = bounded_metric(
|
|
83
|
+
current_result, 'artifact_size_max_bytes', budget['max_artifact_size_bytes'], corpus
|
|
84
|
+
)
|
|
85
|
+
passed = current_wall <= wall_limit && current_p95 <= p95_limit && current_max <= max_limit &&
|
|
86
|
+
current_rss <= rss_limit && allocations.fetch('passed') && artifact.fetch('passed')
|
|
87
|
+
{
|
|
88
|
+
'available' => true,
|
|
89
|
+
'baseline_wall_time_seconds' => baseline_wall,
|
|
90
|
+
'current_wall_time_seconds' => current_wall,
|
|
91
|
+
'wall_time_limit_seconds' => wall_limit.round(6),
|
|
92
|
+
'current_wall_time_p95_seconds' => current_p95,
|
|
93
|
+
'wall_time_p95_limit_seconds' => p95_limit.round(6),
|
|
94
|
+
'current_wall_time_max_seconds' => current_max,
|
|
95
|
+
'wall_time_max_limit_seconds' => max_limit.round(6),
|
|
96
|
+
'baseline_rss_kb' => baseline_rss,
|
|
97
|
+
'current_rss_kb' => current_rss,
|
|
98
|
+
'rss_limit_kb' => rss_limit.round,
|
|
99
|
+
'rss_kind' => current_result['rss_kind'],
|
|
100
|
+
'rss_scope' => current_result['rss_scope'],
|
|
101
|
+
'allocation_gate' => allocations,
|
|
102
|
+
'artifact_size_gate' => artifact,
|
|
103
|
+
'passed' => passed
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def bounded_metric(result, key, configured_limit, corpus)
|
|
108
|
+
return { 'configured' => false, 'passed' => true } if configured_limit.nil?
|
|
109
|
+
|
|
110
|
+
limit = configured_limit.is_a?(Hash) ? configured_limit.fetch(corpus) : configured_limit
|
|
111
|
+
value = Integer(result.fetch(key))
|
|
112
|
+
limit = Integer(limit)
|
|
113
|
+
raise ArgumentError, "#{key} limit must be positive" unless limit.positive?
|
|
114
|
+
|
|
115
|
+
{ 'configured' => true, 'value' => value, 'limit' => limit, 'passed' => value <= limit }
|
|
116
|
+
rescue KeyError
|
|
117
|
+
{ 'configured' => true, 'passed' => false, 'diagnostic' => "current #{key} measurement unavailable" }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def provenance_error
|
|
121
|
+
return @provenance_error if defined?(@provenance_error)
|
|
122
|
+
|
|
123
|
+
@provenance_error = validate_provenance
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_provenance
|
|
127
|
+
return 'baseline performance schema_version must be 1' unless baseline_document['schema_version'] == 1
|
|
128
|
+
return 'current performance provenance schema_version must be 1' unless current_provenance['schema_version'] == 1
|
|
129
|
+
unless baseline_document['git_ref'] == config.dig('baseline', 'git_ref')
|
|
130
|
+
return 'baseline performance git_ref does not match the audit baseline'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
baseline_environment = baseline_document['environment']
|
|
134
|
+
current_environment = current_provenance['environment']
|
|
135
|
+
return 'baseline performance environment is missing' unless baseline_environment.is_a?(Hash)
|
|
136
|
+
return 'current performance environment is missing' unless current_environment.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
mismatch = PROVENANCE_FIELDS.find do |field|
|
|
139
|
+
baseline_environment[field].to_s != current_environment[field].to_s
|
|
140
|
+
end
|
|
141
|
+
"performance environment mismatch for #{mismatch}" if mismatch
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def compatible_rss?(current_result)
|
|
145
|
+
environment = baseline_document.fetch('environment')
|
|
146
|
+
current_result['rss_kind'] == environment['rss_kind'] &&
|
|
147
|
+
current_result['rss_scope'] == environment['rss_scope']
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def relative_limit(baseline_value, ratio:, allowance:, absolute:)
|
|
151
|
+
[baseline_value * ratio, baseline_value + allowance].max.clamp(0, absolute)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def rss_value(performance)
|
|
155
|
+
value = performance['process_hwm_kb'] || performance['process_rss_kb']
|
|
156
|
+
Integer(value) if value
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def unavailable(diagnostic)
|
|
160
|
+
{ 'passed' => false, 'available' => false, 'diagnostic' => diagnostic }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|