rspec-signal 0.2.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +326 -38
  4. data/exe/rspec-signal +9 -0
  5. data/exe/rspec-signal-parallel +12 -5
  6. data/lib/rspec/signal/causal/analysis.rb +292 -0
  7. data/lib/rspec/signal/causal/census.rb +86 -0
  8. data/lib/rspec/signal/causal/entities.rb +67 -0
  9. data/lib/rspec/signal/causal/evidence.rb +144 -0
  10. data/lib/rspec/signal/causal/phase.rb +98 -0
  11. data/lib/rspec/signal/causal/scope_analysis.rb +80 -0
  12. data/lib/rspec/signal/code_paths.rb +101 -0
  13. data/lib/rspec/signal/comparison.rb +101 -0
  14. data/lib/rspec/signal/configuration.rb +18 -2
  15. data/lib/rspec/signal/failure.rb +26 -3
  16. data/lib/rspec/signal/failure_builder.rb +17 -2
  17. data/lib/rspec/signal/fingerprint.rb +18 -2
  18. data/lib/rspec/signal/formatter.rb +121 -69
  19. data/lib/rspec/signal/group.rb +20 -3
  20. data/lib/rspec/signal/history.rb +102 -0
  21. data/lib/rspec/signal/message.rb +54 -1
  22. data/lib/rspec/signal/outside_example.rb +148 -0
  23. data/lib/rspec/signal/parallel_merger.rb +67 -7
  24. data/lib/rspec/signal/parallel_run.rb +4 -3
  25. data/lib/rspec/signal/progress_bar.rb +48 -0
  26. data/lib/rspec/signal/report.rb +119 -23
  27. data/lib/rspec/signal/reporters/json_report.rb +3 -2
  28. data/lib/rspec/signal/reporters/markdown.rb +29 -21
  29. data/lib/rspec/signal/reporters/outside_examples.rb +101 -0
  30. data/lib/rspec/signal/reporters/relationships.rb +228 -0
  31. data/lib/rspec/signal/reporters/shared_code_paths.rb +86 -0
  32. data/lib/rspec/signal/rerun.rb +39 -0
  33. data/lib/rspec/signal/run_status.rb +40 -0
  34. data/lib/rspec/signal/selection.rb +130 -0
  35. data/lib/rspec/signal/terminal_summary.rb +119 -0
  36. data/lib/rspec/signal/version.rb +1 -1
  37. data/lib/rspec/signal/writer.rb +55 -18
  38. data/lib/rspec/signal.rb +18 -0
  39. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6cd660f06b5b7ecd54cdacbd562c06588c157139121debebe3a9ca2a58e6686e
4
- data.tar.gz: fdd869cce815ac1b14733b54edf8eac6a3d233986aa316f340c60724c852998d
3
+ metadata.gz: 5b2d960f1c23decbc4bd847430d8f1df056e93bfe2823223397df48058d99217
4
+ data.tar.gz: b114d8d9b06efe92e7216919e383b1ac9a58c97f0e3684b0d68fc4bfe88a575b
5
5
  SHA512:
6
- metadata.gz: 819b4e310c972c189c1497ea25280651d3496c876f17e306a563413b4d2ab6bd03a79bb45595e4e840f2463a641bdd8890d3e4843037567196b2616ab6e5f8b0
7
- data.tar.gz: d2f3245f7e28d91cb75414ce7c8e5dd361fcefcd8bb6f5ff44036f4b0022ebbc2020fac36c50487c5e9b2cbd587643946be035e073769ca9f4335f4bad99b2db
6
+ metadata.gz: 8d6ec971a7db81678e54dd1ef59dccce8f3e2f87b0e37af4c254ba2b916434aa04720b9c50935c025d0f1d1c17109ebe4a93f97a7dc4656f910e0e49640c17b3
7
+ data.tar.gz: 893b91607a8c90ac55cc26f9e385cbfe20a9fde84f1263cb6dc1c28b10fe6189d2db81b5e35555dd458de7e837f243fa64e35ba8016ebdb69c41b85ecbb4c2d1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,79 @@ 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
+
10
83
  ## [0.2.0] - 2026-08-27
11
84
 
