necropsy 0.1.0 → 0.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +18 -0
  4. data/MEASUREMENTS.md +85 -0
  5. data/README.md +60 -8
  6. data/Rakefile +3 -1
  7. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +12 -2
  8. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +5 -1
  9. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +68 -14
  10. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +107 -12
  11. data/lib/necropsy/analyzers/static/cha.rb +10 -6
  12. data/lib/necropsy/analyzers/static/name_resolution.rb +29 -20
  13. data/lib/necropsy/analyzers/static/rta.rb +19 -3
  14. data/lib/necropsy/ast_scanner/call_recording.rb +156 -0
  15. data/lib/necropsy/ast_scanner/dsl_macros.rb +142 -0
  16. data/lib/necropsy/ast_scanner/method_definitions.rb +176 -0
  17. data/lib/necropsy/ast_scanner/references.rb +81 -0
  18. data/lib/necropsy/ast_scanner/ruby_semantics.rb +177 -0
  19. data/lib/necropsy/ast_scanner/traversal.rb +184 -0
  20. data/lib/necropsy/ast_scanner/value_definitions.rb +68 -0
  21. data/lib/necropsy/ast_scanner.rb +24 -560
  22. data/lib/necropsy/bench/evaluator.rb +29 -10
  23. data/lib/necropsy/cache/scan_cache.rb +20 -12
  24. data/lib/necropsy/cli.rb +88 -32
  25. data/lib/necropsy/confidence/scorer.rb +97 -19
  26. data/lib/necropsy/configuration.rb +125 -7
  27. data/lib/necropsy/diagnostics.rb +202 -0
  28. data/lib/necropsy/entry_points/plain.rb +19 -2
  29. data/lib/necropsy/entry_points/rails.rb +121 -85
  30. data/lib/necropsy/graph/call_graph.rb +203 -21
  31. data/lib/necropsy/guardrail/baseline.rb +14 -5
  32. data/lib/necropsy/guardrail/diff.rb +5 -2
  33. data/lib/necropsy/guardrail/quarantine.rb +9 -3
  34. data/lib/necropsy/models.rb +19 -11
  35. data/lib/necropsy/project.rb +49 -3
  36. data/lib/necropsy/reachability/engine.rb +31 -7
  37. data/lib/necropsy/report.rb +44 -14
  38. data/lib/necropsy/reporter.rb +11 -5
  39. data/lib/necropsy/runner.rb +33 -4
  40. data/lib/necropsy/trace_point_runtime.rb +19 -0
  41. data/lib/necropsy/version.rb +1 -1
  42. data/lib/necropsy.rb +4 -0
  43. data/script/measure.rb +20 -0
  44. metadata +22 -2
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def callback_names(node)
8
+ names = symbol_arguments(node)
9
+ with = keyword_value(node, 'with')
10
+ names << with if with.is_a?(String)
11
+ names.uniq
12
+ end
13
+
14
+ def custom_validator_names(node)
15
+ keyword_keys(node).reject { |name| RAILS_BUILTIN_VALIDATORS.include?(name) }.map do |name|
16
+ name.split('_').map(&:capitalize).join
17
+ end
18
+ end
19
+
20
+ def delegated_method_name(name, target, prefix)
21
+ return name unless prefix
22
+
23
+ prefix_name = prefix == true ? target.to_s.delete_prefix('@') : prefix.to_s
24
+ "#{prefix_name}_#{name}"
25
+ end
26
+
27
+ def record_delegate_target(caller_id, target, node, context)
28
+ record_reference_call(caller_id, target.to_s.delete_prefix('@'), node, context, receiver_kind: :self)
29
+ end
30
+
31
+ def record_delegated_message(caller_id, message, node, context)
32
+ record_reference_call(caller_id, message, node, context, receiver_kind: :unknown)
33
+ end
34
+
35
+ def record_symbol_reference(node, context)
36
+ return unless SYMBOL_REFERENCE_CALLS.include?(node.name)
37
+
38
+ message = first_symbol_argument(node) || first_string_argument(node)
39
+ return unless message
40
+
41
+ receiver = classify_receiver(node.receiver, context)
42
+ record_reference_call(
43
+ context.current_caller_id,
44
+ message,
45
+ node,
46
+ context,
47
+ receiver_kind: receiver[:kind],
48
+ receiver_name: receiver[:name],
49
+ candidates: receiver[:candidates]
50
+ )
51
+ end
52
+
53
+ def record_symbol_to_proc(node, context)
54
+ block = node.block
55
+ return unless block.is_a?(Prism::BlockArgumentNode)
56
+ return unless block.expression.is_a?(Prism::SymbolNode)
57
+
58
+ record_reference_call(
59
+ context.current_caller_id,
60
+ block.expression.unescaped.to_s,
61
+ node,
62
+ context,
63
+ receiver_kind: :unknown
64
+ )
65
+ end
66
+
67
+ def record_reference_call(caller_id, message, node, context, receiver_kind:, receiver_name: nil, candidates: [])
68
+ call_sites << CallSite.new(
69
+ caller_id: caller_id,
70
+ message: message.to_s,
71
+ receiver_kind: receiver_kind,
72
+ receiver_name: receiver_name,
73
+ file: context.relative_file,
74
+ line: node.location.start_line,
75
+ test: context.test,
76
+ dynamic: false,
77
+ metadata: { 'symbol_reference' => true, 'receiver_candidates' => Array(candidates) }
78
+ )
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,177 @@
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: nil } unless receiver
9
+ return { kind: :self, name: context.owner } if receiver.is_a?(Prism::SelfNode)
10
+
11
+ constant = constant_name(receiver)
12
+ if constant
13
+ candidates = constant_candidates(constant, context.namespace)
14
+ return { kind: :constant, name: candidates.first, 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.namespace)
21
+ return { kind: :instance, name: candidates.first, 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 symbol_arguments(node)
37
+ arguments(node).filter_map do |arg|
38
+ next unless arg.is_a?(Prism::SymbolNode)
39
+
40
+ arg.unescaped.to_s
41
+ end
42
+ end
43
+
44
+ def arguments(node)
45
+ node.arguments&.arguments || []
46
+ end
47
+
48
+ def keyword_value(node, key)
49
+ hash = arguments(node).find { |argument| argument.is_a?(Prism::KeywordHashNode) }
50
+ pair = hash&.elements&.find do |element|
51
+ element.is_a?(Prism::AssocNode) && literal_value(element.key).to_s == key
52
+ end
53
+ literal_value(pair&.value)
54
+ end
55
+
56
+ def keyword_keys(node)
57
+ hash = arguments(node).find { |argument| argument.is_a?(Prism::KeywordHashNode) }
58
+ Array(hash&.elements).filter_map do |element|
59
+ literal_value(element.key).to_s if element.is_a?(Prism::AssocNode)
60
+ end
61
+ end
62
+
63
+ def literal_value(node)
64
+ case node
65
+ when Prism::SymbolNode, Prism::StringNode
66
+ node.unescaped.to_s
67
+ when Prism::TrueNode
68
+ true
69
+ when Prism::FalseNode
70
+ false
71
+ end
72
+ end
73
+
74
+ def constant_name(node)
75
+ return nil unless node
76
+
77
+ case node
78
+ when Prism::ConstantReadNode
79
+ node.name.to_s
80
+ when Prism::ConstantPathNode
81
+ prefix = node.parent ? constant_name(node.parent) : ''
82
+ [prefix, node.name.to_s].join('::')
83
+ end
84
+ end
85
+
86
+ def qualify_constant(name, namespace)
87
+ return nil unless name
88
+ return name.delete_prefix('::') if name.start_with?('::')
89
+ return name if namespace.nil? || namespace.empty? || name.include?('::')
90
+
91
+ "#{namespace}::#{name}"
92
+ end
93
+
94
+ def record_class_info(node, namespace, context)
95
+ return unless namespace
96
+
97
+ data = class_record(namespace)
98
+ data[:kind] = node.is_a?(Prism::ModuleNode) ? :module : :class
99
+ data[:file] = context.relative_file
100
+ data[:line] = node.location.start_line
101
+ return unless node.respond_to?(:superclass)
102
+
103
+ superclass = constant_name(node.superclass)
104
+ data[:superclass_candidates] = constant_candidates(superclass, context.namespace) if superclass
105
+ end
106
+
107
+ def class_record(namespace)
108
+ class_data[namespace] ||= {
109
+ id: namespace,
110
+ kind: :class,
111
+ file: nil,
112
+ line: nil,
113
+ superclass: nil,
114
+ superclass_candidates: [],
115
+ includes: [],
116
+ prepends: [],
117
+ extends: [],
118
+ dynamic: false
119
+ }
120
+ end
121
+
122
+ def class_infos
123
+ class_data.values.map do |data|
124
+ superclass_candidates = data.fetch(:superclass_candidates)
125
+ ClassInfo.new(
126
+ id: data.fetch(:id),
127
+ kind: data.fetch(:kind),
128
+ file: data[:file],
129
+ line: data[:line],
130
+ superclass: resolve_candidate_group(superclass_candidates),
131
+ superclass_candidates: superclass_candidates,
132
+ includes: resolve_candidate_groups(data.fetch(:includes)),
133
+ prepends: resolve_candidate_groups(data.fetch(:prepends)),
134
+ extends: resolve_candidate_groups(data.fetch(:extends)),
135
+ dynamic: data.fetch(:dynamic)
136
+ )
137
+ end
138
+ end
139
+
140
+ def resolve_candidate_groups(groups)
141
+ Array(groups).filter_map { |group| resolve_candidate_group(Array(group)) }.uniq
142
+ end
143
+
144
+ def resolve_candidate_group(group)
145
+ group.find { |candidate| class_data.key?(candidate) } || group.first
146
+ end
147
+
148
+ def constant_candidates(name, namespace)
149
+ return [name.delete_prefix('::')] if name.start_with?('::')
150
+ return [name] if namespace.nil? || namespace.empty?
151
+
152
+ head, *rest = name.split('::')
153
+ suffix = rest.empty? ? '' : "::#{rest.join('::')}"
154
+ parts = namespace.split('::')
155
+ candidates = []
156
+ parts.length.downto(1) do |length|
157
+ candidates << "#{parts.first(length).join('::')}::#{head}#{suffix}"
158
+ end
159
+ inherited_namespaces(namespace).each do |ancestor|
160
+ candidates << "#{ancestor}::#{head}#{suffix}"
161
+ end
162
+ candidates << name
163
+ candidates.uniq
164
+ end
165
+
166
+ def inherited_namespaces(namespace, seen = Set.new)
167
+ return [] unless namespace && seen.add?(namespace)
168
+
169
+ data = class_data[namespace]
170
+ return [] unless data
171
+
172
+ Array(data[:superclass_candidates]).flat_map do |superclass|
173
+ [superclass, *inherited_namespaces(superclass, seen)]
174
+ end.uniq
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def copy_module_function_call_sites
8
+ module_functions = nodes.select { |node| node.defined_via == :module_function }
9
+ copies = module_functions.flat_map do |node|
10
+ instance_id = "#{node.owner}##{node.name}"
11
+ call_sites.select { |site| site.caller_id == instance_id }.map do |site|
12
+ site.with(caller_id: node.id, metadata: site.metadata.merge('module_function' => true))
13
+ end
14
+ end
15
+ call_sites.concat(copies)
16
+ end
17
+
18
+ def scan_file(file)
19
+ relative = project.relative_path(file)
20
+ root_id = "file:#{relative}"
21
+ test = project.test_file?(file)
22
+ nodes << Node.new(
23
+ id: root_id,
24
+ kind: :block_entry,
25
+ file: relative,
26
+ line: 1,
27
+ end_line: 1,
28
+ defined_via: :file,
29
+ owner: nil,
30
+ name: relative,
31
+ test: test,
32
+ visibility: :public
33
+ )
34
+
35
+ result = Prism.parse(File.read(file))
36
+ record_parse_errors(root_id, result) if result.failure?
37
+
38
+ visit(
39
+ result.value,
40
+ Context.new(
41
+ namespace: nil,
42
+ owner: nil,
43
+ current_caller_id: root_id,
44
+ current_kind: :block_entry,
45
+ root_id: root_id,
46
+ file: file,
47
+ relative_file: relative,
48
+ test: test,
49
+ singleton_scope: false,
50
+ visibility: :public,
51
+ module_function: false
52
+ )
53
+ )
54
+ rescue SystemCallError, EncodingError => e
55
+ uncertainties[root_id] << "Could not parse #{relative}: #{e.message}"
56
+ end
57
+
58
+ def visit(node, context)
59
+ return unless node.respond_to?(:child_nodes)
60
+
61
+ case node
62
+ when Prism::ClassNode, Prism::ModuleNode
63
+ visit_namespace(node, context)
64
+ when Prism::DefNode
65
+ visit_def(node, context)
66
+ when Prism::CallNode
67
+ visit_call(node, context)
68
+ when Prism::AliasMethodNode
69
+ visit_alias_method_node(node, context)
70
+ when Prism::SuperNode, Prism::ForwardingSuperNode
71
+ visit_super(node, context)
72
+ when Prism::SingletonClassNode
73
+ visit_singleton_class(node, context)
74
+ when Prism::ConstantWriteNode
75
+ visit_constant_write(node, context)
76
+ else
77
+ node.child_nodes.compact.each { |child| visit(child, context) }
78
+ end
79
+ end
80
+
81
+ def visit_namespace(node, context)
82
+ namespace = qualify_constant(constant_name(node.constant_path), context.namespace)
83
+ record_class_info(node, namespace, context)
84
+ child_context = context.dup
85
+ child_context.namespace = namespace
86
+ child_context.owner = namespace
87
+ child_context.singleton_scope = false
88
+ child_context.visibility = :public
89
+ child_context.module_function = false
90
+ visit(node.body, child_context)
91
+ end
92
+
93
+ def visit_def(node, context)
94
+ owner = definition_owner(node, context)
95
+ return visit_children(node, context) unless owner
96
+
97
+ kind = node.receiver || context.singleton_scope ? :singleton_method : :instance_method
98
+ separator = kind == :singleton_method ? '.' : '#'
99
+ id = "#{owner}#{separator}#{node.name}"
100
+ nodes << Node.new(
101
+ id: id,
102
+ kind: kind,
103
+ file: context.relative_file,
104
+ line: node.location.start_line,
105
+ end_line: node.location.end_line,
106
+ defined_via: :def,
107
+ owner: owner,
108
+ name: node.name.to_s,
109
+ test: context.test,
110
+ visibility: node.receiver ? :public : context.visibility
111
+ )
112
+ record_module_function_copy(node, context, owner) if kind == :instance_method && context.module_function
113
+ if node.name == :method_missing
114
+ uncertainties[id] << "#{owner} defines method_missing"
115
+ class_record(owner)[:dynamic] = true
116
+ end
117
+
118
+ method_context = context.dup
119
+ method_context.owner = owner
120
+ method_context.current_caller_id = id
121
+ method_context.current_kind = kind
122
+ method_context.singleton_scope = false
123
+ method_context.visibility = :public
124
+ method_context.module_function = false
125
+ visit(node.body, method_context)
126
+ end
127
+
128
+ def visit_call(node, context)
129
+ return if handle_visibility(node, context)
130
+ return if handle_module_function(node, context)
131
+ return if handle_eval(node, context)
132
+ return if handle_define_singleton_method(node, context)
133
+ return if handle_define_method(node, context)
134
+ return if handle_attr_macro(node, context)
135
+ return if handle_delegate(node, context)
136
+ return if handle_forwardable(node, context)
137
+ return if handle_alias_method(node, context)
138
+
139
+ handle_module_relation(node, context)
140
+ handle_rails_callback(node, context)
141
+
142
+ record_instantiation(node, context)
143
+ record_symbol_reference(node, context)
144
+ record_symbol_to_proc(node, context)
145
+ site = build_call_site(node, context)
146
+ call_sites << site if site
147
+ if site&.dynamic
148
+ record_uncertainty(site)
149
+ elsif unresolved_dynamic_dispatch?(node)
150
+ record_uncertainty_at(node, context)
151
+ end
152
+
153
+ visit_children(node, context)
154
+ end
155
+
156
+ def visit_super(node, context)
157
+ method_name = context.current_caller_id&.split(/[.#]/)&.last
158
+ if method_name
159
+ call_sites << CallSite.new(
160
+ caller_id: context.current_caller_id,
161
+ message: method_name,
162
+ receiver_kind: :super,
163
+ receiver_name: context.owner,
164
+ file: context.relative_file,
165
+ line: node.location.start_line,
166
+ test: context.test,
167
+ dynamic: false,
168
+ metadata: { 'super' => true }
169
+ )
170
+ end
171
+ visit_children(node, context)
172
+ end
173
+
174
+ def visit_singleton_class(node, context)
175
+ return visit_children(node, context) unless node.expression.is_a?(Prism::SelfNode) && context.owner
176
+
177
+ singleton_context = context.dup
178
+ singleton_context.singleton_scope = true
179
+ singleton_context.visibility = :public
180
+ singleton_context.module_function = false
181
+ visit(node.body, singleton_context)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,68 @@
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
+ symbol_arguments(node.value).each do |name|
18
+ nodes << Node.new(
19
+ id: "#{owner}##{name}",
20
+ kind: :instance_method,
21
+ file: context.relative_file,
22
+ line: node.location.start_line,
23
+ end_line: node.location.end_line,
24
+ defined_via: node.value.name == :define ? :data_define : :struct_new,
25
+ owner: owner,
26
+ name: name,
27
+ test: context.test,
28
+ visibility: :public
29
+ )
30
+ next if node.value.name == :define
31
+
32
+ nodes << Node.new(
33
+ id: "#{owner}##{name}=",
34
+ kind: :instance_method,
35
+ file: context.relative_file,
36
+ line: node.location.start_line,
37
+ end_line: node.location.end_line,
38
+ defined_via: :struct_new,
39
+ owner: owner,
40
+ name: "#{name}=",
41
+ test: context.test,
42
+ visibility: :public
43
+ )
44
+ end
45
+
46
+ return unless node.value.block
47
+
48
+ block_context = context.dup
49
+ block_context.namespace = owner
50
+ block_context.owner = owner
51
+ block_context.singleton_scope = false
52
+ block_context.visibility = :public
53
+ block_context.module_function = false
54
+ visit(node.value.block.body, block_context)
55
+ end
56
+
57
+ def struct_or_data_definition?(value)
58
+ return false unless value.is_a?(Prism::CallNode)
59
+
60
+ receiver_name = constant_name(value.receiver)
61
+ (receiver_name == 'Struct' && value.name == :new) || (receiver_name == 'Data' && value.name == :define)
62
+ end
63
+
64
+ def visit_children(node, context)
65
+ node.child_nodes.compact.each { |child| visit(child, context) }
66
+ end
67
+ end
68
+ end