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,73 +8,446 @@ module Necropsy
8
8
  return unless MODULE_RELATION_MACROS.include?(node.name)
9
9
  return unless context.owner
10
10
 
11
- class_record(context.owner)[:extends] << [context.owner] if node.name == :extend && arguments(node).any?(Prism::SelfNode)
11
+ unless static_module_relation?(node, context)
12
+ constants = arguments(node).filter_map { |argument| constant_name(argument) }
13
+ class_record(context.owner)[:dynamic] = true
14
+ record_semantic_blocker(
15
+ :dynamic_ancestry,
16
+ node,
17
+ context,
18
+ "#{node.name} is not an unconditional load-time ancestry mutation",
19
+ suggested_action: :make_ancestry_static,
20
+ scope_owner: [context.owner, *constants].uniq,
21
+ force_global: %w[BasicObject Kernel Object].include?(context.owner)
22
+ )
23
+ return
24
+ end
25
+
26
+ if node.name == :extend && arguments(node).any?(Prism::SelfNode)
27
+ class_record(context.owner)[:extends] << [context.owner]
28
+ class_record(context.owner)[:singleton_includes] << [context.owner]
29
+ end
30
+
31
+ dynamic_arguments = arguments(node).reject do |argument|
32
+ constant_name(argument) || (node.name == :extend && argument.is_a?(Prism::SelfNode))
33
+ end
34
+ unless dynamic_arguments.empty?
35
+ class_record(context.owner)[:dynamic] = true
36
+ record_semantic_blocker(
37
+ :dynamic_ancestry,
38
+ node,
39
+ context,
40
+ "#{node.name} has a runtime-computed module and lookup cannot be closed",
41
+ suggested_action: :make_ancestry_static
42
+ )
43
+ end
12
44
 
13
45
  constants = arguments(node).filter_map { |argument| constant_name(argument) }
14
46
  return if constants.empty?
15
47
 
16
48
  data = class_record(context.owner)
17
- key = :"#{node.name}s"
18
- constants.each { |constant| data[key] << constant_candidates(constant, context.namespace) }
49
+ key = module_relation_key(node.name, context)
50
+ constants.reverse_each do |constant|
51
+ candidates = constant_candidates(constant, context.lexical_nesting)
52
+ data[key] << candidates
53
+ data[:extends] << candidates if key == :singleton_includes && node.name == :extend
54
+ end
55
+ end
56
+
57
+ def static_module_relation?(node, context)
58
+ context.static_ancestry && node.receiver.nil?
59
+ end
60
+
61
+ def module_relation_key(name, context)
62
+ return :singleton_includes if context.singleton_scope && name == :include
63
+ return :singleton_prepends if context.singleton_scope && name == :prepend
64
+ return :singleton_includes if name == :extend
65
+
66
+ :"#{name}s"
19
67
  end
20
68
 
21
69
  def handle_rails_callback(node, context)
22
- return unless context.owner
23
- return if context.test
70
+ return false unless context.owner
71
+ return false if context.test
72
+
73
+ if runtime_block_macro_enabled?(node)
74
+ callback = keyword_value(node, 'callback')
75
+ if callback.is_a?(String)
76
+ entrypoint_hints << EntryPoint.new(
77
+ node_id: "#{context.owner}##{callback}", reason: :callback_registered,
78
+ evidence: { 'type' => 'runtime_callback', 'macro' => node.name.to_s }
79
+ )
80
+ elsif keyword_keys(node).include?('callback')
81
+ record_semantic_blocker(
82
+ :framework_runtime_callback,
83
+ node,
84
+ context,
85
+ "#{node.name} callback is not statically bounded",
86
+ suggested_action: :review_callback
87
+ )
88
+ end
89
+ if node.block
90
+ synthetic = visit_synthetic_body(node.block.body, context, :runtime_callback_block)
91
+ entrypoint_hints << EntryPoint.new(
92
+ node_id: synthetic.graph_id,
93
+ reason: :callback_registered,
94
+ evidence: { 'type' => 'runtime_callback_block', 'macro' => node.name.to_s }
95
+ )
96
+ return true
97
+ end
98
+ return false
99
+ end
24
100
 