12
85
  ### Added
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 context-reduction layer between RSpec and a coding
15
- agent. It writes `tmp/rspec-signal/signal.md`: a compact, model-neutral report you
16
- can paste into any assistant or point an agent at.
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 becomes a quiet formatter that prevents verbose failures from entering an autonomous agent's context.
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:4
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
- ```ruby
108
- # Gemfile
109
- group :test do
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 individual failures from the report as needed
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 primary compact report -- hand this to the agent
248
- signal.json the same data, machine readable, for CI and tooling
249
- (`signatures` and `related` mirror the two grouping layers)
250
- full.txt optional original output; off by default
251
- .gitignore written automatically; artifacts can contain application data
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
- A run with **no** failures deletes these files. A stale report describing failures
255
- you already fixed is worse than no report at all, because an agent will go and
256
- "fix" them again.
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.** A `before(:suite)` blow-up, or a spec file that fails
601
- to load, produces no failed examples. RSpec reports those only through its message
602
- stream, which a formatter cannot subscribe to without swallowing every other
603
- message. `rspec-signal` reports the count and says where to look, but cannot
604
- capture the text.
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,6 +901,13 @@ 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
 
@@ -661,7 +943,13 @@ grouping and rendering stages be tested directly against synthetic fixtures in
661
943
  real `rspec` processes in generated throwaway projects.
662
944
 
663
945
  Tests deliberately assert on output *size* as well as content, so a future change
664
- 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.
665
953
 
666
954
  ## Contributing
667
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
@@ -5,6 +5,7 @@ require "English"
5
5
  require "fileutils"
6
6
  require "securerandom"
7
7
  require "tmpdir"
8
+ require_relative "../lib/rspec/signal/selection"
8
9
 
9
10
  begin
10
11
  parallel_rspec = Gem.bin_path("parallel_tests", "parallel_rspec")
@@ -20,10 +21,16 @@ ENV["RUBYLIB"] = [lib, ENV.fetch("RUBYLIB", nil)].compact.join(File::PATH_SEPARA
20
21
  ENV["RSPEC_SIGNAL_RUN_ID"] = run_id
21
22
  ENV["RSPEC_SIGNAL_RUN_REGISTRY"] = registry
22
23
  ENV["RSPEC_SIGNAL_QUIET"] = "1"
24
+ ENV[RSpec::Signal::Selection::ENV_KEY] = JSON.generate(RSpec::Signal::Selection.from_arguments(ARGV).to_h)
23
25
  ENV["PARALLEL_TEST_FIRST_IS_1"] = "true"
24
26
  signal_options = "--require rspec/signal --format RSpec::Signal::Formatter"
25
27
  ENV["SPEC_OPTS"] = [ENV.fetch("SPEC_OPTS", nil), signal_options].compact.join(" ")
26
28
 
29
+ unless ARGV.include?("--dry-run") || %w[1 true yes on].include?(ENV.fetch("RSPEC_SIGNAL_DISABLE", "").downcase)
30
+ require "rspec/signal"
31
+ RSpec::Signal::Writer.new(RSpec::Signal.configuration).invalidate_current!
32
+ end
33
+
27
34
  system(Gem.ruby, parallel_rspec, *ARGV)
28
35
  test_status = $CHILD_STATUS.exitstatus || 1
29
36
 
@@ -31,12 +38,12 @@ begin
31
38
  require "rspec/signal"
32
39
  result = RSpec::Signal::ParallelMerger.new(registry: registry).call
33
40
  report = result.report
41
+ writer = RSpec::Signal::Writer.new(RSpec::Signal.configuration)
42
+ path = writer.relative(result.write_result.summary_path) if result.write_result.summary_path
34
43
  puts
35
- puts "#{report.example_count} examples, #{report.failure_count} failures across #{result.workers} workers"
36
- puts "rspec-signal: #{report.group_count} exact signatures, #{report.cluster_count} related clusters, " \
37
- "#{report.omitted_frames} backtrace frames omitted"
38
- if result.write_result.summary_path
39
- puts "Report: #{RSpec::Signal::Writer.new(RSpec::Signal.configuration).relative(result.write_result.summary_path)}"
44
+ terminal = RSpec::Signal::TerminalSummary.new(report, report_path: path, quiet: true, workers: result.workers)
45
+ terminal.lines.each do |line|
46
+ puts line
40
47
  end
41
48
  if result.missing.any?
42
49
  warn "rspec-signal: warning: #{result.missing.size} worker artifacts were missing; report is incomplete"