necropsy 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- metadata +85 -1
|
@@ -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
|
|
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
|
-
|
|
77
|
-
|
|
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 =
|
|
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(
|
|
93
|
+
ids_for(gold_entries)
|
|
89
94
|
end
|
|
90
95
|
|
|
91
96
|
def gold_entries
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
136
|
-
|
|
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
|