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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 90b1681b42b715c3a8c90836ae9fe8c2378d15fb542b014512ad9cbab6451089
4
+ data.tar.gz: 9535ac4a1206eb1dd91bef0170abf2d7d877ac4a18257dd47faafb15c4bcfa71
5
+ SHA512:
6
+ metadata.gz: 042df51e319ebf9d8057d2f9125cffdd8437c6b3898e1ee5d450c33be21bec3d1218321f2256e0ea5cd51d4f9c1c73d9350e2fcfb2a40424ac7deb8d627df473
7
+ data.tar.gz: 27e433a4e256a55150270aa5448a8fb9ee06d378a1e528236d63b5f558016e04724cf574382ae64b9730f878deb3e3d5b49f3825b9ba4865de0f40ff86f93b1c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 meganemura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,262 @@
1
+ # bulldogger
2
+
3
+ bulldogger writes Ruby test failures as structured evidence for coding agents.
4
+ Each JSON file contains the exception, the backtrace, and captured frame values.
5
+ The failure output gives the absolute path to that file.
6
+
7
+ Development and measurements used Ruby 4.0.6 and debug 1.11.1.
8
+ Publication of bulldogger 0.1.0 is pending.
9
+
10
+ ## Install
11
+
12
+ Add both gems to the test group:
13
+
14
+ ```ruby
15
+ gem "bulldogger", group: :test
16
+ gem "debug", group: :test
17
+ ```
18
+
19
+ The `debug` gem supplies `DEBUGGER__.capture_frames`, which gives bulldogger locals from each frame.
20
+ Ruby distributes `debug` as a bundled gem.
21
+ Bundler only exposes it when the application Gemfile includes it.
22
+
23
+ Without `debug`, bulldogger records locals from the raising frame.
24
+ It records file, line, and label data for the remaining frames.
25
+
26
+ Add one framework entry point.
27
+
28
+ For Minitest, add this line to `test_helper.rb`:
29
+
30
+ ```ruby
31
+ require "bulldogger/minitest"
32
+ ```
33
+
34
+ For RSpec, add this line to `spec_helper.rb`:
35
+
36
+ ```ruby
37
+ require "bulldogger/rspec"
38
+ ```
39
+
40
+ Each entry point starts capture and records each failed test.
41
+ It finishes the run index when the suite ends.
42
+
43
+ ## Three approaches
44
+
45
+ bulldogger provides three ways to collect runtime evidence:
46
+
47
+ - A failure snapshot is the default. Green tests do not capture data when they raise no exception.
48
+ - `probe` watches named methods during one explicit run.
49
+ - `record` traces all Ruby method calls during one explicit run.
50
+
51
+ Use a failure snapshot when a failed test already provides an evidence path.
52
+ Use `probe` to inspect one method or to compare behavior before and after a change.
53
+ Use `record` when you must follow the full call sequence.
54
+
55
+ ## Failure output
56
+
57
+ This output came from:
58
+
59
+ ```sh
60
+ bundle exec ruby -Ilib test/fixtures/minitest_red/red_test.rb
61
+ ```
62
+
63
+ ```text
64
+ 2) Error:
65
+ RedTest#test_deep_raise:
66
+ ArgumentError: expected 3 to equal the sum of [1, 2, 3]
67
+ test/fixtures/minitest_red/app.rb:9:in 'Order.total'
68
+ test/fixtures/minitest_red/red_test.rb:20:in 'RedTest#test_deep_raise'
69
+ bulldogger evidence: /home/you/project/tmp/bulldogger/run-20260828-173512-6178/002-RedTest-test_deep_raise.json
70
+ ```
71
+
72
+ Open the path after `bulldogger evidence:`.
73
+ The file contains one failure and its captured runtime values.
74
+
75
+ Each run uses this layout:
76
+
77
+ ```text
78
+ tmp/bulldogger/
79
+ latest -> run-20260828-173512-6178
80
+ run-20260828-173512-6178/
81
+ 001-RedTest-test_assertion_failure.json
82
+ 002-RedTest-test_deep_raise.json
83
+ index.json
84
+ ```
85
+
86
+ The [`bulldogger` skill](skills/bulldogger/SKILL.md) tells an agent how to inspect these files.
87
+ The [evidence schema](docs/evidence-schema.md) defines every field and capture mode.
88
+
89
+ ## Target a method with probe
90
+
91
+ Wrap the relevant test or operation with a named target:
92
+
93
+ ```ruby
94
+ before_path = Bulldogger.probe("Billing::Invoice#amount") do
95
+ run_related_test
96
+ end
97
+ ```
98
+
99
+ The evidence summarizes argument and return classes, `nil` values, raised exits, and callers.
100
+ It serializes the first 10 samples by default and counts every call.
101
+
102
+ Run the probe before and after a change, then compare the two files:
103
+
104
+ ```ruby
105
+ result = Bulldogger.probe_compare(before_path, after_path)
106
+ result.fetch("identical")
107
+ ```
108
+
109
+ An `identical` value of `true` shows that the compared behavior stayed the same.
110
+ The comparison covers call counts, classes, `nil` counts, raised exits, parameters, callers, and normalized samples.
111
+
112
+ This excerpt came from a generated probe file:
113
+
114
+ ```json
115
+ {
116
+ "kind": "probe",
117
+ "targets": ["ProseSample#amount"],
118
+ "methods": {
119
+ "ProseSample#amount": {
120
+ "calls": 3,
121
+ "raised_exits": 1,
122
+ "returns": {
123
+ "classes": {"Integer": 1, "NilClass": 1},
124
+ "nil_count": 1,
125
+ "samples": [{"value": "21"}, {"value": "nil"}]
126
+ },
127
+ "raised": {"ArgumentError": 1},
128
+ "callers": {"-e:1:in 'block in <main>'": 3}
129
+ }
130
+ },
131
+ "limits": {"max_samples": 10, "max_value_length": 200}
132
+ }
133
+ ```
134
+
135
+ ## Record the call sequence
136
+
137
+ Wrap one focused operation when you need a full call sequence:
138
+
139
+ ```ruby
140
+ trace_path = Bulldogger.record do
141
+ run_related_test
142
+ end
143
+ ```
144
+
145
+ The result is a JSONL file with a header and one object for each call, return, or raise event.
146
+ This excerpt came from a generated trace:
147
+
148
+ ```jsonl
149
+ {"schema_version":1,"kind":"record","events":["call","return","raise"],"limits":{"max_value_length":200}}
150
+ {"event":"call","seq":1,"depth":1,"path":"-e","line":1,"method":"ProseTrace#outer","args":{"value":{"value":"3"}}}
151
+ {"event":"return","seq":4,"depth":1,"path":"-e","line":1,"method":"ProseTrace#outer","return":{"value":"6"}}
152
+ {"event":"raise","seq":7,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","exception":{"class":"ArgumentError","message":"negative"}}
153
+ {"event":"return","seq":8,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","raised":true}
154
+ ```
155
+
156
+ The [trace schema](docs/trace-schema.md) defines the event fields and tested `jq` queries.
157
+
158
+ JSONL is the primary record format.
159
+ `Bulldogger.trace_to_sqlite(trace_path, db_path)` converts an existing trace when the `sqlite3` gem is available.
160
+ The converter uses a soft require, so bulldogger keeps zero runtime dependencies.
161
+
162
+ ## Cost
163
+
164
+ `TracePoint(:raise)` observes every raised exception, including exceptions that application code rescues.
165
+ These measurements used Ruby 4.0.6.
166
+ Each condition has three runs, and the table gives each median.
167
+
168
+ | condition | without bulldogger | with bulldogger | ratio |
169
+ |---|---:|---:|---:|
170
+ | 2,000,000 no-op iterations with no raised exception | 0.0389s | 0.0387s | 0.99x |
171
+ | 10,000 raise and rescue cycles | 0.0052s | 0.4256s | 82.60x |
172
+ | 200 recorded failures with file output | 0.0001s | 0.0252s | 412.85x |
173
+
174
+ The second condition costs 42.042 microseconds for each exception.
175
+ Repeated runs measured 40 to 42 microseconds and 76x to 83x.
176
+
177
+ The capture cost has this measured breakdown:
178
+
179
+ | stage | added cost for each exception |
180
+ |---|---:|
181
+ | Subscribe to `TracePoint(:raise)` | 0.019 microseconds |
182
+ | Call `DEBUGGER__.capture_frames` | 1.411 microseconds |
183
+ | Serialize, redact, and insert into the ring | 24.198 microseconds |
184
+
185
+ Frame capture has a small cost in this measurement, while later processing accounts for most of the measured cost.
186
+
187
+ A green suite with no raised exception caused no measurable overhead in this test.
188
+ A green suite can still raise and rescue exceptions, and each exception incurs the capture cost.
189
+
190
+ ### Explicit verb cost
191
+
192
+ The proportionality harness used one app fixture for both verbs.
193
+ It measured about 1,330 ns per targeted method call for `probe`.
194
+ It measured about 4,500 ns per traced call for `record`.
195
+
196
+ `probe` cost scales with calls to the targeted method, which the harness names M.
197
+ `record` cost scales with all traced calls, which the harness names N.
198
+ With M/N at 0.25, the fixture measured `probe` at 8.28x and `record` at 111.76x.
199
+ These ratios describe this app fixture, and another app has a different M/N value.
200
+
201
+ `probe` and `record` are explicit verbs for one focused run before or after a change.
202
+ Do not apply either verb continuously to the full suite.
203
+
204
+ ## Disable bulldogger
205
+
206
+ Set `BULLDOGGER_DISABLE=1` to disable capture and output for one test process.
207
+ `BULLDOGGER_DISABLED=1` is an alias with the same behavior.
208
+
209
+ With either switch, startup returns before the `TracePoint(:raise)` subscription.
210
+ It writes no evidence, creates no run directory, and adds no evidence line to a failure.
211
+ The test exit code and failure count stay unchanged.
212
+ Acceptance tests confirm this behavior for Minitest and RSpec.
213
+
214
+ A rescue-heavy green suite measured 0.99x with bulldogger disabled.
215
+
216
+ ## Environment variables
217
+
218
+ These environment variables configure a child test process:
219
+
220
+ | Variable | Accepted value | Default and effect |
221
+ |---|---|---|
222
+ | `BULLDOGGER_DISABLE` | `1` | Capture is enabled by default. `1` disables capture and output. |
223
+ | `BULLDOGGER_DISABLED` | `1` | Alias for `BULLDOGGER_DISABLE`. |
224
+ | `BULLDOGGER_OUTPUT_DIR` | A nonempty path | The default is `tmp/bulldogger`, relative to the working directory. |
225
+ | `BULLDOGGER_FRAME_SOURCE` | `capture_frames` or `degraded` | The default is automatic selection. |
226
+
227
+ ## Secrets and limits
228
+
229
+ Captured values can contain secrets.
230
+ bulldogger checks each local name before it calls `inspect` on the value.
231
+ A matching local becomes `{"redacted": true, "reason": "name"}` and has no `value` field.
232
+
233
+ The default patterns match these names without regard to case:
234
+
235
+ - `password`, `passwd`, and `pass`
236
+ - `secret` and `token`
237
+ - `api_key` and `api-key`
238
+ - the word `key`
239
+ - `credential`, `auth`, `session`, and `cookie`
240
+
241
+ The patterns favor redaction when a name is ambiguous.
242
+ For example, `/auth/i` also matches `author` and `authorized`.
243
+ Applications can replace `Bulldogger.config.redact_patterns` with their own regular expressions.
244
+
245
+ bulldogger also checks keys while it renders a Hash.
246
+ A matching key has the string `"[REDACTED]"` as its rendered value.
247
+
248
+ The defaults keep 20 frames and 50 locals for each frame.
249
+ Each rendered value keeps 200 characters, and each Array or Hash keeps 10 elements.
250
+ The evidence file marks omitted or truncated data.
251
+
252
+ ## Scope of version 0.1
253
+
254
+ Version 0.1 provides failure snapshots, targeted probes, and explicit full records.
255
+ It writes JSON evidence and JSONL traces.
256
+ The version 0.1 boundary exposes file artifacts and an offline SQLite converter.
257
+
258
+ The [design decisions](docs/design-decisions.md) explain the three approaches and their measured costs.
259
+
260
+ ## License
261
+
262
+ MIT. See `LICENSE`.
@@ -0,0 +1,142 @@
1
+ # Design decisions
2
+
3
+ These decisions define the failure capture path in bulldogger 0.1.0.
4
+ The measurements used Ruby 4.0.6 and debug 1.11.1.
5
+
6
+ ## Capture only raised exceptions
7
+
8
+ An early design traced each method call and return.
9
+ Measurements placed its run time at 60x to 106x the baseline.
10
+ That cost applied throughout each test run.
11
+
12
+ bulldogger subscribes to `TracePoint(:raise)`.
13
+ The subscription produces no capture work until Ruby raises an exception.
14
+
15
+ ## Build no shadow stack
16
+
17
+ Another design kept each frame `Binding` in a shadow call stack.
18
+ A `TracePoint` updated the stack on every call and return.
19
+ Measurements placed this design at 9x to 18x the baseline.
20
+
21
+ bulldogger asks `DEBUGGER__.capture_frames` for the stack when Ruby raises.
22
+ This choice removes the continuous call and return work.
23
+
24
+ ## Use one frame capture call
25
+
26
+ `binding_of_caller` can return a caller binding.
27
+ Repeated calls intended to walk outward returned the same frame during testing.
28
+ This behavior made the captured stack incorrect.
29
+
30
+ `DEBUGGER__.capture_frames` returns the available frame bindings in one call.
31
+
32
+ ## Serialize during the raise hook
33
+
34
+ A `DEBUGGER__::FrameInfo` holds a live `Binding`.
35
+ The binding keeps objects reachable from its frame alive.
36
+
37
+ A measurement retained a frame from a `TracePoint` block.
38
+ The retained binding could still reach the block's captured array.
39
+ Keeping frame objects would let a bounded entry ring retain unbounded object graphs.
40
+
41
+ bulldogger renders values before the `:raise` hook returns.
42
+ The pending ring then holds Hashes, Strings, numbers, and Boolean values.
43
+ It holds no captured `FrameInfo` or `Binding`.
44
+
45
+ The full capture path measured 42.042 microseconds for each exception.
46
+ This path captures frames, renders values, applies redaction, and inserts the snapshot into the ring.
47
+
48
+ ## Exclude bulldogger frames
49
+
50
+ `DEBUGGER__.capture_frames(prefix)` removes frames whose paths start with `prefix`.
51
+ A test passed the application directory and received zero application frames.
52
+
53
+ bulldogger passes its own `lib` directory as the prefix.
54
+ This value removes bulldogger frames while it retains application frames.
55
+
56
+ ## Keep zero runtime dependencies
57
+
58
+ The gemspec declares development dependencies and no runtime dependency.
59
+ The core uses the Ruby standard library.
60
+
61
+ The complete frame source uses `debug/frame_info` when the application bundle exposes it.
62
+ The `debug` gem is bundled with Ruby 4.0.6, and it is not a default gem.
63
+ Under Bundler, the application Gemfile must include `debug`.
64
+
65
+ When `debug/frame_info` raises `LoadError`, bulldogger uses the degraded frame source.
66
+ That source records raising-frame locals and the remaining frame locations.
67
+
68
+ This design lets applications choose the `debug` dependency.
69
+ The installation guide includes `gem "debug", group: :test` for complete frame locals.
70
+
71
+ ## Require an explicit Minitest entry point
72
+
73
+ Minitest discovers files named `minitest/*_plugin.rb` in each active gem.
74
+ A measured plugin file started bulldogger in every Minitest run that activated the gem.
75
+ The test suite did not need a `require "bulldogger"` line to trigger this behavior.
76
+
77
+ This repository's own tests then ran with bulldogger capture active.
78
+ The automatic behavior conflicted with the explicit integration boundary.
79
+
80
+ bulldogger provides `require "bulldogger/minitest"` as the Minitest entry point.
81
+ The require line makes capture activation visible in the test setup.
82
+
83
+ ## Keep complete probe counts
84
+
85
+ The probe records every caller, class, and `nil` occurrence.
86
+ Caller sampling could hide a call site and support a false claim that it did not occur.
87
+ Count sampling could hide a later `nil` value and support the same false claim.
88
+ We rejected both performance proposals because they would create false negative evidence.
89
+
90
+ The probe serializes only the first 10 values by default.
91
+ It continues the complete class, `nil`, and caller counts after that limit.
92
+ This design keeps the evidence complete for the questions that probe answers.
93
+
94
+ A raised method also produces a Ruby `:return` event with a `nil` value.
95
+ The Ruby-level rescue counter identifies that raised exit.
96
+ The evidence records `raised_exits` and excludes that event from normal return counts.
97
+
98
+ ## Measure the shipped event work
99
+
100
+ Early estimates measured a different amount of work.
101
+ Using those estimates would state a low probe cost and a high record cost.
102
+ Measurement of the shipped paths corrected both directions.
103
+
104
+ The common proportionality harness measured about 1,330 ns per targeted probe call.
105
+ It measured about 4,500 ns per traced record call.
106
+ The record-specific harness measured 40.94x for capture and 58.34x with file output.
107
+
108
+ ## Use proportionality as the performance rule
109
+
110
+ A probe observes M calls to its named methods.
111
+ A record observes N calls to all Ruby methods in its traced operation.
112
+ The measured probe cost follows M, and the measured record cost follows N.
113
+
114
+ A fixed cost ratio changes when the workload changes M/N.
115
+ The proportionality rule lets a reader apply the measurement to an application call graph.
116
+
117
+ ## Keep JSONL as the record writer
118
+
119
+ The record path writes versioned JSONL with one event on each line.
120
+ `trace_to_sqlite` converts a finished file when the `sqlite3` gem is available.
121
+
122
+ This converter keeps SQLite behind the file boundary.
123
+ It also keeps the core at zero runtime dependencies.
124
+
125
+ ## Account for the coverage blind spot
126
+
127
+ Ruby `Coverage` does not observe lines that run under a `TracePoint` callback.
128
+ The suite calls the callback logic directly to test those lines.
129
+
130
+ The coverage gate also keeps a line-specific ledger for callback gaps.
131
+ The measured ledger has zero entries, and its fixed cap is zero.
132
+ The gate fails when a new entry appears.
133
+
134
+ ## Limit dogfooding to the acceptance suite
135
+
136
+ The acceptance suite runs under bulldogger and keeps its outer capture instance active.
137
+ The dogfood demo produced `capture_frames` evidence with a planted local and a redacted secret.
138
+
139
+ The unit setup resets the module singleton before each test.
140
+ That reset also stops an outer dogfood instance.
141
+ An instance API would let the subject and the test tool coexist.
142
+ That API is planned after version 0.1.