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,26 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module Necropsy
4
6
  module EntryPoints
5
7
  class Rails
6
8
  ROUTE_VERBS = %w[get post put patch delete match].freeze
7
9
  RESTFUL_ACTIONS = %w[index show new create edit update destroy].freeze
8
10
  SINGULAR_ACTIONS = %w[show new create edit update destroy].freeze
11
+ IRREGULAR_PLURALS = { 'person' => 'people', 'man' => 'men', 'woman' => 'women', 'child' => 'children' }.freeze
9
12
  RouteContext = Struct.new(:modules, :resource, :controller, keyword_init: true)
10
13
 
11
14
  def apply(graph, project)
12
15
  return unless project.config.rails_enabled?
13
16
 
17
+ referenced_view_methods = view_method_names(project)
14
18
  graph.method_nodes.each do |node|
15
19
  case node.file
16
20
  when %r{\Aapp/jobs/}
17
21
  graph.add_entry_point(node.id, :job_perform) if node.name == 'perform'
18
22
  when %r{\Aapp/mailers/}
19
- graph.add_entry_point(node.id, :mailer_action) if node.kind == :instance_method
23
+ if node.kind == :instance_method && node.visibility == :public
24
+ graph.add_entry_point(node.id,
25
+ :mailer_action)
26
+ end
20
27
  when %r{\Aapp/helpers/}
21
- graph.add_entry_point(node.id, :rails_view_helper) if helper_referenced?(project, node.name)
28
+ graph.add_entry_point(node.id, :rails_view_helper) if referenced_view_methods.include?(node.name)
22
29
  when %r{\Aapp/components/}
23
30
  graph.add_entry_point(node.id, :rails_component) if component_entrypoint?(node)
31
+ when %r{\Adb/migrate/}
32
+ graph.add_entry_point(node.id, :rails_migration) if %w[change up down].include?(node.name)
33
+ end
34
+
35
+ if node.file.start_with?('app/') && !node.file.start_with?('app/helpers/', 'app/components/') &&
36
+ referenced_view_methods.include?(node.name)
37
+ graph.add_entry_point(node.id, :rails_view_reference)
24
38
  end
25
39
  end
26
40
 
@@ -50,49 +64,76 @@ module Necropsy
50
64
  return [] if seen[expanded]
51
65
 
52
66
  seen[expanded] = true
53
- parse_routes(File.readlines(expanded), root: root, seen: seen, concerns: concerns)
67
+ source = File.read(expanded)
68
+ result = Prism.parse(source)
69
+ return [] if result.failure?
70
+
71
+ parse_route_statements(
72
+ result.value.statements,
73
+ source: source,
74
+ root: root,
75
+ seen: seen,
76
+ concerns: concerns,
77
+ context: RouteContext.new(modules: [], resource: nil)
78
+ )
79
+ rescue SystemCallError, EncodingError
80
+ []
54
81
  end
55
82
 
56
- def parse_routes(lines, root:, seen:, concerns:, initial_context: RouteContext.new(modules: [], resource: nil))
57
- contexts = [initial_context]
58
- capture = nil
59
- targets = []
83
+ def parse_route_statements(statements, source:, root:, seen:, concerns:, context:)
84
+ Array(statements&.body).flat_map do |statement|
85
+ parse_route_statement(statement, source: source, root: root, seen: seen, concerns: concerns,
86
+ context: context)
87
+ end.compact.uniq
88
+ end
60
89
 
61
- lines.each do |line|
62
- stripped = strip_route_comment(line).strip
63
- next if stripped.empty?
90
+ def parse_route_statement(statement, source:, root:, seen:, concerns:, context:)
91
+ unless statement.is_a?(Prism::CallNode)
92
+ return statement.child_nodes.compact.flat_map do |child|
93
+ parse_route_statement(child, source: source, root: root, seen: seen, concerns: concerns, context: context)
94
+ end
95
+ end
64
96
 
65
- if capture
66
- capture[:depth] += block_openings(stripped)
67
- capture[:depth] -= 1 if stripped == 'end'
97
+ call_source = route_call_source(statement, source)
98
+ if statement.name == :concern && statement.block
99
+ name = literal_route_argument(statement.arguments&.arguments&.first)
100
+ concerns[name] = [statement.block.body, source] if name
101
+ return []
102
+ end
68
103
 
