necropsy 0.2.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. metadata +85 -1
@@ -4,12 +4,25 @@ module Necropsy
4
4
  class AstScanner
5
5
  private
6
6
 
7
+ def resolve_deferred_module_function_sources
8
+ deferred_module_functions.each do |copy_id, instance_id|
9
+ module_function_sources[copy_id] = nodes.filter_map do |definition|
10
+ definition.graph_id if definition.kind == :instance_method && definition.symbol_id == instance_id
11
+ end
12
+ signatures = module_function_sources.fetch(copy_id).filter_map { |source_id| method_signatures[source_id] }.uniq
13
+ method_signatures[copy_id] = signatures.first if signatures.one?
14
+ end
15
+ end
16
+
7
17
  def copy_module_function_call_sites
8
- module_functions = nodes.select { |node| node.defined_via == :module_function }
9
- copies = module_functions.flat_map do |node|
10
- instance_id = "#{node.owner}##{node.name}"
11
- call_sites.select { |site| site.caller_id == instance_id }.map do |site|
12
- site.with(caller_id: node.id, metadata: site.metadata.merge('module_function' => true))
18
+ copies = module_function_sources.flat_map do |copy_id, source_ids|
19
+ source_ids.flat_map do |source_id|
20
+ call_sites.select { |site| site.caller_id == source_id }.map do |site|
21
+ derived_call_site(
22
+ site, derivation: :module_function, caller_id: copy_id,
23
+ metadata: { 'module_function' => true }
24
+ )
25
+ end
13
26
  end
14
27
  end
15
28
  call_sites.concat(copies)
@@ -17,42 +30,49 @@ module Necropsy
17
30
 
18
31
  def scan_file(file)
19
32
  relative = project.relative_path(file)
20
- root_id = "file:#{relative}"
21
33
  test = project.test_file?(file)
