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
@@ -8,121 +8,254 @@ module Necropsy
8
8
  return false unless node.name == :define_method
9
9
  return false unless context.owner
10
10
 
11
- method_name = first_symbol_argument(node)
12
- return false unless method_name
11
+ method_name = literal_argument(node, index: 0)
12
+ unless method_name
13
+ record_semantic_blocker(
14
+ :dynamic_definition,
15
+ node,
16
+ context,
17
+ 'define_method name is not statically bounded',
18
+ suggested_action: :make_method_name_literal
19
+ )
20
+ visit_synthetic_body(node.block&.body, context, :dynamic_define_method)
21
+ return handled_call
22
+ end
13
23
 
14
24
  kind, separator = method_kind_and_separator(context)
15
25
  id = "#{context.owner}#{separator}#{method_name}"
16
- nodes << Node.new(
17
- id: id,
26
+ definition = add_definition(
27
+ symbol_id: id,
18
28
  kind: kind,
19
- file: context.relative_file,
20
- line: node.location.start_line,
21
- end_line: node.location.end_line,
29
+ source_node: node,
30
+ context: context,
22
31
  defined_via: :define_method,
23
32
  owner: context.owner,
24
33
  name: method_name,
25
- test: context.test,
26
34
  visibility: context.visibility
27
35
  )
28
- record_module_function_copy(node, context, context.owner, method_name) if context.module_function
36
+ record_module_function_copy(node, context, definition) if context.module_function
29
37
 
30
38
  if node.block
31
39
  block_context = context.dup
32
- block_context.current_caller_id = id
40
+ block_context.current_caller_id = definition.graph_id
41
+ block_context.current_method_name = method_name
33
42
  block_context.current_kind = kind
34
43
  block_context.singleton_scope = false
35
44
  block_context.visibility = :public
36
45
  block_context.module_function = false
46
+ block_context.static_ancestry = false
37
47
  visit(node.block.body, block_context)
38
48
  end
39
- true
49
+ handled_call
40
50
  end
41
51
 
42
52
  def handle_define_singleton_method(node, context)
43
53
  return false unless node.name == :define_singleton_method
44
54
 
45
55
  owner = definition_owner_for_call(node, context)
46
- method_name = first_symbol_argument(node) || first_string_argument(node)
47
- return false unless owner && method_name
56
+ method_name = literal_argument(node, index: 0)
57
+ unless owner && method_name
58
+ record_semantic_blocker(
59
+ :dynamic_singleton_definition,
60
+ node,
61
+ context,
62
+ 'define_singleton_method receiver or name is not statically bounded',
63
+ suggested_action: :make_singleton_definition_static,
64
+ scope_owner: owner,
65
+ force_global: !owner
66
+ )
67
+ visit_synthetic_body(node.block&.body, context, :dynamic_define_singleton_method)
68
+ return handled_call
69
+ end
48
70
 
49
71
  id = "#{owner}.#{method_name}"
50
- nodes << Node.new(
51
- id: id,
72
+ definition = add_definition(
73
+ symbol_id: id,
52
74
  kind: :singleton_method,
53
- file: context.relative_file,
54
- line: node.location.start_line,
55
- end_line: node.location.end_line,
75
+ source_node: node,
76
+ context: context,
56
77
  defined_via: :define_singleton_method,
57
78
  owner: owner,
58
79
  name: method_name,
59
- test: context.test,
60
80
  visibility: :public
61
81
  )
62
82
  if node.block
63
83
  block_context = context.dup
64
84
  block_context.owner = owner
65
- block_context.current_caller_id = id
85
+ block_context.current_caller_id = definition.graph_id
86
+ block_context.current_method_name = method_name
66
87
  block_context.current_kind = :singleton_method
67
88
  block_context.singleton_scope = false
68
89
  block_context.visibility = :public
69
90
  block_context.module_function = false
91
+ block_context.static_ancestry = false
70
92
  visit(node.block.body, block_context)
71
93
  end
72
- true
94
+ handled_call
73
95
  end
74
96
 
75
97
  def handle_eval(node, context)
76
- return false unless %i[class_eval module_eval].include?(node.name)
77
- return false unless node.block
98
+ return false unless %i[class_eval class_exec module_eval module_exec instance_eval instance_exec eval].include?(node.name)
78
99
 
79
100
  owner = eval_owner(node, context)
80
- return false unless owner
101
+ unless node.block && owner && node.name != :eval
102
+ record_semantic_blocker(
103
+ :variable_eval,
104
+ node,
105
+ context,
106
+ 'Eval receiver or source is not statically bounded',
107
+ suggested_action: :replace_variable_eval,
108
+ scope_owner: node.name == :eval ? nil : owner,
109
+ receiver_kind: %i[instance_eval instance_exec].include?(node.name) ? :constant : nil,
110
+ force_global: node.name == :eval
111
+ )
112
+ visit_synthetic_body(node.block&.body, context, :dynamic_eval)
113
+ return handled_call
114
+ end
81
115
 
82
116
  block_context = context.dup
83
117
  block_context.namespace = owner
84
118
  block_context.owner = owner
85
- block_context.singleton_scope = false
119
+ block_context.singleton_scope = %i[instance_eval instance_exec].include?(node.name)
86
120
  block_context.visibility = :public
87
121
  block_context.module_function = false
122
+ block_context.static_ancestry = false
88
123
  visit(node.block.body, block_context)
89
- true
124
+ handled_call
125
+ end
126
+
127
+ def handle_unsupported_semantics(node, context)
128
+ return false unless %i[refine using].include?(node.name)
129
+
130
+ record_semantic_blocker(
131
+ :unsupported_refinement,
132
+ node,
133
+ context,
134
+ "#{node.name} changes lexical method lookup and is not resolved",
135
+ suggested_action: :review_refinement_scope
136
+ )
137
+ handled_call
138
+ end
139
+
140
+ def record_semantic_blocker(kind, node, context, reason, suggested_action: :review,
141
+ scope_owner: context.owner, receiver_kind: nil, force_global: false)
142
+ global = force_global || !scope_owner
143
+ scope_kind = global ? :global : :owner
144
+ scope_value = global ? '*' : scope_owner
145
+ metadata = {
146
+ 'caller_domain' => context.test ? 'test' : 'runtime',
147
+ 'caller_id' => context.current_caller_id,
148
+ 'semantic_operation' => (node.respond_to?(:name) ? node.name : node.type).to_s,
149
+ 'owner_scope' => Array(scope_owner).compact,
150
+ 'file' => context.relative_file,
151
+ 'line' => node.location.start_line,
152
+ 'reason_code' => kind.to_s
153
+ }
154
+ metadata['receiver_kind'] = receiver_kind.to_s if receiver_kind
155
+ semantic_blockers << Blocker.new(
156
+ kind: kind,
157
+ scope_kind: scope_kind,
158
+ scope_value: scope_value,
159
+ source: 'ast_scanner',
160
+ reason: reason,
161
+ suggested_action: suggested_action,
162
+ metadata: metadata
163
+ )
90
164
  end
91
165
 
92
166
  def handle_visibility(node, context)
93
167
  return false unless VISIBILITY_MACROS.include?(node.name)
94
168
  return false unless context.owner
95
169
 
96
- names = symbol_arguments(node)
170
+ modifier_definitions = arguments(node).grep(Prism::DefNode)
97
171
  class_method = node.name.to_s.end_with?('_class_method')
98
172
  visibility = node.name.to_s.delete_suffix('_class_method').to_sym
173
+ unless modifier_definitions.empty?
174
+ modifier_definitions.each do |definition|
175
+ definition_context = context.dup
176
+ definition_context.visibility = visibility
177
+ visit_def(definition, definition_context)
178
+ end
179
+ return handled_call(arguments: false)
180
+ end
181
+
182
+ modifier_calls = arguments(node).grep(Prism::CallNode)
183
+ unless modifier_calls.empty?
184
+ modifier_calls.each { |call| visit(call, context) }
185
+ names = [*symbol_arguments(node), *modifier_calls.flat_map { |call| visibility_result_names(call) }].uniq
186
+ names.each { |name| update_method_visibility(context, name, visibility, node, singleton: class_method) }
187
+ record_unknown_visibility_result(node, context, modifier_calls, visibility) if names.empty?
188
+ arguments(node).reject { |argument| argument.is_a?(Prism::CallNode) || argument.is_a?(Prism::DefNode) }
189
+ .each { |argument| visit(argument, context) }
190
+ return handled_call(receiver: false, arguments: false)
191
+ end
192
+
193
+ names = symbol_arguments(node)
99
194
  if names.empty?
100
195
  unless class_method
101
196
  context.visibility = visibility
102
197
  context.module_function = false
103
198
  end
104
199
  else
105
- names.each { |name| update_method_visibility(context, name, visibility, singleton: class_method) }
200
+ names.each { |name| update_method_visibility(context, name, visibility, node, singleton: class_method) }
106
201
  end
107
- true
202
+ handled_call
203
+ end
204
+
205
+ def visibility_result_names(call)
206
+ names = symbol_arguments(call)
207
+ case call.name
208
+ when :attr_reader
209
+ names
210
+ when :attr_writer
211
+ names.map { |name| "#{name}=" }
212
+ when :attr_accessor
213
+ names.flat_map { |name| [name, "#{name}="] }
214
+ else
215
+ []
216
+ end
217
+ end
218
+
219
+ def record_unknown_visibility_result(node, context, modifier_calls, visibility)
220
+ operations = modifier_calls.map(&:name).compact.map(&:to_s).uniq.sort
221
+ record_semantic_blocker(
222
+ :visibility_activation,
223
+ node,
224
+ context,
225
+ "#{visibility} receives runtime-computed method names from #{operations.join(', ')}",
226
+ suggested_action: :review_load_order
227
+ )
108
228
  end
109
229
 
110
230
  def handle_module_function(node, context)
111
231
  return false unless node.name == :module_function
112
232
  return false unless context.owner
113
233
 
234
+ modifier_definitions = arguments(node).grep(Prism::DefNode)
235
+ unless modifier_definitions.empty?
236
+ modifier_definitions.each do |definition|
237
+ definition_context = context.dup
238
+ definition_context.module_function = true
239
+ definition_context.visibility = :private
240
+ visit_def(definition, definition_context)
241
+ end
242
+ return handled_call(arguments: false)
243
+ end
244
+
114
245
  names = symbol_arguments(node)
115
246
  if names.empty?
116
247
  context.module_function = true
117
248
  context.visibility = :private
118
249
  else
119
- names.each { |name| promote_module_function(context, name, node.location) }
250
+ names.each { |name| defer_module_function(context, name, node) }
120
251
  end
121
- true
252
+ handled_call
122
253
  end
123
254
 
124
- def record_module_function_copy(node, context, _owner, method_name = node.name.to_s)
125
- promote_module_function(context, method_name, node.location)
255
+ def record_module_function_copy(node, context, instance_node)
256
+ copy = add_module_function_definition(context, instance_node.name, node)
257
+ module_function_sources[copy.graph_id] = [instance_node.graph_id]
258
+ method_signatures[copy.graph_id] = method_signatures[instance_node.graph_id] if method_signatures[instance_node.graph_id]
126
259
  end
127
260
 
128
261
  def handle_alias_method(node, context)
@@ -132,8 +265,47 @@ module Necropsy
132
265
  new_name, old_name = symbol_arguments(node)
133
266
  return false unless new_name && old_name
134
267
 
135
- record_alias_method(context, node.location, new_name, old_name)
136
- true
268
+ record_alias_method(context, node, new_name, old_name)
269
+ handled_call
270
+ end
271
+
272
+ def handle_method_removal(node, context)
273
+ return false unless %i[remove_method undef_method].include?(node.name)
274
+ return false unless context.owner
275
+
276
+ names = symbol_arguments(node)
277
+ if names.empty?
278
+ record_semantic_blocker(
279
+ :dynamic_method_removal,
280
+ node,
281
+ context,
282
+ "#{node.name} target is not statically bounded",
283
+ suggested_action: :make_method_name_literal,
284
+ force_global: true
285
+ )
286
+ return handled_call
287
+ end
288
+
289
+ names.each do |name|
290
+ semantic_blockers << Blocker.new(
291
+ kind: node.name,
292
+ scope_kind: :message,
293
+ scope_value: name,
294
+ source: 'ast_scanner',
295
+ reason: "#{context.owner} #{node.name}s #{name}; activation order is not closed",
296
+ suggested_action: :review_method_activation,
297
+ metadata: {
298
+ 'caller_domain' => context.test ? 'test' : 'runtime',
299
+ 'caller_id' => context.current_caller_id,
300
+ 'owner_scope' => [context.owner],
301
+ 'file' => context.relative_file,
302
+ 'line' => node.location.start_line,
303
+ 'message' => name,
304
+ 'reason_code' => node.name.to_s
305
+ }
306
+ )
307
+ end
308
+ handled_call
137
309
  end
138
310
 
139
311
  def visit_alias_method_node(node, context)
@@ -141,36 +313,46 @@ module Necropsy
141
313
 
142
314
  new_name = node.new_name.unescaped.to_s
143
315
  old_name = node.old_name.unescaped.to_s
144
- record_alias_method(context, node.location, new_name, old_name)
316
+ record_alias_method(context, node, new_name, old_name)
145
317
  end
146
318
 
147
- def record_alias_method(context, location, new_name, old_name)
319
+ def record_alias_method(context, source_node, new_name, old_name)
148
320
  kind = context.singleton_scope ? :singleton_method : :instance_method
149
321
  separator = kind == :singleton_method ? '.' : '#'
150
322
  id = "#{context.owner}#{separator}#{new_name}"
151
- nodes << Node.new(
152
- id: id,
323
+ physical_source = physical_alias_source(context, source_node, old_name, kind)
324
+ definition = add_definition(
325
+ symbol_id: id,
153
326
  kind: kind,
154
- file: context.relative_file,
155
- line: location.start_line,
156
- end_line: location.end_line,
327
+ source_node: source_node,
328
+ context: context,
157
329
  defined_via: :alias_method,
158
330
  owner: context.owner,
159
331
  name: new_name,
160
- test: context.test,
161
- visibility: context.visibility
332
+ visibility: physical_source&.visibility || context.visibility
162
333
  )
163
- call_sites << CallSite.new(
164
- caller_id: id,
334
+ metadata = { 'original_message' => old_name, 'alias_method' => true }
335
+ metadata['physical_target_definition_id'] = physical_source.graph_id if physical_source
336
+ add_scanned_call_site(
337
+ source_node: source_node,
338
+ context: context,
339
+ role: :alias_target,
340
+ caller_id: definition.graph_id,
165
341
  message: old_name,
166
342
  receiver_kind: :self,
167
343
  receiver_name: context.owner,
168
- file: context.relative_file,
169
- line: location.start_line,
170
- test: context.test,
171
344
  dynamic: false,
172
- metadata: { 'original_message' => old_name, 'alias_method' => true }
345
+ metadata: metadata
173
346
  )
174
347
  end
348
+
349
+ def physical_alias_source(context, source_node, old_name, kind)
350
+ return unless context.static_ancestry
351
+
352
+ nodes.select do |definition|
353
+ definition.kind == kind && definition.owner == context.owner && definition.name == old_name &&
354
+ definition.file == context.relative_file && definition.line <= source_node.location.start_line
355
+ end.max_by { |definition| [definition.line, definition.ordinal] }
356
+ end
175
357
  end
176
358
  end
@@ -2,6 +2,15 @@
2
2
 
3
3
  module Necropsy
4
4
  class AstScanner
5
+ RESPOND_TO_TRUTHY_LITERAL_TYPES = %i[
6
+ array_node float_node hash_node imaginary_node integer_node
7
+ interpolated_match_last_line_node interpolated_regular_expression_node
8
+ interpolated_string_node interpolated_symbol_node interpolated_x_string_node
9
+ keyword_hash_node match_last_line_node range_node rational_node
10
+ regular_expression_node source_encoding_node source_file_node source_line_node
11
+ string_node symbol_node true_node x_string_node
12
+ ].freeze
13
+
5
14
  private
6
15
 
7
16
  def callback_names(node)
@@ -25,11 +34,14 @@ module Necropsy
25
34
  end
26
35
 
27
36
  def record_delegate_target(caller_id, target, node, context)
28
- record_reference_call(caller_id, target.to_s.delete_prefix('@'), node, context, receiver_kind: :self)
37
+ record_reference_call(
38
+ caller_id, target.to_s.delete_prefix('@'), node, context,
39
+ role: :delegate_target, receiver_kind: :self
40
+ )
29
41
  end
30
42
 
31
43
  def record_delegated_message(caller_id, message, node, context)
32
- record_reference_call(caller_id, message, node, context, receiver_kind: :unknown)
44
+ record_reference_call(caller_id, message, node, context, role: :delegate_message, receiver_kind: :unknown)
33
45
  end
34
46
 
35
47
  def record_symbol_reference(node, context)
@@ -44,9 +56,11 @@ module Necropsy
44
56
  message,
45
57
  node,
46
58
  context,
59
+ role: :symbol_reference,
47
60
  receiver_kind: receiver[:kind],
48
61
  receiver_name: receiver[:name],
49
- candidates: receiver[:candidates]
62
+ candidates: receiver[:candidates],
63
+ metadata: symbol_reference_metadata(node)
50
64
  )
51
65
  end
52
66
 
@@ -60,22 +74,42 @@ module Necropsy
60
74
  block.expression.unescaped.to_s,
61
75
  node,
62
76
  context,
77
+ role: :symbol_to_proc,
63
78
  receiver_kind: :unknown
64
79
  )
65
80
  end
66
81
 
67
- def record_reference_call(caller_id, message, node, context, receiver_kind:, receiver_name: nil, candidates: [])
68
- call_sites << CallSite.new(
82
+ def record_reference_call(caller_id, message, node, context, role:, receiver_kind:, receiver_name: nil,
83
+ candidates: [], metadata: {})
84
+ add_scanned_call_site(
85
+ source_node: node,
86
+ context: context,
87
+ role: role,
69
88
  caller_id: caller_id,
70
89
  message: message.to_s,
71
90
  receiver_kind: receiver_kind,
72
91
  receiver_name: receiver_name,
73
- file: context.relative_file,
74
- line: node.location.start_line,
75
- test: context.test,
76
92
  dynamic: false,
77
- metadata: { 'symbol_reference' => true, 'receiver_candidates' => Array(candidates) }
93
+ metadata: { 'symbol_reference' => true, 'receiver_candidates' => Array(candidates) }.merge(metadata)
78
94
  )
79
95
  end
96
+
97
+ def symbol_reference_metadata(node)
98
+ metadata = { 'original_message' => node.name.to_s }
99
+ return metadata unless node.name == :respond_to?
100
+
101
+ metadata['include_private'] = respond_to_include_private(arguments(node))
102
+ metadata
103
+ end
104
+
105
+ def respond_to_include_private(call_arguments)
106
+ return false unless call_arguments.length > 1
107
+
108
+ argument = call_arguments[1]
109
+ return false if %i[false_node nil_node].include?(argument.type)
110
+ return true if RESPOND_TO_TRUTHY_LITERAL_TYPES.include?(argument.type)
111
+
112
+ 'unknown'
113
+ end
80
114
  end
81
115
  end
@@ -5,20 +5,20 @@ module Necropsy
5
5
  private
6
6
 
7
7
  def classify_receiver(receiver, context)
8
- return { kind: :implicit, name: nil } unless receiver
9
- return { kind: :self, name: context.owner } if receiver.is_a?(Prism::SelfNode)
8
+ return { kind: :implicit, name: context.owner, candidates: [context.owner].compact } unless receiver
9
+ return { kind: :self, name: context.owner, candidates: [context.owner].compact } if receiver.is_a?(Prism::SelfNode)
10
10
 
11
11
  constant = constant_name(receiver)
12
12
  if constant
13
- candidates = constant_candidates(constant, context.namespace)
14
- return { kind: :constant, name: candidates.first, candidates: candidates }
13
+ candidates = constant_candidates(constant, context.lexical_nesting)
14
+ return { kind: :constant, name: resolve_candidate_group(candidates), candidates: candidates }
15
15
  end
16
16
 
17
17
  if receiver.is_a?(Prism::CallNode) && receiver.name == :new
18
18
  receiver_constant = constant_name(receiver.receiver)
19
19
  if receiver_constant
20
- candidates = constant_candidates(receiver_constant, context.namespace)
21
- return { kind: :instance, name: candidates.first, candidates: candidates }
20
+ candidates = constant_candidates(receiver_constant, context.lexical_nesting)
21
+ return { kind: :instance, name: resolve_candidate_group(candidates), candidates: candidates }
22
22
  end
23
23
  end
24
24
 
@@ -33,6 +33,17 @@ module Necropsy
33
33
  arguments(node).find { |argument| argument.is_a?(Prism::StringNode) }&.unescaped
34
34
  end
35
35
 
36
+ def literal_argument(node, index:)
37
+ argument = arguments(node)[index]
38
+ return unless literal_name?(argument)
39
+
40
+ argument.unescaped.to_s
41
+ end
42
+
43
+ def literal_name?(node)
44
+ node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
45
+ end
46
+
36
47
  def symbol_arguments(node)
37
48
  arguments(node).filter_map do |arg|
38
49
  next unless arg.is_a?(Prism::SymbolNode)
@@ -71,6 +82,53 @@ module Necropsy
71
82
  end
72
83
  end
73
84
 
85
+ def method_signature(parameters)
86
+ return { 'complete' => false } unless parameters
87
+
88
+ forwarding = parameters.child_nodes.compact.any? { |parameter| parameter.type == :forwarding_parameter_node }
89
+ keywords = Array(parameters.keywords)
90
+ keyword_rest = parameters.keyword_rest
91
+ no_keywords = !keyword_rest.nil? && keyword_rest.type == :no_keywords_parameter_node
92
+ accepts_keywords = keywords.any? || (!keyword_rest.nil? && !no_keywords)
93
+ {
94
+ 'complete' => !forwarding,
95
+ 'minimum_positionals' => Array(parameters.requireds).length + Array(parameters.posts).length,
96
+ 'maximum_positionals' => if parameters.rest
97
+ nil
98
+ else
99
+ Array(parameters.requireds).length +
100
+ Array(parameters.optionals).length + Array(parameters.posts).length
101
+ end,
102
+ 'required_keywords' => keywords.select do |parameter|
103
+ parameter.type == :required_keyword_parameter_node
104
+ end.map { |parameter| parameter.name.to_s }.sort,
105
+ 'accepted_keywords' => keywords.map { |parameter| parameter.name.to_s }.sort,
106
+ 'accepts_keywords' => accepts_keywords,
107
+ 'no_keywords' => no_keywords,
108
+ 'keyword_rest' => !keyword_rest.nil? && !no_keywords
109
+ }
110
+ end
111
+
112
+ def call_arguments(node, offset: 0)
113
+ values = Array(node.arguments&.arguments).drop(offset)
114
+ keyword_hash = values.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
115
+ positional = values.grep_v(Prism::KeywordHashNode)
116
+ keyword_elements = Array(keyword_hash&.elements)
117
+ {
118
+ 'complete' => positional.none? do |argument|
119
+ %i[splat_node forwarding_arguments_node].include?(argument.type)
120
+ end && keyword_elements.none? { |element| element.type == :assoc_splat_node },
121
+ 'positional_count' => positional.length,
122
+ 'keywords' => keyword_elements.filter_map do |element|
123
+ literal_value(element.key).to_s if element.is_a?(Prism::AssocNode)
124
+ end.sort
125
+ }
126
+ end
127
+
128
+ def incomplete_arguments
129
+ { 'complete' => false, 'positional_count' => 0, 'keywords' => [] }
130
+ end
131
+
74
132
  def constant_name(node)
75
133
  return nil unless node
76
134
 
@@ -100,8 +158,34 @@ module Necropsy
100
158
  data[:line] = node.location.start_line
101
159
  return unless node.respond_to?(:superclass)
102
160
 
161
+ unless node.superclass
162
+ data[:superclass_candidates] = implicit_superclass_candidates(namespace)
163
+ return
164
+ end
165
+
103
166
  superclass = constant_name(node.superclass)
104
- data[:superclass_candidates] = constant_candidates(superclass, context.namespace) if superclass
167
+ if superclass
168
+ data[:superclass_candidates] = constant_candidates(superclass, context.lexical_nesting)
169
+ return
170
+ end
171
+
172
+ data[:superclass_candidates] = []
173
+ data[:dynamic] = true
174
+ record_semantic_blocker(
175
+ :dynamic_ancestry,
176
+ node.superclass,
177
+ context,
178
+ "#{namespace} has a runtime-computed superclass",
179
+ suggested_action: :make_superclass_static,
180
+ scope_owner: namespace
181
+ )
182
+ end
183
+
184
+ def implicit_superclass_candidates(namespace)
185
+ return [] if namespace == 'BasicObject'
186
+ return ['BasicObject'] if namespace == 'Object'
187
+
188
+ ['Object']
105
189
  end
106
190
 
107
191
  def class_record(namespace)
@@ -115,6 +199,8 @@ module Necropsy
115
199
  includes: [],
116
200
  prepends: [],
117
201
  extends: [],
202
+ singleton_includes: [],
203
+ singleton_prepends: [],
118
204
  dynamic: false
119
205
  }
120
206
  end
@@ -132,6 +218,8 @@ module Necropsy
132
218
  includes: resolve_candidate_groups(data.fetch(:includes)),
133
219
  prepends: resolve_candidate_groups(data.fetch(:prepends)),
134
220
  extends: resolve_candidate_groups(data.fetch(:extends)),
221
+ singleton_includes: resolve_candidate_groups(data.fetch(:singleton_includes)),
222
+ singleton_prepends: resolve_candidate_groups(data.fetch(:singleton_prepends)),
135
223
  dynamic: data.fetch(:dynamic)
136
224
  )
137
225
  end
@@ -145,18 +233,16 @@ module Necropsy
145
233
  group.find { |candidate| class_data.key?(candidate) } || group.first
146
234
  end
147
235
 
148
- def constant_candidates(name, namespace)
236
+ def constant_candidates(name, lexical_nesting)
149
237
  return [name.delete_prefix('::')] if name.start_with?('::')
150
- return [name] if namespace.nil? || namespace.empty?
238
+
239
+ nesting = Array(lexical_nesting).compact
240
+ return [name] if nesting.empty?
151
241
 
152
242
  head, *rest = name.split('::')
153
243
  suffix = rest.empty? ? '' : "::#{rest.join('::')}"
154
- parts = namespace.split('::')
155
- candidates = []
156
- parts.length.downto(1) do |length|
157
- candidates << "#{parts.first(length).join('::')}::#{head}#{suffix}"
158
- end
159
- inherited_namespaces(namespace).each do |ancestor|
244
+ candidates = nesting.map { |namespace| "#{namespace}::#{head}#{suffix}" }
245
+ inherited_namespaces(nesting.first).each do |ancestor|
160
246
  candidates << "#{ancestor}::#{head}#{suffix}"
161
247
  end
162
248
  candidates << name