specguard-ruby 0.3.23 → 0.3.24
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/README.md +127 -10
- data/lib/specguard/minitest/reporter.rb +93 -11
- data/lib/specguard/rspec/annotation_lookup.rb +70 -5
- data/lib/specguard/rspec/formatter.rb +137 -21
- data/lib/specguard/rspec/ingest_cli.rb +321 -29
- data/lib/specguard/rspec/ingest_reporter.rb +17 -4
- data/lib/specguard/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd1e42b8014d4627c1126b81fd4e419e000316e788f0115a24dbbf9041ad52b1
|
|
4
|
+
data.tar.gz: d74ca48cff5263d913f727a94d59b6bb6ffd477ff4a31bddcf7d83599b96f9c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79519698cb6c136909f09bd425548bdac0e3ab936145449e983937d2f9f97e9f1e30e7f3bcd9936e1c200b2c49358157d9b97469804f3b11a200538557dc78f3
|
|
7
|
+
data.tar.gz: c034dc9514a7cb085909a1bcdfb7c51e84dd67af457bbd73c26c4a4d31f21afae6c6893a7f42d2475dd350b80d0112879698ab5a912cf2dee5c83dfa7abc7c25
|
data/README.md
CHANGED
|
@@ -31,7 +31,9 @@ SPECGUARD_ENDPOINT=https://specguard.example SPECGUARD_API_KEY=sgk_… bundle ex
|
|
|
31
31
|
|
|
32
32
|
The plugin rides alongside Minitest's own reporters (the suite's output is unchanged), posts one
|
|
33
33
|
envelope per process with the same field names the RSpec formatter sends, and never fails the run:
|
|
34
|
-
a refused or unreachable delivery costs one line on stderr and a line in the
|
|
34
|
+
a refused or unreachable delivery costs one line on stderr and a line in the replay queue — and
|
|
35
|
+
when that queue cannot be written either, the same one line reports the loss rather than promising
|
|
36
|
+
a file that is not there (see [Shipping the run to SpecGuard](#shipping-the-run-to-specguard)). Without
|
|
35
37
|
`SPECGUARD_API_KEY` nothing is sent — the run is appended to the local development record, exactly
|
|
36
38
|
as the RSpec formatter behaves. Parameterized tests — the `define_method("test_x_#{param}")` loop
|
|
37
39
|
idiom — ship one row per instance, not one row per definition site, so each instance's outcome and
|
|
@@ -468,14 +470,28 @@ is written to `log/test_results.local.jsonl` — the local development record,
|
|
|
468
470
|
kept apart from the replay queue — so local development needs no opt-out, and a
|
|
469
471
|
fork with no secret configured behaves like a laptop rather than like a broken
|
|
470
472
|
build. The local file's name is configurable via `SPECGUARD_LOCAL_OUTPUT_PATH`
|
|
471
|
-
(or `SpecGuard::RSpec.configure { |c| c.local_output_path = ... }`).
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
473
|
+
(or `SpecGuard::RSpec.configure { |c| c.local_output_path = ... }`). The write
|
|
474
|
+
itself is silent when it succeeds — the ordinary case on any machine that can
|
|
475
|
+
create `log/` — and it usually does.
|
|
476
|
+
|
|
477
|
+
**When the local file cannot be written, the run says so instead.** A read-only
|
|
478
|
+
mount, a full disk, a regular file sitting where the directory should be: both
|
|
479
|
+
Ruby clients print **one** line to stderr naming the **configured** path and the
|
|
480
|
+
underlying error, whatever it is, and the test run is unaffected. The path in
|
|
481
|
+
the line is the client's own — the one your configuration set — and it has to
|
|
482
|
+
be, because a failure like a closed stream carries no path of its own:
|
|
483
|
+
|
|
484
|
+
```
|
|
485
|
+
SpecGuard: could not write telemetry to log/test_results.local.jsonl
|
|
486
|
+
(IOError: closed stream). The test run is unaffected.
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
**A failed delivery is never silent, and the run is kept whenever it can be.**
|
|
490
|
+
If the endpoint refuses the run (a `401` from a rotated key, a `400`, a `500`)
|
|
491
|
+
or cannot be reached at all (connection refused, DNS failure, timeout), the
|
|
492
|
+
formatter prints **one** line to stderr naming the status or the error, and
|
|
493
|
+
writes the payload to `log/test_results.jsonl` — the **replay queue**: runs
|
|
494
|
+
offered to the endpoint and not accepted — so the run can be replayed later with
|
|
479
495
|
[`specguard-ingest`](#replaying-a-saved-run--specguard-ingest):
|
|
480
496
|
|
|
481
497
|
```
|
|
@@ -503,6 +519,23 @@ a reason it can read — an empty body, or the HTML a proxy answers a `413` with
|
|
|
503
519
|
— prints the bare status line above and is still reported as a refusal, not as
|
|
504
520
|
an error.
|
|
505
521
|
|
|
522
|
+
**And when the queue cannot be written either, that same line says so.** A
|
|
523
|
+
refused delivery whose `log/` is unwritable — a read-only mount, a full disk, a
|
|
524
|
+
regular file sitting where the directory should be — really does lose the run,
|
|
525
|
+
and the second half of the line reports the queue rather than promising it:
|
|
526
|
+
|
|
527
|
+
```
|
|
528
|
+
SpecGuard: could not deliver test telemetry (HTTP 401 — the API key was not
|
|
529
|
+
accepted). The replay queue log/test_results.jsonl could not be written either
|
|
530
|
+
(Errno::EACCES: Permission denied @ rb_sysopen - log/test_results.jsonl), so
|
|
531
|
+
this run's telemetry was lost.
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Still one line, still the status first — and still no effect on your suite's
|
|
535
|
+
exit code, which is the only promise SpecGuard makes about a run of yours. Both
|
|
536
|
+
Ruby clients behave this way; the Minitest reporter's sentence is the same
|
|
537
|
+
beyond the wording of the fallback clause.
|
|
538
|
+
|
|
506
539
|
There are **no retries**, and the whole delivery is bounded by `timeout`
|
|
507
540
|
(10 seconds by default, against `Net::HTTP`'s own 60): telemetry is explicitly
|
|
508
541
|
allowed to be lost, and a retry would only double what a hung endpoint can cost
|
|
@@ -654,7 +687,11 @@ named, rather than blindly re-sent:
|
|
|
654
687
|
bundle exec specguard-ingest --from-line 7 log/test_results.jsonl
|
|
655
688
|
```
|
|
656
689
|
|
|
657
|
-
The numbering never shifts
|
|
690
|
+
The numbering never shifts between invocations that do not drain: line 7 is
|
|
691
|
+
line 7 of the file you gave it, both times. (`--drain` is the one exception —
|
|
692
|
+
removing the accepted lines renumbers what is left, so a drain report's numbers
|
|
693
|
+
describe the file as it was read, not as the next run finds it; see
|
|
694
|
+
[Draining the queue as it is accepted](#draining-the-queue-as-it-is-accepted---drain).)
|
|
658
695
|
Re-sending a line that already landed is harmless *only* when it carries a
|
|
659
696
|
`ci_run_id` — that is the identity SpecGuard folds a redelivery onto. A line
|
|
660
697
|
**without** one has nothing to fold onto and becomes a second run, and a keyless
|
|
@@ -806,6 +843,85 @@ were ingested, so a replayed run becomes the repository's latest. For the case
|
|
|
806
843
|
this exists to serve — replay the run that just failed, right after fixing the
|
|
807
844
|
credential — that is correct.
|
|
808
845
|
|
|
846
|
+
#### Draining the queue as it is accepted — `--drain`
|
|
847
|
+
|
|
848
|
+
The replay queue is *runs offered to the endpoint and not accepted* — and until
|
|
849
|
+
the run is drained, a line the endpoint **did** accept stays in it. The next
|
|
850
|
+
incident's failures append behind it, and "re-running the command is the retry"
|
|
851
|
+
then re-sends every one of those already-accepted runs: harmless when the line
|
|
852
|
+
carries a `ci_run_id` (it folds onto the run it already made), a duplicate row
|
|
853
|
+
when it does not. `--drain` is the opt-in follow-through:
|
|
854
|
+
|
|
855
|
+
```bash
|
|
856
|
+
bundle exec specguard-ingest --drain log/test_results.jsonl
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
```
|
|
860
|
+
line 1: accepted — HTTP 202, test_run_id 41f2c9b8, ci_run_id 17442
|
|
861
|
+
line 2: not delivered — HTTP 503 — upstream is down
|
|
862
|
+
specguard-ingest: delivered 1 of 2 runs from log/test_results.jsonl; 1 could not be delivered; 1 accepted line removed from log/test_results.jsonl
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
Only the lines answered `202` **in this invocation** are removed — there is no
|
|
866
|
+
heuristic and nothing is guessed at, which is the same line the rest of this
|
|
867
|
+
command draws. Everything else stays **byte for byte, in the file's order**:
|
|
868
|
+
|
|
869
|
+
- **refused** lines — a `400` is refused every time it is offered, and the
|
|
870
|
+
payload still needs fixing;
|
|
871
|
+
- **undelivered** lines — the endpoint never stored them, so they were never
|
|
872
|
+
accepted;
|
|
873
|
+
- **unparseable** lines — never a run, so never accepted;
|
|
874
|
+
- **blank** lines — never anything;
|
|
875
|
+
- every line **`--from-line` or `--lines` held back** — it was not sent, so it
|
|
876
|
+
was not accepted, whatever the endpoint would have said.
|
|
877
|
+
|
|
878
|
+
The rewrite is **atomic**: a temporary file in the same directory, renamed over
|
|
879
|
+
the original, so a failure mid-drain leaves the file exactly as it was — and
|
|
880
|
+
nothing is written at all unless something was actually accepted.
|
|
881
|
+
|
|
882
|
+
**The removal renumbers what it leaves.** The rewrite keeps the surviving lines
|
|
883
|
+
byte for byte and in order, and packing them up from the top gives them new
|
|
884
|
+
numbers: a queue of `[accepted, not delivered, accepted]` becomes a two-line
|
|
885
|
+
file whose former lines 2 and 3 are now lines 1 and 2 — while the report above
|
|
886
|
+
still says `line 2`, because that number describes the file **as it was read**.
|
|
887
|
+
That is deliberate: the report is a receipt about the file this invocation
|
|
888
|
+
opened, and emitting post-drain numbers would make it disagree with the file it
|
|
889
|
+
read. So do **not** feed a drain report's numbers to the next command's
|
|
890
|
+
`--from-line` or `--lines` — when lines remain, the summary's drain clause says
|
|
891
|
+
so in as many words. The resume is simply: re-run `--list` to see the
|
|
892
|
+
renumbered file, or run `--drain` again. The "the numbering never shifts"
|
|
893
|
+
guarantee holds between invocations that do not drain.
|
|
894
|
+
|
|
895
|
+
**A concurrent append is carried.** The formatter appends to the queue with no
|
|
896
|
+
lock, so a run can land while the deliveries are still going. Bytes appended
|
|
897
|
+
after the file was read and before the rewrite are carried into it verbatim.
|
|
898
|
+
One window remains — the instant between the final read and the rename — and
|
|
899
|
+
it is disclosed in the code rather than claimed closed; closing it would take a
|
|
900
|
+
lock in the formatter, which is deliberately not this flag's business.
|
|
901
|
+
|
|
902
|
+
**Only the replay queue is drained.** Another path is refused with a `2` — the
|
|
903
|
+
local record `log/test_results.local.jsonl` is a development record, not a
|
|
904
|
+
queue, and removing accepted lines from it would delete ordinary laptop runs
|
|
905
|
+
that were never failures. The comparison is exact: the file must be spelled as
|
|
906
|
+
`SPECGUARD_OUTPUT_PATH` (or the default) configures the queue. `--drain` with
|
|
907
|
+
`--list` is refused with a `2` as well — a listing delivers nothing, so there
|
|
908
|
+
is nothing for it to drain.
|
|
909
|
+
|
|
910
|
+
The removal is **stated, never silent**: the summary gains the clause above,
|
|
911
|
+
and the `--json` document's `summary` carries a `drained` count (absent without
|
|
912
|
+
the flag). When lines remain after the rewrite, the clause carries its second
|
|
913
|
+
half and says so: the surviving lines are renumbered, and the report's numbers
|
|
914
|
+
above — which describe the file as it was read — no longer address it. A drain
|
|
915
|
+
that cannot complete is a `2` — the delivery report still
|
|
916
|
+
prints in full, a warning on stderr names the file, and the file is left as it
|
|
917
|
+
was, because a `0` would read as "drained" about a queue that was not.
|
|
918
|
+
|
|
919
|
+
When the whole queue was accepted, the file is left **empty**, and the next
|
|
920
|
+
`specguard-ingest` run — with or without `--drain` — sends nothing, warns, and
|
|
921
|
+
exits `0`, exactly as it does over any empty file. Draining by default is
|
|
922
|
+
deliberately not the behaviour: a tool that deletes your queue unless told not
|
|
923
|
+
to has made the product decision for you.
|
|
924
|
+
|
|
809
925
|
#### Machine-readable output — `--json`
|
|
810
926
|
|
|
811
927
|
An HTTP `400` is the one **permanent** verdict in the table above: a refused line
|
|
@@ -863,6 +979,7 @@ bundle exec specguard-ingest --json log/test_results.jsonl
|
|
|
863
979
|
| `summary.blank` / `skipped` | the two ways a line of the file is not a row here, counted rather than dropped |
|
|
864
980
|
| `summary.absent` | the `--lines` numbers the file does not have, in the shorthand you typed them — a number or an `N-M` range per entry — or `null` when the selector was fully satisfied; never `[]`, on `selector`'s terms |
|
|
865
981
|
| `summary.selector` | `"--lines"`, `"--from-line"`, or `null` when nothing was held back |
|
|
982
|
+
| `summary.drained` | with `--drain`: how many accepted lines were removed from the file — `0` where the flag asked and nothing was accepted. Absent without the flag, and never present under `--list`, which cannot drain. When `drained` is greater than `0`, each `lines[].number` refers to the file **before** the drain: the rewrite renumbers the lines it leaves, so those numbers describe the file as it was read and no longer address the rewritten file |
|
|
866
983
|
| `lines[]` | one entry per row, in the file's order |
|
|
867
984
|
| `foldings[]` | folding, **observed**: the lines that went out with one `ci_run_id` and came back with one `test_run_id`. The same statement the text report makes as a sentence |
|
|
868
985
|
|
|
@@ -61,24 +61,54 @@ module SpecGuard
|
|
|
61
61
|
# and would surface as noise. The RSpec formatter's `capture` makes the
|
|
62
62
|
# equivalent call about a metadata-less example.
|
|
63
63
|
class << self
|
|
64
|
-
# `Dir.pwd`
|
|
65
|
-
#
|
|
66
|
-
#
|
|
64
|
+
# `Dir.pwd` bound once — by the first `Reporter.new` (whose
|
|
65
|
+
# `#initialize` reads it, SPGD-1421) or the first direct read — never
|
|
66
|
+
# per row: a reporter constructed inside a test that changes the
|
|
67
|
+
# working directory would otherwise relativize one suite's rows
|
|
68
|
+
# against two different roots. The memo is what binds it — an
|
|
69
|
+
# unmemoized `Dir.pwd` here re-reads the cwd on every row and is
|
|
70
|
+
# exactly the two-root split this comment names, with both spellings
|
|
71
|
+
# individually well-formed so nothing downstream can detect the
|
|
72
|
+
# drift. The memo is also the ONE value the annotation lookup reads
|
|
73
|
+
# against: `#initialize` hands it over, so the relativization root
|
|
74
|
+
# and the lookup's read root cannot drift apart the way they did when
|
|
75
|
+
# each bound `Dir.pwd` at its own moment.
|
|
67
76
|
def repo_root
|
|
68
|
-
Dir.pwd
|
|
77
|
+
@repo_root ||= Dir.pwd
|
|
69
78
|
end
|
|
70
79
|
end
|
|
71
80
|
|
|
72
81
|
def initialize(configuration: SpecGuard::RSpec.configuration,
|
|
73
82
|
transport: nil, output: $stderr,
|
|
74
|
-
annotations:
|
|
83
|
+
annotations: nil,
|
|
75
84
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
76
85
|
@configuration = configuration
|
|
77
86
|
# Injected for the specs; the real thing is built the same way the
|
|
78
87
|
# RSpec formatter builds it, from the same configuration object.
|
|
79
88
|
@transport = transport
|
|
80
89
|
@output = output
|
|
81
|
-
|
|
90
|
+
# ONE binding, ONE moment (SPGD-1421). The relativization root and the
|
|
91
|
+
# annotation lookup's read root used to be bound separately: the memo
|
|
92
|
+
# below lazily, on the FIRST row; the lookup's `Dir.pwd` at
|
|
93
|
+
# construction. A run whose cwd moved to an ANCESTOR of the
|
|
94
|
+
# construction directory in between relativized its rows against the
|
|
95
|
+
# moved-to directory and resolved them against the construction one —
|
|
96
|
+
# `<root>/work/sample_test.rb` under construction root `<root>/work`
|
|
97
|
+
# reads as `work/sample_test.rb`, which the lookup expanded into the
|
|
98
|
+
# doubled `<root>/work/work/...` no file answers — so every readable,
|
|
99
|
+
# correctly annotated spec in the run shipped `unannotated`, with
|
|
100
|
+
# nothing downstream able to tell. Both halves now read one value,
|
|
101
|
+
# bound at construction — the earlier of the two moments, and the one
|
|
102
|
+
# SPGD-1417 already gave the lookup. Reading the memo here is
|
|
103
|
+
# load-bearing even when a lookup is injected (the specs do): it pins
|
|
104
|
+
# the relativization root at construction, so no row can ever bind it
|
|
105
|
+
# somewhere the lookup is not. The default lookup is handed that same
|
|
106
|
+
# value; `#relative_path` reads the same memo. (SPGD-1429 extends the
|
|
107
|
+
# same one-binding hand-over to the RSpec formatter: its constructor
|
|
108
|
+
# binds one root, hands it to the default lookup, and pins rspec-core's
|
|
109
|
+
# lazily memoized relativization regex to the same moment.)
|
|
110
|
+
root = self.class.repo_root
|
|
111
|
+
@annotations = annotations || SpecGuard::RSpec::AnnotationLookup.new(root: root)
|
|
82
112
|
@clock = clock
|
|
83
113
|
@rows = []
|
|
84
114
|
@started_at = nil
|
|
@@ -198,7 +228,7 @@ module SpecGuard
|
|
|
198
228
|
# to the transport, whose own contract is that nothing escapes it — a
|
|
199
229
|
# non-success `Result` costs one stderr line and a queued run.
|
|
200
230
|
def deliver(data)
|
|
201
|
-
return
|
|
231
|
+
return append_local(data) if @configuration.api_key.to_s.strip.empty?
|
|
202
232
|
|
|
203
233
|
if @configuration.commit_sha.to_s.strip.empty?
|
|
204
234
|
return fall_back(data, "no commit sha could be resolved (set SPECGUARD_COMMIT_SHA)")
|
|
@@ -317,17 +347,69 @@ module SpecGuard
|
|
|
317
347
|
|
|
318
348
|
# One JSON run per line, to whichever file this delivery's outcome
|
|
319
349
|
# warrants (see `#deliver` for which and why).
|
|
350
|
+
#
|
|
351
|
+
# Returns the exception the write died of, or `nil` when it landed,
|
|
352
|
+
# rather than warning itself. Both call sites need the outcome *in* their
|
|
353
|
+
# own sentence — the keyless branch has no preceding line and says so
|
|
354
|
+
# directly, the fall-back folds it into the delivery line it is already
|
|
355
|
+
# spending the run's one warning on — and a warning here would reach
|
|
356
|
+
# `warn_once` first on the fall-back path and spend that budget on the
|
|
357
|
+
# generic write message, losing the HTTP status the operator needs
|
|
358
|
+
# (SPGD-1413). `Interrupt` stays outside the clause, as everywhere else
|
|
359
|
+
# in this class.
|
|
320
360
|
def append(data, path)
|
|
321
361
|
FileUtils.mkdir_p(File.dirname(path))
|
|
322
362
|
File.open(path, "a") { |f| f.puts(JSON.generate(data)) }
|
|
363
|
+
nil
|
|
323
364
|
rescue ScriptError, StandardError => e
|
|
324
|
-
|
|
365
|
+
e
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The keyless branch's write, and its own warning. Nothing preceded it on
|
|
369
|
+
# this path, so the budget is free and the failure gets the whole line —
|
|
370
|
+
# naming the configured sink path, which is the one fact `#report`'s
|
|
371
|
+
# outer handler could not supply if this were left to raise.
|
|
372
|
+
def append_local(data)
|
|
373
|
+
path = @configuration.local_output_path
|
|
374
|
+
error = append(data, path)
|
|
375
|
+
return if error.nil?
|
|
376
|
+
|
|
377
|
+
warn_once("could not write telemetry to #{path} " \
|
|
378
|
+
"(#{error.class}: #{error.message}). The test run is unaffected.")
|
|
325
379
|
end
|
|
326
380
|
|
|
381
|
+
# The refused-delivery sink, and the one line the run is allowed to
|
|
382
|
+
# report it on.
|
|
383
|
+
#
|
|
384
|
+
# == The order, which SPGD-1400 fenced and SPGD-1413 kept
|
|
385
|
+
#
|
|
386
|
+
# `reason` is still what the one allotted line is spent on — the status
|
|
387
|
+
# and the platform's own words, the fact that tells an operator what to
|
|
388
|
+
# DO. That was the argument for warning before the write and it has not
|
|
389
|
+
# moved: nothing else may take this budget, which is why `#append` no
|
|
390
|
+
# longer warns for itself.
|
|
391
|
+
#
|
|
392
|
+
# What moved is the *sink clause*. It used to promise the replay queue
|
|
393
|
+
# before the write that fills it had been attempted, and was never
|
|
394
|
+
# corrected when that write failed: `warn_once` returns on `@warned`, so
|
|
395
|
+
# `#append`'s rescue fired into a no-op and a lost run printed "The test
|
|
396
|
+
# run is unaffected." The clause is now composed after the write, from
|
|
397
|
+
# `#append`'s answer, so it reports the queue rather than promising it.
|
|
398
|
+
# One line, same order of reasoning, and the promise is only made when it
|
|
399
|
+
# is true.
|
|
327
400
|
def fall_back(data, reason)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
401
|
+
path = @configuration.output_path
|
|
402
|
+
error = append(data, path)
|
|
403
|
+
warn_once("could not deliver test telemetry (#{reason}). #{sink_clause(path, error)}")
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Report, not promise — see `#fall_back` for why that is the whole of
|
|
407
|
+
# this slice, and why the order above it did not move.
|
|
408
|
+
def sink_clause(path, error)
|
|
409
|
+
return "Falling back to the replay queue. The test run is unaffected." if error.nil?
|
|
410
|
+
|
|
411
|
+
"The replay queue #{path} could not be written either " \
|
|
412
|
+
"(#{error.class}: #{error.message}), so this run's telemetry was lost."
|
|
331
413
|
end
|
|
332
414
|
|
|
333
415
|
# Once per process, like the formatter's `warn_once`: fifty failing
|
|
@@ -87,7 +87,12 @@ module SpecGuard
|
|
|
87
87
|
# nobody;
|
|
88
88
|
# * an `@intent:` whose payload could not be captured or parsed
|
|
89
89
|
# ({Finding#problem?} — `KIND_EXTRACTION` / `KIND_PARSE`);
|
|
90
|
-
# * a file that could not be read at all (`KIND_READ`)
|
|
90
|
+
# * a file that could not be read at all (`KIND_READ`) — read against
|
|
91
|
+
# the root bound at construction, never against wherever the cwd has
|
|
92
|
+
# since moved, so a readable, correctly annotated spec no longer turns
|
|
93
|
+
# unreadable just because the suite changed directory. What still
|
|
94
|
+
# lands here is a path that names nothing under that root: genuinely
|
|
95
|
+
# absent, or relative to some other root this run never bound;
|
|
91
96
|
# * a syntactically fine annotation the schema rejects;
|
|
92
97
|
# * the schema itself failing to load.
|
|
93
98
|
#
|
|
@@ -148,11 +153,38 @@ module SpecGuard
|
|
|
148
153
|
# path cannot drift apart.
|
|
149
154
|
EMPTY_INDEX = Index.new({}.freeze, {}.freeze).freeze
|
|
150
155
|
|
|
156
|
+
# The read root, bound here at construction rather than per read. Both
|
|
157
|
+
# clients build one lookup at suite start ({Formatter}'s and
|
|
158
|
+
# {Reporter}'s constructors default it in) and then hand it
|
|
159
|
+
# repo-relative paths, and a suite is free to change the working
|
|
160
|
+
# directory between those two moments. Resolving per read would open
|
|
161
|
+
# every relative spelling against wherever the cwd had since moved —
|
|
162
|
+
# a readable, correctly annotated spec file that no longer resolves
|
|
163
|
+
# from the new cwd answers {EMPTY_INDEX}, which is byte-for-byte what a
|
|
164
|
+
# genuinely unannotated example reports, so nothing downstream could
|
|
165
|
+
# detect the loss. Binding here keeps one run on one root — the same
|
|
166
|
+
# one-binding-per-run rule the reporter already applies to its own
|
|
167
|
+
# relativization, applied to the lookup's half of the pipeline. When
|
|
168
|
+
# the cwd never moves, resolution is the identity on every path the
|
|
169
|
+
# suite hands over.
|
|
170
|
+
#
|
|
171
|
+
# SPGD-1421 added the `root` parameter for the same one-binding rule one
|
|
172
|
+
# level up: the client binds its own relativization root at construction
|
|
173
|
+
# and hands that value here, so both halves of one run resolve against
|
|
174
|
+
# ONE root instead of each reading `Dir.pwd` at its own moment. Both
|
|
175
|
+
# Ruby clients hand that value over now — the Minitest reporter since
|
|
176
|
+
# SPGD-1421, the RSpec formatter since SPGD-1429. The default keeps
|
|
177
|
+
# this constructor's own SPGD-1417 binding — the caller's cwd, read at
|
|
178
|
+
# construction, never re-read per read.
|
|
179
|
+
#
|
|
151
180
|
# @param env [Hash, ENV] where `SPECGUARD_VALIDATE_INTENT` is read from.
|
|
152
181
|
# Injected for testing, and read LAZILY — see {#backend}.
|
|
153
|
-
|
|
182
|
+
# @param root [String] the directory relative spellings resolve against;
|
|
183
|
+
# bound here, no later than construction, whatever its source.
|
|
184
|
+
def initialize(env: ENV, root: Dir.pwd)
|
|
154
185
|
@env = env
|
|
155
186
|
@indexes = {}
|
|
187
|
+
@root = root
|
|
156
188
|
end
|
|
157
189
|
|
|
158
190
|
# The intent to attach to one example, or nil when it is unannotated.
|
|
@@ -165,7 +197,9 @@ module SpecGuard
|
|
|
165
197
|
# what keeps "warns once" from becoming "warns once but rescans the file
|
|
166
198
|
# for every one of the remaining examples".
|
|
167
199
|
#
|
|
168
|
-
# @param file [String, nil] the example's file, as the payload records it
|
|
200
|
+
# @param file [String, nil] the example's file, as the payload records it.
|
|
201
|
+
# A relative spelling is resolved against the root bound at
|
|
202
|
+
# construction — see {#initialize} — and never against the live cwd.
|
|
169
203
|
# @param line [Integer, nil] `example.metadata[:line_number]`
|
|
170
204
|
# @return [Hash, nil] the parsed, schema-valid annotation
|
|
171
205
|
def intent_for(file:, line:)
|
|
@@ -245,16 +279,43 @@ module SpecGuard
|
|
|
245
279
|
|
|
246
280
|
# @return [Index]
|
|
247
281
|
def build_index(file)
|
|
282
|
+
# One resolution for both legs: the local read below and the validator
|
|
283
|
+
# subprocess (#verdicts_for -> Runner#check) must answer about the SAME
|
|
284
|
+
# file, and the child inherits this process's working directory rather
|
|
285
|
+
# than being told one — so an unresolved relative spelling used to fail
|
|
286
|
+
# on both legs at once, which is why the degradation was total.
|
|
287
|
+
# Resolving before either leg runs gives both one path. The memo
|
|
288
|
+
# stays keyed by the caller's spelling; the resolution is
|
|
289
|
+
# deterministic per construction, so the two can never disagree.
|
|
290
|
+
resolved = resolve_path(file)
|
|
291
|
+
|
|
248
292
|
# The read happens on BOTH paths and first on both. The backend does not
|
|
249
293
|
# need it to find annotations — it reads the file itself — but the
|
|
250
294
|
# comment-form rule below is a property of the LINE an annotation sits
|
|
251
295
|
# on, which no report carries, so the text is required either way. Doing
|
|
252
296
|
# it first also keeps the two paths agreeing about an unreadable file
|
|
253
297
|
# without a subprocess being started to rediscover it.
|
|
254
|
-
text = read(
|
|
298
|
+
text = read(resolved)
|
|
255
299
|
return EMPTY_INDEX if text.nil?
|
|
256
300
|
|
|
257
|
-
index_from(verdicts_for(
|
|
301
|
+
index_from(verdicts_for(resolved, text), text)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# A caller-named path, made absolute against the root {#initialize} bound.
|
|
305
|
+
#
|
|
306
|
+
# Absolute spellings pass through untouched — they are what every existing
|
|
307
|
+
# caller hands over, and expanding them could only normalize a string the
|
|
308
|
+
# caller meant literally. A leading `~` passes through too:
|
|
309
|
+
# {File.expand_path} would resolve it against `$HOME` even with a root
|
|
310
|
+
# given, and a path that could not be read before resolution existed must
|
|
311
|
+
# keep reading as unreadable, not start reading somewhere new.
|
|
312
|
+
#
|
|
313
|
+
# @param file [String] a non-empty path, as the caller named it
|
|
314
|
+
# @return [String]
|
|
315
|
+
def resolve_path(file)
|
|
316
|
+
return file if file.start_with?(File::SEPARATOR, "~")
|
|
317
|
+
|
|
318
|
+
File.expand_path(file, @root)
|
|
258
319
|
end
|
|
259
320
|
|
|
260
321
|
# One shell-out per FILE, which is the cost model this class already
|
|
@@ -337,6 +398,10 @@ module SpecGuard
|
|
|
337
398
|
# class filters out, and nil here becomes {EMPTY_INDEX}. Either way, every
|
|
338
399
|
# example in a file that could not be read is unannotated.
|
|
339
400
|
#
|
|
401
|
+
# {#build_index} hands the path over already resolved — once for both
|
|
402
|
+
# this leg and the validator subprocess — so `file` here opens the same
|
|
403
|
+
# file whichever leg asks, wherever the cwd has since moved.
|
|
404
|
+
#
|
|
340
405
|
# @return [String, nil]
|
|
341
406
|
def read(file)
|
|
342
407
|
File.read(file, encoding: "UTF-8")
|
|
@@ -255,6 +255,24 @@ module SpecGuard
|
|
|
255
255
|
example_started example_passed example_failed example_pending dump_summary
|
|
256
256
|
].freeze
|
|
257
257
|
|
|
258
|
+
class << self
|
|
259
|
+
# `Dir.pwd` bound once — by the first formatter construction (whose
|
|
260
|
+
# `#initialize` reads it, SPGD-1429) or the first direct read — never
|
|
261
|
+
# per row: a suite that changes the working directory after the
|
|
262
|
+
# formatter was built would otherwise relativize one run's rows
|
|
263
|
+
# against two different roots. The memo is what binds it — an
|
|
264
|
+
# unmemoized `Dir.pwd` here re-reads the cwd on every read and is
|
|
265
|
+
# exactly the two-root split the constructor's comment names, with
|
|
266
|
+
# both spellings individually well-formed so nothing downstream can
|
|
267
|
+
# detect the drift. The memo is also the ONE value the default
|
|
268
|
+
# annotation lookup reads against: `#initialize` hands it over, so
|
|
269
|
+
# the relativization root and the lookup's read root cannot drift
|
|
270
|
+
# apart the way they did when each bound `Dir.pwd` at its own moment.
|
|
271
|
+
def repo_root
|
|
272
|
+
@repo_root ||= Dir.pwd
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
258
276
|
# @param output [IO] the stream RSpec hands every formatter. This class's
|
|
259
277
|
# product is a file, so nothing in the capture path writes here — but the
|
|
260
278
|
# stream is *not* unused, and a reader who is here because stdout went
|
|
@@ -287,11 +305,40 @@ module SpecGuard
|
|
|
287
305
|
# `SpecGuard::AnnotationLookup`, neither of which exists — a NameError
|
|
288
306
|
# raised from a formatter's constructor, which RSpec reports as
|
|
289
307
|
# "No examples found".
|
|
290
|
-
|
|
291
|
-
|
|
308
|
+
#
|
|
309
|
+
# Omitted or nil, the constructor builds the default lookup itself and
|
|
310
|
+
# hands it the one root bound below — the only construction production
|
|
311
|
+
# ever runs, and the arm that makes the hand-over observable.
|
|
312
|
+
def initialize(output = nil, error_stream: $stderr, annotations: nil)
|
|
292
313
|
super(output)
|
|
314
|
+
# ONE binding, ONE moment (SPGD-1429). This constructor used to bind two
|
|
315
|
+
# roots at two moments and nothing made them agree: the lookup's read
|
|
316
|
+
# root here, at construction, and rspec-core's relativization root —
|
|
317
|
+
# `Metadata.relative_path_regex` memoizes `Dir.pwd` on its first READ —
|
|
318
|
+
# on the first captured row, after the spec files have loaded. A suite
|
|
319
|
+
# whose cwd moves to a SHALLOWER ancestor in between relativized its
|
|
320
|
+
# rows against the moved-to directory and resolved those spellings
|
|
321
|
+
# against the construction one — `<root>/work/sample_spec.rb` read as
|
|
322
|
+
# `work/sample_spec.rb` under construction root `<root>/work` resolves
|
|
323
|
+
# into the doubled `<root>/work/work/...`, which no file answers — so
|
|
324
|
+
# every readable, correctly annotated spec in the run shipped
|
|
325
|
+
# `unannotated` with nothing downstream able to tell. The same
|
|
326
|
+
# two-root split `SPGD-1421` closed on the Minitest reporter, whose
|
|
327
|
+
# `reporter.rb` comment is the reference for this repair. Both halves
|
|
328
|
+
# now read one value, bound at construction — the earlier of the two
|
|
329
|
+
# moments, and the one `SPGD-1417` already gave the lookup.
|
|
330
|
+
#
|
|
331
|
+
# Reading the memo is load-bearing even when a lookup is injected (the
|
|
332
|
+
# specs do): `#relative_path` delegates to rspec-core either way, so
|
|
333
|
+
# pinning the regex here is what stops any row from binding it somewhere
|
|
334
|
+
# the lookup is not. The reader binds only when unbound — when something
|
|
335
|
+
# has already bound it, the read is a no-op and rspec-core's own value
|
|
336
|
+
# is left alone: its memoization is correct for what it does, and
|
|
337
|
+
# overwriting a bound value would second-guess it.
|
|
338
|
+
root = self.class.repo_root
|
|
339
|
+
::RSpec::Core::Metadata.relative_path_regex
|
|
293
340
|
@error_stream = error_stream
|
|
294
|
-
@annotations = annotations
|
|
341
|
+
@annotations = annotations || SpecGuard::RSpec::AnnotationLookup.new(root: root)
|
|
295
342
|
@specs = []
|
|
296
343
|
@warned = false
|
|
297
344
|
# Stamped here rather than from a `start` hook on purpose. `:start` is not
|
|
@@ -666,9 +713,15 @@ module SpecGuard
|
|
|
666
713
|
# Sink selection, and the fallback that keeps a failed delivery from being
|
|
667
714
|
# a silent one.
|
|
668
715
|
#
|
|
669
|
-
# Called from inside {#never_fail_the_run}, so
|
|
670
|
-
#
|
|
671
|
-
#
|
|
716
|
+
# Called from inside {#never_fail_the_run}, so a raise below it cannot
|
|
717
|
+
# fail the run — but neither arm leaves its failure to that generic
|
|
718
|
+
# envelope, because each spends the run's one warning on a line that has
|
|
719
|
+
# to say what actually happened. {#append} catches the sink's failure
|
|
720
|
+
# modes itself and hands the outcome back, so the one line {#fall_back} is
|
|
721
|
+
# spending can report whether the queue took the run rather than promise
|
|
722
|
+
# it (SPGD-1413); the keyless {#append_local} does the same and names the
|
|
723
|
+
# configured sink path in the line it emits, the way the Minitest twin
|
|
724
|
+
# does (SPGD-1400). Nothing raises out of either way.
|
|
672
725
|
#
|
|
673
726
|
# == Why a failure writes the file rather than shrugging
|
|
674
727
|
#
|
|
@@ -954,12 +1007,27 @@ module SpecGuard
|
|
|
954
1007
|
count == 1 ? noun : "#{noun}s"
|
|
955
1008
|
end
|
|
956
1009
|
|
|
1010
|
+
# The delivery failed; this is the second sink and the one line the run is
|
|
1011
|
+
# allowed to say so on.
|
|
1012
|
+
#
|
|
1013
|
+
# == The order, which SPGD-1400 fenced and SPGD-1413 kept
|
|
1014
|
+
#
|
|
1015
|
+
# `reason` is still the first thing settled and still the fact the one
|
|
1016
|
+
# allotted line is spent on: a 401 and a 400 call for entirely different
|
|
1017
|
+
# actions, and no amount of detail about a file substitutes for the status.
|
|
1018
|
+
# That was the argument for warning before the write, and it is unchanged.
|
|
1019
|
+
#
|
|
1020
|
+
# What moved is only the *sink clause*. It used to be a promise — "falling
|
|
1021
|
+
# back to <path>; the test run is unaffected" — emitted before the write it
|
|
1022
|
+
# described, and never corrected when that write failed: `emit_warning`
|
|
1023
|
+
# returns on `@warned`, so the second failure had no budget left to report
|
|
1024
|
+
# itself and a lost run printed a line saying it was safe. The clause is now
|
|
1025
|
+
# composed *after* the write, from {#append}'s own answer, so it reports the
|
|
1026
|
+
# queue rather than promising it. One line, same order of reasoning, and the
|
|
1027
|
+
# promise is only made when it is true.
|
|
957
1028
|
def fall_back(data, reason)
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
# more specific message, which is the one naming the status code.
|
|
961
|
-
warn_delivery_failure(reason)
|
|
962
|
-
append(data)
|
|
1029
|
+
path = SpecGuard::RSpec.configuration.output_path
|
|
1030
|
+
warn_delivery_failure(reason, path: path, error: append(data, path))
|
|
963
1031
|
end
|
|
964
1032
|
|
|
965
1033
|
def transport_for(configuration)
|
|
@@ -1033,20 +1101,49 @@ module SpecGuard
|
|
|
1033
1101
|
(::RSpec::Core::Metadata.relative_path(string) || string).sub(%r{\A\./}, "")
|
|
1034
1102
|
end
|
|
1035
1103
|
|
|
1036
|
-
# Appends one line. Opened in append mode and written
|
|
1037
|
-
# that two suites sharing an output path (parallel CI
|
|
1038
|
-
# whole runs rather than halves of one.
|
|
1039
|
-
|
|
1040
|
-
|
|
1104
|
+
# Appends one line to the replay queue. Opened in append mode and written
|
|
1105
|
+
# with a single call so that two suites sharing an output path (parallel CI
|
|
1106
|
+
# shards, say) interleave whole runs rather than halves of one.
|
|
1107
|
+
#
|
|
1108
|
+
# `path` is passed in rather than read here, so the line {#fall_back}
|
|
1109
|
+
# composes and the file it describes are provably the same string — a
|
|
1110
|
+
# second read of a process-wide singleton could answer differently and name
|
|
1111
|
+
# a path nothing was written to.
|
|
1112
|
+
#
|
|
1113
|
+
# Returns the exception the write died of, or `nil` when it landed — the
|
|
1114
|
+
# fall-back's one line is composed from that answer (see {#fall_back}), and
|
|
1115
|
+
# a raise here could not be, because `close`'s {#never_fail_the_run} would
|
|
1116
|
+
# only reach a warning budget the delivery message has already spent.
|
|
1117
|
+
# `Interrupt` is deliberately outside the clause, as everywhere else in this
|
|
1118
|
+
# file: Ctrl-C must stay Ctrl-C.
|
|
1119
|
+
def append(data, path)
|
|
1041
1120
|
append_to(data, path)
|
|
1121
|
+
nil
|
|
1122
|
+
rescue ScriptError, StandardError => e
|
|
1123
|
+
e
|
|
1042
1124
|
end
|
|
1043
1125
|
|
|
1044
1126
|
# The keyless branch's sink: `local_output_path`, a local development
|
|
1045
1127
|
# record deliberately kept apart from {#append}'s replay queue so that
|
|
1046
1128
|
# `specguard-ingest log/test_results.jsonl` re-delivers only genuine
|
|
1047
1129
|
# failed deliveries. See {Configuration#local_output_path}.
|
|
1130
|
+
#
|
|
1131
|
+
# Its failure is its own line rather than {#never_fail_the_run}'s generic
|
|
1132
|
+
# substitute: the path is knowable here — read once into a local, for the
|
|
1133
|
+
# same reason {#append} takes one — and the Minitest twin names it on the
|
|
1134
|
+
# same input (SPGD-1400), as does this file's delivery arm through
|
|
1135
|
+
# {#sink_clause} (SPGD-1413). The write goes through {#append} so the
|
|
1136
|
+
# exception comes back as a value to compose the line from; left to raise,
|
|
1137
|
+
# it would fall through to {#never_fail_the_run} and spend the run's one
|
|
1138
|
+
# warning on the one sentence this exists to replace. `Interrupt` stays
|
|
1139
|
+
# outside, as everywhere else in this file.
|
|
1048
1140
|
def append_local(data)
|
|
1049
|
-
|
|
1141
|
+
path = SpecGuard::RSpec.configuration.local_output_path
|
|
1142
|
+
error = append(data, path)
|
|
1143
|
+
return if error.nil?
|
|
1144
|
+
|
|
1145
|
+
emit_warning("SpecGuard: could not write telemetry to #{path} " \
|
|
1146
|
+
"(#{error.class}: #{error.message}). The test run is unaffected.")
|
|
1050
1147
|
end
|
|
1051
1148
|
|
|
1052
1149
|
def append_to(data, path)
|
|
@@ -1079,11 +1176,30 @@ module SpecGuard
|
|
|
1079
1176
|
# status when there was one — a 400 and a 401 call for entirely different
|
|
1080
1177
|
# actions, and a warning that only said "delivery failed" would leave the
|
|
1081
1178
|
# reader unable to tell which.
|
|
1082
|
-
|
|
1083
|
-
|
|
1179
|
+
#
|
|
1180
|
+
# `error` is {#append}'s answer, and it decides the line's second clause
|
|
1181
|
+
# only. `nil` means the queue took the run, and the sentence is exactly the
|
|
1182
|
+
# one this formatter has always printed. Anything else means both sinks are
|
|
1183
|
+
# gone: the run really is lost, and the line says so rather than promising a
|
|
1184
|
+
# file that is not there (SPGD-1413). The status clause is identical either
|
|
1185
|
+
# way, because which of the two happened does not change what refused the
|
|
1186
|
+
# delivery.
|
|
1187
|
+
#
|
|
1188
|
+
# Both are required rather than defaulted. There is one call site, and a
|
|
1189
|
+
# default would be a branch no input could reach — the same objection
|
|
1190
|
+
# {#annotated_percentage} states about an unreachable zero-guard.
|
|
1191
|
+
def warn_delivery_failure(reason, path:, error:)
|
|
1192
|
+
emit_warning("#{DELIVERY_WARNING_PREFIX} (#{reason}). #{sink_clause(path, error)}")
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
# Report, not promise. Composed after the write so it can tell the operator
|
|
1196
|
+
# which of the two things actually happened — see {#fall_back} for why that
|
|
1197
|
+
# is the whole of this slice, and why the order above it did not move.
|
|
1198
|
+
def sink_clause(path, error)
|
|
1199
|
+
return "Falling back to #{path}; the test run is unaffected." if error.nil?
|
|
1084
1200
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1201
|
+
"The replay queue #{path} could not be written either " \
|
|
1202
|
+
"(#{error.class}: #{error.message}), so this run's telemetry was lost."
|
|
1087
1203
|
end
|
|
1088
1204
|
|
|
1089
1205
|
def emit_warning(line)
|
|
@@ -162,6 +162,43 @@ module SpecGuard
|
|
|
162
162
|
# convention and the shell's; this file adopts it on purpose and pins it in
|
|
163
163
|
# the spec, so it is a decision rather than a default nobody looked at.
|
|
164
164
|
#
|
|
165
|
+
# == `--drain`, the follow-through, and why it removes only what it saw
|
|
166
|
+
#
|
|
167
|
+
# A successful replay used to leave the queue byte-identical: this file had
|
|
168
|
+
# no write, rename or truncate path, so the next incident's failures
|
|
169
|
+
# appended behind runs that had already landed, and the README's retry
|
|
170
|
+
# gesture — re-running the command — re-sent every one of them. A line with
|
|
171
|
+
# a `ci_run_id` folds onto the run it already made, so re-sending it is
|
|
172
|
+
# harmless; a line without one has nothing to fold onto and becomes a
|
|
173
|
+
# second row on the platform. `--drain` is the opt-in answer: after the
|
|
174
|
+
# deliveries, exactly the lines answered 202 **in this invocation** are
|
|
175
|
+
# removed from the file.
|
|
176
|
+
#
|
|
177
|
+
# Only those. The removal is keyed to what was *observed*, never to what
|
|
178
|
+
# was inferred — the same line this file draws against guessing which lines
|
|
179
|
+
# were failures. A refused line is refused every time it is offered, an
|
|
180
|
+
# undelivered one never arrived, an unparseable one was never a run, a
|
|
181
|
+
# blank one was never anything, and a line a selector held back was never
|
|
182
|
+
# sent — so all of them stay, byte for byte, in the file's order. The queue
|
|
183
|
+
# is failure-only by construction, which is what makes removing accepted
|
|
184
|
+
# lines coherent here in a way it would not be on the mixed local record:
|
|
185
|
+
# draining a file of ordinary laptop runs would delete runs that were
|
|
186
|
+
# never failures. That is the second guard — the drain refuses any path
|
|
187
|
+
# that is not the configured replay queue, compared exactly as
|
|
188
|
+
# {#no_such_file_message} compares, and it refuses `--list` too, since a
|
|
189
|
+
# listing delivers nothing for it to drain.
|
|
190
|
+
#
|
|
191
|
+
# The rewrite is atomic: a temporary file in the same directory, renamed
|
|
192
|
+
# over the original, so a failure mid-drain leaves the file as it was —
|
|
193
|
+
# and the file is not touched at all unless something was accepted. Bytes
|
|
194
|
+
# appended while the deliveries ran (the formatter appends to the queue
|
|
195
|
+
# with no lock) are carried into the rewrite; the window that remains
|
|
196
|
+
# between the final read and the rename is disclosed at {#drain_source},
|
|
197
|
+
# not claimed closed. A drain that cannot complete is stated, never
|
|
198
|
+
# silent: the deliveries are still reported in full, the warning names the
|
|
199
|
+
# file, and the run exits 2, because a 0 would read as "drained" about a
|
|
200
|
+
# queue that was not.
|
|
201
|
+
#
|
|
165
202
|
# == `--list`, which is what makes "check the file first" an instruction
|
|
166
203
|
#
|
|
167
204
|
# Having refused to guess *for* the user, this command owes them what they
|
|
@@ -218,20 +255,26 @@ module SpecGuard
|
|
|
218
255
|
BANNER = "Usage: specguard-ingest [options] <file>"
|
|
219
256
|
|
|
220
257
|
# Shown by `--help`. The second paragraph is the one that has to be there:
|
|
221
|
-
# the
|
|
222
|
-
#
|
|
258
|
+
# the local record — and any replay-queue file from before the sink split
|
|
259
|
+
# — mixes failed deliveries with ordinary keyless local runs and nothing
|
|
260
|
+
# on the line tells them apart, so a developer must not be able to
|
|
223
261
|
# discover only afterwards that they pushed their laptop's history.
|
|
224
262
|
DESCRIPTION = <<~TEXT.freeze
|
|
225
|
-
Re-delivers a saved run to SpecGuard's ingest endpoint. <file> is a
|
|
226
|
-
|
|
227
|
-
|
|
263
|
+
Re-delivers a saved run to SpecGuard's ingest endpoint. <file> is a file
|
|
264
|
+
the RSpec formatter wrote — one whole run per line, byte-for-byte the
|
|
265
|
+
body the endpoint was offered. The formatter writes failed deliveries
|
|
266
|
+
to the replay queue, log/test_results.jsonl, and — when no API key is
|
|
267
|
+
configured — ordinary local runs to the local development record,
|
|
268
|
+
log/test_results.local.jsonl.
|
|
228
269
|
|
|
229
270
|
EVERY line in <file> is delivered — or, when you narrow it, every line
|
|
230
|
-
--from-line or --lines names. The
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
271
|
+
--from-line or --lines names. The two files were split precisely so a
|
|
272
|
+
log/test_results.jsonl written entirely by this version or later could
|
|
273
|
+
hold only genuine failed deliveries — by construction. But the local
|
|
274
|
+
record, and any file written before the split, carries no marker: a
|
|
275
|
+
laptop's file is a file of ordinary local runs mixed with genuine
|
|
276
|
+
failures, indistinguishable on the line, and all of them will be sent.
|
|
277
|
+
Nothing is filtered and nothing is guessed at.
|
|
235
278
|
|
|
236
279
|
So check the file first: --list prints one row per line — branch, commit,
|
|
237
280
|
ci_run_id or its absence, how many examples, how long — and delivers
|
|
@@ -246,6 +289,22 @@ module SpecGuard
|
|
|
246
289
|
that same numbering, instead of re-sending all of it. Both narrow the
|
|
247
290
|
same file, so give one or the other and never both.
|
|
248
291
|
|
|
292
|
+
--drain is the follow-through, and it is opt-in: after the deliveries,
|
|
293
|
+
the lines this invocation got a 202 for are removed from <file>, so the
|
|
294
|
+
next incident's failures do not land behind runs that already landed.
|
|
295
|
+
Everything else stays byte for byte and in order — refused, undelivered,
|
|
296
|
+
unparseable and blank lines, and every line --from-line or --lines held
|
|
297
|
+
back. Removing lines renumbers what is left: the report's line numbers
|
|
298
|
+
describe <file> as it was read, not as the next run finds it, so resume
|
|
299
|
+
by re-running --list (or --drain) rather than reusing those numbers.
|
|
300
|
+
The rewrite is atomic: a temporary file in the same directory,
|
|
301
|
+
renamed over the original, so a failure mid-drain leaves the file as it
|
|
302
|
+
was, and bytes appended while the deliveries ran are carried into the
|
|
303
|
+
rewrite. Only the replay queue is drained — another path is refused,
|
|
304
|
+
because the local record is a development record, not a queue — and
|
|
305
|
+
--drain with --list is refused too, since a listing delivers nothing
|
|
306
|
+
for it to drain.
|
|
307
|
+
|
|
249
308
|
Reads SPECGUARD_ENDPOINT, SPECGUARD_API_KEY and SPECGUARD_TIMEOUT.
|
|
250
309
|
|
|
251
310
|
--json replaces the human report with one JSON document on stdout, in
|
|
@@ -264,10 +323,11 @@ module SpecGuard
|
|
|
264
323
|
an unreadable file, an unparseable line, a delivery that never
|
|
265
324
|
reached the endpoint, or one the endpoint answered without ever
|
|
266
325
|
reading it (401, 404, 429, 5xx — nothing was stored, so none of
|
|
267
|
-
them is a verdict about your run). With --
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
326
|
+
them is a verdict about your run). With --drain, a rewrite that
|
|
327
|
+
could not complete is a 2 as well — the file is left as it was.
|
|
328
|
+
With --list the only reachable 2s are a bad flag and a file that
|
|
329
|
+
cannot be read: listing needs no credentials, and an unparseable
|
|
330
|
+
line becomes a row in the listing rather than an exit code
|
|
271
331
|
TEXT
|
|
272
332
|
|
|
273
333
|
# Every line in the file was accepted by the endpoint — including the
|
|
@@ -360,8 +420,36 @@ module SpecGuard
|
|
|
360
420
|
# selector was fully satisfied, and empty under `--from-line`, whose
|
|
361
421
|
# past-the-end case is already a suffix that selected nothing.
|
|
362
422
|
#
|
|
423
|
+
# `raw` and `read_bytes` are the drain's inputs, captured by
|
|
424
|
+
# {#read_source} and by nothing else: the file's exact bytes as of the
|
|
425
|
+
# read, and that read's length in bytes. The rebuild keeps `raw`'s lines
|
|
426
|
+
# minus the accepted numbers — which is what makes "byte for byte" a
|
|
427
|
+
# property of the rewrite rather than a hope — and `read_bytes` is where
|
|
428
|
+
# the tail starts: anything appended past it while the deliveries ran is
|
|
429
|
+
# carried into the rewrite verbatim. They are members of {Source} rather
|
|
430
|
+
# than a second read inside {#drain_source} because the length that
|
|
431
|
+
# matters is the one the numbered lines were counted against; a fresh
|
|
432
|
+
# read at drain time would answer a different read.
|
|
433
|
+
#
|
|
363
434
|
# @return [Array<String>] `absent`, each entry a number or an `N-M` range
|
|
364
|
-
Source = Struct.new(:path, :lines, :blank, :skipped, :absent, :selector,
|
|
435
|
+
Source = Struct.new(:path, :lines, :blank, :skipped, :absent, :selector,
|
|
436
|
+
:raw, :read_bytes, keyword_init: true)
|
|
437
|
+
|
|
438
|
+
# What `--drain` did, decided once in {#drain_source} and rendered by
|
|
439
|
+
# both renderers — the same one-fact-two-renderings discipline the status
|
|
440
|
+
# counts and the folding groups are held to. `removed` is the count of
|
|
441
|
+
# lines taken out of the file (0 when nothing was accepted, so no rewrite
|
|
442
|
+
# happened at all), `remaining` is the count of lines the rewrite LEFT in
|
|
443
|
+
# the file — `kept + appended`, counted as lines (blank lines and the
|
|
444
|
+
# carried tail included), so a renderer can tell a queue that still holds
|
|
445
|
+
# work from one the drain emptied. `remaining` is meaningful only where a
|
|
446
|
+
# rewrite actually happened: on the no-accept and failed paths the file
|
|
447
|
+
# did not move, so it stays `nil`. `failed` is a rewrite that could not
|
|
448
|
+
# complete: the file was left as it was, the warning is already on
|
|
449
|
+
# stderr, and the exit code is a 2, because a 0 would read as "drained"
|
|
450
|
+
# about a queue that was not. `nil` — no {Drain} at all — is the flag's
|
|
451
|
+
# absence, and both renderers render nothing for it.
|
|
452
|
+
Drain = Struct.new(:removed, :remaining, :failed, keyword_init: true)
|
|
365
453
|
|
|
366
454
|
# What the command line asked for. A struct rather than a bare path,
|
|
367
455
|
# because `--from-line` is the second half of the same question — which
|
|
@@ -371,12 +459,15 @@ module SpecGuard
|
|
|
371
459
|
# starting point, and the two are mutually exclusive (see the class
|
|
372
460
|
# comment). `list` is the third half: whether those lines are to be
|
|
373
461
|
# *shown* or *sent*. `json` is orthogonal to all three — it chooses the
|
|
374
|
-
# renderer, never the set and never the verdict.
|
|
462
|
+
# renderer, never the set and never the verdict. `drain` is the one
|
|
463
|
+
# member about what happens *after* the sending: whether the lines this
|
|
464
|
+
# invocation got a 202 for are to be removed from <file> once the
|
|
465
|
+
# deliveries are done.
|
|
375
466
|
#
|
|
376
467
|
# `line_set` is an Array of Ranges rather than an expanded Array of
|
|
377
468
|
# Integers, so `--lines 1-90000000` costs nothing to hold. `nil` means the
|
|
378
469
|
# flag was not given and `from_line` is the selector.
|
|
379
|
-
Options = Struct.new(:path, :from_line, :list, :line_set, :json, keyword_init: true)
|
|
470
|
+
Options = Struct.new(:path, :from_line, :list, :line_set, :json, :drain, keyword_init: true)
|
|
380
471
|
|
|
381
472
|
# One entry of a `--lines` spec: `12` or `12-15`, and nothing else. No
|
|
382
473
|
# sign, no open end, no whitespace inside — {#parse_line_set} strips each
|
|
@@ -425,9 +516,14 @@ module SpecGuard
|
|
|
425
516
|
|
|
426
517
|
source = read_source(options)
|
|
427
518
|
results = source.lines.map { |number, text| deliver_line(number, text, transport) }
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
519
|
+
# After the deliveries, before the report — the only position where the
|
|
520
|
+
# summary can state what the drain removed. `drained` is nil without
|
|
521
|
+
# the flag, and both renderers render nothing for it, which is the
|
|
522
|
+
# whole of the flag being opt-in.
|
|
523
|
+
drained = options.drain ? drain_source(source, results) : nil
|
|
524
|
+
|
|
525
|
+
report(source, results, json: options.json, drained: drained)
|
|
526
|
+
exit_code(results, drained: drained)
|
|
431
527
|
rescue Errno::EPIPE
|
|
432
528
|
# The report's reader stopped listening — `| head`, a quitting pager,
|
|
433
529
|
# a CI log tailer. The deliveries happened and the per-line verdicts
|
|
@@ -436,7 +532,7 @@ module SpecGuard
|
|
|
436
532
|
# nil on the `--list` path, which builds none, and when the pipe
|
|
437
533
|
# closed before any delivery was made; then {EXIT_OK} stands. See
|
|
438
534
|
# the class comment.
|
|
439
|
-
results ? exit_code(results) : EXIT_OK
|
|
535
|
+
results ? exit_code(results, drained: drained) : EXIT_OK
|
|
440
536
|
rescue UsageError => e
|
|
441
537
|
@stderr.puts "specguard-ingest: error: #{e.message}"
|
|
442
538
|
EXIT_MISUSE
|
|
@@ -455,7 +551,14 @@ module SpecGuard
|
|
|
455
551
|
# reached the endpoint leaves the job unfinished, and reporting that as
|
|
456
552
|
# "your content was refused" is the exact confusion the contract exists to
|
|
457
553
|
# prevent.
|
|
458
|
-
|
|
554
|
+
#
|
|
555
|
+
# `drained` joins the dominance list ahead of the content verdicts, on
|
|
556
|
+
# the same grounds: a drain that could not complete left the queue
|
|
557
|
+
# holding everything it held before, accepted lines included, and
|
|
558
|
+
# reporting that as "your content was refused" (or as a clean 0) would
|
|
559
|
+
# both be the wrong shout.
|
|
560
|
+
def exit_code(results, drained: nil)
|
|
561
|
+
return EXIT_MISUSE if drained&.failed
|
|
459
562
|
return EXIT_MISUSE if results.any? { |result| %i[undelivered unparseable].include?(result.status) }
|
|
460
563
|
return EXIT_REFUSED if results.any? { |result| result.status == :refused }
|
|
461
564
|
|
|
@@ -662,7 +765,7 @@ module SpecGuard
|
|
|
662
765
|
# from, which is not a verdict about anybody's run.
|
|
663
766
|
def read_source(options)
|
|
664
767
|
path = options.path
|
|
665
|
-
raise UsageError,
|
|
768
|
+
raise UsageError, no_such_file_message(path) unless File.exist?(path)
|
|
666
769
|
raise UsageError, "not a file: #{path}" unless File.file?(path)
|
|
667
770
|
|
|
668
771
|
lines = []
|
|
@@ -700,13 +803,50 @@ module SpecGuard
|
|
|
700
803
|
end
|
|
701
804
|
end
|
|
702
805
|
|
|
806
|
+
# The drain's capture, taken AFTER the line walk and for that reason:
|
|
807
|
+
# read first, length second means `read_bytes` covers everything the
|
|
808
|
+
# numbered lines were counted against, even where an append landed
|
|
809
|
+
# between the two reads — such bytes are in `raw` (as extra tail lines
|
|
810
|
+
# numbered past the delivered set, kept by any rewrite) and past
|
|
811
|
+
# `read_bytes` is genuinely only what arrived after this method
|
|
812
|
+
# returned. Reading the bytes first would invert that: a line appended
|
|
813
|
+
# in the gap could then be delivered AND carried back by the tail.
|
|
814
|
+
#
|
|
815
|
+
# Binary on purpose — this is the file's bytes, not its characters.
|
|
816
|
+
# The default path never looks at either member; the whole capture is
|
|
817
|
+
# the drain's, and its cost on the default path is one read.
|
|
818
|
+
raw = File.binread(path)
|
|
819
|
+
|
|
703
820
|
Source.new(path: path, lines: lines, blank: blank, skipped: skipped,
|
|
704
821
|
absent: absent_entries(options, length),
|
|
705
|
-
selector: options.line_set ? :line_set : :from_line
|
|
822
|
+
selector: options.line_set ? :line_set : :from_line,
|
|
823
|
+
raw: raw, read_bytes: raw.bytesize)
|
|
706
824
|
rescue SystemCallError, IOError => e
|
|
707
825
|
raise UsageError, "could not read #{path}: #{e.message}"
|
|
708
826
|
end
|
|
709
827
|
|
|
828
|
+
# The `no such file` refusal, with one conditional clause: when the
|
|
829
|
+
# missing path IS the configured replay queue and the configured local
|
|
830
|
+
# record exists, name the record. A keyless developer is pointed at
|
|
831
|
+
# `log/test_results.jsonl` by the help while their run wrote the other
|
|
832
|
+
# file, and this is the one moment the tool can say so. The guard is the
|
|
833
|
+
# conjunction — equality, not "the path looks like a queue", and an
|
|
834
|
+
# existence check, not "the record is merely configured" — so an ordinary
|
|
835
|
+
# typo keeps the plain message byte-for-byte, and the clause cannot fire
|
|
836
|
+
# on a relative default that happens to resolve against the working
|
|
837
|
+
# directory.
|
|
838
|
+
def no_such_file_message(path)
|
|
839
|
+
configuration = Configuration.new(env: @env)
|
|
840
|
+
local = configuration.local_output_path
|
|
841
|
+
if path == configuration.output_path && File.exist?(local)
|
|
842
|
+
"no such file: #{path} — the replay queue was never written, but the " \
|
|
843
|
+
"local record #{local} does exist (what the formatter writes when " \
|
|
844
|
+
"no API key is configured)"
|
|
845
|
+
else
|
|
846
|
+
"no such file: #{path}"
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
|
|
710
850
|
# The whole of the selection, and the only place it is decided. `--lines`
|
|
711
851
|
# is an explicit set, so a line it does not name is held back wherever in
|
|
712
852
|
# the file it sits; `--from-line` is a suffix, so only the lines before it
|
|
@@ -765,7 +905,7 @@ module SpecGuard
|
|
|
765
905
|
# renderers can disagree about how much of a file it delivered is worse
|
|
766
906
|
# than one that only prints prose: the disagreement is unfalsifiable from
|
|
767
907
|
# outside the process.
|
|
768
|
-
def report(source, results, json:)
|
|
908
|
+
def report(source, results, json:, drained: nil)
|
|
769
909
|
if results.empty?
|
|
770
910
|
@stderr.puts "specguard-ingest: warning: #{source.path} holds no runs to deliver#{empty_detail(source)}"
|
|
771
911
|
return unless json
|
|
@@ -776,12 +916,12 @@ module SpecGuard
|
|
|
776
916
|
|
|
777
917
|
if json
|
|
778
918
|
@stdout.puts IngestReporter.render_delivery(source: source, results: results, counts: counts,
|
|
779
|
-
foldings: foldings)
|
|
919
|
+
foldings: foldings, drained: drained)
|
|
780
920
|
return
|
|
781
921
|
end
|
|
782
922
|
|
|
783
923
|
results.each { |result| @stdout.puts line_report(result) }
|
|
784
|
-
@stdout.puts summary_line(source, results, counts)
|
|
924
|
+
@stdout.puts summary_line(source, results, counts, drained)
|
|
785
925
|
foldings.each { |folding| @stdout.puts folding_observation(folding) }
|
|
786
926
|
end
|
|
787
927
|
|
|
@@ -866,7 +1006,7 @@ module SpecGuard
|
|
|
866
1006
|
# accepted count is over the total rather than on its own, and every other
|
|
867
1007
|
# outcome gets a clause of its own instead of being folded into a
|
|
868
1008
|
# remainder the reader has to compute.
|
|
869
|
-
def summary_line(source, results, counts)
|
|
1009
|
+
def summary_line(source, results, counts, drained)
|
|
870
1010
|
parts = ["specguard-ingest: delivered #{counts.fetch(:accepted, 0)} of " \
|
|
871
1011
|
"#{results.length} run#{'s' unless results.length == 1} from #{source.path}"]
|
|
872
1012
|
|
|
@@ -876,10 +1016,36 @@ module SpecGuard
|
|
|
876
1016
|
parts << blank_clause(source) if source.blank.positive?
|
|
877
1017
|
parts << skipped_clause(source) if source.skipped.positive?
|
|
878
1018
|
parts << absent_clause(source) if source.absent.any?
|
|
1019
|
+
parts << drain_clause(source, drained) if drained&.removed&.positive?
|
|
879
1020
|
|
|
880
1021
|
parts.join("; ")
|
|
881
1022
|
end
|
|
882
1023
|
|
|
1024
|
+
# The drain's clause, present exactly when lines were removed and never
|
|
1025
|
+
# otherwise — the summary's established shape, where a clause names a
|
|
1026
|
+
# fact that is positive rather than padding the line with zeroes. The
|
|
1027
|
+
# `removed: 0` of a rewrite that did not happen (nothing was accepted)
|
|
1028
|
+
# needs no clause: the accepted count in the first clause already says
|
|
1029
|
+
# so. The `removed: 0` of a rewrite that FAILED is carried by the stderr
|
|
1030
|
+
# warning and by the exit code, both louder than a clause would be.
|
|
1031
|
+
#
|
|
1032
|
+
# The clause's second half states the consequence the removal has for
|
|
1033
|
+
# the numbers this very report just printed: a rewrite that leaves any
|
|
1034
|
+
# line in the file renumbers them, because the survivors are packed up
|
|
1035
|
+
# from the top — so the numbers above describe the file as it was READ,
|
|
1036
|
+
# not as the next invocation will find it, and must not be reused to
|
|
1037
|
+
# address it. A drain that emptied the file says nothing extra: there is
|
|
1038
|
+
# no number left for the sentence to be about.
|
|
1039
|
+
def drain_clause(source, drained)
|
|
1040
|
+
clause = +"#{drained.removed} accepted line#{'s' unless drained.removed == 1} removed from #{source.path}"
|
|
1041
|
+
return clause unless drained.remaining&.positive?
|
|
1042
|
+
|
|
1043
|
+
left = drained.remaining
|
|
1044
|
+
clause << " — the #{left} line#{'s' unless left == 1} left " \
|
|
1045
|
+
"#{left == 1 ? 'is' : 'are'} now numbered from 1, so the numbers above " \
|
|
1046
|
+
"no longer address #{left == 1 ? 'it' : 'them'}"
|
|
1047
|
+
end
|
|
1048
|
+
|
|
883
1049
|
# Folding, stated only where it was *seen*.
|
|
884
1050
|
#
|
|
885
1051
|
# The proposal for this tool asked it to say whether a replayed line
|
|
@@ -911,6 +1077,96 @@ module SpecGuard
|
|
|
911
1077
|
"came back with test_run_id #{folding.test_run_id} — the endpoint folded them onto one run"
|
|
912
1078
|
end
|
|
913
1079
|
|
|
1080
|
+
# --drain: remove from <file> exactly the lines this invocation got a 202
|
|
1081
|
+
# for, atomically, carrying anything appended while the deliveries ran.
|
|
1082
|
+
#
|
|
1083
|
+
# == What is removed, and what is not
|
|
1084
|
+
#
|
|
1085
|
+
# Only `:accepted` results, by their own file-line numbers — the removal
|
|
1086
|
+
# is keyed to what the endpoint answered this run, never to a guess about
|
|
1087
|
+
# which lines were failures. Everything else {Source#raw} holds is kept:
|
|
1088
|
+
# refused, undelivered and unparseable lines, the blank ones, and every
|
|
1089
|
+
# line a selector held back — each byte for byte, in the file's order.
|
|
1090
|
+
#
|
|
1091
|
+
# == The tail, carried
|
|
1092
|
+
#
|
|
1093
|
+
# The formatter appends to the queue with no lock, so bytes can land
|
|
1094
|
+
# after {#read_source} and before this rewrite. Those bytes are the
|
|
1095
|
+
# tail: everything in the file NOW past the length read then, carried
|
|
1096
|
+
# into the rewrite verbatim. They were never delivered, so the accepted
|
|
1097
|
+
# set can never name them — carrying them is what keeps a concurrent
|
|
1098
|
+
# append from being destroyed by the very run that emptied the queue.
|
|
1099
|
+
#
|
|
1100
|
+
# == The residual race, disclosed rather than closed
|
|
1101
|
+
#
|
|
1102
|
+
# The tail read below is as late as the design can put it, which narrows
|
|
1103
|
+
# the race to read → rename: bytes a formatter appends AFTER that read
|
|
1104
|
+
# but BEFORE {File.rename} lands go to the old inode and are lost when
|
|
1105
|
+
# the rename swaps the directory entry. The window is two syscalls wide
|
|
1106
|
+
# and is NOT closed — closing it would take a lock in the formatter, and
|
|
1107
|
+
# that is deliberately out of scope here. What this code claims is
|
|
1108
|
+
# narrower: everything appended before the final read survives, and a
|
|
1109
|
+
# failure at any point before the rename leaves the original
|
|
1110
|
+
# byte-identical.
|
|
1111
|
+
#
|
|
1112
|
+
# Nothing is written unless something was accepted: a drain over a file
|
|
1113
|
+
# whose every line was refused, or whose selector held everything back,
|
|
1114
|
+
# leaves the file — and its mtime — exactly as it was.
|
|
1115
|
+
#
|
|
1116
|
+
# @return [Drain] what was removed; `failed`, with the warning already
|
|
1117
|
+
# on stderr, when the rewrite could not complete
|
|
1118
|
+
def drain_source(source, results)
|
|
1119
|
+
accepted = results.select { |result| result.status == :accepted }.map(&:number)
|
|
1120
|
+
return Drain.new(removed: 0, failed: false) if accepted.empty?
|
|
1121
|
+
|
|
1122
|
+
# Binary throughout: `raw` is the file's bytes, and the rebuild is a
|
|
1123
|
+
# byte operation, not a character one. `each_line` splits on the same
|
|
1124
|
+
# `"\n"` the numbering was counted with, so `number` here is the
|
|
1125
|
+
# number the reports printed.
|
|
1126
|
+
kept = String.new(encoding: Encoding::BINARY)
|
|
1127
|
+
source.raw.each_line.with_index(1) do |text, number|
|
|
1128
|
+
kept << text unless accepted.include?(number)
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
current = File.binread(source.path)
|
|
1132
|
+
appended =
|
|
1133
|
+
if current.bytesize > source.read_bytes
|
|
1134
|
+
current.byteslice(source.read_bytes, current.bytesize - source.read_bytes)
|
|
1135
|
+
else
|
|
1136
|
+
String.new(encoding: Encoding::BINARY)
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
rewritten = kept + appended
|
|
1140
|
+
drain_write(source.path, rewritten)
|
|
1141
|
+
# Counted as LINES, on `each_line`'s terms — the same split the
|
|
1142
|
+
# numbering itself was built on — never as bytes: a blank line counts,
|
|
1143
|
+
# and a queue the drain emptied reads 0 rather than 1.
|
|
1144
|
+
Drain.new(removed: accepted.length, remaining: rewritten.each_line.count, failed: false)
|
|
1145
|
+
rescue SystemCallError, IOError => e
|
|
1146
|
+
# Stated, never silent — and reported without costing the delivery
|
|
1147
|
+
# report: stdout below is still the full per-line report, this warning
|
|
1148
|
+
# names the file, and {#exit_code} turns the run into a 2, because a
|
|
1149
|
+
# 0 would read as "drained" about a queue that was not.
|
|
1150
|
+
@stderr.puts "specguard-ingest: warning: could not remove the accepted lines from #{source.path}: " \
|
|
1151
|
+
"#{e.message} — the file is left as it was"
|
|
1152
|
+
Drain.new(removed: 0, failed: true)
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
# The atomic swap: a temporary file in the SAME directory — `rename` is
|
|
1156
|
+
# only atomic within one filesystem — renamed over the original. A
|
|
1157
|
+
# failure at any point before the rename leaves the original untouched,
|
|
1158
|
+
# and the `ensure` takes the temporary with it.
|
|
1159
|
+
def drain_write(path, content)
|
|
1160
|
+
tmp = File.join(File.dirname(path),
|
|
1161
|
+
".#{File.basename(path)}.drain-#{Process.pid}-#{rand(1 << 32).to_s(36)}.tmp")
|
|
1162
|
+
begin
|
|
1163
|
+
File.binwrite(tmp, content)
|
|
1164
|
+
File.rename(tmp, path)
|
|
1165
|
+
ensure
|
|
1166
|
+
File.unlink(tmp) if File.exist?(tmp)
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
|
|
914
1170
|
# @return [Options, nil] `nil` when `--help` or `--version` has already
|
|
915
1171
|
# said everything the invocation was asking for.
|
|
916
1172
|
def parse_options(argv)
|
|
@@ -918,6 +1174,7 @@ module SpecGuard
|
|
|
918
1174
|
line_set = nil
|
|
919
1175
|
list = false
|
|
920
1176
|
json = false
|
|
1177
|
+
drain = false
|
|
921
1178
|
|
|
922
1179
|
parser = OptionParser.new do |o|
|
|
923
1180
|
o.banner = BANNER
|
|
@@ -957,6 +1214,13 @@ module SpecGuard
|
|
|
957
1214
|
o.on("--json", "Emit one JSON document on stdout instead of the human report") do
|
|
958
1215
|
json = true
|
|
959
1216
|
end
|
|
1217
|
+
# Delivery-shaped and opt-in: it changes nothing about what is sent,
|
|
1218
|
+
# only what happens to <file> afterwards, and the guards below hold
|
|
1219
|
+
# it to the queue and refuse it a listing.
|
|
1220
|
+
o.on("--drain", "After delivering, remove from <file> exactly the lines this run accepted —",
|
|
1221
|
+
"atomically, keeping every other line byte for byte") do
|
|
1222
|
+
drain = true
|
|
1223
|
+
end
|
|
960
1224
|
o.on("-v", "--version", "Print the version and exit") do
|
|
961
1225
|
@stdout.puts "specguard-ruby #{VERSION}"
|
|
962
1226
|
return nil
|
|
@@ -978,7 +1242,35 @@ module SpecGuard
|
|
|
978
1242
|
raise UsageError, "--from-line and --lines both choose which lines to send; give one or the other"
|
|
979
1243
|
end
|
|
980
1244
|
|
|
981
|
-
|
|
1245
|
+
# `--drain` follows the delivery; `--list` is the refusal to deliver.
|
|
1246
|
+
# They are not an intersection to resolve but two answers to "does this
|
|
1247
|
+
# run send anything", and a listing that also drained would either
|
|
1248
|
+
# drain nothing silently or drain without the deliveries the removal
|
|
1249
|
+
# is keyed to — both are the quiet-failure shape this file refuses.
|
|
1250
|
+
if drain && list
|
|
1251
|
+
raise UsageError, "--drain delivers and removes the lines that were accepted; " \
|
|
1252
|
+
"--list delivers nothing, so there is nothing for it to drain"
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
# The drain may empty the replay queue and nothing else. A path that
|
|
1256
|
+
# is not {Configuration#output_path} is either the local record — a
|
|
1257
|
+
# development record of ordinary keyless runs, not a queue, where
|
|
1258
|
+
# removing accepted lines would delete runs that were never failures —
|
|
1259
|
+
# or a file this invocation was pointed at by mistake. The comparison
|
|
1260
|
+
# is exact string equality, the one {#no_such_file_message} makes: a
|
|
1261
|
+
# path spelled differently from the configuration is refused rather
|
|
1262
|
+
# than resolved, and SPECGUARD_OUTPUT_PATH relocates the drain with
|
|
1263
|
+
# the queue.
|
|
1264
|
+
if drain
|
|
1265
|
+
queue = Configuration.new(env: @env).output_path
|
|
1266
|
+
if files.first != queue
|
|
1267
|
+
raise UsageError, "--drain empties the replay queue, and #{files.first} is not it " \
|
|
1268
|
+
"(configured as #{queue}) — the local record is a development record, not a queue"
|
|
1269
|
+
end
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
Options.new(path: files.first, from_line: from_line || 1, list: list, line_set: line_set, json: json,
|
|
1273
|
+
drain: drain)
|
|
982
1274
|
rescue OptionParser::ParseError => e
|
|
983
1275
|
# Uncaught, this is the likeliest way a user sees a false "the endpoint
|
|
984
1276
|
# refused your run": OptionParser raises and Ruby exits 1. Retyping it
|
|
@@ -113,13 +113,17 @@ module SpecGuard
|
|
|
113
113
|
# {IngestCLI} for both renderers
|
|
114
114
|
# @param foldings [Array<IngestCLI::Folding>] the folding observations,
|
|
115
115
|
# grouped once for both renderers
|
|
116
|
+
# @param drained [IngestCLI::Drain, nil] what `--drain` removed, or nil
|
|
117
|
+
# when the flag was absent — the key is then absent from `summary`,
|
|
118
|
+
# which is the whole of the flag being opt-in
|
|
116
119
|
# @return [String] one JSON document, without a trailing newline
|
|
117
|
-
def self.render_delivery(source:, results:, counts:, foldings:)
|
|
120
|
+
def self.render_delivery(source:, results:, counts:, foldings:, drained: nil)
|
|
118
121
|
document(
|
|
119
122
|
mode: MODE_DELIVER,
|
|
120
123
|
source: source,
|
|
121
124
|
lines: results.map { |result| delivered(result) },
|
|
122
|
-
summary: summary(source, lines: results.length, counts: counts, attempted: attempted(counts)
|
|
125
|
+
summary: summary(source, lines: results.length, counts: counts, attempted: attempted(counts),
|
|
126
|
+
drained: drained),
|
|
123
127
|
foldings: foldings.map { |folding| folded(folding) }
|
|
124
128
|
)
|
|
125
129
|
end
|
|
@@ -177,8 +181,15 @@ module SpecGuard
|
|
|
177
181
|
# render the identical document. It is `null` rather than `[]` where the
|
|
178
182
|
# selector was fully satisfied, on {#selector}'s terms: a fact that does
|
|
179
183
|
# not apply is absent, never a fabricated empty.
|
|
180
|
-
|
|
181
|
-
|
|
184
|
+
#
|
|
185
|
+
# `drained` is the `--drain` count, and it is present exactly when the
|
|
186
|
+
# flag was given — `0` where the flag asked and nothing was accepted, so
|
|
187
|
+
# a consumer that asked for the drain can always read its outcome here,
|
|
188
|
+
# and a document without the key is a run that never asked. The listing
|
|
189
|
+
# never carries it: `--drain --list` is refused before either renderer
|
|
190
|
+
# runs.
|
|
191
|
+
def self.summary(source, lines:, counts:, attempted:, drained: nil)
|
|
192
|
+
summary = {
|
|
182
193
|
"lines" => lines,
|
|
183
194
|
"attempted" => attempted,
|
|
184
195
|
"accepted" => counts.fetch(:accepted, 0),
|
|
@@ -190,6 +201,8 @@ module SpecGuard
|
|
|
190
201
|
"absent" => absent(source),
|
|
191
202
|
"selector" => selector(source)
|
|
192
203
|
}
|
|
204
|
+
summary["drained"] = drained.removed if drained
|
|
205
|
+
summary
|
|
193
206
|
end
|
|
194
207
|
|
|
195
208
|
# The typed line numbers the file does not have, in the shorthand they
|
data/lib/specguard/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: specguard-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.24
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- specguard Agent
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|