Class: SpecGuard::RSpec::IngestCLI
- Inherits:
-
Object
- Object
- SpecGuard::RSpec::IngestCLI
- Defined in:
- lib/specguard/rspec/ingest_cli.rb
Overview
Replays a saved run: reads a log/test_results.jsonl back and re-delivers
each line through the Transport this gem already ships.
The file is not a new wire format, and that is the whole design
SpecGuard::RSpecFormatter#deliver hands the same Hash to
Transport#deliver and to its own append, and each of them calls
JSON.generate on it once. A line in the sink is therefore byte-for-byte
the body the endpoint refused, so replaying it needs no parser, no
migration and no versioning — only something that reads a line back and
POSTs it. That is all this class is.
The exit contract, which is CLI's reasoning transferred verbatim
0 every line was accepted
1 at least one line was refused by the endpoint
2 this tool could not do its job
Ruby exits 1 for an uncaught exception and for an uncaught
OptionParser::InvalidOption, and 1 is already spent here on "the
endpoint said no". Left alone, specguard-ingest --dry-runn and an
unreadable file would both report that the platform refused a run it was
never offered — a tool failure wearing the costume of a content failure,
which is the defect CLI exists to keep out of the linter. So #run
rescues and returns rather than exits, and EXIT_REFUSED is produced in
exactly one place — #exit_code, over a :refused LineResult — so it
means that and nothing else.
Interrupt, SignalException and SystemExit are deliberately not
caught. Ctrl-C halfway through a 40-line file must stay Ctrl-C.
Where the line between 1 and 2 actually falls
Not where Transport::Result draws it. That struct answers :rejected
for any non-2xx, and "a non-2xx came back" is not the same claim as
"the endpoint read this payload and judged it" — so this class redraws
the line rather than inheriting it.
The platform emits exactly one verdict about a payload:
Api::V1::IngestsController#create reaches
render_bad_request(payload.errors), a 400. Every other non-2xx is
the endpoint declining to look at the run — a 401 is answered by
authenticate_api_key!'s before_action before the action runs at all,
so Ingest::Payload is never even constructed — or the endpoint not
being there (404), or the platform failing (429, 5xx). Nothing was
stored in any of them, and none of them is a statement about anyone's
suite. They are :undelivered, and they are a 2.
:failed — connection refused, DNS, TLS, a read timeout — is a 2 for the
same reason arrived at from further away: nothing was delivered and no
verdict exists. A socket error reported as a 1 would be this tool telling
an operator their run is bad on the strength of a broken pipe; a 404
reported as a 1 would be it telling them the same thing on the strength
of a typo in SPECGUARD_ENDPOINT, while its own advice line says to go
fix that variable.
That last case is the one that fixes the rule in place: an unset
SPECGUARD_ENDPOINT is a 2 from #build_transport, so an endpoint set
to the wrong URL must be a 2 as well. Same operator mistake, same fix,
and the case where this tool knows more must not report worse.
2 also dominates: a file where line 3 was refused and line 7 never arrived exits 2, because the second fact is the one that leaves work undone. Both are printed either way — the exit code chooses what to shout, never what to say.
It re-delivers EVERY line, and will not guess which ones were failures
The sink is not a failure log. #deliver writes to it when a delivery
failed and when no API key was set at all (return append(data) if blank?(configuration.api_key)), and the two are indistinguishable on the
line — there is no field to tell them apart. A developer's local file is
therefore a file of ordinary laptop runs, and pointing this command at it
sends all of them.
A heuristic here would be worse than the gap: it would be this tool
guessing at somebody's intent from data that does not carry it, and being
confidently wrong about which runs reach the platform. So the tool is
explicit and user-initiated instead, --help says so in as many words,
and no line is filtered.
--from-line and --lines are the two things that narrow the set, and
both narrow it by numbers the user typed after reading the report — which
is the opposite of a guess. They exist because LineResult's numbering is
only half of criterion 3: a report that says line 7 was refused is worth
little if acting on it means re-sending lines 1 through 6, and re-sending
a line that carries no ci_run_id is not free — RunRecorder has no
identity to fold it onto, so it becomes a second row.
Why a suffix was not enough, and why --lines is not a heuristic
--from-line N can express only a suffix, and the set a report points
at is a suffix at most once. The sink is append-only and mixes both
sources, so ordinary keyless laptop runs keep landing after the CI
failures somebody wants to replay. Worse, CONTENT_REFUSAL_CODES is a
content verdict — a 400 will be refused every time it is offered — so a
400 sitting at line 3 of a 40-line file is a line no suffix can step over,
and the file can never be replayed to completion.
--lines 3,7,12-15 is the same category of thing as --from-line: a set
the user typed, over the file's own numbering, after reading --list.
Nothing about the line's content is consulted, which is the line the
paragraph above draws and this flag stays on the explicit side of.
The two are refused together — UsageError, a 2. They answer the same
question, and an intersection would silently drop a number the user typed
(--from-line 5 --lines 3,7 delivers only 7, and the 3 vanishes without a
word). Quietly narrowing what it was asked for is the failure this whole
file is arranged against, so the combination is refused rather than
resolved. Carving the file up with sed is not the alternative either: a
carved temp file renumbers, and the whole value of resuming from a report
is that line 12 is still line 12.
A repeated selector is the other half of that question and gets the
opposite answer deliberately, not by omission: --lines 1,2 --lines 4
delivers line 4, and --from-line 2 --from-line 5 starts at 5, both by
last-wins. The reason the cross-flag case is refused does not reach this
one. Refusing --from-line with --lines is about two different flags
answering one question, where combining them yields a set smaller than
either one names and the user cannot see which of their numbers went. A
repeat is one flag answering its own question twice, and the later answer
replaces the earlier rather than intersecting it — the delivered set is
exactly the last one typed, which is the one thing on the command line
that is unambiguously current. It is also what makes a selector
overridable at all: a wrapper script or shell alias that bakes in
--lines is corrected by appending a new one, and refusing the repeat
would take that away for no gain in clarity. Last-wins is OptionParser's
convention and the shell's; this file adopts it on purpose and pins it in
the spec, so it is a decision rather than a default nobody looked at.
--list, which is what makes "check the file first" an instruction
Having refused to guess for the user, this command owes them what they
need to decide. #list prints one row per line off the fields the
envelope already carries — branch, commit_sha, ci_run_id or its
absence, how many examples, how long — and delivers nothing. Reading the
file by hand is not the alternative: one line is one whole run, and at
this project's design point that is megabytes of JSON on a single
physical line.
It short-circuits ahead of #build_transport, deliberately, and that
ordering is the load-bearing part. build_transport raises for a blank
SPECGUARD_ENDPOINT or SPECGUARD_API_KEY, and the file that most needs
checking is the one written because no API key was set
(Formatter — return append(data) if blank?(configuration.api_key)).
Requiring a key to look at the file would withdraw the instrument in
exactly the situation that produces the hazard.
Listing delivers nothing, so it can never be a content verdict: it answers 0 or 2 and never routes through #exit_code, which stays the one place EXIT_REFUSED is produced.
What it will not claim
The natural question about a replay is whether a line folded onto an
existing run or created a new one, and the platform does not answer it:
Ingest::RunRecorder#record find-or-creates by ci_run_id and the 202
body carries no created-versus-updated flag. So this class reports what it
can see — the test_run_id that came back, and whether the line carried a
ci_run_id at all — and states folding only where two lines observed
it: same ci_run_id in, same test_run_id out is one row, not an
inference about one. See #folded_runs.
--json, and the one thing the human report cannot say
A 400 is the only permanent verdict here (CONTENT_REFUSAL_CODES), so the only way to land a refused line is to learn which specs the platform objected to and fix the payload. It names every one of them — one error per bad spec — and Transport::Result#reason renders three, truncated to 300 characters, because it exists for the one stderr line an in-run CI warning is allowed. On a systemic client bug over a 20,000-example suite, the command whose entire job is to fix and re-send a refused run was showing three of twenty thousand reasons.
--json is the second channel that cap's own grounds hand over, and it is
a RENDERER: stdout carries one document instead of the human report, in
both modes, over the same LineResults, the same counts, the same
groupings and the same #exit_code. Nothing about what this command
decides moves — the cap, #reason and the formatter's warning are
untouched, and the default output is byte-identical (pinned in
spec/specguard/rspec/regression_targets_spec.rb). See IngestReporter
for the document, and for which runs emit one.
Defined Under Namespace
Classes: Folding, LineResult, ListedLine, Options, Source
Constant Summary collapse
- BANNER =
"Usage: specguard-ingest [options] <file>"- DESCRIPTION =
Shown by
--help. The second paragraph is the one that has to be there: the sink mixes failed deliveries with ordinary keyless local runs and nothing on the line tells them apart, so a developer must not be able to discover only afterwards that they pushed their laptop's history. <<~TEXT.freeze Re-delivers a saved run to SpecGuard's ingest endpoint. <file> is a log/test_results.jsonl written by the RSpec formatter — one whole run per line, byte-for-byte the body the endpoint was offered. EVERY line in <file> is delivered — or, when you narrow it, every line --from-line or --lines names. The formatter writes to this file both when a delivery failed and when no API key was configured at all, and the two are indistinguishable on the line, so a laptop's file is a file of ordinary local runs and all of them will be sent. Nothing is filtered and nothing is guessed at. So check the file first: --list prints one row per line — branch, commit, ci_run_id or its absence, how many examples, how long — and delivers nothing at all. It needs no SPECGUARD_ENDPOINT and no SPECGUARD_API_KEY, because the file most worth checking is the one written when no API key was set. It composes with --from-line and --lines, so you can list the exact set you are about to send. Each line is delivered once, with no retry, and reported by its line number in <file>. Use --from-line to resume a file that was only partly accepted, or --lines to send an arbitrary set of them — 3,7,12-15 — over that same numbering, instead of re-sending all of it. Both narrow the same file, so give one or the other and never both. Reads SPECGUARD_ENDPOINT, SPECGUARD_API_KEY and SPECGUARD_TIMEOUT. --json replaces the human report with one JSON document on stdout, in both modes. It carries every line's status, the endpoint's HTTP code and the FULL list of reasons a refusal named — the human line has room for three of them — plus the same summary counts and folding observations. Warnings stay on stderr, and a run that never got as far as reading <file> writes no document at all. Exit codes: 0 every line was accepted — or, with --list, the file was listed 1 at least one line was refused by the endpoint — it read the payload and said no (HTTP 400). Unreachable with --list, which delivers nothing and so can never carry a verdict about a run 2 this tool could not do its job — bad flags, no endpoint or API key, an unreadable file, an unparseable line, a delivery that never reached the endpoint, or one the endpoint answered without ever reading it (401, 404, 429, 5xx — nothing was stored, so none of them is a verdict about your run). With --list the only reachable 2s are a bad flag and a file that cannot be read: listing needs no credentials, and an unparseable line becomes a row in the listing rather than an exit code TEXT
- EXIT_OK =
Every line in the file was accepted by the endpoint — including the vacuous case of a file with no lines in it, which is loud on stderr for exactly the reason
specguard-lint's empty selection is: the contract has no code for "there was nothing to do", so the warning carries it. 0- EXIT_REFUSED =
At least one line was refused. The only code that says something about the content of a run, and the only path to it is a non-2xx whose status is in CONTENT_REFUSAL_CODES.
1- EXIT_MISUSE =
This tool could not do its job: bad flags, no endpoint or API key configured, a file it could not read, a line it could not parse, a delivery that never reached the endpoint at all, or one the endpoint answered without ever reading — a 401, 404, 429 or 5xx.
2- CONTENT_REFUSAL_CODES =
The status codes that carry a verdict about the payload — which on this platform is exactly one.
Api::V1::IngestsController#createreachesrender_bad_request(payload.errors)and nothing else. A 401 comes fromauthenticate_api_key!'sbefore_actionbefore the action runs, soIngest::Payloadis never constructed and no verdict about the run exists to report; a 403, 404, 429 or 5xx never gets as far as a body either. See the class comment for what that costs an operator when it is got wrong.A list of one rather than
code == 400, so a platform that grows a second verdict is a one-line change here — and deliberately not pre-seeded with a 422 the platform does not send. An unlisted refusal is reported as:undelivered, which under-claims, and under-claiming is the safe direction: it sends an operator to look at their setup rather than at a suite that was never judged. [400].freeze
- LINE_SPEC_ENTRY =
One entry of a
--linesspec:12or12-15, and nothing else. No sign, no open end, no whitespace inside — #parse_line_set strips each entry before matching, so3, 7is fine, but12-and5 - 7are not near-misses to be repaired, they are typos to be reported. This is theInteger-coercion rationale on--from-lineapplied to a richer grammar: a spec that half-parses would silently deliver the wrong set, and delivering the wrong set is the one outcome a selector exists to prevent. /\A(\d+)(?:-(\d+))?\z/- STATUS_LABELS =
{ accepted: "accepted", refused: "refused", undelivered: "not delivered", unparseable: "unparseable" }.freeze
Instance Method Summary collapse
-
#initialize(stdout: $stdout, stderr: $stderr, env: ENV) ⇒ IngestCLI
constructor
A new instance of IngestCLI.
-
#run(argv) ⇒ Integer
0, 1 or 2 — never anything else, and never by letting an exception reach the shell.
Constructor Details
#initialize(stdout: $stdout, stderr: $stderr, env: ENV) ⇒ IngestCLI
Returns a new instance of IngestCLI.
372 373 374 375 376 |
# File 'lib/specguard/rspec/ingest_cli.rb', line 372 def initialize(stdout: $stdout, stderr: $stderr, env: ENV) @stdout = stdout @stderr = stderr @env = env end |
Instance Method Details
#run(argv) ⇒ Integer
Returns 0, 1 or 2 — never anything else, and never by letting an exception reach the shell.
381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 |
# File 'lib/specguard/rspec/ingest_cli.rb', line 381 def run(argv) = (argv) return EXIT_OK if .nil? # --help / --version already printed # Ahead of `build_transport`, and that is the whole point of the branch # being here rather than after it. Listing sends nothing, so it needs no # endpoint and no key — and the file that most wants looking at is the # one the formatter wrote *because* no key was set. A listing that # demanded credentials would be unavailable in exactly the case it # exists for. return list() if .list # Before the file is opened, deliberately. "There is nowhere to send # this" is the earlier question — which lines to send does not matter # when nothing is going to accept them — and asking it first means an # unconfigured run reads its one real problem instead of a complaint # about a path that was never the point. transport = build_transport source = read_source() results = source.lines.map { |number, text| deliver_line(number, text, transport) } report(source, results, json: .json) exit_code(results) rescue UsageError => e @stderr.puts "specguard-ingest: error: #{e.}" EXIT_MISUSE rescue ScriptError, StandardError => e # The backstop that makes exit 1 mean one thing. Anything reaching here # is a bug in this tool, not a verdict from the endpoint about anyone's # run, so it is a 2 and it says so in those words. @stderr.puts "specguard-ingest: internal error: #{e.class}: #{e.}" EXIT_MISUSE end |