bulldogger 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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +262 -0
  4. data/docs/design-decisions.md +142 -0
  5. data/docs/evidence-schema.md +388 -0
  6. data/docs/maintenance.md +92 -0
  7. data/docs/trace-schema.md +118 -0
  8. data/lib/bulldogger/capture.rb +98 -0
  9. data/lib/bulldogger/config.rb +57 -0
  10. data/lib/bulldogger/evidence.rb +106 -0
  11. data/lib/bulldogger/formatter.rb +103 -0
  12. data/lib/bulldogger/frame_source.rb +147 -0
  13. data/lib/bulldogger/integrations/minitest.rb +87 -0
  14. data/lib/bulldogger/integrations/rspec.rb +56 -0
  15. data/lib/bulldogger/minitest.rb +7 -0
  16. data/lib/bulldogger/pending.rb +58 -0
  17. data/lib/bulldogger/probe/bucket.rb +90 -0
  18. data/lib/bulldogger/probe/comparator.rb +95 -0
  19. data/lib/bulldogger/probe/method_stats.rb +215 -0
  20. data/lib/bulldogger/probe/raise_tracker.rb +141 -0
  21. data/lib/bulldogger/probe/registry.rb +32 -0
  22. data/lib/bulldogger/probe/session.rb +159 -0
  23. data/lib/bulldogger/probe/target.rb +13 -0
  24. data/lib/bulldogger/probe/target_resolver.rb +86 -0
  25. data/lib/bulldogger/probe/writer.rb +61 -0
  26. data/lib/bulldogger/probe.rb +36 -0
  27. data/lib/bulldogger/record/session.rb +334 -0
  28. data/lib/bulldogger/record/sqlite_converter.rb +86 -0
  29. data/lib/bulldogger/record/writer.rb +67 -0
  30. data/lib/bulldogger/record.rb +51 -0
  31. data/lib/bulldogger/redactor.rb +30 -0
  32. data/lib/bulldogger/rspec.rb +7 -0
  33. data/lib/bulldogger/run.rb +113 -0
  34. data/lib/bulldogger/version.rb +5 -0
  35. data/lib/bulldogger.rb +133 -0
  36. data/skills/bulldogger/SKILL.md +37 -0
  37. data/skills/bulldogger/references/failure-evidence.md +56 -0
  38. data/skills/bulldogger/references/probe.md +36 -0
  39. data/skills/bulldogger/references/record.md +28 -0
  40. metadata +153 -0
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulldogger
4
+ # Runtime knobs for capture, redaction, and file output. Each attribute
5
+ # has a hard-coded default so the tool works with zero setup; the
6
+ # environment variable overrides exist so an agent running tests in a
7
+ # child process can change behavior (for example, pointing output_dir
8
+ # at a scratch directory) without editing the app's own config file.
9
+ class Config
10
+ DEFAULT_REDACT_PATTERNS = [
11
+ /pass(?:word|wd)?/i,
12
+ /secret/i,
13
+ /token/i,
14
+ /api[_-]?key/i,
15
+ /\bkey\b/i,
16
+ /credential/i,
17
+ /auth/i,
18
+ /session/i,
19
+ /cookie/i
20
+ ].freeze
21
+
22
+ attr_accessor :enabled, :output_dir, :max_frames, :max_locals,
23
+ :max_value_length, :max_pending, :max_samples, :redact_patterns,
24
+ :frame_source
25
+
26
+ def initialize
27
+ @enabled = true
28
+ @output_dir = "tmp/bulldogger"
29
+ @max_frames = 20
30
+ @max_locals = 50
31
+ @max_value_length = 200
32
+ @max_pending = 32
33
+ @max_samples = 10
34
+ @redact_patterns = DEFAULT_REDACT_PATTERNS.dup
35
+ @frame_source = :auto
36
+ apply_env_overrides
37
+ end
38
+
39
+ private
40
+
41
+ def apply_env_overrides
42
+ output_dir_override = ENV["BULLDOGGER_OUTPUT_DIR"]
43
+ @output_dir = output_dir_override if output_dir_override && !output_dir_override.empty?
44
+
45
+ case ENV["BULLDOGGER_FRAME_SOURCE"]
46
+ when "capture_frames" then @frame_source = :capture_frames
47
+ when "degraded" then @frame_source = :degraded
48
+ end
49
+
50
+ # BULLDOGGER_DISABLE is the documented name; BULLDOGGER_DISABLED is
51
+ # accepted too. An adjective is the form English speakers reach
52
+ # for first, and a kill switch that silently ignores the natural
53
+ # spelling is worse than one that accepts an extra name.
54
+ @enabled = false if ENV["BULLDOGGER_DISABLE"] == "1" || ENV["BULLDOGGER_DISABLED"] == "1"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "version"
5
+
6
+ module Bulldogger
7
+ # Assembles and writes one evidence file: the exception, the test it
8
+ # failed in, and (if the ring still has it) the snapshot captured at
9
+ # :raise time.
10
+ #
11
+ # The exception's message and backtrace are read here, at report
12
+ # time, not inside the :raise hook. Exception#backtrace can still be
13
+ # nil when :raise fires -- Ruby fills it in as the exception
14
+ # unwinds -- so reading it from the hook would record less than what
15
+ # a test framework's own failure report already has by the time it
16
+ # calls record_failure.
17
+ class Evidence
18
+ SLUG_MAX_LENGTH = 80
19
+
20
+ def initialize(config:, run:, capture:)
21
+ @config = config
22
+ @run = run
23
+ @capture = capture
24
+ end
25
+
26
+ def record_failure(exception:, test:)
27
+ # A disabled switch means "wrote nothing" -- not "wrote an empty
28
+ # missed record". Writing capture_mode: missed here would mean
29
+ # a user who turned this off still gets a run directory and a
30
+ # frames_unavailable_reason, which reads as "tried and failed"
31
+ # rather than "did not run", the opposite of what they asked for.
32
+ return nil unless @config.enabled
33
+ return nil if exception.nil?
34
+
35
+ path = @run.next_path(slug_for(test))
36
+ payload = build_payload(exception: exception, test: test)
37
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
38
+ @run.record(path, test: payload["test"], exception_summary: payload["exception"].slice("class", "message"))
39
+ path
40
+ end
41
+
42
+ private
43
+
44
+ def build_payload(exception:, test:)
45
+ snapshot = @capture.snapshot_for(exception)
46
+ payload = {
47
+ "schema_version" => 1,
48
+ "tool" => { "name" => "bulldogger", "version" => Bulldogger::VERSION },
49
+ "captured_at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
50
+ "capture_mode" => snapshot ? snapshot["capture_mode"] : "missed",
51
+ "test" => normalize_test(test),
52
+ "exception" => build_exception_section(exception),
53
+ "frames" => snapshot ? snapshot["frames"] : []
54
+ }
55
+ frames_omitted = snapshot ? snapshot["frames_omitted"] : 0
56
+ payload["frames_omitted"] = frames_omitted if frames_omitted&.positive?
57
+ payload["frames_unavailable_reason"] = @capture.reason_for_missing(exception) unless snapshot
58
+ payload["limits"] = limits_section
59
+ payload
60
+ end
61
+
62
+ def build_exception_section(exception)
63
+ message = exception.message.to_s
64
+ limit = @config.max_value_length * 5
65
+ section = {
66
+ "class" => exception_class_name(exception),
67
+ "message" => message.length > limit ? "#{message[0, limit]}…" : message
68
+ }
69
+ if message.length > limit
70
+ section["message_truncated"] = true
71
+ section["message_original_length"] = message.length
72
+ end
73
+ section["backtrace"] = Array(exception.backtrace).first(@config.max_frames)
74
+ section
75
+ end
76
+
77
+ def exception_class_name(exception)
78
+ exception.class.name || exception.class.to_s
79
+ rescue Exception # rubocop:disable Lint/RescueException
80
+ "Object"
81
+ end
82
+
83
+ def normalize_test(test)
84
+ test ||= {}
85
+ {
86
+ "framework" => test[:framework],
87
+ "id" => test[:id],
88
+ "file" => test[:file],
89
+ "line" => test[:line]
90
+ }
91
+ end
92
+
93
+ def limits_section
94
+ {
95
+ "max_frames" => @config.max_frames,
96
+ "max_locals" => @config.max_locals,
97
+ "max_value_length" => @config.max_value_length
98
+ }
99
+ end
100
+
101
+ def slug_for(test)
102
+ raw = (test && test[:id] || "unknown").to_s
103
+ raw.gsub(/[^A-Za-z0-9_-]/, "-")[0, SLUG_MAX_LENGTH]
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulldogger
4
+ # Turns an arbitrary Ruby value into a bounded, JSON-safe String.
5
+ # Bounded because a snapshot is written on every failing test, so an
6
+ # unbounded value (a huge String, a deep object graph) would make
7
+ # capture itself expensive. Safe because `inspect` is arbitrary user
8
+ # code: it can raise, and the object it runs on may descend from
9
+ # BasicObject, where even `#class` is undefined.
10
+ class Formatter
11
+ MAX_ELEMENTS = 10
12
+
13
+ def initialize(config:, redactor:)
14
+ @max_value_length = config.max_value_length
15
+ @redactor = redactor
16
+ end
17
+
18
+ # Returns the per-local entry shape: {"value" => str} normally, plus
19
+ # "truncated"/"original_length" when the final string was too long.
20
+ # Those extra keys are the record that something was cut -- they
21
+ # must never appear on a value that was not, so a reader can trust
22
+ # their absence.
23
+ def format(value)
24
+ truncate_entry(render(value))
25
+ end
26
+
27
+ # Returns just the (possibly truncated) String, for JSON slots that
28
+ # hold a plain string rather than a {"value"=>...} entry -- a
29
+ # frame's "self", for example.
30
+ def format_self(value)
31
+ format(value)["value"]
32
+ end
33
+
34
+ private
35
+
36
+ def render(value)
37
+ case value
38
+ when Array
39
+ render_array(value)
40
+ when Hash
41
+ render_hash(value)
42
+ else
43
+ safe_inspect(value)
44
+ end
45
+ end
46
+
47
+ def render_array(array)
48
+ kept = array.first(MAX_ELEMENTS)
49
+ parts = kept.map { |element| render_nested(element) }
50
+ parts << "…" if array.size > kept.size
51
+ "[#{parts.join(', ')}]"
52
+ end
53
+
54
+ def render_hash(hash)
55
+ kept = hash.first(MAX_ELEMENTS)
56
+ parts = kept.map { |key, value| render_pair(key, value) }
57
+ parts << "…" if hash.size > kept.size
58
+ "{#{parts.join(', ')}}"
59
+ end
60
+
61
+ def render_pair(key, value)
62
+ key_repr = render_nested(key)
63
+ value_repr = @redactor.redact_key?(key) ? '"[REDACTED]"' : render_nested(value)
64
+ "#{key_repr} => #{value_repr}"
65
+ end
66
+
67
+ # One level of expansion only: an Array/Hash found *inside* an
68
+ # Array/Hash is shown as a placeholder, not walked further. Without
69
+ # this, a self-referential or very deep structure could make a
70
+ # single value's rendering unbounded even with max_value_length
71
+ # trimming the end result.
72
+ def render_nested(value)
73
+ case value
74
+ when Array then "[…]"
75
+ when Hash then "{…}"
76
+ else safe_inspect(value)
77
+ end
78
+ end
79
+
80
+ def safe_inspect(value)
81
+ value.inspect
82
+ rescue Exception => e # rubocop:disable Lint/RescueException
83
+ "#<#{safe_class_name(value)} (inspect raised #{e.class})>"
84
+ end
85
+
86
+ def safe_class_name(value)
87
+ klass = value.class
88
+ klass.respond_to?(:name) ? (klass.name || klass.to_s) : klass.to_s
89
+ rescue Exception # rubocop:disable Lint/RescueException
90
+ "Object"
91
+ end
92
+
93
+ def truncate_entry(str)
94
+ return { "value" => str } if str.length <= @max_value_length
95
+
96
+ {
97
+ "value" => str[0, @max_value_length] + "…",
98
+ "truncated" => true,
99
+ "original_length" => str.length
100
+ }
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulldogger
4
+ # Turns a :raise TracePoint event into a bounded array of frame
5
+ # descriptions. Prefers DEBUGGER__.capture_frames, which yields every
6
+ # frame's Binding; falls back to TracePoint#binding (raising frame
7
+ # only) plus the exception's backtrace locations (position only, no
8
+ # locals) when `debug/frame_info` cannot be loaded. That fallback is
9
+ # not a rare edge case: `debug` is a bundled gem, not a default gem,
10
+ # so under Bundler it is only present when the app's own Gemfile asks
11
+ # for it.
12
+ class FrameSource
13
+ def initialize(config:, formatter:, redactor:, skip_path_prefix: self.class.default_skip_path_prefix)
14
+ @config = config
15
+ @formatter = formatter
16
+ @redactor = redactor
17
+ @skip_path_prefix = skip_path_prefix
18
+ @mode = nil
19
+ end
20
+
21
+ # skip_path_prefix must be *this library's* lib directory, not the
22
+ # app's. capture_frames drops every frame whose path starts with
23
+ # the given prefix; pointing it at the app's own lib directory was
24
+ # measured to make the app's frames disappear instead of ours,
25
+ # leaving a snapshot with zero useful frames.
26
+ def self.default_skip_path_prefix
27
+ File.expand_path("..", __dir__)
28
+ end
29
+
30
+ # :auto resolves once, here, and is cached for the life of this
31
+ # object -- not re-checked on every raise, which would repeat a
32
+ # `require` check thousands of times in a large suite.
33
+ def resolve!
34
+ @mode ||= resolve_mode
35
+ self
36
+ end
37
+
38
+ def mode
39
+ @mode ||= resolve_mode
40
+ end
41
+
42
+ def capture(tp)
43
+ mode == :capture_frames ? capture_via_debugger(tp) : capture_degraded(tp)
44
+ end
45
+
46
+ private
47
+
48
+ def resolve_mode
49
+ configured = @config.frame_source
50
+ return :degraded if configured == :degraded
51
+
52
+ # Explicit :capture_frames still needs this require to have run --
53
+ # it is what defines the DEBUGGER__ constant this class calls
54
+ # into. Only :degraded can skip it; :auto needs the result to
55
+ # decide, and explicit :capture_frames needs it as a side effect
56
+ # even though the caller has already made the decision.
57
+ available = capture_frames_available?
58
+ return available ? :capture_frames : :degraded if configured == :auto
59
+
60
+ configured
61
+ end
62
+
63
+ def capture_frames_available?
64
+ require "debug/frame_info"
65
+ DEBUGGER__.respond_to?(:capture_frames)
66
+ rescue LoadError
67
+ false
68
+ end
69
+
70
+ def capture_via_debugger(tp)
71
+ frame_infos = DEBUGGER__.capture_frames(@skip_path_prefix)
72
+ kept = frame_infos.first(@config.max_frames)
73
+ frames = kept.each_with_index.map { |frame_info, index| build_frame(frame_info, index) }
74
+ [frames, frame_infos.size - kept.size]
75
+ end
76
+
77
+ def build_frame(frame_info, index)
78
+ location = frame_info.location
79
+ frame = {
80
+ "index" => index,
81
+ "path" => location&.path,
82
+ "line" => location&.lineno,
83
+ "label" => frame_info.name,
84
+ "self" => @formatter.format_self(frame_info.self)
85
+ }
86
+ binding = frame_info.binding
87
+ locals, locals_omitted = binding ? build_locals(binding) : [{}, 0]
88
+ frame["locals"] = locals
89
+ frame["locals_omitted"] = locals_omitted if locals_omitted.positive?
90
+ frame
91
+ end
92
+
93
+ def capture_degraded(tp)
94
+ locations = degraded_locations(tp)
95
+ kept = locations.first(@config.max_frames)
96
+ frames = kept.each_with_index.map { |location, index| build_degraded_frame(tp, location, index) }
97
+ [frames, locations.size - kept.size]
98
+ end
99
+
100
+ def degraded_locations(tp)
101
+ locations = tp.raised_exception.backtrace_locations
102
+ return locations if locations
103
+
104
+ # caller_locations here includes this hook's own frames (unlike
105
+ # backtrace_locations, which is the app's own backtrace and never
106
+ # contains ours), so they need the same prefix filter.
107
+ Array(caller_locations).reject { |location| location.path&.start_with?(@skip_path_prefix) }
108
+ end
109
+
110
+ def build_degraded_frame(tp, location, index)
111
+ frame = {
112
+ "index" => index,
113
+ "path" => location.path,
114
+ "line" => location.lineno,
115
+ "label" => location.label
116
+ }
117
+ if index.zero?
118
+ locals, locals_omitted = build_frame0_locals(tp)
119
+ frame["locals"] = locals
120
+ frame["locals_omitted"] = locals_omitted if locals_omitted.positive?
121
+ frame["self"] = @formatter.format_self(tp.self)
122
+ else
123
+ frame["locals_unavailable"] = true
124
+ end
125
+ frame
126
+ end
127
+
128
+ def build_frame0_locals(tp)
129
+ binding = tp.binding
130
+ binding ? build_locals(binding) : [{}, 0]
131
+ end
132
+
133
+ def build_locals(binding)
134
+ names = binding.local_variables
135
+ kept = names.first(@config.max_locals)
136
+ locals = {}
137
+ kept.each { |name| locals[name.to_s] = build_local_entry(name, binding) }
138
+ [locals, names.size - kept.size]
139
+ end
140
+
141
+ def build_local_entry(name, binding)
142
+ return { "redacted" => true, "reason" => "name" } if @redactor.redact_name?(name)
143
+
144
+ @formatter.format(binding.local_variable_get(name))
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require_relative "../../bulldogger"
5
+
6
+ module Bulldogger
7
+ module Minitest
8
+ # Minitest's own extension point: Minitest.register_plugin with a
9
+ # Module makes Minitest call #minitest_plugin_init once, during
10
+ # Minitest.run, at the one moment Minitest.reporter is set (the
11
+ # accessor is nil outside that window). That is why Bulldogger.start
12
+ # and the reporter wiring happen here and not at require time --
13
+ # nothing has run yet, so nothing needs the TracePoint before this.
14
+ def self.minitest_plugin_init(_options)
15
+ return if @wired
16
+
17
+ @wired = true
18
+ Bulldogger.start
19
+ ::Minitest.reporter << Reporter.new
20
+ ::Minitest.after_run do
21
+ Bulldogger.finish
22
+ Bulldogger.stop
23
+ end
24
+ end
25
+
26
+ # Turns one finished test into a Bulldogger evidence file.
27
+ # Registered into Minitest's CompositeReporter, so #record runs
28
+ # once per test method, pass or fail. Minitest.run_one_method
29
+ # always returns a Minitest::Result, never the Test instance
30
+ # itself, so the test's id/file/line come from Result's own
31
+ # klass/name/source_location rather than from re-deriving them.
32
+ class Reporter < ::Minitest::AbstractReporter
33
+ def record(result)
34
+ failure = result.failure
35
+ return if failure.nil? || result.skipped?
36
+
37
+ exception = exception_for(failure)
38
+ path = Bulldogger.record_failure(exception: exception, test: test_for(result))
39
+ annotate!(failure, path) if path
40
+ end
41
+
42
+ private
43
+
44
+ # UnexpectedError wraps whatever the app actually raised;
45
+ # #error unwraps it, and is defined to return self on a plain
46
+ # Assertion, so this one line also covers assertion failures
47
+ # without a separate branch. The wrapper itself is only ever
48
+ # `.new`'d, never `raise`'d, so :raise only fires for the inner
49
+ # exception -- snapshot_for is still tried on both, in that
50
+ # order, so nothing breaks if a future Minitest version starts
51
+ # re-raising the wrapper instead.
52
+ def exception_for(failure)
53
+ inner = failure.respond_to?(:error) ? failure.error : failure
54
+ return inner if Bulldogger.snapshot_for(inner)
55
+ return failure if Bulldogger.snapshot_for(failure)
56
+
57
+ inner
58
+ end
59
+
60
+ def test_for(result)
61
+ file, line = result.source_location
62
+ {
63
+ framework: "minitest",
64
+ id: "#{result.class_name}##{result.name}",
65
+ file: file,
66
+ line: line
67
+ }
68
+ end
69
+
70
+ # First choice: tag the failure exception's own #message, so the
71
+ # one line sits inside the same text Minitest's SummaryReporter
72
+ # already prints for this failure. A raised exception can be
73
+ # frozen (a re-raised literal, an app that freezes its errors),
74
+ # and #define_singleton_method on a frozen object raises --
75
+ # fall back to printing the line ourselves so a frozen exception
76
+ # never means a silently missing evidence line.
77
+ def annotate!(failure, path)
78
+ original = failure.message
79
+ failure.define_singleton_method(:message) { "#{original}\nbulldogger evidence: #{path}" }
80
+ rescue FrozenError
81
+ $stdout.puts "bulldogger evidence: #{path}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ ::Minitest.register_plugin(Bulldogger::Minitest)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require_relative "../../bulldogger"
5
+
6
+ module Bulldogger
7
+ module RSpec
8
+ # RSpec has already scored the example's description and its
9
+ # file/line into metadata by the time after(:each) runs; test_for
10
+ # reads those off the Example instead of re-deriving them.
11
+ def self.test_for(example)
12
+ {
13
+ framework: "rspec",
14
+ id: example.full_description,
15
+ file: example.metadata[:file_path],
16
+ line: example.metadata[:line_number]
17
+ }
18
+ end
19
+
20
+ # Mirrors the minitest integration's annotate!: tag the exception's
21
+ # own #message first, so the evidence line sits inside the same
22
+ # text RSpec's own formatter already prints for this failure. A
23
+ # frozen exception can't take a singleton method; fall back to
24
+ # printing the line directly so a frozen exception never means a
25
+ # silently missing evidence line.
26
+ def self.annotate!(exception, path)
27
+ original = exception.message
28
+ exception.define_singleton_method(:message) { "#{original}\nbulldogger evidence: #{path}" }
29
+ rescue FrozenError
30
+ $stdout.puts "bulldogger evidence: #{path}"
31
+ end
32
+ end
33
+ end
34
+
35
+ ::RSpec.configure do |config|
36
+ config.before(:suite) { Bulldogger.start }
37
+
38
+ # after(:each), not a formatter hook: example.exception is already
39
+ # set by the time this runs (Example#run assigns it before
40
+ # run_after_example fires the after(:each) hooks), and evidence
41
+ # must be written -- and the exception's #message annotated -- before
42
+ # RSpec's own formatters render the failure, or the added line would
43
+ # never reach the user's terminal.
44
+ config.after(:each) do |example|
45
+ exception = example.exception
46
+ next unless exception
47
+
48
+ path = Bulldogger.record_failure(exception: exception, test: Bulldogger::RSpec.test_for(example))
49
+ Bulldogger::RSpec.annotate!(exception, path) if path
50
+ end
51
+
52
+ config.after(:suite) do
53
+ Bulldogger.finish
54
+ Bulldogger.stop
55
+ end
56
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The one line a test_helper.rb adds to turn Bulldogger on for
4
+ # Minitest. Kept separate from lib/bulldogger/integrations/minitest.rb
5
+ # so that file can stay organized by framework alongside rspec.rb,
6
+ # while this stays the short, memorable require path.
7
+ require_relative "integrations/minitest"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulldogger
4
+ # Insertion-ordered, bounded map from an exception object to its
5
+ # captured snapshot. Bounded because most raises are caught and
6
+ # handled by the app and never become a test failure -- without a
7
+ # cap, a suite that raises-and-rescues heavily would grow this map
8
+ # without limit. Matches by object identity (`equal?`), not `hash`/
9
+ # `eql?`, so an exception class that overrides those can't collide
10
+ # with an unrelated exception instance.
11
+ #
12
+ # ObjectSpace::WeakMap was considered and rejected: its eviction is
13
+ # driven by GC timing, which we cannot observe or bound, and its
14
+ # membership semantics would need the same identity-matching logic
15
+ # this class already provides directly.
16
+ class Pending
17
+ Entry = Struct.new(:exception, :snapshot)
18
+
19
+ def initialize(max_size)
20
+ @max_size = max_size
21
+ @entries = []
22
+ @evicted = []
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # First-write-wins: a re-raised exception (rescue; raise) fires
27
+ # :raise again from the rescue frame. That second capture is worth
28
+ # less than the first -- it points at the handler, not the bug --
29
+ # so an exception already in the ring keeps its original snapshot.
30
+ def put(exception, snapshot)
31
+ @mutex.synchronize do
32
+ next if find(exception)
33
+
34
+ @entries << Entry.new(exception, snapshot)
35
+ next unless @entries.size > @max_size
36
+
37
+ evicted_entry = @entries.shift
38
+ @evicted << evicted_entry.exception
39
+ @evicted.shift if @evicted.size > @max_size
40
+ end
41
+ nil
42
+ end
43
+
44
+ def get(exception)
45
+ @mutex.synchronize { find(exception)&.snapshot }
46
+ end
47
+
48
+ def evicted?(exception)
49
+ @mutex.synchronize { @evicted.any? { |e| e.equal?(exception) } }
50
+ end
51
+
52
+ private
53
+
54
+ def find(exception)
55
+ @entries.find { |entry| entry.exception.equal?(exception) }
56
+ end
57
+ end
58
+ end