69
- if capture[:depth].zero?
70
- concerns[capture[:name]] = capture[:lines]
71
- capture = nil
72
- else
73
- capture[:lines] << stripped
74
- end
75
- next
76
- end
104
+ targets = route_targets(call_source, context)
105
+ targets.concat(route_file_targets(call_source, root, seen, concerns))
106
+ targets.concat(concern_targets(call_source, context, root, seen, concerns))
107
+ return targets unless statement.block
108
+
109
+ child_context = nested_route_context(call_source, context)
110
+ targets.concat(
111
+ parse_route_statements(
112
+ statement.block.body,
113
+ source: source,
114
+ root: root,
115
+ seen: seen,
116
+ concerns: concerns,
117
+ context: child_context
118
+ )
119
+ )
120
+ end
77
121
 
78
- if (match = stripped.match(/\bconcern\s+:([a-zA-Z_]\w*)\s+do\b/))
79
- capture = { name: match[1], depth: 1, lines: [] }
80
- next
81
- end
122
+ def route_call_source(node, source)
123
+ finish = node.block ? node.block.opening_loc.start_offset : node.location.end_offset
124
+ source.byteslice(node.location.start_offset...finish).gsub(/\s+/, ' ').strip
125
+ end
82
126
 
83
- if stripped == 'end'
84
- contexts.pop if contexts.length > 1
85
- next
86
- end
127
+ def literal_route_argument(node)
128
+ return unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
87
129
 
88
- context = contexts.last
89
- targets.concat(route_targets(stripped, context))
90
- targets.concat(route_file_targets(stripped, root, seen, concerns))
91
- targets.concat(concern_targets(stripped, context, root, seen, concerns))
92
- push_context(contexts, stripped, context)
93
- end
130
+ node.unescaped.to_s
131
+ end
94
132
 
95
- targets.compact.uniq
133
+ def nested_route_context(line, context)
134
+ contexts = [context]
135
+ push_context(contexts, line, context)
136
+ contexts.last
96
137
  end
97
138
 
98
139
  def route_targets(line, context)
