necropsy 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/exe/necropsy +6 -0
- data/lib/necropsy/analyzer.rb +19 -0
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
- data/lib/necropsy/analyzers/static/cha.rb +106 -0
- data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
- data/lib/necropsy/analyzers/static/rta.rb +79 -0
- data/lib/necropsy/ast_scanner.rb +616 -0
- data/lib/necropsy/bench/evaluator.rb +127 -0
- data/lib/necropsy/cache/scan_cache.rb +158 -0
- data/lib/necropsy/cli.rb +287 -0
- data/lib/necropsy/confidence/scorer.rb +158 -0
- data/lib/necropsy/configuration.rb +116 -0
- data/lib/necropsy/coverage_runtime.rb +13 -0
- data/lib/necropsy/entry_points/plain.rb +36 -0
- data/lib/necropsy/entry_points/rails.rb +335 -0
- data/lib/necropsy/entry_points/test.rb +13 -0
- data/lib/necropsy/graph/call_graph.rb +199 -0
- data/lib/necropsy/guardrail/baseline.rb +47 -0
- data/lib/necropsy/guardrail/diff.rb +16 -0
- data/lib/necropsy/guardrail/quarantine.rb +46 -0
- data/lib/necropsy/models.rb +164 -0
- data/lib/necropsy/project.rb +65 -0
- data/lib/necropsy/reachability/engine.rb +42 -0
- data/lib/necropsy/report.rb +50 -0
- data/lib/necropsy/reporter.rb +112 -0
- data/lib/necropsy/runner.rb +74 -0
- data/lib/necropsy/version.rb +5 -0
- data/lib/necropsy.rb +46 -0
- metadata +95 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
module Bench
|
|
7
|
+
class Evaluator
|
|
8
|
+
ABLATION_ANALYZERS = {
|
|
9
|
+
'name_resolution' => [Analyzers::Static::NameResolution],
|
|
10
|
+
'cha' => [Analyzers::Static::CHA],
|
|
11
|
+
'rta' => [Analyzers::Static::RTA],
|
|
12
|
+
'name_resolution+cha' => [Analyzers::Static::NameResolution, Analyzers::Static::CHA],
|
|
13
|
+
'name_resolution+rta' => [Analyzers::Static::NameResolution, Analyzers::Static::RTA],
|
|
14
|
+
'all_static' => [Analyzers::Static::NameResolution, Analyzers::Static::CHA, Analyzers::Static::RTA]
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
report:,
|
|
19
|
+
gold_standard_path:,
|
|
20
|
+
min_confidence: :low,
|
|
21
|
+
root: nil,
|
|
22
|
+
config_path: nil,
|
|
23
|
+
ablation: false,
|
|
24
|
+
precision_threshold: 0.85,
|
|
25
|
+
recall_threshold: nil
|
|
26
|
+
)
|
|
27
|
+
@report = report
|
|
28
|
+
@gold_standard_path = gold_standard_path
|
|
29
|
+
@min_confidence = min_confidence
|
|
30
|
+
@root = root
|
|
31
|
+
@config_path = config_path
|
|
32
|
+
@ablation = ablation
|
|
33
|
+
@precision_threshold = precision_threshold
|
|
34
|
+
@recall_threshold = recall_threshold
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call
|
|
38
|
+
result = metrics_for(report)
|
|
39
|
+
result['by_classification'] = grouped_metrics(:classification)
|
|
40
|
+
result['by_confidence'] = grouped_metrics(:confidence)
|
|
41
|
+
result['ablation'] = ablation_metrics if ablation && root
|
|
42
|
+
result['release_criteria'] = release_criteria(result)
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :report, :gold_standard_path, :min_confidence, :root, :config_path, :ablation, :precision_threshold,
|
|
49
|
+
:recall_threshold
|
|
50
|
+
|
|
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 }
|
|
53
|
+
true_positive = actual & expected
|
|
54
|
+
false_positive = actual - expected
|
|
55
|
+
false_negative = expected - actual
|
|
56
|
+
|
|
57
|
+
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))
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
'precision' => precision.round(4),
|
|
63
|
+
'recall' => recall.round(4),
|
|
64
|
+
'f1' => f1.round(4),
|
|
65
|
+
'true_positive' => true_positive.to_a.sort,
|
|
66
|
+
'false_positive' => false_positive.to_a.sort,
|
|
67
|
+
'false_negative' => false_negative.to_a.sort
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def gold_standard(classification: nil)
|
|
72
|
+
entries = gold_entries
|
|
73
|
+
if classification
|
|
74
|
+
scoped = entries.select do |entry|
|
|
75
|
+
!entry.key?('classification') || entry['classification'] == classification.to_s
|
|
76
|
+
end
|
|
77
|
+
return ids_for(scoped)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
ids_for(entries)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def gold_entries
|
|
84
|
+
payload = YAML.load_file(gold_standard_path) || {}
|
|
85
|
+
entries = payload['dead_methods'] || payload['findings'] || payload
|
|
86
|
+
Array(entries).map do |entry|
|
|
87
|
+
entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { 'id' => entry }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ids_for(entries)
|
|
92
|
+
entries.map { |entry| entry['id'] || entry['node_id'] }.compact.to_set
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
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)
|
|
99
|
+
end.transform_keys(&:to_s)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ablation_metrics
|
|
103
|
+
ABLATION_ANALYZERS.transform_values do |classes|
|
|
104
|
+
analyzers = classes.map(&:new)
|
|
105
|
+
metrics_for(Necropsy.analyze(root: root, config_path: config_path, analyzers: analyzers))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ratio(numerator, denominator)
|
|
110
|
+
return 1.0 if denominator.zero?
|
|
111
|
+
|
|
112
|
+
numerator.to_f / denominator
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def release_criteria(result)
|
|
116
|
+
checks = { 'precision' => result['precision'] >= precision_threshold }
|
|
117
|
+
checks['recall'] = result['recall'] >= recall_threshold if recall_threshold
|
|
118
|
+
{
|
|
119
|
+
'precision_threshold' => precision_threshold,
|
|
120
|
+
'recall_threshold' => recall_threshold,
|
|
121
|
+
'passed' => checks.values.all?,
|
|
122
|
+
'checks' => checks
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module Cache
|
|
8
|
+
class ScanCache
|
|
9
|
+
VERSION = 3
|
|
10
|
+
|
|
11
|
+
def initialize(project:)
|
|
12
|
+
@project = project
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fetch(files)
|
|
16
|
+
return yield unless project.config.cache_enabled?
|
|
17
|
+
|
|
18
|
+
metadata = cache_metadata(files)
|
|
19
|
+
cached = read(metadata)
|
|
20
|
+
return cached if cached
|
|
21
|
+
|
|
22
|
+
result = yield
|
|
23
|
+
write(metadata, result)
|
|
24
|
+
result
|
|
25
|
+
rescue SystemCallError, Psych::Exception
|
|
26
|
+
yield
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :project
|
|
32
|
+
|
|
33
|
+
def read(metadata)
|
|
34
|
+
payload = load_payload
|
|
35
|
+
return nil unless payload
|
|
36
|
+
return nil unless payload['version'] == VERSION
|
|
37
|
+
return nil unless payload['metadata'] == metadata
|
|
38
|
+
|
|
39
|
+
deserialize_scan_result(payload.fetch('scan_result'))
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def load_payload
|
|
45
|
+
return nil unless File.exist?(path)
|
|
46
|
+
|
|
47
|
+
YAML.load_file(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write(metadata, result)
|
|
51
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
52
|
+
File.write(path, {
|
|
53
|
+
'version' => VERSION,
|
|
54
|
+
'metadata' => metadata,
|
|
55
|
+
'scan_result' => serialize_scan_result(result)
|
|
56
|
+
}.to_yaml)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def path
|
|
62
|
+
@path ||= File.expand_path(project.config.cache_path, project.root)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def file_metadata(files)
|
|
66
|
+
files.each_with_object({}) do |file, metadata|
|
|
67
|
+
stat = File.stat(file)
|
|
68
|
+
metadata[project.relative_path(file)] = {
|
|
69
|
+
'size' => stat.size,
|
|
70
|
+
'mtime' => "#{stat.mtime.to_i}.#{stat.mtime.nsec}"
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cache_metadata(files)
|
|
76
|
+
{
|
|
77
|
+
'files' => file_metadata(files),
|
|
78
|
+
'configuration' => project.config.scan_cache_key
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def serialize_scan_result(result)
|
|
83
|
+
{
|
|
84
|
+
'nodes' => result.nodes.map(&:to_h),
|
|
85
|
+
'call_sites' => result.call_sites.map(&:to_h),
|
|
86
|
+
'instantiated_classes' => result.instantiated_classes.to_a.sort,
|
|
87
|
+
'uncertainties' => result.uncertainties.transform_values { |messages| Array(messages).map(&:to_s) },
|
|
88
|
+
'class_infos' => result.class_infos.map(&:to_h),
|
|
89
|
+
'entrypoint_hints' => result.entrypoint_hints.map(&:to_h)
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def deserialize_scan_result(data)
|
|
94
|
+
ScanResult.new(
|
|
95
|
+
nodes: Array(data['nodes']).map { |node| deserialize_node(node) },
|
|
96
|
+
call_sites: Array(data['call_sites']).map { |site| deserialize_call_site(site) },
|
|
97
|
+
instantiated_classes: Set.new(Array(data['instantiated_classes'])),
|
|
98
|
+
uncertainties: deserialize_uncertainties(data['uncertainties']),
|
|
99
|
+
class_infos: Array(data['class_infos']).map { |info| deserialize_class_info(info) },
|
|
100
|
+
entrypoint_hints: Array(data['entrypoint_hints']).map { |entry| deserialize_entry_point(entry) }
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def deserialize_node(data)
|
|
105
|
+
Node.new(
|
|
106
|
+
id: data['id'],
|
|
107
|
+
kind: data['kind'].to_sym,
|
|
108
|
+
file: data['file'],
|
|
109
|
+
line: data['line'].to_i,
|
|
110
|
+
end_line: data['end_line'].to_i,
|
|
111
|
+
defined_via: data['defined_via'].to_sym,
|
|
112
|
+
owner: data['owner'],
|
|
113
|
+
name: data['name'],
|
|
114
|
+
test: data['test']
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def deserialize_call_site(data)
|
|
119
|
+
CallSite.new(
|
|
120
|
+
caller_id: data['caller_id'],
|
|
121
|
+
message: data['message'],
|
|
122
|
+
receiver_kind: data['receiver_kind'].to_sym,
|
|
123
|
+
receiver_name: data['receiver_name'],
|
|
124
|
+
file: data['file'],
|
|
125
|
+
line: data['line'].to_i,
|
|
126
|
+
test: data['test'],
|
|
127
|
+
dynamic: data['dynamic'],
|
|
128
|
+
metadata: data['metadata'] || {}
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def deserialize_class_info(data)
|
|
133
|
+
ClassInfo.new(
|
|
134
|
+
id: data['id'],
|
|
135
|
+
kind: data['kind'].to_sym,
|
|
136
|
+
file: data['file'],
|
|
137
|
+
line: data['line'],
|
|
138
|
+
superclass: data['superclass'],
|
|
139
|
+
superclass_candidates: Array(data['superclass_candidates']),
|
|
140
|
+
includes: Array(data['includes']),
|
|
141
|
+
prepends: Array(data['prepends']),
|
|
142
|
+
extends: Array(data['extends']),
|
|
143
|
+
dynamic: data['dynamic']
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def deserialize_entry_point(data)
|
|
148
|
+
EntryPoint.new(node_id: data['node_id'], reason: data['reason'].to_sym)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def deserialize_uncertainties(data)
|
|
152
|
+
Hash.new { |hash, key| hash[key] = [] }.tap do |uncertainties|
|
|
153
|
+
(data || {}).each { |node_id, messages| uncertainties[node_id] = Array(messages) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/necropsy/cli.rb
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'English'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'optparse'
|
|
8
|
+
require 'securerandom'
|
|
9
|
+
require 'yaml'
|
|
10
|
+
require 'necropsy'
|
|
11
|
+
|
|
12
|
+
module Necropsy
|
|
13
|
+
class CLI
|
|
14
|
+
def self.run(argv)
|
|
15
|
+
new.run(argv)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(argv)
|
|
19
|
+
command = argv.first&.start_with?('-') ? 'analyze' : argv.shift || 'analyze'
|
|
20
|
+
options = default_options
|
|
21
|
+
parser = build_parser(options)
|
|
22
|
+
parser.parse!(argv)
|
|
23
|
+
apply_config_defaults(options)
|
|
24
|
+
|
|
25
|
+
case command
|
|
26
|
+
when 'analyze'
|
|
27
|
+
report = analyze(options)
|
|
28
|
+
puts Reporter.new(report).render(format: options[:format], min_confidence: options[:min_confidence])
|
|
29
|
+
0
|
|
30
|
+
when 'baseline'
|
|
31
|
+
report = analyze(options)
|
|
32
|
+
path = File.expand_path(options[:baseline], options[:root])
|
|
33
|
+
Guardrail::Baseline.write(report, path: path)
|
|
34
|
+
puts "Wrote #{path}"
|
|
35
|
+
0
|
|
36
|
+
when 'check'
|
|
37
|
+
check(options)
|
|
38
|
+
when 'quarantine'
|
|
39
|
+
quarantine(options)
|
|
40
|
+
when 'bench'
|
|
41
|
+
bench(options)
|
|
42
|
+
when 'record'
|
|
43
|
+
record(options, argv)
|
|
44
|
+
when 'coverage'
|
|
45
|
+
coverage(options, argv)
|
|
46
|
+
else
|
|
47
|
+
warn "Unknown command: #{command}"
|
|
48
|
+
warn parser
|
|
49
|
+
2
|
|
50
|
+
end
|
|
51
|
+
rescue OptionParser::ParseError, Error => e
|
|
52
|
+
warn e.message
|
|
53
|
+
2
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def default_options
|
|
59
|
+
{
|
|
60
|
+
root: '.',
|
|
61
|
+
config: nil,
|
|
62
|
+
format: :human,
|
|
63
|
+
min_confidence: :low,
|
|
64
|
+
baseline: nil,
|
|
65
|
+
fail_on: nil,
|
|
66
|
+
diff_base: nil,
|
|
67
|
+
ratchet: false,
|
|
68
|
+
write: false,
|
|
69
|
+
gold_standard: nil,
|
|
70
|
+
output: 'tmp/necropsy_trace_point.yml',
|
|
71
|
+
sample_rate: 1.0,
|
|
72
|
+
ablation: false,
|
|
73
|
+
precision_threshold: nil,
|
|
74
|
+
recall_threshold: nil
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_parser(options)
|
|
79
|
+
OptionParser.new do |parser|
|
|
80
|
+
parser.banner = 'Usage: necropsy COMMAND [options]'
|
|
81
|
+
parser.on('--root PATH', 'Project root') { |value| options[:root] = value }
|
|
82
|
+
parser.on('--config PATH', 'Configuration file') { |value| options[:config] = value }
|
|
83
|
+
parser.on('--format FORMAT', 'human, json, yaml, sarif, or github') { |value| options[:format] = value.to_sym }
|
|
84
|
+
parser.on('--min-confidence LEVEL', 'low, medium, high, or certain') do |value|
|
|
85
|
+
options[:min_confidence] = value.to_sym
|
|
86
|
+
end
|
|
87
|
+
parser.on('--baseline PATH', 'Baseline path') { |value| options[:baseline] = value }
|
|
88
|
+
parser.on('--fail-on LEVEL', 'CI failure threshold') { |value| options[:fail_on] = value.to_sym }
|
|
89
|
+
parser.on('--diff-base REV', 'Restrict reported findings to files changed since REV') do |value|
|
|
90
|
+
options[:diff_base] = value
|
|
91
|
+
end
|
|
92
|
+
parser.on('--ratchet', 'Fail if finding count grows beyond baseline count') { options[:ratchet] = true }
|
|
93
|
+
parser.on('--write', 'Write quarantine annotations') { options[:write] = true }
|
|
94
|
+
parser.on('--gold-standard PATH', 'Gold standard YAML for bench') { |value| options[:gold_standard] = value }
|
|
95
|
+
parser.on('--output PATH', 'Output path for record') { |value| options[:output] = value }
|
|
96
|
+
parser.on('--sample-rate RATE', Float, 'TracePoint sample rate for record') do |value|
|
|
97
|
+
options[:sample_rate] = value
|
|
98
|
+
end
|
|
99
|
+
parser.on('--ablation', 'Run bench across analyzer combinations') { options[:ablation] = true }
|
|
100
|
+
parser.on('--precision-threshold N', Float, 'Bench release precision threshold') do |value|
|
|
101
|
+
options[:precision_threshold] = value
|
|
102
|
+
end
|
|
103
|
+
parser.on('--recall-threshold N', Float, 'Bench release recall threshold') do |value|
|
|
104
|
+
options[:recall_threshold] = value
|
|
105
|
+
end
|
|
106
|
+
parser.on('-h', '--help', 'Show help') do
|
|
107
|
+
puts parser
|
|
108
|
+
exit 0
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def analyze(options)
|
|
114
|
+
Necropsy.analyze(root: options[:root], config_path: options[:config])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def apply_config_defaults(options)
|
|
118
|
+
config = Configuration.load(root: File.expand_path(options[:root]), path: options[:config])
|
|
119
|
+
options[:baseline] ||= config.baseline_path
|
|
120
|
+
options[:fail_on] ||= config.fail_on
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def check(options)
|
|
124
|
+
report = analyze(options)
|
|
125
|
+
findings = filtered_findings(report, options)
|
|
126
|
+
baseline_path = File.expand_path(options[:baseline], options[:root])
|
|
127
|
+
baseline = Guardrail::Baseline.load(baseline_path)
|
|
128
|
+
failures = findings.reject { |finding| baseline.include?(finding) }
|
|
129
|
+
|
|
130
|
+
if options[:ratchet] && findings.length > baseline.fingerprints.length
|
|
131
|
+
puts "Ratchet failed: #{findings.length} findings exceed baseline count #{baseline.fingerprints.length}"
|
|
132
|
+
return 1
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if failures.any?
|
|
136
|
+
puts Reporter.new(Report.new(root: report.root, graph: report.graph, findings: failures)).render(
|
|
137
|
+
format: :human,
|
|
138
|
+
min_confidence: options[:fail_on]
|
|
139
|
+
)
|
|
140
|
+
return 1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
puts 'Necropsy check passed'
|
|
144
|
+
0
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def filtered_findings(report, options)
|
|
148
|
+
findings = report.dead_methods(min_confidence: options[:fail_on])
|
|
149
|
+
return findings unless options[:diff_base]
|
|
150
|
+
|
|
151
|
+
project = Project.new(root: File.expand_path(options[:root]), config: report_config(options))
|
|
152
|
+
changed = project.changed_files(options[:diff_base])
|
|
153
|
+
findings.select { |finding| changed.include?(finding.node.file) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def report_config(options)
|
|
157
|
+
Configuration.load(root: File.expand_path(options[:root]), path: options[:config])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def quarantine(options)
|
|
161
|
+
report = analyze(options)
|
|
162
|
+
quarantine = Guardrail::Quarantine.new(report: report, root: File.expand_path(options[:root]))
|
|
163
|
+
if options[:write]
|
|
164
|
+
quarantine.write(min_confidence: options[:min_confidence])
|
|
165
|
+
puts 'Wrote quarantine annotations'
|
|
166
|
+
else
|
|
167
|
+
quarantine.suggestions(min_confidence: options[:min_confidence]).each do |suggestion|
|
|
168
|
+
finding = suggestion[:finding]
|
|
169
|
+
puts "#{suggestion[:path]}:#{suggestion[:line]} #{suggestion[:annotation]} #{finding.node.id}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
0
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def bench(options)
|
|
176
|
+
raise Error, '--gold-standard is required for bench' unless options[:gold_standard]
|
|
177
|
+
|
|
178
|
+
report = analyze(options)
|
|
179
|
+
config = report_config(options)
|
|
180
|
+
result = Bench::Evaluator.new(
|
|
181
|
+
report: report,
|
|
182
|
+
gold_standard_path: options[:gold_standard],
|
|
183
|
+
min_confidence: options[:min_confidence],
|
|
184
|
+
root: options[:root],
|
|
185
|
+
config_path: options[:config],
|
|
186
|
+
ablation: options[:ablation],
|
|
187
|
+
precision_threshold: options[:precision_threshold] || config.bench_precision_threshold,
|
|
188
|
+
recall_threshold: options[:recall_threshold] || config.bench_recall_threshold
|
|
189
|
+
).call
|
|
190
|
+
puts JSON.pretty_generate(result)
|
|
191
|
+
0
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def record(options, argv)
|
|
195
|
+
script_argv = argv.dup
|
|
196
|
+
script_argv.shift if script_argv.first == 'ruby'
|
|
197
|
+
script = script_argv.shift
|
|
198
|
+
raise Error, 'record requires a Ruby script after --' unless script
|
|
199
|
+
|
|
200
|
+
output = File.expand_path(options[:output], options[:root])
|
|
201
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
202
|
+
|
|
203
|
+
previous_argv = ARGV.dup
|
|
204
|
+
ARGV.replace(script_argv)
|
|
205
|
+
Analyzers::Dynamic::TracePointCollector.record(
|
|
206
|
+
root: File.expand_path(options[:root]),
|
|
207
|
+
output: output,
|
|
208
|
+
sample_rate: options[:sample_rate]
|
|
209
|
+
) do
|
|
210
|
+
load File.expand_path(script, options[:root])
|
|
211
|
+
end
|
|
212
|
+
puts "Wrote #{output}"
|
|
213
|
+
0
|
|
214
|
+
ensure
|
|
215
|
+
ARGV.replace(previous_argv) if previous_argv
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def coverage(options, argv)
|
|
219
|
+
script_argv = argv.dup
|
|
220
|
+
raise Error, 'coverage requires a Ruby script or command after --' if script_argv.empty?
|
|
221
|
+
|
|
222
|
+
output = File.expand_path(options[:output].sub('trace_point', 'coverage'), options[:root])
|
|
223
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
224
|
+
|
|
225
|
+
return record_coverage_script(options, output, script_argv) if local_ruby_script?(options, script_argv)
|
|
226
|
+
|
|
227
|
+
run_coverage_command(options, output, script_argv)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def record_coverage_script(options, output, script_argv)
|
|
231
|
+
script, args = ruby_script_and_args(options, script_argv)
|
|
232
|
+
previous_argv = ARGV.dup
|
|
233
|
+
ARGV.replace(args)
|
|
234
|
+
Analyzers::Dynamic::CoverageCollector.record(root: File.expand_path(options[:root]), output: output) do
|
|
235
|
+
load File.expand_path(script, options[:root])
|
|
236
|
+
end
|
|
237
|
+
puts "Wrote #{output}"
|
|
238
|
+
0
|
|
239
|
+
ensure
|
|
240
|
+
ARGV.replace(previous_argv) if previous_argv
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def run_coverage_command(options, output, script_argv)
|
|
244
|
+
run_id = SecureRandom.hex(16)
|
|
245
|
+
status = system(coverage_runtime_env(options, output, run_id), *script_argv)
|
|
246
|
+
puts "Wrote #{output}" if output_for_run?(output, run_id)
|
|
247
|
+
return 0 if status
|
|
248
|
+
|
|
249
|
+
$CHILD_STATUS&.exitstatus || 1
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def local_ruby_script?(options, script_argv)
|
|
253
|
+
script, = ruby_script_and_args(options, script_argv)
|
|
254
|
+
script&.end_with?('.rb') && File.file?(File.expand_path(script, options[:root]))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def ruby_script_and_args(_options, script_argv)
|
|
258
|
+
return [script_argv[1], script_argv.drop(2)] if script_argv.first == 'ruby'
|
|
259
|
+
|
|
260
|
+
[script_argv.first, script_argv.drop(1)]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def coverage_runtime_env(options, output, run_id)
|
|
264
|
+
rubyopt = [ENV.fetch('RUBYOPT', nil), '-rnecropsy/coverage_runtime'].compact.reject(&:empty?).join(' ')
|
|
265
|
+
{
|
|
266
|
+
'NECROPSY_COVERAGE_ROOT' => File.expand_path(options[:root]),
|
|
267
|
+
'NECROPSY_COVERAGE_OUTPUT' => output,
|
|
268
|
+
'NECROPSY_COVERAGE_MERGE' => '1',
|
|
269
|
+
'NECROPSY_COVERAGE_RUN_ID' => run_id,
|
|
270
|
+
'RUBYOPT' => rubyopt,
|
|
271
|
+
'RUBYLIB' => rubylib
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def rubylib
|
|
276
|
+
paths = [File.expand_path('..', __dir__), ENV.fetch('RUBYLIB', nil)].compact.reject(&:empty?)
|
|
277
|
+
paths.join(File::PATH_SEPARATOR)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def output_for_run?(output, run_id)
|
|
281
|
+
payload = YAML.load_file(output) || {}
|
|
282
|
+
payload.dig('observation', 'run_id') == run_id
|
|
283
|
+
rescue SystemCallError, Psych::Exception
|
|
284
|
+
false
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|