bulldogger
bulldogger writes Ruby test failures as structured evidence for coding agents. Each JSON file contains the exception, the backtrace, and captured frame values. The failure output gives the absolute path to that file.
Development and snapshot measurements used Ruby 4.0.6 and debug 1.11.1.
Install
Add both gems to the test group:
gem "bulldogger", group: :test
gem "debug", group: :test
The debug gem supplies DEBUGGER__.capture_frames, which gives bulldogger locals from each frame.
Ruby distributes debug as a bundled gem.
Bundler only exposes it when the application Gemfile includes it.
Without debug, bulldogger records locals from the raising frame.
It records file, line, and label data for the remaining frames.
Install the skill on an agent host from the default branch:
gh skill install meganemura/bulldogger
Use the copy that matches the installed gem when version alignment matters:
bulldogger skill path
The first command installs the skill on the agent host. The second command prints the matching gem copy.
The CLI has these subcommands:
bulldogger frames -- command...
bulldogger preflight -- command...
bulldogger flt 'path:method#k' [--index path] -- command...
bulldogger exec 'path:method#k' --line N [--visit K] --statement text [--index path] -- command...
bulldogger skill path
bulldogger version
bulldogger --version
Add one framework entry point.
For Minitest, add this line to test_helper.rb:
require "bulldogger/minitest"
For RSpec, add this line to spec_helper.rb:
require "bulldogger/rspec"
Each entry point starts capture and records each failed test. It finishes the run index when the suite ends.
Use independent instances
The Bulldogger module delegates its API to Bulldogger.default.
Existing calls such as Bulldogger.start and Bulldogger.probe use that default instance.
Create a separate instance when two capture lifecycles must run at the same time:
observer = Bulldogger::Instance.new
observer.start
Each instance owns its configuration, capture subscription, run, and evidence state. It also provides the failure and probe methods from the module facade.
An integration can use an assigned instance instead of the default:
Bulldogger::Minitest.instance = observer
Bulldogger::RSpec.instance = observer
Assign the instance before the suite starts. This separation lets an outer observer remain active while test setup replaces the default instance.
Cost model
The always-on path captures a failure snapshot. It does no work for a green test that raises no exception. Each observed exception costs microseconds.
The snapshot benchmark used Ruby 4.0.6. It measured 44.126 microseconds for each raise-and-rescue cycle. Frame capture used 1.288 microseconds of that total.
Re-execution is explicit.
frames, preflight, flt, and exec start a new process only when you run them.
probe also runs only around a block that you select.
Start from a failure snapshot
This output came from:
bundle exec ruby -Ilib test/fixtures/minitest_red/red_test.rb --seed 12345
1) Error:
RedTest#test_deep_raise:
ArgumentError: expected 3 to equal the sum of [1, 2, 3]
test/fixtures/minitest_red/app.rb:9:in 'Order.total'
test/fixtures/minitest_red/red_test.rb:20:in 'RedTest#test_deep_raise'
bulldogger evidence: /home/you/project/tmp/bulldogger/run-20260831-192835-69510/001-RedTest-test_deep_raise.json (raising method is in these frames)
bulldogger rerun: bundle exec ruby -Itest test/fixtures/minitest_red/red_test.rb -n /\\Atest_deep_raise\\z/ --seed 12345
Open the path on the line with the parenthetical guidance. The file contains one failure and its captured runtime values. The rerun line gives the complete command for that test and seed. bulldogger does not run this command automatically.
Each run uses this layout:
tmp/bulldogger/
latest -> run-20260829-100406-58231
run-20260829-100406-58231/
001-RedTest-test_deep_raise.json
002-RedTest-test_assertion_failure.json
index.json
The bulldogger skill tells an agent how to inspect these files.
The evidence schema defines every field and capture mode.
Index one isolated run with frames
Run the rerun command under frames:
bulldogger frames -- bundle exec ruby -Itest test/fixtures/frames/minitest_frames_test.rb --seed 12345
The command prints the index path and the child result:
bulldogger frames: /home/you/project/tmp/bulldogger/frames-69833.jsonl
bulldogger result: pass (exit 0)
The index names each call with a frame identifier, or fid.
The format is path:method#k.
The number counts calls to that method inside the test window.
The index includes application, framework, and gem frames.
Verify an isolated rerun with preflight
Run preflight before flt or exec:
bulldogger preflight -- bundle exec ruby -Itest test/fixtures/frames/minitest_frames_test.rb --seed 12345
It runs the command twice in separate processes. It compares the two application-frame sequences.
bulldogger preflight: deterministic (app frames: 3)
bulldogger preflight indexes: /home/you/project/tmp/bulldogger/frames-70427.jsonl /home/you/project/tmp/bulldogger/frames-70432.jsonl
Use flt or exec only when preflight reports deterministic.
Both verbs accept application frame identifiers only.
Trace one frame with flt
Pass an application fid and the same isolated command:
bulldogger flt 'test/fixtures/flt/minitest_flt_test.rb:branchy#1' -- bundle exec ruby -Itest test/fixtures/flt/minitest_flt_test.rb --seed 12345
bulldogger flt: /home/you/project/tmp/bulldogger/flt-77206.jsonl
bulldogger result: pass (exit 0)
The trace contains the frame entry, line changes, raises, and the return.
It records new locals under new and updates under changed.
It lists locals that leave scope under out_of_scope.
It folds loop middle iterations into skipped_iterations records.
Use --index path to require the index and rerun to have the same code-state marker.
Evaluate one statement with exec
Address one line visit inside an application frame:
bulldogger exec 'test/fixtures/exec/minitest_exec_test.rb:threshold#1' --line 9 --statement 'binding.local_variable_set(:result, 10)' -- bundle exec ruby -Itest test/fixtures/exec/minitest_exec_test.rb --seed 12345 -n test_injection_can_change_the_outcome
bulldogger exec: /home/you/project/tmp/bulldogger/exec-77267.jsonl
bulldogger value: 10
bulldogger result: pass (exit 0)
exec evaluates the statement in the selected frame binding.
The default visit is the first visit to the line.
Use --visit K for a later visit.
The launcher gives the child the required BULLDOGGER_EXEC=1 token.
Use --index path to require a matching code-state marker.
The statement can change test behavior and perform side effects. Read the result file before you use the changed outcome as evidence.
Seed random values in RSpec
RSpec uses its seed to order examples.
RSpec does not call Kernel.srand.
Add this line inside RSpec.configure when examples call rand:
config.before(:suite) { Kernel.srand config.seed }
This line lets the printed rerun seed reproduce those random values.
bulldogger does not call Kernel.srand for the application.
Target a method with probe
Wrap the relevant test or operation with a named target:
before_path = Bulldogger.probe("Billing::Invoice#amount") do
end
The evidence summarizes argument and return classes, nil values, raised exits, and callers.
It serializes the first 10 samples by default and counts every call.
Run the probe before and after a change, then compare the two files:
result = Bulldogger.probe_compare(before_path, after_path)
result.fetch("identical")
An identical value of true shows that the compared behavior stayed the same.
The comparison covers call counts, classes, nil counts, raised exits, parameters, callers, and normalized samples.
This excerpt came from a generated probe file:
{
"kind": "probe",
"targets": ["ProseSample#amount"],
"methods": {
"ProseSample#amount": {
"calls": 3,
"raised_exits": 1,
"returns": {
"classes": {"Integer": 1, "NilClass": 1},
"nil_count": 1,
"samples": [{"value": "21"}, {"value": "nil"}]
},
"raised": {"ArgumentError": 1},
"callers": {"-e:1:in 'block in <main>'": 3}
}
},
"limits": {"max_samples": 10, "max_value_length": 200}
}
Cost
TracePoint(:raise) observes every raised exception, including exceptions that application code rescues.
These measurements used Ruby 4.0.6.
Each condition has three runs, and the table gives each median.
| condition | without bulldogger | with bulldogger | ratio |
|---|---|---|---|
| 2,000,000 no-op iterations with no raised exception | 0.0423s | 0.0424s | 1.00x |
| 10,000 raise and rescue cycles | 0.0055s | 0.4468s | 81.14x |
| 200 recorded failures with file output | 0.0001s | 0.0277s | 413.58x |
The second condition costs 44.126 microseconds for each exception in this measurement.
The capture cost has this measured breakdown:
| stage | added cost for each exception |
|---|---|
Subscribe to TracePoint(:raise) |
0.136 microseconds |
Call DEBUGGER__.capture_frames |
1.288 microseconds |
| Serialize, redact, and insert into the ring | 23.220 microseconds |
Frame capture has a small cost in this measurement, while later processing accounts for most of the measured cost.
A green suite with no raised exception caused no measurable overhead in this test. A green suite can still raise and rescue exceptions, and each exception incurs the capture cost.
Explicit verb cost
The proportionality harness measured 1461.5 ns per targeted probe call.
The frame gate measured about 105 ns per non-target call.
An flt line event has a fixed cost near 0.5 microseconds.
Reading each local adds about 0.1 microseconds per line event.
A frame with 20 locals and 10,000 line events cost about 27 ms.
These measurements used Ruby 4.0.6 and the rubygems.org test suite. The suite had 4,925 tests, seed 12345, and one worker. The measurements ran on 2026-08-31.
Disable bulldogger
Set BULLDOGGER_DISABLE=1 to disable capture and output for one test process.
BULLDOGGER_DISABLED=1 is an alias with the same behavior.
With either switch, startup returns before the TracePoint(:raise) subscription.
It writes no evidence, creates no run directory, and adds no evidence line to a failure.
The test exit code and failure count stay unchanged.
Acceptance tests confirm this behavior for Minitest and RSpec.
A rescue-heavy green suite measured 1.00x with bulldogger disabled.
Environment variables
These environment variables configure a child test process:
| Variable | Accepted value | Default and effect |
|---|---|---|
BULLDOGGER_DISABLE |
1 |
Capture is enabled by default. 1 disables capture and output. |
BULLDOGGER_DISABLED |
1 |
Alias for BULLDOGGER_DISABLE. |
BULLDOGGER_OUTPUT_DIR |
A nonempty path | The default is tmp/bulldogger, relative to the working directory. |
BULLDOGGER_FRAME_SOURCE |
capture_frames or degraded |
The default is automatic selection. |
Secrets and limits
Captured values can contain secrets.
bulldogger checks each local name before it calls inspect on the value.
A matching local becomes {"redacted": true, "reason": "name"} and has no value field.
The default patterns match these names without regard to case:
password,passwd, andpasssecretandtokenapi_keyandapi-key- the word
key credential,auth,session, andcookie
The patterns favor redaction when a name is ambiguous.
For example, /auth/i also matches author and authorized.
Applications can replace Bulldogger.config.redact_patterns with their own regular expressions.
Bulldogger compiles these patterns into one union when it constructs a redactor.
An in-place change to the source array does not change an existing redactor.
Assign a new pattern array before Bulldogger constructs the capture or trace session that will use it.
bulldogger also checks keys while it renders a Hash.
A matching key has the string "[REDACTED]" as its rendered value.
The defaults keep 20 frames and 50 locals for each frame. Each rendered value keeps 200 characters, and each Array or Hash keeps 10 elements. The evidence file marks omitted or truncated data.
Scope of version 0.2
Version 0.2 provides failure snapshots, targeted probes, frame indexes, determinism checks, frame lifetime traces, statement evaluation, and independent instances. It writes JSON evidence and JSONL artifacts. Heavy collection starts only through an explicit verb.
The design decisions explain the evidence model and its measured costs.
License
MIT. See LICENSE.