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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. 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