necropsy 0.1.0 → 0.3.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. metadata +106 -2
@@ -1,616 +1,141 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'prism'
4
+ require_relative 'ast_scanner/definition_creation'
5
+ require_relative 'ast_scanner/call_site_creation'
6
+ require_relative 'ast_scanner/traversal'
7
+ require_relative 'ast_scanner/value_definitions'
8
+ require_relative 'ast_scanner/method_definitions'
9
+ require_relative 'ast_scanner/dsl_macros'
10
+ require_relative 'ast_scanner/call_recording'
11
+ require_relative 'ast_scanner/references'
12
+ require_relative 'ast_scanner/ruby_semantics'
4
13
 
5
14
  module Necropsy
6
- ScanResult = Data.define(:nodes, :call_sites, :instantiated_classes, :uncertainties, :class_infos, :entrypoint_hints)
15
+ ScanResult = Data.define(
16
+ :nodes,
17
+ :call_sites,
18
+ :instantiated_classes,
19
+ :uncertainties,
20
+ :class_infos,
21
+ :entrypoint_hints,
22
+ :file_statuses,
23
+ :source_errors,
24
+ :source_domains,
25
+ :scope_diagnostics,
26
+ :method_signatures,
27
+ :semantic_blockers
28
+ ) do
29
+ def initialize(nodes:, call_sites:, instantiated_classes:, uncertainties:, class_infos:, entrypoint_hints:,
30
+ file_statuses: {}, source_errors: [], source_domains: {}, scope_diagnostics: {},
31
+ method_signatures: {}, semantic_blockers: [])
32
+ super
33
+ end
34
+ end
7
35
 
8
36
  class AstScanner
37
+ CallTraversal = Data.define(:receiver, :arguments, :block)
9
38
  ATTR_MACROS = %i[attr_reader attr_writer attr_accessor].freeze
10
39
  DYNAMIC_SENDS = %i[send public_send __send__].freeze
11
40
  MODULE_RELATION_MACROS = %i[include prepend extend].freeze
12
41
  RAILS_CALLBACK_MACROS = %i[
13
42
  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
43
+ before_validation after_validation before_save after_save around_save
44
+ around_validation before_touch after_touch
45
+ before_enqueue around_enqueue after_enqueue before_perform around_perform after_perform
46
+ before_create after_create around_create before_update after_update around_update
47
+ before_destroy after_destroy around_destroy after_commit after_rollback after_initialize after_find
48
+ after_create_commit after_update_commit after_destroy_commit after_save_commit
49
+ validate rescue_from helper_method
50
+ ].freeze
51
+ RAILS_GENERATED_METHOD_MACROS = %i[
52
+ enum store store_accessor attribute class_attribute mattr_reader mattr_writer mattr_accessor
53
+ cattr_reader cattr_writer cattr_accessor belongs_to has_one has_many scope
54
+ ].freeze
55
+ RAILS_RUNTIME_BLOCK_MACROS = %i[discard_on retry_on stream_for stream_from].freeze
56
+ SIDEKIQ_RUNTIME_BLOCK_MACROS = %i[sidekiq_retries_exhausted sidekiq_retry_in].freeze
57
+ VISIBILITY_MACROS = %i[public protected private public_class_method private_class_method].freeze
58
+ SYMBOL_REFERENCE_CALLS = %i[method respond_to? try try!].freeze
59
+ ANCESTRY_CONTROL_FLOW_TYPES = %i[
60
+ and_node block_node case_match_node case_node else_node for_node if_node in_node lambda_node
61
+ or_node rescue_modifier_node rescue_node until_node when_node while_node
62
+ ].freeze
63
+ RAILS_BUILTIN_VALIDATORS = %w[
64
+ absence acceptance allow_blank allow_nil comparison confirmation exclusion format if inclusion length message numericality
65
+ on presence strict uniqueness unless
16
66
  ].freeze
17
67
 
18
68
  Context = Struct.new(
19
69
  :namespace,
70
+ :lexical_nesting,
20
71
  :owner,
21
72
  :current_caller_id,
73
+ :current_method_name,
22
74
  :current_kind,
23
75
  :root_id,
24
76
  :file,
25
77
  :relative_file,
26
78
  :test,
27
79
  :singleton_scope,
80
+ :visibility,
81
+ :module_function,
82
+ :static_ancestry,
83
+ :flow_result,
28
84
  keyword_init: true
29
85
  )
30
86
 
31
- def initialize(project:, files:)
87
+ def initialize(project:, files:, source_domains: nil, scope_diagnostics: {})
32
88
  @project = project
33
89
  @files = files
90
+ @source_domains = source_domains || files.to_h { |file| [project.relative_path(file), :analyze] }
91
+ @scope_diagnostics = scope_diagnostics
34
92
  @nodes = []
35
93
  @call_sites = []
36
94
  @instantiated_classes = Set.new
37
95
  @uncertainties = Hash.new { |hash, key| hash[key] = [] }
38
96
  @class_data = {}
39
97
  @entrypoint_hints = []
98
+ @file_statuses = {}
99
+ @source_errors = []
100
+ @definition_ordinals = Hash.new(0)
101
+ @call_site_ordinals = Hash.new(0)
102
+ @module_function_sources = {}
103
+ @deferred_module_functions = {}
104
+ @method_signatures = {}
105
+ @semantic_blockers = []
106
+ @factory_methods = project.config.factory_methods.to_set(&:to_s)
107
+ @convention_rules = ConventionRules.new
40
108
  end
41
109
 
42
110
  def scan
43
- files.each { |file| scan_file(file) }
111
+ files.sort_by { |file| project.relative_path(file) }.each { |file| scan_file(file) }
112
+ resolve_deferred_module_function_sources
113
+ copy_module_function_call_sites
44
114
  ScanResult.new(
45
- nodes: nodes.uniq(&:id),
115
+ nodes: nodes,
46
116
  call_sites: call_sites,
47
117
  instantiated_classes: instantiated_classes,
48
118
  uncertainties: uncertainties,
49
119
  class_infos: class_infos,
50
- entrypoint_hints: entrypoint_hints.uniq
120
+ entrypoint_hints: entrypoint_hints.uniq,
121
+ file_statuses: file_statuses,
122
+ source_errors: source_errors,
123
+ source_domains: source_domains,
124
+ scope_diagnostics: scope_diagnostics,
125
+ method_signatures: method_signatures,
126
+ semantic_blockers: semantic_blockers.sort_by { |blocker| BoundedCanonicalizer.dump(blocker.to_h) }
51
127
  )
52
128
  end
53
129
 
54
130
  private
55
131
 
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}"
132
+ def handled_call(receiver: true, arguments: true, block: false)
133
+ CallTraversal.new(receiver: receiver, arguments: arguments, block: block)
544
134
  end
545
135
 
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
136
+ attr_reader :project, :files, :nodes, :call_sites, :instantiated_classes, :uncertainties, :class_data,
137
+ :entrypoint_hints, :file_statuses, :source_errors, :definition_ordinals, :module_function_sources,
138
+ :deferred_module_functions, :call_site_ordinals, :source_domains, :scope_diagnostics,
139
+ :method_signatures, :semantic_blockers, :convention_rules
615
140
  end
616
141
  end