rspec-signal 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +94 -1
- data/README.md +332 -39
- data/exe/rspec-signal +9 -0
- data/exe/rspec-signal-parallel +12 -5
- data/lib/rspec/signal/backtrace/frame.rb +11 -3
- data/lib/rspec/signal/backtrace/parser.rb +1 -1
- data/lib/rspec/signal/backtrace/reducer.rb +41 -13
- data/lib/rspec/signal/causal/analysis.rb +292 -0
- data/lib/rspec/signal/causal/census.rb +86 -0
- data/lib/rspec/signal/causal/entities.rb +67 -0
- data/lib/rspec/signal/causal/evidence.rb +144 -0
- data/lib/rspec/signal/causal/phase.rb +98 -0
- data/lib/rspec/signal/causal/scope_analysis.rb +80 -0
- data/lib/rspec/signal/cluster.rb +22 -6
- data/lib/rspec/signal/code_paths.rb +101 -0
- data/lib/rspec/signal/comparison.rb +101 -0
- data/lib/rspec/signal/configuration.rb +28 -5
- data/lib/rspec/signal/failure.rb +26 -3
- data/lib/rspec/signal/failure_builder.rb +18 -3
- data/lib/rspec/signal/fingerprint.rb +18 -2
- data/lib/rspec/signal/formatter.rb +123 -64
- data/lib/rspec/signal/group.rb +30 -6
- data/lib/rspec/signal/history.rb +102 -0
- data/lib/rspec/signal/html_summary.rb +19 -13
- data/lib/rspec/signal/message.rb +58 -3
- data/lib/rspec/signal/outside_example.rb +148 -0
- data/lib/rspec/signal/parallel_merger.rb +68 -8
- data/lib/rspec/signal/parallel_run.rb +4 -3
- data/lib/rspec/signal/progress_bar.rb +48 -0
- data/lib/rspec/signal/project.rb +8 -3
- data/lib/rspec/signal/redactor.rb +4 -2
- data/lib/rspec/signal/report.rb +137 -28
- data/lib/rspec/signal/reporters/json_report.rb +3 -2
- data/lib/rspec/signal/reporters/markdown.rb +56 -30
- data/lib/rspec/signal/reporters/outside_examples.rb +101 -0
- data/lib/rspec/signal/reporters/related_failures.rb +12 -4
- data/lib/rspec/signal/reporters/relationships.rb +228 -0
- data/lib/rspec/signal/reporters/shared_code_paths.rb +86 -0
- data/lib/rspec/signal/rerun.rb +39 -0
- data/lib/rspec/signal/run_status.rb +40 -0
- data/lib/rspec/signal/selection.rb +130 -0
- data/lib/rspec/signal/symptom.rb +3 -1
- data/lib/rspec/signal/symptoms/http_status.rb +5 -3
- data/lib/rspec/signal/symptoms/record.rb +4 -4
- data/lib/rspec/signal/symptoms/route.rb +2 -2
- data/lib/rspec/signal/symptoms/ruby_error.rb +2 -2
- data/lib/rspec/signal/symptoms/selector.rb +6 -4
- data/lib/rspec/signal/symptoms.rb +3 -1
- data/lib/rspec/signal/terminal_summary.rb +119 -0
- data/lib/rspec/signal/version.rb +1 -1
- data/lib/rspec/signal/writer.rb +58 -19
- data/lib/rspec/signal.rb +25 -3
- metadata +27 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b2d960f1c23decbc4bd847430d8f1df056e93bfe2823223397df48058d99217
|
|
4
|
+
data.tar.gz: b114d8d9b06efe92e7216919e383b1ac9a58c97f0e3684b0d68fc4bfe88a575b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8d6ec971a7db81678e54dd1ef59dccce8f3e2f87b0e37af4c254ba2b916434aa04720b9c50935c025d0f1d1c17109ebe4a93f97a7dc4656f910e0e49640c17b3
|
|
7
|
+
data.tar.gz: 893b91607a8c90ac55cc26f9e385cbfe20a9fde84f1263cb6dc1c28b10fe6189d2db81b5e35555dd458de7e837f243fa64e35ba8016ebdb69c41b85ecbb4c2d1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,98 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Relationships (experimental, off by default).** With `config.causal_analysis = true`,
|
|
13
|
+
a fourth layer relates *signatures* by structural evidence only: a `before(:context)`
|
|
14
|
+
exception object shared by every example, one underlying exception (bare or wrapped)
|
|
15
|
+
at one first-party line, a missing definition read from exception attributes (`ENV`
|
|
16
|
+
key, constant, method on a first-party class), or one failing setup step. Signatures
|
|
17
|
+
are grouped as CAUSAL (always HIGH), SCOPE (concentration in one file or `type:`; no
|
|
18
|
+
confidence, no cause wording) or INDEPENDENT (no relationship found), and every
|
|
19
|
+
failure is classified into exactly one group. Printed only when it adds something;
|
|
20
|
+
published as an `analysis` block in `signal.json` marked `"experimental": true`,
|
|
21
|
+
whose fields may change. Evaluated by a corpus of real runs in `spec/causal/`.
|
|
22
|
+
- **Shared code paths.** A third analysis layer that reads the *stack* rather than
|
|
23
|
+
the message, and reports the first-party lines that more than one signature runs
|
|
24
|
+
through. This relates failures whose messages have nothing in common — a `KeyError`
|
|
25
|
+
and the `expect { }.not_to raise_error` that swallowed it — which no message-based
|
|
26
|
+
extractor can do. Two rules hold it back: only the innermost first-party frames
|
|
27
|
+
outside the spec suite are indexed (`config.code_path_depth`, default 5), and a
|
|
28
|
+
line is reported only when two or more distinct signatures cross it. It claims a
|
|
29
|
+
measured fact, never a cause. Configure with `config.max_code_paths`.
|
|
30
|
+
- **Run-to-run comparison.** `tmp/rspec-signal/history.json` records the last ten
|
|
31
|
+
runs as signature digests and counts — no messages, no paths. Each run then reports
|
|
32
|
+
what changed: `28 resolved, 3 new, 14 still failing, 3 changed signature`. The
|
|
33
|
+
fourth bucket is what makes the rest trustworthy: an edit that only shifts line
|
|
34
|
+
numbers reads as *changed*, never as a fix. The history survives the green run that
|
|
35
|
+
deletes every other artifact. Turn it off with `config.track_history = false`.
|
|
36
|
+
- **Errors outside examples are captured, not just counted.** A spec file that fails
|
|
37
|
+
to load, or a `before(:suite)` hook that blows up, now reaches the report with its
|
|
38
|
+
exception, message, reduced trace and a rerun command. Previously only a count was
|
|
39
|
+
possible. The formatter registers RSpec's `:message` notification and re-emits
|
|
40
|
+
anything no other formatter is listening for, so nothing is lost or duplicated.
|
|
41
|
+
- Stdout in quiet mode is now a triage view: counts, what changed since the last run,
|
|
42
|
+
and the top shared code paths. Enough to decide whether to open the report.
|
|
43
|
+
- `signal.json` gains `run_id`, `since_last_run`, `code_paths`, `outside_examples`,
|
|
44
|
+
and per-signature `summary`, `rerun`, `rerun_ids` and `loose_signature`. The
|
|
45
|
+
contract is documented in the README.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **RSpec's `Failure/Error:` source echo is no longer part of a signature.** When an
|
|
50
|
+
error is raised inside a gem -- a factory's `save!`, a driver call -- RSpec echoes
|
|
51
|
+
each example's own call site, so one broken factory used to become one signature per
|
|
52
|
+
`create` line. The echo is still shown; it just no longer decides identity.
|
|
53
|
+
Every digest changes once as a result, so `history.json` moves to schema 2 and an
|
|
54
|
+
older file is ignored: the first run after upgrading has no "since last run" line,
|
|
55
|
+
rather than reporting every signature as resolved and new.
|
|
56
|
+
- **Rerun commands now name RSpec example ids rather than locations**, shell-quoted.
|
|
57
|
+
A location selects every example defined on that line, so the printed command could
|
|
58
|
+
rerun ten examples for a loop-generated `it`, and the *same* command could be
|
|
59
|
+
printed for two different signatures. Reruns are now exact. The human-readable
|
|
60
|
+
location remains on the `Example` line above.
|
|
61
|
+
- The signature index shows the exception message rather than RSpec's
|
|
62
|
+
`Failure/Error:` source echo, which was identical on every row.
|
|
63
|
+
- `signal.json` is `schema: 2`. Every schema 1 field keeps its name and meaning.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- In a parallel run, every signature's loose digest was derived from a placeholder
|
|
68
|
+
message, so signatures of the same exception class shared one loose key and the
|
|
69
|
+
changed-signature bucket could pair unrelated failures. Worker payloads now carry the
|
|
70
|
+
loose digest.
|
|
71
|
+
- One error outside examples repeated across many spec files -- a helper every file
|
|
72
|
+
requires failing at boot -- rendered one identical section per file. It now renders once,
|
|
73
|
+
naming every file it stopped from loading.
|
|
74
|
+
- `signal.json` ignored `max_message_lines` and `max_diff_lines`, so configuring
|
|
75
|
+
them changed `signal.md` and left the JSON at the built-in defaults.
|
|
76
|
+
- The parallel merger dropped `shared_group_locations`, so the "Via shared example
|
|
77
|
+
group" locator was missing from every parallel report.
|
|
78
|
+
- A run whose only problem was outside every example wrote no report.
|
|
79
|
+
- The terminal summary printed "0 failures" beside a report link when a spec file
|
|
80
|
+
failed to load.
|
|
81
|
+
- `--dry-run` deleted the report written by the previous real run.
|
|
82
|
+
|
|
83
|
+
## [0.2.0] - 2026-08-27
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- Ruby 2.7 support. `rspec-signal` now installs and runs on Ruby 2.7, 3.0, 3.1,
|
|
88
|
+
3.2, 3.3 and 3.4; CI exercises the full matrix, including `rspec-signal-parallel`
|
|
89
|
+
under `parallel_tests` on Ruby 2.7 with the last `parallel_tests` release that
|
|
90
|
+
still supports it (4.7.1 — 4.7.2 and later, and all of the 5.x series, require
|
|
91
|
+
Ruby 3.0+ or 3.1+).
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
|
|
95
|
+
- `spec.required_ruby_version` lowered from `>= 3.1.0` to `>= 2.7.0`.
|
|
96
|
+
- Endless method definitions (a Ruby 3.0+ feature) were rewritten as ordinary
|
|
97
|
+
`def...end` methods throughout the library and spec suite, with no behavior
|
|
98
|
+
change.
|
|
99
|
+
- Gemspec summary and description now emphasize token-efficient RSpec output for
|
|
100
|
+
AI coding agents.
|
|
101
|
+
|
|
10
102
|
## [0.1.0] - 2026-08-26
|
|
11
103
|
|
|
12
104
|
Initial release.
|
|
@@ -64,5 +156,6 @@ Initial release.
|
|
|
64
156
|
- Diff hunk headers and HTML summary sizes are normalized out of the fingerprint,
|
|
65
157
|
so a page one line longer is no longer a different failure.
|
|
66
158
|
|
|
67
|
-
[Unreleased]: https://github.com/SilenceDogood1984/rspec-signal/compare/v0.
|
|
159
|
+
[Unreleased]: https://github.com/SilenceDogood1984/rspec-signal/compare/v0.2.0...HEAD
|
|
160
|
+
[0.2.0]: https://github.com/SilenceDogood1984/rspec-signal/compare/v0.1.0...v0.2.0
|
|
68
161
|
[0.1.0]: https://github.com/SilenceDogood1984/rspec-signal/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -11,11 +11,51 @@ thousands of tokens on `rspec-core/hooks.rb`, Bundler, and Thor before the agent
|
|
|
11
11
|
reaches a single line of your code — and you pay that cost repeatedly,
|
|
12
12
|
even when many failures stem from one bug.
|
|
13
13
|
|
|
14
|
-
`rspec-signal` is a deterministic
|
|
15
|
-
agent. It
|
|
16
|
-
|
|
14
|
+
`rspec-signal` is a deterministic failure-intelligence layer between RSpec and a
|
|
15
|
+
coding agent. It answers one question -- *what actually failed, and what is it
|
|
16
|
+
really?* -- in a bounded number of tokens, with an exact command to verify each fix.
|
|
17
17
|
|
|
18
|
-
In normal mode your terminal output stays as it was. In opt-in agent mode it also
|
|
18
|
+
In normal mode your terminal output stays as it was. In opt-in agent mode it also
|
|
19
|
+
becomes a quiet formatter that prevents verbose failures from entering an autonomous
|
|
20
|
+
agent's context.
|
|
21
|
+
|
|
22
|
+
No model, no network, no API key. Two runs of the same suite produce byte-identical
|
|
23
|
+
reports.
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# Gemfile
|
|
29
|
+
group :test do
|
|
30
|
+
gem "rspec-signal"
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# spec/spec_helper.rb (or rails_helper.rb)
|
|
36
|
+
require "rspec/signal"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bundle exec rspec-signal
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That is the whole setup. Your terminal output is unchanged by the require; the
|
|
44
|
+
`rspec-signal` command is the quiet wrapper for agents. Either way you get
|
|
45
|
+
`tmp/rspec-signal/signal.md`.
|
|
46
|
+
|
|
47
|
+
## Vocabulary
|
|
48
|
+
|
|
49
|
+
Six words the report uses, defined once.
|
|
50
|
+
|
|
51
|
+
| Term | Meaning |
|
|
52
|
+
|------|---------|
|
|
53
|
+
| **signature** | A set of failures that *are* the same failure, by an exact four-part fingerprint. Authoritative |
|
|
54
|
+
| **related cluster** | Failures across more than one signature that share one strong diagnostic *symptom*. A hint, never a diagnosis |
|
|
55
|
+
| **symptom** | The one characteristic a cluster is keyed on: an HTTP status, a missing selector, a route, a model, a constant |
|
|
56
|
+
| **shared code path** | A line of *your* code that more than one signature runs through. A measured fact about stacks, not messages |
|
|
57
|
+
| **culprit** | The innermost frame that is not test-runner plumbing: the code that actually raised |
|
|
58
|
+
| **app context** | The innermost first-party frame outside your spec suite: the file to open first |
|
|
19
59
|
|
|
20
60
|
---
|
|
21
61
|
|
|
@@ -84,7 +124,7 @@ spec/models/shelf_spec.rb:5 in `block (3 levels) in <top (required)>'
|
|
|
84
124
|
**Rerun**
|
|
85
125
|
|
|
86
126
|
```bash
|
|
87
|
-
bundle exec rspec spec/models/shelf_spec.rb:
|
|
127
|
+
bundle exec rspec './spec/models/shelf_spec.rb[1:1]'
|
|
88
128
|
```
|
|
89
129
|
|
|
90
130
|
**Also failing identically (11)**
|
|
@@ -104,21 +144,9 @@ failures reduces far less, and that is correct behaviour.
|
|
|
104
144
|
|
|
105
145
|
## Installation
|
|
106
146
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
gem "rspec-signal"
|
|
111
|
-
end
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Then require it from `spec_helper.rb` (or `rails_helper.rb`):
|
|
115
|
-
|
|
116
|
-
```ruby
|
|
117
|
-
require "rspec/signal"
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
That is the whole setup. Requiring the gem registers the formatter and puts RSpec's
|
|
121
|
-
default formatter back, so your terminal output is unchanged.
|
|
147
|
+
See [Quickstart](#quickstart) for the two lines. Requiring the gem registers the
|
|
148
|
+
formatter and puts RSpec's default formatter back, so your terminal output is
|
|
149
|
+
unchanged.
|
|
122
150
|
|
|
123
151
|
If you would rather be explicit, or you need it only in CI, skip the require and
|
|
124
152
|
name the formatter instead:
|
|
@@ -144,16 +172,30 @@ suite remains non-zero; a passing suite remains zero. The
|
|
|
144
172
|
low-level `bundle exec rspec --format RSpec::Signal::Formatter` interface remains
|
|
145
173
|
available after requiring `rspec/signal` from the spec helper.
|
|
146
174
|
|
|
175
|
+
Stdout is a tool call's return value, so stdout is the triage view — enough to decide
|
|
176
|
+
whether to open the report at all:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
2085 examples, 42 failures, 6 pending
|
|
180
|
+
|
|
181
|
+
rspec-signal: 42 failures in 35 distinct signatures, 4 related clusters (2767 backtrace frames omitted)
|
|
182
|
+
Since last run: 28 resolved, 3 new, 14 still failing (42 -> 17 failures)
|
|
183
|
+
Shared code paths: app/models/reader.rb:88 (9 signatures), app/api/serializer.rb:31 (4 signatures)
|
|
184
|
+
Report: tmp/rspec-signal/signal.md
|
|
185
|
+
```
|
|
186
|
+
|
|
147
187
|
```text
|
|
148
188
|
RSpec runs the full suite
|
|
149
189
|
↓
|
|
150
190
|
verbose failure bodies and framework backtraces are not printed
|
|
151
191
|
↓
|
|
152
|
-
rspec-signal writes tmp/rspec-signal/signal.md
|
|
192
|
+
rspec-signal writes tmp/rspec-signal/signal.md and prints five lines of triage
|
|
153
193
|
↓
|
|
154
|
-
the agent reads signal.md
|
|
194
|
+
the agent reads signal.md only if the triage is not enough
|
|
155
195
|
↓
|
|
156
|
-
the agent reruns
|
|
196
|
+
the agent reruns one signature by its exact example ids
|
|
197
|
+
↓
|
|
198
|
+
the next run reports what that fix resolved, and what it did not
|
|
157
199
|
```
|
|
158
200
|
|
|
159
201
|
### Parallel suites (`parallel_tests`)
|
|
@@ -218,6 +260,9 @@ rspec-signal: 42 failures in 35 distinct signatures, 4 related clusters (2767 ba
|
|
|
218
260
|
Report: tmp/rspec-signal/signal.md
|
|
219
261
|
```
|
|
220
262
|
|
|
263
|
+
The parent merger owns the run comparison, because it is the only process that has
|
|
264
|
+
seen the whole run.
|
|
265
|
+
|
|
221
266
|
### Human and CI modes
|
|
222
267
|
|
|
223
268
|
For a human, run RSpec normally:
|
|
@@ -244,16 +289,35 @@ claude "fix the failures in tmp/rspec-signal/signal.md"
|
|
|
244
289
|
|
|
245
290
|
```text
|
|
246
291
|
tmp/rspec-signal/
|
|
247
|
-
signal.md
|
|
248
|
-
signal.json
|
|
249
|
-
|
|
250
|
-
full.txt
|
|
251
|
-
.gitignore
|
|
292
|
+
signal.md primary compact report -- hand this to the agent
|
|
293
|
+
signal.json the same data, machine readable, for CI and tooling
|
|
294
|
+
history.json digests and counts of recent runs, so a run can say what changed
|
|
295
|
+
full.txt optional original output; off by default
|
|
296
|
+
.gitignore written automatically; artifacts can contain application data
|
|
252
297
|
```
|
|
253
298
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
299
|
+
At the start of a run, the wrappers delete the previous `signal.md`, `signal.json`,
|
|
300
|
+
and `full.txt`. The formatter does the same when execution starts. New files are
|
|
301
|
+
rendered and staged together, then supporting files are renamed into place before
|
|
302
|
+
`signal.md`. Treat `signal.md` as the publication marker: if it exists, its optional
|
|
303
|
+
companions belong to the same completed generation. An aborted run or rendering
|
|
304
|
+
failure therefore leaves no current report rather than stale or mixed evidence.
|
|
305
|
+
|
|
306
|
+
The wrapper can invalidate the default (or `RSPEC_SIGNAL_OUTPUT_DIR`) directory before
|
|
307
|
+
RSpec boots. At that point Ruby configuration from a spec helper has not loaded, so
|
|
308
|
+
`config.output_dir` and `config.enabled = false` cannot affect early invalidation. Set
|
|
309
|
+
`RSPEC_SIGNAL_DISABLE=1` to disable the wrapper without mutating artifacts, and use
|
|
310
|
+
`RSPEC_SIGNAL_OUTPUT_DIR` when a custom directory needs the early-failure guarantee.
|
|
311
|
+
Dry runs also leave current artifacts alone.
|
|
312
|
+
|
|
313
|
+
Plain `bundle exec rspec` cannot invalidate until rspec-signal's formatter starts; a
|
|
314
|
+
failure before the gem or formatter loads can consequently leave the prior artifacts
|
|
315
|
+
in place. Use `rspec-signal` when the early-failure guarantee matters.
|
|
316
|
+
|
|
317
|
+
`history.json` deliberately survives invalidation — "42 failures became 0" is the
|
|
318
|
+
most valuable thing a run can say, and the run that deletes the report is exactly the
|
|
319
|
+
run that should be able to say it. See
|
|
320
|
+
[Knowing what changed](#knowing-what-changed).
|
|
257
321
|
|
|
258
322
|
The large `full.txt` artifact is off by default. Enable it only when you need the
|
|
259
323
|
original unreduced formatter rendering:
|
|
@@ -268,6 +332,100 @@ Individual per-failure files are deliberately **not** written. With failures
|
|
|
268
332
|
collapsed into a handful of signatures, `signal.md` is already the unit you want
|
|
269
333
|
to paste, and a directory of near-duplicate fragments is just more to sift through.
|
|
270
334
|
|
|
335
|
+
### `signal.json`
|
|
336
|
+
|
|
337
|
+
`schema: 2`. Stable, and safe to build tooling on:
|
|
338
|
+
|
|
339
|
+
| Key | What it is |
|
|
340
|
+
|-----|-----------|
|
|
341
|
+
| `run_id` | Identifies this run |
|
|
342
|
+
| `summary` | Counts: examples, failures, pending, signatures, related clusters, shared code paths |
|
|
343
|
+
| `since_last_run` | `resolved` / `new` / `persistent` / `changed`, each a list of signatures. Absent on a first run |
|
|
344
|
+
| `signatures[]` | One per exact signature, largest first |
|
|
345
|
+
| `signatures[].signature` | The digest. **This is the stable identity of a failure** — key on it |
|
|
346
|
+
| `signatures[].summary` | The one line that says what went wrong |
|
|
347
|
+
| `signatures[].rerun` | A copy-pasteable command that reruns the representative and nothing else |
|
|
348
|
+
| `signatures[].rerun_ids` | Every affected example's RSpec id |
|
|
349
|
+
| `signatures[].culprit` / `.app_context` | Where it raised, and the first-party line to open |
|
|
350
|
+
| `signatures[].representative` | The one failure rendered in full: message, reduced trace, diagnostics |
|
|
351
|
+
| `signatures[].affected[]` | Every example in the signature: location, id, description |
|
|
352
|
+
| `related[]` | Symptom clusters |
|
|
353
|
+
| `code_paths[]` | Lines of your code crossed by more than one signature |
|
|
354
|
+
| `outside_examples[]` | Failures RSpec reported without an example |
|
|
355
|
+
| `analysis` | Only with `config.causal_analysis = true`. **Experimental and not covered by the schema:** see [Relationships](#relationships-experimental) |
|
|
356
|
+
|
|
357
|
+
Only the representative carries a full trace. That is the size trade-off the whole
|
|
358
|
+
gem is built on: the other members of a signature are, by definition, the same
|
|
359
|
+
failure.
|
|
360
|
+
|
|
361
|
+
## Rerunning exactly what failed
|
|
362
|
+
|
|
363
|
+
Every `Rerun` block names **RSpec example ids**, not locations:
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
bundle exec rspec './spec/models/shelf_spec.rb[1:1]'
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
A location (`spec/models/shelf_spec.rb:4`) is what a human types, but it selects
|
|
370
|
+
every example defined on that line — which for a loop-generated `it` is all of them,
|
|
371
|
+
and for two failures that share a line is both of them. An agent that fixes a
|
|
372
|
+
signature, reruns the printed command and still sees failures cannot then tell
|
|
373
|
+
whether its fix failed or whether it caught something unrelated.
|
|
374
|
+
|
|
375
|
+
The ids are shell-quoted because `[` and `]` are glob metacharacters: bash passes an
|
|
376
|
+
unmatched glob through unchanged, but zsh fails the command outright. The
|
|
377
|
+
human-readable location is still on the `Example` line directly above.
|
|
378
|
+
|
|
379
|
+
RSpec's own `--only-failures` keys on the same ids. The two go together:
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
# spec/spec_helper.rb
|
|
383
|
+
RSpec.configure { |c| c.example_status_persistence_file_path = "spec/examples.txt" }
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
bundle exec rspec-signal --only-failures # just what broke last time
|
|
388
|
+
bundle exec rspec-signal --next-failure # one at a time, in defined order
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
## Knowing what changed
|
|
392
|
+
|
|
393
|
+
An agent has no memory between turns. The tool has a filesystem, so it keeps one:
|
|
394
|
+
|
|
395
|
+
```text
|
|
396
|
+
2085 examples, 42 failures, 6 pending
|
|
397
|
+
|
|
398
|
+
rspec-signal: 42 failures in 35 distinct signatures, 4 related clusters
|
|
399
|
+
Since last run: 28 resolved, 3 new, 14 still failing (42 -> 17 failures)
|
|
400
|
+
Shared code paths: app/models/reader.rb:88 (9 signatures)
|
|
401
|
+
Report: tmp/rspec-signal/signal.md
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Four buckets, from two keys per signature — the full fingerprint, and a **loose** key
|
|
405
|
+
of exception and normalized message alone, independent of line numbers:
|
|
406
|
+
|
|
407
|
+
| Bucket | Rule |
|
|
408
|
+
|--------|------|
|
|
409
|
+
| **still failing** | The digest is in both runs |
|
|
410
|
+
| **resolved** | The digest is gone, and its loose key is gone too |
|
|
411
|
+
| **new** | The digest appeared, and its loose key is new too |
|
|
412
|
+
| **changed signature** | The digest changed but the loose key survived — same failure, moved or reworded |
|
|
413
|
+
|
|
414
|
+
The fourth bucket is what makes the other three trustworthy. Editing the file that
|
|
415
|
+
raises shifts every culprit line; without it, one such edit turns every persistent
|
|
416
|
+
failure into a spurious resolved/new pair, and being told "28 resolved" when you
|
|
417
|
+
resolved nothing is worse than being told nothing.
|
|
418
|
+
|
|
419
|
+
`history.json` holds the last ten runs as **digests and counts only** — no messages,
|
|
420
|
+
no paths, nothing the redactor exists to protect. A missing, unreadable or corrupt
|
|
421
|
+
history means the run has nothing to compare against, which is exactly how every run
|
|
422
|
+
behaved before this existed. CI that discards `tmp/` simply gets no comparison, and
|
|
423
|
+
needs no configuration to say so.
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
RSpec::Signal.configure { |config| config.track_history = false }
|
|
427
|
+
```
|
|
428
|
+
|
|
271
429
|
## Filtering philosophy
|
|
272
430
|
|
|
273
431
|
The goal is not the shortest possible output. It is **the minimum context that
|
|
@@ -339,7 +497,7 @@ by a deterministic fingerprint of four components:
|
|
|
339
497
|
| Component | What it is |
|
|
340
498
|
|-----------|-----------|
|
|
341
499
|
| **exception class** | `Capybara::ElementNotFound`, `ActiveRecord::RecordInvalid`, ... |
|
|
342
|
-
| **normalized message** | The message with volatile parts masked: object addresses, UUIDs, timestamps, record ids, temp paths. Small numbers are left alone — `expected 3, got 4` and `expected 7, got 2` are different failures. |
|
|
500
|
+
| **normalized message** | The message with volatile parts masked: object addresses, UUIDs, timestamps, record ids, temp paths. Small numbers are left alone — `expected 3, got 4` and `expected 7, got 2` are different failures. RSpec's `Failure/Error:` echo of the failing source line is left out: for an error raised inside a gem it is each example's own call site. |
|
|
343
501
|
| **culprit** | The innermost frame that is not test-runner plumbing: the code that actually raised. |
|
|
344
502
|
| **app context** | The innermost first-party frame outside your spec suite. `nil` for a plain matcher failure; decisive when the same error comes from two different call sites. |
|
|
345
503
|
|
|
@@ -356,6 +514,8 @@ The four components are what stop over-collapsing:
|
|
|
356
514
|
- Two identical `expect(x).to be true` failures in **different specs** stay apart —
|
|
357
515
|
a matcher failure raises at the spec line, so the culprit differs.
|
|
358
516
|
- The same missing record id in twenty specs collapses to one — ids are masked.
|
|
517
|
+
- One broken factory `create`d from ten spec lines collapses to one — the error is
|
|
518
|
+
raised inside a gem, and the `Failure/Error:` echo of each call site is not identity.
|
|
359
519
|
|
|
360
520
|
Each group renders one full trace, from the failure carrying the most first-party
|
|
361
521
|
frames, plus every affected example's location. Groups are ordered largest first,
|
|
@@ -430,6 +590,110 @@ hold the line:
|
|
|
430
590
|
Ordering is deterministic: largest cluster first, then most signatures spanned, then
|
|
431
591
|
run order.
|
|
432
592
|
|
|
593
|
+
## Shared code paths
|
|
594
|
+
|
|
595
|
+
Both layers above read the failure **message**. Neither can relate two failures whose
|
|
596
|
+
messages have nothing in common — and the most valuable question is not "which
|
|
597
|
+
failures look alike" but **"which line of my code is behind the most failures"**.
|
|
598
|
+
|
|
599
|
+
So a third layer reads the **stack**:
|
|
600
|
+
|
|
601
|
+
```markdown
|
|
602
|
+
## Shared code paths
|
|
603
|
+
|
|
604
|
+
First-party lines that more than one signature runs through, most signatures first.
|
|
605
|
+
A measured fact, not a diagnosis: these examples all execute this line. The
|
|
606
|
+
signatures below remain authoritative.
|
|
607
|
+
|
|
608
|
+
- `app/serializers/order.rb:5` in `call` -- 2 signatures, 12 examples (#1, #4)
|
|
609
|
+
- `app/models/reader.rb:88` in `progress` -- 9 signatures, 21 examples (#2, #3, #5, ...)
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
A worked case. One missing hash key in a pricing module, reached through three call
|
|
613
|
+
paths from three spec files, produces two signatures:
|
|
614
|
+
|
|
615
|
+
```text
|
|
616
|
+
KeyError: key not found: :annual
|
|
617
|
+
RSpec::Expectations::ExpectationNotMetError: expected no Exception, got #<KeyError...>
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
No symptom extractor can relate those — correctly, because their messages really do
|
|
621
|
+
have almost nothing in common. Both stacks run through `app/pricing.rb:4`, and that
|
|
622
|
+
fact was in the backtrace all along.
|
|
623
|
+
|
|
624
|
+
### What stops it over-reporting
|
|
625
|
+
|
|
626
|
+
The same two rules as the cluster layer, for the same reasons:
|
|
627
|
+
|
|
628
|
+
1. **Only first-party frames outside the spec suite, and only the innermost few.**
|
|
629
|
+
The outer end of a stack is shared plumbing — every request spec passes through
|
|
630
|
+
`application_controller.rb` — while the inner end is the bug. Depth is
|
|
631
|
+
`config.code_path_depth`, default 5.
|
|
632
|
+
2. **Two or more distinct signatures, or it is not reported.** One signature crossing
|
|
633
|
+
a line is already reported as that signature's `app_context`; saying it twice is
|
|
634
|
+
noise.
|
|
635
|
+
|
|
636
|
+
And one rule of its own: **it claims nothing about causation.** The section states a
|
|
637
|
+
measured fact — these signatures execute this line — and says so in those words. When
|
|
638
|
+
no line is shared, the section does not appear.
|
|
639
|
+
|
|
640
|
+
Ordering is deterministic: most signatures spanned, then most examples, then run
|
|
641
|
+
order.
|
|
642
|
+
|
|
643
|
+
## Relationships (experimental)
|
|
644
|
+
|
|
645
|
+
**Off by default.** Turn it on with `config.causal_analysis = true`.
|
|
646
|
+
|
|
647
|
+
Signatures say which failures are *the same failure*. This experimental layer relates
|
|
648
|
+
**signatures to each other**, using only facts it can check, and prints nothing when it
|
|
649
|
+
has nothing to add. It works between signatures as they are: it does not repair a
|
|
650
|
+
signature that has wrongly split one failure in two, and it never names a root cause.
|
|
651
|
+
|
|
652
|
+
```text
|
|
653
|
+
Relationships between signatures (experimental):
|
|
654
|
+
CAUSAL · HIGH · 6 failures in 4 signatures
|
|
655
|
+
missing constant Billing::TaxRate (NameError#name)
|
|
656
|
+
same underlying NameError at app/invoice.rb:8, also raised wrapped as InvoiceJob::Failed
|
|
657
|
+
INDEPENDENT · 5 failures in 5 signatures (no relationship found)
|
|
658
|
+
11/11 failures classified: 6 causal, 0 scope, 5 independent
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
| Group | What it claims | Evidence it may use |
|
|
662
|
+
|-------|----------------|---------------------|
|
|
663
|
+
| **CAUSAL · HIGH** | These signatures share an origin | one exception object RSpec handed to every example of a failed `before(:context)`; one underlying exception (same class, message and first-party line), bare or wrapped; one missing definition read from the exception itself -- an `ENV` key (`KeyError#receiver` is `ENV`), a constant (`NameError#name`), a method on a class defined in your project; one failing setup step (`before` hook, `let`, `after` hook); one error outside examples repeated across spec files |
|
|
664
|
+
| **SCOPE** | Failures are concentrated here -- *where*, never *why* | `9/11 examples failed in spec/x_spec.rb; 0/529 elsewhere`: at least two otherwise-unlinked signatures in one file or one `type:`, where at least two thirds failed and fewer than one in twenty elsewhere did. No confidence label |
|
|
665
|
+
| **INDEPENDENT** | No relationship was found | Not "proven unrelated" |
|
|
666
|
+
|
|
667
|
+
`n/n failures classified` means every failure was placed in exactly one of those groups.
|
|
668
|
+
It is bookkeeping, not a count of root causes.
|
|
669
|
+
|
|
670
|
+
What it knows that raw output does not show: which phase failed (read from the frames
|
|
671
|
+
RSpec itself puts on the stack, before reduction discards them), whether examples ever
|
|
672
|
+
reached their bodies, which exception object they received, and how many examples
|
|
673
|
+
*passed* in the same file.
|
|
674
|
+
|
|
675
|
+
What it deliberately does not infer:
|
|
676
|
+
|
|
677
|
+
- a relationship from exception class, message similarity, file, timing or test order;
|
|
678
|
+
- anything from a method missing on `nil` or on a core class -- two nils are not one nil;
|
|
679
|
+
- a shared object outside `before(:context)`: Ruby keeps the first backtrace when an
|
|
680
|
+
exception instance is raised again, so a reused instance cannot prove anything;
|
|
681
|
+
- concentration per parallel worker: `parallel_tests` assigns files to workers by size,
|
|
682
|
+
so "every failure ran on worker 2" usually just means "every failing file did".
|
|
683
|
+
|
|
684
|
+
Weaker observations (one line raising several different messages; one exception instance
|
|
685
|
+
reported by several examples) appear as `hints` in `signal.json` and never change the
|
|
686
|
+
grouping.
|
|
687
|
+
|
|
688
|
+
**`signal.json`:** when switched on, the report gains an `analysis` block marked
|
|
689
|
+
`"experimental": true`. The rest of `signal.json` keeps its schema 2 contract. Fields
|
|
690
|
+
inside `analysis` may change or disappear before the layer graduates. Do not build
|
|
691
|
+
automation on them yet.
|
|
692
|
+
|
|
693
|
+
Evaluation: `bundle exec ruby spec/causal/evaluate.rb --verbose` runs a corpus of real
|
|
694
|
+
`rspec` projects with known causes, and scores this layer against the existing ones.
|
|
695
|
+
See [the design notes](docs/design/causal-failure-intelligence.md).
|
|
696
|
+
|
|
433
697
|
## Large HTML responses
|
|
434
698
|
|
|
435
699
|
A request spec expecting one sentence and receiving a Rails exception page produces
|
|
@@ -498,10 +762,18 @@ RSpec::Signal.configure do |config|
|
|
|
498
762
|
config.max_clusters = 10 # clusters rendered in full (nil = all)
|
|
499
763
|
config.max_cluster_specs = 6 # spec files listed per cluster
|
|
500
764
|
|
|
765
|
+
# Shared code paths (see "Shared code paths").
|
|
766
|
+
config.code_path_depth = 5 # first-party frames indexed, from the raise site in
|
|
767
|
+
config.max_code_paths = 5 # paths rendered in full (nil = all)
|
|
768
|
+
|
|
769
|
+
# Relationships (experimental, off by default; see "Relationships").
|
|
770
|
+
config.causal_analysis = false
|
|
771
|
+
|
|
501
772
|
# Artifacts.
|
|
502
773
|
config.write_json = true
|
|
503
774
|
config.write_full = true
|
|
504
775
|
config.write_gitignore = true
|
|
776
|
+
config.track_history = true # history.json, for "since last run"
|
|
505
777
|
|
|
506
778
|
# Behaviour.
|
|
507
779
|
config.enabled = true
|
|
@@ -597,11 +869,14 @@ everything with `config.redaction_filter`, or turn scrubbing off with
|
|
|
597
869
|
|
|
598
870
|
## Limitations
|
|
599
871
|
|
|
600
|
-
- **Errors outside examples
|
|
601
|
-
to load, produces no failed
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
872
|
+
- **Errors outside examples are recovered, but from rendered text.** A spec file that
|
|
873
|
+
fails to load, or a `before(:suite)` blow-up, produces no failed example; RSpec
|
|
874
|
+
reports it on its message stream instead. `rspec-signal` captures those, parses
|
|
875
|
+
RSpec's own rendering back apart, and reports them under **Errors outside
|
|
876
|
+
examples** with the exception, the message, the file and a rerun command — but it
|
|
877
|
+
is reading formatted text rather than an exception object, so an unusual RSpec
|
|
878
|
+
rendering could reduce it to a count. Whatever it cannot parse it still re-emits
|
|
879
|
+
verbatim.
|
|
605
880
|
- **Parallel test runners.** Use `rspec-signal-parallel` for suites launched with
|
|
606
881
|
`parallel_tests` (see [Parallel suites](#parallel-suites-parallel_tests)); it isolates
|
|
607
882
|
each worker and merges the results deterministically. Do not set a per-worker
|
|
@@ -626,17 +901,29 @@ everything with `config.redaction_filter`, or turn scrubbing off with
|
|
|
626
901
|
- **No source packaging.** `rspec-signal` names files and lines; it does not embed
|
|
627
902
|
source code. Agents working inside a repository can open the files themselves, and
|
|
628
903
|
in v1 that is a better division of labour than guessing which snippets to inline.
|
|
904
|
+
- **Shared code paths are a measurement, not a diagnosis.** "These signatures execute
|
|
905
|
+
this line" is exactly what is claimed and no more. A line every failure happens to
|
|
906
|
+
pass through — a shared serializer, a base controller — will be reported, and
|
|
907
|
+
judging whether it is the cause is the reader's job.
|
|
908
|
+
- **Run comparison needs the previous run's `history.json`.** CI that starts from a
|
|
909
|
+
clean `tmp/` gets no comparison. That degrades to the behaviour of every run before
|
|
910
|
+
the feature existed; it does not degrade to a wrong answer.
|
|
629
911
|
- **Reduction depends on your suite.** Failures that are already distinct and already
|
|
630
912
|
shallow reduce very little. That is the correct outcome, not a failure of the gem.
|
|
631
913
|
|
|
632
914
|
## Compatibility
|
|
633
915
|
|
|
634
|
-
- Ruby 3.1
|
|
916
|
+
- Ruby 2.7, 3.0, 3.1, 3.2, 3.3, 3.4
|
|
635
917
|
- RSpec 3.10+ (via `rspec-core`)
|
|
636
918
|
- Rails, Capybara, ActiveRecord: optional
|
|
637
919
|
|
|
638
920
|
The only runtime dependency is `rspec-core`.
|
|
639
921
|
|
|
922
|
+
If you use `rspec-signal-parallel`, note that `parallel_tests` itself dropped
|
|
923
|
+
Ruby 2.7 support after its 4.7.1 release; on Ruby 2.7, Bundler resolves it (and
|
|
924
|
+
`rubocop`) to the newest release that still supports your Ruby version. Ruby
|
|
925
|
+
3.0+ can use any current `parallel_tests` release.
|
|
926
|
+
|
|
640
927
|
## Development
|
|
641
928
|
|
|
642
929
|
```bash
|
|
@@ -656,7 +943,13 @@ grouping and rendering stages be tested directly against synthetic fixtures in
|
|
|
656
943
|
real `rspec` processes in generated throwaway projects.
|
|
657
944
|
|
|
658
945
|
Tests deliberately assert on output *size* as well as content, so a future change
|
|
659
|
-
cannot quietly let noise back in.
|
|
946
|
+
cannot quietly let noise back in. `spec/integration/agent_workflow_spec.rb` runs real
|
|
947
|
+
`rspec` processes through the whole loop — run, fix, rerun exactly, verify what
|
|
948
|
+
changed — because a rerun command that reruns the wrong examples cannot be caught
|
|
949
|
+
from inside the process that printed it. `spec/performance` is a guard rather than a
|
|
950
|
+
benchmark: it fails on an order-of-magnitude regression in per-failure cost, on the
|
|
951
|
+
analysis ceasing to be roughly linear, or on the report ceasing to be bounded by the
|
|
952
|
+
number of problems rather than the number of failures.
|
|
660
953
|
|
|
661
954
|
## Contributing
|
|
662
955
|
|
data/exe/rspec-signal
CHANGED
|
@@ -2,8 +2,17 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
ENV["RSPEC_SIGNAL_QUIET"] = "1"
|
|
5
|
+
require_relative "../lib/rspec/signal/selection"
|
|
6
|
+
ENV[RSpec::Signal::Selection::ENV_KEY] = JSON.generate(RSpec::Signal::Selection.from_arguments(ARGV).to_h)
|
|
5
7
|
arguments = ["--require", "rspec/signal", "--format", "RSpec::Signal::Formatter", *ARGV]
|
|
6
8
|
|
|
9
|
+
# The wrapper owns the invocation early enough to invalidate a previous report
|
|
10
|
+
# even when RSpec aborts before it can load the formatter.
|
|
11
|
+
unless ARGV.include?("--dry-run") || %w[1 true yes on].include?(ENV.fetch("RSPEC_SIGNAL_DISABLE", "").downcase)
|
|
12
|
+
require "rspec/signal"
|
|
13
|
+
RSpec::Signal::Writer.new(RSpec::Signal.configuration).invalidate_current!
|
|
14
|
+
end
|
|
15
|
+
|
|
7
16
|
# Run the same executable as `bundle exec rspec`. Besides avoiding a parallel
|
|
8
17
|
# implementation of RSpec's CLI, this is important because the executable uses
|
|
9
18
|
# Runner.invoke, which applies RSpec's default spec discovery when no paths are
|