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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acc21c7bf0937bb810decd0a6df03911149a32fc08146b15a37e74d133b397eb
4
- data.tar.gz: 781f39ee1fb14299dbd9bedb6cad792785269517494b1d6c336fdb52674c10c2
3
+ metadata.gz: cc6a4c3ecf1f46c6950e6cb91e522d6162e41b1bc1b8e8b2aaa96a75b1ccb575
4
+ data.tar.gz: b77ffd85970bf4445c287cce8e3727c3e8ba675314ecfc5b9ce5cc8df73cbdf9
5
5
  SHA512:
6
- metadata.gz: cef485cc0bb21d6e684107bb08f7882655ddf24600c9f9d53c744b5ce8a4cf78a460540c6bc9d981e652c6679f12641d34950761ceb1f78d17e8708436afc4d8
7
- data.tar.gz: 787d658f3eb89506530b4467d3b7071ed9eccab5a9288b4e023e6b9dbcdd97f6a5b5a7127927d060f698deca80510d0ccd81e059a33990bb0531238d85c95ed4
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
- The first implementation includes:
8
+ Necropsy includes:
9
9
 
10
- - Prism-based method collection for `def`, `define_method`, `attr_*`, and `delegate`
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/callback/view-helper/component, plain Ruby, and test-suite entry point providers
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/human/SARIF/GitHub reports, baseline/check guardrails, quarantine suggestions, TracePoint recording, and a bench evaluator
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.yml
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. Rails route
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, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/gem_tasks'
4
+ require 'rubocop/rake_task'
4
5
  require 'rspec/core/rake_task'
5
6
 
6
7
  RSpec::Core::RakeTask.new(:spec)
8
+ RuboCop::RakeTask.new
7
9
 
8
- task default: :spec
10
+ task default: %i[spec rubocop]
@@ -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
- return Coverage.peek_result if Coverage.respond_to?(:peek_result) && Coverage.running?
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.load(content) || {}
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.load_file(path) || {}
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.load_file(path) || {}
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 = TCPSocket.new(uri.host, uri.port || 6379)
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
- command('AUTH', uri.password)
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") + "\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 = socket.read(1)
247
+ prefix = read_exact(1)
233
248
  case prefix
234
249
  when '+'
235
- socket.gets("\r\n").delete_suffix("\r\n")
250
+ read_line
236
251
  when '-'
237
- raise Error, socket.gets("\r\n").delete_suffix("\r\n")
252
+ raise Error, read_line
238
253
  when ':'
239
- socket.gets("\r\n").to_i
254
+ read_line.to_i
240
255
  when '$'
241
256
  read_bulk_string
242
257
  when '*'
243
- Array.new(socket.gets("\r\n").to_i) { read_response }
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 = socket.gets("\r\n").to_i
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
- socket.read(2)
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
- YAML.load(value)
264
- rescue Psych::SyntaxError
265
- Marshal.load(value)
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 initialize(root:, output:, sample_rate: 1.0)
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
- @stack = []
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, :stack
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
- if event.event == :return
46
- stack.pop if stack.last == node_id
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
- caller_id = stack.last
51
- nodes[node_id] = true
52
- edges[[caller_id, node_id]] = true if caller_id
88
+ def unwind_stack(stack, node_id)
89
+ index = stack.rindex { |frame| frame.first == node_id }
90
+ return unless index
53
91
 
54
- stack << node_id
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
- File.write(output, payload.to_yaml)
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