necropsy 0.1.0 → 0.2.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/.rubocop.yml +33 -0
- data/CHANGELOG.md +18 -0
- data/MEASUREMENTS.md +85 -0
- data/README.md +60 -8
- data/Rakefile +3 -1
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +12 -2
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +5 -1
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +68 -14
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +107 -12
- data/lib/necropsy/analyzers/static/cha.rb +10 -6
- data/lib/necropsy/analyzers/static/name_resolution.rb +29 -20
- data/lib/necropsy/analyzers/static/rta.rb +19 -3
- data/lib/necropsy/ast_scanner/call_recording.rb +156 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +142 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +176 -0
- data/lib/necropsy/ast_scanner/references.rb +81 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +177 -0
- data/lib/necropsy/ast_scanner/traversal.rb +184 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +68 -0
- data/lib/necropsy/ast_scanner.rb +24 -560
- data/lib/necropsy/bench/evaluator.rb +29 -10
- data/lib/necropsy/cache/scan_cache.rb +20 -12
- data/lib/necropsy/cli.rb +88 -32
- data/lib/necropsy/confidence/scorer.rb +97 -19
- data/lib/necropsy/configuration.rb +125 -7
- data/lib/necropsy/diagnostics.rb +202 -0
- data/lib/necropsy/entry_points/plain.rb +19 -2
- data/lib/necropsy/entry_points/rails.rb +121 -85
- data/lib/necropsy/graph/call_graph.rb +203 -21
- data/lib/necropsy/guardrail/baseline.rb +14 -5
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +9 -3
- data/lib/necropsy/models.rb +19 -11
- data/lib/necropsy/project.rb +49 -3
- data/lib/necropsy/reachability/engine.rb +31 -7
- data/lib/necropsy/report.rb +44 -14
- data/lib/necropsy/reporter.rb +11 -5
- data/lib/necropsy/runner.rb +33 -4
- data/lib/necropsy/trace_point_runtime.rb +19 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy.rb +4 -0
- data/script/measure.rb +20 -0
- metadata +22 -2
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
|
-
require '
|
|
4
|
+
require 'json'
|
|
5
5
|
|
|
6
6
|
module Necropsy
|
|
7
7
|
module Cache
|
|
8
8
|
class ScanCache
|
|
9
|
-
VERSION =
|
|
9
|
+
VERSION = 4
|
|
10
10
|
|
|
11
11
|
def initialize(project:)
|
|
12
12
|
@project = project
|
|
@@ -22,7 +22,8 @@ module Necropsy
|
|
|
22
22
|
result = yield
|
|
23
23
|
write(metadata, result)
|
|
24
24
|
result
|
|
25
|
-
rescue SystemCallError,
|
|
25
|
+
rescue SystemCallError, JSON::ParserError => e
|
|
26
|
+
warn_cache("Cache unavailable: #{e.message}")
|
|
26
27
|
yield
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -37,24 +38,26 @@ module Necropsy
|
|
|
37
38
|
return nil unless payload['metadata'] == metadata
|
|
38
39
|
|
|
39
40
|
deserialize_scan_result(payload.fetch('scan_result'))
|
|
40
|
-
rescue StandardError
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
warn_cache("Ignoring invalid cache: #{e.message}")
|
|
41
43
|
nil
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
def load_payload
|
|
45
47
|
return nil unless File.exist?(path)
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
JSON.parse(File.read(path))
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def write(metadata, result)
|
|
51
53
|
FileUtils.mkdir_p(File.dirname(path))
|
|
52
|
-
File.write(path, {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
rescue StandardError
|
|
54
|
+
File.write(path, JSON.generate({
|
|
55
|
+
'version' => VERSION,
|
|
56
|
+
'metadata' => metadata,
|
|
57
|
+
'scan_result' => serialize_scan_result(result)
|
|
58
|
+
}))
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
warn_cache("Could not write cache: #{e.message}")
|
|
58
61
|
nil
|
|
59
62
|
end
|
|
60
63
|
|
|
@@ -111,7 +114,8 @@ module Necropsy
|
|
|
111
114
|
defined_via: data['defined_via'].to_sym,
|
|
112
115
|
owner: data['owner'],
|
|
113
116
|
name: data['name'],
|
|
114
|
-
test: data['test']
|
|
117
|
+
test: data['test'],
|
|
118
|
+
visibility: (data['visibility'] || 'public').to_sym
|
|
115
119
|
)
|
|
116
120
|
end
|
|
117
121
|
|
|
@@ -153,6 +157,10 @@ module Necropsy
|
|
|
153
157
|
(data || {}).each { |node_id, messages| uncertainties[node_id] = Array(messages) }
|
|
154
158
|
end
|
|
155
159
|
end
|
|
160
|
+
|
|
161
|
+
def warn_cache(message)
|
|
162
|
+
warn "Necropsy: #{message}" if project.config.verbose?
|
|
163
|
+
end
|
|
156
164
|
end
|
|
157
165
|
end
|
|
158
166
|
end
|
data/lib/necropsy/cli.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'English'
|
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
require 'json'
|
|
7
7
|
require 'optparse'
|
|
8
|
+
require 'rbconfig'
|
|
8
9
|
require 'securerandom'
|
|
9
10
|
require 'yaml'
|
|
10
11
|
require 'necropsy'
|
|
@@ -20,12 +21,24 @@ module Necropsy
|
|
|
20
21
|
options = default_options
|
|
21
22
|
parser = build_parser(options)
|
|
22
23
|
parser.parse!(argv)
|
|
24
|
+
if options[:help]
|
|
25
|
+
puts parser
|
|
26
|
+
return 0
|
|
27
|
+
end
|
|
28
|
+
if options[:version]
|
|
29
|
+
puts Necropsy::VERSION
|
|
30
|
+
return 0
|
|
31
|
+
end
|
|
23
32
|
apply_config_defaults(options)
|
|
24
33
|
|
|
25
34
|
case command
|
|
26
35
|
when 'analyze'
|
|
27
36
|
report = analyze(options)
|
|
28
|
-
puts Reporter.new(report).render(
|
|
37
|
+
puts Reporter.new(report).render(
|
|
38
|
+
format: options[:format],
|
|
39
|
+
min_confidence: options[:min_confidence],
|
|
40
|
+
include_graph: options[:include_graph]
|
|
41
|
+
)
|
|
29
42
|
0
|
|
30
43
|
when 'baseline'
|
|
31
44
|
report = analyze(options)
|
|
@@ -43,12 +56,14 @@ module Necropsy
|
|
|
43
56
|
record(options, argv)
|
|
44
57
|
when 'coverage'
|
|
45
58
|
coverage(options, argv)
|
|
59
|
+
when 'why', 'explain'
|
|
60
|
+
diagnose(command, options, argv)
|
|
46
61
|
else
|
|
47
62
|
warn "Unknown command: #{command}"
|
|
48
63
|
warn parser
|
|
49
64
|
2
|
|
50
65
|
end
|
|
51
|
-
rescue OptionParser::ParseError, Error => e
|
|
66
|
+
rescue OptionParser::ParseError, Psych::Exception, Error => e
|
|
52
67
|
warn e.message
|
|
53
68
|
2
|
|
54
69
|
end
|
|
@@ -60,18 +75,21 @@ module Necropsy
|
|
|
60
75
|
root: '.',
|
|
61
76
|
config: nil,
|
|
62
77
|
format: :human,
|
|
63
|
-
min_confidence:
|
|
78
|
+
min_confidence: Reporter::DEFAULT_MIN_CONFIDENCE,
|
|
64
79
|
baseline: nil,
|
|
65
80
|
fail_on: nil,
|
|
66
81
|
diff_base: nil,
|
|
67
82
|
ratchet: false,
|
|
68
83
|
write: false,
|
|
69
84
|
gold_standard: nil,
|
|
70
|
-
output:
|
|
85
|
+
output: nil,
|
|
71
86
|
sample_rate: 1.0,
|
|
72
87
|
ablation: false,
|
|
73
88
|
precision_threshold: nil,
|
|
74
|
-
recall_threshold: nil
|
|
89
|
+
recall_threshold: nil,
|
|
90
|
+
help: false,
|
|
91
|
+
version: false,
|
|
92
|
+
include_graph: false
|
|
75
93
|
}
|
|
76
94
|
end
|
|
77
95
|
|
|
@@ -80,12 +98,17 @@ module Necropsy
|
|
|
80
98
|
parser.banner = 'Usage: necropsy COMMAND [options]'
|
|
81
99
|
parser.on('--root PATH', 'Project root') { |value| options[:root] = value }
|
|
82
100
|
parser.on('--config PATH', 'Configuration file') { |value| options[:config] = value }
|
|
83
|
-
parser.on('--format FORMAT',
|
|
101
|
+
parser.on('--format FORMAT', Reporter::FORMATS.map(&:to_s), 'Output format') do |value|
|
|
102
|
+
options[:format] = value.to_sym
|
|
103
|
+
end
|
|
104
|
+
parser.on('--include-graph', 'Include nodes and edges in JSON/YAML output') { options[:include_graph] = true }
|
|
84
105
|
parser.on('--min-confidence LEVEL', 'low, medium, high, or certain') do |value|
|
|
85
|
-
options[:min_confidence] = value
|
|
106
|
+
options[:min_confidence] = confidence_level(value)
|
|
86
107
|
end
|
|
87
108
|
parser.on('--baseline PATH', 'Baseline path') { |value| options[:baseline] = value }
|
|
88
|
-
parser.on('--fail-on LEVEL', 'CI failure threshold')
|
|
109
|
+
parser.on('--fail-on LEVEL', 'CI failure threshold') do |value|
|
|
110
|
+
options[:fail_on] = confidence_level(value)
|
|
111
|
+
end
|
|
89
112
|
parser.on('--diff-base REV', 'Restrict reported findings to files changed since REV') do |value|
|
|
90
113
|
options[:diff_base] = value
|
|
91
114
|
end
|
|
@@ -94,6 +117,8 @@ module Necropsy
|
|
|
94
117
|
parser.on('--gold-standard PATH', 'Gold standard YAML for bench') { |value| options[:gold_standard] = value }
|
|
95
118
|
parser.on('--output PATH', 'Output path for record') { |value| options[:output] = value }
|
|
96
119
|
parser.on('--sample-rate RATE', Float, 'TracePoint sample rate for record') do |value|
|
|
120
|
+
raise OptionParser::InvalidArgument, 'sample rate must be between 0.0 and 1.0' unless value.between?(0.0, 1.0)
|
|
121
|
+
|
|
97
122
|
options[:sample_rate] = value
|
|
98
123
|
end
|
|
99
124
|
parser.on('--ablation', 'Run bench across analyzer combinations') { options[:ablation] = true }
|
|
@@ -104,9 +129,9 @@ module Necropsy
|
|
|
104
129
|
options[:recall_threshold] = value
|
|
105
130
|
end
|
|
106
131
|
parser.on('-h', '--help', 'Show help') do
|
|
107
|
-
|
|
108
|
-
exit 0
|
|
132
|
+
options[:help] = true
|
|
109
133
|
end
|
|
134
|
+
parser.on('-v', '--version', 'Show version') { options[:version] = true }
|
|
110
135
|
end
|
|
111
136
|
end
|
|
112
137
|
|
|
@@ -114,6 +139,17 @@ module Necropsy
|
|
|
114
139
|
Necropsy.analyze(root: options[:root], config_path: options[:config])
|
|
115
140
|
end
|
|
116
141
|
|
|
142
|
+
def diagnose(command, options, argv)
|
|
143
|
+
node_id = argv.shift
|
|
144
|
+
raise Error, "#{command} requires a symbol ID" unless node_id
|
|
145
|
+
raise Error, "Unexpected arguments for #{command}: #{argv.join(' ')}" unless argv.empty?
|
|
146
|
+
|
|
147
|
+
diagnostics = Diagnostics.new(analyze(options))
|
|
148
|
+
payload = command == 'why' ? diagnostics.why(node_id) : diagnostics.explain(node_id)
|
|
149
|
+
puts diagnostics.render(payload, format: options[:format])
|
|
150
|
+
0
|
|
151
|
+
end
|
|
152
|
+
|
|
117
153
|
def apply_config_defaults(options)
|
|
118
154
|
config = Configuration.load(root: File.expand_path(options[:root]), path: options[:config])
|
|
119
155
|
options[:baseline] ||= config.baseline_path
|
|
@@ -127,8 +163,9 @@ module Necropsy
|
|
|
127
163
|
baseline = Guardrail::Baseline.load(baseline_path)
|
|
128
164
|
failures = findings.reject { |finding| baseline.include?(finding) }
|
|
129
165
|
|
|
130
|
-
|
|
131
|
-
|
|
166
|
+
baseline_count = baseline.count_at_least(options[:fail_on])
|
|
167
|
+
if options[:ratchet] && findings.length > baseline_count
|
|
168
|
+
puts "Ratchet failed: #{findings.length} findings exceed baseline count #{baseline_count}"
|
|
132
169
|
return 1
|
|
133
170
|
end
|
|
134
171
|
|
|
@@ -192,34 +229,25 @@ module Necropsy
|
|
|
192
229
|
end
|
|
193
230
|
|
|
194
231
|
def record(options, argv)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
script = script_argv.shift
|
|
198
|
-
raise Error, 'record requires a Ruby script after --' unless script
|
|
232
|
+
command = argv.dup
|
|
233
|
+
raise Error, 'record requires a Ruby script or command after --' if command.empty?
|
|
199
234
|
|
|
200
|
-
output = File.expand_path(options[:output], options[:root])
|
|
235
|
+
output = File.expand_path(options[:output] || 'tmp/necropsy_trace_point.yml', options[:root])
|
|
201
236
|
FileUtils.mkdir_p(File.dirname(output))
|
|
237
|
+
command = trace_command(options, command)
|
|
238
|
+
run_id = SecureRandom.hex(16)
|
|
239
|
+
status = system(trace_runtime_env(options, output, run_id), *command)
|
|
240
|
+
puts "Wrote #{output}" if output_for_run?(output, run_id)
|
|
241
|
+
return 0 if status
|
|
202
242
|
|
|
203
|
-
|
|
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
|
|
243
|
+
$CHILD_STATUS&.exitstatus || 1
|
|
216
244
|
end
|
|
217
245
|
|
|
218
246
|
def coverage(options, argv)
|
|
219
247
|
script_argv = argv.dup
|
|
220
248
|
raise Error, 'coverage requires a Ruby script or command after --' if script_argv.empty?
|
|
221
249
|
|
|
222
|
-
output = File.expand_path(options[:output]
|
|
250
|
+
output = File.expand_path(options[:output] || 'tmp/necropsy_coverage.yml', options[:root])
|
|
223
251
|
FileUtils.mkdir_p(File.dirname(output))
|
|
224
252
|
|
|
225
253
|
return record_coverage_script(options, output, script_argv) if local_ruby_script?(options, script_argv)
|
|
@@ -272,16 +300,44 @@ module Necropsy
|
|
|
272
300
|
}
|
|
273
301
|
end
|
|
274
302
|
|
|
303
|
+
def trace_runtime_env(options, output, run_id)
|
|
304
|
+
rubyopt = [ENV.fetch('RUBYOPT', nil), '-rnecropsy/trace_point_runtime'].compact.reject(&:empty?).join(' ')
|
|
305
|
+
{
|
|
306
|
+
'NECROPSY_TRACE_ROOT' => File.expand_path(options[:root]),
|
|
307
|
+
'NECROPSY_TRACE_OUTPUT' => output,
|
|
308
|
+
'NECROPSY_TRACE_SAMPLE_RATE' => options[:sample_rate].to_s,
|
|
309
|
+
'NECROPSY_TRACE_MERGE' => '1',
|
|
310
|
+
'NECROPSY_TRACE_RUN_ID' => run_id,
|
|
311
|
+
'RUBYOPT' => rubyopt,
|
|
312
|
+
'RUBYLIB' => rubylib
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def trace_command(options, command)
|
|
317
|
+
script = command.first
|
|
318
|
+
path = File.expand_path(script, options[:root])
|
|
319
|
+
return [RbConfig.ruby, path, *command.drop(1)] if script.end_with?('.rb') && File.file?(path)
|
|
320
|
+
|
|
321
|
+
command
|
|
322
|
+
end
|
|
323
|
+
|
|
275
324
|
def rubylib
|
|
276
325
|
paths = [File.expand_path('..', __dir__), ENV.fetch('RUBYLIB', nil)].compact.reject(&:empty?)
|
|
277
326
|
paths.join(File::PATH_SEPARATOR)
|
|
278
327
|
end
|
|
279
328
|
|
|
280
329
|
def output_for_run?(output, run_id)
|
|
281
|
-
payload = YAML.
|
|
330
|
+
payload = YAML.safe_load_file(output, aliases: false) || {}
|
|
282
331
|
payload.dig('observation', 'run_id') == run_id
|
|
283
332
|
rescue SystemCallError, Psych::Exception
|
|
284
333
|
false
|
|
285
334
|
end
|
|
335
|
+
|
|
336
|
+
def confidence_level(value)
|
|
337
|
+
level = value.to_sym
|
|
338
|
+
return level if CONFIDENCE_LEVELS.key?(level)
|
|
339
|
+
|
|
340
|
+
raise OptionParser::InvalidArgument, "unknown confidence level: #{value}"
|
|
341
|
+
end
|
|
286
342
|
end
|
|
287
343
|
end
|
|
@@ -5,10 +5,27 @@ require 'date'
|
|
|
5
5
|
module Necropsy
|
|
6
6
|
module Confidence
|
|
7
7
|
class Scorer
|
|
8
|
+
RUBY_HOOKS = %w[
|
|
9
|
+
inherited included extended prepended method_added singleton_method_added
|
|
10
|
+
const_missing method_missing respond_to_missing?
|
|
11
|
+
].freeze
|
|
12
|
+
RUBY_PROTOCOLS = %w[
|
|
13
|
+
== eql? hash <=> to_s to_str to_a to_h to_proc inspect each call coerce
|
|
14
|
+
succ initialize_copy marshal_dump marshal_load
|
|
15
|
+
].freeze
|
|
16
|
+
BUILTIN_IMPLICIT_CALLERS = [
|
|
17
|
+
{
|
|
18
|
+
name_pattern: /^on_/,
|
|
19
|
+
owner_ancestors: ['RuboCop::Cop::Base'],
|
|
20
|
+
reason: 'RuboCop Commissioner dispatches on_* callbacks'
|
|
21
|
+
}
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
8
24
|
def initialize(graph:, reachability:, project:)
|
|
9
25
|
@graph = graph
|
|
10
26
|
@reachability = reachability
|
|
11
27
|
@project = project
|
|
28
|
+
@source_lines = {}
|
|
12
29
|
end
|
|
13
30
|
|
|
14
31
|
def findings
|
|
@@ -18,12 +35,13 @@ module Necropsy
|
|
|
18
35
|
classification = classification_for(node)
|
|
19
36
|
next unless classification
|
|
20
37
|
|
|
21
|
-
score, level, reasons = score_for(node, classification)
|
|
38
|
+
score, level, reasons, score_components = score_for(node, classification)
|
|
22
39
|
Finding.new(
|
|
23
40
|
node: node,
|
|
24
41
|
classification: classification,
|
|
25
42
|
confidence: level,
|
|
26
43
|
score: score,
|
|
44
|
+
score_components: score_components,
|
|
27
45
|
reasons: reasons,
|
|
28
46
|
evidences: graph.incoming_edges(node.id).flat_map(&:evidences) + graph.alive_evidences(node.id)
|
|
29
47
|
)
|
|
@@ -35,11 +53,13 @@ module Necropsy
|
|
|
35
53
|
attr_reader :graph, :reachability, :project
|
|
36
54
|
|
|
37
55
|
def classification_for(node)
|
|
38
|
-
if
|
|
39
|
-
|
|
56
|
+
return nil if graph.dynamic_alive?(node.id)
|
|
57
|
+
|
|
58
|
+
if reachability.runtime_paths.key?(node.id)
|
|
59
|
+
return :unused if graph.dynamic_enabled? && !generated_accessor?(node)
|
|
40
60
|
|
|
41
61
|
nil
|
|
42
|
-
elsif reachability.
|
|
62
|
+
elsif reachability.test_paths.key?(node.id)
|
|
43
63
|
:test_only_reachable
|
|
44
64
|
else
|
|
45
65
|
:unreachable
|
|
@@ -49,41 +69,42 @@ module Necropsy
|
|
|
49
69
|
def score_for(node, classification)
|
|
50
70
|
reasons = []
|
|
51
71
|
score = base_score(classification)
|
|
72
|
+
components = [score_component("base(#{classification})", score, 'Base classification score')]
|
|
52
73
|
|
|
53
74
|
if graph.uncertainties(node.id).any?
|
|
54
75
|
score -= 0.35
|
|
76
|
+
components << score_component('near_metaprogramming', -0.35, 'Unresolved metaprogramming nearby')
|
|
55
77
|
reasons << 'Lowered because this node is near unresolved metaprogramming.'
|
|
56
78
|
end
|
|
57
79
|
|
|
58
80
|
if graph.class_info(node.owner)&.dynamic
|
|
59
81
|
score -= 0.25
|
|
82
|
+
components << score_component('dynamic_owner', -0.25, 'Owner defines dynamic dispatch')
|
|
60
83
|
reasons << 'Lowered because the owner class defines dynamic dispatch.'
|
|
61
84
|
end
|
|
62
85
|
|
|
63
86
|
if generated_accessor?(node)
|
|
64
87
|
score -= 0.2
|
|
88
|
+
components << score_component('generated_accessor', -0.2, 'Generated by an accessor DSL')
|
|
65
89
|
reasons << 'Lowered because this node is generated by a Ruby accessor DSL.'
|
|
66
90
|
end
|
|
67
91
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
reasons << "
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
evidence_analyzers = graph.incoming_edges(node.id).flat_map(&:evidences).map(&:analyzer).uniq
|
|
75
|
-
if evidence_analyzers.length > 1
|
|
76
|
-
score += 0.08
|
|
77
|
-
reasons << 'Raised because multiple analyzers agree on incoming reachability evidence.'
|
|
92
|
+
if (reason = implicit_call_context(node))
|
|
93
|
+
score -= 0.4
|
|
94
|
+
components << score_component('implicit_caller', -0.4, reason)
|
|
95
|
+
reasons << "Lowered because #{reason}."
|
|
78
96
|
end
|
|
79
97
|
|
|
80
98
|
if classification == :unreachable && graph.dynamic_enabled? && !graph.dynamic_alive?(node.id)
|
|
81
99
|
score += 0.25
|
|
100
|
+
components << score_component('absent_from_dynamic', 0.25, 'Absent from dynamic observations')
|
|
82
101
|
reasons << 'Static unreachable and absent from dynamic observations.'
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
if classification == :unreachable && quarantine_expired?(node)
|
|
86
|
-
|
|
105
|
+
raised_score = [score, 0.95].max
|
|
106
|
+
components << score_component('expired_quarantine', raised_score - score, 'Expired without alive evidence')
|
|
107
|
+
score = raised_score
|
|
87
108
|
reasons << 'Raised because quarantine annotation has expired without alive evidence.'
|
|
88
109
|
end
|
|
89
110
|
|
|
@@ -91,20 +112,26 @@ module Necropsy
|
|
|
91
112
|
days = observation_days
|
|
92
113
|
if days && days < project.config.min_observation_days
|
|
93
114
|
score -= 0.3
|
|
115
|
+
components << score_component('short_observation', -0.3, "Observed for only #{days} days")
|
|
94
116
|
reasons << "Observation window is #{days} days, below the configured minimum."
|
|
95
117
|
else
|
|
96
118
|
reasons << 'Reachable statically but never observed dynamically.'
|
|
97
119
|
end
|
|
98
120
|
end
|
|
99
121
|
|
|
100
|
-
|
|
101
|
-
|
|
122
|
+
clamped_score = score.clamp(0.0, 1.0)
|
|
123
|
+
components << score_component('score_clamp', clamped_score - score, 'Clamped to the 0.0–1.0 range') if clamped_score != score
|
|
124
|
+
[clamped_score, level_for(clamped_score), reasons, components]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def score_component(name, value, details)
|
|
128
|
+
ScoreComponent.new(name: name, value: value.round(4), details: details)
|
|
102
129
|
end
|
|
103
130
|
|
|
104
131
|
def base_score(classification)
|
|
105
132
|
case classification
|
|
106
133
|
when :unreachable
|
|
107
|
-
|
|
134
|
+
0.62
|
|
108
135
|
when :test_only_reachable
|
|
109
136
|
0.55
|
|
110
137
|
when :unused
|
|
@@ -118,6 +145,56 @@ module Necropsy
|
|
|
118
145
|
%i[attr_reader attr_writer attr_accessor struct_new data_define].include?(node.defined_via)
|
|
119
146
|
end
|
|
120
147
|
|
|
148
|
+
def implicit_call_reason(node)
|
|
149
|
+
return "#{node.name} is a Ruby hook invoked by the VM" if RUBY_HOOKS.include?(node.name)
|
|
150
|
+
return "#{node.name} is a Ruby protocol method invoked implicitly" if RUBY_PROTOCOLS.include?(node.name)
|
|
151
|
+
|
|
152
|
+
rule = implicit_caller_rules.find do |candidate|
|
|
153
|
+
candidate[:name_pattern].match?(node.name) && owner_matches?(node.owner, candidate[:owner_ancestors])
|
|
154
|
+
end
|
|
155
|
+
return unless rule
|
|
156
|
+
|
|
157
|
+
rule[:reason] || 'the method matches a configured implicit caller'
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def implicit_call_context(node)
|
|
161
|
+
direct_reason = implicit_call_reason(node)
|
|
162
|
+
return direct_reason if direct_reason
|
|
163
|
+
|
|
164
|
+
root_id = implicit_paths[node.id]
|
|
165
|
+
"it is reachable from implicitly invoked #{root_id}" if root_id
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def implicit_paths
|
|
169
|
+
@implicit_paths ||= begin
|
|
170
|
+
roots = graph.method_nodes.reject(&:test).select { |node| implicit_call_reason(node) }.map(&:id)
|
|
171
|
+
paths = roots.to_h { |node_id| [node_id, node_id] }
|
|
172
|
+
queue = roots.dup
|
|
173
|
+
|
|
174
|
+
until queue.empty?
|
|
175
|
+
node_id = queue.shift
|
|
176
|
+
graph.edges_from(node_id).each_key do |callee_id|
|
|
177
|
+
next if paths.key?(callee_id)
|
|
178
|
+
|
|
179
|
+
paths[callee_id] = paths.fetch(node_id)
|
|
180
|
+
queue << callee_id
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
paths
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def implicit_caller_rules
|
|
188
|
+
BUILTIN_IMPLICIT_CALLERS + project.config.implicit_callers
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def owner_matches?(owner, ancestors)
|
|
192
|
+
return true if ancestors.empty?
|
|
193
|
+
return false unless owner
|
|
194
|
+
|
|
195
|
+
ancestors.any? { |ancestor| graph.owner_reachable_from_ancestor?(owner, ancestor) }
|
|
196
|
+
end
|
|
197
|
+
|
|
121
198
|
def level_for(score)
|
|
122
199
|
return :certain if score >= 0.9
|
|
123
200
|
return :high if score >= 0.7
|
|
@@ -145,7 +222,8 @@ module Necropsy
|
|
|
145
222
|
path = File.join(project.root, node.file)
|
|
146
223
|
return nil unless File.exist?(path)
|
|
147
224
|
|
|
148
|
-
|
|
225
|
+
lines = @source_lines[path] ||= File.readlines(path, chomp: true)
|
|
226
|
+
window = lines[[node.line - 4, 0].max, 4] || []
|
|
149
227
|
annotation = window.find { |line| line.include?('necropsy:quarantine') && line.match?(/\bsince=/) }
|
|
150
228
|
return nil unless annotation
|
|
151
229
|
|