22
- nodes << Node.new(
23
- id: root_id,
34
+ context = Context.new(
35
+ namespace: nil,
36
+ lexical_nesting: [],
37
+ owner: nil,
38
+ current_caller_id: nil,
39
+ current_method_name: nil,
40
+ current_kind: :block_entry,
41
+ root_id: nil,
42
+ file: file,
43
+ relative_file: relative,
44
+ test: test,
45
+ singleton_scope: false,
46
+ visibility: :public,
47
+ module_function: false,
48
+ static_ancestry: true,
49
+ flow_result: nil
50
+ )
51
+ result = Prism.parse(File.read(file))
52
+ root = add_definition(
53
+ symbol_id: "file:#{relative}",
24
54
  kind: :block_entry,
25
- file: relative,
26
- line: 1,
27
- end_line: 1,
55
+ source_node: result.value,
56
+ context: context,
28
57
  defined_via: :file,
29
58
  owner: nil,
30
59
  name: relative,
31
- test: test,
32
60
  visibility: :public
33
61
  )
62
+ root_id = root.graph_id
63
+ context.current_caller_id = root_id
64
+ context.root_id = root_id
34
65
 
35
- result = Prism.parse(File.read(file))
36
- record_parse_errors(root_id, result) if result.failure?
37
-
38
- visit(
39
- result.value,
40
- Context.new(
41
- namespace: nil,
42
- owner: nil,
43
- current_caller_id: root_id,
44
- current_kind: :block_entry,
45
- root_id: root_id,
46
- file: file,
47
- relative_file: relative,
48
- test: test,
49
- singleton_scope: false,
50
- visibility: :public,
51
- module_function: false
52
- )
53
- )
54
- rescue SystemCallError, EncodingError => e
55
- uncertainties[root_id] << "Could not parse #{relative}: #{e.message}"
66
+ if result.failure?
67
+ file_statuses[relative] = :recovered
68
+ record_parse_errors(root_id, relative, result)
69
+ else
70
+ file_statuses[relative] = :complete
71
+ end
72
+
73
+ visit(result.value, context)
74
+ rescue DefinitionIdentity::CanonicalizationError, SystemStackError, SystemCallError, EncodingError => e
75
+ record_source_failure(root_id || "file:#{relative}", relative, e)
56
76
  end
57
77
 
58
78
  def visit(node, context)
@@ -74,15 +94,25 @@ module Necropsy
74
94
  when Prism::ConstantWriteNode
75
95
  visit_constant_write(node, context)
76
96
  else
77
- node.child_nodes.compact.each { |child| visit(child, context) }
97
+ visit_generic_node(node, context)
78
98
  end
79
99
  end
80
100
 
101
+ def visit_generic_node(node, context)
102
+ return visit_children(node, context) unless ANCESTRY_CONTROL_FLOW_TYPES.include?(node.type)
103
+
104
+ nested_context = context.dup
105
+ nested_context.static_ancestry = false
106
+ visit_children(node, nested_context)
107
+ end
108
+
81
109
  def visit_namespace(node, context)
82
110
  namespace = qualify_constant(constant_name(node.constant_path), context.namespace)
83
111
  record_class_info(node, namespace, context)
112
+ visit(node.superclass, context) if node.respond_to?(:superclass) && node.superclass
84
113
  child_context = context.dup
85
114
  child_context.namespace = namespace
115
+ child_context.lexical_nesting = [namespace, *context.lexical_nesting].compact.uniq
86
116
  child_context.owner = namespace
87
117
  child_context.singleton_scope = false
88
118
  child_context.visibility = :public
@@ -92,93 +122,226 @@ module Necropsy
92
122
 
93
123
  def visit_def(node, context)
94
124
  owner = definition_owner(node, context)
95
- return visit_children(node, context) unless owner
125
+ unless owner
126
+ visit(node.receiver, context) if node.receiver
127
+ record_semantic_blocker(
128
+ :dynamic_singleton_definition,
129
+ node,
130
+ context,
131
+ 'singleton method receiver is not statically bounded',
132
+ suggested_action: :make_singleton_receiver_static,
133
+ force_global: true
134
+ )
135
+ visit_synthetic_body(node.body, context, :dynamic_singleton_method)
136
+ return
137
+ end
96
138
 
97
139
  kind = node.receiver || context.singleton_scope ? :singleton_method : :instance_method
98
140
  separator = kind == :singleton_method ? '.' : '#'
99
141
  id = "#{owner}#{separator}#{node.name}"
100
- nodes << Node.new(
101
- id: id,
142
+ definition = add_definition(
143
+ symbol_id: id,
102
144
  kind: kind,
103
- file: context.relative_file,
104
- line: node.location.start_line,
105
- end_line: node.location.end_line,
145
+ source_node: node,
146
+ context: context,
106
147
  defined_via: :def,
107
148
  owner: owner,
108
149
  name: node.name.to_s,
109
- test: context.test,
110
150
  visibility: node.receiver ? :public : context.visibility
111
151
  )
112
- record_module_function_copy(node, context, owner) if kind == :instance_method && context.module_function
113
- if node.name == :method_missing
114
- uncertainties[id] << "#{owner} defines method_missing"
152
+ method_signatures[definition.graph_id] = method_signature(node.parameters)
153
+ record_module_function_copy(node, context, definition) if kind == :instance_method && context.module_function
154
+ if %i[method_missing respond_to_missing?].include?(node.name)
155
+ uncertainties[definition.graph_id] << "#{owner} defines #{node.name}"
115
156
  class_record(owner)[:dynamic] = true
116
157
  end
158
+ rule_hit = convention_rules.method_hit(
159
+ owner: owner,
160
+ method_name: node.name,
161
+ ancestors: convention_ancestors(owner),
162
+ frameworks: project.config.frameworks
163
+ )
164
+ if rule_hit
165
+ entrypoint_hints << EntryPoint.new(
166
+ node_id: definition.graph_id,
167
+ reason: :convention_callback,
168
+ evidence: { 'type' => 'convention_rule', **rule_hit }
169
+ )
170
+ end
117
171
 
118
172
  method_context = context.dup
119
173
  method_context.owner = owner
120
- method_context.current_caller_id = id
174
+ method_context.current_caller_id = definition.graph_id
175
+ method_context.current_method_name = node.name.to_s
121
176
  method_context.current_kind = kind
122
177
  method_context.singleton_scope = false
123
178
  method_context.visibility = :public
124
179
  method_context.module_function = false
180
+ method_context.static_ancestry = false
181
+ method_context.flow_result = FlowInterpreter.new(
182
+ constant_resolver: lambda do |constant|
183
+ resolve_candidate_group(constant_candidates(constant, method_context.lexical_nesting))
184
+ end
185
+ ).analyze(node.body)
186
+ method_context.flow_result.issues.each do |issue|
187
+ uncertainties[definition.graph_id] << "Forward value analysis stopped at #{issue}; affected calls use conservative lookup"
188
+ end
189
+ visit_default_parameters(node.parameters, method_context)
125
190
  visit(node.body, method_context)
126
191
  end
127
192
 
193
+ def visit_default_parameters(parameters, context)
194
+ return unless parameters
195
+
196
+ defaults = [*Array(parameters.optionals), *Array(parameters.keywords)].filter_map do |parameter|
197
+ parameter.value if parameter.respond_to?(:value)
198
+ end
199
+ defaults.each { |default| visit(default, context) }
200
+ end
201
+
202
+ def convention_ancestors(owner)
203
+ ancestors = []
204
+ pending = convention_parent_candidates(class_data[owner])
205
+ until pending.empty?
206
+ current = pending.shift
207
+ next if ancestors.include?(current)
208
+
209
+ ancestors << current
210
+ pending.concat(convention_parent_candidates(class_data[current]))
211
+ end
212
+ ancestors
213
+ end
214
+
215
+ def convention_parent_candidates(data)
216
+ return [] unless data
217
+
218
+ Array(data[:superclass_candidates]) +
219
+ %i[includes prepends extends singleton_includes singleton_prepends].flat_map do |relation|
220
+ Array(data[relation]).flatten
221
+ end
222
+ end
223
+
128
224
  def visit_call(node, context)
129
- return if handle_visibility(node, context)
130
- return if handle_module_function(node, context)
131
- return if handle_eval(node, context)
132
- return if handle_define_singleton_method(node, context)
133
- return if handle_define_method(node, context)
134
- return if handle_attr_macro(node, context)
135
- return if handle_delegate(node, context)
136
- return if handle_forwardable(node, context)
137
- return if handle_alias_method(node, context)
225
+ handlers = %i[
226
+ handle_unsupported_semantics handle_visibility handle_module_function handle_eval
227
+ handle_define_singleton_method handle_define_method handle_attr_macro handle_delegate
228
+ handle_forwardable handle_alias_method handle_method_removal
229
+ ]
230
+ handled = handlers.lazy.map { |handler| send(handler, node, context) }.find(&:itself)
231
+ if handled
232
+ raise TypeError, "semantic call handler must return AstScanner::CallTraversal, got #{handled.class}" unless
233
+ handled.is_a?(CallTraversal)
234
+
235
+ visit_call_children(
236
+ node,
237
+ context,
238
+ visit_receiver: handled.receiver,
239
+ visit_arguments: handled.arguments,
240
+ visit_block: handled.block
241
+ )
242
+ return
243
+ end
138
244
 
139
245
  handle_module_relation(node, context)
140
- handle_rails_callback(node, context)
246
+ callback_block_consumed = handle_rails_callback(node, context)
247
+ handle_graphql_field(node, context)
248
+ if handle_generated_rails_methods(node, context)
249
+ visit_call_children(node, context, visit_block: false)
250
+ return
251
+ end
141
252
 
142
253
  record_instantiation(node, context)
143
254
  record_symbol_reference(node, context)
144
255
  record_symbol_to_proc(node, context)
145
- site = build_call_site(node, context)
146
- call_sites << site if site
147
- if site&.dynamic
148
- record_uncertainty(site)
149
- elsif unresolved_dynamic_dispatch?(node)
150
- record_uncertainty_at(node, context)
151
- end
256
+ sites = build_call_sites(node, context).compact
257
+ call_sites.concat(sites)
258
+ sites.each { |site| record_uncertainty(site) if site.dynamic }
259
+ record_uncertainty_at(node, context) if sites.empty? && unresolved_dynamic_dispatch?(node)
152
260
 
153
- visit_children(node, context)
261
+ visit_call_children(node, context, visit_block: !callback_block_consumed)
262
+ end
263
+
264
+ def visit_call_children(node, context, visit_receiver: true, visit_arguments: true, visit_block: true)
265
+ visit(node.receiver, context) if visit_receiver && node.receiver
266
+ arguments(node).each { |argument| visit(argument, context) } if visit_arguments
267
+ visit(node.block, context) if visit_block && node.block
268
+ end
269
+
270
+ def visit_synthetic_body(body, context, purpose)
271
+ return unless body
272
+
273
+ synthetic = add_definition(
274
+ symbol_id: "synthetic:#{purpose}:#{context.relative_file}:#{body.location.start_line}",
275
+ kind: :block_entry,
276
+ source_node: body,
277
+ context: context,
278
+ defined_via: purpose,
279
+ owner: context.owner,
280
+ name: purpose,
281
+ visibility: :public
282
+ )
283
+ body_context = context.dup
284
+ body_context.current_caller_id = synthetic.graph_id
285
+ body_context.current_method_name = nil
286
+ body_context.current_kind = :block_entry
287
+ body_context.static_ancestry = false
288
+ body_context.flow_result = nil
289
+ visit(body, body_context)
290
+ synthetic
154
291
  end
155
292
 
156
293
  def visit_super(node, context)
157
- method_name = context.current_caller_id&.split(/[.#]/)&.last
294
+ method_name = context.current_method_name
158
295
  if method_name
159
- call_sites << CallSite.new(
160
- caller_id: context.current_caller_id,
296
+ add_scanned_call_site(
297
+ source_node: node,
298
+ context: context,
299
+ role: :super,
161
300
  message: method_name,
162
301
  receiver_kind: :super,
163
302
  receiver_name: context.owner,
164
- file: context.relative_file,
165
- line: node.location.start_line,
166
- test: context.test,
167
303
  dynamic: false,
168
- metadata: { 'super' => true }
304
+ metadata: {
305
+ 'super' => true,
306
+ 'zsuper' => node.is_a?(Prism::ForwardingSuperNode),
307
+ 'arguments' => node.is_a?(Prism::ForwardingSuperNode) ? incomplete_arguments : call_arguments(node)
308
+ }
169
309
  )
170
310
  end
171
311
  visit_children(node, context)
172
312
  end
173
313
 
174
314
  def visit_singleton_class(node, context)
175
- return visit_children(node, context) unless node.expression.is_a?(Prism::SelfNode) && context.owner
315
+ owner = singleton_class_owner(node.expression, context)
316
+ unless owner
317
+ visit(node.expression, context)
318
+ record_semantic_blocker(
319
+ :dynamic_singleton_definition,
320
+ node,
321
+ context,
322
+ 'singleton class receiver is not statically bounded',
323
+ suggested_action: :make_singleton_receiver_static,
324
+ force_global: true
325
+ )
326
+ return
327
+ end
176
328
 
177
329
  singleton_context = context.dup
330
+ singleton_context.namespace = owner
331
+ singleton_context.owner = owner
178
332
  singleton_context.singleton_scope = true
179
333
  singleton_context.visibility = :public
180
334
  singleton_context.module_function = false
181
335
  visit(node.body, singleton_context)
182
336
  end
337
+
338
+ def singleton_class_owner(expression, context)
339
+ return context.owner if expression.is_a?(Prism::SelfNode)
340
+
341
+ name = constant_name(expression)
342
+ return unless name
343
+
344
+ resolve_candidate_group(constant_candidates(name, context.lexical_nesting))
345
+ end
183
346
  end
184
347
  end
@@ -14,31 +14,40 @@ module Necropsy
14
14
  data[:line] = node.location.start_line
15
15
  instantiated_classes << owner
16
16
 
17
- symbol_arguments(node.value).each do |name|
18
- nodes << Node.new(
19
- id: "#{owner}##{name}",
17
+ value_arguments = arguments(node.value)
18
+ names = value_arguments.filter_map { |argument| literal_value(argument) if literal_name?(argument) }
19
+ if names.length != value_arguments.length
20
+ record_semantic_blocker(
21
+ :dynamic_generated_methods,
22
+ node.value,
23
+ context,
24
+ "#{node.value.name} has runtime-computed member names",
25
+ suggested_action: :make_member_name_literal,
26
+ scope_owner: owner
27
+ )
28
+ end
29
+
30
+ names.each do |name|
31
+ add_definition(
32
+ symbol_id: "#{owner}##{name}",
20
33
  kind: :instance_method,
21
- file: context.relative_file,
22
- line: node.location.start_line,
23
- end_line: node.location.end_line,
34
+ source_node: node,
35
+ context: context,
24
36
  defined_via: node.value.name == :define ? :data_define : :struct_new,
25
37
  owner: owner,
26
38
  name: name,
27
- test: context.test,
28
39
  visibility: :public
29
40
  )
30
41
  next if node.value.name == :define
31
42
 
32
- nodes << Node.new(
33
- id: "#{owner}##{name}=",
43
+ add_definition(
44
+ symbol_id: "#{owner}##{name}=",
34
45
  kind: :instance_method,
35
- file: context.relative_file,
36
- line: node.location.start_line,
37
- end_line: node.location.end_line,
46
+ source_node: node,
47
+ context: context,
38
48
  defined_via: :struct_new,
39
49
  owner: owner,
40
50
  name: "#{name}=",
41
- test: context.test,
42
51
  visibility: :public
43
52
  )
44
53
  end
@@ -51,6 +60,7 @@ module Necropsy
51
60
  block_context.singleton_scope = false
52
61
  block_context.visibility = :public
53
62
  block_context.module_function = false
63
+ block_context.static_ancestry = false
54
64
  visit(node.value.block.body, block_context)
55
65
  end
56
66
 
@@ -1,6 +1,8 @@
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'
4
6
  require_relative 'ast_scanner/traversal'
5
7
  require_relative 'ast_scanner/value_definitions'
6
8
  require_relative 'ast_scanner/method_definitions'
@@ -10,9 +12,29 @@ require_relative 'ast_scanner/references'
10
12
  require_relative 'ast_scanner/ruby_semantics'
11
13
 
12
14
  module Necropsy
13
- 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
14
35
 
15
36
  class AstScanner
37
+ CallTraversal = Data.define(:receiver, :arguments, :block)
16
38
  ATTR_MACROS = %i[attr_reader attr_writer attr_accessor].freeze
17
39
  DYNAMIC_SENDS = %i[send public_send __send__].freeze
18
40
  MODULE_RELATION_MACROS = %i[include prepend extend].freeze
@@ -20,13 +42,24 @@ module Necropsy
20
42
  before_action after_action around_action
21
43
  before_validation after_validation before_save after_save around_save
22
44
  around_validation before_touch after_touch
45
+ before_enqueue around_enqueue after_enqueue before_perform around_perform after_perform
23
46
  before_create after_create around_create before_update after_update around_update
24
47
  before_destroy after_destroy around_destroy after_commit after_rollback after_initialize after_find
25
48
  after_create_commit after_update_commit after_destroy_commit after_save_commit
26
49
  validate rescue_from helper_method
27
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
28
57
  VISIBILITY_MACROS = %i[public protected private public_class_method private_class_method].freeze
29
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
30
63
  RAILS_BUILTIN_VALIDATORS = %w[
31
64
  absence acceptance allow_blank allow_nil comparison confirmation exclusion format if inclusion length message numericality
32
65
  on presence strict uniqueness unless
@@ -34,8 +67,10 @@ module Necropsy
34
67
 
35
68
  Context = Struct.new(
36
69
  :namespace,
70
+ :lexical_nesting,
37
71
  :owner,
38
72
  :current_caller_id,
73
+ :current_method_name,
39
74
  :current_kind,
40
75
  :root_id,
41
76
  :file,
@@ -44,37 +79,63 @@ module Necropsy
44
79
  :singleton_scope,
45
80
  :visibility,
46
81
  :module_function,
82
+ :static_ancestry,
83
+ :flow_result,
47
84
  keyword_init: true
48
85
  )
49
86
 
50
- def initialize(project:, files:)
87
+ def initialize(project:, files:, source_domains: nil, scope_diagnostics: {})
51
88
  @project = project
52
89
  @files = files
90
+ @source_domains = source_domains || files.to_h { |file| [project.relative_path(file), :analyze] }
91
+ @scope_diagnostics = scope_diagnostics
53
92
  @nodes = []
54
93
  @call_sites = []
55
94
  @instantiated_classes = Set.new
56
95
  @uncertainties = Hash.new { |hash, key| hash[key] = [] }
57
96
  @class_data = {}
58
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 = []
59
106
  @factory_methods = project.config.factory_methods.to_set(&:to_s)
107
+ @convention_rules = ConventionRules.new
60
108
  end
61
109
 
62
110
  def scan
63
- 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
64
113
  copy_module_function_call_sites
65
114
  ScanResult.new(
66
- nodes: nodes.reverse.uniq(&:id).reverse,
115
+ nodes: nodes,
67
116
  call_sites: call_sites,
68
117
  instantiated_classes: instantiated_classes,
69
118
  uncertainties: uncertainties,
70
119
  class_infos: class_infos,
71
- 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) }
72
127
  )
73
128
  end
74
129
 
75
130
  private
76
131
 
132
+ def handled_call(receiver: true, arguments: true, block: false)
133
+ CallTraversal.new(receiver: receiver, arguments: arguments, block: block)
134
+ end
135
+
77
136
  attr_reader :project, :files, :nodes, :call_sites, :instantiated_classes, :uncertainties, :class_data,
78
- :entrypoint_hints
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
79
140
  end
80
141
  end