25
101
  if RAILS_CALLBACK_MACROS.include?(node.name)
102
+ return false if callback_disabled?(node)
103
+
104
+ record_callback_condition_blocker(node, context) if callback_condition_unknown?(node)
26
105
  callback_names(node).each do |method_name|
27
106
  entrypoint_hints << EntryPoint.new(node_id: "#{context.owner}##{method_name}", reason: :callback_registered)
28
107
  end
108
+ callback_condition_names(node).each do |method_name|
109
+ entrypoint_hints << EntryPoint.new(
110
+ node_id: "#{context.owner}##{method_name}",
111
+ reason: :callback_registered,
112
+ evidence: { 'type' => 'callback_condition', 'macro' => node.name.to_s }
113
+ )
114
+ end
115
+ if node.block
116
+ synthetic = visit_synthetic_body(node.block.body, context, :callback_block)
117
+ entrypoint_hints << EntryPoint.new(
118
+ node_id: synthetic.graph_id,
119
+ reason: :callback_registered,
120
+ evidence: { 'type' => 'callback_block', 'macro' => node.name.to_s }
121
+ )
122
+ return true
123
+ end
29
124
  elsif node.name == :validates
30
125
  custom_validator_names(node).each do |validator|
31
- constant_candidates("#{validator}Validator", context.namespace).each do |owner|
126
+ constant_candidates("#{validator}Validator", context.lexical_nesting).each do |owner|
32
127
  entrypoint_hints << EntryPoint.new(node_id: "#{owner}#validate_each", reason: :callback_registered)
33
128
  end
34
129
  end
35
130
  elsif node.name == :validates_with
36
131
  arguments(node).filter_map { |argument| constant_name(argument) }.each do |validator|
37
- constant_candidates(validator, context.namespace).each do |owner|
132
+ constant_candidates(validator, context.lexical_nesting).each do |owner|
38
133
  entrypoint_hints << EntryPoint.new(node_id: "#{owner}#validate", reason: :callback_registered)
39
134
  end
40
135
  end
41
136
  end
