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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: acc21c7bf0937bb810decd0a6df03911149a32fc08146b15a37e74d133b397eb
4
+ data.tar.gz: 781f39ee1fb14299dbd9bedb6cad792785269517494b1d6c336fdb52674c10c2
5
+ SHA512:
6
+ metadata.gz: cef485cc0bb21d6e684107bb08f7882655ddf24600c9f9d53c744b5ce8a4cf78a460540c6bc9d981e652c6679f12641d34950761ceb1f78d17e8708436afc4d8
7
+ data.tar.gz: 787d658f3eb89506530b4467d3b7071ed9eccab5a9288b4e023e6b9dbcdd97f6a5b5a7127927d060f698deca80510d0ccd81e059a33990bb0531238d85c95ed4
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # Necropsy
2
+
3
+ Necropsy is a Ruby dead-code detector built around a unified call graph. It
4
+ collects method definitions with Prism, adds call-edge evidence from static and
5
+ optional dynamic analyzers, then runs reachability from framework and configured
6
+ entry points.
7
+
8
+ The first implementation includes:
9
+
10
+ - Prism-based method collection for `def`, `define_method`, `attr_*`, and `delegate`
11
+ - static name resolution, CHA, and RTA-style filtering over instantiated classes
12
+ - Rails route/callback/view-helper/component, plain Ruby, and test-suite entry point providers
13
+ - `unreachable`, `unused`, and `test_only_reachable` classifications
14
+ - confidence levels, JSON/YAML/human/SARIF/GitHub reports, baseline/check guardrails, quarantine suggestions, TracePoint recording, and a bench evaluator
15
+
16
+ ## Installation
17
+
18
+ Add the gem to the development/test group:
19
+
20
+ ```ruby
21
+ gem "necropsy", require: false
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ Create a baseline:
27
+
28
+ ```bash
29
+ bundle exec necropsy baseline --root .
30
+ ```
31
+
32
+ Run a report:
33
+
34
+ ```bash
35
+ bundle exec necropsy analyze --root . --format human
36
+ ```
37
+
38
+ Fail CI only for new high-confidence findings:
39
+
40
+ ```bash
41
+ bundle exec necropsy check --root . --fail-on high
42
+ ```
43
+
44
+ Record dynamic evidence from a Ruby script:
45
+
46
+ ```bash
47
+ bundle exec necropsy record --root . --output tmp/necropsy_trace_point.yml -- script/runner.rb
48
+ bundle exec necropsy coverage --root . --output tmp/necropsy_coverage.yml -- script/runner.rb
49
+ ```
50
+
51
+ Record Ruby Coverage while running an external command:
52
+
53
+ ```bash
54
+ bundle exec necropsy coverage --root . --output tmp/necropsy_coverage.yml -- bundle exec rspec
55
+ ```
56
+
57
+ Child Ruby processes inherit the collector and are merged into the same output
58
+ for that run.
59
+
60
+ Evaluate against a gold standard:
61
+
62
+ ```bash
63
+ bundle exec necropsy bench --root . --gold-standard gold.yml --ablation
64
+ ```
65
+
66
+ Example configuration:
67
+
68
+ ```yaml
69
+ analyzers:
70
+ static: [name_resolution, cha, rta]
71
+ dynamic:
72
+ coverage:
73
+ source: tmp/necropsy_coverage.yml
74
+ min_observation_days: 30
75
+ coverband:
76
+ source: redis://prod-redis:6379/2?key=coverband
77
+ trace_point:
78
+ source: tmp/necropsy_trace_point.yml
79
+ custom:
80
+ - "MyCompany::GraphqlEntryAnalyzer"
81
+ cache:
82
+ enabled: true
83
+ path: .necropsy_cache/scan.yml
84
+ entry_points:
85
+ extra:
86
+ - "PublicApi::*"
87
+ ci:
88
+ fail_on: high
89
+ baseline: .necropsy_baseline.yml
90
+ quarantine:
91
+ days: 30
92
+ bench:
93
+ precision_threshold: 0.85
94
+ ```
95
+
96
+ The scan cache is invalidated when scanned Ruby files or configuration values
97
+ change.
98
+
99
+ Dynamic inputs may provide `executed` or `nodes` entries with method IDs,
100
+ `edges` with `caller_id`/`callee_id`, and an `observation` hash. SARIF and
101
+ GitHub Actions annotations are available via `--format sarif` and
102
+ `--format github`.
103
+
104
+ Coverband file, Redis string, and Redis hash exports are supported. Rails route
105
+ entry point detection covers common `resources`, `resource`, `namespace`,
106
+ `scope`, `controller`, `concerns`, `draw`, `mount`, `root`, and verb route
107
+ forms.
108
+
109
+ `necropsy quarantine --write` adds `# necropsy:quarantine since=YYYY-MM-DD`.
110
+ When the configured quarantine window expires and no alive evidence appears,
111
+ the finding is raised to `certain`.
112
+
113
+ ## Development
114
+
115
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
116
+
117
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
118
+
119
+ ## Contributing
120
+
121
+ Bug reports and pull requests are welcome on GitHub.
122
+
123
+ ## License
124
+
125
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/necropsy ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'necropsy/cli'
5
+
6
+ exit Necropsy::CLI.run(ARGV)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class Analyzer
5
+ def analyze(_graph, _project)
6
+ raise NotImplementedError, "#{self.class} must implement #analyze"
7
+ end
8
+
9
+ def profile
10
+ raise NotImplementedError, "#{self.class} must implement #profile"
11
+ end
12
+
13
+ private
14
+
15
+ def evidence(kind:, details:, analyzer: profile.name, weight: 1.0, metadata: {})
16
+ Evidence.new(analyzer: analyzer, kind: kind, weight: weight, details: details, metadata: metadata)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'coverage'
4
+ require 'fileutils'
5
+ require 'time'
6
+ require 'yaml'
7
+
8
+ module Necropsy
9
+ module Analyzers
10
+ module Dynamic
11
+ class CoverageCollector
12
+ def self.record(root:, output:, &)
13
+ new(root: root, output: output).record(&)
14
+ end
15
+
16
+ def self.install_at_exit(root:, output:, merge: false, run_id: nil)
17
+ new(root: root, output: output, merge: merge, run_id: run_id).install_at_exit
18
+ end
19
+
20
+ def initialize(root:, output:, merge: false, run_id: nil)
21
+ @root = File.expand_path(root)
22
+ @output = output
23
+ @merge = merge
24
+ @run_id = run_id
25
+ end
26
+
27
+ def record
28
+ started_at = Time.now.utc
29
+ Coverage.start(methods: true)
30
+ yield
31
+ write_payload(result: Coverage.result, started_at: started_at, finished_at: Time.now.utc)
32
+ ensure
33
+ Coverage.result(stop: true, clear: true) if Coverage.running?
34
+ end
35
+
36
+ def install_at_exit
37
+ started_at = Time.now.utc
38
+ started = start_coverage
39
+
40
+ at_exit do
41
+ finished_at = Time.now.utc
42
+ result = coverage_result(started: started)
43
+ write_payload(result: result, started_at: started_at, finished_at: finished_at)
44
+ rescue StandardError => e
45
+ warn "Necropsy coverage collector failed: #{e.message}"
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :root, :output, :run_id
52
+
53
+ def merge?
54
+ @merge
55
+ end
56
+
57
+ def start_coverage
58
+ return false if Coverage.running?
59
+
60
+ Coverage.start(methods: true)
61
+ true
62
+ end
63
+
64
+ def coverage_result(started:)
65
+ return Coverage.result(stop: true, clear: true) if started && Coverage.running?
66
+ return Coverage.peek_result if Coverage.respond_to?(:peek_result) && Coverage.running?
67
+
68
+ {}
69
+ end
70
+
71
+ def write_payload(result:, started_at:, finished_at:)
72
+ payload = {
73
+ 'nodes' => executed_nodes(result).sort,
74
+ 'observation' => {
75
+ 'started_at' => started_at.iso8601,
76
+ 'finished_at' => finished_at.iso8601,
77
+ 'days' => [((finished_at - started_at) / 86_400.0).ceil, 1].max,
78
+ 'collector' => 'coverage'
79
+ }.tap { |observation| observation['run_id'] = run_id if run_id }
80
+ }
81
+ FileUtils.mkdir_p(File.dirname(output))
82
+ merge? ? write_merged_payload(payload) : File.write(output, payload.to_yaml)
83
+ end
84
+
85
+ def write_merged_payload(payload)
86
+ File.open(output, File::RDWR | File::CREAT, 0o644) do |file|
87
+ file.flock(File::LOCK_EX)
88
+ existing = payload_for_current_run(read_payload(file), payload)
89
+ merged = merge_payload(existing, payload)
90
+ file.rewind
91
+ file.truncate(0)
92
+ file.write(merged.to_yaml)
93
+ file.flush
94
+ end
95
+ end
96
+
97
+ def read_payload(file)
98
+ file.rewind
99
+ content = file.read
100
+ return {} if content.empty?
101
+
102
+ YAML.load(content) || {}
103
+ rescue Psych::Exception
104
+ {}
105
+ end
106
+
107
+ def payload_for_current_run(existing, payload)
108
+ current_run = payload.dig('observation', 'run_id')
109
+ return existing unless current_run
110
+ return existing if empty_payload?(existing)
111
+ return existing if existing.dig('observation', 'run_id') == current_run
112
+
113
+ {}
114
+ end
115
+
116
+ def empty_payload?(payload)
117
+ Array(payload['nodes']).empty? && payload.fetch('observation', {}).empty?
118
+ end
119
+
120
+ def merge_payload(left, right)
121
+ observation = merge_observation(left.fetch('observation', {}), right.fetch('observation', {}))
122
+ {
123
+ 'nodes' => (Array(left['nodes']) + Array(right['nodes'])).uniq.sort,
124
+ 'observation' => observation
125
+ }
126
+ end
127
+
128
+ def merge_observation(left, right)
129
+ started_at = [left['started_at'], right['started_at']].compact.min
130
+ finished_at = [left['finished_at'], right['finished_at']].compact.max
131
+ observation = left.merge(right)
132
+ observation['started_at'] = started_at if started_at
133
+ observation['finished_at'] = finished_at if finished_at
134
+ observation['days'] = merged_days(left, right, started_at, finished_at)
135
+ observation['collector'] = 'coverage'
136
+ observation['processes'] = process_count(left) + process_count(right)
137
+ observation
138
+ end
139
+
140
+ def merged_days(left, right, started_at, finished_at)
141
+ observed_days = [left['days'].to_i, right['days'].to_i, days_between(started_at, finished_at)].max
142
+ [observed_days, 1].max
143
+ end
144
+
145
+ def days_between(started_at, finished_at)
146
+ return 0 unless started_at && finished_at
147
+
148
+ [((Time.parse(finished_at) - Time.parse(started_at)) / 86_400.0).ceil, 1].max
149
+ rescue ArgumentError
150
+ 0
151
+ end
152
+
153
+ def process_count(observation)
154
+ return observation['processes'].to_i if observation['processes']
155
+ return 0 if observation.empty?
156
+
157
+ 1
158
+ end
159
+
160
+ def executed_nodes(result)
161
+ result.flat_map do |path, coverage|
162
+ next [] unless project_path?(path)
163
+
164
+ coverage.fetch(:methods, {}).filter_map do |method_key, count|
165
+ next unless count.to_i.positive?
166
+
167
+ node_id_for(method_key)
168
+ end
169
+ end
170
+ end
171
+
172
+ def project_path?(path)
173
+ File.expand_path(path).start_with?("#{root}/")
174
+ end
175
+
176
+ def node_id_for(method_key)
177
+ owner = method_key[0]
178
+ method_name = method_key[1]
179
+ owner_name, separator = owner_and_separator(owner)
180
+ return nil unless owner_name
181
+
182
+ "#{owner_name}#{separator}#{method_name}"
183
+ end
184
+
185
+ def owner_and_separator(owner)
186
+ return [owner.name, '#'] if owner.respond_to?(:name) && owner.name
187
+
188
+ singleton_owner = owner.inspect[/\A#<Class:(.+)>\z/, 1]
189
+ return [singleton_owner, '.'] if singleton_owner
190
+
191
+ nil
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module Necropsy
7
+ module Analyzers
8
+ module Dynamic
9
+ class CoverageImporter < Analyzer
10
+ def initialize(config)
11
+ @config = config || {}
12
+ end
13
+
14
+ def analyze(_graph, project)
15
+ source = config['source']
16
+ return AnalyzerResult.empty unless source
17
+
18
+ payload = load_payload(File.expand_path(source, project.root))
19
+ alive = Array(payload['executed'] || payload['nodes']).map do |node_id|
20
+ AliveEvidence.new(
21
+ node_id: node_id,
22
+ evidence: evidence(kind: :alive, details: "Coverage marked #{node_id} as executed",
23
+ metadata: payload['observation'] || {})
24
+ )
25
+ end
26
+
27
+ edge_evidences = Array(payload['edges']).map do |edge|
28
+ caller_id = edge['caller_id'] || edge[:caller_id]
29
+ callee_id = edge['callee_id'] || edge[:callee_id]
30
+ EdgeEvidence.new(
31
+ caller_id: caller_id,
32
+ callee_id: callee_id,
33
+ evidence: evidence(kind: :call_edge, details: "Coverage observed #{caller_id} -> #{callee_id}")
34
+ )
35
+ end
36
+
37
+ AnalyzerResult.new(
38
+ edge_evidences: edge_evidences,
39
+ alive_evidences: alive,
40
+ uncertainties: {},
41
+ observation: { 'coverage' => payload['observation'] || {} }
42
+ )
43
+ end
44
+
45
+ def profile
46
+ AnalyzerProfile.new(
47
+ name: :coverage,
48
+ kind: :dynamic,
49
+ soundness: :observational,
50
+ description: 'Imports method execution and observed edges from Ruby Coverage output.'
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :config
57
+
58
+ def load_payload(path)
59
+ case File.extname(path)
60
+ when '.json'
61
+ JSON.parse(File.read(path))
62
+ else
63
+ YAML.load_file(path) || {}
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end