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
data/lib/necropsy/ast_scanner.rb
CHANGED
|
@@ -1,616 +1,141 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'prism'
|
|
4
|
+
require_relative 'ast_scanner/definition_creation'
|
|
5
|
+
require_relative 'ast_scanner/call_site_creation'
|
|
6
|
+
require_relative 'ast_scanner/traversal'
|
|
7
|
+
require_relative 'ast_scanner/value_definitions'
|
|
8
|
+
require_relative 'ast_scanner/method_definitions'
|
|
9
|
+
require_relative 'ast_scanner/dsl_macros'
|
|
10
|
+
require_relative 'ast_scanner/call_recording'
|
|
11
|
+
require_relative 'ast_scanner/references'
|
|
12
|
+
require_relative 'ast_scanner/ruby_semantics'
|
|
4
13
|
|
|
5
14
|
module Necropsy
|
|
6
|
-
ScanResult = Data.define(
|
|
15
|
+
ScanResult = Data.define(
|
|
16
|
+
:nodes,
|
|
17
|
+
:call_sites,
|
|
18
|
+
:instantiated_classes,
|
|
19
|
+
:uncertainties,
|
|
20
|
+
:class_infos,
|
|
21
|
+
:entrypoint_hints,
|
|
22
|
+
:file_statuses,
|
|
23
|
+
:source_errors,
|
|
24
|
+
:source_domains,
|
|
25
|
+
:scope_diagnostics,
|
|
26
|
+
:method_signatures,
|
|
27
|
+
:semantic_blockers
|
|
28
|
+
) do
|
|
29
|
+
def initialize(nodes:, call_sites:, instantiated_classes:, uncertainties:, class_infos:, entrypoint_hints:,
|
|
30
|
+
file_statuses: {}, source_errors: [], source_domains: {}, scope_diagnostics: {},
|
|
31
|
+
method_signatures: {}, semantic_blockers: [])
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
end
|
|
7
35
|
|
|
8
36
|
class AstScanner
|
|
37
|
+
CallTraversal = Data.define(:receiver, :arguments, :block)
|
|
9
38
|
ATTR_MACROS = %i[attr_reader attr_writer attr_accessor].freeze
|
|
10
39
|
DYNAMIC_SENDS = %i[send public_send __send__].freeze
|
|
11
40
|
MODULE_RELATION_MACROS = %i[include prepend extend].freeze
|
|
12
41
|
RAILS_CALLBACK_MACROS = %i[
|
|
13
42
|
before_action after_action around_action
|
|
14
|
-
before_save after_save
|
|
15
|
-
|
|
43
|
+
before_validation after_validation before_save after_save around_save
|
|
44
|
+
around_validation before_touch after_touch
|
|
45
|
+
before_enqueue around_enqueue after_enqueue before_perform around_perform after_perform
|
|
46
|
+
before_create after_create around_create before_update after_update around_update
|
|
47
|
+
before_destroy after_destroy around_destroy after_commit after_rollback after_initialize after_find
|
|
48
|
+
after_create_commit after_update_commit after_destroy_commit after_save_commit
|
|
49
|
+
validate rescue_from helper_method
|
|
50
|
+
].freeze
|
|
51
|
+
RAILS_GENERATED_METHOD_MACROS = %i[
|
|
52
|
+
enum store store_accessor attribute class_attribute mattr_reader mattr_writer mattr_accessor
|
|
53
|
+
cattr_reader cattr_writer cattr_accessor belongs_to has_one has_many scope
|
|
54
|
+
].freeze
|
|
55
|
+
RAILS_RUNTIME_BLOCK_MACROS = %i[discard_on retry_on stream_for stream_from].freeze
|
|
56
|
+
SIDEKIQ_RUNTIME_BLOCK_MACROS = %i[sidekiq_retries_exhausted sidekiq_retry_in].freeze
|
|
57
|
+
VISIBILITY_MACROS = %i[public protected private public_class_method private_class_method].freeze
|
|
58
|
+
SYMBOL_REFERENCE_CALLS = %i[method respond_to? try try!].freeze
|
|
59
|
+
ANCESTRY_CONTROL_FLOW_TYPES = %i[
|
|
60
|
+
and_node block_node case_match_node case_node else_node for_node if_node in_node lambda_node
|
|
61
|
+
or_node rescue_modifier_node rescue_node until_node when_node while_node
|
|
62
|
+
].freeze
|
|
63
|
+
RAILS_BUILTIN_VALIDATORS = %w[
|
|
64
|
+
absence acceptance allow_blank allow_nil comparison confirmation exclusion format if inclusion length message numericality
|
|
65
|
+
on presence strict uniqueness unless
|
|
16
66
|
].freeze
|
|
17
67
|
|
|
18
68
|
Context = Struct.new(
|
|
19
69
|
:namespace,
|
|
70
|
+
:lexical_nesting,
|
|
20
71
|
:owner,
|
|
21
72
|
:current_caller_id,
|
|
73
|
+
:current_method_name,
|
|
22
74
|
:current_kind,
|
|
23
75
|
:root_id,
|
|
24
76
|
:file,
|
|
25
77
|
:relative_file,
|
|
26
78
|
:test,
|
|
27
79
|
:singleton_scope,
|
|
80
|
+
:visibility,
|
|
81
|
+
:module_function,
|
|
82
|
+
:static_ancestry,
|
|
83
|
+
:flow_result,
|
|
28
84
|
keyword_init: true
|
|
29
85
|
)
|
|
30
86
|
|
|
31
|
-
def initialize(project:, files:)
|
|
87
|
+
def initialize(project:, files:, source_domains: nil, scope_diagnostics: {})
|
|
32
88
|
@project = project
|
|
33
89
|
@files = files
|
|
90
|
+
@source_domains = source_domains || files.to_h { |file| [project.relative_path(file), :analyze] }
|
|
91
|
+
@scope_diagnostics = scope_diagnostics
|
|
34
92
|
@nodes = []
|
|
35
93
|
@call_sites = []
|
|
36
94
|
@instantiated_classes = Set.new
|
|
37
95
|
@uncertainties = Hash.new { |hash, key| hash[key] = [] }
|
|
38
96
|
@class_data = {}
|
|
39
97
|
@entrypoint_hints = []
|
|
98
|
+
@file_statuses = {}
|
|
99
|
+
@source_errors = []
|
|
100
|
+
@definition_ordinals = Hash.new(0)
|
|
101
|
+
@call_site_ordinals = Hash.new(0)
|
|
102
|
+
@module_function_sources = {}
|
|
103
|
+
@deferred_module_functions = {}
|
|
104
|
+
@method_signatures = {}
|
|
105
|
+
@semantic_blockers = []
|
|
106
|
+
@factory_methods = project.config.factory_methods.to_set(&:to_s)
|
|
107
|
+
@convention_rules = ConventionRules.new
|
|
40
108
|
end
|
|
41
109
|
|
|
42
110
|
def scan
|
|
43
|
-
files.each { |file| scan_file(file) }
|
|
111
|
+
files.sort_by { |file| project.relative_path(file) }.each { |file| scan_file(file) }
|
|
112
|
+
resolve_deferred_module_function_sources
|
|
113
|
+
copy_module_function_call_sites
|
|
44
114
|
ScanResult.new(
|
|
45
|
-
nodes: nodes
|
|
115
|
+
nodes: nodes,
|
|
46
116
|
call_sites: call_sites,
|
|
47
117
|
instantiated_classes: instantiated_classes,
|
|
48
118
|
uncertainties: uncertainties,
|
|
49
119
|
class_infos: class_infos,
|
|
50
|
-
entrypoint_hints: entrypoint_hints.uniq
|
|
120
|
+
entrypoint_hints: entrypoint_hints.uniq,
|
|
121
|
+
file_statuses: file_statuses,
|
|
122
|
+
source_errors: source_errors,
|
|
123
|
+
source_domains: source_domains,
|
|
124
|
+
scope_diagnostics: scope_diagnostics,
|
|
125
|
+
method_signatures: method_signatures,
|
|
126
|
+
semantic_blockers: semantic_blockers.sort_by { |blocker| BoundedCanonicalizer.dump(blocker.to_h) }
|
|
51
127
|
)
|
|
52
128
|
end
|
|
53
129
|
|
|
54
130
|
private
|
|
55
131
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def scan_file(file)
|
|
60
|
-
relative = project.relative_path(file)
|
|
61
|
-
root_id = "file:#{relative}"
|
|
62
|
-
test = project.test_file?(file)
|
|
63
|
-
nodes << Node.new(
|
|
64
|
-
id: root_id,
|
|
65
|
-
kind: :block_entry,
|
|
66
|
-
file: relative,
|
|
67
|
-
line: 1,
|
|
68
|
-
end_line: 1,
|
|
69
|
-
defined_via: :file,
|
|
70
|
-
owner: nil,
|
|
71
|
-
name: relative,
|
|
72
|
-
test: test
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
result = Prism.parse(File.read(file))
|
|
76
|
-
record_parse_errors(root_id, result) if result.failure?
|
|
77
|
-
|
|
78
|
-
visit(
|
|
79
|
-
result.value,
|
|
80
|
-
Context.new(
|
|
81
|
-
namespace: nil,
|
|
82
|
-
owner: nil,
|
|
83
|
-
current_caller_id: root_id,
|
|
84
|
-
current_kind: :block_entry,
|
|
85
|
-
root_id: root_id,
|
|
86
|
-
file: file,
|
|
87
|
-
relative_file: relative,
|
|
88
|
-
test: test,
|
|
89
|
-
singleton_scope: false
|
|
90
|
-
)
|
|
91
|
-
)
|
|
92
|
-
rescue SystemCallError, EncodingError => e
|
|
93
|
-
uncertainties[root_id] << "Could not parse #{relative}: #{e.message}"
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def visit(node, context)
|
|
97
|
-
return unless node.respond_to?(:child_nodes)
|
|
98
|
-
|
|
99
|
-
case node
|
|
100
|
-
when Prism::ClassNode, Prism::ModuleNode
|
|
101
|
-
visit_namespace(node, context)
|
|
102
|
-
when Prism::DefNode
|
|
103
|
-
visit_def(node, context)
|
|
104
|
-
when Prism::CallNode
|
|
105
|
-
visit_call(node, context)
|
|
106
|
-
when Prism::AliasMethodNode
|
|
107
|
-
visit_alias_method_node(node, context)
|
|
108
|
-
when Prism::SingletonClassNode
|
|
109
|
-
visit_singleton_class(node, context)
|
|
110
|
-
when Prism::ConstantWriteNode
|
|
111
|
-
visit_constant_write(node, context)
|
|
112
|
-
else
|
|
113
|
-
node.child_nodes.compact.each { |child| visit(child, context) }
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def visit_namespace(node, context)
|
|
118
|
-
namespace = qualify_constant(constant_name(node.constant_path), context.namespace)
|
|
119
|
-
record_class_info(node, namespace, context)
|
|
120
|
-
child_context = context.dup
|
|
121
|
-
child_context.namespace = namespace
|
|
122
|
-
child_context.owner = namespace
|
|
123
|
-
child_context.singleton_scope = false
|
|
124
|
-
visit(node.body, child_context)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def visit_def(node, context)
|
|
128
|
-
owner = definition_owner(node, context)
|
|
129
|
-
return visit_children(node, context) unless owner
|
|
130
|
-
|
|
131
|
-
kind = node.receiver || context.singleton_scope ? :singleton_method : :instance_method
|
|
132
|
-
separator = kind == :singleton_method ? '.' : '#'
|
|
133
|
-
id = "#{owner}#{separator}#{node.name}"
|
|
134
|
-
nodes << Node.new(
|
|
135
|
-
id: id,
|
|
136
|
-
kind: kind,
|
|
137
|
-
file: context.relative_file,
|
|
138
|
-
line: node.location.start_line,
|
|
139
|
-
end_line: node.location.end_line,
|
|
140
|
-
defined_via: :def,
|
|
141
|
-
owner: owner,
|
|
142
|
-
name: node.name.to_s,
|
|
143
|
-
test: context.test
|
|
144
|
-
)
|
|
145
|
-
if node.name == :method_missing
|
|
146
|
-
uncertainties[id] << "#{owner} defines method_missing"
|
|
147
|
-
class_record(owner)[:dynamic] = true
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
method_context = context.dup
|
|
151
|
-
method_context.owner = owner
|
|
152
|
-
method_context.current_caller_id = id
|
|
153
|
-
method_context.current_kind = kind
|
|
154
|
-
method_context.singleton_scope = false
|
|
155
|
-
visit(node.body, method_context)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def visit_call(node, context)
|
|
159
|
-
return if handle_define_method(node, context)
|
|
160
|
-
return if handle_attr_macro(node, context)
|
|
161
|
-
return if handle_delegate(node, context)
|
|
162
|
-
return if handle_alias_method(node, context)
|
|
163
|
-
|
|
164
|
-
handle_module_relation(node, context)
|
|
165
|
-
handle_rails_callback(node, context)
|
|
166
|
-
|
|
167
|
-
record_instantiation(node, context)
|
|
168
|
-
site = build_call_site(node, context)
|
|
169
|
-
call_sites << site if site
|
|
170
|
-
if site&.dynamic
|
|
171
|
-
record_uncertainty(site)
|
|
172
|
-
elsif unresolved_dynamic_dispatch?(node)
|
|
173
|
-
record_uncertainty_at(node, context)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
visit_children(node, context)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def visit_singleton_class(node, context)
|
|
180
|
-
return visit_children(node, context) unless node.expression.is_a?(Prism::SelfNode) && context.owner
|
|
181
|
-
|
|
182
|
-
singleton_context = context.dup
|
|
183
|
-
singleton_context.singleton_scope = true
|
|
184
|
-
visit(node.body, singleton_context)
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def visit_constant_write(node, context)
|
|
188
|
-
return visit_children(node, context) unless struct_or_data_definition?(node.value)
|
|
189
|
-
|
|
190
|
-
owner = qualify_constant(node.name.to_s, context.namespace)
|
|
191
|
-
data = class_record(owner)
|
|
192
|
-
data[:kind] = :class
|
|
193
|
-
data[:file] = context.relative_file
|
|
194
|
-
data[:line] = node.location.start_line
|
|
195
|
-
instantiated_classes << owner
|
|
196
|
-
|
|
197
|
-
symbol_arguments(node.value).each do |name|
|
|
198
|
-
nodes << Node.new(
|
|
199
|
-
id: "#{owner}##{name}",
|
|
200
|
-
kind: :instance_method,
|
|
201
|
-
file: context.relative_file,
|
|
202
|
-
line: node.location.start_line,
|
|
203
|
-
end_line: node.location.end_line,
|
|
204
|
-
defined_via: node.value.name == :define ? :data_define : :struct_new,
|
|
205
|
-
owner: owner,
|
|
206
|
-
name: name,
|
|
207
|
-
test: context.test
|
|
208
|
-
)
|
|
209
|
-
next if node.value.name == :define
|
|
210
|
-
|
|
211
|
-
nodes << Node.new(
|
|
212
|
-
id: "#{owner}##{name}=",
|
|
213
|
-
kind: :instance_method,
|
|
214
|
-
file: context.relative_file,
|
|
215
|
-
line: node.location.start_line,
|
|
216
|
-
end_line: node.location.end_line,
|
|
217
|
-
defined_via: :struct_new,
|
|
218
|
-
owner: owner,
|
|
219
|
-
name: "#{name}=",
|
|
220
|
-
test: context.test
|
|
221
|
-
)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
return unless node.value.block
|
|
225
|
-
|
|
226
|
-
block_context = context.dup
|
|
227
|
-
block_context.namespace = owner
|
|
228
|
-
block_context.owner = owner
|
|
229
|
-
block_context.singleton_scope = false
|
|
230
|
-
visit(node.value.block.body, block_context)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def struct_or_data_definition?(value)
|
|
234
|
-
return false unless value.is_a?(Prism::CallNode)
|
|
235
|
-
|
|
236
|
-
receiver_name = constant_name(value.receiver)
|
|
237
|
-
(receiver_name == 'Struct' && value.name == :new) || (receiver_name == 'Data' && value.name == :define)
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def visit_children(node, context)
|
|
241
|
-
node.child_nodes.compact.each { |child| visit(child, context) }
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def handle_define_method(node, context)
|
|
245
|
-
return false unless node.name == :define_method
|
|
246
|
-
return false unless context.owner
|
|
247
|
-
|
|
248
|
-
method_name = first_symbol_argument(node)
|
|
249
|
-
return false unless method_name
|
|
250
|
-
|
|
251
|
-
id = "#{context.owner}##{method_name}"
|
|
252
|
-
nodes << Node.new(
|
|
253
|
-
id: id,
|
|
254
|
-
kind: :instance_method,
|
|
255
|
-
file: context.relative_file,
|
|
256
|
-
line: node.location.start_line,
|
|
257
|
-
end_line: node.location.end_line,
|
|
258
|
-
defined_via: :define_method,
|
|
259
|
-
owner: context.owner,
|
|
260
|
-
name: method_name,
|
|
261
|
-
test: context.test
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
if node.block
|
|
265
|
-
block_context = context.dup
|
|
266
|
-
block_context.current_caller_id = id
|
|
267
|
-
block_context.current_kind = :instance_method
|
|
268
|
-
visit(node.block.body, block_context)
|
|
269
|
-
end
|
|
270
|
-
true
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
def handle_alias_method(node, context)
|
|
274
|
-
return false unless node.name == :alias_method
|
|
275
|
-
return false unless context.owner
|
|
276
|
-
|
|
277
|
-
new_name, old_name = symbol_arguments(node)
|
|
278
|
-
return false unless new_name && old_name
|
|
279
|
-
|
|
280
|
-
record_alias_method(context, node.location, new_name, old_name)
|
|
281
|
-
true
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
def visit_alias_method_node(node, context)
|
|
285
|
-
return visit_children(node, context) unless context.owner
|
|
286
|
-
|
|
287
|
-
new_name = node.new_name.unescaped.to_s
|
|
288
|
-
old_name = node.old_name.unescaped.to_s
|
|
289
|
-
record_alias_method(context, node.location, new_name, old_name)
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def record_alias_method(context, location, new_name, old_name)
|
|
293
|
-
kind = context.singleton_scope ? :singleton_method : :instance_method
|
|
294
|
-
separator = kind == :singleton_method ? '.' : '#'
|
|
295
|
-
id = "#{context.owner}#{separator}#{new_name}"
|
|
296
|
-
nodes << Node.new(
|
|
297
|
-
id: id,
|
|
298
|
-
kind: kind,
|
|
299
|
-
file: context.relative_file,
|
|
300
|
-
line: location.start_line,
|
|
301
|
-
end_line: location.end_line,
|
|
302
|
-
defined_via: :alias_method,
|
|
303
|
-
owner: context.owner,
|
|
304
|
-
name: new_name,
|
|
305
|
-
test: context.test
|
|
306
|
-
)
|
|
307
|
-
call_sites << CallSite.new(
|
|
308
|
-
caller_id: id,
|
|
309
|
-
message: old_name,
|
|
310
|
-
receiver_kind: :self,
|
|
311
|
-
receiver_name: context.owner,
|
|
312
|
-
file: context.relative_file,
|
|
313
|
-
line: location.start_line,
|
|
314
|
-
test: context.test,
|
|
315
|
-
dynamic: false,
|
|
316
|
-
metadata: { 'original_message' => old_name, 'alias_method' => true }
|
|
317
|
-
)
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def handle_module_relation(node, context)
|
|
321
|
-
return unless MODULE_RELATION_MACROS.include?(node.name)
|
|
322
|
-
return unless context.owner
|
|
323
|
-
|
|
324
|
-
constants = arguments(node).filter_map { |argument| constant_name(argument) }
|
|
325
|
-
return if constants.empty?
|
|
326
|
-
|
|
327
|
-
data = class_record(context.owner)
|
|
328
|
-
key = :"#{node.name}s"
|
|
329
|
-
constants.each { |constant| data[key].concat(constant_candidates(constant, context.namespace)) }
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
def handle_rails_callback(node, context)
|
|
333
|
-
return unless RAILS_CALLBACK_MACROS.include?(node.name)
|
|
334
|
-
return unless context.owner
|
|
335
|
-
|
|
336
|
-
symbol_arguments(node).each do |method_name|
|
|
337
|
-
entrypoint_hints << EntryPoint.new(node_id: "#{context.owner}##{method_name}", reason: :callback_registered)
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
def handle_attr_macro(node, context)
|
|
342
|
-
return false unless ATTR_MACROS.include?(node.name)
|
|
343
|
-
return false unless context.owner
|
|
344
|
-
|
|
345
|
-
symbol_arguments(node).each do |name|
|
|
346
|
-
nodes << Node.new(
|
|
347
|
-
id: "#{context.owner}##{name}",
|
|
348
|
-
kind: :instance_method,
|
|
349
|
-
file: context.relative_file,
|
|
350
|
-
line: node.location.start_line,
|
|
351
|
-
end_line: node.location.end_line,
|
|
352
|
-
defined_via: node.name,
|
|
353
|
-
owner: context.owner,
|
|
354
|
-
name: name,
|
|
355
|
-
test: context.test
|
|
356
|
-
)
|
|
357
|
-
next unless %i[attr_writer attr_accessor].include?(node.name)
|
|
358
|
-
|
|
359
|
-
nodes << Node.new(
|
|
360
|
-
id: "#{context.owner}##{name}=",
|
|
361
|
-
kind: :instance_method,
|
|
362
|
-
file: context.relative_file,
|
|
363
|
-
line: node.location.start_line,
|
|
364
|
-
end_line: node.location.end_line,
|
|
365
|
-
defined_via: node.name,
|
|
366
|
-
owner: context.owner,
|
|
367
|
-
name: "#{name}=",
|
|
368
|
-
test: context.test
|
|
369
|
-
)
|
|
370
|
-
end
|
|
371
|
-
true
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
def handle_delegate(node, context)
|
|
375
|
-
return false unless node.name == :delegate
|
|
376
|
-
return false unless context.owner
|
|
377
|
-
|
|
378
|
-
symbol_arguments(node).each do |name|
|
|
379
|
-
nodes << Node.new(
|
|
380
|
-
id: "#{context.owner}##{name}",
|
|
381
|
-
kind: :instance_method,
|
|
382
|
-
file: context.relative_file,
|
|
383
|
-
line: node.location.start_line,
|
|
384
|
-
end_line: node.location.end_line,
|
|
385
|
-
defined_via: :delegate,
|
|
386
|
-
owner: context.owner,
|
|
387
|
-
name: name,
|
|
388
|
-
test: context.test
|
|
389
|
-
)
|
|
390
|
-
end
|
|
391
|
-
true
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
def build_call_site(node, context)
|
|
395
|
-
message = node.name&.to_s
|
|
396
|
-
dynamic = false
|
|
397
|
-
receiver = classify_receiver(node.receiver, context)
|
|
398
|
-
metadata = { 'original_message' => message, 'receiver_candidates' => receiver[:candidates] }
|
|
399
|
-
|
|
400
|
-
if DYNAMIC_SENDS.include?(node.name)
|
|
401
|
-
literal = first_symbol_argument(node) || first_string_argument(node)
|
|
402
|
-
dynamic = literal.nil?
|
|
403
|
-
message = literal
|
|
404
|
-
metadata['dynamic_dispatch'] = true
|
|
405
|
-
end
|
|
406
|
-
|
|
407
|
-
return nil unless message
|
|
408
|
-
|
|
409
|
-
CallSite.new(
|
|
410
|
-
caller_id: context.current_caller_id,
|
|
411
|
-
message: message,
|
|
412
|
-
receiver_kind: receiver.fetch(:kind),
|
|
413
|
-
receiver_name: receiver[:name],
|
|
414
|
-
file: context.relative_file,
|
|
415
|
-
line: node.location.start_line,
|
|
416
|
-
test: context.test,
|
|
417
|
-
dynamic: dynamic,
|
|
418
|
-
metadata: metadata
|
|
419
|
-
)
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
def record_instantiation(node, context)
|
|
423
|
-
return record_factory_instantiation(node, context) unless %i[new []].include?(node.name)
|
|
424
|
-
|
|
425
|
-
receiver = classify_receiver(node.receiver, context)
|
|
426
|
-
return unless receiver[:kind] == :constant
|
|
427
|
-
|
|
428
|
-
Array(receiver[:candidates] || receiver[:name]).each { |name| instantiated_classes << name }
|
|
429
|
-
record_initialize_call(node, context, receiver)
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
def record_initialize_call(node, context, receiver)
|
|
433
|
-
return unless node.name == :new
|
|
434
|
-
|
|
435
|
-
call_sites << CallSite.new(
|
|
436
|
-
caller_id: context.current_caller_id,
|
|
437
|
-
message: 'initialize',
|
|
438
|
-
receiver_kind: :instance,
|
|
439
|
-
receiver_name: receiver[:name],
|
|
440
|
-
file: context.relative_file,
|
|
441
|
-
line: node.location.start_line,
|
|
442
|
-
test: context.test,
|
|
443
|
-
dynamic: false,
|
|
444
|
-
metadata: { 'original_message' => 'new', 'receiver_candidates' => receiver[:candidates],
|
|
445
|
-
'implicit_from' => 'new' }
|
|
446
|
-
)
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
def record_factory_instantiation(node, context)
|
|
450
|
-
factory_methods = Array(project.config.factory_methods).map(&:to_s)
|
|
451
|
-
return unless factory_methods.include?(node.name.to_s)
|
|
452
|
-
|
|
453
|
-
receiver = classify_receiver(node.receiver, context)
|
|
454
|
-
return unless receiver[:kind] == :constant
|
|
455
|
-
|
|
456
|
-
Array(receiver[:candidates] || receiver[:name]).each do |name|
|
|
457
|
-
instantiated_classes << name
|
|
458
|
-
end
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
def record_uncertainty(site)
|
|
462
|
-
uncertainties[site.caller_id] << "Dynamic dispatch at #{site.file}:#{site.line}"
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
def record_uncertainty_at(node, context)
|
|
466
|
-
uncertainties[context.current_caller_id] << "Dynamic dispatch at #{context.relative_file}:#{node.location.start_line}"
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
def unresolved_dynamic_dispatch?(node)
|
|
470
|
-
DYNAMIC_SENDS.include?(node.name) && first_symbol_argument(node).nil? && first_string_argument(node).nil?
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
def record_parse_errors(root_id, result)
|
|
474
|
-
result.errors.each do |error|
|
|
475
|
-
uncertainties[root_id] << "Parse warning at line #{error.location.start_line}: #{error.message}"
|
|
476
|
-
end
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
def definition_owner(node, context)
|
|
480
|
-
return context.owner unless node.receiver
|
|
481
|
-
return context.owner if node.receiver.is_a?(Prism::SelfNode)
|
|
482
|
-
|
|
483
|
-
receiver = classify_receiver(node.receiver, context)
|
|
484
|
-
receiver[:name]
|
|
485
|
-
end
|
|
486
|
-
|
|
487
|
-
def classify_receiver(receiver, context)
|
|
488
|
-
return { kind: :implicit, name: nil } unless receiver
|
|
489
|
-
return { kind: :self, name: context.owner } if receiver.is_a?(Prism::SelfNode)
|
|
490
|
-
|
|
491
|
-
constant = constant_name(receiver)
|
|
492
|
-
if constant
|
|
493
|
-
candidates = constant_candidates(constant, context.namespace)
|
|
494
|
-
return { kind: :constant, name: candidates.first, candidates: candidates }
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
if receiver.is_a?(Prism::CallNode) && receiver.name == :new
|
|
498
|
-
receiver_constant = constant_name(receiver.receiver)
|
|
499
|
-
if receiver_constant
|
|
500
|
-
candidates = constant_candidates(receiver_constant, context.namespace)
|
|
501
|
-
return { kind: :instance, name: candidates.first, candidates: candidates }
|
|
502
|
-
end
|
|
503
|
-
end
|
|
504
|
-
|
|
505
|
-
{ kind: :unknown, name: nil, candidates: [] }
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
def first_symbol_argument(node)
|
|
509
|
-
symbol_arguments(node).first
|
|
510
|
-
end
|
|
511
|
-
|
|
512
|
-
def first_string_argument(node)
|
|
513
|
-
arguments(node).find { |arg| arg.respond_to?(:unescaped) && arg.class.name.end_with?('StringNode') }&.unescaped
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
def symbol_arguments(node)
|
|
517
|
-
arguments(node).filter_map do |arg|
|
|
518
|
-
next unless arg.respond_to?(:unescaped) && arg.class.name.end_with?('SymbolNode')
|
|
519
|
-
|
|
520
|
-
arg.unescaped.to_s
|
|
521
|
-
end
|
|
522
|
-
end
|
|
523
|
-
|
|
524
|
-
def arguments(node)
|
|
525
|
-
node.arguments&.arguments || []
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
def constant_name(node)
|
|
529
|
-
return nil unless node
|
|
530
|
-
|
|
531
|
-
case node
|
|
532
|
-
when Prism::ConstantReadNode
|
|
533
|
-
node.name.to_s
|
|
534
|
-
when Prism::ConstantPathNode
|
|
535
|
-
[constant_name(node.parent), node.name.to_s].compact.join('::')
|
|
536
|
-
end
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
def qualify_constant(name, namespace)
|
|
540
|
-
return nil unless name
|
|
541
|
-
return name if namespace.nil? || namespace.empty? || name.include?('::')
|
|
542
|
-
|
|
543
|
-
"#{namespace}::#{name}"
|
|
132
|
+
def handled_call(receiver: true, arguments: true, block: false)
|
|
133
|
+
CallTraversal.new(receiver: receiver, arguments: arguments, block: block)
|
|
544
134
|
end
|
|
545
135
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
data[:kind] = node.is_a?(Prism::ModuleNode) ? :module : :class
|
|
551
|
-
data[:file] = context.relative_file
|
|
552
|
-
data[:line] = node.location.start_line
|
|
553
|
-
return unless node.respond_to?(:superclass)
|
|
554
|
-
|
|
555
|
-
superclass = constant_name(node.superclass)
|
|
556
|
-
data[:superclass_candidates] = constant_candidates(superclass, context.namespace) if superclass
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
def class_record(namespace)
|
|
560
|
-
class_data[namespace] ||= {
|
|
561
|
-
id: namespace,
|
|
562
|
-
kind: :class,
|
|
563
|
-
file: nil,
|
|
564
|
-
line: nil,
|
|
565
|
-
superclass: nil,
|
|
566
|
-
superclass_candidates: [],
|
|
567
|
-
includes: [],
|
|
568
|
-
prepends: [],
|
|
569
|
-
extends: [],
|
|
570
|
-
dynamic: false
|
|
571
|
-
}
|
|
572
|
-
end
|
|
573
|
-
|
|
574
|
-
def class_infos
|
|
575
|
-
class_data.values.map do |data|
|
|
576
|
-
ClassInfo.new(
|
|
577
|
-
id: data.fetch(:id),
|
|
578
|
-
kind: data.fetch(:kind),
|
|
579
|
-
file: data[:file],
|
|
580
|
-
line: data[:line],
|
|
581
|
-
superclass: resolve_known_constants(data.fetch(:superclass_candidates)).first,
|
|
582
|
-
superclass_candidates: data.fetch(:superclass_candidates),
|
|
583
|
-
includes: resolve_known_constants(data.fetch(:includes)),
|
|
584
|
-
prepends: resolve_known_constants(data.fetch(:prepends)),
|
|
585
|
-
extends: resolve_known_constants(data.fetch(:extends)),
|
|
586
|
-
dynamic: data.fetch(:dynamic)
|
|
587
|
-
)
|
|
588
|
-
end
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
def resolve_known_constants(candidates)
|
|
592
|
-
grouped_candidates(candidates).map do |group|
|
|
593
|
-
group.find { |candidate| class_data.key?(candidate) } || group.first
|
|
594
|
-
end.compact.uniq
|
|
595
|
-
end
|
|
596
|
-
|
|
597
|
-
def grouped_candidates(candidates)
|
|
598
|
-
Array(candidates).chunk_while do |left, right|
|
|
599
|
-
suffix = left.split('::').last
|
|
600
|
-
right&.end_with?("::#{suffix}") || right == suffix
|
|
601
|
-
end.to_a
|
|
602
|
-
end
|
|
603
|
-
|
|
604
|
-
def constant_candidates(name, namespace)
|
|
605
|
-
return [name] if namespace.nil? || namespace.empty? || name.include?('::')
|
|
606
|
-
|
|
607
|
-
parts = namespace.split('::')
|
|
608
|
-
candidates = []
|
|
609
|
-
parts.length.downto(1) do |length|
|
|
610
|
-
candidates << "#{parts.first(length).join('::')}::#{name}"
|
|
611
|
-
end
|
|
612
|
-
candidates << name
|
|
613
|
-
candidates.uniq
|
|
614
|
-
end
|
|
136
|
+
attr_reader :project, :files, :nodes, :call_sites, :instantiated_classes, :uncertainties, :class_data,
|
|
137
|
+
:entrypoint_hints, :file_statuses, :source_errors, :definition_ordinals, :module_function_sources,
|
|
138
|
+
:deferred_module_functions, :call_site_ordinals, :source_domains, :scope_diagnostics,
|
|
139
|
+
:method_signatures, :semantic_blockers, :convention_rules
|
|
615
140
|
end
|
|
616
141
|
end
|