necropsy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/exe/necropsy +6 -0
- data/lib/necropsy/analyzer.rb +19 -0
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
- data/lib/necropsy/analyzers/static/cha.rb +106 -0
- data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
- data/lib/necropsy/analyzers/static/rta.rb +79 -0
- data/lib/necropsy/ast_scanner.rb +616 -0
- data/lib/necropsy/bench/evaluator.rb +127 -0
- data/lib/necropsy/cache/scan_cache.rb +158 -0
- data/lib/necropsy/cli.rb +287 -0
- data/lib/necropsy/confidence/scorer.rb +158 -0
- data/lib/necropsy/configuration.rb +116 -0
- data/lib/necropsy/coverage_runtime.rb +13 -0
- data/lib/necropsy/entry_points/plain.rb +36 -0
- data/lib/necropsy/entry_points/rails.rb +335 -0
- data/lib/necropsy/entry_points/test.rb +13 -0
- data/lib/necropsy/graph/call_graph.rb +199 -0
- data/lib/necropsy/guardrail/baseline.rb +47 -0
- data/lib/necropsy/guardrail/diff.rb +16 -0
- data/lib/necropsy/guardrail/quarantine.rb +46 -0
- data/lib/necropsy/models.rb +164 -0
- data/lib/necropsy/project.rb +65 -0
- data/lib/necropsy/reachability/engine.rb +42 -0
- data/lib/necropsy/report.rb +50 -0
- data/lib/necropsy/reporter.rb +112 -0
- data/lib/necropsy/runner.rb +74 -0
- data/lib/necropsy/version.rb +5 -0
- data/lib/necropsy.rb +46 -0
- metadata +95 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
module Analyzers
|
|
5
|
+
module Static
|
|
6
|
+
class RTA < Analyzer
|
|
7
|
+
ENUMERABLE_MESSAGES = %w[
|
|
8
|
+
all? any? chunk collect count cycle detect drop drop_while each_cons each_entry
|
|
9
|
+
each_slice each_with_index each_with_object entries filter find find_all flat_map
|
|
10
|
+
grep group_by include? inject map max min none? one? partition reduce reject
|
|
11
|
+
reverse_each select sort sort_by take take_while to_a to_h
|
|
12
|
+
].freeze
|
|
13
|
+
COMPARISON_MESSAGES = %w[< <= > >= between? clamp sort sort_by max min].freeze
|
|
14
|
+
|
|
15
|
+
def analyze(graph, _project)
|
|
16
|
+
edge_evidences = expanded_call_sites(graph).flat_map do |site|
|
|
17
|
+
graph.resolve_call_site(site, rta: true).map do |candidate|
|
|
18
|
+
EdgeEvidence.new(
|
|
19
|
+
caller_id: site.caller_id,
|
|
20
|
+
callee_id: candidate.id,
|
|
21
|
+
evidence: evidence(
|
|
22
|
+
kind: :call_edge,
|
|
23
|
+
details: "RTA candidate at #{site.file}:#{site.line}",
|
|
24
|
+
metadata: site.to_h.merge('instantiated_classes' => graph.instantiated_classes.to_a.sort)
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
AnalyzerResult.new(
|
|
31
|
+
edge_evidences: edge_evidences,
|
|
32
|
+
alive_evidences: [],
|
|
33
|
+
uncertainties: {},
|
|
34
|
+
observation: {}
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def profile
|
|
39
|
+
AnalyzerProfile.new(
|
|
40
|
+
name: :rta,
|
|
41
|
+
kind: :static,
|
|
42
|
+
soundness: :partial,
|
|
43
|
+
description: 'Narrows instance dispatch to classes that are constructed in the scanned program.'
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def expanded_call_sites(graph)
|
|
48
|
+
graph.call_sites.flat_map do |site|
|
|
49
|
+
[site, *implicit_sites(site)]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def implicit_sites(site)
|
|
54
|
+
implicit_messages(site.message).map do |message|
|
|
55
|
+
CallSite.new(
|
|
56
|
+
caller_id: site.caller_id,
|
|
57
|
+
message: message,
|
|
58
|
+
receiver_kind: site.receiver_kind,
|
|
59
|
+
receiver_name: site.receiver_name,
|
|
60
|
+
file: site.file,
|
|
61
|
+
line: site.line,
|
|
62
|
+
test: site.test,
|
|
63
|
+
dynamic: site.dynamic,
|
|
64
|
+
metadata: site.metadata.merge('implicit_from' => site.message)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def implicit_messages(message)
|
|
70
|
+
messages = []
|
|
71
|
+
messages << 'each' if ENUMERABLE_MESSAGES.include?(message) && message != 'each'
|
|
72
|
+
messages << '<=>' if COMPARISON_MESSAGES.include?(message)
|
|
73
|
+
messages << 'to_s' if %w[puts print warn].include?(message)
|
|
74
|
+
messages
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
ScanResult = Data.define(:nodes, :call_sites, :instantiated_classes, :uncertainties, :class_infos, :entrypoint_hints)
|
|
7
|
+
|
|
8
|
+
class AstScanner
|
|
9
|
+
ATTR_MACROS = %i[attr_reader attr_writer attr_accessor].freeze
|
|
10
|
+
DYNAMIC_SENDS = %i[send public_send __send__].freeze
|
|
11
|
+
MODULE_RELATION_MACROS = %i[include prepend extend].freeze
|
|
12
|
+
RAILS_CALLBACK_MACROS = %i[
|
|
13
|
+
before_action after_action around_action
|
|
14
|
+
before_save after_save before_create after_create before_update after_update
|
|
15
|
+
before_destroy after_destroy around_save validate
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
Context = Struct.new(
|
|
19
|
+
:namespace,
|
|
20
|
+
:owner,
|
|
21
|
+
:current_caller_id,
|
|
22
|
+
:current_kind,
|
|
23
|
+
:root_id,
|
|
24
|
+
:file,
|
|
25
|
+
:relative_file,
|
|
26
|
+
:test,
|
|
27
|
+
:singleton_scope,
|
|
28
|
+
keyword_init: true
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def initialize(project:, files:)
|
|
32
|
+
@project = project
|
|
33
|
+
@files = files
|
|
34
|
+
@nodes = []
|
|
35
|
+
@call_sites = []
|
|
36
|
+
@instantiated_classes = Set.new
|
|
37
|
+
@uncertainties = Hash.new { |hash, key| hash[key] = [] }
|
|
38
|
+
@class_data = {}
|
|
39
|
+
@entrypoint_hints = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def scan
|
|
43
|
+
files.each { |file| scan_file(file) }
|
|
44
|
+
ScanResult.new(
|
|
45
|
+
nodes: nodes.uniq(&:id),
|
|
46
|
+
call_sites: call_sites,
|
|
47
|
+
instantiated_classes: instantiated_classes,
|
|
48
|
+
uncertainties: uncertainties,
|
|
49
|
+
class_infos: class_infos,
|
|
50
|
+
entrypoint_hints: entrypoint_hints.uniq
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :project, :files, :nodes, :call_sites, :instantiated_classes, :uncertainties, :class_data,
|
|
57
|
+
:entrypoint_hints
|
|
58
|
+
|
|
59
|
+
def scan_file(file)
|
|
60
|
+
relative = project.relative_path(file)
|
|
61
|
+
root_id = "file:#{relative}"
|
|
62
|
+
test = project.test_file?(file)
|
|
63
|
+
nodes << Node.new(
|
|
64
|
+
id: root_id,
|
|
65
|
+
kind: :block_entry,
|
|
66
|
+
file: relative,
|
|
67
|
+
line: 1,
|
|
68
|
+
end_line: 1,
|
|
69
|
+
defined_via: :file,
|
|
70
|
+
owner: nil,
|
|
71
|
+
name: relative,
|
|
72
|
+
test: test
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
result = Prism.parse(File.read(file))
|
|
76
|
+
record_parse_errors(root_id, result) if result.failure?
|
|
77
|
+
|
|
78
|
+
visit(
|
|
79
|
+
result.value,
|
|
80
|
+
Context.new(
|
|
81
|
+
namespace: nil,
|
|
82
|
+
owner: nil,
|
|
83
|
+
current_caller_id: root_id,
|
|
84
|
+
current_kind: :block_entry,
|
|
85
|
+
root_id: root_id,
|
|
86
|
+
file: file,
|
|
87
|
+
relative_file: relative,
|
|
88
|
+
test: test,
|
|
89
|
+
singleton_scope: false
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
rescue SystemCallError, EncodingError => e
|
|
93
|
+
uncertainties[root_id] << "Could not parse #{relative}: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def visit(node, context)
|
|
97
|
+
return unless node.respond_to?(:child_nodes)
|
|
98
|
+
|
|
99
|
+
case node
|
|
100
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
101
|
+
visit_namespace(node, context)
|
|
102
|
+
when Prism::DefNode
|
|
103
|
+
visit_def(node, context)
|
|
104
|
+
when Prism::CallNode
|
|
105
|
+
visit_call(node, context)
|
|
106
|
+
when Prism::AliasMethodNode
|
|
107
|
+
visit_alias_method_node(node, context)
|
|
108
|
+
when Prism::SingletonClassNode
|
|
109
|
+
visit_singleton_class(node, context)
|
|
110
|
+
when Prism::ConstantWriteNode
|
|
111
|
+
visit_constant_write(node, context)
|
|
112
|
+
else
|
|
113
|
+
node.child_nodes.compact.each { |child| visit(child, context) }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def visit_namespace(node, context)
|
|
118
|
+
namespace = qualify_constant(constant_name(node.constant_path), context.namespace)
|
|
119
|
+
record_class_info(node, namespace, context)
|
|
120
|
+
child_context = context.dup
|
|
121
|
+
child_context.namespace = namespace
|
|
122
|
+
child_context.owner = namespace
|
|
123
|
+
child_context.singleton_scope = false
|
|
124
|
+
visit(node.body, child_context)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def visit_def(node, context)
|
|
128
|
+
owner = definition_owner(node, context)
|
|
129
|
+
return visit_children(node, context) unless owner
|
|
130
|
+
|
|
131
|
+
kind = node.receiver || context.singleton_scope ? :singleton_method : :instance_method
|
|
132
|
+
separator = kind == :singleton_method ? '.' : '#'
|
|
133
|
+
id = "#{owner}#{separator}#{node.name}"
|
|
134
|
+
nodes << Node.new(
|
|
135
|
+
id: id,
|
|
136
|
+
kind: kind,
|
|
137
|
+
file: context.relative_file,
|
|
138
|
+
line: node.location.start_line,
|
|
139
|
+
end_line: node.location.end_line,
|
|
140
|
+
defined_via: :def,
|
|
141
|
+
owner: owner,
|
|
142
|
+
name: node.name.to_s,
|
|
143
|
+
test: context.test
|
|
144
|
+
)
|
|
145
|
+
if node.name == :method_missing
|
|
146
|
+
uncertainties[id] << "#{owner} defines method_missing"
|
|
147
|
+
class_record(owner)[:dynamic] = true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
method_context = context.dup
|
|
151
|
+
method_context.owner = owner
|
|
152
|
+
method_context.current_caller_id = id
|
|
153
|
+
method_context.current_kind = kind
|
|
154
|
+
method_context.singleton_scope = false
|
|
155
|
+
visit(node.body, method_context)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def visit_call(node, context)
|
|
159
|
+
return if handle_define_method(node, context)
|
|
160
|
+
return if handle_attr_macro(node, context)
|
|
161
|
+
return if handle_delegate(node, context)
|
|
162
|
+
return if handle_alias_method(node, context)
|
|
163
|
+
|
|
164
|
+
handle_module_relation(node, context)
|
|
165
|
+
handle_rails_callback(node, context)
|
|
166
|
+
|
|
167
|
+
record_instantiation(node, context)
|
|
168
|
+
site = build_call_site(node, context)
|
|
169
|
+
call_sites << site if site
|
|
170
|
+
if site&.dynamic
|
|
171
|
+
record_uncertainty(site)
|
|
172
|
+
elsif unresolved_dynamic_dispatch?(node)
|
|
173
|
+
record_uncertainty_at(node, context)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
visit_children(node, context)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def visit_singleton_class(node, context)
|
|
180
|
+
return visit_children(node, context) unless node.expression.is_a?(Prism::SelfNode) && context.owner
|
|
181
|
+
|
|
182
|
+
singleton_context = context.dup
|
|
183
|
+
singleton_context.singleton_scope = true
|
|
184
|
+
visit(node.body, singleton_context)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def visit_constant_write(node, context)
|
|
188
|
+
return visit_children(node, context) unless struct_or_data_definition?(node.value)
|
|
189
|
+
|
|
190
|
+
owner = qualify_constant(node.name.to_s, context.namespace)
|
|
191
|
+
data = class_record(owner)
|
|
192
|
+
data[:kind] = :class
|
|
193
|
+
data[:file] = context.relative_file
|
|
194
|
+
data[:line] = node.location.start_line
|
|
195
|
+
instantiated_classes << owner
|
|
196
|
+
|
|
197
|
+
symbol_arguments(node.value).each do |name|
|
|
198
|
+
nodes << Node.new(
|
|
199
|
+
id: "#{owner}##{name}",
|
|
200
|
+
kind: :instance_method,
|
|
201
|
+
file: context.relative_file,
|
|
202
|
+
line: node.location.start_line,
|
|
203
|
+
end_line: node.location.end_line,
|
|
204
|
+
defined_via: node.value.name == :define ? :data_define : :struct_new,
|
|
205
|
+
owner: owner,
|
|
206
|
+
name: name,
|
|
207
|
+
test: context.test
|
|
208
|
+
)
|
|
209
|
+
next if node.value.name == :define
|
|
210
|
+
|
|
211
|
+
nodes << Node.new(
|
|
212
|
+
id: "#{owner}##{name}=",
|
|
213
|
+
kind: :instance_method,
|
|
214
|
+
file: context.relative_file,
|
|
215
|
+
line: node.location.start_line,
|
|
216
|
+
end_line: node.location.end_line,
|
|
217
|
+
defined_via: :struct_new,
|
|
218
|
+
owner: owner,
|
|
219
|
+
name: "#{name}=",
|
|
220
|
+
test: context.test
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
return unless node.value.block
|
|
225
|
+
|
|
226
|
+
block_context = context.dup
|
|
227
|
+
block_context.namespace = owner
|
|
228
|
+
block_context.owner = owner
|
|
229
|
+
block_context.singleton_scope = false
|
|
230
|
+
visit(node.value.block.body, block_context)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def struct_or_data_definition?(value)
|
|
234
|
+
return false unless value.is_a?(Prism::CallNode)
|
|
235
|
+
|
|
236
|
+
receiver_name = constant_name(value.receiver)
|
|
237
|
+
(receiver_name == 'Struct' && value.name == :new) || (receiver_name == 'Data' && value.name == :define)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def visit_children(node, context)
|
|
241
|
+
node.child_nodes.compact.each { |child| visit(child, context) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def handle_define_method(node, context)
|
|
245
|
+
return false unless node.name == :define_method
|
|
246
|
+
return false unless context.owner
|
|
247
|
+
|
|
248
|
+
method_name = first_symbol_argument(node)
|
|
249
|
+
return false unless method_name
|
|
250
|
+
|
|
251
|
+
id = "#{context.owner}##{method_name}"
|
|
252
|
+
nodes << Node.new(
|
|
253
|
+
id: id,
|
|
254
|
+
kind: :instance_method,
|
|
255
|
+
file: context.relative_file,
|
|
256
|
+
line: node.location.start_line,
|
|
257
|
+
end_line: node.location.end_line,
|
|
258
|
+
defined_via: :define_method,
|
|
259
|
+
owner: context.owner,
|
|
260
|
+
name: method_name,
|
|
261
|
+
test: context.test
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if node.block
|
|
265
|
+
block_context = context.dup
|
|
266
|
+
block_context.current_caller_id = id
|
|
267
|
+
block_context.current_kind = :instance_method
|
|
268
|
+
visit(node.block.body, block_context)
|
|
269
|
+
end
|
|
270
|
+
true
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def handle_alias_method(node, context)
|
|
274
|
+
return false unless node.name == :alias_method
|
|
275
|
+
return false unless context.owner
|
|
276
|
+
|
|
277
|
+
new_name, old_name = symbol_arguments(node)
|
|
278
|
+
return false unless new_name && old_name
|
|
279
|
+
|
|
280
|
+
record_alias_method(context, node.location, new_name, old_name)
|
|
281
|
+
true
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def visit_alias_method_node(node, context)
|
|
285
|
+
return visit_children(node, context) unless context.owner
|
|
286
|
+
|
|
287
|
+
new_name = node.new_name.unescaped.to_s
|
|
288
|
+
old_name = node.old_name.unescaped.to_s
|
|
289
|
+
record_alias_method(context, node.location, new_name, old_name)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def record_alias_method(context, location, new_name, old_name)
|
|
293
|
+
kind = context.singleton_scope ? :singleton_method : :instance_method
|
|
294
|
+
separator = kind == :singleton_method ? '.' : '#'
|
|
295
|
+
id = "#{context.owner}#{separator}#{new_name}"
|
|
296
|
+
nodes << Node.new(
|
|
297
|
+
id: id,
|
|
298
|
+
kind: kind,
|
|
299
|
+
file: context.relative_file,
|
|
300
|
+
line: location.start_line,
|
|
301
|
+
end_line: location.end_line,
|
|
302
|
+
defined_via: :alias_method,
|
|
303
|
+
owner: context.owner,
|
|
304
|
+
name: new_name,
|
|
305
|
+
test: context.test
|
|
306
|
+
)
|
|
307
|
+
call_sites << CallSite.new(
|
|
308
|
+
caller_id: id,
|
|
309
|
+
message: old_name,
|
|
310
|
+
receiver_kind: :self,
|
|
311
|
+
receiver_name: context.owner,
|
|
312
|
+
file: context.relative_file,
|
|
313
|
+
line: location.start_line,
|
|
314
|
+
test: context.test,
|
|
315
|
+
dynamic: false,
|
|
316
|
+
metadata: { 'original_message' => old_name, 'alias_method' => true }
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def handle_module_relation(node, context)
|
|
321
|
+
return unless MODULE_RELATION_MACROS.include?(node.name)
|
|
322
|
+
return unless context.owner
|
|
323
|
+
|
|
324
|
+
constants = arguments(node).filter_map { |argument| constant_name(argument) }
|
|
325
|
+
return if constants.empty?
|
|
326
|
+
|
|
327
|
+
data = class_record(context.owner)
|
|
328
|
+
key = :"#{node.name}s"
|
|
329
|
+
constants.each { |constant| data[key].concat(constant_candidates(constant, context.namespace)) }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def handle_rails_callback(node, context)
|
|
333
|
+
return unless RAILS_CALLBACK_MACROS.include?(node.name)
|
|
334
|
+
return unless context.owner
|
|
335
|
+
|
|
336
|
+
symbol_arguments(node).each do |method_name|
|
|
337
|
+
entrypoint_hints << EntryPoint.new(node_id: "#{context.owner}##{method_name}", reason: :callback_registered)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def handle_attr_macro(node, context)
|
|
342
|
+
return false unless ATTR_MACROS.include?(node.name)
|
|
343
|
+
return false unless context.owner
|
|
344
|
+
|
|
345
|
+
symbol_arguments(node).each do |name|
|
|
346
|
+
nodes << Node.new(
|
|
347
|
+
id: "#{context.owner}##{name}",
|
|
348
|
+
kind: :instance_method,
|
|
349
|
+
file: context.relative_file,
|
|
350
|
+
line: node.location.start_line,
|
|
351
|
+
end_line: node.location.end_line,
|
|
352
|
+
defined_via: node.name,
|
|
353
|
+
owner: context.owner,
|
|
354
|
+
name: name,
|
|
355
|
+
test: context.test
|
|
356
|
+
)
|
|
357
|
+
next unless %i[attr_writer attr_accessor].include?(node.name)
|
|
358
|
+
|
|
359
|
+
nodes << Node.new(
|
|
360
|
+
id: "#{context.owner}##{name}=",
|
|
361
|
+
kind: :instance_method,
|
|
362
|
+
file: context.relative_file,
|
|
363
|
+
line: node.location.start_line,
|
|
364
|
+
end_line: node.location.end_line,
|
|
365
|
+
defined_via: node.name,
|
|
366
|
+
owner: context.owner,
|
|
367
|
+
name: "#{name}=",
|
|
368
|
+
test: context.test
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
true
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def handle_delegate(node, context)
|
|
375
|
+
return false unless node.name == :delegate
|
|
376
|
+
return false unless context.owner
|
|
377
|
+
|
|
378
|
+
symbol_arguments(node).each do |name|
|
|
379
|
+
nodes << Node.new(
|
|
380
|
+
id: "#{context.owner}##{name}",
|
|
381
|
+
kind: :instance_method,
|
|
382
|
+
file: context.relative_file,
|
|
383
|
+
line: node.location.start_line,
|
|
384
|
+
end_line: node.location.end_line,
|
|
385
|
+
defined_via: :delegate,
|
|
386
|
+
owner: context.owner,
|
|
387
|
+
name: name,
|
|
388
|
+
test: context.test
|
|
389
|
+
)
|
|
390
|
+
end
|
|
391
|
+
true
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def build_call_site(node, context)
|
|
395
|
+
message = node.name&.to_s
|
|
396
|
+
dynamic = false
|
|
397
|
+
receiver = classify_receiver(node.receiver, context)
|
|
398
|
+
metadata = { 'original_message' => message, 'receiver_candidates' => receiver[:candidates] }
|
|
399
|
+
|
|
400
|
+
if DYNAMIC_SENDS.include?(node.name)
|
|
401
|
+
literal = first_symbol_argument(node) || first_string_argument(node)
|
|
402
|
+
dynamic = literal.nil?
|
|
403
|
+
message = literal
|
|
404
|
+
metadata['dynamic_dispatch'] = true
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
return nil unless message
|
|
408
|
+
|
|
409
|
+
CallSite.new(
|
|
410
|
+
caller_id: context.current_caller_id,
|
|
411
|
+
message: message,
|
|
412
|
+
receiver_kind: receiver.fetch(:kind),
|
|
413
|
+
receiver_name: receiver[:name],
|
|
414
|
+
file: context.relative_file,
|
|
415
|
+
line: node.location.start_line,
|
|
416
|
+
test: context.test,
|
|
417
|
+
dynamic: dynamic,
|
|
418
|
+
metadata: metadata
|
|
419
|
+
)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def record_instantiation(node, context)
|
|
423
|
+
return record_factory_instantiation(node, context) unless %i[new []].include?(node.name)
|
|
424
|
+
|
|
425
|
+
receiver = classify_receiver(node.receiver, context)
|
|
426
|
+
return unless receiver[:kind] == :constant
|
|
427
|
+
|
|
428
|
+
Array(receiver[:candidates] || receiver[:name]).each { |name| instantiated_classes << name }
|
|
429
|
+
record_initialize_call(node, context, receiver)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def record_initialize_call(node, context, receiver)
|
|
433
|
+
return unless node.name == :new
|
|
434
|
+
|
|
435
|
+
call_sites << CallSite.new(
|
|
436
|
+
caller_id: context.current_caller_id,
|
|
437
|
+
message: 'initialize',
|
|
438
|
+
receiver_kind: :instance,
|
|
439
|
+
receiver_name: receiver[:name],
|
|
440
|
+
file: context.relative_file,
|
|
441
|
+
line: node.location.start_line,
|
|
442
|
+
test: context.test,
|
|
443
|
+
dynamic: false,
|
|
444
|
+
metadata: { 'original_message' => 'new', 'receiver_candidates' => receiver[:candidates],
|
|
445
|
+
'implicit_from' => 'new' }
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def record_factory_instantiation(node, context)
|
|
450
|
+
factory_methods = Array(project.config.factory_methods).map(&:to_s)
|
|
451
|
+
return unless factory_methods.include?(node.name.to_s)
|
|
452
|
+
|
|
453
|
+
receiver = classify_receiver(node.receiver, context)
|
|
454
|
+
return unless receiver[:kind] == :constant
|
|
455
|
+
|
|
456
|
+
Array(receiver[:candidates] || receiver[:name]).each do |name|
|
|
457
|
+
instantiated_classes << name
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def record_uncertainty(site)
|
|
462
|
+
uncertainties[site.caller_id] << "Dynamic dispatch at #{site.file}:#{site.line}"
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def record_uncertainty_at(node, context)
|
|
466
|
+
uncertainties[context.current_caller_id] << "Dynamic dispatch at #{context.relative_file}:#{node.location.start_line}"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def unresolved_dynamic_dispatch?(node)
|
|
470
|
+
DYNAMIC_SENDS.include?(node.name) && first_symbol_argument(node).nil? && first_string_argument(node).nil?
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def record_parse_errors(root_id, result)
|
|
474
|
+
result.errors.each do |error|
|
|
475
|
+
uncertainties[root_id] << "Parse warning at line #{error.location.start_line}: #{error.message}"
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def definition_owner(node, context)
|
|
480
|
+
return context.owner unless node.receiver
|
|
481
|
+
return context.owner if node.receiver.is_a?(Prism::SelfNode)
|
|
482
|
+
|
|
483
|
+
receiver = classify_receiver(node.receiver, context)
|
|
484
|
+
receiver[:name]
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def classify_receiver(receiver, context)
|
|
488
|
+
return { kind: :implicit, name: nil } unless receiver
|
|
489
|
+
return { kind: :self, name: context.owner } if receiver.is_a?(Prism::SelfNode)
|
|
490
|
+
|
|
491
|
+
constant = constant_name(receiver)
|
|
492
|
+
if constant
|
|
493
|
+
candidates = constant_candidates(constant, context.namespace)
|
|
494
|
+
return { kind: :constant, name: candidates.first, candidates: candidates }
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
if receiver.is_a?(Prism::CallNode) && receiver.name == :new
|
|
498
|
+
receiver_constant = constant_name(receiver.receiver)
|
|
499
|
+
if receiver_constant
|
|
500
|
+
candidates = constant_candidates(receiver_constant, context.namespace)
|
|
501
|
+
return { kind: :instance, name: candidates.first, candidates: candidates }
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
{ kind: :unknown, name: nil, candidates: [] }
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def first_symbol_argument(node)
|
|
509
|
+
symbol_arguments(node).first
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def first_string_argument(node)
|
|
513
|
+
arguments(node).find { |arg| arg.respond_to?(:unescaped) && arg.class.name.end_with?('StringNode') }&.unescaped
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def symbol_arguments(node)
|
|
517
|
+
arguments(node).filter_map do |arg|
|
|
518
|
+
next unless arg.respond_to?(:unescaped) && arg.class.name.end_with?('SymbolNode')
|
|
519
|
+
|
|
520
|
+
arg.unescaped.to_s
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def arguments(node)
|
|
525
|
+
node.arguments&.arguments || []
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def constant_name(node)
|
|
529
|
+
return nil unless node
|
|
530
|
+
|
|
531
|
+
case node
|
|
532
|
+
when Prism::ConstantReadNode
|
|
533
|
+
node.name.to_s
|
|
534
|
+
when Prism::ConstantPathNode
|
|
535
|
+
[constant_name(node.parent), node.name.to_s].compact.join('::')
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def qualify_constant(name, namespace)
|
|
540
|
+
return nil unless name
|
|
541
|
+
return name if namespace.nil? || namespace.empty? || name.include?('::')
|
|
542
|
+
|
|
543
|
+
"#{namespace}::#{name}"
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def record_class_info(node, namespace, context)
|
|
547
|
+
return unless namespace
|
|
548
|
+
|
|
549
|
+
data = class_record(namespace)
|
|
550
|
+
data[:kind] = node.is_a?(Prism::ModuleNode) ? :module : :class
|
|
551
|
+
data[:file] = context.relative_file
|
|
552
|
+
data[:line] = node.location.start_line
|
|
553
|
+
return unless node.respond_to?(:superclass)
|
|
554
|
+
|
|
555
|
+
superclass = constant_name(node.superclass)
|
|
556
|
+
data[:superclass_candidates] = constant_candidates(superclass, context.namespace) if superclass
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def class_record(namespace)
|
|
560
|
+
class_data[namespace] ||= {
|
|
561
|
+
id: namespace,
|
|
562
|
+
kind: :class,
|
|
563
|
+
file: nil,
|
|
564
|
+
line: nil,
|
|
565
|
+
superclass: nil,
|
|
566
|
+
superclass_candidates: [],
|
|
567
|
+
includes: [],
|
|
568
|
+
prepends: [],
|
|
569
|
+
extends: [],
|
|
570
|
+
dynamic: false
|
|
571
|
+
}
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def class_infos
|
|
575
|
+
class_data.values.map do |data|
|
|
576
|
+
ClassInfo.new(
|
|
577
|
+
id: data.fetch(:id),
|
|
578
|
+
kind: data.fetch(:kind),
|
|
579
|
+
file: data[:file],
|
|
580
|
+
line: data[:line],
|
|
581
|
+
superclass: resolve_known_constants(data.fetch(:superclass_candidates)).first,
|
|
582
|
+
superclass_candidates: data.fetch(:superclass_candidates),
|
|
583
|
+
includes: resolve_known_constants(data.fetch(:includes)),
|
|
584
|
+
prepends: resolve_known_constants(data.fetch(:prepends)),
|
|
585
|
+
extends: resolve_known_constants(data.fetch(:extends)),
|
|
586
|
+
dynamic: data.fetch(:dynamic)
|
|
587
|
+
)
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def resolve_known_constants(candidates)
|
|
592
|
+
grouped_candidates(candidates).map do |group|
|
|
593
|
+
group.find { |candidate| class_data.key?(candidate) } || group.first
|
|
594
|
+
end.compact.uniq
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def grouped_candidates(candidates)
|
|
598
|
+
Array(candidates).chunk_while do |left, right|
|
|
599
|
+
suffix = left.split('::').last
|
|
600
|
+
right&.end_with?("::#{suffix}") || right == suffix
|
|
601
|
+
end.to_a
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def constant_candidates(name, namespace)
|
|
605
|
+
return [name] if namespace.nil? || namespace.empty? || name.include?('::')
|
|
606
|
+
|
|
607
|
+
parts = namespace.split('::')
|
|
608
|
+
candidates = []
|
|
609
|
+
parts.length.downto(1) do |length|
|
|
610
|
+
candidates << "#{parts.first(length).join('::')}::#{name}"
|
|
611
|
+
end
|
|
612
|
+
candidates << name
|
|
613
|
+
candidates.uniq
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|