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
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def add_definition(symbol_id:, kind:, source_node:, context:, defined_via:, owner:, name:,
8
+ visibility: context.visibility)
9
+ body_digest = DefinitionIdentity.body_digest(source_node)
10
+ ordinal_key = [kind, symbol_id, context.relative_file, body_digest]
11
+ ordinal = definition_ordinals[ordinal_key] += 1
12
+ definition_id = if defined_via == :file
13
+ DefinitionIdentity.file_root_id(relative_path: context.relative_file)
14
+ else
15
+ DefinitionIdentity.definition_id(
16
+ kind: kind,
17
+ symbol_id: symbol_id,
18
+ relative_path: context.relative_file,
19
+ body_digest: body_digest,
20
+ ordinal: ordinal
21
+ )
22
+ end
23
+ definition = Node.new(
24
+ id: symbol_id,
25
+ symbol_id: symbol_id,
26
+ definition_id: definition_id,
27
+ body_digest: body_digest,
28
+ ordinal: ordinal,
29
+ kind: kind,
30
+ file: context.relative_file,
31
+ line: source_node.location.start_line,
32
+ end_line: source_node.location.end_line,
33
+ defined_via: defined_via,
34
+ owner: owner,
35
+ name: name.to_s,
36
+ test: context.test,
37
+ visibility: visibility
38
+ )
39
+ nodes << definition
40
+ definition
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,532 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def handle_module_relation(node, context)
8
+ return unless MODULE_RELATION_MACROS.include?(node.name)
9
+ return unless context.owner
10
+
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
44
+
45
+ constants = arguments(node).filter_map { |argument| constant_name(argument) }
46
+ return if constants.empty?
47
+
48
+ data = class_record(context.owner)
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"
67
+ end
68
+
69
+ def handle_rails_callback(node, context)
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
100
+
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)
105
+ callback_names(node).each do |method_name|
106
+ entrypoint_hints << EntryPoint.new(node_id: "#{context.owner}##{method_name}", reason: :callback_registered)
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
124
+ elsif node.name == :validates
125
+ custom_validator_names(node).each do |validator|
126
+ constant_candidates("#{validator}Validator", context.lexical_nesting).each do |owner|
127
+ entrypoint_hints << EntryPoint.new(node_id: "#{owner}#validate_each", reason: :callback_registered)
128
+ end
129
+ end
130
+ elsif node.name == :validates_with
131
+ arguments(node).filter_map { |argument| constant_name(argument) }.each do |validator|
132
+ constant_candidates(validator, context.lexical_nesting).each do |owner|
133
+ entrypoint_hints << EntryPoint.new(node_id: "#{owner}#validate", reason: :callback_registered)
134
+ end
135
+ end
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
408
+ end
409
+
410
+ def handle_attr_macro(node, context)
411
+ return false unless ATTR_MACROS.include?(node.name)
412
+ return false unless context.owner
413
+
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|
426
+ kind, separator = method_kind_and_separator(context)
427
+ add_definition(
428
+ symbol_id: "#{context.owner}#{separator}#{name}",
429
+ kind: kind,
430
+ source_node: node,
431
+ context: context,
432
+ defined_via: node.name,
433
+ owner: context.owner,
434
+ name: name,
435
+ visibility: context.visibility
436
+ )
437
+ next unless %i[attr_writer attr_accessor].include?(node.name)
438
+
439
+ add_definition(
440
+ symbol_id: "#{context.owner}#{separator}#{name}=",
441
+ kind: kind,
442
+ source_node: node,
443
+ context: context,
444
+ defined_via: node.name,
445
+ owner: context.owner,
446
+ name: "#{name}=",
447
+ visibility: context.visibility
448
+ )
449
+ end
450
+ handled_call
451
+ end
452
+
453
+ def handle_delegate(node, context)
454
+ return false unless node.name == :delegate
455
+ return false unless context.owner
456
+
457
+ target = keyword_value(node, 'to')
458
+ prefix = keyword_value(node, 'prefix')
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|
472
+ generated_name = delegated_method_name(name, target, prefix)
473
+ kind, separator = method_kind_and_separator(context)
474
+ id = "#{context.owner}#{separator}#{generated_name}"
475
+ definition = add_definition(
476
+ symbol_id: id,
477
+ kind: kind,
478
+ source_node: node,
479
+ context: context,
480
+ defined_via: :delegate,
481
+ owner: context.owner,
482
+ name: generated_name,
483
+ visibility: context.visibility
484
+ )
485
+ record_delegate_target(definition.graph_id, target, node, context) if target
486
+ record_delegated_message(definition.graph_id, name, node, context)
487
+ end
488
+ handled_call
489
+ end
490
+
491
+ def handle_forwardable(node, context)
492
+ return false unless %i[def_delegator def_delegators].include?(node.name)
493
+ return false unless context.owner
494
+
495
+ symbols = symbol_arguments(node)
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
506
+
507
+ target = symbols.shift
508
+ definitions = if node.name == :def_delegator
509
+ [[symbols[1] || symbols[0], symbols[0]]]
510
+ else
511
+ symbols.map { |name| [name, name] }
512
+ end
513
+ definitions.each do |generated_name, delegated_name|
514
+ kind, separator = method_kind_and_separator(context)
515
+ id = "#{context.owner}#{separator}#{generated_name}"
516
+ definition = add_definition(
517
+ symbol_id: id,
518
+ kind: kind,
519
+ source_node: node,
520
+ context: context,
521
+ defined_via: node.name,
522
+ owner: context.owner,
523
+ name: generated_name,
524
+ visibility: context.visibility
525
+ )
526
+ record_delegate_target(definition.graph_id, target, node, context)
527
+ record_delegated_message(definition.graph_id, delegated_name, node, context)
528
+ end
529
+ handled_call
530
+ end
531
+ end
532
+ end