137
+ false
138
+ end
139
+
140
+ def runtime_block_macro_enabled?(node)
141
+ return framework_enabled?('rails') if RAILS_RUNTIME_BLOCK_MACROS.include?(node.name)
142
+ return framework_enabled?('sidekiq') if SIDEKIQ_RUNTIME_BLOCK_MACROS.include?(node.name)
143
+
144
+ false
145
+ end
146
+
147
+ def handle_graphql_field(node, context)
148
+ return unless node.name == :field && context.owner && !context.test
149
+ return unless framework_enabled?('graphql')
150
+ return unless graphql_owner?(context.owner)
151
+
152
+ method_name = if keyword_keys(node).include?('method')
153
+ keyword_value(node, 'method')
154
+ else
155
+ literal_argument(node, index: 0)
156
+ end
157
+ if method_name.is_a?(String)
158
+ entrypoint_hints << EntryPoint.new(
159
+ node_id: "#{context.owner}##{method_name}",
160
+ reason: :graphql_field,
161
+ evidence: { 'type' => 'graphql_field', 'field' => literal_argument(node, index: 0) }
162
+ )
163
+ else
164
+ record_semantic_blocker(
165
+ :graphql_field,
166
+ node,
167
+ context,
168
+ 'GraphQL field resolver method is not statically bounded',
169
+ suggested_action: :review_graphql_field
170
+ )
171
+ end
172
+ end
173
+
174
+ def graphql_owner?(owner)
175
+ owner.end_with?('Type', 'Resolver', 'Mutation', 'Subscription') ||
176
+ convention_ancestors(owner).any? { |ancestor| ancestor.start_with?('GraphQL::') }
177
+ end
178
+
179
+ def framework_enabled?(name)
180
+ project.config.frameworks(reference_files: project.reference_files).include?(name)
181
+ rescue SystemCallError, EncodingError
182
+ project.config.frameworks.include?(name)
183
+ end
184
+
185
+ def callback_condition_names(node)
186
+ %w[if unless].filter_map do |key|
187
+ value = keyword_value(node, key)
188
+ value if value.is_a?(String)
189
+ end.uniq
190
+ end
191
+
192
+ def callback_disabled?(node)
193
+ (keyword_keys(node).include?('if') && keyword_value(node, 'if') == false) ||
194
+ (keyword_keys(node).include?('unless') && keyword_value(node, 'unless') == true)
195
+ end
196
+
197
+ def callback_condition_unknown?(node)
198
+ keyword_keys(node).intersect?(%w[if unless]) &&
199
+ keyword_value(node, 'if').nil? && keyword_value(node, 'unless').nil?
200
+ end
201
+
202
+ def record_callback_condition_blocker(node, context)
203
+ record_semantic_blocker(
204
+ :rails_callback_condition,
205
+ node,
206
+ context,
207
+ 'callback condition is not statically known',
208
+ suggested_action: :review_callback_condition
209
+ )
210
+ end
211
+
212
+ def handle_generated_rails_methods(node, context)
213
+ return false unless RAILS_GENERATED_METHOD_MACROS.include?(node.name)
214
+ return false unless context.owner && !context.test && rails_framework_enabled?
215
+
216
+ return handle_rails_enum(node, context) if node.name == :enum
217
+ return handle_rails_scope(node, context) if node.name == :scope
218
+
219
+ names = literal_positional_names(node)
220
+ if names.empty?
221
+ record_semantic_blocker(
222
+ :rails_generated_methods,
223
+ node,
224
+ context,
225
+ "#{node.name} has dynamic generated method names",
226
+ suggested_action: :review_generated_methods
227
+ )
228
+ return handled_call
229
+ end
230
+
231
+ case node.name
232
+ when :store_accessor
233
+ add_generated_methods(context, names.drop(1).flat_map { |name| [name, "#{name}="] }, node)
234
+ when :store
235
+ accessors = array_keyword_values(node, 'accessors')
236
+ add_generated_methods(context, [names.first, "#{names.first}=", *accessors.flat_map { |name| [name, "#{name}="] }], node)
237
+ when :attribute
238
+ add_generated_methods(context, [names.first, "#{names.first}="], node)
239
+ when :class_attribute, :mattr_reader, :mattr_writer, :mattr_accessor,
240
+ :cattr_reader, :cattr_writer, :cattr_accessor
241
+ reader = !node.name.to_s.end_with?('_writer')
242
+ writer = !node.name.to_s.end_with?('_reader')
243
+ generated = names.flat_map { |name| [reader && name, writer && "#{name}="].compact }
244
+ add_generated_methods(context, generated, node)
245
+ add_generated_methods(context, generated, node, kind: :singleton_method)
246
+ when :belongs_to, :has_one
247
+ names.each do |name|
248
+ add_generated_methods(
249
+ context,
250
+ [name, "#{name}=", "build_#{name}", "create_#{name}", "create_#{name}!", "reload_#{name}", "reset_#{name}"],
251
+ node
252
+ )
253
+ end
254
+ when :has_many
255
+ names.each { |name| add_generated_methods(context, [name, "#{name}="], node) }
256
+ end
257
+ handled_call
258
+ end
259
+
260
+ def handle_rails_enum(node, context)
261
+ declarations = enum_declarations(node)
262
+ if declarations.empty?
263
+ record_semantic_blocker(
264
+ :rails_generated_methods,
265
+ node,
266
+ context,
267
+ 'enum has a dynamic name or value set',
268
+ suggested_action: :review_generated_methods
269
+ )
270
+ return handled_call
271
+ end
272
+
273
+ declarations.each do |enum_name, values|
274
+ add_generated_methods(context, [enum_name, "#{enum_name}="], node)
275
+ next if keyword_value(node, 'instance_methods') == false
276
+
277
+ decorated = values.map { |value| decorate_enum_value(enum_name, value, node) }
278
+ add_generated_methods(context, decorated.flat_map { |value| ["#{value}?", "#{value}!"] }, node)
279
+ next if keyword_value(node, 'scopes') == false
280
+
281
+ scopes = decorated.flat_map { |value| [value, "not_#{value}"] }
282
+ add_generated_methods(context, scopes, node, kind: :singleton_method)
283
+ end
284
+ handled_call
285
+ end
286
+
287
+ def enum_declarations(node)
288
+ positional = arguments(node).grep_v(Prism::KeywordHashNode)
289
+ if literal_name?(positional.first)
290
+ values = literal_hash_keys(positional[1])
291
+ values = literal_array_values(positional[1]) if values.empty?
292
+ if values.empty?
293
+ options = %w[prefix suffix _prefix _suffix scopes instance_methods validate]
294
+ values = keyword_keys(node) - options
295
+ end
296
+ return [[literal_value(positional.first), values]] unless values.empty?
297
+ end
298
+
299
+ keyword_hash = arguments(node).find { |argument| argument.is_a?(Prism::KeywordHashNode) }
300
+ Array(keyword_hash&.elements).filter_map do |element|
301
+ next unless element.is_a?(Prism::AssocNode)
302
+
303
+ enum_name = literal_value(element.key)
304
+ values = literal_hash_keys(element.value)
305
+ values = literal_array_values(element.value) if values.empty?
306
+ [enum_name, values] if enum_name && !values.empty?
307
+ end
308
+ end
309
+
310
+ def decorate_enum_value(enum_name, value, node)
311
+ prefix = keyword_value(node, 'prefix') || keyword_value(node, '_prefix')
312
+ suffix = keyword_value(node, 'suffix') || keyword_value(node, '_suffix')
313
+ value = "#{prefix == true ? enum_name : prefix}_#{value}" if prefix
314
+ value = "#{value}_#{suffix == true ? enum_name : suffix}" if suffix
315
+ value
316
+ end
317
+
318
+ def handle_rails_scope(node, context)
319
+ name = literal_argument(node, index: 0)
320
+ unless name
321
+ record_semantic_blocker(
322
+ :rails_generated_methods,
323
+ node,
324
+ context,
325
+ 'scope has a runtime-computed name',
326
+ suggested_action: :review_generated_methods
327
+ )
328
+ return handled_call
329
+ end
330
+
331
+ definition = add_generated_methods(context, [name], node, kind: :singleton_method).first
332
+ callable = arguments(node)[1]
333
+ body = callable.body if callable.is_a?(Prism::LambdaNode)
334
+ body ||= node.block&.body
335
+ if body
336
+ scope_context = context.dup
337
+ scope_context.current_caller_id = definition.graph_id
338
+ scope_context.current_method_name = name
339
+ scope_context.current_kind = :singleton_method
340
+ scope_context.static_ancestry = false
341
+ scope_context.flow_result = nil
342
+ visit(body, scope_context)
343
+ return handled_call(arguments: false)
344
+ end
345
+
346
+ record_semantic_blocker(
347
+ :rails_generated_methods,
348
+ node,
349
+ context,
350
+ 'scope body is not a literal block or lambda',
351
+ suggested_action: :review_generated_methods
352
+ )
353
+ handled_call
354
+ end
355
+
356
+ def literal_positional_names(node)
357
+ arguments(node).grep_v(Prism::KeywordHashNode).filter_map do |argument|
358
+ literal_value(argument) if literal_name?(argument)
359
+ end
360
+ end
361
+
362
+ def array_keyword_values(node, key)
363
+ hash = arguments(node).find { |argument| argument.is_a?(Prism::KeywordHashNode) }
364
+ pair = Array(hash&.elements).find do |element|
365
+ element.is_a?(Prism::AssocNode) && literal_value(element.key).to_s == key
366
+ end
367
+ literal_array_values(pair&.value)
368
+ end
369
+
370
+ def literal_hash_keys(node)
371
+ return [] unless node.is_a?(Prism::HashNode) || node.is_a?(Prism::KeywordHashNode)
372
+
373
+ Array(node.elements).filter_map do |element|
374
+ literal_value(element.key) if element.is_a?(Prism::AssocNode) && literal_name?(element.key)
375
+ end
376
+ end
377
+
378
+ def literal_array_values(node)
379
+ return [] unless node.is_a?(Prism::ArrayNode) && !node.contains_splat?
380
+
381
+ node.elements.filter_map { |element| literal_value(element) if literal_name?(element) }
382
+ end
383
+
384
+ def rails_framework_enabled?
385
+ project.config.frameworks.include?('rails') || project.config.rails_enabled?(reference_files: project.reference_files)
386
+ rescue SystemCallError, EncodingError
387
+ project.config.frameworks.include?('rails')
388
+ end
389
+
390
+ def add_generated_methods(context, names, source_node, kind: nil)
391
+ names.uniq.map do |name|
392
+ generated_kind, separator = if kind
393
+ [kind, kind == :singleton_method ? '.' : '#']
394
+ else
395
+ method_kind_and_separator(context)
396
+ end
397
+ add_definition(
398
+ symbol_id: "#{context.owner}#{separator}#{name}",
399
+ kind: generated_kind,
400
+ source_node: source_node,
401
+ context: context,
402
+ defined_via: :"rails_#{source_node.name}",
403
+ owner: context.owner,
404
+ name: name,
405
+ visibility: :public
406
+ )
407
+ end
42
408
  end
