necropsy 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. metadata +106 -2
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require_relative 'finding_facts'
4
5
 
5
6
  module Necropsy
6
7
  module Bench
@@ -21,6 +22,7 @@ module Necropsy
21
22
  root: nil,
22
23
  config_path: nil,
23
24
  ablation: false,
25
+ feature_ablation: {},
24
26
  precision_threshold: 0.85,
25
27
  recall_threshold: nil
26
28
  )
@@ -30,15 +32,20 @@ module Necropsy
30
32
  @root = root
31
33
  @config_path = config_path
32
34
  @ablation = ablation
35
+ @feature_ablation = feature_ablation
33
36
  @precision_threshold = precision_threshold
34
37
  @recall_threshold = recall_threshold
35
38
  end
36
39
 
37
40
  def call
38
41
  result = metrics_for(report)
42
+ result['identity_views'] = identity_views(report)
43
+ result['quality'] = quality_metrics(report)
44
+ result['by_category'] = category_metrics(report)
39
45
  result['by_classification'] = grouped_metrics(:classification)
40
46
  result['by_confidence'] = grouped_metrics(:confidence)
41
47
  result['ablation'] = ablation_metrics if ablation && root
48
+ result['feature_ablation'] = feature_ablation_metrics unless feature_ablation.empty?
42
49
  result['release_criteria'] = release_criteria(result)
43
50
  result
44
51
  end
@@ -46,75 +53,203 @@ module Necropsy
46
53
  private
47
54
 
48
55
  attr_reader :report, :gold_standard_path, :min_confidence, :root, :config_path, :ablation, :precision_threshold,
49
- :recall_threshold
56
+ :recall_threshold, :feature_ablation
50
57
 
51
- def metrics_for(target_report, expected: gold_standard)
52
- actual = target_report.dead_methods(min_confidence: min_confidence).to_set { |finding| finding.node.id }
58
+ def metrics_for(target_report, expected: gold_standard, recall_expected: expected)
59
+ actual = actionable_findings(target_report).to_set { |finding| finding.node.id }
53
60
  true_positive = actual & expected
54
61
  false_positive = actual - expected
55
- false_negative = expected - actual
62
+ false_negative = recall_expected ? recall_expected - actual : Set.new
56
63
 
57
64
  precision = ratio(true_positive.length, actual.length)
58
- recall = ratio(true_positive.length, expected.length)
59
- f1 = (precision + recall).zero? ? 0.0 : (2 * precision * recall / (precision + recall))
65
+ recall = ratio((actual & recall_expected).length, recall_expected.length) if recall_expected
66
+ f1 = if recall
67
+ (precision + recall).zero? ? 0.0 : (2 * precision * recall / (precision + recall))
68
+ end
60
69
 
61
70
  {
62
71
  'precision' => precision.round(4),
63
- 'recall' => recall.round(4),
64
- 'f1' => f1.round(4),
72
+ 'recall' => recall&.round(4),
73
+ 'f1' => f1&.round(4),
65
74
  'true_positive' => true_positive.to_a.sort,
66
75
  'false_positive' => false_positive.to_a.sort,
67
76
  'false_negative' => false_negative.to_a.sort
68
77
  }
69
78
  end
70
79
 
71
- def gold_standard(classification: nil)
72
- entries = gold_entries
80
+ def gold_standard(classification: nil, confidence: nil)
73
81
  if classification
74
- scoped = entries.select do |entry|
75
- !entry.key?('classification') || entry['classification'] == classification.to_s
76
- end
82
+ grouped = gold_entries_by(:classification)
83
+ scoped = grouped.fetch(nil, []) + grouped.fetch(classification.to_s, [])
77
84
  return ids_for(scoped)
78
85
  end
86
+ if confidence
87
+ scoped = gold_entries_by(:confidence).fetch(confidence.to_s, [])
88
+ return nil if scoped.empty?
79
89
 
80
- ids_for(entries)
90
+ return ids_for(scoped)
91
+ end
92
+
93
+ ids_for(gold_entries)
81
94
  end
82
95
 
83
96
  def gold_entries
