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
data/lib/necropsy/ast_scanner.rb
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'prism'
|
|
4
|
+
require_relative 'ast_scanner/traversal'
|
|
5
|
+
require_relative 'ast_scanner/value_definitions'
|
|
6
|
+
require_relative 'ast_scanner/method_definitions'
|
|
7
|
+
require_relative 'ast_scanner/dsl_macros'
|
|
8
|
+
require_relative 'ast_scanner/call_recording'
|
|
9
|
+
require_relative 'ast_scanner/references'
|
|
10
|
+
require_relative 'ast_scanner/ruby_semantics'
|
|
4
11
|
|
|
5
12
|
module Necropsy
|
|
6
13
|
ScanResult = Data.define(:nodes, :call_sites, :instantiated_classes, :uncertainties, :class_infos, :entrypoint_hints)
|
|
@@ -11,8 +18,18 @@ module Necropsy
|
|
|
11
18
|
MODULE_RELATION_MACROS = %i[include prepend extend].freeze
|
|
12
19
|
RAILS_CALLBACK_MACROS = %i[
|
|
13
20
|
before_action after_action around_action
|
|
14
|
-
before_save after_save
|
|
15
|
-
|
|
21
|
+
before_validation after_validation before_save after_save around_save
|
|
22
|
+
around_validation before_touch after_touch
|
|
23
|
+
before_create after_create around_create before_update after_update around_update
|
|
24
|
+
before_destroy after_destroy around_destroy after_commit after_rollback after_initialize after_find
|
|
25
|
+
after_create_commit after_update_commit after_destroy_commit after_save_commit
|
|
26
|
+
validate rescue_from helper_method
|
|
27
|
+
].freeze
|
|
28
|
+
VISIBILITY_MACROS = %i[public protected private public_class_method private_class_method].freeze
|
|
29
|
+
SYMBOL_REFERENCE_CALLS = %i[method respond_to? try try!].freeze
|
|
30
|
+
RAILS_BUILTIN_VALIDATORS = %w[
|
|
31
|
+
absence acceptance allow_blank allow_nil comparison confirmation exclusion format if inclusion length message numericality
|
|
32
|
+
on presence strict uniqueness unless
|
|
16
33
|
].freeze
|
|
17
34
|
|
|
18
35
|
Context = Struct.new(
|
|
@@ -25,6 +42,8 @@ module Necropsy
|
|
|
25
42
|
:relative_file,
|
|
26
43
|
:test,
|
|
27
44
|
:singleton_scope,
|
|
45
|
+
:visibility,
|
|
46
|
+
:module_function,
|
|
28
47
|
keyword_init: true
|
|
29
48
|
)
|
|
30
49
|
|
|
@@ -37,12 +56,14 @@ module Necropsy
|
|
|
37
56
|
@uncertainties = Hash.new { |hash, key| hash[key] = [] }
|
|
38
57
|
@class_data = {}
|
|
39
58
|
@entrypoint_hints = []
|
|
59
|
+
@factory_methods = project.config.factory_methods.to_set(&:to_s)
|
|
40
60
|
end
|
|
41
61
|
|
|
42
62
|
def scan
|
|
43
63
|
files.each { |file| scan_file(file) }
|
|
64
|
+
copy_module_function_call_sites
|
|
44
65
|
ScanResult.new(
|
|
45
|
-
nodes: nodes.uniq(&:id),
|
|
66
|
+
nodes: nodes.reverse.uniq(&:id).reverse,
|
|
46
67
|
call_sites: call_sites,
|
|
47
68
|
instantiated_classes: instantiated_classes,
|
|
48
69
|
uncertainties: uncertainties,
|
|
@@ -55,562 +76,5 @@ module Necropsy
|
|
|
55
76
|
|
|
56
77
|
attr_reader :project, :files, :nodes, :call_sites, :instantiated_classes, :uncertainties, :class_data,
|
|
57
78
|
: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
79
|
end
|
|
616
80
|
end
|
|
@@ -48,27 +48,29 @@ module Necropsy
|
|
|
48
48
|
attr_reader :report, :gold_standard_path, :min_confidence, :root, :config_path, :ablation, :precision_threshold,
|
|
49
49
|
:recall_threshold
|
|
50
50
|
|
|
51
|
-
def metrics_for(target_report, expected: gold_standard)
|
|
51
|
+
def metrics_for(target_report, expected: gold_standard, recall_expected: expected)
|
|
52
52
|
actual = target_report.dead_methods(min_confidence: min_confidence).to_set { |finding| finding.node.id }
|
|
53
53
|
true_positive = actual & expected
|
|
54
54
|
false_positive = actual - expected
|
|
55
|
-
false_negative =
|
|
55
|
+
false_negative = recall_expected ? recall_expected - actual : Set.new
|
|
56
56
|
|
|
57
57
|
precision = ratio(true_positive.length, actual.length)
|
|
58
|
-
recall = ratio(
|
|
59
|
-
f1 =
|
|
58
|
+
recall = ratio((actual & recall_expected).length, recall_expected.length) if recall_expected
|
|
59
|
+
f1 = if recall
|
|
60
|
+
(precision + recall).zero? ? 0.0 : (2 * precision * recall / (precision + recall))
|
|
61
|
+
end
|
|
60
62
|
|
|
61
63
|
{
|
|
62
64
|
'precision' => precision.round(4),
|
|
63
|
-
'recall' => recall
|
|
64
|
-
'f1' => f1
|
|
65
|
+
'recall' => recall&.round(4),
|
|
66
|
+
'f1' => f1&.round(4),
|
|
65
67
|
'true_positive' => true_positive.to_a.sort,
|
|
66
68
|
'false_positive' => false_positive.to_a.sort,
|
|
67
69
|
'false_negative' => false_negative.to_a.sort
|
|
68
70
|
}
|
|
69
71
|
end
|
|
70
72
|
|
|
71
|
-
def gold_standard(classification: nil)
|
|
73
|
+
def gold_standard(classification: nil, confidence: nil)
|
|
72
74
|
entries = gold_entries
|
|
73
75
|
if classification
|
|
74
76
|
scoped = entries.select do |entry|
|
|
@@ -76,12 +78,18 @@ module Necropsy
|
|
|
76
78
|
end
|
|
77
79
|
return ids_for(scoped)
|
|
78
80
|
end
|
|
81
|
+
if confidence
|
|
82
|
+
scoped = entries.select { |entry| entry['confidence'] == confidence.to_s }
|
|
83
|
+
return nil if scoped.empty?
|
|
84
|
+
|
|
85
|
+
return ids_for(scoped)
|
|
86
|
+
end
|
|
79
87
|
|
|
80
88
|
ids_for(entries)
|
|
81
89
|
end
|
|
82
90
|
|
|
83
91
|
def gold_entries
|
|
84
|
-
payload = YAML.
|
|
92
|
+
payload = YAML.safe_load_file(gold_standard_path, aliases: true) || {}
|
|
85
93
|
entries = payload['dead_methods'] || payload['findings'] || payload
|
|
86
94
|
Array(entries).map do |entry|
|
|
87
95
|
entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { 'id' => entry }
|
|
@@ -94,8 +102,19 @@ module Necropsy
|
|
|
94
102
|
|
|
95
103
|
def grouped_metrics(attribute)
|
|
96
104
|
report.findings.group_by { |finding| finding.public_send(attribute) }.transform_values do |findings|
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
value = findings.first.public_send(attribute)
|
|
106
|
+
grouped_report = Report.new(root: report.root, graph: report.graph, findings: findings)
|
|
107
|
+
if attribute == :classification
|
|
108
|
+
expected_scope = gold_standard(classification: value)
|
|
109
|
+
metrics_for(grouped_report, expected: expected_scope)
|
|
110
|
+
else
|
|
111
|
+
expected_scope = gold_standard(confidence: value)
|
|
112
|
+
if expected_scope
|
|
113
|
+
metrics_for(grouped_report, expected: expected_scope)
|
|
114
|
+
else
|
|
115
|
+
metrics_for(grouped_report, expected: gold_standard, recall_expected: nil)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
99
118
|
end.transform_keys(&:to_s)
|
|
100
119
|
end
|
|
101
120
|
|