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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +18 -0
  4. data/MEASUREMENTS.md +85 -0
  5. data/README.md +60 -8
  6. data/Rakefile +3 -1
  7. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +12 -2
  8. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +5 -1
  9. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +68 -14
  10. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +107 -12
  11. data/lib/necropsy/analyzers/static/cha.rb +10 -6
  12. data/lib/necropsy/analyzers/static/name_resolution.rb +29 -20
  13. data/lib/necropsy/analyzers/static/rta.rb +19 -3
  14. data/lib/necropsy/ast_scanner/call_recording.rb +156 -0
  15. data/lib/necropsy/ast_scanner/dsl_macros.rb +142 -0
  16. data/lib/necropsy/ast_scanner/method_definitions.rb +176 -0
  17. data/lib/necropsy/ast_scanner/references.rb +81 -0
  18. data/lib/necropsy/ast_scanner/ruby_semantics.rb +177 -0
  19. data/lib/necropsy/ast_scanner/traversal.rb +184 -0
  20. data/lib/necropsy/ast_scanner/value_definitions.rb +68 -0
  21. data/lib/necropsy/ast_scanner.rb +24 -560
  22. data/lib/necropsy/bench/evaluator.rb +29 -10
  23. data/lib/necropsy/cache/scan_cache.rb +20 -12
  24. data/lib/necropsy/cli.rb +88 -32
  25. data/lib/necropsy/confidence/scorer.rb +97 -19
  26. data/lib/necropsy/configuration.rb +125 -7
  27. data/lib/necropsy/diagnostics.rb +202 -0
  28. data/lib/necropsy/entry_points/plain.rb +19 -2
  29. data/lib/necropsy/entry_points/rails.rb +121 -85
  30. data/lib/necropsy/graph/call_graph.rb +203 -21
  31. data/lib/necropsy/guardrail/baseline.rb +14 -5
  32. data/lib/necropsy/guardrail/diff.rb +5 -2
  33. data/lib/necropsy/guardrail/quarantine.rb +9 -3
  34. data/lib/necropsy/models.rb +19 -11
  35. data/lib/necropsy/project.rb +49 -3
  36. data/lib/necropsy/reachability/engine.rb +31 -7
  37. data/lib/necropsy/report.rb +44 -14
  38. data/lib/necropsy/reporter.rb +11 -5
  39. data/lib/necropsy/runner.rb +33 -4
  40. data/lib/necropsy/trace_point_runtime.rb +19 -0
  41. data/lib/necropsy/version.rb +1 -1
  42. data/lib/necropsy.rb +4 -0
  43. data/script/measure.rb +20 -0
  44. metadata +22 -2
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
- require 'yaml'
4
+ require 'json'
5
5
 
6
6
  module Necropsy
7
7
  module Cache
8
8
  class ScanCache
9
- VERSION = 3
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, Psych::Exception
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
- YAML.load_file(path)
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
- 'version' => VERSION,
54
- 'metadata' => metadata,
55
- 'scan_result' => serialize_scan_result(result)
56
- }.to_yaml)
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(format: options[:format], min_confidence: options[:min_confidence])
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: :low,
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: 'tmp/necropsy_trace_point.yml',
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', 'human, json, yaml, sarif, or github') { |value| options[:format] = value.to_sym }
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.to_sym
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') { |value| options[:fail_on] = value.to_sym }
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
- puts parser
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
- if options[:ratchet] && findings.length > baseline.fingerprints.length
131
- puts "Ratchet failed: #{findings.length} findings exceed baseline count #{baseline.fingerprints.length}"
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
- 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
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
- 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
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].sub('trace_point', 'coverage'), options[:root])
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.load_file(output) || {}
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 reachability.runtime_alive.include?(node.id)
39
- return :unused if graph.dynamic_enabled? && !graph.dynamic_alive?(node.id)
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.test_alive.include?(node.id)
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
- static_count = graph.profiles.count { |profile| profile.kind == :static }
69
- if static_count > 1
70
- score += [static_count - 1, 3].min * 0.04
71
- reasons << "Raised because #{static_count} static analyzers participated."
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
- score = [score, 0.95].max
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
- score = [[score, 0.0].max, 1.0].min
101
- [score, level_for(score), reasons]
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
- graph.profiles.any? { |profile| profile.name == :rta } ? 0.78 : 0.62
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
- window = File.readlines(path, chomp: true)[[node.line - 4, 0].max, 4] || []
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