84
- payload = YAML.load_file(gold_standard_path) || {}
85
- entries = payload['dead_methods'] || payload['findings'] || payload
97
+ return @gold_entries if defined?(@gold_entries)
98
+
99
+ payload = gold_payload
100
+ entries = if payload.is_a?(Hash)
101
+ payload['dead_methods'] || payload['findings'] || dead_labels(payload['labels']) || []
102
+ else
103
+ payload
104
+ end
105
+ @gold_entries = normalize_entries(entries).select do |entry|
106
+ classification = entry['classification']
107
+ classification.nil? || FindingFacts::ACTIONABLE_CLASSIFICATIONS.include?(classification.to_sym)
108
+ end.freeze
109
+ end
110
+
111
+ def known_positive_entries
112
+ return @known_positive_entries if defined?(@known_positive_entries)
113
+
114
+ payload = gold_payload
115
+ entries = payload['known_positives'] || payload['known_positive_methods'] if payload.is_a?(Hash)
116
+ @known_positive_entries = (entries ? normalize_entries(entries).freeze : gold_entries)
117
+ end
118
+
119
+ def gold_entries_by(attribute)
120
+ @gold_entries_by ||= {}
121
+ @gold_entries_by[attribute] ||= gold_entries.group_by { |entry| entry[attribute.to_s] }.freeze
122
+ end
123
+
124
+ def gold_payload
125
+ @gold_payload ||= YAML.safe_load_file(gold_standard_path, aliases: true) || {}
126
+ end
127
+
128
+ def normalize_entries(entries)
86
129
  Array(entries).map do |entry|
87
130
  entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { 'id' => entry }
88
131
  end
89
132
  end
90
133
 
134
+ def dead_labels(entries)
135
+ return unless entries
136
+
137
+ Array(entries).select { |entry| entry.is_a?(Hash) && (entry['value'] || entry[:value]).to_s == 'dead' }
138
+ end
139
+
91
140
  def ids_for(entries)
92
141
  entries.map { |entry| entry['id'] || entry['node_id'] }.compact.to_set
93
142
  end
94
143
 
95
144
  def grouped_metrics(attribute)
96
- report.findings.group_by { |finding| finding.public_send(attribute) }.transform_values do |findings|
97
- expected_scope = attribute == :classification ? gold_standard(classification: findings.first.public_send(attribute)) : gold_standard
98
- metrics_for(Report.new(root: report.root, graph: report.graph, findings: findings), expected: expected_scope)
145
+ report.reportable_findings.group_by { |finding| finding.public_send(attribute) }.transform_values do |findings|
146
+ value = findings.first.public_send(attribute)
147
+ grouped_report = Report.new(root: report.root, graph: report.graph, findings: findings)
148
+ if attribute == :classification
149
+ expected_scope = gold_standard(classification: value)
150
+ metrics_for(grouped_report, expected: expected_scope)
151
+ else
152
+ expected_scope = gold_standard(confidence: value)
153
+ if expected_scope
154
+ metrics_for(grouped_report, expected: expected_scope)
155
+ else
156
+ metrics_for(grouped_report, expected: gold_standard, recall_expected: nil)
157
+ end
158
+ end
99
159
  end.transform_keys(&:to_s)
100
160
  end
101
161
 
102
162
  def ablation_metrics
103
- ABLATION_ANALYZERS.transform_values do |classes|
163
+ analyzer_metrics = ABLATION_ANALYZERS.transform_values do |classes|
104
164
  analyzers = classes.map(&:new)
105
- metrics_for(Necropsy.analyze(root: root, config_path: config_path, analyzers: analyzers))
165
+ measurement_for(Necropsy.analyze(
166
+ root: root,
167
+ config_path: config_path,
168
+ analyzers: analyzers,
169
+ ignored_reference_paths: [gold_standard_path]
170
+ ))
106
171
  end
