necropsy 0.1.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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +125 -0
  4. data/Rakefile +8 -0
  5. data/exe/necropsy +6 -0
  6. data/lib/necropsy/analyzer.rb +19 -0
  7. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
  8. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
  9. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
  10. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
  11. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
  12. data/lib/necropsy/analyzers/static/cha.rb +106 -0
  13. data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
  14. data/lib/necropsy/analyzers/static/rta.rb +79 -0
  15. data/lib/necropsy/ast_scanner.rb +616 -0
  16. data/lib/necropsy/bench/evaluator.rb +127 -0
  17. data/lib/necropsy/cache/scan_cache.rb +158 -0
  18. data/lib/necropsy/cli.rb +287 -0
  19. data/lib/necropsy/confidence/scorer.rb +158 -0
  20. data/lib/necropsy/configuration.rb +116 -0
  21. data/lib/necropsy/coverage_runtime.rb +13 -0
  22. data/lib/necropsy/entry_points/plain.rb +36 -0
  23. data/lib/necropsy/entry_points/rails.rb +335 -0
  24. data/lib/necropsy/entry_points/test.rb +13 -0
  25. data/lib/necropsy/graph/call_graph.rb +199 -0
  26. data/lib/necropsy/guardrail/baseline.rb +47 -0
  27. data/lib/necropsy/guardrail/diff.rb +16 -0
  28. data/lib/necropsy/guardrail/quarantine.rb +46 -0
  29. data/lib/necropsy/models.rb +164 -0
  30. data/lib/necropsy/project.rb +65 -0
  31. data/lib/necropsy/reachability/engine.rb +42 -0
  32. data/lib/necropsy/report.rb +50 -0
  33. data/lib/necropsy/reporter.rb +112 -0
  34. data/lib/necropsy/runner.rb +74 -0
  35. data/lib/necropsy/version.rb +5 -0
  36. data/lib/necropsy.rb +46 -0
  37. metadata +95 -0
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class CallGraph
5
+ attr_reader :nodes, :call_sites, :instantiated_classes, :entry_points, :profiles, :observation, :class_infos,
6
+ :entrypoint_hints
7
+
8
+ def initialize(scan_result)
9
+ @nodes = {}
10
+ @edges = Hash.new { |hash, key| hash[key] = {} }
11
+ @call_sites = scan_result.call_sites
12
+ @instantiated_classes = scan_result.instantiated_classes.dup
13
+ @class_infos = scan_result.class_infos.to_h { |info| [info.id, info] }
14
+ @entrypoint_hints = scan_result.entrypoint_hints
15
+ @entry_points = []
16
+ @profiles = []
17
+ @uncertainties = scan_result.uncertainties
18
+ @dynamic_alive = {}
19
+ @observation = {}
20
+ scan_result.nodes.each { |node| add_node(node) }
21
+ retain_known_instantiated_classes
22
+ end
23
+
24
+ def add_node(node)
25
+ nodes[node.id] ||= node
26
+ end
27
+
28
+ def add_entry_point(node_id, reason)
29
+ return unless nodes.key?(node_id)
30
+
31
+ entry = EntryPoint.new(node_id: node_id, reason: reason)
32
+ entry_points << entry unless entry_points.include?(entry)
33
+ end
34
+
35
+ def apply_result(result)
36
+ result.edge_evidences.each { |edge| add_edge(edge.caller_id, edge.callee_id, edge.evidence) }
37
+ result.alive_evidences.each { |alive| add_alive(alive.node_id, alive.evidence) }
38
+ result.uncertainties.each { |node_id, messages| uncertainties[node_id].concat(Array(messages)) }
39
+ observation.merge!(result.observation) { |_key, left, right| merge_observation(left, right) }
40
+ end
41
+
42
+ def add_profile(profile)
43
+ profiles << profile
44
+ end
45
+
46
+ def add_edge(caller_id, callee_id, evidence)
47
+ return unless nodes.key?(caller_id) && nodes.key?(callee_id)
48
+
49
+ @edges[caller_id][callee_id] ||= []
50
+ @edges[caller_id][callee_id] << evidence
51
+ end
52
+
53
+ def add_alive(node_id, evidence)
54
+ return unless nodes.key?(node_id)
55
+
56
+ @dynamic_alive[node_id] ||= []
57
+ @dynamic_alive[node_id] << evidence
58
+ end
59
+
60
+ def dynamic_alive?(node_id)
61
+ @dynamic_alive.key?(node_id)
62
+ end
63
+
64
+ def alive_evidences(node_id)
65
+ @dynamic_alive[node_id] || []
66
+ end
67
+
68
+ def dynamic_enabled?
69
+ @dynamic_alive.any? || observation.any?
70
+ end
71
+
72
+ def edges_from(node_id)
73
+ @edges[node_id] || {}
74
+ end
75
+
76
+ def edges
77
+ @edges.flat_map do |caller_id, callees|
78
+ callees.map do |callee_id, evidences|
79
+ Edge.new(caller_id: caller_id, callee_id: callee_id, evidences: evidences)
80
+ end
81
+ end
82
+ end
83
+
84
+ def incoming_edges(node_id)
85
+ edges.select { |edge| edge.callee_id == node_id }
86
+ end
87
+
88
+ def uncertainties(node_id = nil)
89
+ return @uncertainties unless node_id
90
+
91
+ @uncertainties[node_id] || []
92
+ end
93
+
94
+ def method_nodes
95
+ nodes.values.select(&:method?)
96
+ end
97
+
98
+ def class_info(owner)
99
+ class_infos[owner]
100
+ end
101
+
102
+ def descendants_of(owner)
103
+ class_infos.keys.select { |candidate| candidate == owner || ancestor_chain(candidate).include?(owner) }
104
+ end
105
+
106
+ def modules_for(owner)
107
+ info = class_info(owner)
108
+ return [] unless info
109
+
110
+ (info.prepends + info.includes + info.extends).uniq
111
+ end
112
+
113
+ def candidate_nodes(message)
114
+ method_nodes.select { |node| node.name == message }
115
+ end
116
+
117
+ def resolve_call_site(site, rta: false)
118
+ candidates = candidates_for_receiver(site)
119
+ candidates = candidates.select { |node| rta_candidate?(node, site) } if rta
120
+ candidates
121
+ end
122
+
123
+ def to_h
124
+ {
125
+ 'nodes' => nodes.values.map(&:to_h),
126
+ 'edges' => edges.map(&:to_h),
127
+ 'entry_points' => entry_points.map(&:to_h),
128
+ 'class_infos' => class_infos.values.map(&:to_h),
129
+ 'instantiated_classes' => instantiated_classes.to_a.sort,
130
+ 'profiles' => profiles.map(&:to_h),
131
+ 'observation' => observation
132
+ }
133
+ end
134
+
135
+ private
136
+
137
+ def candidates_for_receiver(site)
138
+ case site.receiver_kind
139
+ when :constant
140
+ exact = receiver_candidates(site).filter_map { |name| nodes["#{name}.#{site.message}"] }.first
141
+ exact ? [exact] : candidate_nodes(site.message)
142
+ when :instance
143
+ exact = receiver_candidates(site).filter_map { |name| nodes["#{name}##{site.message}"] }.first
144
+ exact ? [exact] : candidate_nodes(site.message)
145
+ when :self
146
+ same_owner_candidates(site)
147
+ when :implicit
148
+ same_owner_candidates(site).then { |matches| matches.empty? ? candidate_nodes(site.message) : matches }
149
+ else
150
+ candidate_nodes(site.message)
151
+ end
152
+ end
153
+
154
+ def same_owner_candidates(site)
155
+ caller = nodes[site.caller_id]
156
+ return [] unless caller&.owner
157
+
158
+ ids = [
159
+ "#{caller.owner}##{site.message}",
160
+ "#{caller.owner}.#{site.message}"
161
+ ]
162
+ ids.filter_map { |id| nodes[id] }
163
+ end
164
+
165
+ def receiver_candidates(site)
166
+ candidates = site.metadata['receiver_candidates'] || site.metadata[:receiver_candidates]
167
+ Array(candidates).compact.empty? ? [site.receiver_name].compact : Array(candidates).compact
168
+ end
169
+
170
+ def rta_candidate?(node, site)
171
+ return true unless node.kind == :instance_method
172
+ return true if node.owner == nodes[site.caller_id]&.owner
173
+ return true if class_info(node.owner)&.dynamic
174
+
175
+ instantiated_classes.include?(node.owner)
176
+ end
177
+
178
+ def ancestor_chain(owner)
179
+ chain = []
180
+ current = class_info(owner)&.superclass
181
+ while current && !chain.include?(current)
182
+ chain << current
183
+ current = class_info(current)&.superclass
184
+ end
185
+ chain
186
+ end
187
+
188
+ def merge_observation(left, right)
189
+ return right unless left.is_a?(Hash) && right.is_a?(Hash)
190
+
191
+ left.merge(right)
192
+ end
193
+
194
+ def retain_known_instantiated_classes
195
+ known_owners = method_nodes.map(&:owner).compact.to_set
196
+ instantiated_classes.select! { |name| known_owners.include?(name) }
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'time'
5
+
6
+ module Necropsy
7
+ module Guardrail
8
+ class Baseline
9
+ attr_reader :path, :fingerprints
10
+
11
+ def self.load(path)
12
+ return new(path: path, fingerprints: []) unless File.exist?(path)
13
+
14
+ payload = YAML.load_file(path) || {}
15
+ new(path: path, fingerprints: Array(payload['findings']).map { |finding| finding['fingerprint'] })
16
+ end
17
+
18
+ def self.write(report, path:)
19
+ findings = report.findings.map do |finding|
20
+ {
21
+ 'fingerprint' => finding.fingerprint,
22
+ 'classification' => finding.classification.to_s,
23
+ 'confidence' => finding.confidence.to_s,
24
+ 'node_id' => finding.node.id,
25
+ 'file' => finding.node.file,
26
+ 'line' => finding.node.line
27
+ }
28
+ end
29
+ payload = {
30
+ 'version' => 1,
31
+ 'generated_at' => Time.now.utc.iso8601,
32
+ 'findings' => findings
33
+ }
34
+ File.write(path, payload.to_yaml)
35
+ end
36
+
37
+ def initialize(path:, fingerprints:)
38
+ @path = path
39
+ @fingerprints = fingerprints
40
+ end
41
+
42
+ def include?(finding)
43
+ fingerprints.include?(finding.fingerprint)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Necropsy
6
+ module Guardrail
7
+ class Diff
8
+ def self.changed_files(root:, diff_base:)
9
+ stdout, status = Open3.capture2('git', '-C', root, 'diff', '--name-only', "#{diff_base}...HEAD")
10
+ return Set.new unless status.success?
11
+
12
+ stdout.lines.map(&:strip).reject(&:empty?).to_set
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Necropsy
6
+ module Guardrail
7
+ class Quarantine
8
+ ANNOTATION_PREFIX = '# necropsy:quarantine'
9
+
10
+ def initialize(report:, root:)
11
+ @report = report
12
+ @root = root
13
+ end
14
+
15
+ def suggestions(min_confidence: :high)
16
+ report.dead_methods(min_confidence: min_confidence).map do |finding|
17
+ {
18
+ finding: finding,
19
+ annotation: "#{ANNOTATION_PREFIX} since=#{Date.today.iso8601}",
20
+ path: File.join(root, finding.node.file),
21
+ line: finding.node.line
22
+ }
23
+ end
24
+ end
25
+
26
+ def write(min_confidence: :high)
27
+ grouped = suggestions(min_confidence: min_confidence).group_by { |suggestion| suggestion[:path] }
28
+ grouped.each do |path, entries|
29
+ lines = File.readlines(path, chomp: true)
30
+ entries.sort_by { |entry| -entry[:line] }.each do |entry|
31
+ index = [entry[:line] - 1, 0].max
32
+ next if lines[index - 1]&.include?(ANNOTATION_PREFIX)
33
+
34
+ indent = lines[index][/^\s*/] || ''
35
+ lines.insert(index, "#{indent}#{entry[:annotation]}")
36
+ end
37
+ File.write(path, "#{lines.join("\n")}\n")
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :report, :root
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Necropsy
6
+ Node = Data.define(:id, :kind, :file, :line, :end_line, :defined_via, :owner, :name, :test) do
7
+ def method?
8
+ kind != :block_entry
9
+ end
10
+
11
+ def fingerprint(classification)
12
+ Digest::SHA256.hexdigest("#{classification}:#{id}")
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ 'id' => id,
18
+ 'kind' => kind.to_s,
19
+ 'file' => file,
20
+ 'line' => line,
21
+ 'end_line' => end_line,
22
+ 'defined_via' => defined_via.to_s,
23
+ 'owner' => owner,
24
+ 'name' => name,
25
+ 'test' => test
26
+ }
27
+ end
28
+ end
29
+
30
+ Evidence = Data.define(:analyzer, :kind, :weight, :details, :metadata) do
31
+ def to_h
32
+ {
33
+ 'analyzer' => analyzer.to_s,
34
+ 'kind' => kind.to_s,
35
+ 'weight' => weight,
36
+ 'details' => details,
37
+ 'metadata' => metadata
38
+ }
39
+ end
40
+ end
41
+
42
+ Edge = Data.define(:caller_id, :callee_id, :evidences) do
43
+ def to_h
44
+ {
45
+ 'caller_id' => caller_id,
46
+ 'callee_id' => callee_id,
47
+ 'evidences' => evidences.map(&:to_h)
48
+ }
49
+ end
50
+ end
51
+
52
+ EntryPoint = Data.define(:node_id, :reason) do
53
+ def test?
54
+ reason == :test_suite
55
+ end
56
+
57
+ def to_h
58
+ { 'node_id' => node_id, 'reason' => reason.to_s }
59
+ end
60
+ end
61
+
62
+ ClassInfo = Data.define(
63
+ :id,
64
+ :kind,
65
+ :file,
66
+ :line,
67
+ :superclass,
68
+ :superclass_candidates,
69
+ :includes,
70
+ :prepends,
71
+ :extends,
72
+ :dynamic
73
+ ) do
74
+ def to_h
75
+ {
76
+ 'id' => id,
77
+ 'kind' => kind.to_s,
78
+ 'file' => file,
79
+ 'line' => line,
80
+ 'superclass' => superclass,
81
+ 'superclass_candidates' => superclass_candidates,
82
+ 'includes' => includes,
83
+ 'prepends' => prepends,
84
+ 'extends' => extends,
85
+ 'dynamic' => dynamic
86
+ }
87
+ end
88
+ end
89
+
90
+ CallSite = Data.define(
91
+ :caller_id,
92
+ :message,
93
+ :receiver_kind,
94
+ :receiver_name,
95
+ :file,
96
+ :line,
97
+ :test,
98
+ :dynamic,
99
+ :metadata
100
+ ) do
101
+ def to_h
102
+ {
103
+ 'caller_id' => caller_id,
104
+ 'message' => message,
105
+ 'receiver_kind' => receiver_kind.to_s,
106
+ 'receiver_name' => receiver_name,
107
+ 'file' => file,
108
+ 'line' => line,
109
+ 'test' => test,
110
+ 'dynamic' => dynamic,
111
+ 'metadata' => metadata
112
+ }
113
+ end
114
+ end
115
+
116
+ AnalyzerProfile = Data.define(:name, :kind, :soundness, :description) do
117
+ def to_h
118
+ {
119
+ 'name' => name.to_s,
120
+ 'kind' => kind.to_s,
121
+ 'soundness' => soundness.to_s,
122
+ 'description' => description
123
+ }
124
+ end
125
+ end
126
+
127
+ EdgeEvidence = Data.define(:caller_id, :callee_id, :evidence)
128
+ AliveEvidence = Data.define(:node_id, :evidence)
129
+
130
+ AnalyzerResult = Data.define(:edge_evidences, :alive_evidences, :uncertainties, :observation) do
131
+ def self.empty
132
+ new(edge_evidences: [], alive_evidences: [], uncertainties: {}, observation: {})
133
+ end
134
+ end
135
+
136
+ Finding = Data.define(:node, :classification, :confidence, :score, :reasons, :evidences) do
137
+ LEVELS = {
138
+ low: 0,
139
+ medium: 1,
140
+ high: 2,
141
+ certain: 3
142
+ }.freeze
143
+
144
+ def at_least?(level)
145
+ LEVELS.fetch(confidence) >= LEVELS.fetch(level)
146
+ end
147
+
148
+ def fingerprint
149
+ node.fingerprint(classification)
150
+ end
151
+
152
+ def to_h
153
+ {
154
+ 'fingerprint' => fingerprint,
155
+ 'classification' => classification.to_s,
156
+ 'confidence' => confidence.to_s,
157
+ 'score' => score,
158
+ 'node' => node.to_h,
159
+ 'reasons' => reasons,
160
+ 'evidences' => evidences.map(&:to_h)
161
+ }
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Necropsy
6
+ class Project
7
+ EXCLUDED_DIRECTORIES = %w[
8
+ .bundle
9
+ .git
10
+ .serena
11
+ coverage
12
+ doc
13
+ node_modules
14
+ pkg
15
+ tmp
16
+ vendor
17
+ ].freeze
18
+
19
+ attr_reader :root, :config
20
+
21
+ def initialize(root:, config:)
22
+ @root = File.expand_path(root)
23
+ @config = config
24
+ end
25
+
26
+ def scan_result
27
+ @scan_result ||= Cache::ScanCache.new(project: self).fetch(ruby_files) do
28
+ AstScanner.new(project: self, files: ruby_files).scan
29
+ end
30
+ end
31
+
32
+ def ruby_files
33
+ @ruby_files ||= begin
34
+ globbed = Dir.glob(File.join(root, '**', '*.rb'))
35
+ special = %w[Rakefile].map { |name| File.join(root, name) }.select { |file| File.file?(file) }
36
+ rake = Dir.glob(File.join(root, '**', '*.rake'))
37
+ executables = Dir.glob(File.join(root, '{bin,exe}', '*')).select { |file| File.file?(file) }
38
+ gemspecs = Dir.glob(File.join(root, '*.gemspec'))
39
+ (globbed + special + rake + executables + gemspecs).uniq.select { |file| analyzable_file?(file) }.sort
40
+ end
41
+ end
42
+
43
+ def test_file?(file)
44
+ relative = relative_path(file)
45
+ relative.start_with?('spec/', 'test/')
46
+ end
47
+
48
+ def relative_path(file)
49
+ Pathname.new(File.expand_path(file)).relative_path_from(Pathname.new(root)).to_s
50
+ end
51
+
52
+ def changed_files(diff_base)
53
+ Guardrail::Diff.changed_files(root: root, diff_base: diff_base)
54
+ end
55
+
56
+ private
57
+
58
+ def analyzable_file?(file)
59
+ relative_parts = relative_path(file).split(File::SEPARATOR)
60
+ !relative_parts.intersect?(EXCLUDED_DIRECTORIES)
61
+ rescue ArgumentError
62
+ false
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module Reachability
5
+ Result = Data.define(:runtime_alive, :test_alive)
6
+
7
+ class Engine
8
+ def initialize(graph)
9
+ @graph = graph
10
+ end
11
+
12
+ def call
13
+ runtime_roots = graph.entry_points.reject(&:test?).map(&:node_id)
14
+ test_roots = graph.entry_points.select(&:test?).map(&:node_id)
15
+
16
+ Result.new(
17
+ runtime_alive: traverse(runtime_roots),
18
+ test_alive: traverse(test_roots)
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :graph
25
+
26
+ def traverse(roots)
27
+ visited = Set.new
28
+ queue = roots.compact.uniq
29
+
30
+ until queue.empty?
31
+ node_id = queue.shift
32
+ next if visited.include?(node_id)
33
+
34
+ visited << node_id
35
+ graph.edges_from(node_id).each_key { |callee_id| queue << callee_id unless visited.include?(callee_id) }
36
+ end
37
+
38
+ visited
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module Necropsy
7
+ class Report
8
+ attr_reader :root, :graph, :findings
9
+
10
+ def initialize(root:, graph:, findings:)
11
+ @root = root
12
+ @graph = graph
13
+ @findings = findings
14
+ end
15
+
16
+ def dead_methods(min_confidence: :low)
17
+ findings.select { |finding| finding.at_least?(min_confidence) }
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ 'root' => root,
23
+ 'summary' => summary,
24
+ 'findings' => findings.map(&:to_h),
25
+ 'graph' => graph.to_h
26
+ }
27
+ end
28
+
29
+ def to_json(*)
30
+ JSON.pretty_generate(to_h, *)
31
+ end
32
+
33
+ def to_yaml
34
+ to_h.to_yaml
35
+ end
36
+
37
+ def summary
38
+ grouped = findings.group_by(&:classification)
39
+ {
40
+ 'nodes' => graph.nodes.length,
41
+ 'edges' => graph.edges.length,
42
+ 'entry_points' => graph.entry_points.length,
43
+ 'findings' => findings.length,
44
+ 'unreachable' => grouped.fetch(:unreachable, []).length,
45
+ 'unused' => grouped.fetch(:unused, []).length,
46
+ 'test_only_reachable' => grouped.fetch(:test_only_reachable, []).length
47
+ }
48
+ end
49
+ end
50
+ end