43
409
 
44
410
  def handle_attr_macro(node, context)
45
411
  return false unless ATTR_MACROS.include?(node.name)
46
412
  return false unless context.owner
47
413
 
48
- symbol_arguments(node).each do |name|
414
+ names = arguments(node).filter_map { |argument| literal_value(argument) if literal_name?(argument) }
415
+ if names.length != arguments(node).length
416
+ record_semantic_blocker(
417
+ :dynamic_generated_methods,
418
+ node,
419
+ context,
420
+ "#{node.name} has a runtime-computed method name",
421
+ suggested_action: :make_method_name_literal
422
+ )
423
+ end
424
+
425
+ names.each do |name|
49
426
  kind, separator = method_kind_and_separator(context)
50
- nodes << Node.new(
51
- id: "#{context.owner}#{separator}#{name}",
427
+ add_definition(
428
+ symbol_id: "#{context.owner}#{separator}#{name}",
52
429
  kind: kind,
53
- file: context.relative_file,
54
- line: node.location.start_line,
55
- end_line: node.location.end_line,
430
+ source_node: node,
431
+ context: context,
56
432
  defined_via: node.name,
57
433
  owner: context.owner,
58
434
  name: name,
59
- test: context.test,
60
435
  visibility: context.visibility
61
436
  )
62
437
  next unless %i[attr_writer attr_accessor].include?(node.name)
63
438
 
64
- nodes << Node.new(
65
- id: "#{context.owner}#{separator}#{name}=",
439
+ add_definition(
440
+ symbol_id: "#{context.owner}#{separator}#{name}=",
66
441
  kind: kind,
67
- file: context.relative_file,
68
- line: node.location.start_line,
69
- end_line: node.location.end_line,
442
+ source_node: node,
443
+ context: context,
70
444
  defined_via: node.name,
71
445
  owner: context.owner,
72
446
  name: "#{name}=",
73
- test: context.test,
74
447
  visibility: context.visibility
75
448
  )
76
449
  end
77
- true
450
+ handled_call
78
451
  end
79
452
 
80
453
  def handle_delegate(node, context)
@@ -83,26 +456,36 @@ module Necropsy
83
456
 
84
457
  target = keyword_value(node, 'to')
85
458
  prefix = keyword_value(node, 'prefix')
86
- symbol_arguments(node).each do |name|
459
+ positional = arguments(node).grep_v(Prism::KeywordHashNode)
460
+ names = positional.filter_map { |argument| literal_value(argument) if literal_name?(argument) }
461
+ if names.length != positional.length || target.nil?
462
+ record_semantic_blocker(
463
+ :dynamic_generated_methods,
464
+ node,
465
+ context,
466
+ 'delegate name or target is not statically bounded',
467
+ suggested_action: :make_delegate_literal
468
+ )
469
+ end
470
+
471
+ names.each do |name|
87
472
  generated_name = delegated_method_name(name, target, prefix)
88
473
  kind, separator = method_kind_and_separator(context)
89
474
  id = "#{context.owner}#{separator}#{generated_name}"
90
- nodes << Node.new(
91
- id: id,
475
+ definition = add_definition(
476
+ symbol_id: id,
92
477
  kind: kind,
93
- file: context.relative_file,
94
- line: node.location.start_line,
95
- end_line: node.location.end_line,
478
+ source_node: node,
479
+ context: context,
96
480
  defined_via: :delegate,
97
481
  owner: context.owner,
98
482
  name: generated_name,
99
- test: context.test,
100
483
  visibility: context.visibility
101
484
  )
102
- record_delegate_target(id, target, node, context) if target
103
- record_delegated_message(id, name, node, context)
485
+ record_delegate_target(definition.graph_id, target, node, context) if target
486
+ record_delegated_message(definition.graph_id, name, node, context)
104
487
  end
105
- true
488
+ handled_call
106
489
  end
107
490
 
108
491
  def handle_forwardable(node, context)
@@ -110,7 +493,16 @@ module Necropsy
110
493
  return false unless context.owner
111
494
 
112
495
  symbols = symbol_arguments(node)
113
- return false if symbols.length < 2
496
+ if symbols.length < 2 || symbols.length != arguments(node).length
497
+ record_semantic_blocker(
498
+ :dynamic_generated_methods,
499
+ node,
500
+ context,
501
+ "#{node.name} has runtime-computed delegation names",
502
+ suggested_action: :make_delegate_literal
503
+ )
504
+ return handled_call
505
+ end
114
506
 
115
507
  target = symbols.shift
116
508
  definitions = if node.name == :def_delegator
@@ -121,22 +513,20 @@ module Necropsy
121
513
  definitions.each do |generated_name, delegated_name|
122
514
  kind, separator = method_kind_and_separator(context)
123
515
  id = "#{context.owner}#{separator}#{generated_name}"
124
- nodes << Node.new(
125
- id: id,
516
+ definition = add_definition(
517
+ symbol_id: id,
126
518
  kind: kind,
127
- file: context.relative_file,
128
- line: node.location.start_line,
129
- end_line: node.location.end_line,
519
+ source_node: node,
520
+ context: context,
130
521
  defined_via: node.name,
131
522
  owner: context.owner,
132
523
  name: generated_name,
133
- test: context.test,
134
524
  visibility: context.visibility
135
525
  )
136
- record_delegate_target(id, target, node, context)
137
- record_delegated_message(id, delegated_name, node, context)
526
+ record_delegate_target(definition.graph_id, target, node, context)
527
+ record_delegated_message(definition.graph_id, delegated_name, node, context)
138
528
  end
139
- true
529
+ handled_call
140
530
  end
141
531
  end
142
532
  end