172
+ analyzer_metrics.merge(rta_pruning_metrics)
173
+ end
174
+
175
+ def rta_pruning_metrics
176
+ analyzers = ABLATION_ANALYZERS.fetch('all_static').map(&:new)
177
+ rank_only = measurement_for(
178
+ Runner.new(
179
+ root: root,
180
+ config_path: config_path,
181
+ analyzers: analyzers,
182
+ ignored_reference_paths: [gold_standard_path]
183
+ ).analyze(rta_pruning: :rank_only)
184
+ )
185
+ legacy = measurement_for(
186
+ Runner.new(
187
+ root: root,
188
+ config_path: config_path,
189
+ analyzers: analyzers,
190
+ ignored_reference_paths: [gold_standard_path]
191
+ ).analyze(rta_pruning: :legacy)
192
+ )
193
+ rank_candidates = candidate_ids(rank_only)
194
+ legacy_candidates = candidate_ids(legacy)
195
+ difference = {
196
+ 'only_in_rank_only' => (rank_candidates - legacy_candidates).sort,
197
+ 'only_in_legacy' => (legacy_candidates - rank_candidates).sort
198
+ }
199
+ {
200
+ 'rta_rank_only' => rank_only.merge('rta_pruning' => 'rank_only', 'candidate_diff' => difference),
201
+ 'rta_legacy' => legacy.merge('rta_pruning' => 'legacy', 'candidate_diff' => difference)
202
+ }
203
+ end
204
+
205
+ def candidate_ids(metrics)
206
+ Set.new(metrics.fetch('true_positive') + metrics.fetch('false_positive'))
107
207
  end
108
208
 
109
209
  def ratio(numerator, denominator)
110
- return 1.0 if denominator.zero?
210
+ return 0.0 if denominator.zero?
111
211
 
112
212
  numerator.to_f / denominator
113
213
  end
114
214
 
215
+ def identity_views(target_report)
216
+ findings = actionable_findings(target_report)
217
+ logical = findings.map { |finding| finding.node.symbol_id }.uniq.sort
218
+ physical = findings.sort_by do |finding|
219
+ [finding.node.symbol_id, finding.node.file, finding.node.line, finding.node.definition_id]
220
+ end.map do |finding|
221
+ {
222
+ 'definition_id' => finding.node.definition_id,
223
+ 'symbol_id' => finding.node.symbol_id,
224
+ 'physical_fingerprint' => finding.physical_fingerprint,
225
+ 'logical_fingerprint' => finding.logical_fingerprint,
226
+ 'file' => finding.node.file,
227
+ 'line' => finding.node.line
228
+ }
229
+ end
230
+ {
231
+ 'legacy_logical' => {
232
+ 'identity_key' => 'symbol_id',
233
+ 'candidate_count' => logical.length,
234
+ 'candidate_ids' => logical
235
+ },
236
+ 'physical_definition' => {
237
+ 'identity_key' => 'definition_id',
238
+ 'candidate_count' => physical.length,
239
+ 'candidates' => physical
240
+ }
241
+ }
242
+ end
243
+
115
244
  def release_criteria(result)
116
- checks = { 'precision' => result['precision'] >= precision_threshold }
117
- checks['recall'] = result['recall'] >= recall_threshold if recall_threshold
245
+ checks = {
246
+ 'precision' => result.dig('quality', 'candidate_precision') >= precision_threshold,
247
+ 'candidate_yield' => result.dig('quality', 'candidate_count').positive?
248
+ }
249
+ if recall_threshold
250
+ known_positive_recall = result.dig('quality', 'known_positive_recall')
251
+ checks['recall'] = !known_positive_recall.nil? && known_positive_recall >= recall_threshold
252
+ end
118
253
  {
119
254
  'precision_threshold' => precision_threshold,
120
255
  'recall_threshold' => recall_threshold,
@@ -122,6 +257,203 @@ module Necropsy
122
257
  'checks' => checks
123
258
  }
124
259
  end
