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,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 before_create after_create before_update after_update
15
- before_destroy after_destroy around_save validate
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 = expected - actual
55
+ false_negative = recall_expected ? recall_expected - actual : Set.new
56
56
 
57
57
  precision = ratio(true_positive.length, actual.length)
58
- recall = ratio(true_positive.length, expected.length)
59
- f1 = (precision + recall).zero? ? 0.0 : (2 * precision * recall / (precision + recall))
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.round(4),
64
- 'f1' => f1.round(4),
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.load_file(gold_standard_path) || {}
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
- expected_scope = attribute == :classification ? gold_standard(classification: findings.first.public_send(attribute)) : gold_standard
98
- metrics_for(Report.new(root: report.root, graph: report.graph, findings: findings), expected: expected_scope)
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