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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +125 -0
  4. data/Rakefile +8 -0
  5. data/exe/necropsy +6 -0
  6. data/lib/necropsy/analyzer.rb +19 -0
  7. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
  8. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
  9. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
  10. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
  11. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
  12. data/lib/necropsy/analyzers/static/cha.rb +106 -0
  13. data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
  14. data/lib/necropsy/analyzers/static/rta.rb +79 -0
  15. data/lib/necropsy/ast_scanner.rb +616 -0
  16. data/lib/necropsy/bench/evaluator.rb +127 -0
  17. data/lib/necropsy/cache/scan_cache.rb +158 -0
  18. data/lib/necropsy/cli.rb +287 -0
  19. data/lib/necropsy/confidence/scorer.rb +158 -0
  20. data/lib/necropsy/configuration.rb +116 -0
  21. data/lib/necropsy/coverage_runtime.rb +13 -0
  22. data/lib/necropsy/entry_points/plain.rb +36 -0
  23. data/lib/necropsy/entry_points/rails.rb +335 -0
  24. data/lib/necropsy/entry_points/test.rb +13 -0
  25. data/lib/necropsy/graph/call_graph.rb +199 -0
  26. data/lib/necropsy/guardrail/baseline.rb +47 -0
  27. data/lib/necropsy/guardrail/diff.rb +16 -0
  28. data/lib/necropsy/guardrail/quarantine.rb +46 -0
  29. data/lib/necropsy/models.rb +164 -0
  30. data/lib/necropsy/project.rb +65 -0
  31. data/lib/necropsy/reachability/engine.rb +42 -0
  32. data/lib/necropsy/report.rb +50 -0
  33. data/lib/necropsy/reporter.rb +112 -0
  34. data/lib/necropsy/runner.rb +74 -0
  35. data/lib/necropsy/version.rb +5 -0
  36. data/lib/necropsy.rb +46 -0
  37. 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