260
+
261
+ def actionable_findings(target_report)
262
+ if target_report.respond_to?(:actionable_candidates)
263
+ target_report.actionable_candidates(min_confidence: min_confidence)
264
+ else
265
+ target_report.dead_methods(min_confidence: min_confidence).select { |finding| FindingFacts.actionable?(finding) }
266
+ end
267
+ end
268
+
269
+ def quality_metrics(target_report)
270
+ candidates = actionable_findings(target_report)
271
+ findings = target_report.reportable_findings
272
+ blocked = findings.select { |finding| finding.classification == :blocked }
273
+ unknown = findings.select { |finding| FindingFacts.unknown?(finding) }
274
+ resolutions = FindingFacts.resolution_counts(target_report)
275
+ {
276
+ 'scope' => 'report',
277
+ 'candidate_precision' => physical_precision(candidates, gold_entries),
278
+ 'candidate_count' => candidates.length,
279
+ 'candidate_loc' => candidates.sum { |finding| FindingFacts.loc(finding) },
280
+ 'known_positive_recall' => physical_recall(candidates, known_positive_entries),
281
+ 'known_positive_count' => known_positive_entries.length,
282
+ 'diagnostic_count' => findings.count { |finding| !FindingFacts.actionable?(finding) },
283
+ 'blocked_count' => blocked.length,
284
+ 'blocked_rate' => FindingFacts.ratio(blocked.length, findings.length),
285
+ 'unknown_finding_count' => unknown.length,
286
+ 'unknown_finding_rate' => FindingFacts.ratio(unknown.length, findings.length),
287
+ 'resolution_counts' => resolutions,
288
+ 'unknown_resolution_rate' => FindingFacts.ratio(resolutions.fetch('unknown'), resolutions.fetch('total')),
289
+ 'rule_counts' => FindingFacts.report_rule_counts(target_report),
290
+ 'risk_counts' => FindingFacts.report_risk_counts(target_report)
291
+ }
292
+ end
293
+
294
+ def category_metrics(target_report)
295
+ candidates = actionable_findings(target_report)
296
+ findings = target_report.reportable_findings
297
+ expected = gold_entries
298
+ known = known_positive_entries
299
+ expected_by_category = expected.group_by { |entry| category_for_entry(entry) }
300
+ known_by_category = known.group_by { |entry| category_for_entry(entry) }
301
+ categories = candidates.map { |finding| category_for_finding(finding) } +
302
+ findings.map { |finding| category_for_finding(finding) } +
303
+ (expected + known).map { |entry| category_for_entry(entry) }
304
+ categories.uniq.sort.to_h do |category|
305
+ category_candidates = candidates.select { |finding| category_for_finding(finding) == category }
306
+ category_findings = findings.select { |finding| category_for_finding(finding) == category }
307
+ category_expected = expected_by_category.fetch(category, [])
308
+ category_known = known_by_category.fetch(category, [])
309
+ blocked = category_findings.select { |finding| finding.classification == :blocked }
310
+ unknown = category_findings.select { |finding| FindingFacts.unknown?(finding) }
311
+ [category, {
312
+ 'candidate_precision' => physical_precision(category_candidates, category_expected),
313
+ 'candidate_count' => category_candidates.length,
314
+ 'candidate_loc' => category_candidates.sum { |finding| FindingFacts.loc(finding) },
315
+ 'known_positive_recall' => physical_recall(category_candidates, category_known),
316
+ 'known_positive_count' => category_known.length,
317
+ 'finding_count' => category_findings.length,
318
+ 'blocked_count' => blocked.length,
319
+ 'blocked_rate' => FindingFacts.ratio(blocked.length, category_findings.length),
320
+ 'unknown_count' => unknown.length,
321
+ 'unknown_rate' => FindingFacts.ratio(unknown.length, category_findings.length),
322
+ 'rule_counts' => FindingFacts.tally(
323
+ category_findings.flat_map { |finding| FindingFacts.rule_hits(finding) }
324
+ ),
325
+ 'risk_counts' => FindingFacts.tally(
326
+ category_findings.flat_map { |finding| FindingFacts.risk_flags(finding) }
327
+ )
328
+ }]
329
+ end
330
+ end
331
+
332
+ def category_for_finding(finding)
333
+ entry = finding_identifiers(finding).filter_map { |identifier| gold_entry_index[identifier] }.min_by(&:first)&.last
334
+ entry ? category_for_entry(entry) : FindingFacts.category(finding)
335
+ end
336
+
337
+ def gold_entry_index
338
+ @gold_entry_index ||= (gold_entries + known_positive_entries).each_with_index.with_object({}) do |(entry, index), lookup|
339
+ identifier = entry_identifier(entry)
340
+ lookup[identifier] ||= [index, entry] if identifier
341
+ end.freeze
342
+ end
343
+
344
+ def category_for_entry(entry)
345
+ category = entry['category']
346
+ category.to_s.empty? ? 'uncategorized' : category.to_s
347
+ end
348
+
349
+ def physical_precision(candidates, expected_entries)
350
+ return 0.0 if candidates.empty?
351
+
352
+ expected_ids = expected_entries.filter_map { |entry| entry_identifier(entry) }.to_set
353
+ matches = candidates.count { |finding| expected_ids.intersect?(finding_identifiers(finding)) }
354
+ FindingFacts.ratio(matches, candidates.length)
355
+ end
356
+
357
+ def physical_recall(candidates, expected_entries)
358
+ return nil if expected_entries.empty?
359
+
360
+ candidate_ids = candidates.flat_map { |finding| finding_identifiers(finding) }.to_set
361
+ matches = expected_entries.count { |entry| candidate_ids.include?(entry_identifier(entry)) }
362
+ FindingFacts.ratio(matches, expected_entries.length)
363
+ end
364
+
365
+ def entry_identifier(entry)
366
+ entry['definition_id'] || entry['id'] || entry['node_id']
367
+ end
368
+
369
+ def finding_identifiers(finding)
370
+ [finding.node.definition_id, finding.node.symbol_id, finding.node.id].compact.uniq
371
+ end
372
+
373
+ def feature_ablation_metrics
374
+ feature_ablation.sort.to_h do |name, variants|
375
+ enabled = variant_report(variants, :on)
376
+ disabled = variant_report(variants, :off)
377
+ enabled_metrics = quality_metrics(enabled)
378
+ disabled_metrics = quality_metrics(disabled)
379
+ enabled_categories = category_metrics(enabled)
380
+ disabled_categories = category_metrics(disabled)
381
+ [name.to_s, {
382
+ 'on' => enabled_metrics.merge('by_category' => enabled_categories),
383
+ 'off' => disabled_metrics.merge('by_category' => disabled_categories),
384
+ 'difference' => quality_difference(
385
+ enabled, disabled, enabled_metrics, disabled_metrics, enabled_categories, disabled_categories
386
+ )
387
+ }]
388
+ end
389
+ end
390
+
391
+ def variant_report(variants, key)
392
+ report = variants[key] || variants[key.to_s]
393
+ raise Error, "Feature ablation requires #{key.inspect} report" unless report
394
+
395
+ report
396
+ end
397
+
398
+ def quality_difference(enabled, disabled, enabled_metrics, disabled_metrics, enabled_categories,
399
+ disabled_categories)
400
+ numeric_keys = %w[
401
+ candidate_count candidate_loc candidate_precision known_positive_recall
402
+ blocked_count blocked_rate unknown_finding_count unknown_finding_rate
403
+ unknown_resolution_rate
404
+ ]
405
+ difference = numeric_keys.to_h do |key|
406
+ [key, metric_delta(enabled_metrics[key], disabled_metrics[key])]
407
+ end
408
+ enabled_ids = physical_candidate_ids(enabled)
409
+ disabled_ids = physical_candidate_ids(disabled)
410
+ difference.merge(
411
+ 'only_with_feature' => (enabled_ids - disabled_ids).sort,
412
+ 'only_without_feature' => (disabled_ids - enabled_ids).sort,
413
+ 'rule_counts' => count_difference(enabled_metrics['rule_counts'], disabled_metrics['rule_counts']),
414
+ 'risk_counts' => count_difference(enabled_metrics['risk_counts'], disabled_metrics['risk_counts']),
415
+ 'by_category' => category_difference(enabled_categories, disabled_categories)
416
+ )
417
+ end
418
+
419
+ def category_difference(enabled, disabled)
420
+ (enabled.keys | disabled.keys).sort.to_h do |category|
421
+ on = enabled.fetch(category, {})
422
+ off = disabled.fetch(category, {})
423
+ numeric_keys = %w[
424
+ candidate_precision candidate_count candidate_loc known_positive_recall
425
+ blocked_count blocked_rate unknown_count unknown_rate
426
+ ]
427
+ difference = numeric_keys.to_h { |key| [key, metric_delta(on[key] || 0, off[key] || 0)] }
428
+ [category, difference.merge(
429
+ 'rule_counts' => count_difference(on.fetch('rule_counts', {}), off.fetch('rule_counts', {})),
430
+ 'risk_counts' => count_difference(on.fetch('risk_counts', {}), off.fetch('risk_counts', {}))
431
+ )]
432
+ end
433
+ end
434
+
435
+ def measurement_for(target_report)
436
+ metrics_for(target_report).merge(
437
+ 'quality' => quality_metrics(target_report),
438
+ 'by_category' => category_metrics(target_report)
439
+ )
440
+ end
441
+
442
+ def physical_candidate_ids(target_report)
443
+ actionable_findings(target_report).to_set { |finding| finding.node.definition_id }
444
+ end
445
+
446
+ def metric_delta(enabled, disabled)
447
+ return nil if enabled.nil? || disabled.nil?
448
+
449
+ (enabled - disabled).round(4)
450
+ end
451
+
452
+ def count_difference(enabled, disabled)
453
+ (enabled.keys | disabled.keys).sort.to_h do |key|
454
+ [key, enabled.fetch(key, 0) - disabled.fetch(key, 0)]
455
+ end
456
+ end
125
457
  end
