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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +125 -0
  4. data/Rakefile +8 -0
  5. data/exe/necropsy +6 -0
  6. data/lib/necropsy/analyzer.rb +19 -0
  7. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
  8. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
  9. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
  10. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
  11. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
  12. data/lib/necropsy/analyzers/static/cha.rb +106 -0
  13. data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
  14. data/lib/necropsy/analyzers/static/rta.rb +79 -0
  15. data/lib/necropsy/ast_scanner.rb +616 -0
  16. data/lib/necropsy/bench/evaluator.rb +127 -0
  17. data/lib/necropsy/cache/scan_cache.rb +158 -0
  18. data/lib/necropsy/cli.rb +287 -0
  19. data/lib/necropsy/confidence/scorer.rb +158 -0
  20. data/lib/necropsy/configuration.rb +116 -0
  21. data/lib/necropsy/coverage_runtime.rb +13 -0
  22. data/lib/necropsy/entry_points/plain.rb +36 -0
  23. data/lib/necropsy/entry_points/rails.rb +335 -0
  24. data/lib/necropsy/entry_points/test.rb +13 -0
  25. data/lib/necropsy/graph/call_graph.rb +199 -0
  26. data/lib/necropsy/guardrail/baseline.rb +47 -0
  27. data/lib/necropsy/guardrail/diff.rb +16 -0
  28. data/lib/necropsy/guardrail/quarantine.rb +46 -0
  29. data/lib/necropsy/models.rb +164 -0
  30. data/lib/necropsy/project.rb +65 -0
  31. data/lib/necropsy/reachability/engine.rb +42 -0
  32. data/lib/necropsy/report.rb +50 -0
  33. data/lib/necropsy/reporter.rb +112 -0
  34. data/lib/necropsy/runner.rb +74 -0
  35. data/lib/necropsy/version.rb +5 -0
  36. data/lib/necropsy.rb +46 -0
  37. 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
@@ -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