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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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/]*)["']
|
|
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
|
|
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*)
|
|
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)\
|
|
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
|
-
|
|
185
|
-
|
|
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.
|
|
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
|
-
|
|
274
|
-
|
|
325
|
+
node.id
|
|
326
|
+
end
|
|
275
327
|
end
|
|
276
328
|
|
|
277
329
|
def helper_referenced?(project, method_name)
|
|
278
|
-
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 =
|
|
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]
|
|
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.
|
|
38
|
-
result.
|
|
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?
|
|
89
|
+
@dynamic_alive.any?
|
|
70
90
|
end
|
|
71
91
|
|
|
72
92
|
def edges_from(node_id)
|
|
73
|
-
@edges
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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] :
|
|
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] :
|
|
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
|
|
246
|
+
same_owner_candidates(site).then do |matches|
|
|
247
|
+
matches.empty? ? ambiguous_fallback_candidates(site.message) : matches
|
|
248
|
+
end
|
|
149
249
|
else
|
|
150
|
-
|
|
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
|
-
|
|
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.
|
|
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
|