126
458
  end
127
459
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Bench
5
+ module FindingFacts
6
+ ACTIONABLE_CLASSIFICATIONS = %i[unreachable unused].freeze
7
+ UNKNOWN_BLOCKER_KINDS = %i[
8
+ analyzer_failure ambiguity_limit_exceeded dynamic_dispatch evidence_collision
9
+ dynamic_ancestry incomplete_analysis incomplete_source parse_failure parse_incomplete
10
+ partial_dispatch reference_scope_incomplete resolution_conflict resolution_invalid
11
+ unknown_dispatch unparsed_external_reference unsupported_refinement variable_eval
12
+ ].freeze
13
+ RULE_ROOT_REASONS = %i[
14
+ callback_registered rails_component rails_migration rails_route
15
+ rails_view_helper rails_view_reference
16
+ ].freeze
17
+ ORDINARY_DEFINITION_KINDS = %i[def defs define_method].freeze
18
+
19
+ module_function
20
+
21
+ def actionable?(finding)
22
+ ACTIONABLE_CLASSIFICATIONS.include?(finding.classification.to_sym)
23
+ end
24
+
25
+ def loc(finding)
26
+ start_line = positive_integer(finding.node.line)
27
+ end_line = positive_integer(finding.node.end_line) || start_line
28
+ return 0 unless start_line
29
+
30
+ [end_line - start_line + 1, 1].max
31
+ end
32
+
33
+ def unknown?(finding)
34
+ finding.blockers.any? { |blocker| unknown_blocker?(blocker) }
35
+ end
36
+
37
+ def risk_flags(finding)
38
+ node = finding.node
39
+ flags = []
40
+ flags << 'public_or_protected_visibility' if %i[public protected].include?(node.visibility.to_sym)
41
+ flags << 'generated_method' unless ORDINARY_DEFINITION_KINDS.include?(node.defined_via.to_sym)
42
+ flags << 'no_owner' if node.owner.to_s.empty?
43
+ flags << 'test_definition' if node.test
44
+ flags << 'analysis_incomplete' if finding.blockers.any?
45
+ flags << 'duplicate_or_redefinition' if finding.blockers.any? do |blocker|
46
+ blocker.kind.to_sym == :duplicate_definition
47
+ end
48
+ flags.uniq.sort
49
+ end
50
+
51
+ def rule_hits(finding)
52
+ evidence_rules = finding.evidences.flat_map do |evidence|
53
+ metadata = stringify_keys(evidence.metadata)
54
+ values = [metadata['rule_id'], metadata['rule'], metadata['rules']]
55
+ values.flatten.compact
56
+ end
57
+ blocker_rules = finding.blockers.filter_map do |blocker|
58
+ metadata = stringify_keys(blocker.metadata)
59
+ metadata['rule_id'] || metadata['rule']
60
+ end
61
+ (evidence_rules + blocker_rules).map(&:to_s).reject(&:empty?).uniq.sort
62
+ end
63
+
64
+ def category(finding)
65
+ explicit = explicit_category(finding)
66
+ return explicit if explicit
67
+
68
+ blocker = finding.blockers.first
69
+ return blocker.kind.to_s if blocker
70
+
71
+ defined_via = finding.node.defined_via.to_sym
72
+ return 'generated_method' unless ORDINARY_DEFINITION_KINDS.include?(defined_via)
73
+
74
+ finding.classification.to_s
75
+ end
76
+
77
+ def report_rule_counts(report)
78
+ finding_rules = report.reportable_findings.flat_map { |finding| rule_hits(finding) }
79
+ root_rules = report.graph.entry_points.filter_map do |root|
80
+ node = report.graph.nodes.exact(root.definition_id)
81
+ next unless node && report_path?(report, node.file)
82
+
83
+ root.reason.to_s if RULE_ROOT_REASONS.include?(root.reason.to_sym)
84
+ end
85
+ tally(finding_rules + root_rules)
86
+ end
87
+
88
+ def report_risk_counts(report)
89
+ tally(report.reportable_findings.flat_map { |finding| risk_flags(finding) })
90
+ end
91
+
92
+ def tally(values)
93
+ values.map(&:to_s).reject(&:empty?).tally.sort.to_h
94
+ end
95
+
96
+ def resolution_counts(report)
97
+ visible_call_site_ids = report.graph.call_sites.filter_map do |site|
98
+ site.call_site_id if report_path?(report, site.file)
99
+ end.to_set
100
+ records = report.graph.resolution_records.select do |record|
101
+ visible_call_site_ids.include?(record.resolution.call_site_id)
102
+ end
103
+ counts = records.map { |record| record.resolution.status.to_s }.tally
104
+ {
105
+ 'total' => records.length,
106
+ 'complete' => counts.fetch('complete', 0),
107
+ 'partial' => counts.fetch('partial', 0),
108
+ 'unknown' => counts.fetch('unknown', 0)
109
+ }
110
+ end
111
+
112
+ def ratio(numerator, denominator)
113
+ denominator.zero? ? 0.0 : (numerator.to_f / denominator).round(4)
114
+ end
115
+
116
+ def report_path?(report, path)
117
+ !report.respond_to?(:report_path?) || report.report_path?(path)
118
+ end
119
+ private_class_method :report_path?
120
+
121
+ def stringify_keys(value)
122
+ return {} unless value.respond_to?(:to_h)
123
+
124
+ value.to_h.transform_keys(&:to_s)
125
+ end
126
+ private_class_method :stringify_keys
127
+
128
+ def positive_integer(value)
129
+ integer = Integer(value, exception: false)
130
+ integer if integer&.positive?
131
+ end
132
+ private_class_method :positive_integer
133
+
134
+ def unknown_blocker?(blocker)
135
+ kind = blocker.kind.to_sym
136
+ UNKNOWN_BLOCKER_KINDS.include?(kind) || kind.to_s.include?('unknown') || kind.to_s.include?('partial')
137
+ end
138
+ private_class_method :unknown_blocker?
139
+
140
+ def explicit_category(finding)
141
+ records = finding.evidences + finding.blockers
142
+ records.each do |record|
143
+ metadata = stringify_keys(record.metadata)
144
+ value = metadata['benchmark_category'] || metadata['category']
145
+ return value.to_s unless value.to_s.empty?
146
+ end
147
+ nil
148
+ end
149
+ private_class_method :explicit_category
150
+ end
151
+ end
152
+ end