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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc6a4c3ecf1f46c6950e6cb91e522d6162e41b1bc1b8e8b2aaa96a75b1ccb575
|
|
4
|
+
data.tar.gz: b77ffd85970bf4445c287cce8e3727c3e8ba675314ecfc5b9ce5cc8df73cbdf9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c86a65179785984b75bf39de3843ae5a2759b1e6c327ec7d9f6cbb88f961b0dfaa4944af69bd4aa602e0d27ac23accc69f62d344b55bd258779271047fd72fb
|
|
7
|
+
data.tar.gz: a91136e79e7056cc81c816be84b28b77598efc525e28304b530484282b045a5a8c463b0276dbfc487f5db0ee7fdf3c14aa137ed8c542cd94781e90794bb9e18e
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
TargetRubyVersion: 3.2
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
CacheRootDirectory: tmp/rubocop_cache
|
|
6
|
+
Exclude:
|
|
7
|
+
- "pkg/**/*"
|
|
8
|
+
- "tmp/**/*"
|
|
9
|
+
- "vendor/**/*"
|
|
10
|
+
|
|
11
|
+
Layout/LineLength:
|
|
12
|
+
Max: 150
|
|
13
|
+
|
|
14
|
+
Lint/MissingSuper:
|
|
15
|
+
Enabled: false
|
|
16
|
+
|
|
17
|
+
Naming/PredicateMethod:
|
|
18
|
+
Enabled: false
|
|
19
|
+
|
|
20
|
+
Metrics:
|
|
21
|
+
Enabled: false
|
|
22
|
+
|
|
23
|
+
Style/Documentation:
|
|
24
|
+
Enabled: false
|
|
25
|
+
|
|
26
|
+
Style/FetchEnvVar:
|
|
27
|
+
Enabled: false
|
|
28
|
+
|
|
29
|
+
Style/HashSyntax:
|
|
30
|
+
EnforcedShorthandSyntax: never
|
|
31
|
+
|
|
32
|
+
Style/MultilineBlockChain:
|
|
33
|
+
Enabled: false
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Necropsy are documented in this file.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.2.0 - 2026-08-03
|
|
8
|
+
|
|
9
|
+
- **Breaking:** Reports now omit `low` confidence findings by default, substantially reducing noisy output. Pass `--min-confidence low` to retain the previous output.
|
|
10
|
+
- Improve analysis accuracy for qualified and inherited constants, implicit constructors, ambiguous calls, Ruby protocols, and framework callbacks, including Rails and RuboCop entry points.
|
|
11
|
+
- Speed up analysis of large projects by indexing and caching call-graph resolution and resolving each call site only once.
|
|
12
|
+
- Add `why` and `explain` commands to show shortest reachability paths, uncertainty, nearby live symbols, and confidence-score components in human or JSON output.
|
|
13
|
+
- Add `report.include` and `report.exclude` to limit reported paths without removing code from the call graph, and warn when scan-path filters hide potential entry points.
|
|
14
|
+
- Make CI and dynamic analysis more reliable with corrected ratchet behavior, hardened TracePoint/Coverage/Coverband imports, deterministic report ordering, and line-ending-safe quarantine writes.
|
|
15
|
+
|
|
16
|
+
## 0.1.0 - 2026-07-09
|
|
17
|
+
|
|
18
|
+
- Initial implementation.
|
data/MEASUREMENTS.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Measurements
|
|
2
|
+
|
|
3
|
+
Measurements are local wall-clock results. Compare runs made on the same machine and Ruby version; absolute timings are not portable.
|
|
4
|
+
|
|
5
|
+
| date | commit | target | nodes | findings | ratio | seconds |
|
|
6
|
+
|---|---|---|---:|---:|---:|---:|
|
|
7
|
+
| 2026-08-01 | baseline | necropsy | 678 | 246 | 36.3% | 4.33 |
|
|
8
|
+
| 2026-08-01 | +T-01 | necropsy | 678 | 56 | 8.3% | 4.33 |
|
|
9
|
+
| 2026-08-01 | +T-02 | necropsy | 681 | 19 | 2.8% | 4.33 |
|
|
10
|
+
| 2026-08-01 | +T-04,T-05 | necropsy | 681 | 19 | 2.8% | 0.52 |
|
|
11
|
+
| 2026-08-01 | +T-04,T-05 | rubocop 1.75.0 | 8999 | 6039 | 67.1% | 20.50 |
|
|
12
|
+
| 2026-08-02 | +T-06 | necropsy (Ruby 4.0.0) | 683 | 19 | 2.8% | 0.18 |
|
|
13
|
+
| 2026-08-02 | +T-07 | necropsy (Ruby 4.0.0) | 686 | 19 | 2.8% | 0.20 |
|
|
14
|
+
| 2026-08-02 | +T-07 | rubocop 1.75.0 (Ruby 4.0.0) | 8999 | 6029 | 67.0% | 6.53 |
|
|
15
|
+
|
|
16
|
+
## Ambiguous fallback experiment
|
|
17
|
+
|
|
18
|
+
RuboCop 1.75.0, Ruby 4.0.0. The limit of four is the smallest value with the lowest bounded finding count. Eight adds edges without reducing findings; unlimited has a small precision gain at a substantial runtime and recall risk.
|
|
19
|
+
|
|
20
|
+
| ambiguity limit | nodes | edges | findings | medium | low | seconds |
|
|
21
|
+
|---:|---:|---:|---:|---:|---:|---:|
|
|
22
|
+
| 1 (previous behavior) | 8999 | 44313 | 6039 | 1039 | 5000 | 5.96 |
|
|
23
|
+
| 2 | 8999 | 46398 | 6032 | 1292 | 4740 | 6.32 |
|
|
24
|
+
| 4 (selected) | 8999 | 46607 | 6029 | 1789 | 4240 | 6.76 |
|
|
25
|
+
| 8 | 8999 | 46951 | 6029 | 2184 | 3845 | 6.77 |
|
|
26
|
+
| unlimited | 8999 | 70616 | 5931 | 2351 | 3580 | 10.27 |
|
|
27
|
+
|
|
28
|
+
The 19 self-analysis findings remain unchanged at every tested limit, including the verified dead methods `Necropsy::CallGraph#modules_for` and `Necropsy::EntryPoints::Rails#helper_referenced?`.
|
|
29
|
+
|
|
30
|
+
## Default reporting threshold
|
|
31
|
+
|
|
32
|
+
RuboCop 1.75.0 at ambiguity limit four:
|
|
33
|
+
|
|
34
|
+
| threshold | reported findings |
|
|
35
|
+
|---|---:|
|
|
36
|
+
| `medium` (selected default) | 1789 |
|
|
37
|
+
| `low` (explicit compatibility mode) | 6029 |
|
|
38
|
+
|
|
39
|
+
## Implicit caller experiment
|
|
40
|
+
|
|
41
|
+
RuboCop 1.75.0 at ambiguity limit four and the default `medium` reporting threshold:
|
|
42
|
+
|
|
43
|
+
| rules | reported findings | change |
|
|
44
|
+
|---|---:|---:|
|
|
45
|
+
| none | 1789 | baseline |
|
|
46
|
+
| Ruby hooks and protocols | 1785 | -4 |
|
|
47
|
+
| Ruby rules plus scoped RuboCop `on_*` rule | 1161 | -624 |
|
|
48
|
+
| Rules plus transitive implicit-caller uncertainty | 359 | -802 |
|
|
49
|
+
|
|
50
|
+
The RuboCop rule is enabled as a built-in framework pack because its ancestor constraint limits it to commissioner-dispatched cop callbacks. The same rule remains configurable for other frameworks through `implicit_callers`.
|
|
51
|
+
|
|
52
|
+
## Report path experiment
|
|
53
|
+
|
|
54
|
+
RuboCop 1.75.0 at the default `medium` reporting threshold:
|
|
55
|
+
|
|
56
|
+
| configuration | graph nodes | raw findings | reported findings | outside `lib/` |
|
|
57
|
+
|---|---:|---:|---:|---:|
|
|
58
|
+
| full scan and report | 8999 | 6029 | 1161 | 4 |
|
|
59
|
+
| `report.include: ["lib/**"]` | 8999 | 6029 | 1157 | 0 |
|
|
60
|
+
| `paths.include: ["lib/**"]` | 8131 | — | 1552 | 0 |
|
|
61
|
+
|
|
62
|
+
`report.include` preserves the full graph and removes only four out-of-scope reports. `paths.include` removes 868 graph nodes, emits an entry-point warning, and increases reported findings by 34%.
|
|
63
|
+
|
|
64
|
+
## Debride comparison
|
|
65
|
+
|
|
66
|
+
Debride 1.15.2 and Necropsy's default `medium` threshold, both restricted to RuboCop 1.75.0's `lib/` definitions. Debride output was mapped back to fully qualified Necropsy node IDs; 19 unmatched debride entries were constants or definitions without an unambiguous method ID.
|
|
67
|
+
|
|
68
|
+
| set | methods |
|
|
69
|
+
|---|---:|
|
|
70
|
+
| debride | 718 |
|
|
71
|
+
| Necropsy | 355 |
|
|
72
|
+
| both | 39 |
|
|
73
|
+
| debride only | 679 |
|
|
74
|
+
| Necropsy only | 316 |
|
|
75
|
+
|
|
76
|
+
Twenty alphabetically stable entries from each exclusive set were inspected against RuboCop source and specs.
|
|
77
|
+
|
|
78
|
+
- Debride-only: 4/20 were probable production-dead compatibility methods (`ConfigLoader.inject_defaults!`, `Alignment#end_of_line_comment`, `AllowedPattern#ignored_line?`, and `AllowedPattern#matches_ignored_pattern?`). The other 16 were a test-only generated writer, public subclass API, node-pattern callback, or commissioner-dispatched `on_*` callbacks.
|
|
79
|
+
- Necropsy-only: 0/20 were probable dead methods. Two `CopsDocumentationGenerator` methods are called through the `STRUCTURE` lambda table and a Rake entry point. The other 18 belong to CLI command subclasses selected through `Base.by_command_name` and constructed dynamically.
|
|
80
|
+
|
|
81
|
+
The sample shows why the sets overlap only slightly: debride reports Ruby/RuboCop callbacks by name, while Necropsy's remaining false positives cluster around registry-selected classes and callable tables. The next diagnostic priority is therefore witness/explanation support; adding broader survival rules without path evidence would risk hiding the four probable true positives found in the debride-only sample.
|
|
82
|
+
|
|
83
|
+
## Witness storage
|
|
84
|
+
|
|
85
|
+
RuboCop 1.75.0 retained 6,029 raw findings and 359 default reports after predecessor paths were added. Wall time was 5.35 seconds and peak RSS was 557.0 MB, below the 600 MB guardrail.
|
data/README.md
CHANGED
|
@@ -5,13 +5,13 @@ collects method definitions with Prism, adds call-edge evidence from static and
|
|
|
5
5
|
optional dynamic analyzers, then runs reachability from framework and configured
|
|
6
6
|
entry points.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Necropsy includes:
|
|
9
9
|
|
|
10
|
-
- Prism-based method collection for
|
|
10
|
+
- Prism-based method collection for ordinary, singleton, delegated, aliased, forwarded, and dynamically defined methods
|
|
11
11
|
- static name resolution, CHA, and RTA-style filtering over instantiated classes
|
|
12
|
-
- Rails route
|
|
12
|
+
- Prism-backed Rails route parsing plus callback, view, component, migration, plain Ruby, and test-suite entry points
|
|
13
13
|
- `unreachable`, `unused`, and `test_only_reachable` classifications
|
|
14
|
-
- confidence levels, JSON/YAML
|
|
14
|
+
- confidence levels, compact JSON/YAML reports, SARIF/GitHub output, CI guardrails, dynamic collectors, and benchmarking
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -33,8 +33,25 @@ Run a report:
|
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
bundle exec necropsy analyze --root . --format human
|
|
36
|
+
bundle exec necropsy analyze --root . --min-confidence low # include exploratory findings
|
|
37
|
+
bundle exec necropsy --version
|
|
36
38
|
```
|
|
37
39
|
|
|
40
|
+
Reports omit `low` confidence findings by default. Pass `--min-confidence low`
|
|
41
|
+
to retain the pre-0.2 behavior.
|
|
42
|
+
|
|
43
|
+
Inspect why a symbol is alive or dead, including the shortest evidenced path,
|
|
44
|
+
nearby alive node, and unresolved dispatch notes:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bundle exec necropsy why 'MyService#call' --root .
|
|
48
|
+
bundle exec necropsy why 'MyService#call' --root . --format json
|
|
49
|
+
bundle exec necropsy explain 'LegacyService#unused' --root .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`explain` shows every confidence score component and the final confidence
|
|
53
|
+
level. Missing symbol IDs return partial-match suggestions.
|
|
54
|
+
|
|
38
55
|
Fail CI only for new high-confidence findings:
|
|
39
56
|
|
|
40
57
|
```bash
|
|
@@ -63,6 +80,9 @@ Evaluate against a gold standard:
|
|
|
63
80
|
bundle exec necropsy bench --root . --gold-standard gold.yml --ablation
|
|
64
81
|
```
|
|
65
82
|
|
|
83
|
+
JSON and YAML omit the full call graph by default. Add `--include-graph` when
|
|
84
|
+
nodes, edges, evidence, and entry points are needed in machine-readable output.
|
|
85
|
+
|
|
66
86
|
Example configuration:
|
|
67
87
|
|
|
68
88
|
```yaml
|
|
@@ -77,10 +97,22 @@ analyzers:
|
|
|
77
97
|
trace_point:
|
|
78
98
|
source: tmp/necropsy_trace_point.yml
|
|
79
99
|
custom:
|
|
80
|
-
- "MyCompany::GraphqlEntryAnalyzer"
|
|
100
|
+
- class: "MyCompany::GraphqlEntryAnalyzer"
|
|
101
|
+
require: "config/necropsy/graphql_entry_analyzer"
|
|
81
102
|
cache:
|
|
82
103
|
enabled: true
|
|
83
|
-
path: .necropsy_cache/scan.
|
|
104
|
+
path: .necropsy_cache/scan.json
|
|
105
|
+
resolution:
|
|
106
|
+
ambiguity_limit: 4 # use "unlimited" to retain every same-name candidate
|
|
107
|
+
implicit_callers:
|
|
108
|
+
- name_pattern: "^on_"
|
|
109
|
+
owner_ancestors: ["RuboCop::Cop::Base"]
|
|
110
|
+
reason: "RuboCop Commissioner callback"
|
|
111
|
+
paths:
|
|
112
|
+
exclude: ["app/legacy/**/*.rb"]
|
|
113
|
+
report:
|
|
114
|
+
include: ["app/**", "lib/**"]
|
|
115
|
+
exclude: ["lib/generated/**"]
|
|
84
116
|
entry_points:
|
|
85
117
|
extra:
|
|
86
118
|
- "PublicApi::*"
|
|
@@ -91,17 +123,35 @@ quarantine:
|
|
|
91
123
|
days: 30
|
|
92
124
|
bench:
|
|
93
125
|
precision_threshold: 0.85
|
|
126
|
+
logging:
|
|
127
|
+
verbose: false
|
|
94
128
|
```
|
|
95
129
|
|
|
96
130
|
The scan cache is invalidated when scanned Ruby files or configuration values
|
|
97
131
|
change.
|
|
98
132
|
|
|
133
|
+
When a call receiver cannot be resolved exactly, Necropsy conservatively keeps
|
|
134
|
+
up to `resolution.ambiguity_limit` same-name candidates alive. The default of
|
|
135
|
+
four is based on the RuboCop 1.75.0 measurements in `MEASUREMENTS.md`.
|
|
136
|
+
|
|
137
|
+
Ruby VM hooks and common protocol methods receive lower confidence because
|
|
138
|
+
their callers may not appear in source. Add `implicit_callers` rules for
|
|
139
|
+
framework or application callbacks; `owner_ancestors` is optional.
|
|
140
|
+
|
|
141
|
+
`paths.include` narrows the source files used to construct the call graph and
|
|
142
|
+
can remove executables, tests, routes, and other entry points. Use
|
|
143
|
+
`report.include` and `report.exclude` when the full project should be analyzed
|
|
144
|
+
but only selected application paths should be reported. Necropsy warns when a
|
|
145
|
+
configured `paths.include` excludes detected entry-point files.
|
|
146
|
+
|
|
99
147
|
Dynamic inputs may provide `executed` or `nodes` entries with method IDs,
|
|
100
148
|
`edges` with `caller_id`/`callee_id`, and an `observation` hash. SARIF and
|
|
101
149
|
GitHub Actions annotations are available via `--format sarif` and
|
|
102
150
|
`--format github`.
|
|
103
151
|
|
|
104
|
-
Coverband file, Redis string, and Redis hash exports are supported.
|
|
152
|
+
Coverband file, Redis string, and Redis hash exports are supported. Redis URLs
|
|
153
|
+
may include an ACL username and password; `connect_timeout` and `read_timeout`
|
|
154
|
+
can be set in the Coverband analyzer configuration. Rails route
|
|
105
155
|
entry point detection covers common `resources`, `resource`, `namespace`,
|
|
106
156
|
`scope`, `controller`, `concerns`, `draw`, `mount`, `root`, and verb route
|
|
107
157
|
forms.
|
|
@@ -112,7 +162,9 @@ the finding is raised to `certain`.
|
|
|
112
162
|
|
|
113
163
|
## Development
|
|
114
164
|
|
|
115
|
-
After checking out the repo, run `bin/setup` to install dependencies. Then
|
|
165
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run
|
|
166
|
+
`bundle exec rake` for the specs and RuboCop checks. Use `bin/console` for an
|
|
167
|
+
interactive prompt.
|
|
116
168
|
|
|
117
169
|
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
170
|
|
data/Rakefile
CHANGED
|
@@ -63,11 +63,21 @@ module Necropsy
|
|
|
63
63
|
|
|
64
64
|
def coverage_result(started:)
|
|
65
65
|
return Coverage.result(stop: true, clear: true) if started && Coverage.running?
|
|
66
|
-
|
|
66
|
+
|
|
67
|
+
if Coverage.respond_to?(:peek_result) && Coverage.running?
|
|
68
|
+
result = Coverage.peek_result
|
|
69
|
+
return result if method_coverage?(result)
|
|
70
|
+
|
|
71
|
+
warn 'Necropsy coverage collector found Coverage already running without methods: true; no methods were recorded.'
|
|
72
|
+
end
|
|
67
73
|
|
|
68
74
|
{}
|
|
69
75
|
end
|
|
70
76
|
|
|
77
|
+
def method_coverage?(result)
|
|
78
|
+
result.values.any? { |coverage| coverage.is_a?(Hash) && coverage.key?(:methods) }
|
|
79
|
+
end
|
|
80
|
+
|
|
71
81
|
def write_payload(result:, started_at:, finished_at:)
|
|
72
82
|
payload = {
|
|
73
83
|
'nodes' => executed_nodes(result).sort,
|
|
@@ -99,7 +109,7 @@ module Necropsy
|
|
|
99
109
|
content = file.read
|
|
100
110
|
return {} if content.empty?
|
|
101
111
|
|
|
102
|
-
YAML.
|
|
112
|
+
YAML.safe_load(content, aliases: false) || {}
|
|
103
113
|
rescue Psych::Exception
|
|
104
114
|
{}
|
|
105
115
|
end
|
|
@@ -56,12 +56,16 @@ module Necropsy
|
|
|
56
56
|
attr_reader :config
|
|
57
57
|
|
|
58
58
|
def load_payload(path)
|
|
59
|
+
raise Error, "Coverage source does not exist: #{path}" unless File.file?(path)
|
|
60
|
+
|
|
59
61
|
case File.extname(path)
|
|
60
62
|
when '.json'
|
|
61
63
|
JSON.parse(File.read(path))
|
|
62
64
|
else
|
|
63
|
-
YAML.
|
|
65
|
+
YAML.safe_load_file(path, aliases: false) || {}
|
|
64
66
|
end
|
|
67
|
+
rescue JSON::ParserError, Psych::Exception => e
|
|
68
|
+
raise Error, "Could not parse coverage source #{path}: #{e.message}"
|
|
65
69
|
end
|
|
66
70
|
end
|
|
67
71
|
end
|
|
@@ -52,12 +52,16 @@ module Necropsy
|
|
|
52
52
|
return load_redis_payload(source) if source.start_with?('redis://', 'rediss://')
|
|
53
53
|
|
|
54
54
|
path = File.expand_path(source, root)
|
|
55
|
+
raise Error, "Coverband source does not exist: #{path}" unless File.file?(path)
|
|
56
|
+
|
|
55
57
|
case File.extname(path)
|
|
56
58
|
when '.json'
|
|
57
59
|
JSON.parse(File.read(path))
|
|
58
60
|
else
|
|
59
|
-
YAML.
|
|
61
|
+
YAML.safe_load_file(path, aliases: true) || {}
|
|
60
62
|
end
|
|
63
|
+
rescue JSON::ParserError, Psych::Exception => e
|
|
64
|
+
raise Error, "Could not parse Coverband source #{path}: #{e.message}"
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def load_redis_payload(source)
|
|
@@ -145,6 +149,8 @@ module Necropsy
|
|
|
145
149
|
|
|
146
150
|
class RedisPayloadLoader
|
|
147
151
|
DEFAULT_PATTERN = 'coverband*'
|
|
152
|
+
DEFAULT_CONNECT_TIMEOUT = 5.0
|
|
153
|
+
DEFAULT_READ_TIMEOUT = 5.0
|
|
148
154
|
|
|
149
155
|
def initialize(source:, config:)
|
|
150
156
|
@uri = URI(source)
|
|
@@ -166,8 +172,12 @@ module Necropsy
|
|
|
166
172
|
attr_reader :uri, :config, :socket
|
|
167
173
|
|
|
168
174
|
def connect
|
|
169
|
-
tcp_socket =
|
|
175
|
+
tcp_socket = Socket.tcp(uri.host, uri.port || 6379, connect_timeout: connect_timeout)
|
|
176
|
+
apply_read_timeout(tcp_socket)
|
|
170
177
|
@socket = uri.scheme == 'rediss' ? tls_socket(tcp_socket) : tcp_socket
|
|
178
|
+
rescue SystemCallError, SocketError, IO::TimeoutError => e
|
|
179
|
+
tcp_socket&.close
|
|
180
|
+
raise Error, "Could not connect to Redis at #{uri.host}:#{uri.port || 6379}: #{e.message}"
|
|
171
181
|
end
|
|
172
182
|
|
|
173
183
|
def tls_socket(tcp_socket)
|
|
@@ -181,7 +191,10 @@ module Necropsy
|
|
|
181
191
|
def authenticate
|
|
182
192
|
return unless uri.password
|
|
183
193
|
|
|
184
|
-
|
|
194
|
+
password = URI.decode_www_form_component(uri.password)
|
|
195
|
+
return command('AUTH', password) unless uri.user
|
|
196
|
+
|
|
197
|
+
command('AUTH', URI.decode_www_form_component(uri.user), password)
|
|
185
198
|
end
|
|
186
199
|
|
|
187
200
|
def select_database
|
|
@@ -214,6 +227,8 @@ module Necropsy
|
|
|
214
227
|
def command(*parts)
|
|
215
228
|
socket.write(redis_command(parts))
|
|
216
229
|
read_response
|
|
230
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
231
|
+
raise Error, "Redis connection failed: #{e.message}"
|
|
217
232
|
end
|
|
218
233
|
|
|
219
234
|
def payload_for_key(key)
|
|
@@ -225,44 +240,83 @@ module Necropsy
|
|
|
225
240
|
end
|
|
226
241
|
|
|
227
242
|
def redis_command(parts)
|
|
228
|
-
["*#{parts.length}", *parts.flat_map { |part| ["$#{part.to_s.bytesize}", part.to_s] }].join("\r\n")
|
|
243
|
+
"#{["*#{parts.length}", *parts.flat_map { |part| ["$#{part.to_s.bytesize}", part.to_s] }].join("\r\n")}\r\n"
|
|
229
244
|
end
|
|
230
245
|
|
|
231
246
|
def read_response
|
|
232
|
-
prefix =
|
|
247
|
+
prefix = read_exact(1)
|
|
233
248
|
case prefix
|
|
234
249
|
when '+'
|
|
235
|
-
|
|
250
|
+
read_line
|
|
236
251
|
when '-'
|
|
237
|
-
raise Error,
|
|
252
|
+
raise Error, read_line
|
|
238
253
|
when ':'
|
|
239
|
-
|
|
254
|
+
read_line.to_i
|
|
240
255
|
when '$'
|
|
241
256
|
read_bulk_string
|
|
242
257
|
when '*'
|
|
243
|
-
|
|
258
|
+
length = read_line.to_i
|
|
259
|
+
return nil if length.negative?
|
|
260
|
+
|
|
261
|
+
Array.new(length) { read_response }
|
|
244
262
|
else
|
|
245
263
|
raise Error, "Unsupported Redis response prefix #{prefix.inspect}"
|
|
246
264
|
end
|
|
247
265
|
end
|
|
248
266
|
|
|
249
267
|
def read_bulk_string
|
|
250
|
-
length =
|
|
268
|
+
length = read_line.to_i
|
|
251
269
|
return nil if length.negative?
|
|
252
270
|
|
|
271
|
+
value = read_exact(length)
|
|
272
|
+
read_exact(2)
|
|
273
|
+
value
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def read_line
|
|
277
|
+
line = socket.gets("\r\n")
|
|
278
|
+
raise Error, 'Redis closed the connection while reading a response' unless line
|
|
279
|
+
|
|
280
|
+
line.delete_suffix("\r\n")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def read_exact(length)
|
|
253
284
|
value = socket.read(length)
|
|
254
|
-
|
|
285
|
+
raise Error, 'Redis closed the connection while reading a response' unless value&.bytesize == length
|
|
286
|
+
|
|
255
287
|
value
|
|
256
288
|
end
|
|
257
289
|
|
|
290
|
+
def connect_timeout
|
|
291
|
+
Float(config.fetch('connect_timeout', DEFAULT_CONNECT_TIMEOUT))
|
|
292
|
+
rescue ArgumentError, TypeError
|
|
293
|
+
DEFAULT_CONNECT_TIMEOUT
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def read_timeout
|
|
297
|
+
Float(config.fetch('read_timeout', DEFAULT_READ_TIMEOUT))
|
|
298
|
+
rescue ArgumentError, TypeError
|
|
299
|
+
DEFAULT_READ_TIMEOUT
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def apply_read_timeout(tcp_socket)
|
|
303
|
+
seconds = read_timeout
|
|
304
|
+
timeout = [seconds.to_i, ((seconds % 1) * 1_000_000).to_i].pack('l_2')
|
|
305
|
+
tcp_socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, timeout)
|
|
306
|
+
end
|
|
307
|
+
|
|
258
308
|
def parse_payload(value)
|
|
259
309
|
return nil if value.nil? || value.empty?
|
|
260
310
|
|
|
261
311
|
JSON.parse(value)
|
|
262
312
|
rescue JSON::ParserError
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
313
|
+
parse_yaml_payload(value)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def parse_yaml_payload(value)
|
|
317
|
+
YAML.safe_load(value, aliases: false)
|
|
318
|
+
rescue Psych::Exception, ArgumentError, EncodingError => e
|
|
319
|
+
raise Error, "Invalid Redis coverage payload: #{e.message}"
|
|
266
320
|
end
|
|
267
321
|
|
|
268
322
|
def parse_hash_payload(entries)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
|
+
require 'fileutils'
|
|
4
5
|
require 'time'
|
|
5
6
|
require 'yaml'
|
|
6
7
|
|
|
@@ -12,13 +13,22 @@ module Necropsy
|
|
|
12
13
|
new(root: root, output: output, sample_rate: sample_rate).record(&)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
def
|
|
16
|
+
def self.install_at_exit(root:, output:, sample_rate: 1.0, merge: false, run_id: nil)
|
|
17
|
+
new(root: root, output: output, sample_rate: sample_rate, merge: merge, run_id: run_id).install_at_exit
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(root:, output:, sample_rate: 1.0, merge: false, run_id: nil)
|
|
16
21
|
@root = File.expand_path(root)
|
|
17
22
|
@output = output
|
|
18
23
|
@sample_rate = sample_rate.to_f
|
|
24
|
+
raise Error, 'sample_rate must be between 0.0 and 1.0' unless @sample_rate.between?(0.0, 1.0)
|
|
25
|
+
|
|
19
26
|
@nodes = {}
|
|
20
27
|
@edges = {}
|
|
21
|
-
@
|
|
28
|
+
@stacks = {}.compare_by_identity
|
|
29
|
+
@lock = Mutex.new
|
|
30
|
+
@merge = merge
|
|
31
|
+
@run_id = run_id
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
def record
|
|
@@ -31,27 +41,55 @@ module Necropsy
|
|
|
31
41
|
write_payload(started_at: started_at, finished_at: Time.now.utc)
|
|
32
42
|
end
|
|
33
43
|
|
|
44
|
+
def install_at_exit
|
|
45
|
+
started_at = Time.now.utc
|
|
46
|
+
tracer = TracePoint.new(:call, :return) { |event| capture(event) }
|
|
47
|
+
tracer.enable
|
|
48
|
+
at_exit do
|
|
49
|
+
tracer.disable
|
|
50
|
+
write_payload(started_at: started_at, finished_at: Time.now.utc)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
warn "Necropsy TracePoint collector failed: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
34
56
|
private
|
|
35
57
|
|
|
36
|
-
attr_reader :root, :output, :sample_rate, :nodes, :edges, :
|
|
58
|
+
attr_reader :root, :output, :sample_rate, :nodes, :edges, :stacks, :lock, :run_id
|
|
59
|
+
|
|
60
|
+
def merge?
|
|
61
|
+
@merge
|
|
62
|
+
end
|
|
37
63
|
|
|
38
64
|
def capture(event)
|
|
39
65
|
return unless project_path?(event.path)
|
|
40
|
-
return if sample_rate < 1.0 && SecureRandom.random_number > sample_rate
|
|
41
66
|
|
|
42
67
|
node_id = node_id_for(event)
|
|
43
68
|
return unless node_id
|
|
44
69
|
|
|
45
|
-
|
|
46
|
-
stack
|
|
47
|
-
return
|
|
70
|
+
lock.synchronize do
|
|
71
|
+
stack = stacks[Thread.current] ||= []
|
|
72
|
+
if event.event == :return
|
|
73
|
+
unwind_stack(stack, node_id)
|
|
74
|
+
stacks.delete(Thread.current) if stack.empty?
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sampled = sample_rate >= 1.0 || (sample_rate.positive? && SecureRandom.random_number < sample_rate)
|
|
79
|
+
caller_id = stack.last&.first if stack.last&.last
|
|
80
|
+
if sampled
|
|
81
|
+
nodes[node_id] = true
|
|
82
|
+
edges[[caller_id, node_id]] = true if caller_id
|
|
83
|
+
end
|
|
84
|
+
stack << [node_id, sampled]
|
|
48
85
|
end
|
|
86
|
+
end
|
|
49
87
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
88
|
+
def unwind_stack(stack, node_id)
|
|
89
|
+
index = stack.rindex { |frame| frame.first == node_id }
|
|
90
|
+
return unless index
|
|
53
91
|
|
|
54
|
-
stack
|
|
92
|
+
stack.slice!(index..)
|
|
55
93
|
end
|
|
56
94
|
|
|
57
95
|
def project_path?(path)
|
|
@@ -88,7 +126,64 @@ module Necropsy
|
|
|
88
126
|
'sample_rate' => sample_rate
|
|
89
127
|
}
|
|
90
128
|
}
|
|
91
|
-
|
|
129
|
+
payload['observation']['run_id'] = run_id if run_id
|
|
130
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
131
|
+
merge? ? write_merged_payload(payload) : File.write(output, payload.to_yaml)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def write_merged_payload(payload)
|
|
135
|
+
File.open(output, File::RDWR | File::CREAT, 0o644) do |file|
|
|
136
|
+
file.flock(File::LOCK_EX)
|
|
137
|
+
existing = trace_payload_for_current_run(read_payload(file), payload)
|
|
138
|
+
merged = merge_payload(existing, payload)
|
|
139
|
+
file.rewind
|
|
140
|
+
file.truncate(0)
|
|
141
|
+
file.write(merged.to_yaml)
|
|
142
|
+
file.flush
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def read_payload(file)
|
|
147
|
+
file.rewind
|
|
148
|
+
content = file.read
|
|
149
|
+
return {} if content.empty?
|
|
150
|
+
|
|
151
|
+
YAML.safe_load(content, aliases: false) || {}
|
|
152
|
+
rescue Psych::Exception
|
|
153
|
+
{}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def trace_payload_for_current_run(existing, payload)
|
|
157
|
+
current_run = payload.dig('observation', 'run_id')
|
|
158
|
+
return existing unless current_run
|
|
159
|
+
return existing if existing.empty? || existing.dig('observation', 'run_id') == current_run
|
|
160
|
+
|
|
161
|
+
{}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def merge_payload(left, right)
|
|
165
|
+
edges = (Array(left['edges']) + Array(right['edges'])).uniq do |edge|
|
|
166
|
+
[edge['caller_id'], edge['callee_id']]
|
|
167
|
+
end
|
|
168
|
+
left_observation = left.fetch('observation', {})
|
|
169
|
+
right_observation = right.fetch('observation', {})
|
|
170
|
+
observation = left_observation.merge(right_observation)
|
|
171
|
+
observation['started_at'] = [left_observation['started_at'], right_observation['started_at']].compact.min
|
|
172
|
+
observation['finished_at'] = [left_observation['finished_at'], right_observation['finished_at']].compact.max
|
|
173
|
+
observation['days'] = [left_observation['days'].to_i, right_observation['days'].to_i, 1].max
|
|
174
|
+
observation['processes'] = process_count(left_observation) + process_count(right_observation)
|
|
175
|
+
{
|
|
176
|
+
'nodes' => (Array(left['nodes']) + Array(right['nodes'])).uniq.sort,
|
|
177
|
+
'edges' => edges,
|
|
178
|
+
'observation' => observation
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def process_count(observation)
|
|
183
|
+
return observation['processes'].to_i if observation['processes']
|
|
184
|
+
return 0 if observation.empty?
|
|
185
|
+
|
|
186
|
+
1
|
|
92
187
|
end
|
|
93
188
|
end
|
|
94
189
|
end
|