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,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