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.
- checksums.yaml +4 -4
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +18 -0
- data/MEASUREMENTS.md +85 -0
- data/README.md +60 -8
- data/Rakefile +3 -1
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +12 -2
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +5 -1
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +68 -14
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +107 -12
- data/lib/necropsy/analyzers/static/cha.rb +10 -6
- data/lib/necropsy/analyzers/static/name_resolution.rb +29 -20
- data/lib/necropsy/analyzers/static/rta.rb +19 -3
- data/lib/necropsy/ast_scanner/call_recording.rb +156 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +142 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +176 -0
- data/lib/necropsy/ast_scanner/references.rb +81 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +177 -0
- data/lib/necropsy/ast_scanner/traversal.rb +184 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +68 -0
- data/lib/necropsy/ast_scanner.rb +24 -560
- data/lib/necropsy/bench/evaluator.rb +29 -10
- data/lib/necropsy/cache/scan_cache.rb +20 -12
- data/lib/necropsy/cli.rb +88 -32
- data/lib/necropsy/confidence/scorer.rb +97 -19
- data/lib/necropsy/configuration.rb +125 -7
- data/lib/necropsy/diagnostics.rb +202 -0
- data/lib/necropsy/entry_points/plain.rb +19 -2
- data/lib/necropsy/entry_points/rails.rb +121 -85
- data/lib/necropsy/graph/call_graph.rb +203 -21
- data/lib/necropsy/guardrail/baseline.rb +14 -5
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +9 -3
- data/lib/necropsy/models.rb +19 -11
- data/lib/necropsy/project.rb +49 -3
- data/lib/necropsy/reachability/engine.rb +31 -7
- data/lib/necropsy/report.rb +44 -14
- data/lib/necropsy/reporter.rb +11 -5
- data/lib/necropsy/runner.rb +33 -4
- data/lib/necropsy/trace_point_runtime.rb +19 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy.rb +4 -0
- data/script/measure.rb +20 -0
- metadata +22 -2
|
@@ -9,10 +9,10 @@ module Necropsy
|
|
|
9
9
|
attr_reader :path, :fingerprints
|
|
10
10
|
|
|
11
11
|
def self.load(path)
|
|
12
|
-
return new(path: path,
|
|
12
|
+
return new(path: path, findings: []) unless File.exist?(path)
|
|
13
13
|
|
|
14
|
-
payload = YAML.
|
|
15
|
-
new(path: path,
|
|
14
|
+
payload = YAML.safe_load_file(path, aliases: false) || {}
|
|
15
|
+
new(path: path, findings: Array(payload['findings']))
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def self.write(report, path:)
|
|
@@ -34,14 +34,23 @@ module Necropsy
|
|
|
34
34
|
File.write(path, payload.to_yaml)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def initialize(path:,
|
|
37
|
+
def initialize(path:, findings:)
|
|
38
38
|
@path = path
|
|
39
|
-
@
|
|
39
|
+
@findings = findings
|
|
40
|
+
@fingerprints = findings.filter_map { |finding| finding['fingerprint'] }.to_set
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def include?(finding)
|
|
43
44
|
fingerprints.include?(finding.fingerprint)
|
|
44
45
|
end
|
|
46
|
+
|
|
47
|
+
def count_at_least(confidence)
|
|
48
|
+
threshold = CONFIDENCE_LEVELS.fetch(confidence)
|
|
49
|
+
@findings.count do |finding|
|
|
50
|
+
level = finding['confidence']&.to_sym
|
|
51
|
+
level && CONFIDENCE_LEVELS.fetch(level, -1) >= threshold
|
|
52
|
+
end
|
|
53
|
+
end
|
|
45
54
|
end
|
|
46
55
|
end
|
|
47
56
|
end
|
|
@@ -6,8 +6,11 @@ module Necropsy
|
|
|
6
6
|
module Guardrail
|
|
7
7
|
class Diff
|
|
8
8
|
def self.changed_files(root:, diff_base:)
|
|
9
|
-
stdout, status = Open3.
|
|
10
|
-
|
|
9
|
+
stdout, stderr, status = Open3.capture3('git', '-C', root, 'diff', '--name-only', "#{diff_base}...HEAD")
|
|
10
|
+
unless status.success?
|
|
11
|
+
detail = stderr.strip.empty? ? 'unknown git error' : stderr.strip
|
|
12
|
+
raise Error, "Could not determine changed files from #{diff_base}: #{detail}"
|
|
13
|
+
end
|
|
11
14
|
|
|
12
15
|
stdout.lines.map(&:strip).reject(&:empty?).to_set
|
|
13
16
|
end
|
|
@@ -26,15 +26,21 @@ module Necropsy
|
|
|
26
26
|
def write(min_confidence: :high)
|
|
27
27
|
grouped = suggestions(min_confidence: min_confidence).group_by { |suggestion| suggestion[:path] }
|
|
28
28
|
grouped.each do |path, entries|
|
|
29
|
-
|
|
29
|
+
source = File.binread(path)
|
|
30
|
+
newline = source[/\r\n|\n/] || "\n"
|
|
31
|
+
trailing_newline = source.end_with?("\r\n", "\n")
|
|
32
|
+
lines = source.split(/\r\n|\n/, -1)
|
|
33
|
+
lines.pop if trailing_newline
|
|
30
34
|
entries.sort_by { |entry| -entry[:line] }.each do |entry|
|
|
31
35
|
index = [entry[:line] - 1, 0].max
|
|
32
|
-
next if lines[index - 1]&.include?(ANNOTATION_PREFIX)
|
|
36
|
+
next if index.positive? && lines[index - 1]&.include?(ANNOTATION_PREFIX)
|
|
33
37
|
|
|
34
38
|
indent = lines[index][/^\s*/] || ''
|
|
35
39
|
lines.insert(index, "#{indent}#{entry[:annotation]}")
|
|
36
40
|
end
|
|
37
|
-
|
|
41
|
+
rewritten = lines.join(newline)
|
|
42
|
+
rewritten << newline if trailing_newline
|
|
43
|
+
File.binwrite(path, rewritten)
|
|
38
44
|
end
|
|
39
45
|
end
|
|
40
46
|
|
data/lib/necropsy/models.rb
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
require 'digest'
|
|
4
4
|
|
|
5
5
|
module Necropsy
|
|
6
|
-
|
|
6
|
+
CONFIDENCE_LEVELS = {
|
|
7
|
+
low: 0,
|
|
8
|
+
medium: 1,
|
|
9
|
+
high: 2,
|
|
10
|
+
certain: 3
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
Node = Data.define(:id, :kind, :file, :line, :end_line, :defined_via, :owner, :name, :test, :visibility) do
|
|
7
14
|
def method?
|
|
8
15
|
kind != :block_entry
|
|
9
16
|
end
|
|
@@ -22,7 +29,8 @@ module Necropsy
|
|
|
22
29
|
'defined_via' => defined_via.to_s,
|
|
23
30
|
'owner' => owner,
|
|
24
31
|
'name' => name,
|
|
25
|
-
'test' => test
|
|
32
|
+
'test' => test,
|
|
33
|
+
'visibility' => visibility.to_s
|
|
26
34
|
}
|
|
27
35
|
end
|
|
28
36
|
end
|
|
@@ -127,22 +135,21 @@ module Necropsy
|
|
|
127
135
|
EdgeEvidence = Data.define(:caller_id, :callee_id, :evidence)
|
|
128
136
|
AliveEvidence = Data.define(:node_id, :evidence)
|
|
129
137
|
|
|
138
|
+
ScoreComponent = Data.define(:name, :value, :details) do
|
|
139
|
+
def to_h
|
|
140
|
+
{ 'name' => name, 'value' => value, 'details' => details }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
130
144
|
AnalyzerResult = Data.define(:edge_evidences, :alive_evidences, :uncertainties, :observation) do
|
|
131
145
|
def self.empty
|
|
132
146
|
new(edge_evidences: [], alive_evidences: [], uncertainties: {}, observation: {})
|
|
133
147
|
end
|
|
134
148
|
end
|
|
135
149
|
|
|
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
|
-
|
|
150
|
+
Finding = Data.define(:node, :classification, :confidence, :score, :score_components, :reasons, :evidences) do
|
|
144
151
|
def at_least?(level)
|
|
145
|
-
|
|
152
|
+
CONFIDENCE_LEVELS.fetch(confidence) >= CONFIDENCE_LEVELS.fetch(level)
|
|
146
153
|
end
|
|
147
154
|
|
|
148
155
|
def fingerprint
|
|
@@ -155,6 +162,7 @@ module Necropsy
|
|
|
155
162
|
'classification' => classification.to_s,
|
|
156
163
|
'confidence' => confidence.to_s,
|
|
157
164
|
'score' => score,
|
|
165
|
+
'score_components' => score_components.map(&:to_h),
|
|
158
166
|
'node' => node.to_h,
|
|
159
167
|
'reasons' => reasons,
|
|
160
168
|
'evidences' => evidences.map(&:to_h)
|
data/lib/necropsy/project.rb
CHANGED
|
@@ -7,7 +7,6 @@ module Necropsy
|
|
|
7
7
|
EXCLUDED_DIRECTORIES = %w[
|
|
8
8
|
.bundle
|
|
9
9
|
.git
|
|
10
|
-
.serena
|
|
11
10
|
coverage
|
|
12
11
|
doc
|
|
13
12
|
node_modules
|
|
@@ -36,7 +35,12 @@ module Necropsy
|
|
|
36
35
|
rake = Dir.glob(File.join(root, '**', '*.rake'))
|
|
37
36
|
executables = Dir.glob(File.join(root, '{bin,exe}', '*')).select { |file| File.file?(file) }
|
|
38
37
|
gemspecs = Dir.glob(File.join(root, '*.gemspec'))
|
|
39
|
-
(globbed + special + rake + executables + gemspecs).uniq
|
|
38
|
+
candidates = (globbed + special + rake + executables + gemspecs).uniq
|
|
39
|
+
candidates.select! { |file| analyzable_file?(file) }
|
|
40
|
+
warn_excluded_entry_points(candidates) if config.include_paths.any?
|
|
41
|
+
candidates.select! { |file| included_path?(relative_path(file)) } if config.include_paths.any?
|
|
42
|
+
candidates.reject! { |file| excluded_path?(relative_path(file)) }
|
|
43
|
+
candidates.sort
|
|
40
44
|
end
|
|
41
45
|
end
|
|
42
46
|
|
|
@@ -57,9 +61,51 @@ module Necropsy
|
|
|
57
61
|
|
|
58
62
|
def analyzable_file?(file)
|
|
59
63
|
relative_parts = relative_path(file).split(File::SEPARATOR)
|
|
60
|
-
|
|
64
|
+
return false if EXCLUDED_DIRECTORIES.include?(relative_parts.first)
|
|
65
|
+
|
|
66
|
+
ruby_source?(file)
|
|
61
67
|
rescue ArgumentError
|
|
62
68
|
false
|
|
63
69
|
end
|
|
70
|
+
|
|
71
|
+
def ruby_source?(file)
|
|
72
|
+
return true if file.end_with?('.rb', '.rake', '.gemspec') || File.basename(file) == 'Rakefile'
|
|
73
|
+
|
|
74
|
+
File.open(file, &:readline).match?(/\A#!.*\bruby\b/)
|
|
75
|
+
rescue EOFError, SystemCallError, EncodingError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def included_path?(relative)
|
|
80
|
+
config.include_paths.any? { |pattern| path_matches?(pattern, relative) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def excluded_path?(relative)
|
|
84
|
+
config.exclude_paths.any? { |pattern| path_matches?(pattern, relative) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def path_matches?(pattern, relative)
|
|
88
|
+
File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
89
|
+
File.fnmatch?(File.join(pattern, '**', '*'), relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def warn_excluded_entry_points(candidates)
|
|
93
|
+
excluded = candidates.filter_map do |file|
|
|
94
|
+
relative = relative_path(file)
|
|
95
|
+
relative if potential_entry_point_path?(relative) && !included_path?(relative)
|
|
96
|
+
end
|
|
97
|
+
return if excluded.empty?
|
|
98
|
+
|
|
99
|
+
sample = excluded.sort.first(5)
|
|
100
|
+
suffix = excluded.length > sample.length ? " and #{excluded.length - sample.length} more" : ''
|
|
101
|
+
warn "Necropsy paths.include excludes potential entry points: #{sample.join(', ')}#{suffix}. " \
|
|
102
|
+
'Use report.include to filter findings without narrowing analysis.'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def potential_entry_point_path?(relative)
|
|
106
|
+
relative == 'Rakefile' || relative == 'config/routes.rb' ||
|
|
107
|
+
relative.start_with?('bin/', 'exe/', 'spec/', 'test/') ||
|
|
108
|
+
relative.end_with?('.rake', '.gemspec')
|
|
109
|
+
end
|
|
64
110
|
end
|
|
65
111
|
end
|
|
@@ -2,7 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Necropsy
|
|
4
4
|
module Reachability
|
|
5
|
-
Result = Data.define(:
|
|
5
|
+
Result = Data.define(:runtime_paths, :test_paths) do
|
|
6
|
+
def runtime_alive
|
|
7
|
+
runtime_paths.keys
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def test_alive
|
|
11
|
+
test_paths.keys
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def witness(node_id, kind: :runtime)
|
|
15
|
+
paths = kind == :runtime ? runtime_paths : test_paths
|
|
16
|
+
return unless paths.key?(node_id)
|
|
17
|
+
|
|
18
|
+
chain = []
|
|
19
|
+
current = node_id
|
|
20
|
+
while current
|
|
21
|
+
chain.unshift(current)
|
|
22
|
+
current = paths[current]
|
|
23
|
+
end
|
|
24
|
+
chain
|
|
25
|
+
end
|
|
26
|
+
end
|
|
6
27
|
|
|
7
28
|
class Engine
|
|
8
29
|
def initialize(graph)
|
|
@@ -14,8 +35,8 @@ module Necropsy
|
|
|
14
35
|
test_roots = graph.entry_points.select(&:test?).map(&:node_id)
|
|
15
36
|
|
|
16
37
|
Result.new(
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
runtime_paths: traverse(runtime_roots),
|
|
39
|
+
test_paths: traverse(test_roots)
|
|
19
40
|
)
|
|
20
41
|
end
|
|
21
42
|
|
|
@@ -24,15 +45,18 @@ module Necropsy
|
|
|
24
45
|
attr_reader :graph
|
|
25
46
|
|
|
26
47
|
def traverse(roots)
|
|
27
|
-
visited =
|
|
48
|
+
visited = {}
|
|
28
49
|
queue = roots.compact.uniq
|
|
50
|
+
queue.each { |node_id| visited[node_id] = nil }
|
|
29
51
|
|
|
30
52
|
until queue.empty?
|
|
31
53
|
node_id = queue.shift
|
|
32
|
-
|
|
54
|
+
graph.edges_from(node_id).each_key do |callee_id|
|
|
55
|
+
next if visited.key?(callee_id)
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
visited[callee_id] = node_id
|
|
58
|
+
queue << callee_id
|
|
59
|
+
end
|
|
36
60
|
end
|
|
37
61
|
|
|
38
62
|
visited
|
data/lib/necropsy/report.rb
CHANGED
|
@@ -5,46 +5,76 @@ require 'yaml'
|
|
|
5
5
|
|
|
6
6
|
module Necropsy
|
|
7
7
|
class Report
|
|
8
|
-
attr_reader :root, :graph, :findings
|
|
8
|
+
attr_reader :root, :graph, :findings, :reachability
|
|
9
9
|
|
|
10
|
-
def initialize(root:, graph:, findings:)
|
|
10
|
+
def initialize(root:, graph:, findings:, reachability: nil, report_include_paths: [], report_exclude_paths: [])
|
|
11
11
|
@root = root
|
|
12
12
|
@graph = graph
|
|
13
|
-
@findings = findings
|
|
13
|
+
@findings = findings.sort_by { |finding| [finding.node.file, finding.node.line, finding.node.id] }
|
|
14
|
+
@reachability = reachability
|
|
15
|
+
@report_include_paths = report_include_paths
|
|
16
|
+
@report_exclude_paths = report_exclude_paths
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def dead_methods(min_confidence: :low)
|
|
17
|
-
|
|
20
|
+
reported_findings.select { |finding| finding.at_least?(min_confidence) }
|
|
18
21
|
end
|
|
19
22
|
|
|
20
|
-
def to_h
|
|
21
|
-
{
|
|
23
|
+
def to_h(include_graph: false)
|
|
24
|
+
payload = {
|
|
22
25
|
'root' => root,
|
|
23
26
|
'summary' => summary,
|
|
24
|
-
'findings' =>
|
|
25
|
-
'graph' => graph.to_h
|
|
27
|
+
'findings' => reported_findings.map(&:to_h)
|
|
26
28
|
}
|
|
29
|
+
payload['graph'] = graph.to_h if include_graph
|
|
30
|
+
payload
|
|
27
31
|
end
|
|
28
32
|
|
|
29
|
-
def to_json(
|
|
30
|
-
|
|
33
|
+
def to_json(state = nil, include_graph: false)
|
|
34
|
+
payload = to_h(include_graph: include_graph)
|
|
35
|
+
return JSON.pretty_generate(payload) unless state
|
|
36
|
+
|
|
37
|
+
payload.to_json(state)
|
|
31
38
|
end
|
|
32
39
|
|
|
33
|
-
def to_yaml
|
|
34
|
-
to_h.to_yaml
|
|
40
|
+
def to_yaml(include_graph: false)
|
|
41
|
+
to_h(include_graph: include_graph).to_yaml
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
def summary
|
|
38
|
-
grouped =
|
|
45
|
+
grouped = reported_findings.group_by(&:classification)
|
|
39
46
|
{
|
|
40
47
|
'nodes' => graph.nodes.length,
|
|
41
48
|
'edges' => graph.edges.length,
|
|
42
49
|
'entry_points' => graph.entry_points.length,
|
|
43
|
-
'findings' =>
|
|
50
|
+
'findings' => reported_findings.length,
|
|
44
51
|
'unreachable' => grouped.fetch(:unreachable, []).length,
|
|
45
52
|
'unused' => grouped.fetch(:unused, []).length,
|
|
46
53
|
'test_only_reachable' => grouped.fetch(:test_only_reachable, []).length
|
|
47
54
|
}
|
|
48
55
|
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
attr_reader :report_include_paths, :report_exclude_paths
|
|
60
|
+
|
|
61
|
+
def reported_findings
|
|
62
|
+
@reported_findings ||= findings.select do |finding|
|
|
63
|
+
included_in_report?(finding.node.file) && !excluded_from_report?(finding.node.file)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def included_in_report?(path)
|
|
68
|
+
report_include_paths.empty? || report_include_paths.any? { |pattern| path_matches?(pattern, path) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def excluded_from_report?(path)
|
|
72
|
+
report_exclude_paths.any? { |pattern| path_matches?(pattern, path) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def path_matches?(pattern, path)
|
|
76
|
+
File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
77
|
+
File.fnmatch?(File.join(pattern, '**', '*'), path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
78
|
+
end
|
|
49
79
|
end
|
|
50
80
|
end
|
data/lib/necropsy/reporter.rb
CHANGED
|
@@ -2,21 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
module Necropsy
|
|
4
4
|
class Reporter
|
|
5
|
+
FORMATS = %i[human json yaml yml sarif github annotations].freeze
|
|
6
|
+
DEFAULT_MIN_CONFIDENCE = :medium
|
|
7
|
+
|
|
5
8
|
def initialize(report)
|
|
6
9
|
@report = report
|
|
7
10
|
end
|
|
8
11
|
|
|
9
|
-
def render(format: :human, min_confidence: :
|
|
10
|
-
|
|
12
|
+
def render(format: :human, min_confidence: DEFAULT_MIN_CONFIDENCE, include_graph: false)
|
|
13
|
+
normalized_format = format.to_sym
|
|
14
|
+
raise Error, "Unknown report format: #{format}" unless FORMATS.include?(normalized_format)
|
|
15
|
+
|
|
16
|
+
case normalized_format
|
|
11
17
|
when :json
|
|
12
|
-
report.to_json
|
|
18
|
+
report.to_json(include_graph: include_graph)
|
|
13
19
|
when :sarif
|
|
14
20
|
render_sarif(min_confidence)
|
|
15
21
|
when :github, :annotations
|
|
16
22
|
render_github_annotations(min_confidence)
|
|
17
23
|
when :yaml, :yml
|
|
18
|
-
report.to_yaml
|
|
19
|
-
|
|
24
|
+
report.to_yaml(include_graph: include_graph)
|
|
25
|
+
when :human
|
|
20
26
|
render_human(min_confidence)
|
|
21
27
|
end
|
|
22
28
|
end
|
data/lib/necropsy/runner.rb
CHANGED
|
@@ -12,17 +12,28 @@ module Necropsy
|
|
|
12
12
|
|
|
13
13
|
def analyze
|
|
14
14
|
project = Project.new(root: root, config: config)
|
|
15
|
-
graph = CallGraph.new(project.scan_result)
|
|
15
|
+
graph = CallGraph.new(project.scan_result, ambiguity_limit: config.ambiguity_limit)
|
|
16
|
+
rta_results = []
|
|
16
17
|
|
|
17
18
|
apply_entry_points(graph, project)
|
|
18
19
|
configured_analyzers.each do |analyzer|
|
|
19
20
|
graph.add_profile(analyzer.profile)
|
|
20
|
-
|
|
21
|
+
result = analyzer.analyze(graph, project)
|
|
22
|
+
graph.apply_result(result)
|
|
23
|
+
rta_results << result if analyzer.profile.name == :rta
|
|
21
24
|
end
|
|
25
|
+
rta_results.each { |result| graph.reconcile_rta_result(result) }
|
|
22
26
|
|
|
23
27
|
reachability = Reachability::Engine.new(graph).call
|
|
24
28
|
findings = Confidence::Scorer.new(graph: graph, reachability: reachability, project: project).findings
|
|
25
|
-
Report.new(
|
|
29
|
+
Report.new(
|
|
30
|
+
root: root,
|
|
31
|
+
graph: graph,
|
|
32
|
+
findings: findings,
|
|
33
|
+
reachability: reachability,
|
|
34
|
+
report_include_paths: config.report_include_paths,
|
|
35
|
+
report_exclude_paths: config.report_exclude_paths
|
|
36
|
+
)
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
private
|
|
@@ -45,6 +56,8 @@ module Necropsy
|
|
|
45
56
|
Analyzers::Static::CHA.new
|
|
46
57
|
when 'rta'
|
|
47
58
|
Analyzers::Static::RTA.new
|
|
59
|
+
else
|
|
60
|
+
raise Error, "Unknown static analyzer: #{name}"
|
|
48
61
|
end
|
|
49
62
|
end
|
|
50
63
|
|
|
@@ -60,13 +73,29 @@ module Necropsy
|
|
|
60
73
|
end
|
|
61
74
|
|
|
62
75
|
def custom_analyzers
|
|
63
|
-
config.custom_analyzers.map do |
|
|
76
|
+
config.custom_analyzers.map do |entry|
|
|
77
|
+
class_name, required_path = custom_analyzer_definition(entry)
|
|
78
|
+
require_custom_analyzer(required_path) if required_path
|
|
64
79
|
constantize(class_name).new
|
|
65
80
|
rescue NameError => e
|
|
66
81
|
raise Error, "Could not load custom analyzer #{class_name}: #{e.message}"
|
|
82
|
+
rescue LoadError => e
|
|
83
|
+
raise Error, "Could not require custom analyzer #{class_name}: #{e.message}"
|
|
67
84
|
end
|
|
68
85
|
end
|
|
69
86
|
|
|
87
|
+
def custom_analyzer_definition(entry)
|
|
88
|
+
return [entry, nil] unless entry.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
[entry.fetch('class'), entry['require']]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def require_custom_analyzer(path)
|
|
94
|
+
expanded = File.expand_path(path, root)
|
|
95
|
+
expanded = "#{expanded}.rb" if !File.file?(expanded) && File.file?("#{expanded}.rb")
|
|
96
|
+
File.file?(expanded) ? require(expanded) : require(path)
|
|
97
|
+
end
|
|
98
|
+
|
|
70
99
|
def constantize(class_name)
|
|
71
100
|
class_name.split('::').reduce(Object) { |constant, name| constant.const_get(name) }
|
|
72
101
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'necropsy'
|
|
4
|
+
|
|
5
|
+
root = ENV.fetch('NECROPSY_TRACE_ROOT', nil)
|
|
6
|
+
output = ENV.fetch('NECROPSY_TRACE_OUTPUT', nil)
|
|
7
|
+
sample_rate = ENV.fetch('NECROPSY_TRACE_SAMPLE_RATE', '1.0').to_f
|
|
8
|
+
merge = ENV['NECROPSY_TRACE_MERGE'] == '1'
|
|
9
|
+
run_id = ENV.fetch('NECROPSY_TRACE_RUN_ID', nil)
|
|
10
|
+
|
|
11
|
+
if root && output
|
|
12
|
+
Necropsy::Analyzers::Dynamic::TracePointCollector.install_at_exit(
|
|
13
|
+
root: root,
|
|
14
|
+
output: output,
|
|
15
|
+
sample_rate: sample_rate,
|
|
16
|
+
merge: merge,
|
|
17
|
+
run_id: run_id
|
|
18
|
+
)
|
|
19
|
+
end
|
data/lib/necropsy/version.rb
CHANGED
data/lib/necropsy.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Set is built in on supported Rubies, but requiring it keeps direct file loading portable.
|
|
4
|
+
require 'set' # rubocop:disable Lint/RedundantRequireStatement
|
|
5
|
+
|
|
3
6
|
require_relative 'necropsy/version'
|
|
4
7
|
require_relative 'necropsy/models'
|
|
5
8
|
require_relative 'necropsy/configuration'
|
|
@@ -23,6 +26,7 @@ require_relative 'necropsy/reachability/engine'
|
|
|
23
26
|
require_relative 'necropsy/confidence/scorer'
|
|
24
27
|
require_relative 'necropsy/report'
|
|
25
28
|
require_relative 'necropsy/reporter'
|
|
29
|
+
require_relative 'necropsy/diagnostics'
|
|
26
30
|
require_relative 'necropsy/guardrail/baseline'
|
|
27
31
|
require_relative 'necropsy/guardrail/diff'
|
|
28
32
|
require_relative 'necropsy/guardrail/quarantine'
|
data/script/measure.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../lib/necropsy'
|
|
4
|
+
|
|
5
|
+
root = File.expand_path(ARGV[0] || '.', Dir.pwd)
|
|
6
|
+
config_path = ARGV[1]
|
|
7
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
8
|
+
report = Necropsy.analyze(root: root, config_path: config_path)
|
|
9
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
10
|
+
node_count = report.graph.nodes.size
|
|
11
|
+
|
|
12
|
+
puts({
|
|
13
|
+
root: root,
|
|
14
|
+
nodes: node_count,
|
|
15
|
+
edges: report.graph.edges.size,
|
|
16
|
+
findings: report.findings.size,
|
|
17
|
+
ratio: node_count.zero? ? 0.0 : (100.0 * report.findings.size / node_count).round(1),
|
|
18
|
+
by_confidence: report.findings.group_by(&:confidence).transform_values(&:size),
|
|
19
|
+
seconds: elapsed.round(2)
|
|
20
|
+
}.inspect)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: necropsy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -16,6 +16,9 @@ dependencies:
|
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '1.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '2.0'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -23,6 +26,9 @@ dependencies:
|
|
|
23
26
|
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
28
|
version: '1.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '2.0'
|
|
26
32
|
description: Necropsy combines static call-graph reachability, optional dynamic evidence,
|
|
27
33
|
and CI guardrails for Ruby dead-code detection.
|
|
28
34
|
email:
|
|
@@ -32,7 +38,10 @@ executables:
|
|
|
32
38
|
extensions: []
|
|
33
39
|
extra_rdoc_files: []
|
|
34
40
|
files:
|
|
41
|
+
- ".rubocop.yml"
|
|
42
|
+
- CHANGELOG.md
|
|
35
43
|
- LICENSE.txt
|
|
44
|
+
- MEASUREMENTS.md
|
|
36
45
|
- README.md
|
|
37
46
|
- Rakefile
|
|
38
47
|
- exe/necropsy
|
|
@@ -47,12 +56,20 @@ files:
|
|
|
47
56
|
- lib/necropsy/analyzers/static/name_resolution.rb
|
|
48
57
|
- lib/necropsy/analyzers/static/rta.rb
|
|
49
58
|
- lib/necropsy/ast_scanner.rb
|
|
59
|
+
- lib/necropsy/ast_scanner/call_recording.rb
|
|
60
|
+
- lib/necropsy/ast_scanner/dsl_macros.rb
|
|
61
|
+
- lib/necropsy/ast_scanner/method_definitions.rb
|
|
62
|
+
- lib/necropsy/ast_scanner/references.rb
|
|
63
|
+
- lib/necropsy/ast_scanner/ruby_semantics.rb
|
|
64
|
+
- lib/necropsy/ast_scanner/traversal.rb
|
|
65
|
+
- lib/necropsy/ast_scanner/value_definitions.rb
|
|
50
66
|
- lib/necropsy/bench/evaluator.rb
|
|
51
67
|
- lib/necropsy/cache/scan_cache.rb
|
|
52
68
|
- lib/necropsy/cli.rb
|
|
53
69
|
- lib/necropsy/confidence/scorer.rb
|
|
54
70
|
- lib/necropsy/configuration.rb
|
|
55
71
|
- lib/necropsy/coverage_runtime.rb
|
|
72
|
+
- lib/necropsy/diagnostics.rb
|
|
56
73
|
- lib/necropsy/entry_points/plain.rb
|
|
57
74
|
- lib/necropsy/entry_points/rails.rb
|
|
58
75
|
- lib/necropsy/entry_points/test.rb
|
|
@@ -66,14 +83,17 @@ files:
|
|
|
66
83
|
- lib/necropsy/report.rb
|
|
67
84
|
- lib/necropsy/reporter.rb
|
|
68
85
|
- lib/necropsy/runner.rb
|
|
86
|
+
- lib/necropsy/trace_point_runtime.rb
|
|
69
87
|
- lib/necropsy/version.rb
|
|
88
|
+
- script/measure.rb
|
|
70
89
|
homepage: https://github.com/ydah/necropsy
|
|
71
90
|
licenses:
|
|
72
91
|
- MIT
|
|
73
92
|
metadata:
|
|
74
93
|
allowed_push_host: https://rubygems.org
|
|
75
94
|
homepage_uri: https://github.com/ydah/necropsy
|
|
76
|
-
source_code_uri: https://github.com/ydah/necropsy
|
|
95
|
+
source_code_uri: https://github.com/ydah/necropsy/tree/main
|
|
96
|
+
changelog_uri: https://github.com/ydah/necropsy/blob/main/CHANGELOG.md
|
|
77
97
|
rubygems_mfa_required: 'true'
|
|
78
98
|
rdoc_options: []
|
|
79
99
|
require_paths:
|