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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +262 -0
- data/docs/design-decisions.md +142 -0
- data/docs/evidence-schema.md +388 -0
- data/docs/maintenance.md +92 -0
- data/docs/trace-schema.md +118 -0
- data/lib/bulldogger/capture.rb +98 -0
- data/lib/bulldogger/config.rb +57 -0
- data/lib/bulldogger/evidence.rb +106 -0
- data/lib/bulldogger/formatter.rb +103 -0
- data/lib/bulldogger/frame_source.rb +147 -0
- data/lib/bulldogger/integrations/minitest.rb +87 -0
- data/lib/bulldogger/integrations/rspec.rb +56 -0
- data/lib/bulldogger/minitest.rb +7 -0
- data/lib/bulldogger/pending.rb +58 -0
- data/lib/bulldogger/probe/bucket.rb +90 -0
- data/lib/bulldogger/probe/comparator.rb +95 -0
- data/lib/bulldogger/probe/method_stats.rb +215 -0
- data/lib/bulldogger/probe/raise_tracker.rb +141 -0
- data/lib/bulldogger/probe/registry.rb +32 -0
- data/lib/bulldogger/probe/session.rb +159 -0
- data/lib/bulldogger/probe/target.rb +13 -0
- data/lib/bulldogger/probe/target_resolver.rb +86 -0
- data/lib/bulldogger/probe/writer.rb +61 -0
- data/lib/bulldogger/probe.rb +36 -0
- data/lib/bulldogger/record/session.rb +334 -0
- data/lib/bulldogger/record/sqlite_converter.rb +86 -0
- data/lib/bulldogger/record/writer.rb +67 -0
- data/lib/bulldogger/record.rb +51 -0
- data/lib/bulldogger/redactor.rb +30 -0
- data/lib/bulldogger/rspec.rb +7 -0
- data/lib/bulldogger/run.rb +113 -0
- data/lib/bulldogger/version.rb +5 -0
- data/lib/bulldogger.rb +133 -0
- data/skills/bulldogger/SKILL.md +37 -0
- data/skills/bulldogger/references/failure-evidence.md +56 -0
- data/skills/bulldogger/references/probe.md +36 -0
- data/skills/bulldogger/references/record.md +28 -0
- metadata +153 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# Evidence schema
|
|
2
|
+
|
|
3
|
+
bulldogger writes one JSON evidence file for each failed test.
|
|
4
|
+
The run directory also contains `index.json`, which lists the failure files.
|
|
5
|
+
|
|
6
|
+
## Top-level fields
|
|
7
|
+
|
|
8
|
+
| Field | Type | Presence | Meaning |
|
|
9
|
+
|---|---|---|---|
|
|
10
|
+
| `schema_version` | Integer | always | Schema version. Version 0.1.0 writes `1`. |
|
|
11
|
+
| `tool` | Object | always | Writer name and version. |
|
|
12
|
+
| `captured_at` | String | always | UTC write time in `YYYY-MM-DDTHH:MM:SSZ` format. |
|
|
13
|
+
| `capture_mode` | String | always | `capture_frames`, `degraded`, or `missed`. |
|
|
14
|
+
| `test` | Object | always | Framework test data. |
|
|
15
|
+
| `exception` | Object | always | Exception data. |
|
|
16
|
+
| `frames` | Array | always | Frames from the raise site outward. |
|
|
17
|
+
| `frames_omitted` | Integer | when positive | Frames removed by `max_frames`. |
|
|
18
|
+
| `frames_unavailable_reason` | String | in missed mode | Reason for an empty frame list. |
|
|
19
|
+
| `limits` | Object | always | Limits used for this capture. |
|
|
20
|
+
|
|
21
|
+
## Test fields
|
|
22
|
+
|
|
23
|
+
The framework integration supplies all four fields.
|
|
24
|
+
Each value can be `null` when the framework has no value.
|
|
25
|
+
|
|
26
|
+
| Field | Type |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `framework` | String or null |
|
|
29
|
+
| `id` | String or null |
|
|
30
|
+
| `file` | String or null |
|
|
31
|
+
| `line` | Integer or null |
|
|
32
|
+
|
|
33
|
+
## Exception fields
|
|
34
|
+
|
|
35
|
+
| Field | Type | Presence | Meaning |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| `class` | String | always | Exception class name. |
|
|
38
|
+
| `message` | String | always | Message with a limit of five times `max_value_length`. |
|
|
39
|
+
| `message_truncated` | Boolean | when true | The message exceeded its limit. |
|
|
40
|
+
| `message_original_length` | Integer | with `message_truncated` | Length before truncation. |
|
|
41
|
+
| `backtrace` | Array of String | always | First `max_frames` backtrace lines. |
|
|
42
|
+
|
|
43
|
+
## Frame fields
|
|
44
|
+
|
|
45
|
+
| Field | Type | Presence | Meaning |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| `index` | Integer | always | Position from the raise site. |
|
|
48
|
+
| `path` | String or null | always | Ruby source path. |
|
|
49
|
+
| `line` | Integer or null | always | Ruby source line. |
|
|
50
|
+
| `label` | String or null | always | Method or block label. |
|
|
51
|
+
| `self` | String | when captured | Rendered frame receiver. |
|
|
52
|
+
| `locals` | Object | when captured | Map from local names to entries. |
|
|
53
|
+
| `locals_omitted` | Integer | when positive | Locals removed by `max_locals`. |
|
|
54
|
+
| `locals_unavailable` | Boolean | degraded frames after frame 0 | The capture source could not read these locals. |
|
|
55
|
+
|
|
56
|
+
Frame 0 is the innermost frame.
|
|
57
|
+
An assertion library can raise before control returns to the test method.
|
|
58
|
+
Search frame labels and paths to identify the frame that owns the needed locals.
|
|
59
|
+
|
|
60
|
+
## Local entries
|
|
61
|
+
|
|
62
|
+
A captured local has a String value:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{"qty": {"value": "3"}}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A value longer than `max_value_length` also has truncation fields:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{"value": "a long rendered value…", "truncated": true, "original_length": 814}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A local name that matches a redaction pattern has this shape:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{"api_token": {"redacted": true, "reason": "name"}}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The redacted entry has no `value` field.
|
|
81
|
+
Version 0.1.0 emits only `"name"` as the `reason` value.
|
|
82
|
+
|
|
83
|
+
Hash-key redaction appears inside the parent value String:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{"settings": {"value": "{\"token\" => \"[REDACTED]\", \"level\" => \"debug\"}"}}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Value rendering
|
|
90
|
+
|
|
91
|
+
bulldogger stores each rendered value as a JSON String.
|
|
92
|
+
It uses Ruby `inspect` for scalar values and Strings.
|
|
93
|
+
|
|
94
|
+
Arrays and Hashes expand one level.
|
|
95
|
+
A nested Array becomes `[…]`, and a nested Hash becomes `{…}`.
|
|
96
|
+
The formatter keeps 10 elements and adds `…` when more elements exist.
|
|
97
|
+
|
|
98
|
+
For other objects, the formatter calls `inspect` and catches any exception.
|
|
99
|
+
An object with a failing `inspect` produces `#<ClassName (inspect raised ExceptionClass)>`.
|
|
100
|
+
|
|
101
|
+
## Capture modes
|
|
102
|
+
|
|
103
|
+
### `capture_frames`
|
|
104
|
+
|
|
105
|
+
The `debug` frame API supplies locals and `self` for each retained frame.
|
|
106
|
+
This projection came from a generated Minitest failure file:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"schema_version": 1,
|
|
111
|
+
"tool": {"name": "bulldogger", "version": "0.1.0"},
|
|
112
|
+
"capture_mode": "capture_frames",
|
|
113
|
+
"test": {
|
|
114
|
+
"framework": "minitest",
|
|
115
|
+
"id": "RedTest#test_deep_raise",
|
|
116
|
+
"file": "test/fixtures/minitest_red/red_test.rb",
|
|
117
|
+
"line": 19
|
|
118
|
+
},
|
|
119
|
+
"exception": {
|
|
120
|
+
"class": "ArgumentError",
|
|
121
|
+
"message": "expected 3 to equal the sum of [1, 2, 3]"
|
|
122
|
+
},
|
|
123
|
+
"frames": [
|
|
124
|
+
{
|
|
125
|
+
"index": 0,
|
|
126
|
+
"line": 9,
|
|
127
|
+
"label": "Order.total",
|
|
128
|
+
"self": "Order",
|
|
129
|
+
"locals": {
|
|
130
|
+
"qty": {"value": "3"},
|
|
131
|
+
"rows": {"value": "[1, 2, 3]"},
|
|
132
|
+
"api_token": {"redacted": true, "reason": "name"}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
],
|
|
136
|
+
"limits": {"max_frames": 20, "max_locals": 50, "max_value_length": 200}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### `degraded`
|
|
141
|
+
|
|
142
|
+
Frame 0 uses the raising `TracePoint` binding.
|
|
143
|
+
Later frames contain positions and `locals_unavailable: true`.
|
|
144
|
+
This projection came from the same fixture with `BULLDOGGER_FRAME_SOURCE=degraded`:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"schema_version": 1,
|
|
149
|
+
"capture_mode": "degraded",
|
|
150
|
+
"frames": [
|
|
151
|
+
{
|
|
152
|
+
"index": 0,
|
|
153
|
+
"line": 9,
|
|
154
|
+
"label": "Order.total",
|
|
155
|
+
"locals": {
|
|
156
|
+
"qty": {"value": "3"},
|
|
157
|
+
"rows": {"value": "[1, 2, 3]"},
|
|
158
|
+
"api_token": {"redacted": true, "reason": "name"}
|
|
159
|
+
},
|
|
160
|
+
"self": "Order"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"index": 1,
|
|
164
|
+
"path": "test/fixtures/minitest_red/red_test.rb",
|
|
165
|
+
"line": 20,
|
|
166
|
+
"label": "RedTest#test_deep_raise",
|
|
167
|
+
"locals_unavailable": true
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A later frame can have Ruby locals even when the evidence has no `locals` field.
|
|
174
|
+
Add `gem "debug", group: :test` and repeat the failed test to capture those locals.
|
|
175
|
+
|
|
176
|
+
### `missed`
|
|
177
|
+
|
|
178
|
+
Missed mode has an empty frame list.
|
|
179
|
+
The following complete file came from recording an exception before capture started:
|
|
180
|
+
|
|
181
|
+
```json
|
|
182
|
+
{
|
|
183
|
+
"schema_version": 1,
|
|
184
|
+
"tool": {"name": "bulldogger", "version": "0.1.0"},
|
|
185
|
+
"captured_at": "2026-08-28T07:58:25Z",
|
|
186
|
+
"capture_mode": "missed",
|
|
187
|
+
"test": {
|
|
188
|
+
"framework": "minitest",
|
|
189
|
+
"id": "MissedTest#test_example",
|
|
190
|
+
"file": "test/missed_test.rb",
|
|
191
|
+
"line": 7
|
|
192
|
+
},
|
|
193
|
+
"exception": {
|
|
194
|
+
"class": "ArgumentError",
|
|
195
|
+
"message": "missed example",
|
|
196
|
+
"backtrace": ["-e:1:in '<main>'"]
|
|
197
|
+
},
|
|
198
|
+
"frames": [],
|
|
199
|
+
"frames_unavailable_reason": "capture_disabled",
|
|
200
|
+
"limits": {"max_frames": 20, "max_locals": 50, "max_value_length": 200}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The reason has one of these values:
|
|
205
|
+
|
|
206
|
+
| Value | Meaning |
|
|
207
|
+
|---|---|
|
|
208
|
+
| `capture_disabled` | Capture was not running when Ruby raised the exception. |
|
|
209
|
+
| `not_captured` | Capture was running, but the pending ring had no matching exception. |
|
|
210
|
+
| `evicted` | Later exceptions removed the matching snapshot from the bounded ring. |
|
|
211
|
+
|
|
212
|
+
Missed evidence retains the test data, exception message, and exception backtrace.
|
|
213
|
+
|
|
214
|
+
## Limits and omission markers
|
|
215
|
+
|
|
216
|
+
The default limits are 20 frames, 50 locals per frame, and 200 characters per value.
|
|
217
|
+
The pending ring keeps 32 exception snapshots.
|
|
218
|
+
|
|
219
|
+
The file records positive omission counts:
|
|
220
|
+
|
|
221
|
+
- `frames_omitted` counts frames after `max_frames`.
|
|
222
|
+
- `locals_omitted` counts locals after `max_locals`.
|
|
223
|
+
- `original_length` records the rendered length before value truncation.
|
|
224
|
+
- `message_original_length` records the message length before message truncation.
|
|
225
|
+
|
|
226
|
+
An Array or Hash uses a trailing `…` to mark elements after the first 10.
|
|
227
|
+
|
|
228
|
+
## Run index
|
|
229
|
+
|
|
230
|
+
`index.json` has this shape:
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"schema_version": 1,
|
|
235
|
+
"run_dir": "/absolute/path/tmp/bulldogger/run-20260828-165825-28806",
|
|
236
|
+
"failures": [
|
|
237
|
+
{
|
|
238
|
+
"path": "001-RedTest-test_assertion_failure.json",
|
|
239
|
+
"test": {
|
|
240
|
+
"framework": "minitest",
|
|
241
|
+
"id": "RedTest#test_assertion_failure",
|
|
242
|
+
"file": "test/fixtures/minitest_red/red_test.rb",
|
|
243
|
+
"line": 12
|
|
244
|
+
},
|
|
245
|
+
"exception": {
|
|
246
|
+
"class": "Minitest::Assertion",
|
|
247
|
+
"message": "seeded api_token: 9 chars.\nExpected: 4\n Actual: 9"
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Each failure path is relative to the run directory.
|
|
255
|
+
The `latest` symlink points to the last finished run when the filesystem supports symlinks.
|
|
256
|
+
|
|
257
|
+
## Probe evidence
|
|
258
|
+
|
|
259
|
+
`Bulldogger.probe` writes one JSON file after the observed block finishes.
|
|
260
|
+
The file has `kind: "probe"` and a `methods` entry for each target.
|
|
261
|
+
|
|
262
|
+
This excerpt came from a generated probe of `ProseSample#amount`:
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"schema_version": 1,
|
|
267
|
+
"kind": "probe",
|
|
268
|
+
"tool": {"name": "bulldogger", "version": "0.1.0"},
|
|
269
|
+
"targets": ["ProseSample#amount"],
|
|
270
|
+
"methods": {
|
|
271
|
+
"ProseSample#amount": {
|
|
272
|
+
"calls": 3,
|
|
273
|
+
"raised_exits": 1,
|
|
274
|
+
"parameters": [["req", "mult"], ["key", "discount"], ["key", "api_token"]],
|
|
275
|
+
"params": {
|
|
276
|
+
"discount": {
|
|
277
|
+
"classes": {"NilClass": 2, "TrueClass": 1},
|
|
278
|
+
"nil_count": 2,
|
|
279
|
+
"samples": [{"value": "nil"}, {"value": "true"}, {"value": "nil"}]
|
|
280
|
+
},
|
|
281
|
+
"api_token": {
|
|
282
|
+
"classes": {"NilClass": 2, "String": 1},
|
|
283
|
+
"nil_count": 2,
|
|
284
|
+
"samples": [
|
|
285
|
+
{"redacted": true, "reason": "name"},
|
|
286
|
+
{"redacted": true, "reason": "name"},
|
|
287
|
+
{"redacted": true, "reason": "name"}
|
|
288
|
+
]
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
"returns": {
|
|
292
|
+
"classes": {"Integer": 1, "NilClass": 1},
|
|
293
|
+
"nil_count": 1,
|
|
294
|
+
"samples": [{"value": "21"}, {"value": "nil"}]
|
|
295
|
+
},
|
|
296
|
+
"raised": {"ArgumentError": 1},
|
|
297
|
+
"callers": {"-e:1:in 'block in <main>'": 3}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
"limits": {"max_samples": 10, "max_value_length": 200}
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Probe method fields
|
|
305
|
+
|
|
306
|
+
| Field | Type | Meaning |
|
|
307
|
+
|---|---|---|
|
|
308
|
+
| `calls` | Integer | Calls observed for this target. |
|
|
309
|
+
| `raised_exits` | Integer | Calls that left the method through an exception. |
|
|
310
|
+
| `parameters` | Array | Declared parameter kinds and names. |
|
|
311
|
+
| `params` | Object | Class counts, `nil` counts, and samples for each named argument. |
|
|
312
|
+
| `returns` | Object | Class counts, `nil` count, and samples for normal returns. |
|
|
313
|
+
| `raised` | Object | Exception class counts for raised exits. |
|
|
314
|
+
| `callers` | Object | Call-site strings and their counts. |
|
|
315
|
+
|
|
316
|
+
A raised exit does not increase the return count.
|
|
317
|
+
This rule distinguishes an exception exit from a normal `nil` return.
|
|
318
|
+
|
|
319
|
+
Each parameter and return bucket counts every observed value.
|
|
320
|
+
The `samples` array contains the first `max_samples` rendered values.
|
|
321
|
+
When later values exist, `samples_omitted` gives their count.
|
|
322
|
+
Redaction and value limits use the failure evidence rules.
|
|
323
|
+
|
|
324
|
+
`Bulldogger.probe_compare` compares two probe files by their behavior shape.
|
|
325
|
+
Its result has `identical` and `differences` fields.
|
|
326
|
+
|
|
327
|
+
## jq queries
|
|
328
|
+
|
|
329
|
+
These queries ran against generated evidence files.
|
|
330
|
+
|
|
331
|
+
List the capture mode and test identifier:
|
|
332
|
+
|
|
333
|
+
```sh
|
|
334
|
+
jq '{capture_mode, test_id: .test.id}' evidence.json
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
List frame positions:
|
|
338
|
+
|
|
339
|
+
```sh
|
|
340
|
+
jq '[.frames[] | {index, label, path}]' evidence.json
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Find a local in any captured frame:
|
|
344
|
+
|
|
345
|
+
```sh
|
|
346
|
+
jq '[.frames[] | select(.locals.qty) | {index, label, qty: .locals.qty}]' evidence.json
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
List truncated locals:
|
|
350
|
+
|
|
351
|
+
```sh
|
|
352
|
+
jq '[.frames[] | .index as $frame | (.locals // {}) | to_entries[]
|
|
353
|
+
| select(.value.truncated == true)
|
|
354
|
+
| {frame: $frame, name: .key, original_length: .value.original_length}]' evidence.json
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
List redacted locals:
|
|
358
|
+
|
|
359
|
+
```sh
|
|
360
|
+
jq '[.frames[] | .index as $frame | (.locals // {}) | to_entries[]
|
|
361
|
+
| select(.value.redacted == true)
|
|
362
|
+
| {frame: $frame, name: .key, reason: .value.reason}]' evidence.json
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
List failed test identifiers from the run index:
|
|
366
|
+
|
|
367
|
+
```sh
|
|
368
|
+
jq '[.failures[].test.id]' tmp/bulldogger/latest/index.json
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
List the call count, raised exits, and callers from probe evidence:
|
|
372
|
+
|
|
373
|
+
```sh
|
|
374
|
+
jq '.methods | to_entries[] | {
|
|
375
|
+
method: .key, calls: .value.calls,
|
|
376
|
+
raised_exits: .value.raised_exits, callers: .value.callers
|
|
377
|
+
}' probe.json
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
List parameter and return `nil` counts:
|
|
381
|
+
|
|
382
|
+
```sh
|
|
383
|
+
jq '.methods | to_entries[] | {
|
|
384
|
+
method: .key,
|
|
385
|
+
params: (.value.params | map_values(.nil_count)),
|
|
386
|
+
returns: .value.returns.nil_count
|
|
387
|
+
}' probe.json
|
|
388
|
+
```
|
data/docs/maintenance.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Maintenance
|
|
2
|
+
|
|
3
|
+
## How releases work
|
|
4
|
+
|
|
5
|
+
GitHub Actions publishes bulldogger through RubyGems.org Trusted
|
|
6
|
+
Publishing. The release workflow holds no RubyGems.org API key. It signs
|
|
7
|
+
in with a short-lived OIDC token that RubyGems.org accepts only from this
|
|
8
|
+
repository, this workflow file, and this environment.
|
|
9
|
+
|
|
10
|
+
`.github/workflows/release.yml` runs when a `v*` tag reaches GitHub. It
|
|
11
|
+
checks that the tag version matches `Bulldogger::VERSION`, runs the
|
|
12
|
+
default task and the coverage gate, builds the gem, pushes it, and
|
|
13
|
+
attaches the built gem to a GitHub release.
|
|
14
|
+
|
|
15
|
+
## Before the first release
|
|
16
|
+
|
|
17
|
+
Two things live outside this repository, and a release fails without
|
|
18
|
+
either one.
|
|
19
|
+
|
|
20
|
+
### Register the pending trusted publisher
|
|
21
|
+
|
|
22
|
+
RubyGems.org accepts a publisher for a gem name that does not exist yet.
|
|
23
|
+
Open the [pending trusted publishers
|
|
24
|
+
page](https://rubygems.org/profile/oidc/pending_trusted_publishers) and
|
|
25
|
+
create one with these values:
|
|
26
|
+
|
|
27
|
+
| Field | Value |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| Gem name | `bulldogger` |
|
|
30
|
+
| Repository owner | `meganemura` |
|
|
31
|
+
| Repository name | `bulldogger` |
|
|
32
|
+
| Workflow filename | `release.yml` |
|
|
33
|
+
| Environment | `release` |
|
|
34
|
+
| Workflow repository owner | Leave this field blank. |
|
|
35
|
+
| Workflow repository name | Leave this field blank. |
|
|
36
|
+
|
|
37
|
+
Leave both workflow repository fields blank. Those fields name a
|
|
38
|
+
reusable workflow in a different repository, and this workflow lives
|
|
39
|
+
beside the gem.
|
|
40
|
+
|
|
41
|
+
RubyGems.org converts the pending publisher after the first successful
|
|
42
|
+
push, and adds the profile account as an owner of the new gem.
|
|
43
|
+
|
|
44
|
+
For a gem that already exists, open the gem page, select **Trusted
|
|
45
|
+
publishers**, select **Create**, and enter the same values.
|
|
46
|
+
|
|
47
|
+
### Create the GitHub environment
|
|
48
|
+
|
|
49
|
+
The workflow names `release` as its environment. Create that environment
|
|
50
|
+
in the repository settings and add a required reviewer.
|
|
51
|
+
|
|
52
|
+
RubyGems.org matches this name exactly. The workflow, the environment in
|
|
53
|
+
the repository settings, and the trusted publisher must all use the same
|
|
54
|
+
name, or the push step fails to authenticate.
|
|
55
|
+
|
|
56
|
+
Without a required reviewer the environment still satisfies
|
|
57
|
+
RubyGems.org, and the release proceeds with no human confirmation.
|
|
58
|
+
Publishing a version cannot be undone: yanking a version does not free
|
|
59
|
+
its number, and the number can never be reused. The reviewer is what
|
|
60
|
+
turns a pushed tag into a decision.
|
|
61
|
+
|
|
62
|
+
## Make a release
|
|
63
|
+
|
|
64
|
+
1. Update the version in `lib/bulldogger/version.rb`.
|
|
65
|
+
2. Complete the normal review process for the version change.
|
|
66
|
+
3. Create a `vVERSION` tag, using the exact version from
|
|
67
|
+
`version.rb`. Version `0.1.0` takes the tag `v0.1.0`.
|
|
68
|
+
4. Push the tag.
|
|
69
|
+
5. Approve the waiting release in the GitHub Actions run.
|
|
70
|
+
6. Confirm that the workflow succeeds.
|
|
71
|
+
7. Confirm that RubyGems.org shows the new version.
|
|
72
|
+
|
|
73
|
+
The workflow refuses a tag whose version disagrees with `version.rb`, so
|
|
74
|
+
a mistyped tag stops before anything is published.
|
|
75
|
+
|
|
76
|
+
Re-running a release that already pushed its gem succeeds. The push step
|
|
77
|
+
treats "Repushing of gem versions is not allowed" as success, so a
|
|
78
|
+
failure in a later step can be retried without the earlier step blocking
|
|
79
|
+
it.
|
|
80
|
+
|
|
81
|
+
## Releasing a version whose tag already exists
|
|
82
|
+
|
|
83
|
+
A tag that is already on GitHub does not start a workflow by existing.
|
|
84
|
+
Deleting the tag and pushing it again does:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
git push origin :refs/tags/vVERSION
|
|
88
|
+
git push origin vVERSION
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Confirm the tag points at the commit intended for release before pushing
|
|
92
|
+
it back.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Trace schema
|
|
2
|
+
|
|
3
|
+
`Bulldogger.record` writes a JSONL file named `trace-NNN.jsonl`.
|
|
4
|
+
The first line is a header, and each later line is one event.
|
|
5
|
+
|
|
6
|
+
## Header
|
|
7
|
+
|
|
8
|
+
This header came from a generated trace:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{"schema_version":1,"kind":"record","started_at":"2026-08-28T12:15:18Z","events":["call","return","raise"],"limits":{"max_value_length":200}}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| Field | Type | Meaning |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `schema_version` | Integer | Schema version. Version 0.1.0 writes `1`. |
|
|
17
|
+
| `kind` | String | The value is `record`. |
|
|
18
|
+
| `started_at` | String | UTC start time. |
|
|
19
|
+
| `events` | Array | Event kinds written after the header. |
|
|
20
|
+
| `limits` | Object | Value limits used for this trace. |
|
|
21
|
+
|
|
22
|
+
## Events
|
|
23
|
+
|
|
24
|
+
Each event has `event`, `seq`, `depth`, `path`, `line`, and `method`.
|
|
25
|
+
The sequence starts at 1 and follows write order.
|
|
26
|
+
The depth is the Ruby call depth observed by this recording session.
|
|
27
|
+
|
|
28
|
+
A call event has an `args` object with named arguments:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{"event":"call","seq":2,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","args":{"value":{"value":"3"}}}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A normal return has a rendered `return` entry:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{"event":"return","seq":3,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","return":{"value":"6"}}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A raise event has an exception class and message:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{"event":"raise","seq":7,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","exception":{"class":"ArgumentError","message":"negative"}}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Ruby emits a `:return` event when an exception leaves a method.
|
|
47
|
+
That event has `raised: true` and has no `return` field:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{"event":"return","seq":8,"depth":2,"path":"-e","line":1,"method":"ProseTrace#inner","raised":true}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The discriminator uses Ruby-level `:rescue` events.
|
|
54
|
+
It distinguishes raised exits from normal `nil` returns.
|
|
55
|
+
The trace uses this event for internal state and does not write it.
|
|
56
|
+
|
|
57
|
+
## Event selection
|
|
58
|
+
|
|
59
|
+
The default event set contains `:call`, `:return`, and `:raise`.
|
|
60
|
+
The header records this set for each file.
|
|
61
|
+
|
|
62
|
+
`:line` produces an event for each executed source line.
|
|
63
|
+
`:b_call` produces an event for each block invocation.
|
|
64
|
+
Their event counts follow executed lines and block invocations.
|
|
65
|
+
The record verb keeps method boundaries and raises within a usable explicit run.
|
|
66
|
+
|
|
67
|
+
## Values and secrets
|
|
68
|
+
|
|
69
|
+
Argument and return entries use the bounded formatter from failure evidence.
|
|
70
|
+
Named arguments also use the configured redaction patterns.
|
|
71
|
+
A redacted argument has no `value` field.
|
|
72
|
+
|
|
73
|
+
Exception messages use five times `max_value_length`.
|
|
74
|
+
A long message has `message_truncated` and `message_original_length` fields.
|
|
75
|
+
|
|
76
|
+
## jq queries
|
|
77
|
+
|
|
78
|
+
These queries ran against the generated trace used for the examples.
|
|
79
|
+
|
|
80
|
+
Read the header:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
head -n 1 trace.jsonl | jq '{schema_version, kind, events, limits}'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
List the event sequence:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
jq -c 'select(.event) | {seq, depth, event, method}' trace.jsonl
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
List raised exits:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
jq -c 'select(.event == "return" and .raised == true)
|
|
96
|
+
| {seq, depth, method, raised}' trace.jsonl
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
List raised exceptions:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
jq -c 'select(.event == "raise")
|
|
103
|
+
| {seq, method, class: .exception.class, message: .exception.message}' trace.jsonl
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Count calls by method:
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
jq -s 'map(select(.event == "call"))
|
|
110
|
+
| group_by(.method)
|
|
111
|
+
| map({method: .[0].method, calls: length})' trace.jsonl
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## SQLite adapter
|
|
115
|
+
|
|
116
|
+
`Bulldogger.trace_to_sqlite(jsonl_path, db_path)` converts a finished trace.
|
|
117
|
+
It returns `nil` with a warning when the `sqlite3` gem is unavailable.
|
|
118
|
+
The converter stores each event and its complete JSON payload.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "redactor"
|
|
4
|
+
require_relative "formatter"
|
|
5
|
+
require_relative "frame_source"
|
|
6
|
+
require_relative "pending"
|
|
7
|
+
|
|
8
|
+
module Bulldogger
|
|
9
|
+
# Subscribes to :raise globally and turns every raised exception into
|
|
10
|
+
# a bounded, already-serialized snapshot before the hook returns.
|
|
11
|
+
#
|
|
12
|
+
# Serialization happens here, inside the hook, rather than later from
|
|
13
|
+
# whatever holds the frame data: a DEBUGGER__::FrameInfo keeps its
|
|
14
|
+
# frame's Binding alive, and a live Binding keeps every object
|
|
15
|
+
# reachable from that frame alive too (measured: the TracePoint
|
|
16
|
+
# block's own captured-array local was still reachable through a
|
|
17
|
+
# retained Binding). Rendering every value to a String and dropping
|
|
18
|
+
# the FrameInfo/Binding before this method returns is what makes the
|
|
19
|
+
# Pending ring's size an actual memory bound, not just a count of
|
|
20
|
+
# references to unbounded object graphs.
|
|
21
|
+
class Capture
|
|
22
|
+
def initialize(config:)
|
|
23
|
+
@config = config
|
|
24
|
+
@redactor = Redactor.new(config.redact_patterns)
|
|
25
|
+
@formatter = Formatter.new(config: config, redactor: @redactor)
|
|
26
|
+
@frame_source = FrameSource.new(config: config, formatter: @formatter, redactor: @redactor)
|
|
27
|
+
@pending = Pending.new(config.max_pending)
|
|
28
|
+
@trace_point = nil
|
|
29
|
+
@start_mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def start
|
|
33
|
+
@start_mutex.synchronize do
|
|
34
|
+
return self if @trace_point
|
|
35
|
+
|
|
36
|
+
@frame_source.resolve!
|
|
37
|
+
trace_point = TracePoint.new(:raise) { |tp| handle_raise(tp) }
|
|
38
|
+
trace_point.enable
|
|
39
|
+
@trace_point = trace_point
|
|
40
|
+
end
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stop
|
|
45
|
+
@start_mutex.synchronize do
|
|
46
|
+
@trace_point&.disable
|
|
47
|
+
@trace_point = nil
|
|
48
|
+
end
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def running?
|
|
53
|
+
!@trace_point.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def snapshot_for(exception)
|
|
57
|
+
@pending.get(exception)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reason_for_missing(exception)
|
|
61
|
+
return "capture_disabled" unless running?
|
|
62
|
+
return "evicted" if @pending.evicted?(exception)
|
|
63
|
+
|
|
64
|
+
"not_captured"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# :raise fires for every exception, including ones the app rescues
|
|
70
|
+
# and handles without incident. This hook must never let an
|
|
71
|
+
# exception escape: doing so would replace the app's real raise
|
|
72
|
+
# with one from inside our own hook, corrupting the very failure
|
|
73
|
+
# the app was raising. `rescue Exception`, not `StandardError`,
|
|
74
|
+
# because even a NoMemoryError or SystemStackError surfacing from
|
|
75
|
+
# our own code here must not propagate into the app's raise path.
|
|
76
|
+
def handle_raise(tp)
|
|
77
|
+
exception = tp.raised_exception
|
|
78
|
+
frames, frames_omitted = @frame_source.capture(tp)
|
|
79
|
+
snapshot = {
|
|
80
|
+
"capture_mode" => @frame_source.mode.to_s,
|
|
81
|
+
"frames" => frames
|
|
82
|
+
}
|
|
83
|
+
# frames_omitted only appears when something was actually cut, so
|
|
84
|
+
# its presence alone tells a reader "frames were dropped here" --
|
|
85
|
+
# the same rule build_frame already applies to locals_omitted.
|
|
86
|
+
snapshot["frames_omitted"] = frames_omitted if frames_omitted.positive?
|
|
87
|
+
@pending.put(exception, snapshot)
|
|
88
|
+
nil
|
|
89
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
90
|
+
warn("bulldogger: capture failed: #{e.class}: #{e.message}") if debug?
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def debug?
|
|
95
|
+
ENV["BULLDOGGER_DEBUG"] == "1"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|