specguard-ruby 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +49 -0
- data/.github/workflows/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +1036 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-ingest +40 -0
- data/bin/specguard-lint +34 -0
- data/lib/minitest/specguard_plugin.rb +25 -0
- data/lib/specguard/minitest/reporter.rb +244 -0
- data/lib/specguard/rspec/annotation_lookup.rb +363 -0
- data/lib/specguard/rspec/annotation_scanner.rb +190 -0
- data/lib/specguard/rspec/cli.rb +375 -0
- data/lib/specguard/rspec/configuration.rb +472 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +74 -0
- data/lib/specguard/rspec/formatter.rb +1110 -0
- data/lib/specguard/rspec/ingest_cli.rb +942 -0
- data/lib/specguard/rspec/ingest_reporter.rb +280 -0
- data/lib/specguard/rspec/json_reporter.rb +169 -0
- data/lib/specguard/rspec/linter.rb +156 -0
- data/lib/specguard/rspec/payload_normalizer.rb +143 -0
- data/lib/specguard/rspec/scanner.rb +235 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +449 -0
- data/lib/specguard/rspec/validator_backend.rb +1450 -0
- data/lib/specguard/rspec/version.rb +14 -0
- data/lib/specguard/rspec.rb +71 -0
- data/lib/specguard/version.rb +11 -0
- data/script/bump-version.sh +128 -0
- metadata +96 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
require_relative "../rspec"
|
|
7
|
+
require_relative "ingest_reporter"
|
|
8
|
+
require_relative "transport"
|
|
9
|
+
|
|
10
|
+
# `specguard-ingest`'s command line — the other end of `log/test_results.jsonl`.
|
|
11
|
+
#
|
|
12
|
+
# == Why this file is not on `require "specguard/rspec"`'s chain
|
|
13
|
+
#
|
|
14
|
+
# `specguard-lint` runs on machines that never make a network call, and putting
|
|
15
|
+
# this file on the umbrella's chain would put `net/http`, `uri` and `zlib` on
|
|
16
|
+
# the linter's load path to serve a command it never invokes. So it is loaded by
|
|
17
|
+
# its own path, exactly as the formatter is and for the same reason —
|
|
18
|
+
# `require "specguard/rspec/ingest_cli"`, which `bin/specguard-ingest` does.
|
|
19
|
+
module SpecGuard
|
|
20
|
+
module RSpec
|
|
21
|
+
# Replays a saved run: reads a `log/test_results.jsonl` back and re-delivers
|
|
22
|
+
# each line through the {Transport} this gem already ships.
|
|
23
|
+
#
|
|
24
|
+
# == The file is not a new wire format, and that is the whole design
|
|
25
|
+
#
|
|
26
|
+
# `SpecGuard::RSpecFormatter#deliver` hands the *same* Hash to
|
|
27
|
+
# `Transport#deliver` and to its own `append`, and each of them calls
|
|
28
|
+
# `JSON.generate` on it once. A line in the sink is therefore byte-for-byte
|
|
29
|
+
# the body the endpoint refused, so replaying it needs no parser, no
|
|
30
|
+
# migration and no versioning — only something that reads a line back and
|
|
31
|
+
# POSTs it. That is all this class is.
|
|
32
|
+
#
|
|
33
|
+
# == The exit contract, which is {CLI}'s reasoning transferred verbatim
|
|
34
|
+
#
|
|
35
|
+
# 0 every line was accepted
|
|
36
|
+
# 1 at least one line was refused by the endpoint
|
|
37
|
+
# 2 this tool could not do its job
|
|
38
|
+
#
|
|
39
|
+
# Ruby exits **1** for an uncaught exception and for an uncaught
|
|
40
|
+
# `OptionParser::InvalidOption`, and 1 is already spent here on "the
|
|
41
|
+
# endpoint said no". Left alone, `specguard-ingest --dry-runn` and an
|
|
42
|
+
# unreadable file would both report that the platform refused a run it was
|
|
43
|
+
# never offered — a tool failure wearing the costume of a content failure,
|
|
44
|
+
# which is the defect {CLI} exists to keep out of the linter. So {#run}
|
|
45
|
+
# rescues and *returns* rather than exits, and {EXIT_REFUSED} is produced in
|
|
46
|
+
# exactly one place — {#exit_code}, over a `:refused` {LineResult} — so it
|
|
47
|
+
# means that and nothing else.
|
|
48
|
+
#
|
|
49
|
+
# `Interrupt`, `SignalException` and `SystemExit` are deliberately not
|
|
50
|
+
# caught. Ctrl-C halfway through a 40-line file must stay Ctrl-C.
|
|
51
|
+
#
|
|
52
|
+
# == Where the line between 1 and 2 actually falls
|
|
53
|
+
#
|
|
54
|
+
# Not where {Transport::Result} draws it. That struct answers `:rejected`
|
|
55
|
+
# for *any* non-2xx, and "a non-2xx came back" is not the same claim as
|
|
56
|
+
# "the endpoint read this payload and judged it" — so this class redraws
|
|
57
|
+
# the line rather than inheriting it.
|
|
58
|
+
#
|
|
59
|
+
# The platform emits exactly one verdict about a payload:
|
|
60
|
+
# `Api::V1::IngestsController#create` reaches
|
|
61
|
+
# `render_bad_request(payload.errors)`, a **400**. Every other non-2xx is
|
|
62
|
+
# the endpoint declining to look at the run — a 401 is answered by
|
|
63
|
+
# `authenticate_api_key!`'s `before_action` before the action runs at all,
|
|
64
|
+
# so `Ingest::Payload` is never even constructed — or the endpoint not
|
|
65
|
+
# being there (404), or the platform failing (429, 5xx). **Nothing was
|
|
66
|
+
# stored in any of them**, and none of them is a statement about anyone's
|
|
67
|
+
# suite. They are `:undelivered`, and they are a 2.
|
|
68
|
+
#
|
|
69
|
+
# `:failed` — connection refused, DNS, TLS, a read timeout — is a 2 for the
|
|
70
|
+
# same reason arrived at from further away: nothing was delivered and no
|
|
71
|
+
# verdict exists. A socket error reported as a 1 would be this tool telling
|
|
72
|
+
# an operator their run is bad on the strength of a broken pipe; a 404
|
|
73
|
+
# reported as a 1 would be it telling them the same thing on the strength
|
|
74
|
+
# of a typo in `SPECGUARD_ENDPOINT`, while its own advice line says to go
|
|
75
|
+
# fix that variable.
|
|
76
|
+
#
|
|
77
|
+
# That last case is the one that fixes the rule in place: an **unset**
|
|
78
|
+
# `SPECGUARD_ENDPOINT` is a 2 from {#build_transport}, so an endpoint set
|
|
79
|
+
# to the *wrong URL* must be a 2 as well. Same operator mistake, same fix,
|
|
80
|
+
# and the case where this tool knows more must not report worse.
|
|
81
|
+
#
|
|
82
|
+
# 2 also dominates: a file where line 3 was refused and line 7 never
|
|
83
|
+
# arrived exits 2, because the second fact is the one that leaves work
|
|
84
|
+
# undone. Both are printed either way — the exit code chooses what to
|
|
85
|
+
# shout, never what to say.
|
|
86
|
+
#
|
|
87
|
+
# == It re-delivers EVERY line, and will not guess which ones were failures
|
|
88
|
+
#
|
|
89
|
+
# The sink is not a failure log. `#deliver` writes to it when a delivery
|
|
90
|
+
# failed *and* when no API key was set at all (`return append(data) if
|
|
91
|
+
# blank?(configuration.api_key)`), and the two are indistinguishable on the
|
|
92
|
+
# line — there is no field to tell them apart. A developer's local file is
|
|
93
|
+
# therefore a file of ordinary laptop runs, and pointing this command at it
|
|
94
|
+
# sends all of them.
|
|
95
|
+
#
|
|
96
|
+
# A heuristic here would be worse than the gap: it would be this tool
|
|
97
|
+
# guessing at somebody's intent from data that does not carry it, and being
|
|
98
|
+
# confidently wrong about which runs reach the platform. So the tool is
|
|
99
|
+
# explicit and user-initiated instead, `--help` says so in as many words,
|
|
100
|
+
# and no line is filtered.
|
|
101
|
+
#
|
|
102
|
+
# `--from-line` and `--lines` are the two things that narrow the set, and
|
|
103
|
+
# both narrow it by numbers the user typed after reading the report — which
|
|
104
|
+
# is the opposite of a guess. They exist because {LineResult}'s numbering is
|
|
105
|
+
# only half of criterion 3: a report that says line 7 was refused is worth
|
|
106
|
+
# little if acting on it means re-sending lines 1 through 6, and re-sending
|
|
107
|
+
# a line that carries no `ci_run_id` is not free — `RunRecorder` has no
|
|
108
|
+
# identity to fold it onto, so it becomes a second row.
|
|
109
|
+
#
|
|
110
|
+
# == Why a suffix was not enough, and why `--lines` is not a heuristic
|
|
111
|
+
#
|
|
112
|
+
# `--from-line N` can express only a *suffix*, and the set a report points
|
|
113
|
+
# at is a suffix at most once. The sink is append-only and mixes both
|
|
114
|
+
# sources, so ordinary keyless laptop runs keep landing *after* the CI
|
|
115
|
+
# failures somebody wants to replay. Worse, {CONTENT_REFUSAL_CODES} is a
|
|
116
|
+
# content verdict — a 400 will be refused every time it is offered — so a
|
|
117
|
+
# 400 sitting at line 3 of a 40-line file is a line no suffix can step over,
|
|
118
|
+
# and the file can never be replayed to completion.
|
|
119
|
+
#
|
|
120
|
+
# `--lines 3,7,12-15` is the same category of thing as `--from-line`: a set
|
|
121
|
+
# the user typed, over the file's own numbering, after reading `--list`.
|
|
122
|
+
# Nothing about the line's *content* is consulted, which is the line the
|
|
123
|
+
# paragraph above draws and this flag stays on the explicit side of.
|
|
124
|
+
#
|
|
125
|
+
# The two are refused *together* — {UsageError}, a 2. They answer the same
|
|
126
|
+
# question, and an intersection would silently drop a number the user typed
|
|
127
|
+
# (`--from-line 5 --lines 3,7` delivers only 7, and the 3 vanishes without a
|
|
128
|
+
# word). Quietly narrowing what it was asked for is the failure this whole
|
|
129
|
+
# file is arranged against, so the combination is refused rather than
|
|
130
|
+
# resolved. Carving the file up with `sed` is not the alternative either: a
|
|
131
|
+
# carved temp file renumbers, and the whole value of resuming from a report
|
|
132
|
+
# is that line 12 is still line 12.
|
|
133
|
+
#
|
|
134
|
+
# A *repeated* selector is the other half of that question and gets the
|
|
135
|
+
# opposite answer deliberately, not by omission: `--lines 1,2 --lines 4`
|
|
136
|
+
# delivers line 4, and `--from-line 2 --from-line 5` starts at 5, both by
|
|
137
|
+
# last-wins. The reason the cross-flag case is refused does not reach this
|
|
138
|
+
# one. Refusing `--from-line` with `--lines` is about two *different* flags
|
|
139
|
+
# answering one question, where combining them yields a set smaller than
|
|
140
|
+
# either one names and the user cannot see which of their numbers went. A
|
|
141
|
+
# repeat is one flag answering its own question twice, and the later answer
|
|
142
|
+
# *replaces* the earlier rather than intersecting it — the delivered set is
|
|
143
|
+
# exactly the last one typed, which is the one thing on the command line
|
|
144
|
+
# that is unambiguously current. It is also what makes a selector
|
|
145
|
+
# overridable at all: a wrapper script or shell alias that bakes in
|
|
146
|
+
# `--lines` is corrected by appending a new one, and refusing the repeat
|
|
147
|
+
# would take that away for no gain in clarity. Last-wins is `OptionParser`'s
|
|
148
|
+
# convention and the shell's; this file adopts it on purpose and pins it in
|
|
149
|
+
# the spec, so it is a decision rather than a default nobody looked at.
|
|
150
|
+
#
|
|
151
|
+
# == `--list`, which is what makes "check the file first" an instruction
|
|
152
|
+
#
|
|
153
|
+
# Having refused to guess *for* the user, this command owes them what they
|
|
154
|
+
# need to decide. {#list} prints one row per line off the fields the
|
|
155
|
+
# envelope already carries — `branch`, `commit_sha`, `ci_run_id` or its
|
|
156
|
+
# absence, how many examples, how long — and delivers nothing. Reading the
|
|
157
|
+
# file by hand is not the alternative: one line is one whole run, and at
|
|
158
|
+
# this project's design point that is megabytes of JSON on a single
|
|
159
|
+
# physical line.
|
|
160
|
+
#
|
|
161
|
+
# It short-circuits **ahead of {#build_transport}**, deliberately, and that
|
|
162
|
+
# ordering is the load-bearing part. `build_transport` raises for a blank
|
|
163
|
+
# `SPECGUARD_ENDPOINT` or `SPECGUARD_API_KEY`, and the file that most needs
|
|
164
|
+
# checking is the one written *because no API key was set*
|
|
165
|
+
# (`Formatter` — `return append(data) if blank?(configuration.api_key)`).
|
|
166
|
+
# Requiring a key to look at the file would withdraw the instrument in
|
|
167
|
+
# exactly the situation that produces the hazard.
|
|
168
|
+
#
|
|
169
|
+
# Listing delivers nothing, so it can never be a content verdict: it
|
|
170
|
+
# answers 0 or 2 and never routes through {#exit_code}, which stays the one
|
|
171
|
+
# place {EXIT_REFUSED} is produced.
|
|
172
|
+
#
|
|
173
|
+
# == What it will not claim
|
|
174
|
+
#
|
|
175
|
+
# The natural question about a replay is whether a line *folded onto* an
|
|
176
|
+
# existing run or *created* a new one, and the platform does not answer it:
|
|
177
|
+
# `Ingest::RunRecorder#record` find-or-creates by `ci_run_id` and the 202
|
|
178
|
+
# body carries no created-versus-updated flag. So this class reports what it
|
|
179
|
+
# can see — the `test_run_id` that came back, and whether the line carried a
|
|
180
|
+
# `ci_run_id` at all — and states folding only where two lines *observed*
|
|
181
|
+
# it: same `ci_run_id` in, same `test_run_id` out is one row, not an
|
|
182
|
+
# inference about one. See {#folded_runs}.
|
|
183
|
+
#
|
|
184
|
+
# == `--json`, and the one thing the human report cannot say
|
|
185
|
+
#
|
|
186
|
+
# A 400 is the only *permanent* verdict here ({CONTENT_REFUSAL_CODES}), so
|
|
187
|
+
# the only way to land a refused line is to learn which specs the platform
|
|
188
|
+
# objected to and fix the payload. It names every one of them — one error per
|
|
189
|
+
# bad spec — and {Transport::Result#reason} renders three, truncated to 300
|
|
190
|
+
# characters, because it exists for the one stderr line an in-run CI warning
|
|
191
|
+
# is allowed. On a systemic client bug over a 20,000-example suite, the
|
|
192
|
+
# command whose entire job is to fix and re-send a refused run was showing
|
|
193
|
+
# three of twenty thousand reasons.
|
|
194
|
+
#
|
|
195
|
+
# `--json` is the second channel that cap's own grounds hand over, and it is
|
|
196
|
+
# a RENDERER: stdout carries one document instead of the human report, in
|
|
197
|
+
# both modes, over the same {LineResult}s, the same counts, the same
|
|
198
|
+
# groupings and the same {#exit_code}. Nothing about what this command
|
|
199
|
+
# *decides* moves — the cap, `#reason` and the formatter's warning are
|
|
200
|
+
# untouched, and the default output is byte-identical (pinned in
|
|
201
|
+
# `spec/specguard/rspec/regression_targets_spec.rb`). See {IngestReporter}
|
|
202
|
+
# for the document, and for which runs emit one.
|
|
203
|
+
class IngestCLI
|
|
204
|
+
BANNER = "Usage: specguard-ingest [options] <file>"
|
|
205
|
+
|
|
206
|
+
# Shown by `--help`. The second paragraph is the one that has to be there:
|
|
207
|
+
# the sink mixes failed deliveries with ordinary keyless local runs and
|
|
208
|
+
# nothing on the line tells them apart, so a developer must not be able to
|
|
209
|
+
# discover only afterwards that they pushed their laptop's history.
|
|
210
|
+
DESCRIPTION = <<~TEXT.freeze
|
|
211
|
+
Re-delivers a saved run to SpecGuard's ingest endpoint. <file> is a
|
|
212
|
+
log/test_results.jsonl written by the RSpec formatter — one whole run per
|
|
213
|
+
line, byte-for-byte the body the endpoint was offered.
|
|
214
|
+
|
|
215
|
+
EVERY line in <file> is delivered — or, when you narrow it, every line
|
|
216
|
+
--from-line or --lines names. The formatter writes to this file both
|
|
217
|
+
when a delivery failed and when no API key was configured at all, and
|
|
218
|
+
the two are indistinguishable on the line, so a laptop's file is a file
|
|
219
|
+
of ordinary local runs and all of them will be sent. Nothing is
|
|
220
|
+
filtered and nothing is guessed at.
|
|
221
|
+
|
|
222
|
+
So check the file first: --list prints one row per line — branch, commit,
|
|
223
|
+
ci_run_id or its absence, how many examples, how long — and delivers
|
|
224
|
+
nothing at all. It needs no SPECGUARD_ENDPOINT and no SPECGUARD_API_KEY,
|
|
225
|
+
because the file most worth checking is the one written when no API key
|
|
226
|
+
was set. It composes with --from-line and --lines, so you can list the
|
|
227
|
+
exact set you are about to send.
|
|
228
|
+
|
|
229
|
+
Each line is delivered once, with no retry, and reported by its line
|
|
230
|
+
number in <file>. Use --from-line to resume a file that was only partly
|
|
231
|
+
accepted, or --lines to send an arbitrary set of them — 3,7,12-15 — over
|
|
232
|
+
that same numbering, instead of re-sending all of it. Both narrow the
|
|
233
|
+
same file, so give one or the other and never both.
|
|
234
|
+
|
|
235
|
+
Reads SPECGUARD_ENDPOINT, SPECGUARD_API_KEY and SPECGUARD_TIMEOUT.
|
|
236
|
+
|
|
237
|
+
--json replaces the human report with one JSON document on stdout, in
|
|
238
|
+
both modes. It carries every line's status, the endpoint's HTTP code and
|
|
239
|
+
the FULL list of reasons a refusal named — the human line has room for
|
|
240
|
+
three of them — plus the same summary counts and folding observations.
|
|
241
|
+
Warnings stay on stderr, and a run that never got as far as reading
|
|
242
|
+
<file> writes no document at all.
|
|
243
|
+
|
|
244
|
+
Exit codes:
|
|
245
|
+
0 every line was accepted — or, with --list, the file was listed
|
|
246
|
+
1 at least one line was refused by the endpoint — it read the payload
|
|
247
|
+
and said no (HTTP 400). Unreachable with --list, which delivers
|
|
248
|
+
nothing and so can never carry a verdict about a run
|
|
249
|
+
2 this tool could not do its job — bad flags, no endpoint or API key,
|
|
250
|
+
an unreadable file, an unparseable line, a delivery that never
|
|
251
|
+
reached the endpoint, or one the endpoint answered without ever
|
|
252
|
+
reading it (401, 404, 429, 5xx — nothing was stored, so none of
|
|
253
|
+
them is a verdict about your run). With --list the only reachable
|
|
254
|
+
2s are a bad flag and a file that cannot be read: listing needs no
|
|
255
|
+
credentials, and an unparseable line becomes a row in the listing
|
|
256
|
+
rather than an exit code
|
|
257
|
+
TEXT
|
|
258
|
+
|
|
259
|
+
# Every line in the file was accepted by the endpoint — including the
|
|
260
|
+
# vacuous case of a file with no lines in it, which is loud on stderr for
|
|
261
|
+
# exactly the reason `specguard-lint`'s empty selection is: the contract
|
|
262
|
+
# has no code for "there was nothing to do", so the warning carries it.
|
|
263
|
+
EXIT_OK = 0
|
|
264
|
+
# At least one line was refused. The only code that says something about
|
|
265
|
+
# the *content* of a run, and the only path to it is a non-2xx whose
|
|
266
|
+
# status is in {CONTENT_REFUSAL_CODES}.
|
|
267
|
+
EXIT_REFUSED = 1
|
|
268
|
+
# This tool could not do its job: bad flags, no endpoint or API key
|
|
269
|
+
# configured, a file it could not read, a line it could not parse, a
|
|
270
|
+
# delivery that never reached the endpoint at all, or one the endpoint
|
|
271
|
+
# answered without ever reading — a 401, 404, 429 or 5xx.
|
|
272
|
+
EXIT_MISUSE = 2
|
|
273
|
+
|
|
274
|
+
# The status codes that carry a verdict about the payload — which on this
|
|
275
|
+
# platform is exactly one.
|
|
276
|
+
#
|
|
277
|
+
# `Api::V1::IngestsController#create` reaches
|
|
278
|
+
# `render_bad_request(payload.errors)` and nothing else. A 401 comes from
|
|
279
|
+
# `authenticate_api_key!`'s `before_action` before the action runs, so
|
|
280
|
+
# `Ingest::Payload` is never constructed and no verdict about the run
|
|
281
|
+
# exists to report; a 403, 404, 429 or 5xx never gets as far as a body
|
|
282
|
+
# either. See the class comment for what that costs an operator when it
|
|
283
|
+
# is got wrong.
|
|
284
|
+
#
|
|
285
|
+
# A list of one rather than `code == 400`, so a platform that grows a
|
|
286
|
+
# second verdict is a one-line change here — and deliberately *not*
|
|
287
|
+
# pre-seeded with a 422 the platform does not send. An unlisted refusal
|
|
288
|
+
# is reported as `:undelivered`, which under-claims, and under-claiming
|
|
289
|
+
# is the safe direction: it sends an operator to look at their setup
|
|
290
|
+
# rather than at a suite that was never judged.
|
|
291
|
+
CONTENT_REFUSAL_CODES = [400].freeze
|
|
292
|
+
|
|
293
|
+
# What happened to one line of the file. `number` is its 1-based position
|
|
294
|
+
# in the file *as given*, counting the blank lines that were skipped, so
|
|
295
|
+
# the report addresses the same lines an editor does and a partially
|
|
296
|
+
# accepted file can be resumed from rather than blindly re-sent.
|
|
297
|
+
#
|
|
298
|
+
# `detail` is the flattened one-line rendering the text report prints, and
|
|
299
|
+
# `code` and `reasons` are the two structured facts flattening it destroys
|
|
300
|
+
# — the numeric HTTP status, and the **whole** array off
|
|
301
|
+
# {Transport::Result} rather than the three {Transport::Result#reason} has
|
|
302
|
+
# room for. They are carried rather than derived because a document cannot
|
|
303
|
+
# be reconstructed from prose: `and 19997 more` is not the 19,997.
|
|
304
|
+
#
|
|
305
|
+
# `reasons` is whatever there is to say about why this line did not land,
|
|
306
|
+
# as it arrived: the platform's own strings on a refusal (verbatim,
|
|
307
|
+
# including a `nil` where the body said nothing readable), the parse
|
|
308
|
+
# problem where the line was never a run, the exception's rendering where
|
|
309
|
+
# nothing reached the endpoint, and `[]` on an acceptance. Normalising it
|
|
310
|
+
# to a list of strings is {IngestReporter}'s job, because that guarantee
|
|
311
|
+
# is the document's rather than this struct's.
|
|
312
|
+
LineResult = Struct.new(:number, :status, :detail, :code, :reasons, :test_run_id, :ci_run_id,
|
|
313
|
+
keyword_init: true)
|
|
314
|
+
|
|
315
|
+
# The envelope facts a *listing* states about one line — the same five
|
|
316
|
+
# {#list_row} prints, extracted once so both renderers read one set of
|
|
317
|
+
# facts rather than each deciding for itself what a non-scalar `branch` or
|
|
318
|
+
# a non-Array `specs` means. `problem` names why the line is not a run,
|
|
319
|
+
# and is `nil` for every line that is one.
|
|
320
|
+
#
|
|
321
|
+
# Every other member is `nil` exactly where the row says `no branch`,
|
|
322
|
+
# `no specs` or `no duration_seconds`: the line does not carry that fact.
|
|
323
|
+
ListedLine = Struct.new(:number, :problem, :branch, :commit_sha, :ci_run_id, :examples,
|
|
324
|
+
:duration_seconds, keyword_init: true)
|
|
325
|
+
|
|
326
|
+
# Two or more accepted lines that went out with one `ci_run_id` and came
|
|
327
|
+
# back with one `test_run_id` — folding, observed rather than inferred.
|
|
328
|
+
# Grouped once ({#folded_runs}) and rendered twice, as a sentence by
|
|
329
|
+
# {#folding_observation} and as data by {IngestReporter}.
|
|
330
|
+
Folding = Struct.new(:ci_run_id, :test_run_id, :numbers, keyword_init: true)
|
|
331
|
+
|
|
332
|
+
# The file, as this tool reads it: the numbered lines that carry a
|
|
333
|
+
# payload, a count of the blank ones that do not, and a count of the ones
|
|
334
|
+
# the selector held back — with `selector` naming which flag did it, so
|
|
335
|
+
# the summary can say. The blanks and the skips are counted rather than
|
|
336
|
+
# dropped because a summary that quietly narrows what it is summarising is
|
|
337
|
+
# the failure this project keeps finding.
|
|
338
|
+
Source = Struct.new(:path, :lines, :blank, :skipped, :selector, keyword_init: true)
|
|
339
|
+
|
|
340
|
+
# What the command line asked for. A struct rather than a bare path,
|
|
341
|
+
# because `--from-line` is the second half of the same question — which
|
|
342
|
+
# lines of which file — and threading it as an ivar would put a value
|
|
343
|
+
# {#run} depends on somewhere {#run} does not name. `line_set` is that
|
|
344
|
+
# same half asked the other way, as an explicit set rather than a
|
|
345
|
+
# starting point, and the two are mutually exclusive (see the class
|
|
346
|
+
# comment). `list` is the third half: whether those lines are to be
|
|
347
|
+
# *shown* or *sent*. `json` is orthogonal to all three — it chooses the
|
|
348
|
+
# renderer, never the set and never the verdict.
|
|
349
|
+
#
|
|
350
|
+
# `line_set` is an Array of Ranges rather than an expanded Array of
|
|
351
|
+
# Integers, so `--lines 1-90000000` costs nothing to hold. `nil` means the
|
|
352
|
+
# flag was not given and `from_line` is the selector.
|
|
353
|
+
Options = Struct.new(:path, :from_line, :list, :line_set, :json, keyword_init: true)
|
|
354
|
+
|
|
355
|
+
# One entry of a `--lines` spec: `12` or `12-15`, and nothing else. No
|
|
356
|
+
# sign, no open end, no whitespace inside — {#parse_line_set} strips each
|
|
357
|
+
# entry before matching, so `3, 7` is fine, but `12-` and `5 - 7` are not
|
|
358
|
+
# near-misses to be repaired, they are typos to be reported. This is the
|
|
359
|
+
# `Integer`-coercion rationale on `--from-line` applied to a richer
|
|
360
|
+
# grammar: a spec that half-parses would silently deliver the wrong set,
|
|
361
|
+
# and delivering the wrong set is the one outcome a selector exists to
|
|
362
|
+
# prevent.
|
|
363
|
+
LINE_SPEC_ENTRY = /\A(\d+)(?:-(\d+))?\z/
|
|
364
|
+
|
|
365
|
+
STATUS_LABELS = {
|
|
366
|
+
accepted: "accepted",
|
|
367
|
+
refused: "refused",
|
|
368
|
+
undelivered: "not delivered",
|
|
369
|
+
unparseable: "unparseable"
|
|
370
|
+
}.freeze
|
|
371
|
+
|
|
372
|
+
def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
|
|
373
|
+
@stdout = stdout
|
|
374
|
+
@stderr = stderr
|
|
375
|
+
@env = env
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# @param argv [Array<String>]
|
|
379
|
+
# @return [Integer] 0, 1 or 2 — never anything else, and never by letting
|
|
380
|
+
# an exception reach the shell
|
|
381
|
+
def run(argv)
|
|
382
|
+
options = parse_options(argv)
|
|
383
|
+
return EXIT_OK if options.nil? # --help / --version already printed
|
|
384
|
+
|
|
385
|
+
# Ahead of `build_transport`, and that is the whole point of the branch
|
|
386
|
+
# being here rather than after it. Listing sends nothing, so it needs no
|
|
387
|
+
# endpoint and no key — and the file that most wants looking at is the
|
|
388
|
+
# one the formatter wrote *because* no key was set. A listing that
|
|
389
|
+
# demanded credentials would be unavailable in exactly the case it
|
|
390
|
+
# exists for.
|
|
391
|
+
return list(options) if options.list
|
|
392
|
+
|
|
393
|
+
# Before the file is opened, deliberately. "There is nowhere to send
|
|
394
|
+
# this" is the earlier question — which lines to send does not matter
|
|
395
|
+
# when nothing is going to accept them — and asking it first means an
|
|
396
|
+
# unconfigured run reads its one real problem instead of a complaint
|
|
397
|
+
# about a path that was never the point.
|
|
398
|
+
transport = build_transport
|
|
399
|
+
|
|
400
|
+
source = read_source(options)
|
|
401
|
+
results = source.lines.map { |number, text| deliver_line(number, text, transport) }
|
|
402
|
+
|
|
403
|
+
report(source, results, json: options.json)
|
|
404
|
+
exit_code(results)
|
|
405
|
+
rescue UsageError => e
|
|
406
|
+
@stderr.puts "specguard-ingest: error: #{e.message}"
|
|
407
|
+
EXIT_MISUSE
|
|
408
|
+
rescue ScriptError, StandardError => e
|
|
409
|
+
# The backstop that makes exit 1 mean one thing. Anything reaching here
|
|
410
|
+
# is a bug in this tool, not a verdict from the endpoint about anyone's
|
|
411
|
+
# run, so it is a 2 and it says so in those words.
|
|
412
|
+
@stderr.puts "specguard-ingest: internal error: #{e.class}: #{e.message}"
|
|
413
|
+
EXIT_MISUSE
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
private
|
|
417
|
+
|
|
418
|
+
# One expression, over the whole file. The `:undelivered` and
|
|
419
|
+
# `:unparseable` clause is first because 2 dominates: a line that never
|
|
420
|
+
# reached the endpoint leaves the job unfinished, and reporting that as
|
|
421
|
+
# "your content was refused" is the exact confusion the contract exists to
|
|
422
|
+
# prevent.
|
|
423
|
+
def exit_code(results)
|
|
424
|
+
return EXIT_MISUSE if results.any? { |result| %i[undelivered unparseable].include?(result.status) }
|
|
425
|
+
return EXIT_REFUSED if results.any? { |result| result.status == :refused }
|
|
426
|
+
|
|
427
|
+
EXIT_OK
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# `--list`: read the file, print what is in it, deliver nothing.
|
|
431
|
+
#
|
|
432
|
+
# Note what this does *not* do — it does not call {#exit_code}. Listing
|
|
433
|
+
# makes no request, so no endpoint has read anything and no verdict about
|
|
434
|
+
# anyone's run exists; routing it through the shared code would put a
|
|
435
|
+
# second producer behind {EXIT_REFUSED} and cost exit 1 the single meaning
|
|
436
|
+
# the class comment is built around. The two codes reachable from here are
|
|
437
|
+
# 0 (listed) and, via the {UsageError} {#read_source} raises, 2.
|
|
438
|
+
#
|
|
439
|
+
# It reuses {#read_source} rather than reading the file itself, which is
|
|
440
|
+
# what makes the numbers here the same numbers `--from-line` and `--lines`
|
|
441
|
+
# take, what makes a listing under either flag preview exactly the set a
|
|
442
|
+
# delivery would send, and what makes an invalid-UTF-8 line arrive as a
|
|
443
|
+
# line to be named rather than an exception.
|
|
444
|
+
#
|
|
445
|
+
# Under `--json` the rows become one document and the warning stays where
|
|
446
|
+
# it is. An empty listing still writes the document — the file was read,
|
|
447
|
+
# and `"lines": []` over a summary of zeroes is a true statement about it —
|
|
448
|
+
# whereas a file that could not be read raises out of {#read_source}
|
|
449
|
+
# before there is anything to be a document about.
|
|
450
|
+
def list(options)
|
|
451
|
+
source = read_source(options)
|
|
452
|
+
lines = source.lines.map { |number, text| listed_line(number, text) }
|
|
453
|
+
|
|
454
|
+
if lines.empty?
|
|
455
|
+
@stderr.puts "specguard-ingest: warning: #{source.path} holds no runs to list#{empty_detail(source)}"
|
|
456
|
+
return EXIT_OK unless options.json
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
if options.json
|
|
460
|
+
@stdout.puts IngestReporter.render_listing(source: source, lines: lines, counts: listed_counts(lines))
|
|
461
|
+
return EXIT_OK
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
lines.each { |line| @stdout.puts list_row(line) }
|
|
465
|
+
@stdout.puts list_summary(source)
|
|
466
|
+
EXIT_OK
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# One line of the file, as facts rather than as a judgement. An
|
|
470
|
+
# unparseable line is listed *as unparseable* — the same discipline
|
|
471
|
+
# {#deliver_line} applies, for the same reason: a line silently dropped
|
|
472
|
+
# from a preview is a preview that under-reports what the delivery would
|
|
473
|
+
# do.
|
|
474
|
+
#
|
|
475
|
+
# The envelope is free-form, so every field goes through {#scalar} for the
|
|
476
|
+
# reason {#deliver_line} reports `ci_run_id` that way: rendering
|
|
477
|
+
# `{"a"=>1}` as a branch would be this tool inventing structure the line
|
|
478
|
+
# does not have. `0 examples` and `no specs` are likewise different facts —
|
|
479
|
+
# an empty list is a run that carried none, a missing or non-Array `specs`
|
|
480
|
+
# is a line that does not say — so the count is `nil` rather than 0 for the
|
|
481
|
+
# second, and both renderers inherit that distinction rather than each
|
|
482
|
+
# making it.
|
|
483
|
+
def listed_line(number, text)
|
|
484
|
+
payload, problem = parse_payload(text)
|
|
485
|
+
return ListedLine.new(number: number, problem: problem) if payload.nil?
|
|
486
|
+
|
|
487
|
+
specs = payload["specs"]
|
|
488
|
+
duration = payload["duration_seconds"]
|
|
489
|
+
|
|
490
|
+
ListedLine.new(number: number,
|
|
491
|
+
branch: scalar(payload["branch"]),
|
|
492
|
+
commit_sha: scalar(payload["commit_sha"]),
|
|
493
|
+
ci_run_id: scalar(payload["ci_run_id"]),
|
|
494
|
+
examples: specs.is_a?(Array) ? specs.length : nil,
|
|
495
|
+
duration_seconds: duration.is_a?(Numeric) ? duration : nil)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# The row, off the facts above and nothing else — which is what makes the
|
|
499
|
+
# document and the listing two renderings of one reading of the line.
|
|
500
|
+
def list_row(line)
|
|
501
|
+
return "line #{line.number}: #{STATUS_LABELS.fetch(:unparseable)} — #{line.problem}" if line.problem
|
|
502
|
+
|
|
503
|
+
"line #{line.number}: #{listed_fields(line).join(', ')}"
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# `ci_run_id` is the decision-relevant one — a line without it has nothing
|
|
507
|
+
# for `RunRecorder` to fold onto and becomes a second run — so its absence
|
|
508
|
+
# is stated rather than left as a gap in the row.
|
|
509
|
+
def listed_fields(line)
|
|
510
|
+
[named("branch", line.branch),
|
|
511
|
+
named("commit_sha", line.commit_sha),
|
|
512
|
+
named("ci_run_id", line.ci_run_id),
|
|
513
|
+
line.examples ? "#{line.examples} example#{'s' unless line.examples == 1}" : "no specs",
|
|
514
|
+
line.duration_seconds ? "#{line.duration_seconds}s" : "no duration_seconds"]
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def named(name, value)
|
|
518
|
+
value ? "#{name} #{value}" : "no #{name}"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Says what was listed and, last and unconditionally, that nothing left
|
|
522
|
+
# the machine — the one thing a reader of a preview must not have to infer
|
|
523
|
+
# from the absence of a delivery report.
|
|
524
|
+
def list_summary(source)
|
|
525
|
+
parts = ["specguard-ingest: listed #{source.lines.length} line#{'s' unless source.lines.length == 1} " \
|
|
526
|
+
"from #{source.path}"]
|
|
527
|
+
|
|
528
|
+
parts << blank_clause(source) if source.blank.positive?
|
|
529
|
+
parts << skipped_clause(source) if source.skipped.positive?
|
|
530
|
+
parts << "nothing was delivered"
|
|
531
|
+
|
|
532
|
+
parts.join("; ")
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# One line, one attempt, one result — and no retry loop.
|
|
536
|
+
#
|
|
537
|
+
# The gem's no-retry decision is about in-run cost to CI wall clock, and
|
|
538
|
+
# this tool runs out of band where that argument does not apply. It still
|
|
539
|
+
# does not retry: the line is on disk and re-running the command is the
|
|
540
|
+
# retry, made by someone who can see why the first attempt failed rather
|
|
541
|
+
# than by a loop that cannot.
|
|
542
|
+
def deliver_line(number, text, transport)
|
|
543
|
+
payload, problem = parse_payload(text)
|
|
544
|
+
if payload.nil?
|
|
545
|
+
return LineResult.new(number: number, status: :unparseable, detail: problem, reasons: [problem])
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
ci_run_id = scalar(payload["ci_run_id"])
|
|
549
|
+
result = transport.deliver(payload)
|
|
550
|
+
|
|
551
|
+
case result.outcome
|
|
552
|
+
when :success
|
|
553
|
+
LineResult.new(number: number, status: :accepted, detail: "HTTP #{result.code}", code: result.code,
|
|
554
|
+
reasons: [], test_run_id: result.test_run_id, ci_run_id: ci_run_id)
|
|
555
|
+
when :rejected
|
|
556
|
+
# A non-2xx is not automatically a verdict. {CONTENT_REFUSAL_CODES}
|
|
557
|
+
# names the ones the platform actually forms an opinion in; the rest
|
|
558
|
+
# arrived, stored nothing, and said nothing about this run.
|
|
559
|
+
status = CONTENT_REFUSAL_CODES.include?(result.code) ? :refused : :undelivered
|
|
560
|
+
# `result.reasons` verbatim — the whole array, not the three
|
|
561
|
+
# `result.reason` flattens it to. A body that said nothing readable
|
|
562
|
+
# leaves it nil, which {IngestReporter} renders as `[]`.
|
|
563
|
+
LineResult.new(number: number, status: status, detail: result.reason, code: result.code,
|
|
564
|
+
reasons: result.reasons, ci_run_id: ci_run_id)
|
|
565
|
+
else
|
|
566
|
+
# No code, because no answer: the exception's rendering is the whole of
|
|
567
|
+
# what there is to say, and it is the only thing `reasons` can carry.
|
|
568
|
+
LineResult.new(number: number, status: :undelivered, detail: result.reason,
|
|
569
|
+
reasons: [result.reason], ci_run_id: ci_run_id)
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# `[payload, nil]`, or `[nil, problem]` naming why the line is not a run.
|
|
574
|
+
# A pair rather than one value of two types, because `JSON.parse` answers
|
|
575
|
+
# a bare `"text"` line with a String and a sentinel String would then be
|
|
576
|
+
# indistinguishable from a payload — and rather than an exception, because
|
|
577
|
+
# an unparseable line is a per-line result like any other: one corrupt
|
|
578
|
+
# line (a sink truncated by a killed process, most likely) must not stop
|
|
579
|
+
# the other thirty-nine from being delivered.
|
|
580
|
+
def parse_payload(text)
|
|
581
|
+
# Before `JSON.parse`, which raises an encoding error rather than a
|
|
582
|
+
# `JSON::ParserError` for these — and whose message would carry the
|
|
583
|
+
# offending bytes into a `String#strip` that raises in turn.
|
|
584
|
+
return [nil, "the line is not valid UTF-8, so it cannot be a run"] unless text.valid_encoding?
|
|
585
|
+
|
|
586
|
+
parsed = JSON.parse(text)
|
|
587
|
+
return [parsed, nil] if parsed.is_a?(Hash)
|
|
588
|
+
|
|
589
|
+
[nil, "the line is #{parsed.class} JSON, and a run is an object"]
|
|
590
|
+
rescue JSON::ParserError => e
|
|
591
|
+
[nil, "could not parse the line as JSON: #{e.message.lines.first.to_s.strip}"]
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def build_transport
|
|
595
|
+
configuration = Configuration.new(env: @env)
|
|
596
|
+
|
|
597
|
+
# Both named, and named separately. They fail for different reasons and
|
|
598
|
+
# are fixed in different places, and "delivery is not configured" would
|
|
599
|
+
# leave an operator who set one of the two guessing which.
|
|
600
|
+
raise UsageError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if blank?(configuration.endpoint)
|
|
601
|
+
raise UsageError, "no API key is configured (set SPECGUARD_API_KEY)" if blank?(configuration.api_key)
|
|
602
|
+
|
|
603
|
+
transport = Transport.new(endpoint: configuration.endpoint, api_key: configuration.api_key,
|
|
604
|
+
timeout: configuration.timeout)
|
|
605
|
+
# Asked once, here, so a malformed `SPECGUARD_ENDPOINT` is one exit 2
|
|
606
|
+
# rather than N identical `:failed` lines. `Transport#deliver` would
|
|
607
|
+
# otherwise swallow the same `ArgumentError` once per line and report a
|
|
608
|
+
# delivery problem for what is a configuration problem.
|
|
609
|
+
validate_endpoint(transport)
|
|
610
|
+
|
|
611
|
+
transport
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def validate_endpoint(transport)
|
|
615
|
+
transport.uri
|
|
616
|
+
rescue ArgumentError => e
|
|
617
|
+
raise UsageError, e.message
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# @param options [Options] which lines of which file. Whichever selector
|
|
621
|
+
# was given, everything it does not name is counted and held back, never
|
|
622
|
+
# renumbered — the whole value of resuming from a report is that line 12
|
|
623
|
+
# is still line 12.
|
|
624
|
+
# @raise [UsageError] for every way a file can refuse to be read. All of
|
|
625
|
+
# them are exit 2 — the tool was pointed at something it cannot work
|
|
626
|
+
# from, which is not a verdict about anybody's run.
|
|
627
|
+
def read_source(options)
|
|
628
|
+
path = options.path
|
|
629
|
+
raise UsageError, "no such file: #{path}" unless File.exist?(path)
|
|
630
|
+
raise UsageError, "not a file: #{path}" unless File.file?(path)
|
|
631
|
+
|
|
632
|
+
lines = []
|
|
633
|
+
blank = 0
|
|
634
|
+
skipped = 0
|
|
635
|
+
|
|
636
|
+
# Numbered from the file, not from the payloads: a blank line still
|
|
637
|
+
# advances the count, so line 12 in this report is line 12 in an editor.
|
|
638
|
+
#
|
|
639
|
+
# The selector is asked FIRST, in the one branch position the suffix
|
|
640
|
+
# test used to hold, so both narrowings inherit the numbering, the blank
|
|
641
|
+
# counting and the invalid-UTF-8 handling below unchanged.
|
|
642
|
+
#
|
|
643
|
+
# `strip` RAISES on a line that is not valid UTF-8 — pointing this
|
|
644
|
+
# command at a binary file by mistake is the obvious way to get one —
|
|
645
|
+
# and such a line is not blank, it is a line that cannot be a run. It is
|
|
646
|
+
# kept, so {#parse_payload} says exactly that about it and the rest of
|
|
647
|
+
# the file still delivers. Swallowing it here would lose a line silently
|
|
648
|
+
# and letting it raise would report a bug in this tool.
|
|
649
|
+
File.foreach(path).with_index(1) do |text, number|
|
|
650
|
+
if held_back?(number, options)
|
|
651
|
+
skipped += 1
|
|
652
|
+
elsif text.valid_encoding? && text.strip.empty?
|
|
653
|
+
blank += 1
|
|
654
|
+
else
|
|
655
|
+
lines << [number, text]
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
Source.new(path: path, lines: lines, blank: blank, skipped: skipped,
|
|
660
|
+
selector: options.line_set ? :line_set : :from_line)
|
|
661
|
+
rescue SystemCallError, IOError => e
|
|
662
|
+
raise UsageError, "could not read #{path}: #{e.message}"
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# The whole of the selection, and the only place it is decided. `--lines`
|
|
666
|
+
# is an explicit set, so a line it does not name is held back wherever in
|
|
667
|
+
# the file it sits; `--from-line` is a suffix, so only the lines before it
|
|
668
|
+
# are. The two never both apply — {#parse_options} refuses the pair.
|
|
669
|
+
def held_back?(number, options)
|
|
670
|
+
return !options.line_set.any? { |range| range.cover?(number) } if options.line_set
|
|
671
|
+
|
|
672
|
+
number < options.from_line
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# The per-line report on stdout — it is the product — with the diagnostics
|
|
676
|
+
# about this tool's own situation on stderr, which is the split
|
|
677
|
+
# `specguard-lint` already makes. `--json` moves the product and leaves the
|
|
678
|
+
# diagnostics: a run that delivered nothing is still loud on stderr, in
|
|
679
|
+
# both renderers, because the warning is a statement about this tool's
|
|
680
|
+
# situation and not a result.
|
|
681
|
+
#
|
|
682
|
+
# == Two renderers, one set of facts
|
|
683
|
+
#
|
|
684
|
+
# The counts and the folding groups are computed ONCE here and handed to
|
|
685
|
+
# whichever renderer runs. Both the text summary line and the document's
|
|
686
|
+
# `summary` are statements about the same numbers, and a command whose two
|
|
687
|
+
# renderers can disagree about how much of a file it delivered is worse
|
|
688
|
+
# than one that only prints prose: the disagreement is unfalsifiable from
|
|
689
|
+
# outside the process.
|
|
690
|
+
def report(source, results, json:)
|
|
691
|
+
if results.empty?
|
|
692
|
+
@stderr.puts "specguard-ingest: warning: #{source.path} holds no runs to deliver#{empty_detail(source)}"
|
|
693
|
+
return unless json
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
counts = status_counts(results)
|
|
697
|
+
foldings = folded_runs(results)
|
|
698
|
+
|
|
699
|
+
if json
|
|
700
|
+
@stdout.puts IngestReporter.render_delivery(source: source, results: results, counts: counts,
|
|
701
|
+
foldings: foldings)
|
|
702
|
+
return
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
results.each { |result| @stdout.puts line_report(result) }
|
|
706
|
+
@stdout.puts summary_line(source, results, counts)
|
|
707
|
+
foldings.each { |folding| @stdout.puts folding_observation(folding) }
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def status_counts(results)
|
|
711
|
+
results.group_by(&:status).transform_values(&:length)
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# The listing's counterpart to {#status_counts}. A preview delivers
|
|
715
|
+
# nothing, so `unparseable` is the only status a listed line can hold —
|
|
716
|
+
# but it is counted *here*, next to the delivery path's counts, rather
|
|
717
|
+
# than inside the renderer: {IngestReporter} states that its counts are
|
|
718
|
+
# handed in, and a count computed in the one place that promises not to
|
|
719
|
+
# compute them is the invariant holding by luck instead of by structure.
|
|
720
|
+
def listed_counts(lines)
|
|
721
|
+
{ unparseable: lines.count(&:problem) }
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Why there was nothing to do, when there is a reason other than "the file
|
|
725
|
+
# is empty". A `--from-line` past the end of the file, a `--lines` that
|
|
726
|
+
# names only lines the file does not have, and a genuinely empty file are
|
|
727
|
+
# the same silence otherwise, and only two of them are the user's mistake.
|
|
728
|
+
def empty_detail(source)
|
|
729
|
+
parts = []
|
|
730
|
+
parts << "#{source.blank} blank line#{'s' unless source.blank == 1}" if source.blank.positive?
|
|
731
|
+
parts << skipped_clause(source) if source.skipped.positive?
|
|
732
|
+
|
|
733
|
+
parts.empty? ? "" : " (#{parts.join('; ')})"
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Named for the flag that actually held them back, and worded for what
|
|
737
|
+
# that flag does. `--from-line` holds back a prefix, so those lines are
|
|
738
|
+
# "earlier"; `--lines` holds back whatever it did not name, which can sit
|
|
739
|
+
# anywhere in the file, so calling them earlier would be a lie in the
|
|
740
|
+
# common case.
|
|
741
|
+
def skipped_clause(source)
|
|
742
|
+
count = source.skipped
|
|
743
|
+
return "#{count} line#{'s' unless count == 1} not selected by --lines" if source.selector == :line_set
|
|
744
|
+
|
|
745
|
+
"#{count} earlier line#{'s' unless count == 1} skipped by --from-line"
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def blank_clause(source)
|
|
749
|
+
"#{source.blank} blank line#{'s' unless source.blank == 1} skipped"
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def line_report(result)
|
|
753
|
+
detail = [result.detail, identity(result)].compact.join(", ")
|
|
754
|
+
line = "line #{result.number}: #{STATUS_LABELS.fetch(result.status)}"
|
|
755
|
+
detail.empty? ? line : "#{line} — #{detail}"
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# The two facts criterion 4 can be stated from: what the endpoint said the
|
|
759
|
+
# run's id is, and whether the line carried a run identity of its own.
|
|
760
|
+
#
|
|
761
|
+
# A line with no `ci_run_id` is reported as having none and nothing more.
|
|
762
|
+
# The platform records such a run as its own row, but that happens inside
|
|
763
|
+
# `RunRecorder` where this tool cannot see it, and a claim it cannot
|
|
764
|
+
# observe is a claim it does not make.
|
|
765
|
+
def identity(result)
|
|
766
|
+
return nil unless result.status == :accepted
|
|
767
|
+
|
|
768
|
+
["test_run_id #{result.test_run_id || '(not reported)'}",
|
|
769
|
+
result.ci_run_id ? "ci_run_id #{result.ci_run_id}" : "no ci_run_id"].join(", ")
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# States how much of the file reached the endpoint, always, and in a form
|
|
773
|
+
# that cannot read as "all clean" when it was "nothing sent": the
|
|
774
|
+
# accepted count is over the total rather than on its own, and every other
|
|
775
|
+
# outcome gets a clause of its own instead of being folded into a
|
|
776
|
+
# remainder the reader has to compute.
|
|
777
|
+
def summary_line(source, results, counts)
|
|
778
|
+
parts = ["specguard-ingest: delivered #{counts.fetch(:accepted, 0)} of " \
|
|
779
|
+
"#{results.length} run#{'s' unless results.length == 1} from #{source.path}"]
|
|
780
|
+
|
|
781
|
+
parts << "#{counts[:refused]} refused" if counts[:refused]
|
|
782
|
+
parts << "#{counts[:undelivered]} could not be delivered" if counts[:undelivered]
|
|
783
|
+
parts << "#{counts[:unparseable]} could not be parsed" if counts[:unparseable]
|
|
784
|
+
parts << blank_clause(source) if source.blank.positive?
|
|
785
|
+
parts << skipped_clause(source) if source.skipped.positive?
|
|
786
|
+
|
|
787
|
+
parts.join("; ")
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# Folding, stated only where it was *seen*.
|
|
791
|
+
#
|
|
792
|
+
# The proposal for this tool asked it to say whether a replayed line
|
|
793
|
+
# folded into an existing run or created a new one, and the 202 body does
|
|
794
|
+
# not carry that flag. What it does carry is the run's id, and two lines
|
|
795
|
+
# that went out with the same `ci_run_id` and came back with the same
|
|
796
|
+
# `test_run_id` are two deliveries onto one row — which is not an
|
|
797
|
+
# inference about folding, it is folding, observed. Anything short of two
|
|
798
|
+
# such lines gets no {Folding} at all rather than a hedged one.
|
|
799
|
+
#
|
|
800
|
+
# Grouped here and rendered by {#folding_observation} or by
|
|
801
|
+
# {IngestReporter}, rather than grouped by each of them: one observation
|
|
802
|
+
# rendered twice cannot disagree with itself.
|
|
803
|
+
#
|
|
804
|
+
# @return [Array<Folding>]
|
|
805
|
+
def folded_runs(results)
|
|
806
|
+
results
|
|
807
|
+
.select { |result| result.status == :accepted && result.ci_run_id && result.test_run_id }
|
|
808
|
+
.group_by { |result| [result.ci_run_id, result.test_run_id] }
|
|
809
|
+
.filter_map do |(ci_run_id, test_run_id), group|
|
|
810
|
+
next if group.length < 2
|
|
811
|
+
|
|
812
|
+
Folding.new(ci_run_id: ci_run_id, test_run_id: test_run_id, numbers: group.map(&:number))
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def folding_observation(folding)
|
|
817
|
+
"specguard-ingest: lines #{folding.numbers.join(', ')} carried ci_run_id #{folding.ci_run_id} and each " \
|
|
818
|
+
"came back with test_run_id #{folding.test_run_id} — the endpoint folded them onto one run"
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# @return [Options, nil] `nil` when `--help` or `--version` has already
|
|
822
|
+
# said everything the invocation was asking for.
|
|
823
|
+
def parse_options(argv)
|
|
824
|
+
from_line = nil
|
|
825
|
+
line_set = nil
|
|
826
|
+
list = false
|
|
827
|
+
json = false
|
|
828
|
+
|
|
829
|
+
parser = OptionParser.new do |o|
|
|
830
|
+
o.banner = BANNER
|
|
831
|
+
o.separator ""
|
|
832
|
+
o.separator DESCRIPTION
|
|
833
|
+
o.separator ""
|
|
834
|
+
o.separator "Options:"
|
|
835
|
+
# Deliberately alongside the selectors rather than instead of them:
|
|
836
|
+
# they compose, so the set you list is the set you are about to send.
|
|
837
|
+
o.on("--list", "List the runs in <file> without delivering any of them") do
|
|
838
|
+
list = true
|
|
839
|
+
end
|
|
840
|
+
# `Integer` rather than a String and a `to_i`: `--from-line twelve`
|
|
841
|
+
# would otherwise become 0 and silently deliver the whole file, which
|
|
842
|
+
# is the one outcome a resume flag exists to prevent. OptionParser
|
|
843
|
+
# raises `InvalidArgument` instead, and that is a 2 like every other
|
|
844
|
+
# misuse.
|
|
845
|
+
o.on("--from-line N", Integer, "Start at line N of <file>, skipping the lines before it") do |value|
|
|
846
|
+
raise UsageError, "--from-line must be 1 or greater, got #{value}" if value < 1
|
|
847
|
+
|
|
848
|
+
from_line = value
|
|
849
|
+
end
|
|
850
|
+
# No coercion to lean on for this one — and deliberately not
|
|
851
|
+
# OptionParser's `String`, whose acceptor rejects an empty argument
|
|
852
|
+
# with a message about the flag rather than about the spec. So the
|
|
853
|
+
# raw value comes through and {#parse_line_set} does the same job by
|
|
854
|
+
# hand and to the same standard: every way of mistyping a spec is a
|
|
855
|
+
# UsageError that names what was wrong with it, and none of them falls
|
|
856
|
+
# back to the whole file.
|
|
857
|
+
o.on("--lines SPEC",
|
|
858
|
+
"Deliver only the lines SPEC names — numbers and ranges over <file>'s",
|
|
859
|
+
"own numbering, e.g. 3,7,12-15. Not combinable with --from-line") do |value|
|
|
860
|
+
line_set = parse_line_set(value)
|
|
861
|
+
end
|
|
862
|
+
# Composes with everything above it: it chooses the renderer, never
|
|
863
|
+
# the set that is listed or sent and never the code that is returned.
|
|
864
|
+
o.on("--json", "Emit one JSON document on stdout instead of the human report") do
|
|
865
|
+
json = true
|
|
866
|
+
end
|
|
867
|
+
o.on("-v", "--version", "Print the version and exit") do
|
|
868
|
+
@stdout.puts "specguard-ruby #{VERSION}"
|
|
869
|
+
return nil
|
|
870
|
+
end
|
|
871
|
+
o.on("-h", "--help", "Print this help and exit") do
|
|
872
|
+
@stdout.puts o
|
|
873
|
+
return nil
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
files = parser.parse(argv)
|
|
878
|
+
raise UsageError, "no file given — #{BANNER}" if files.empty?
|
|
879
|
+
raise UsageError, "one file at a time, got #{files.length}: #{files.join(', ')}" if files.length > 1
|
|
880
|
+
|
|
881
|
+
# Refused rather than intersected. Both answer "which lines", and an
|
|
882
|
+
# intersection would drop a number the user typed without saying so —
|
|
883
|
+
# see the class comment.
|
|
884
|
+
if from_line && line_set
|
|
885
|
+
raise UsageError, "--from-line and --lines both choose which lines to send; give one or the other"
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
Options.new(path: files.first, from_line: from_line || 1, list: list, line_set: line_set, json: json)
|
|
889
|
+
rescue OptionParser::ParseError => e
|
|
890
|
+
# Uncaught, this is the likeliest way a user sees a false "the endpoint
|
|
891
|
+
# refused your run": OptionParser raises and Ruby exits 1. Retyping it
|
|
892
|
+
# is what makes a typo'd flag a 2.
|
|
893
|
+
raise UsageError, e.message
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
# `3,7,12-15` → `[3..3, 7..7, 12..15]`, or {UsageError} — never a set that
|
|
897
|
+
# is "close to" what was typed. A selector that half-understood its
|
|
898
|
+
# argument would deliver the wrong runs, which is worse than not running
|
|
899
|
+
# at all, so every entry must match {LINE_SPEC_ENTRY} whole.
|
|
900
|
+
#
|
|
901
|
+
# Ranges are kept as Ranges rather than expanded: membership is
|
|
902
|
+
# `Range#cover?` over a handful of entries, so a fat-fingered
|
|
903
|
+
# `--lines 1-90000000` is refused by the file's length rather than by
|
|
904
|
+
# running the machine out of memory first.
|
|
905
|
+
#
|
|
906
|
+
# @return [Array<Range>] in the order typed, which does not matter —
|
|
907
|
+
# delivery order is the file's, always.
|
|
908
|
+
def parse_line_set(spec)
|
|
909
|
+
entries = spec.split(",", -1).map(&:strip)
|
|
910
|
+
raise UsageError, "--lines needs at least one line number, got #{spec.inspect}" if entries.empty?
|
|
911
|
+
|
|
912
|
+
entries.map { |entry| parse_line_spec_entry(entry, spec) }
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
def parse_line_spec_entry(entry, spec)
|
|
916
|
+
raise UsageError, "--lines has an empty entry in #{spec.inspect}" if entry.empty?
|
|
917
|
+
|
|
918
|
+
match = LINE_SPEC_ENTRY.match(entry)
|
|
919
|
+
raise UsageError, "--lines: #{entry.inspect} is not a line number or a N-M range" if match.nil?
|
|
920
|
+
|
|
921
|
+
first = Integer(match[1], 10)
|
|
922
|
+
last = match[2] ? Integer(match[2], 10) : first
|
|
923
|
+
raise UsageError, "--lines: line numbers start at 1, got #{entry.inspect}" if first < 1
|
|
924
|
+
raise UsageError, "--lines: #{entry.inspect} ends before it starts" if last < first
|
|
925
|
+
|
|
926
|
+
first..last
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def blank?(value)
|
|
930
|
+
value.nil? || value.to_s.strip.empty?
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# A run identity is whatever the CI provider exported, passed through by
|
|
934
|
+
# `Configuration` as a free-form string. Anything that is not a scalar is
|
|
935
|
+
# not one, and reporting `{"a"=>1}` as a `ci_run_id` would be this tool
|
|
936
|
+
# inventing structure the envelope does not have.
|
|
937
|
+
def scalar(value)
|
|
938
|
+
value.is_a?(String) || value.is_a?(Numeric) ? value.to_s : nil
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
end
|