specguard-rspec 0.2.1

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.
data/README.md ADDED
@@ -0,0 +1,735 @@
1
+ # specguard-rspec
2
+
3
+ > The Ruby client for [SpecGuard](https://github.com/yatfa-ai/specguard): an RSpec formatter that ships test-run telemetry,
4
+ > and a CLI linter that validates `@intent` annotations.
5
+
6
+ Two independent tools, one dependency — the [OpenTestIntent](https://github.com/yatfa-ai/open-test-intent) annotation format.
7
+
8
+ ## Install
9
+
10
+ ```ruby
11
+ # Gemfile
12
+ group :test do
13
+ gem "specguard-rspec", require: false
14
+ end
15
+ ```
16
+
17
+ ## The linter — `specguard-lint`
18
+
19
+ Validates `# @intent:` annotations in changed (or all) `*_spec.rb` files against the OpenTestIntent
20
+ JSON Schema. Exits `1` on a malformed annotation; **never** fails on a *missing* one (adoption is
21
+ opt-in and gradual).
22
+
23
+ ```bash
24
+ bundle exec specguard-lint --changed # CI mode: only files in the current diff
25
+ bundle exec specguard-lint # one-off audit: every *_spec.rb
26
+ ```
27
+
28
+ Files are **positional** (`specguard-lint spec/order_spec.rb`); there is no `--source` flag —
29
+ that belongs to the reference tool, not to this one.
30
+
31
+ The linter is an independent implementation of the same protocol, and its agreement with the
32
+ reference is checked by running the two side by side rather than asserted in a comment:
33
+ `tests/parity/run_ruby_parity.sh` in
34
+ [open-test-intent](https://github.com/yatfa-ai/open-test-intent) runs `specguard-lint` and the
35
+ Go validator over a shared corpus and requires identical findings, ordering and exit codes.
36
+ A handful of differences are ratified as such there, each with its reason and each asserted to
37
+ *still* differ; everything else matches byte for byte. They are the same ones described under
38
+ the backend below — two read failures, a parse-failure message, and one that is not about
39
+ wording at all.
40
+
41
+ ### Machine-readable output (`--json`)
42
+
43
+ The human report is for humans. `--json` emits **one JSON document on stdout** instead, so a CI
44
+ step or an agent gets *which file, which line, which rule* as data rather than a prose format to
45
+ regex and a 3-valued exit code. The flag can go anywhere on the command line.
46
+
47
+ ```bash
48
+ bundle exec specguard-lint --json spec/models/order_spec.rb
49
+ ```
50
+ ```json
51
+ {
52
+ "schema": "open-test-intent.v1.json",
53
+ "mode": "source",
54
+ "ok": false,
55
+ "summary": { "files": 1, "annotations": 2, "failed": 2 },
56
+ "findings": [
57
+ { "file": "spec/models/order_spec.rb", "line": 24, "ok": false, "kind": "schema",
58
+ "errors": ["<root>: additional property 'entiity' is not allowed"] },
59
+ { "file": "spec/models/order_spec.rb", "line": 31, "ok": false, "kind": "extraction",
60
+ "errors": ["unterminated object literal (an annotation must fit on one line)"] }
61
+ ]
62
+ }
63
+ ```
64
+
65
+ This is the **same document** `validate-intent --json --source` emits, key for key — the gem
66
+ already *consumes* it when the Go backend is on, and a consumer of both tools should not need two
67
+ parsers for one protocol. It is not byte-identical (Ruby does not escape non-ASCII where Python's
68
+ `json.dumps` does); it is key-, type- and value-identical, which is what a parser sees.
69
+
70
+ | field | meaning |
71
+ | --------------------- | ------- |
72
+ | `schema` | the OpenTestIntent schema version the payloads were validated against |
73
+ | `mode` | always `"source"` — annotations in spec sources, the port's name for what this tool does. Not the *selection* mode (`--changed` vs named files), which the document has no field for |
74
+ | `ok` | whether the run passed — derived from the exit code, not recomputed, so the two renderers cannot disagree |
75
+ | `summary.files` | spec files selected: the number the text report's leading `checked N spec file(s)` line states |
76
+ | `summary.annotations` | annotation sites examined: the number its trailing summary line states. A site whose payload could not be captured or parsed still counts; a file that could not be read contributes none |
77
+ | `summary.failed` | findings with `"ok": false`, read failures included. Note this is **not** the text summary's `M malformed`, which counts malformed annotations and reports unread files in its own clause |
78
+
79
+ Every finding has the same five keys:
80
+
81
+ | field | meaning |
82
+ | -------- | ------- |
83
+ | `file` | the path, echoed back exactly as it was given |
84
+ | `line` | the annotation's line number; **`null`** where the finding is not line-scoped — a read failure saw no line of the file, and `file:0` would point CI annotations and editor quickfix at a line that does not exist |
85
+ | `ok` | whether this finding passed |
86
+ | `kind` | *how* it failed; `null` when it passed |
87
+ | `errors` | every violated rule, or the single problem — **always** a list of strings, never null and never a bare string, so a consumer never branches on its type |
88
+
89
+ `kind` is the field the prose renderer destroys: a failed extraction and an unparseable payload
90
+ both read as one sentence after `FAIL … — `, and only `kind` tells them apart.
91
+
92
+ | `kind` | means |
93
+ | ------ | ----- |
94
+ | `schema` | parsed fine, violated the OpenTestIntent schema |
95
+ | `extraction` | an `@intent:` token whose object literal could not be captured (missing or unbalanced braces, or spread across lines) |
96
+ | `parse` | the payload was captured but is not JSON even after normalisation |
97
+ | `read` | the file could not be read at all (missing, unopenable, or not valid UTF-8), so no annotation in it was ever seen |
98
+
99
+ The port has a fifth kind, `no-match`, that cannot appear here: its arguments are globs and this
100
+ tool's are paths, so a path that matches nothing is a `read` failure of that path.
101
+
102
+ Three things worth knowing:
103
+
104
+ - **Exit codes are identical with and without the flag**, and the default output is unchanged.
105
+ `--json` is a second renderer over the same checks, not a second code path — it is pinned that
106
+ way in `spec/specguard/rspec/exit_contract_spec.rb` and
107
+ `spec/specguard/rspec/regression_targets_spec.rb`.
108
+ - **A run that could not produce verdicts emits no document.** Bad flags, `--changed` outside a
109
+ repository, an unloadable schema, an unmet `--require-validator` — all still exit `2` with prose
110
+ on stderr. Those runs checked nothing, and `{"ok": false, "findings": []}` is exactly how a
111
+ gate that checked nothing gets mistaken for one that found nothing.
112
+ - **The provenance line stays on stderr** and is deliberately *not* duplicated into the document
113
+ (see below). Redirect `2>` to keep it; stdout is the document and nothing else.
114
+
115
+ ### Optional: the Go validator as a backend
116
+
117
+ Set `SPECGUARD_VALIDATE_INTENT` to a `validate-intent` binary and `specguard-lint` will hand the
118
+ selected files to it (`--source --json`) instead of validating them in Ruby, then render the same
119
+ report from its findings:
120
+
121
+ ```bash
122
+ SPECGUARD_VALIDATE_INTENT=/path/to/validate-intent bundle exec specguard-lint --changed
123
+ ```
124
+
125
+ **Off by default.** The binary is not shipped with this gem and is not
126
+ published anywhere yet, so the Ruby path stays the default and stays supported — this is for people
127
+ who already build or vendor the validator and would rather run one implementation than two. A blank
128
+ value counts as unset.
129
+
130
+ Naming a binary that is missing or unusable is already a hard failure (exit `2`) rather than a quiet
131
+ fall back to Ruby. What is *not* caught by that is never naming one at all — see
132
+ `--require-validator` below.
133
+
134
+ #### `--require-validator` — assert that the binary actually ran
135
+
136
+ ```bash
137
+ SPECGUARD_VALIDATE_INTENT=/path/to/validate-intent bundle exec specguard-lint --changed --require-validator
138
+ ```
139
+
140
+ Exits `2` unless `SPECGUARD_VALIDATE_INTENT` named a usable binary, before any file is selected or
141
+ checked:
142
+
143
+ ```
144
+ specguard-lint: validated in Ruby (SPECGUARD_VALIDATE_INTENT is unset)
145
+ specguard-lint: error: --require-validator was given, but SPECGUARD_VALIDATE_INTENT is unset, so this run would have been validated in Ruby
146
+ ```
147
+
148
+ Without the flag nothing changes: the backend stays opt-in, and a run with the variable unset is
149
+ byte-identical to what it always was.
150
+
151
+ The case this exists for is the one the exit code alone cannot show you: a mistyped variable name, a
152
+ conditional CI step that did not run, an environment file that was not loaded. The run *succeeds* —
153
+ validated by the other implementation — and the only trace is a line on stderr that nothing reads.
154
+ That is not "same answer, different engine": the two backends' JSON parsers do not accept the same
155
+ language (see "The difference that is not about wording", below), so on a payload one accepts and
156
+ the other rejects **the two exit codes disagree**, and a report with no findings is exactly what a
157
+ clean run looks like.
158
+
159
+ It is a flag rather than a second environment variable on purpose. `SPECGUARD_VALIDATE_INTENT_REQURED=1`
160
+ would be silently no assertion at all — the same bug one level up. A mistyped `--requre-validator`
161
+ cannot fail open; it exits `2`.
162
+
163
+ `--require-validator --help` and `--require-validator --version` still exit `0`. The flag asserts
164
+ something about a run, and neither of those is one.
165
+
166
+ #### Every run says which implementation validated it
167
+
168
+ Because the two backends produce the same report, the report alone cannot tell you which one ran.
169
+ So `specguard-lint` states it, in one line on **stderr**, on every run and on both arms:
170
+
171
+ ```
172
+ specguard-lint: validated by validate-intent 1.4.0 (go1.22.12 linux/arm64) schema sha256:6535d9ba… at /path/to/validate-intent (SPECGUARD_VALIDATE_INTENT) — it reports enforcing the schema this gem vendors, loaded from /usr/local/schemas/open-test-intent.v1.json
173
+ specguard-lint: validated in Ruby (SPECGUARD_VALIDATE_INTENT is unset)
174
+ specguard-lint: validated in Ruby (SPECGUARD_VALIDATE_INTENT is set but blank, which means off)
175
+ ```
176
+
177
+ The two "validated in Ruby" wordings are the same two `--require-validator` reports its refusal
178
+ with, so one vocabulary describes both. The clause after the backend line is the schema-contract
179
+ comparison — see "Which schema the run enforces", below.
180
+
181
+ The `schema sha256:` token in the first line is elided above only to fit; it prints in full, and it
182
+ is part of the binary's own `--version` answer rather than something `specguard-lint` appends. A
183
+ backend line *without* that token is a different band, and is worded differently.
184
+
185
+ Three things worth knowing about it:
186
+
187
+ * **stdout is untouched.** The line is on stderr, beside the other diagnostics about the linter
188
+ itself, so the findings and the two `checked …` lines are still byte-identical across the two
189
+ backends and still safe to pipe. Under `--json` it stays exactly where it is and is **not**
190
+ copied into the document: that would be the first key by which this gem's document differs from
191
+ the port's, and it would give provenance two homes that can disagree about one fact — the hole
192
+ this line was added to close, not to widen.
193
+ * **The identity is the binary's own.** It comes from `<binary> --version`, asked once per run
194
+ before any file is selected, and is passed through verbatim rather than reworded — that is the
195
+ only thing that can tell two builds of the validator apart.
196
+ * **A binary that cannot answer still validates.** `--version` and `--schema-source` each arrived in
197
+ a later slice of the validator; a build without one reads the flag as a filename and exits 1. That
198
+ costs nothing — same findings, same exit code, same stdout — and the line says which question went
199
+ unanswered, in words (`… which could not report its identity, so the schema contract it carries
200
+ could not be checked`) rather than going missing.
201
+
202
+ #### Which schema the run enforces
203
+
204
+ The identity line is not only printed. `specguard-lint` compares the schema *this gem* vendors —
205
+ digested from the file at runtime — against the schema the binary reports, before any file is
206
+ selected or checked, and refuses the run when the two differ.
207
+
208
+ **Which digest it asks for is the whole of this check.** `validate-intent --version` ends
209
+ `schema sha256:<64-hex>`, the digest of the JSON Schema **compiled into** that binary — and that is
210
+ not the schema a run necessarily *loads*. A `schemas/open-test-intent.v1.json` sitting beside the
211
+ executable takes precedence over the compiled-in copy, and `--version` answers above that decision
212
+ and never reaches it; the binary's own `--help` says the digest "is not a claim about what a given
213
+ run enforced". So `specguard-lint` asks `validate-intent --schema-source`, which runs the real
214
+ loader and reports the origin and digest of the bytes a verdict run would enforce, and compares
215
+ *that*. Both questions are asked once per run, before any file is selected or checked.
216
+
217
+ This is the one thing about the pair that neither half can check by itself. Both sides already pin
218
+ their own schema against their own tree, and both stay green while disagreeing with each other: the
219
+ gem is installed from RubyGems, the binary is built or fetched by version separately, and nothing
220
+ ties the two vintages together. What that produces is a run that succeeds under a contract other
221
+ than the one this gem ships — and on the backend path the gem never loads its own schema at all, so
222
+ no finding, count or exit code downstream can reflect the difference.
223
+
224
+ **The run enforces the schema this gem vendors.** It proceeds, and the line names where that schema
225
+ came from — an absolute path when a file beside the binary won, or `<embedded schema>` when the
226
+ compiled-in copy did:
227
+
228
+ ```
229
+ specguard-lint: validated by validate-intent 1.4.0 (…) at /path/to/validate-intent (SPECGUARD_VALIDATE_INTENT) — it reports enforcing the schema this gem vendors, loaded from <embedded schema>
230
+ ```
231
+
232
+ **It enforces a different one.** Exit `2`, before any file is selected or checked:
233
+
234
+ ```
235
+ specguard-lint: error: the validator backend at /path/to/validate-intent (SPECGUARD_VALIDATE_INTENT) reports enforcing schema sha256:9c1e…, loaded from /usr/local/schemas/open-test-intent.v1.json, but this gem vendors sha256:6535… — the two halves would enforce different contracts, so this run would produce a verdict this gem cannot stand behind; the binary identifies itself as validate-intent 1.5.0 (go1.22.12 linux/arm64) schema sha256:6535…
236
+ ```
237
+
238
+ Both digests are printed in full (elided above only to fit), because one of them lives inside a
239
+ binary and the other inside an installed gem and neither is inspectable from where the other lives.
240
+ The origin is there because it says *which half to move*: a stale `<embedded schema>` is fixed by
241
+ rebuilding or reinstalling the binary, and a path on this host by replacing or deleting that file.
242
+ The version string is there for the question that follows immediately — *which build is this* —
243
+ since on this path the provenance line above never prints. Note the identity in the example above
244
+ carries the digest the gem vendors: a binary can be built against the right schema and still enforce
245
+ the wrong one, which is exactly the case this comparison exists to catch.
246
+
247
+ This is a new way for a run to fail, and it can fail a job that was green yesterday without
248
+ anything in your repository changing: upgrading the gem or the binary, or dropping a schema file
249
+ beside the binary, is enough. That is the intended behaviour, and it is the same judgement
250
+ `--require-validator` makes one level up — a verdict produced under a contract this gem does not
251
+ ship is one it declines to launder. To fix it, move whichever half is stale so the two agree.
252
+
253
+ **The binary is too old to be asked.** `--schema-source` arrived in a later slice of the validator;
254
+ an older build reads it as a filename and exits 1, and a schema that exists beside the binary and
255
+ will not load exits 2 with its own "could not load schema" diagnostic (which the run reaches a
256
+ moment later anyway, from the path that owns it). Neither costs a verdict. The comparison falls back
257
+ to the *carried* digest — the same two outcomes, proceed or exit `2` — and the line keeps the hedge
258
+ that belongs to that weaker question, because on that path it is still true:
259
+
260
+ ```
261
+ specguard-lint: validated by validate-intent 1.2.0 (…) at /path/to/validate-intent (SPECGUARD_VALIDATE_INTENT), which reports carrying the schema this gem vendors — the contract it carries, not necessarily the one this run enforced
262
+ ```
263
+
264
+ **No digest to compare.** Never a refusal — same findings, same exit code, same stdout — and the
265
+ provenance line says which kind of "could not check" it was, in its own words:
266
+
267
+ * `…, which reports no schema digest, so the contract it carries could not be checked` — a build
268
+ older than the slice that added the token. The rule that an older binary must not cost you a
269
+ verdict is unchanged here.
270
+ * `…, which could not report its identity, so the schema contract it carries could not be checked` —
271
+ a build too old to answer `--version` at all.
272
+ * `…, whose schema contract could not be checked: this gem could not read its own vendored copy` —
273
+ the gem's own installation is missing or unreadable. This is not fatal *on this path* on purpose:
274
+ the backend run does not otherwise read that file, and a missing operand is an unanswered
275
+ question, not a disagreement. (On the Ruby path the same file being unreadable is still exit `2`,
276
+ because there it is the contract the run is about to enforce.)
277
+
278
+ "Could not check" and "checked and clean" are different statements, so they are worded differently
279
+ rather than both reading as silence.
280
+
281
+ What the backend does *not* change: the selection, the report format, or the summary-line format.
282
+ What it *can* change is narrower than an earlier version of this section claimed, and the difference
283
+ is worth stating precisely rather than reassuringly.
284
+
285
+ For every payload both JSON parsers accept, the two backends agree completely: the same finding
286
+ against the same file at the same line, the same classification, the same counts and the same exit
287
+ code. **The messages in the table below differ in their trailing text only** — the rows *are* the
288
+ enumeration, not a sample of it, and each is asserted in both directions, so closing one fails the
289
+ suite rather than leaving a stale claim here — in
290
+ `spec/specguard/rspec/validator_backend_spec.rb` and in the parity harness above:
291
+
292
+ | input | Ruby path | Go backend |
293
+ |---|---|---|
294
+ | a payload that is still not JSON after normalisation | `unexpected token at '{ "entity": …'` | `Expecting value: line 1 column 102 (char 101)` |
295
+ | a file that is not valid UTF-8 | `invalid UTF-8 byte sequence` | the validator's own decoder message |
296
+ | a path that does not exist | `No such file or directory @ rb_sysopen - …` | `no file at this path` |
297
+ | a path that is not a regular file | `Is a directory @ io_fread - …` | `no file at this path` |
298
+
299
+ The first row is the one you are most likely to actually see: `parse` is one of the three things
300
+ the linter reports, and **every** malformed-JSON annotation renders differently under the backend.
301
+ The Ruby path interpolates Ruby's `JSON::ParserError`; the validator reproduces CPython's `json`
302
+ diagnostic, and the backend passes that through unaltered rather than inventing a third spelling.
303
+ Both agree on which annotation broke, and on the line and column — only the prose moves. The other
304
+ rows are read failures and need an unreadable path to reach at all.
305
+
306
+ #### The difference that is not about wording
307
+
308
+ The two JSON parsers do not accept the same language. CPython's `json` — which the validator
309
+ reproduces deliberately — accepts two things Ruby's `JSON.parse` refuses:
310
+
311
+ 1. the non-finite literals `NaN`, `Infinity` and `-Infinity`;
312
+ 2. a high surrogate escape (`\ud800`–`\udbff`) with **nothing escaped after it** — either last in
313
+ the string, or followed by a literal character. Ruby rescues it as soon as another `\uXXXX`
314
+ escape follows, without requiring that escape to be a genuine low surrogate.
315
+
316
+ The list was three entries until the `json` gem changed underneath it, and the third is worth
317
+ recording rather than deleting: Ruby used to refuse nesting past `max_nesting: 100` at a depth
318
+ CPython accepted, and used to require a real low surrogate. Both narrowed, so the set shrank —
319
+ which is the direction it is expected to move, and the specs pin the new boundary exactly (see
320
+ `spec/specguard/rspec/validator_backend_spec.rb`) so the next shift is caught the same way.
321
+
322
+ The membership was derived rather than guessed: 89,108 documents, including every
323
+ one of the 65,536 single `\uXXXX` escapes, through both parsers. (A *lone low* surrogate is fine on
324
+ both — the rule is narrower than "surrogate escapes".) The sweep is also **symmetric**: the other
325
+ direction — documents Ruby accepts and CPython refuses — was swept too and is **empty**, so the
326
+ list above is the whole difference between the two parsers and not just the half we went looking
327
+ for. That matters, because a member of the reverse set would show up as the mirror of the first
328
+ shape below: the Ruby path reporting a schema violation where the backend reports a parse failure.
329
+
330
+ On such a payload the backend does not word the failure differently; it does not have the same
331
+ failure. It parses the payload and validates it, so you get a schema violation with `-> ` reason
332
+ lines where the Ruby path gives you one `— could not parse annotation:` line. The file, the line,
333
+ the counts and the exit code still agree.
334
+
335
+ And if that payload is otherwise **schema-valid** — reachable only through the surrogate case,
336
+ since a number and a container cannot fill a slot the schema declares as a string — the backend
337
+ finds nothing wrong and **exits 0 where the Ruby path exits 1**. This is the one input on which
338
+ the two backends disagree about whether your suite passes. It is enumerated and asserted from both
339
+ sides, in the same places as the rows above.
340
+
341
+ Both are ratified rather than fixed, and the reason is scope: `allow_nan: true` and
342
+ `max_nesting: false` would close the non-finite case, but the surrogate case has no such option and
343
+ would mean porting CPython's string decoder into this gem — and both would change what the
344
+ **default** Ruby path does, which the slice that added this backend deliberately holds fixed. See
345
+ `Scanner#parse` for the full reasoning; whether the gem should adopt CPython's acceptance grammar
346
+ is left open there rather than settled.
347
+
348
+ **If you go and count them.** The parity harness numbers this same enumeration two ways, and both
349
+ numbers are correct, so it is worth saying which is which before you follow the link. It *asserts*
350
+ them as six entries, `(i)`–`(vi)`, under "8b. the six enumerated backend differences" — the rows of
351
+ the table above, plus the two shapes the acceptance set takes. It *groups* them in its header as
352
+ four mechanisms, `(a)`–`(d)`, because the two unreadable-path rows share one cause and the two
353
+ acceptance-set shapes share another. Six entries, four mechanisms, one enumeration.
354
+
355
+ Those two totals live in that harness, which asserts them. This page does not restate them beside
356
+ the table, because a count kept in two places is a count that will eventually disagree with itself
357
+ — which is exactly how an earlier revision of this section came to announce three messages directly
358
+ above four rows.
359
+
360
+ Every way the backend can fail — the binary is missing, will not execute, exits with something that
361
+ is not a verdict, or emits output that is not a report — is **exit 2**, the linter's "could not do
362
+ my job" code. It never becomes exit 1, which means "an annotation is malformed" and nothing else.
363
+
364
+ ## The formatter — `SpecGuard::RSpecFormatter`
365
+
366
+ An **additive** RSpec formatter: it runs alongside your usual one (`progress`, `documentation`, …)
367
+ rather than replacing it, and records every example that finished — annotated or not — as one JSON
368
+ object per run, POSTed to SpecGuard (or written to `log/test_results.jsonl` when there is no API
369
+ key).
370
+
371
+ ```ruby
372
+ # spec/spec_helper.rb
373
+ require "specguard/rspec/formatter"
374
+ RSpec.configure do |config|
375
+ config.add_formatter(SpecGuard::RSpecFormatter)
376
+ end
377
+ ```
378
+
379
+ ```
380
+ # ...or in .rspec — the --require is not optional, RSpec cannot guess this path
381
+ --require specguard/rspec/formatter
382
+ --format SpecGuard::RSpecFormatter
383
+ ```
384
+
385
+ The two forms are equivalent, and neither needs you to name a human formatter. Additive is meant
386
+ literally, in both directions: if you chose a formatter that reports the run to a human
387
+ (`progress`, `documentation`, `--format failures`, `--format json`, …), it is left alone and
388
+ SpecGuard adds nothing to your output; if you chose none, you get RSpec's default (`progress`)
389
+ exactly as you would without this gem — same dots, same failures, same summary, byte for byte.
390
+
391
+ The qualifier on that first half is deliberate. SpecGuard restores the default when no *other*
392
+ registered formatter would give a human an account of the run, and it judges that by the formatter
393
+ protocol — whether anything answers to `example_started`, `example_passed`, `example_failed`,
394
+ `example_pending` or `dump_summary`. So if the only other formatter you registered is a silent one
395
+ (another telemetry gem, a custom notifier that writes elsewhere), SpecGuard reads the run as
396
+ unserved and restores `progress`, and you get output you did not have before. That is the error
397
+ direction chosen on purpose — noisy beats silent, which is the whole point of this behaviour — but
398
+ if you want a genuinely quiet run, name a formatter that reports the run and says little:
399
+ `--format failures` prints one line per failure and nothing else, so a green suite stays at zero
400
+ bytes and the restore does not fire.
401
+
402
+ That second half is not free, because RSpec installs its default formatter only when *no* formatter
403
+ was registered at all — so a gem that registers one silently suppresses it, and a failing suite
404
+ prints nothing. SpecGuard restores it on the first notification of the run, once RSpec has finished
405
+ deciding. If you want something other than `progress`, name it the usual way (`--format
406
+ documentation`, or `config.default_formatter = "doc"`) and that is what you will get, on its own.
407
+
408
+ "Byte for byte" is checked rather than asserted: `spec/specguard/rspec/formatter_run_spec.rb` runs
409
+ each wiring and the same suite with no SpecGuard at all, and diffs the two streams end to end with
410
+ only the two wall-clock numbers erased. Both a failing suite and a suite that reports through
411
+ `reporter.message` — an error in an `after(:context)` hook — are compared that way, because they
412
+ travel through different formatters and an addition that is invisible in one shows up in the other.
413
+
414
+ Each example contributes its `id`, `spec_file_path`, `file_path`, `line_number`, `name` (the composed
415
+ `describe`/`context`/`it` string), `duration`, `outcome`, `status` (`"annotated"` or
416
+ `"unannotated"`) and `intent` — the parsed annotation when there is one, `null` when there is not;
417
+ the run envelope carries `commit_sha`, `branch` and `duration_seconds`.
418
+
419
+ `id` is RSpec's own example id — `./spec/orders_spec.rb[1:2]`, the argument that re-runs that one
420
+ example — and it is the key that distinguishes examples a coordinate cannot. A table-driven loop
421
+ writes its `it` once, so all of its examples share a `line_number`; a shared example group reports
422
+ the coordinate of `spec/support/shared.rb` from every file that includes it. `spec_file_path` is the
423
+ spec file that actually **ran** the example, which is the same as `file_path` for an ordinary example
424
+ and the *including* file for a shared one — so duration-by-file adds up against the file you would
425
+ have named, not against a `spec/support/` helper.
426
+
427
+ > `id` is unique within a run, not stable across refactors: it is positional, so reordering examples
428
+ > changes it, exactly as inserting a line changes `line_number`. Matching one test across runs is
429
+ > `name` plus file.
430
+
431
+ `file_path` and `line_number` keep meaning the **definition** site — that is the line the `@intent:`
432
+ annotation is read from.
433
+
434
+ An example counts as **annotated** when an `@intent:` sits on its `it` line, or on the comment line
435
+ immediately above it:
436
+
437
+ ```ruby
438
+ # @intent: { entity: "Order", action: "refund", behavior: "restores stock levels on refund", layer: "unit" }
439
+ it "restores stock on refund" do
440
+
441
+ it "surfaces the decline reason" do # @intent: { entity: "Order", action: "checkout", ... }
442
+ ```
443
+
444
+ One line of lookback, no more — and a trailing annotation belongs to its own example only, never to
445
+ the one on the next line.
446
+
447
+ > **A malformed or schema-invalid annotation is recorded as `unannotated`, with a `null` intent, and
448
+ > the formatter says nothing about it.** That is deliberate: telemetry must never block CI, and the
449
+ > platform validates a run's payload as a whole — so shipping one bad annotation would cost the
450
+ > *entire run* its telemetry rather than one row its metadata. `specguard-lint` is the half of this
451
+ > gem that tells you about a bad annotation, loudly, with exit code `1`. Run it in CI and the
452
+ > formatter never has anything to hide.
453
+
454
+ ```ruby
455
+ # optional — the defaults read the commit, branch, CI run id and shard index
456
+ # from whichever provider is running you (GitHub Actions, GitLab CI, CircleCI,
457
+ # Buildkite, Jenkins), and when none of them named the commit or the branch,
458
+ # ask git directly for both — so a laptop run and a hand-rolled container
459
+ # report their checkout too, without being configured to. A detached checkout
460
+ # reports no branch rather than the string "HEAD".
461
+ # SPECGUARD_COMMIT_SHA / SPECGUARD_BRANCH / SPECGUARD_RUN_ID /
462
+ # SPECGUARD_SHARD_ID / SPECGUARD_OUTPUT_PATH override any of it.
463
+ #
464
+ # Assign a value here only when it is one neither source can know:
465
+ SpecGuard::RSpec.configure do |config|
466
+ config.branch = "release/2.0"
467
+ end
468
+ ```
469
+
470
+ ## Shipping the run to SpecGuard
471
+
472
+ Set an API key and an endpoint and the run is POSTed to
473
+ `<endpoint>/api/v1/ingest` — once per process, as a single request (see
474
+ [If you shard your suite](#if-you-shard-your-suite) for what happens when there
475
+ is more than one process):
476
+
477
+ ```bash
478
+ export SPECGUARD_ENDPOINT=https://specguard.example.com
479
+ export SPECGUARD_API_KEY=… # from your repository's settings
480
+ export SPECGUARD_TIMEOUT=10 # optional; seconds, applied to connect and read
481
+ ```
482
+
483
+ ```ruby
484
+ # ...or in Ruby, if you would rather not use the environment
485
+ SpecGuard::RSpec.configure do |config|
486
+ config.endpoint = "https://specguard.example.com"
487
+ config.api_key = ENV["SPECGUARD_API_KEY"]
488
+ config.timeout = 10
489
+ end
490
+ ```
491
+
492
+ **The API key is the switch.** With no key nothing is sent anywhere and the run
493
+ is written to `log/test_results.jsonl` exactly as before — so local development
494
+ needs no opt-out, and a fork with no secret configured behaves like a laptop
495
+ rather than like a broken build.
496
+
497
+ **A failed delivery is never silent, and never lost.** If the endpoint refuses
498
+ the run (a `401` from a rotated key, a `400`, a `500`) or cannot be reached at
499
+ all (connection refused, DNS failure, timeout), the formatter prints **one**
500
+ line to stderr naming the status or the error, and writes the payload to
501
+ `log/test_results.jsonl` so the run can be replayed later:
502
+
503
+ ```
504
+ SpecGuard: could not deliver test telemetry (HTTP 401 — the API key was not
505
+ accepted). Falling back to log/test_results.jsonl; the test run is unaffected.
506
+ ```
507
+
508
+ There are **no retries**, and the whole delivery is bounded by `timeout`
509
+ (10 seconds by default, against `Net::HTTP`'s own 60): telemetry is explicitly
510
+ allowed to be lost, and a retry would only double what a hung endpoint can cost
511
+ your CI run.
512
+
513
+ **A dry run is refused, to both sinks.** `rspec --dry-run` builds and reports
514
+ every example without executing a single body, so its per-example `duration` is
515
+ the cost of *constructing* an example (single-digit microseconds — a
516
+ `sleep 0.05` example understates its own runtime by three to four orders of
517
+ magnitude) and its `outcome` is `passed` for code that never
518
+ ran. Nothing downstream can tell the difference, and an all-green, near-instant
519
+ run is exactly the shape that poisons both the numbers SpecGuard reports. So
520
+ when RSpec is in dry-run mode the formatter makes no POST **and** writes no line
521
+ to `log/test_results.jsonl` — a file full of zero-duration green runs is the
522
+ same corruption, deferred until something replays it — and says so once:
523
+
524
+ ```
525
+ SpecGuard: skipped test telemetry for a dry run (rspec --dry-run executes no
526
+ example bodies, so this run's durations and outcomes would not be
527
+ measurements). Nothing was sent or written; the test run is unaffected.
528
+ ```
529
+
530
+ This matters most where you are least likely to look for it: an API key is
531
+ usually an environment-level secret rather than a job-level one, so a lint job
532
+ that runs `rspec --dry-run` to catch an unparseable spec file inherits the key
533
+ and would otherwise overwrite your suite's real duration and pass/fail picture
534
+ with zeroes and green.
535
+
536
+ It **never blocks CI.** RSpec does not sandbox formatters — an exception raised
537
+ in one escapes the runner and takes RSpec's own exit code with it — so every
538
+ hook rescues, warns once on stderr, and leaves the exit status to your suite
539
+ alone. A non-2xx response gets the same treatment: `Net::HTTP` returns those as
540
+ ordinary values rather than raising, so they are checked for explicitly instead
541
+ of being left to a `rescue` that would never see them.
542
+
543
+ ### If you shard your suite
544
+
545
+ `parallel_tests`, Knapsack and a CI matrix all run the suite as several
546
+ processes, and each one loads this formatter and POSTs its own slice. The run id
547
+ is what tells SpecGuard those POSTs are **one run**: shards that share it are
548
+ accumulated onto a single record, so a 20,000-example suite reports a 20,000
549
+ denominator instead of one record per shard holding a quarter of it — and a
550
+ quarter is what the dashboard showed before, attributed to the right commit,
551
+ with nothing to mark it as partial.
552
+
553
+ Every supported provider publishes an id for the build (`GITHUB_RUN_ID`,
554
+ `CI_PIPELINE_ID`, `CIRCLE_WORKFLOW_ID`, `BUILDKITE_BUILD_ID`, `BUILD_TAG`), so a
555
+ sharded job on any of them needs no configuration. If you shard somewhere else,
556
+ export one yourself — any value that every shard of the run shares and no other
557
+ run repeats:
558
+
559
+ ```bash
560
+ export SPECGUARD_RUN_ID="$MY_CI_BUILD_ID"
561
+ ```
562
+
563
+ Unset is not an error. A run with no id is treated as a run of its own, which is
564
+ exactly right for `bundle exec rspec` on a laptop. A genuinely different run — a
565
+ nightly, a later push — gets a different id from its provider and stays a
566
+ separate record, which is why the commit alone cannot do this job.
567
+
568
+ #### Re-runs, and why each shard also names itself
569
+
570
+ A CI run id does **not** change when you re-run the build. GitHub's own wording
571
+ for `GITHUB_RUN_ID` is *"This number does not change if you re-run the workflow
572
+ run"*; Buildkite retries a job inside the same `BUILDKITE_BUILD_ID` and GitLab
573
+ inside the same `CI_PIPELINE_ID`. That is the behaviour SpecGuard wants — press
574
+ "re-run failed jobs" on a sharded suite and only the failed shards run again, so
575
+ they need to land back on the run they came from rather than forming a new run
576
+ holding a fifth of the suite.
577
+
578
+ For that to be right, a shard has to be able to *replace* its own earlier
579
+ numbers instead of adding to them, which means naming itself. SpecGuard reads
580
+ the shard index your runner already exports:
581
+
582
+ | Runner | Variable |
583
+ | --- | --- |
584
+ | `parallel_tests` | `TEST_ENV_NUMBER` (its blank first process is read as shard `1`) |
585
+ | GitLab `parallel:`, Knapsack Pro | `CI_NODE_INDEX` |
586
+ | CircleCI `parallelism:` | `CIRCLE_NODE_INDEX` |
587
+ | Buildkite `parallelism:` | `BUILDKITE_PARALLEL_JOB` |
588
+
589
+ **GitHub Actions `matrix:` is the one that needs a line of config.** It exports
590
+ no per-leg index — `GITHUB_JOB` is the job's id in your YAML and is identical
591
+ across every leg — so set it from the matrix value:
592
+
593
+ ```yaml
594
+ strategy:
595
+ matrix:
596
+ shard: [1, 2, 3, 4]
597
+ steps:
598
+ - run: bundle exec rspec
599
+ env:
600
+ SPECGUARD_SHARD_ID: ${{ matrix.shard }}
601
+ ```
602
+
603
+ The value only has to be unique *within one run*; it is never compared across
604
+ runs. Do the same if you nest — `parallel_tests` inside a matrix leg repeats
605
+ `TEST_ENV_NUMBER` across legs, so give `SPECGUARD_SHARD_ID` something that
606
+ composes both.
607
+
608
+ Leaving it unset is not an error and does not lose the slice: an unnamed shard
609
+ is still counted into the run. What it cannot do is be recognised on a second
610
+ delivery, so if that shard is retried its numbers are added again rather than
611
+ replacing what was there. If your suite shards and you re-run it, name the
612
+ shards.
613
+
614
+ ## What SpecGuard collects
615
+
616
+ Everything below is the whole of it. `Transport#deliver` sends the payload
617
+ verbatim — there is no filtering layer between what the formatter captures and
618
+ what leaves the machine — so this list *is* the request body, and a spec pins
619
+ it so that adding a field without updating this section fails the build. A run
620
+ big enough to be worth compressing is gzipped in transit; that changes how the
621
+ body is encoded on the wire, never what is in it.
622
+
623
+ **The run envelope — six fields, once per process:**
624
+
625
+ | Field | What it holds | Where it comes from |
626
+ | --- | --- | --- |
627
+ | `commit_sha` | the commit the suite ran against | `SPECGUARD_COMMIT_SHA` if you set it, else `GITHUB_SHA`, `CI_COMMIT_SHA`, `CIRCLE_SHA1`, `BUILDKITE_COMMIT`, `GIT_COMMIT`, else `git rev-parse HEAD` |
628
+ | `branch` | the branch name; `null` on a detached checkout | `SPECGUARD_BRANCH` if you set it, else `GITHUB_REF_NAME`, `CI_COMMIT_REF_NAME`, `CIRCLE_BRANCH`, `BUILDKITE_BRANCH`, `GIT_BRANCH`, else `git symbolic-ref --short -q HEAD` |
629
+ | `ci_run_id` | your provider's build id, so shards of one run fold together; `null` on a laptop | `SPECGUARD_RUN_ID` if you set it, else `GITHUB_RUN_ID`, `CI_PIPELINE_ID`, `CIRCLE_WORKFLOW_ID`, `BUILDKITE_BUILD_ID`, `BUILD_TAG` |
630
+ | `shard_id` | which slice of that run this process is; `null` when unsharded | `SPECGUARD_SHARD_ID` if you set it, else `TEST_ENV_NUMBER`, `CI_NODE_INDEX`, `CIRCLE_NODE_INDEX`, `BUILDKITE_PARALLEL_JOB` |
631
+ | `duration_seconds` | wall clock for the whole run | measured by the formatter |
632
+ | `specs` | one object per example that finished — the nine fields below | the run |
633
+
634
+ **Each example — nine fields, one object per example, annotated or not:**
635
+
636
+ | Field | What it holds | Where it comes from |
637
+ | --- | --- | --- |
638
+ | `id` | RSpec's own example id, `./spec/orders_spec.rb[1:2]` — the re-run argument | `example.id` |
639
+ | `spec_file_path` | the spec file that **ran** the example, relative to the project root when it lives under it — an absolute path when it does not, because a spec outside the working directory has no relative name | `metadata[:rerun_file_path]` |
640
+ | `file_path` | the spec file the example is **defined** in, on the same terms | `metadata[:file_path]` |
641
+ | `line_number` | the line it is defined on | `metadata[:line_number]` |
642
+ | `name` | the composed `describe`/`context`/`it` string | `example.full_description` |
643
+ | `duration` | seconds that one example took | `execution_result.run_time` |
644
+ | `outcome` | `passed`, `failed` or `pending` | `execution_result.status` |
645
+ | `status` | `annotated` or `unannotated` | whether an `@intent:` was found for that line |
646
+ | `intent` | the parsed `@intent:` annotation; `null` when there is none | the annotation you wrote in the spec file |
647
+
648
+ Two request headers say something about you rather than about the request: the
649
+ API key travels as a bearer token in `Authorization`, and `User-Agent` names
650
+ this gem and its version (`specguard-rspec/<version>`), so the platform can tell
651
+ its clients apart. The rest are ordinary HTTP plumbing that describe the message
652
+ itself and carry nothing about your code or your suite — `Content-Type`,
653
+ `Accept`, `Content-Length`, `Host`, `Accept-Encoding`, and `Content-Encoding:
654
+ gzip` on a run large enough to be compressed. A spec pins that header set too,
655
+ so a header added later cannot quietly slip past this paragraph.
656
+
657
+ ### Test names and annotations are free text, and that is the point
658
+
659
+ `name` and `intent` are written by your developers, in prose; `file_path` and
660
+ `spec_file_path` are the names they gave the files. They **will** carry internal
661
+ product detail — feature names, customer names, the shape of work you have not
662
+ shipped — because a suite describes the system it tests.
663
+
664
+ The paths are the one part of this that is not authored but **machine-derived**,
665
+ and it is worth knowing where that can go further than you meant. A spec under
666
+ the project root reports a project-relative name and nothing more. A spec run
667
+ from *outside* it has no relative name, so its real location is what travels —
668
+ `/home/build-agent-07/…`, `/var/lib/jenkins/workspace/acme-payments-nightly/…` —
669
+ in `spec_file_path`, in `file_path`, and in `id`, which is that same path plus a
670
+ position. Ordinary suites never hit this; a spec vendored outside the tree, a
671
+ shard splitter that expands its arguments to absolute paths, or an IDE runner
672
+ will. That discloses a build machine's directory layout, which is a different
673
+ category from prose, so it is named here rather than folded into the paragraph
674
+ above.
675
+
676
+ SpecGuard is built on that and cannot be built without it. The product answers
677
+ "what does this suite actually cover, and where are the gaps" — a question whose
678
+ entire input is what your tests say they cover. A mode that shipped anonymised
679
+ coordinates would not be a lighter SpecGuard; it would be a SpecGuard that
680
+ cannot answer anything. So there is no opt-out, no field-level redaction and no
681
+ name-scrubbing switch, and none is planned. This is a deliberate product
682
+ decision, stated here so you can make yours.
683
+
684
+ ### If this cannot leave your perimeter, run SpecGuard inside it
685
+
686
+ Self-hosting is the supported answer, and it needs no code change — point
687
+ `SPECGUARD_ENDPOINT` at your own deployment and every byte described above goes
688
+ there instead:
689
+
690
+ ```bash
691
+ export SPECGUARD_ENDPOINT=https://specguard.internal.example.com
692
+ ```
693
+
694
+ ### What is never collected
695
+
696
+ - **No source code.** Not your application's, and not your tests' — no example
697
+ body, no `let`, no fixture, no diff of any of it.
698
+ - **No failure messages and no backtraces.** A failing example contributes the
699
+ string `failed` and nothing else; the exception, its message and its stack
700
+ stay on your machine.
701
+ - **No test output.** Nothing your suite printed to stdout or stderr, and
702
+ nothing any other formatter wrote, is read or forwarded.
703
+ - **No environment.** SpecGuard's own code reads a fixed list of variables and
704
+ no others: the ones named in the envelope table above, which fill
705
+ `commit_sha`, `branch`, `ci_run_id` and `shard_id`; plus four that configure
706
+ the gem itself rather than describing your suite — `SPECGUARD_ENDPOINT` (where
707
+ to send the run), `SPECGUARD_OUTPUT_PATH` (where to write the local file when
708
+ there is no key), `SPECGUARD_TIMEOUT` (how long to wait), and
709
+ `SPECGUARD_API_KEY`, which leaves the machine only as the bearer token
710
+ described above. The other three are never sent, and there is no general
711
+ environment capture to be caught by. (The linter is a separate program that
712
+ sends nothing at all; it reads one variable of its own,
713
+ `SPECGUARD_VALIDATE_INTENT`, documented above.)
714
+ - **One exception, and it is about the route rather than the contents: your
715
+ proxy settings are read.** Sending the run goes through Ruby's `Net::HTTP`,
716
+ which resolves a proxy from the environment the way every Ruby HTTP client
717
+ does — so if your network requires a proxy, the run takes it, without
718
+ SpecGuard being told about it. `http_proxy` (or `HTTP_PROXY`) is the variable
719
+ that does it, **including for an `https://` endpoint**: `Net::HTTP` resolves
720
+ the proxy against an `http` URL whatever the transport, which means setting
721
+ only `https_proxy` will *not* proxy your run. `no_proxy` (or `NO_PROXY`)
722
+ suppresses it per host. In a CGI environment (`REQUEST_METHOD` set)
723
+ `CGI_HTTP_PROXY` is read instead and the uppercase spelling is ignored. None
724
+ of these is ever transmitted, and none of them changes a byte of what is sent
725
+ — they decide only **where it goes**, which is worth knowing alongside
726
+ *"If this cannot leave your perimeter, run SpecGuard inside it"* above, since
727
+ `SPECGUARD_ENDPOINT` is not the only thing that determines the destination.
728
+
729
+ ---
730
+
731
+ <p align="center">
732
+ <a href="https://yatfa.com">
733
+ <img src="assets/built-with-yatfa.png" alt="Built with yatfa — a team of AI agents that plans, builds &amp; ships software." width="100%">
734
+ </a>
735
+ </p>