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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9fac934e9b12828579be8d52a3b9022d327e0011a02a8d509203c4b41ae64ab
4
- data.tar.gz: 8e91e21c7a1667c7f0d3139e8ea41143e1e527684a37a3bb07c61bbd88518784
3
+ metadata.gz: dd1e42b8014d4627c1126b81fd4e419e000316e788f0115a24dbbf9041ad52b1
4
+ data.tar.gz: d74ca48cff5263d913f727a94d59b6bb6ffd477ff4a31bddcf7d83599b96f9c3
5
5
  SHA512:
6
- metadata.gz: 1e90253128008f3e5461b76ef9a6b92edcd9c55fcb21933805d1f492c26a53e64efb924b1caa45a1dc109c69469aada4fafdc26277527d74b61fe477a1b8cf92
7
- data.tar.gz: 5586aca98c5894cf121fd1380f9af56f5feab57119c599e3e5cd33f5900c6be5df63dc41c4fb35eba3512ef50568dfbd36cfaca6f7a9351fbadfd3e654d8c9c9
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 local sink. Without
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
- **A failed delivery is never silent, and never lost.** If the endpoint refuses
474
- the run (a `401` from a rotated key, a `400`, a `500`) or cannot be reached at
475
- all (connection refused, DNS failure, timeout), the formatter prints **one**
476
- line to stderr naming the status or the error, and writes the payload to
477
- `log/test_results.jsonl` — the **replay queue**: runs offered to the endpoint
478
- and not accepted — so the run can be replayed later with
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: line 7 is line 7 of the file you gave it, both times.
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` at load time, not per row: a reporter constructed inside a
65
- # test that changes the working directory would otherwise relativize
66
- # one suite's rows against two different roots.
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: SpecGuard::RSpec::AnnotationLookup.new,
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
- @annotations = annotations
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 append(data, @configuration.local_output_path) if @configuration.api_key.to_s.strip.empty?
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
- warn_once("could not write telemetry to #{path} (#{e.class}: #{e.message}). The test run is unaffected.")
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
- warn_once("could not deliver test telemetry (#{reason}). Falling back to the replay queue. " \
329
- "The test run is unaffected.")
330
- append(data, @configuration.output_path)
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
- def initialize(env: ENV)
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(file)
298
+ text = read(resolved)
255
299
  return EMPTY_INDEX if text.nil?
256
300
 
257
- index_from(verdicts_for(file, text), text)
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
- def initialize(output = nil, error_stream: $stderr,
291
- annotations: SpecGuard::RSpec::AnnotationLookup.new)
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 nothing below has to guard
670
- # itself against raising — including the fallback `append`, whose own
671
- # failure modes (unwritable `log/`, full disk) are already covered there.
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
- # Warned before the write, not after: if the fallback write *also* fails,
959
- # the outer guard's one allotted warning has already been spent on the
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 with a single call so
1037
- # that two suites sharing an output path (parallel CI shards, say) interleave
1038
- # whole runs rather than halves of one.
1039
- def append(data)
1040
- path = SpecGuard::RSpec.configuration.output_path
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
- append_to(data, SpecGuard::RSpec.configuration.local_output_path)
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
- def warn_delivery_failure(reason)
1083
- path = SpecGuard::RSpec.configuration.output_path
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
- emit_warning("#{DELIVERY_WARNING_PREFIX} (#{reason}). " \
1086
- "Falling back to #{path}; the test run is unaffected.")
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 sink mixes failed deliveries with ordinary keyless local runs and
222
- # nothing on the line tells them apart, so a developer must not be able to
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
- log/test_results.jsonl written by the RSpec formatter — one whole run per
227
- line, byte-for-byte the body the endpoint was offered.
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 formatter writes to this file both
231
- when a delivery failed and when no API key was configured at all, and
232
- the two are indistinguishable on the line, so a laptop's file is a file
233
- of ordinary local runs and all of them will be sent. Nothing is
234
- filtered and nothing is guessed at.
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 --list the only reachable
268
- 2s are a bad flag and a file that cannot be read: listing needs no
269
- credentials, and an unparseable line becomes a row in the listing
270
- rather than an exit code
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, keyword_init: true)
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
- report(source, results, json: options.json)
430
- exit_code(results)
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
- def exit_code(results)
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, "no such file: #{path}" unless File.exist?(path)
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
- Options.new(path: files.first, from_line: from_line || 1, list: list, line_set: line_set, json: json)
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
- def self.summary(source, lines:, counts:, attempted:)
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
@@ -7,5 +7,5 @@
7
7
  # framework-scoped version constant a lie, so the number moved here and the
8
8
  # rspec module now points at it for back-compat.
9
9
  module SpecGuard
10
- VERSION = "0.3.23"
10
+ VERSION = "0.3.24"
11
11
  end
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.23
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-23 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json