@@ -152,21 +193,15 @@ module Necropsy
152
193
  elsif (match = line.match(/\bscope\b.*\bmodule:\s+:?["']?([a-zA-Z_]\w*)/))
153
194
  contexts << RouteContext.new(modules: context.modules + [match[1]], resource: context.resource,
154
195
  controller: scoped_controller_option(line, context.controller))
155
- elsif (match = line.match(%r{\bcontroller\s+:?["']?([a-zA-Z_][\w/]*)["']?\s+do\b}))
196
+ elsif (match = line.match(%r{\bcontroller\s+:?["']?([a-zA-Z_][\w/]*)["']?}))
156
197
  contexts << RouteContext.new(modules: context.modules, resource: context.resource, controller: match[1])
157
- elsif line.match?(/\bscope\b.*\bcontroller:\s*.*do\b/)
158
- contexts << RouteContext.new(modules: context.modules, resource: context.resource,
159
- controller: scoped_controller_option(line, context.controller))
160
- elsif (match = line.match(/\bresources\s+:([a-zA-Z_]\w*).*do\b/))
198
+ elsif (match = line.match(/\bresources\s+:([a-zA-Z_]\w*)/))
161
199
  contexts << RouteContext.new(modules: context.modules, resource: resource_controller(line, match[1]),
162
200
  controller: context.controller)
163
- elsif (match = line.match(/\bresource\s+:([a-zA-Z_]\w*).*do\b/))
201
+ elsif (match = line.match(/\bresource\s+:([a-zA-Z_]\w*)/))
164
202
  contexts << RouteContext.new(modules: context.modules,
165
203
  resource: resource_controller(line, pluralize(match[1])), controller: context.controller)
166
- elsif line.match?(/\b(?:member|collection)\s+do\b/)
167
- contexts << RouteContext.new(modules: context.modules, resource: context.resource,
168
- controller: context.controller)
169
- elsif line.match?(/\b(?:constraints|defaults|scope)\b.*do\b/)
204
+ elsif line.match?(/\b(?:member|collection|constraints|defaults|scope)\b/)
170
205
  contexts << RouteContext.new(modules: context.modules, resource: context.resource,
171
206
  controller: scoped_controller_option(line, context.controller))
172
207
  end
@@ -181,8 +216,17 @@ module Necropsy
181
216
  def concern_targets(line, context, root, seen, concerns)
182
217
  target_context = concern_context(line, context)
183
218
  concern_names(line).flat_map do |name|
184
- parse_routes(concerns.fetch(name, []), root: root, seen: seen, concerns: concerns,
185
- initial_context: target_context)
219
+ statements, source = concerns[name]
220
+ next [] unless statements && source
221
+
222
+ parse_route_statements(
223
+ statements,
224
+ source: source,
225
+ root: root,
226
+ seen: seen,
227
+ concerns: concerns,
228
+ context: target_context
229
+ )
186
230
  end
187
231
  end
188
232
 
@@ -219,7 +263,7 @@ module Necropsy
219
263
  def action_option(line, name)
220
264
  array_value = line[/\b#{name}:\s*(?:\[([^\]]+)\]|%i\[([^\]]+)\])/, 1] ||
221
265
  line[/\b#{name}:\s*(?:\[([^\]]+)\]|%i\[([^\]]+)\])/, 2]
222
- return array_value.scan(/:?["']?([a-zA-Z_]\w*)["']?/) if array_value
266
+ return array_value.scan(/:?["']?([a-zA-Z_]\w*)["']?/).flatten if array_value
223
267
 
224
268
  Array(line[/\b#{name}:\s+:?["']?([a-zA-Z_]\w*)/, 1])
225
269
  end
@@ -257,8 +301,9 @@ module Necropsy
257
301
  end
258
302
 
259
303
  def pluralize(name)
304
+ return IRREGULAR_PLURALS.fetch(name) if IRREGULAR_PLURALS.key?(name)
260
305
  return "#{name}es" if name.end_with?('s', 'x', 'z', 'ch', 'sh')
261
- return "#{name.delete_suffix('y')}ies" if name.end_with?('y')
306
+ return "#{name.delete_suffix('y')}ies" if name.match?(/[^aeiou]y\z/)
262
307
 
263
308
  "#{name}s"
264
309
  end
@@ -269,17 +314,20 @@ module Necropsy
269
314
 
270
315
  def matching_route_nodes(graph, node_id)
271
316
  return [node_id] if graph.nodes.key?(node_id)
317
+ return [] unless node_id.include?('Controller#')
318
+
319
+ controller, action = node_id.split('#', 2)
320
+ expected_file = "app/controllers/#{underscore(controller.delete_suffix('Controller'))}_controller.rb"
321
+ graph.method_nodes.filter_map do |node|
322
+ next unless node.name == action && node.owner&.end_with?(controller)
323
+ next unless node.file == expected_file
272
324
 
273
- suffix = "::#{node_id}"
274
- graph.nodes.keys.select { |candidate| candidate.end_with?(suffix) }
325
+ node.id
326
+ end
275
327
  end
276
328
 
277
329
  def helper_referenced?(project, method_name)
278
- view_files(project).any? do |path|
279
- view_source(path).match?(/\b#{Regexp.escape(method_name)}\b/)
280
- end
281
- rescue SystemCallError, EncodingError
282
- false
330
+ view_method_names(project).include?(method_name)
283
331
  end
284
332
 
285
333
  def view_files(project)
@@ -293,6 +341,17 @@ module Necropsy
293
341
  .lines.reject { |line| line.strip.start_with?('-#') }.join
294
342
  end
295
343
 
344
+ def view_method_names(project)
345
+ @view_method_names ||= {}
346
+ @view_method_names[project.root] ||= view_files(project).each_with_object(Set.new) do |path, names|
347
+ view_source(path).scan(/(?<![A-Za-z0-9_])([a-zA-Z_]\w*[!?=]?)(?![A-Za-z0-9_])/).each do |match|
348
+ names << match.first
349
+ end
350
+ rescue SystemCallError, EncodingError
351
+ next
352
+ end
353
+ end
354
+
296
355
  def component_entrypoint?(node)
297
356
  node.kind == :instance_method && %w[call render? before_render].include?(node.name)
298
357
  end
@@ -301,34 +360,11 @@ module Necropsy
301
360
  path.split('/').map { |part| part.split('_').map(&:capitalize).join }.join('::')
302
361
  end
303
362
 
304
- def block_openings(line)
305
- line.scan(/\bdo\b/).length
306
- end
307
-
308
- def strip_route_comment(line)
309
- quote = nil
310
- escaped = false
311
- line.each_char.with_index do |char, index|
312
- if escaped
313
- escaped = false
314
- next
315
- end
316
-
317
- if char == '\\'
318
- escaped = true
319
- next
320
- end
321
-
322
- if quote
323
- quote = nil if char == quote
324
- next
325
- end
326
-
327
- quote = char if ["'", '"'].include?(char)
328
- return line[0...index] if char == '#'
329
- end
330
-
331
- line
363
+ def underscore(constant)
364
+ constant.gsub('::', '/')
365
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
366
+ .gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
367
+ .downcase
332
368
  end
333
369
  end
334
370
  end
@@ -5,24 +5,36 @@ module Necropsy
5
5
  attr_reader :nodes, :call_sites, :instantiated_classes, :entry_points, :profiles, :observation, :class_infos,
6
6
  :entrypoint_hints
7
7
 
8
- def initialize(scan_result)
8
+ def initialize(scan_result, ambiguity_limit: 4)
9
9
  @nodes = {}
10
- @edges = Hash.new { |hash, key| hash[key] = {} }
10
+ @edges = {}
11
+ @incoming_edges = {}
11
12
  @call_sites = scan_result.call_sites
12
13
  @instantiated_classes = scan_result.instantiated_classes.dup
13
14
  @class_infos = scan_result.class_infos.to_h { |info| [info.id, info] }
14
15
  @entrypoint_hints = scan_result.entrypoint_hints
16
+ @ambiguity_limit = ambiguity_limit
15
17
  @entry_points = []
16
18
  @profiles = []
17
- @uncertainties = scan_result.uncertainties
19
+ @uncertainties = scan_result.uncertainties.to_h do |node_id, messages|
20
+ [node_id, Array(messages).dup]
21
+ end
18
22
  @dynamic_alive = {}
19
23
  @observation = {}
24
+ @descendants = {}
20
25
  scan_result.nodes.each { |node| add_node(node) }
21
26
  retain_known_instantiated_classes
22
27
  end
23
28
 
24
29
  def add_node(node)
25
- nodes[node.id] ||= node
30
+ return nodes[node.id] if nodes.key?(node.id)
31
+
32
+ @method_nodes = nil
33
+ @nodes_by_name = nil
34
+ @dispatch_cache = nil
35
+ @lookup_chain_cache = nil
36
+ @owner_ancestor_cache = nil
37
+ nodes[node.id] = node
26
38
  end
27
39
 
28
40
  def add_entry_point(node_id, reason)
@@ -34,8 +46,12 @@ module Necropsy
34
46
 
35
47
  def apply_result(result)
36
48
  result.edge_evidences.each { |edge| add_edge(edge.caller_id, edge.callee_id, edge.evidence) }
37
- result.alive_evidences.each { |alive| add_alive(alive.node_id, alive.evidence) }
38
- result.uncertainties.each { |node_id, messages| uncertainties[node_id].concat(Array(messages)) }
49
+ matched_alive = result.alive_evidences.count { |alive| add_alive(alive.node_id, alive.evidence) }
50
+ warn_unmatched_dynamic_evidence(result.alive_evidences.length) if matched_alive.zero?
51
+ result.uncertainties.each do |node_id, messages|
52
+ @uncertainties[node_id] ||= []
53
+ @uncertainties[node_id].concat(Array(messages))
54
+ end
39
55
  observation.merge!(result.observation) { |_key, left, right| merge_observation(left, right) }
40
56
  end
41
57
 
@@ -46,15 +62,19 @@ module Necropsy
46
62
  def add_edge(caller_id, callee_id, evidence)
47
63
  return unless nodes.key?(caller_id) && nodes.key?(callee_id)
48
64
 
65
+ @edges[caller_id] ||= {}
49
66
  @edges[caller_id][callee_id] ||= []
50
67
  @edges[caller_id][callee_id] << evidence
68
+ @incoming_edges[callee_id] ||= {}
69
+ @incoming_edges[callee_id][caller_id] = @edges[caller_id][callee_id]
51
70
  end
52
71
 
53
72
  def add_alive(node_id, evidence)
54
- return unless nodes.key?(node_id)
73
+ return false unless nodes.key?(node_id)
55
74
 
56
75
  @dynamic_alive[node_id] ||= []
57
76
  @dynamic_alive[node_id] << evidence
77
+ true
58
78
  end
59
79
 
60
80
  def dynamic_alive?(node_id)
@@ -66,11 +86,11 @@ module Necropsy
66
86
  end
67
87
 
68
88
  def dynamic_enabled?
69
- @dynamic_alive.any? || observation.any?
89
+ @dynamic_alive.any?
70
90
  end
71
91
 
72
92
  def edges_from(node_id)
73
- @edges[node_id] || {}
93
+ @edges.fetch(node_id, {})
74
94
  end
75
95
 
76
96
  def edges
@@ -82,17 +102,23 @@ module Necropsy
82
102
  end
83
103
 
84
104
  def incoming_edges(node_id)
85
- edges.select { |edge| edge.callee_id == node_id }
105
+ @incoming_edges.fetch(node_id, {}).map do |caller_id, evidences|
106
+ Edge.new(caller_id: caller_id, callee_id: node_id, evidences: evidences)
107
+ end
86
108
  end
87
109
 
88
110
  def uncertainties(node_id = nil)
89
111
  return @uncertainties unless node_id
90
112
 
91
- @uncertainties[node_id] || []
113
+ @uncertainties.fetch(node_id, [])
92
114
  end
93
115
 
94
116
  def method_nodes
95
- nodes.values.select(&:method?)
117
+ @method_nodes ||= nodes.values.select(&:method?)
118
+ end
119
+
120
+ def nodes_by_name
121
+ @nodes_by_name ||= method_nodes.group_by(&:name).freeze
96
122
  end
97
123
 
98
124
  def class_info(owner)
@@ -100,7 +126,9 @@ module Necropsy
100
126
  end
101
127
 
102
128
  def descendants_of(owner)
103
- class_infos.keys.select { |candidate| candidate == owner || ancestor_chain(candidate).include?(owner) }
129
+ @descendants[owner] ||= class_infos.keys.select do |candidate|
130
+ candidate == owner || ancestor_chain(candidate).include?(owner)
131
+ end
104
132
  end
105
133
 
106
134
  def modules_for(owner)
@@ -111,15 +139,83 @@ module Necropsy
111
139
  end
112
140
 
113
141
  def candidate_nodes(message)
114
- method_nodes.select { |node| node.name == message }
142
+ nodes_by_name.fetch(message, [])
143
+ end
144
+
145
+ def ambiguous_fallback_candidates(message)
146
+ candidates = candidate_nodes(message)
147
+ return candidates if candidates.one?
148
+ return [] if candidates.size > @ambiguity_limit
149
+
150
+ candidates
151
+ end
152
+
153
+ def ambiguous_resolution?
154
+ @ambiguity_limit > 1
155
+ end
156
+
157
+ def owner_reachable_from_ancestor?(owner, ancestor)
158
+ @owner_ancestor_cache ||= {}
159
+ key = [owner, ancestor]
160
+ return @owner_ancestor_cache[key] if @owner_ancestor_cache.key?(key)
161
+
162
+ @owner_ancestor_cache[key] = descendants_of(ancestor).any? do |descendant|
163
+ cached_lookup_chain(descendant).include?(owner)
164
+ end
115
165
  end
116
166
 
117
167
  def resolve_call_site(site, rta: false)
118
- candidates = candidates_for_receiver(site)
168
+ candidates = rta ? rta_candidates_for_receiver(site) : candidates_for_receiver(site)
119
169
  candidates = candidates.select { |node| rta_candidate?(node, site) } if rta
120
170
  candidates
121
171
  end
122
172
 
173
+ def retain_rta_candidates(candidates, site)
174
+ candidates.select { |node| rta_candidate?(node, site) }
175
+ end
176
+
177
+ def reconcile_rta_result(result)
178
+ analyzed_sites = result.observation.dig('rta', 'analyzed_sites')
179
+ return unless analyzed_sites
180
+
181
+ analyzed_keys = analyzed_sites.to_set { |site| call_site_key(site) }
182
+ allowed = result.edge_evidences.each_with_object(Hash.new { |hash, key| hash[key] = Set.new }) do |edge, memo|
183
+ memo[call_site_key(edge.evidence.metadata)] << edge.callee_id
184
+ end
185
+
186
+ @edges.each_value do |callees|
187
+ callees.each do |callee_id, evidences|
188
+ evidences.reject! do |item|
189
+ next false unless %i[name_resolution cha].include?(item.analyzer)
190
+
191
+ key = call_site_key(item.metadata)
192
+ analyzed_keys.include?(key) && !allowed[key].include?(callee_id)
193
+ end
194
+ end
195
+ callees.delete_if { |_callee_id, evidences| evidences.empty? }
196
+ end
197
+ @edges.delete_if { |_caller_id, callees| callees.empty? }
198
+ rebuild_incoming_edges
199
+ end
200
+
201
+ def fallback_resolution?(site, resolved: nil)
202
+ resolved ||= resolve_call_site(site)
203
+ return false if resolved.empty?
204
+
205
+ case site.receiver_kind
206
+ when :constant
207
+ receiver_candidates(site).none? { |name| nodes.key?("#{name}.#{site.message}") }
208
+ when :instance
209
+ receiver_candidates(site).none? { |name| nodes.key?("#{name}##{site.message}") }
210
+ when :implicit
211
+ same_owner_candidates(site).empty?
212
+ when :unknown
213
+ true
214
+ else
215
+ false
216
+ end
217
+ end
218
+
123
219
  def to_h
124
220
  {
125
221
  'nodes' => nodes.values.map(&:to_h),
@@ -138,19 +234,30 @@ module Necropsy
138
234
  case site.receiver_kind
139
235
  when :constant
140
236
  exact = receiver_candidates(site).filter_map { |name| nodes["#{name}.#{site.message}"] }.first
141
- exact ? [exact] : candidate_nodes(site.message)
237
+ exact ? [exact] : ambiguous_fallback_candidates(site.message)
142
238
  when :instance
143
239
  exact = receiver_candidates(site).filter_map { |name| nodes["#{name}##{site.message}"] }.first
144
- exact ? [exact] : candidate_nodes(site.message)
240
+ exact ? [exact] : ambiguous_fallback_candidates(site.message)
145
241
  when :self
146
242
  same_owner_candidates(site)
243
+ when :super
244
+ super_candidates(site)
147
245
  when :implicit
148
- same_owner_candidates(site).then { |matches| matches.empty? ? candidate_nodes(site.message) : matches }
246
+ same_owner_candidates(site).then do |matches|
247
+ matches.empty? ? ambiguous_fallback_candidates(site.message) : matches
248
+ end
149
249
  else
150
- candidate_nodes(site.message)
250
+ ambiguous_fallback_candidates(site.message)
151
251
  end
152
252
  end
153
253
 
254
+ def rta_candidates_for_receiver(site)
255
+ exact = candidates_for_receiver(site)
256
+ return exact unless exact.empty?
257
+
258
+ candidate_nodes(site.message)
259
+ end
260
+
154
261
  def same_owner_candidates(site)
155
262
  caller = nodes[site.caller_id]
156
263
  return [] unless caller&.owner
@@ -162,6 +269,21 @@ module Necropsy
162
269
  ids.filter_map { |id| nodes[id] }
163
270
  end
164
271
 
272
+ def super_candidates(site)
273
+ caller = nodes[site.caller_id]
274
+ return [] unless caller&.owner
275
+
276
+ separator = caller.kind == :singleton_method ? '.' : '#'
277
+ owner = class_info(caller.owner)&.superclass
278
+ while owner
279
+ candidate = nodes["#{owner}#{separator}#{site.message}"]
280
+ return [candidate] if candidate
281
+
282
+ owner = class_info(owner)&.superclass
283
+ end
284
+ []
285
+ end
286
+
165
287
  def receiver_candidates(site)
166
288
  candidates = site.metadata['receiver_candidates'] || site.metadata[:receiver_candidates]
167
289
  Array(candidates).compact.empty? ? [site.receiver_name].compact : Array(candidates).compact
@@ -169,10 +291,39 @@ module Necropsy
169
291
 
170
292
  def rta_candidate?(node, site)
171
293
  return true unless node.kind == :instance_method
172
- return true if node.owner == nodes[site.caller_id]&.owner
294
+
295
+ caller_owner = nodes[site.caller_id]&.owner
296
+ return true if site.receiver_kind == :super
297
+ return dispatched_instance_owner(caller_owner, site.message) == node.owner if %i[self implicit].include?(site.receiver_kind)
298
+
299
+ return true if node.owner == caller_owner
173
300
  return true if class_info(node.owner)&.dynamic
174
301
 
175
- instantiated_classes.include?(node.owner)
302
+ instantiated_classes.any? { |owner| dispatched_instance_owner(owner, site.message) == node.owner }
303
+ end
304
+
305
+ def dispatched_instance_owner(owner, message)
306
+ @dispatch_cache ||= {}
307
+ key = [owner, message]
308
+ return @dispatch_cache[key] if @dispatch_cache.key?(key)
309
+
310
+ @dispatch_cache[key] = cached_lookup_chain(owner).find { |candidate| nodes.key?("#{candidate}##{message}") }
311
+ end
312
+
313
+ def cached_lookup_chain(owner)
314
+ @lookup_chain_cache ||= {}
315
+ @lookup_chain_cache[owner] ||= method_lookup_chain(owner)
316
+ end
317
+
318
+ def method_lookup_chain(owner, seen = Set.new)
319
+ return [] unless owner && seen.add?(owner)
320
+
321
+ info = class_info(owner)
322
+ return [owner] unless info
323
+
324
+ prepends = info.prepends.reverse.flat_map { |name| method_lookup_chain(name, seen) }
325
+ includes = info.includes.reverse.flat_map { |name| method_lookup_chain(name, seen) }
326
+ prepends + [owner] + includes + method_lookup_chain(info.superclass, seen)
176
327
  end
177
328
 
178
329
  def ancestor_chain(owner)
@@ -191,9 +342,40 @@ module Necropsy
191
342
  left.merge(right)
192
343
  end
193
344
 
345
+ def call_site_key(site)
346
+ metadata = site['metadata'] || site[:metadata] || {}
347
+ [
348
+ site['caller_id'] || site[:caller_id],
349
+ site['message'] || site[:message],
350
+ (site['receiver_kind'] || site[:receiver_kind])&.to_s,
351
+ site['receiver_name'] || site[:receiver_name],
352
+ site['file'] || site[:file],
353
+ site['line'] || site[:line],
354
+ Array(metadata['receiver_candidates'] || metadata[:receiver_candidates]).sort,
355
+ metadata['implicit_from'] || metadata[:implicit_from]
356
+ ]
357
+ end
358
+
359
+ def rebuild_incoming_edges
360
+ @incoming_edges = {}
361
+ @edges.each do |caller_id, callees|
362
+ callees.each do |callee_id, evidences|
363
+ @incoming_edges[callee_id] ||= {}
364
+ @incoming_edges[callee_id][caller_id] = evidences
365
+ end
366
+ end
367
+ end
368
+
194
369
  def retain_known_instantiated_classes
195
370
  known_owners = method_nodes.map(&:owner).compact.to_set
196
371
  instantiated_classes.select! { |name| known_owners.include?(name) }
197
372
  end
373
+
374
+ def warn_unmatched_dynamic_evidence(attempted)
375
+ return if attempted.zero?
376
+
377
+ warn "Necropsy ignored #{attempted} dynamic node IDs because none matched the scanned project; " \
378
+ 'dynamic absence will not be used for unused classification.'
379
+ end
198
380
  end
199
381
  end