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
@@ -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,10 +53,10 @@ 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
58
  def metrics_for(target_report, expected: gold_standard, recall_expected: expected)
52
- actual = target_report.dead_methods(min_confidence: min_confidence).to_set { |finding| finding.node.id }
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
62
  false_negative = recall_expected ? recall_expected - actual : Set.new
@@ -71,37 +78,71 @@ module Necropsy
71
78
  end
72
79
 
73
80
  def gold_standard(classification: nil, confidence: nil)
74
- entries = gold_entries
75
81
  if classification
76
- scoped = entries.select do |entry|
77
- !entry.key?('classification') || entry['classification'] == classification.to_s
78
- end
82
+ grouped = gold_entries_by(:classification)
83
+ scoped = grouped.fetch(nil, []) + grouped.fetch(classification.to_s, [])
79
84
  return ids_for(scoped)
80
85
  end
81
86
  if confidence
82
- scoped = entries.select { |entry| entry['confidence'] == confidence.to_s }
87
+ scoped = gold_entries_by(:confidence).fetch(confidence.to_s, [])
83
88
  return nil if scoped.empty?
84
89
 
85
90
  return ids_for(scoped)
86
91
  end
87
92
 
88
- ids_for(entries)
93
+ ids_for(gold_entries)
89
94
  end
90
95
 
91
96
  def gold_entries
92
- payload = YAML.safe_load_file(gold_standard_path, aliases: true) || {}
93
- 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)
94
129
  Array(entries).map do |entry|
95
130
  entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { 'id' => entry }
96
131
  end
97
132
  end
98
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
+
99
140
  def ids_for(entries)
100
141
  entries.map { |entry| entry['id'] || entry['node_id'] }.compact.to_set
101
142
  end
102
143
 
103
144
  def grouped_metrics(attribute)
104
- report.findings.group_by { |finding| finding.public_send(attribute) }.transform_values do |findings|
145
+ report.reportable_findings.group_by { |finding| finding.public_send(attribute) }.transform_values do |findings|
105
146
  value = findings.first.public_send(attribute)
106
147
  grouped_report = Report.new(root: report.root, graph: report.graph, findings: findings)
107
148
  if attribute == :classification
@@ -119,21 +160,96 @@ module Necropsy
119
160
  end
120
161
 
121
162
  def ablation_metrics
122
- ABLATION_ANALYZERS.transform_values do |classes|
163
+ analyzer_metrics = ABLATION_ANALYZERS.transform_values do |classes|
123
164
  analyzers = classes.map(&:new)
124
- 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
+ ))
125
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'))
126
207
  end
127
208
 
128
209
  def ratio(numerator, denominator)
129
- return 1.0 if denominator.zero?
210
+ return 0.0 if denominator.zero?
130
211
 
131
212
  numerator.to_f / denominator
132
213
  end
133
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
+
134
244
  def release_criteria(result)
135
- checks = { 'precision' => result['precision'] >= precision_threshold }
136
- 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
137
253
  {
138
254
  'precision_threshold' => precision_threshold,
139
255
  'recall_threshold' => recall_threshold,
@@ -141,6 +257,203 @@ module Necropsy
141
257
  'checks' => checks
142
258
  }
143
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
144
457
  end
145
458
  end
146
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