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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def classify_receiver(receiver, context)
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
+
11
+ constant = constant_name(receiver)
12
+ if constant
13
+ candidates = constant_candidates(constant, context.lexical_nesting)
14
+ return { kind: :constant, name: resolve_candidate_group(candidates), candidates: candidates }
15
+ end
16
+
17
+ if receiver.is_a?(Prism::CallNode) && receiver.name == :new
18
+ receiver_constant = constant_name(receiver.receiver)
19
+ if receiver_constant
20
+ candidates = constant_candidates(receiver_constant, context.lexical_nesting)
21
+ return { kind: :instance, name: resolve_candidate_group(candidates), candidates: candidates }
22
+ end
23
+ end
24
+
25
+ { kind: :unknown, name: nil, candidates: [] }
26
+ end
27
+
28
+ def first_symbol_argument(node)
29
+ symbol_arguments(node).first
30
+ end
31
+
32
+ def first_string_argument(node)
33
+ arguments(node).find { |argument| argument.is_a?(Prism::StringNode) }&.unescaped
34
+ end
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
+
47
+ def symbol_arguments(node)
48
+ arguments(node).filter_map do |arg|
49
+ next unless arg.is_a?(Prism::SymbolNode)
50
+
51
+ arg.unescaped.to_s
52
+ end
53
+ end
54
+
55
+ def arguments(node)
56
+ node.arguments&.arguments || []
57
+ end
58
+
59
+ def keyword_value(node, key)
60
+ hash = arguments(node).find { |argument| argument.is_a?(Prism::KeywordHashNode) }
61
+ pair = hash&.elements&.find do |element|
62
+ element.is_a?(Prism::AssocNode) && literal_value(element.key).to_s == key
63
+ end
64
+ literal_value(pair&.value)
65
+ end
66
+
67
+ def keyword_keys(node)
68
+ hash = arguments(node).find { |argument| argument.is_a?(Prism::KeywordHashNode) }
69
+ Array(hash&.elements).filter_map do |element|
70
+ literal_value(element.key).to_s if element.is_a?(Prism::AssocNode)
71
+ end
72
+ end
73
+
74
+ def literal_value(node)
75
+ case node
76
+ when Prism::SymbolNode, Prism::StringNode
77
+ node.unescaped.to_s
78
+ when Prism::TrueNode
79
+ true
80
+ when Prism::FalseNode
81
+ false
82
+ end
83
+ end
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
+
132
+ def constant_name(node)
133
+ return nil unless node
134
+
135
+ case node
136
+ when Prism::ConstantReadNode
137
+ node.name.to_s
138
+ when Prism::ConstantPathNode
139
+ prefix = node.parent ? constant_name(node.parent) : ''
140
+ [prefix, node.name.to_s].join('::')
141
+ end
142
+ end
143
+
144
+ def qualify_constant(name, namespace)
145
+ return nil unless name
146
+ return name.delete_prefix('::') if name.start_with?('::')
147
+ return name if namespace.nil? || namespace.empty? || name.include?('::')
148
+
149
+ "#{namespace}::#{name}"
150
+ end
151
+
152
+ def record_class_info(node, namespace, context)
153
+ return unless namespace
154
+
155
+ data = class_record(namespace)
156
+ data[:kind] = node.is_a?(Prism::ModuleNode) ? :module : :class
157
+ data[:file] = context.relative_file
158
+ data[:line] = node.location.start_line
159
+ return unless node.respond_to?(:superclass)
160
+
161
+ unless node.superclass
162
+ data[:superclass_candidates] = implicit_superclass_candidates(namespace)
163
+ return
164
+ end
165
+
166
+ superclass = constant_name(node.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']
189
+ end
190
+
191
+ def class_record(namespace)
192
+ class_data[namespace] ||= {
193
+ id: namespace,
194
+ kind: :class,
195
+ file: nil,
196
+ line: nil,
197
+ superclass: nil,
198
+ superclass_candidates: [],
199
+ includes: [],
200
+ prepends: [],
201
+ extends: [],
202
+ singleton_includes: [],
203
+ singleton_prepends: [],
204
+ dynamic: false
205
+ }
206
+ end
207
+
208
+ def class_infos
209
+ class_data.values.map do |data|
210
+ superclass_candidates = data.fetch(:superclass_candidates)
211
+ ClassInfo.new(
212
+ id: data.fetch(:id),
213
+ kind: data.fetch(:kind),
214
+ file: data[:file],
215
+ line: data[:line],
216
+ superclass: resolve_candidate_group(superclass_candidates),
217
+ superclass_candidates: superclass_candidates,
218
+ includes: resolve_candidate_groups(data.fetch(:includes)),
219
+ prepends: resolve_candidate_groups(data.fetch(:prepends)),
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)),
223
+ dynamic: data.fetch(:dynamic)
224
+ )
225
+ end
226
+ end
227
+
228
+ def resolve_candidate_groups(groups)
229
+ Array(groups).filter_map { |group| resolve_candidate_group(Array(group)) }.uniq
230
+ end
231
+
232
+ def resolve_candidate_group(group)
233
+ group.find { |candidate| class_data.key?(candidate) } || group.first
234
+ end
235
+
236
+ def constant_candidates(name, lexical_nesting)
237
+ return [name.delete_prefix('::')] if name.start_with?('::')
238
+
239
+ nesting = Array(lexical_nesting).compact
240
+ return [name] if nesting.empty?
241
+
242
+ head, *rest = name.split('::')
243
+ suffix = rest.empty? ? '' : "::#{rest.join('::')}"
244
+ candidates = nesting.map { |namespace| "#{namespace}::#{head}#{suffix}" }
245
+ inherited_namespaces(nesting.first).each do |ancestor|
246
+ candidates << "#{ancestor}::#{head}#{suffix}"
247
+ end
248
+ candidates << name
249
+ candidates.uniq
250
+ end
251
+
252
+ def inherited_namespaces(namespace, seen = Set.new)
253
+ return [] unless namespace && seen.add?(namespace)
254
+
255
+ data = class_data[namespace]
256
+ return [] unless data
257
+
258
+ Array(data[:superclass_candidates]).flat_map do |superclass|
259
+ [superclass, *inherited_namespaces(superclass, seen)]
260
+ end.uniq
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
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
+
17
+ def copy_module_function_call_sites
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
26
+ end
27
+ end
28
+ call_sites.concat(copies)
29
+ end
30
+
31
+ def scan_file(file)
32
+ relative = project.relative_path(file)
33
+ test = project.test_file?(file)
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}",
54
+ kind: :block_entry,
55
+ source_node: result.value,
56
+ context: context,
57
+ defined_via: :file,
58
+ owner: nil,
59
+ name: relative,
60
+ visibility: :public
61
+ )
62
+ root_id = root.graph_id
63
+ context.current_caller_id = root_id
64
+ context.root_id = root_id
65
+
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)
76
+ end
77
+
78
+ def visit(node, context)
79
+ return unless node.respond_to?(:child_nodes)
80
+
81
+ case node
82
+ when Prism::ClassNode, Prism::ModuleNode
83
+ visit_namespace(node, context)
84
+ when Prism::DefNode
85
+ visit_def(node, context)
86
+ when Prism::CallNode
87
+ visit_call(node, context)
88
+ when Prism::AliasMethodNode
89
+ visit_alias_method_node(node, context)
90
+ when Prism::SuperNode, Prism::ForwardingSuperNode
91
+ visit_super(node, context)
92
+ when Prism::SingletonClassNode
93
+ visit_singleton_class(node, context)
94
+ when Prism::ConstantWriteNode
95
+ visit_constant_write(node, context)
96
+ else
97
+ visit_generic_node(node, context)
98
+ end
99
+ end
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
+
109
+ def visit_namespace(node, context)
110
+ namespace = qualify_constant(constant_name(node.constant_path), context.namespace)
111
+ record_class_info(node, namespace, context)
112
+ visit(node.superclass, context) if node.respond_to?(:superclass) && node.superclass
113
+ child_context = context.dup
114
+ child_context.namespace = namespace
115
+ child_context.lexical_nesting = [namespace, *context.lexical_nesting].compact.uniq
116
+ child_context.owner = namespace
117
+ child_context.singleton_scope = false
118
+ child_context.visibility = :public
119
+ child_context.module_function = false
120
+ visit(node.body, child_context)
121
+ end
122
+
123
+ def visit_def(node, context)
124
+ owner = definition_owner(node, context)
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
138
+
139
+ kind = node.receiver || context.singleton_scope ? :singleton_method : :instance_method
140
+ separator = kind == :singleton_method ? '.' : '#'
141
+ id = "#{owner}#{separator}#{node.name}"
142
+ definition = add_definition(
143
+ symbol_id: id,
144
+ kind: kind,
145
+ source_node: node,
146
+ context: context,
147
+ defined_via: :def,
148
+ owner: owner,
149
+ name: node.name.to_s,
150
+ visibility: node.receiver ? :public : context.visibility
151
+ )
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}"
156
+ class_record(owner)[:dynamic] = true
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
171
+
172
+ method_context = context.dup
173
+ method_context.owner = owner
174
+ method_context.current_caller_id = definition.graph_id
175
+ method_context.current_method_name = node.name.to_s
176
+ method_context.current_kind = kind
177
+ method_context.singleton_scope = false
178
+ method_context.visibility = :public
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)
190
+ visit(node.body, method_context)
191
+ end
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
+
224
+ def visit_call(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
244
+
245
+ handle_module_relation(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
252
+
253
+ record_instantiation(node, context)
254
+ record_symbol_reference(node, context)
255
+ record_symbol_to_proc(node, context)
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)
260
+
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
291
+ end
292
+
293
+ def visit_super(node, context)
294
+ method_name = context.current_method_name
295
+ if method_name
296
+ add_scanned_call_site(
297
+ source_node: node,
298
+ context: context,
299
+ role: :super,
300
+ message: method_name,
301
+ receiver_kind: :super,
302
+ receiver_name: context.owner,
303
+ dynamic: false,
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
+ }
309
+ )
310
+ end
311
+ visit_children(node, context)
312
+ end
313
+
314
+ def visit_singleton_class(node, context)
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
328
+
329
+ singleton_context = context.dup
330
+ singleton_context.namespace = owner
331
+ singleton_context.owner = owner
332
+ singleton_context.singleton_scope = true
333
+ singleton_context.visibility = :public
334
+ singleton_context.module_function = false
335
+ visit(node.body, singleton_context)
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
346
+ end
347
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def visit_constant_write(node, context)
8
+ return visit_children(node, context) unless struct_or_data_definition?(node.value)
9
+
10
+ owner = qualify_constant(node.name.to_s, context.namespace)
11
+ data = class_record(owner)
12
+ data[:kind] = :class
13
+ data[:file] = context.relative_file
14
+ data[:line] = node.location.start_line
15
+ instantiated_classes << owner
16
+
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}",
33
+ kind: :instance_method,
34
+ source_node: node,
35
+ context: context,
36
+ defined_via: node.value.name == :define ? :data_define : :struct_new,
37
+ owner: owner,
38
+ name: name,
39
+ visibility: :public
40
+ )
41
+ next if node.value.name == :define
42
+
43
+ add_definition(
44
+ symbol_id: "#{owner}##{name}=",
45
+ kind: :instance_method,
46
+ source_node: node,
47
+ context: context,
48
+ defined_via: :struct_new,
49
+ owner: owner,
50
+ name: "#{name}=",
51
+ visibility: :public
52
+ )
53
+ end
54
+
55
+ return unless node.value.block
56
+
57
+ block_context = context.dup
58
+ block_context.namespace = owner
59
+ block_context.owner = owner
60
+ block_context.singleton_scope = false
61
+ block_context.visibility = :public
62
+ block_context.module_function = false
63
+ block_context.static_ancestry = false
64
+ visit(node.value.block.body, block_context)
65
+ end
66
+
67
+ def struct_or_data_definition?(value)
68
+ return false unless value.is_a?(Prism::CallNode)
69
+
70
+ receiver_name = constant_name(value.receiver)
71
+ (receiver_name == 'Struct' && value.name == :new) || (receiver_name == 'Data' && value.name == :define)
72
+ end
73
+
74
+ def visit_children(node, context)
75
+ node.child_nodes.compact.each { |child| visit(child, context) }
76
+ end
77
+ end
78
+ end