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.
- checksums.yaml +4 -4
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1234 -62
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- 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
|