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.
@@ -0,0 +1,1450 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "net/http"
7
+ require "open3"
8
+
9
+ module SpecGuard
10
+ module RSpec
11
+ # The Go validator backend: `validate-intent --source --json`, and since
12
+ # SPGD-867 the ONLY validator `specguard-lint` has.
13
+ #
14
+ # == What this is
15
+ #
16
+ # SPGD-96's Definition of Done ends "…and the Ruby gem invoking it for
17
+ # linting … with the Ruby hand-rolled validation logic removed". This file
18
+ # IS that sentence: {resolve} is default-on, obtaining a prebuilt
19
+ # `validate-intent` binary on first run ({Installer}) when
20
+ # `SPECGUARD_VALIDATE_INTENT` does not name one, and every run validates
21
+ # through the binary it resolved. There is no Ruby fallback — when no
22
+ # binary can be resolved the run exits 2 naming both remediations (see
23
+ # {Installer::REMEDIATIONS}).
24
+ #
25
+ # The reason the cutover waited was distribution, and that blocker is
26
+ # discharged: open-test-intent publishes prebuilt static binaries
27
+ # (`validate-intent-{linux,darwin}-{amd64,arm64}`, CGO_ENABLED=0, schema
28
+ # compiled in) plus a `SHA256SUMS` manifest on its GitHub releases,
29
+ # consumable via `scripts/install.sh` — so the gem can resolve or fetch a
30
+ # binary instead of failing every existing user's working linter.
31
+ #
32
+ # == Why a dedicated seam rather than {Configuration}
33
+ #
34
+ # The originating proposal put this on `Configuration`. That is the wrong
35
+ # object twice over: `Configuration` is the *telemetry* config — its only
36
+ # consumers are `Formatter` and `Transport`, and `CLI` does not reference
37
+ # it at all — so threading the lint backend through it would make the
38
+ # linter depend on the telemetry object solely to read one env var. It also
39
+ # memoizes process-wide (`SpecGuard::RSpec.configuration`), with
40
+ # `reset_configuration!` existing only for tests, which is a poor fit for
41
+ # something a spec wants to vary per example. {CLI} takes an `env:` instead
42
+ # and asks this module, so the seam is one hash away in a test and one
43
+ # method here in production.
44
+ #
45
+ # == The mapping, and the one thing the JSON document does not carry
46
+ #
47
+ # `RunSourceJSON` (open-test-intent, `cmd/validate-intent/report.go`)
48
+ # NORMALIZES the reference's `problem` and `errors` into a single `errors`
49
+ # list, saying so in a comment: "`problem` and `errors` are the same thing
50
+ # to a consumer… `kind` is what tells them apart". {Linter::Result} keeps
51
+ # them apart, and {CLI#report_failure} renders them differently — an em
52
+ # dash on one line for a `problem`, an indented `-> ` line per `reason`.
53
+ #
54
+ # So the split has to be *reconstructed from `kind`*, which is exactly what
55
+ # the Go comment says `kind` is for:
56
+ #
57
+ # schema -> reasons: errors
58
+ # extraction / parse / read -> problem: errors.first
59
+ # no-match -> problem, and see below
60
+ #
61
+ # For the three `problem` kinds an `errors` list of any length other than 1
62
+ # is a **contract change**, not something to paper over by joining the
63
+ # strings: the renderer would silently emit one line where the tool meant
64
+ # several. {Runner} raises, and the run exits 2.
65
+ #
66
+ # == `no-match`: the gem's arguments are PATHS, the binary's are GLOBS
67
+ #
68
+ # `validate-intent` expands its arguments as GLOB PATTERNS, recursive `**`
69
+ # included; `specguard-lint`'s are paths, checked as
70
+ # given (`CLI#select`, `Scanner.scan_file`). Handing a path straight through
71
+ # would silently re-expand it: `spec/fixtures/bracket[1]_spec.rb` becomes a
72
+ # character class, and a file containing `*` becomes a wildcard that may
73
+ # match *other* files. Every path is therefore escaped with {escape_glob},
74
+ # so each argument matches exactly the file it names or nothing at all.
75
+ #
76
+ # "Or nothing at all" is Go's `no-match` kind, which the gem has no
77
+ # `Finding::KIND_*` for. It is mapped to {Finding::KIND_READ}, because that
78
+ # is what it *means* on the gem's side of the seam: a named path that could
79
+ # not be opened. The classification, the dropped line number, the "N files
80
+ # could not be read" clause of the summary and the exit code all follow the
81
+ # Ruby path exactly. Two things about it are deliberate:
82
+ #
83
+ # * the reported file is the ORIGINAL path, not the escaped pattern —
84
+ # `[[]1]` is an artifact of this file and printing it would misreport
85
+ # what the caller asked for;
86
+ # * the wording is the gem's own, not Go's `no file(s) match <pattern>`,
87
+ # which is a statement about a *pattern* — a concept `specguard-lint`
88
+ # does not have, and whose text would carry the escaped form.
89
+ #
90
+ # This is one of the ratified differences between the backends, all of
91
+ # which are asserted in `spec/specguard/rspec/validator_backend_spec.rb`.
92
+ #
93
+ # The other three mechanisms are below. Only the first two are about TEXT —
94
+ # the backend passing the binary's wording through where the Ruby path
95
+ # spells it its own way. The third is not a wording difference at all,
96
+ # which is why this list does not call the group "text differences":
97
+ #
98
+ # * the read-failure tail — see {Scanner.scan_text}, which records the
99
+ # ratified difference and both spellings. Both refuse the file;
100
+ # PROTOCOL.md §1.1 requires that much and specifies no prose.
101
+ # * the parse-failure tail — see {Scanner#parse} (1). A payload that
102
+ # survives normalisation and still is not JSON is described by
103
+ # whichever JSON parser saw it, so the Ruby path carries Ruby's
104
+ # `JSON::ParserError#message` and the backend carries the binary's.
105
+ # This is the most commonly hit of the three: `parse` is one of the
106
+ # three things the linter exists to report.
107
+ # * THE ACCEPTANCE SET — see {Scanner#parse} (2), and note that this one
108
+ # is not a wording difference at all. It used to be a three-member set
109
+ # in which the BINARY was the permissive side; PROTOCOL.md §1.1 states
110
+ # the accepted JSON language now, and the binary refuses all three, so
111
+ # those have converged. What survives runs the other way: this gem's
112
+ # `JSON.parse` accepts a LONE LOW surrogate escape that §1.1(a)
113
+ # refuses, and its nesting boundary sits a little deeper than
114
+ # §1.1(c)'s 100. For such a payload the backend does not merely word
115
+ # the failure differently — it HAS one where the Ruby path does not,
116
+ # so the backend exits 1 where the Ruby path exits 0.
117
+ #
118
+ # That last case is the only known input on which the two backends return
119
+ # different verdicts for the same file, and it is ratified rather than
120
+ # closed for reasons {Scanner#parse} sets out. Nothing here can detect it:
121
+ # a document reporting no findings is exactly what a clean run looks like,
122
+ # so the mapping below is correct and the disagreement is upstream of it.
123
+ #
124
+ # It has NOT always been the only one, and the other one ran the DANGEROUS
125
+ # way round. Until SPGD-512 the binary's `@intent:` payload search was
126
+ # unbounded where {AnnotationScanner#payload_brace} bounds it at the next
127
+ # token, so on a line carrying a malformed token followed by a well-formed
128
+ # one the binary adopted the neighbour's literal, reported it valid and
129
+ # exited 0 where the Ruby path reported no-payload and exited 1 — backend
130
+ # permissive, Ruby strict, the reverse of the surviving case above. That is
131
+ # the direction that costs something: opting into the backend bought a
132
+ # false green. SPGD-512 ported the bound, which is why the claim above
133
+ # holds again; it is recorded rather than deleted because the shape can
134
+ # recur any time a scanner-stage rule lands in one implementation only, and
135
+ # a reader who sees only the surrogate case will not think to look for it.
136
+ #
137
+ # == Everything that can go wrong here is exit 2
138
+ #
139
+ # A missing binary, a binary that will not execute, a non-zero exit this
140
+ # cannot read a document out of, or unparseable output are all "the linter
141
+ # could not do its job". They must never reach exit 1, which the contract
142
+ # has already spent on "an annotation is malformed" ({CLI}). Hence
143
+ # {ValidatorError}, rescued beside {UsageError} so the message reads
144
+ # `specguard-lint: error: …` rather than the backstop's `internal error:`.
145
+ #
146
+ # == Naming what produced the verdicts — and why identity is NOT in that band
147
+ #
148
+ # The paragraph above is about failures to obtain a VERDICT. The binary's
149
+ # identity is not one, and the distinction is the whole of {#identity}.
150
+ #
151
+ # This file already refuses to let "which validator ran" be ambiguous in the
152
+ # two places it can be decided: a named-but-missing binary is a hard exit 2
153
+ # ({Runner#verify!}), and a bare command name is refused rather than
154
+ # PATH-resolved ({Runner#path_hint}) because "a run that succeeded against a
155
+ # different validator … nothing downstream can detect". Both close the hole
156
+ # for binaries that do not resolve. For every binary that DOES resolve, the
157
+ # run was still silent about it: a report produced by the port and a report
158
+ # produced by {Linter} were the same bytes.
159
+ #
160
+ # So {Runner#verify!} asks the binary who it is, once, before anything is
161
+ # selected or scanned — the same fail-early placement as the checks beside
162
+ # it — and {Runner#provenance} renders the answer for the one stderr line
163
+ # {CLI} prints per run. Three properties make that safe:
164
+ #
165
+ # * it is asked ONCE per run, so it cannot become a per-batch cost on a
166
+ # large audit. Not by memoizing the answer — {Runner#verify!} re-probes
167
+ # every time it is called, exactly as it re-runs the three file checks
168
+ # beside it — but because {ValidatorBackend.resolve} is the only thing
169
+ # that calls {Runner#verify!} and {CLI} resolves once per run;
170
+ # * the answer is passed through VERBATIM. A line the gem composed about
171
+ # the binary would be a claim by the gem; the point is to carry the
172
+ # binary's own statement, which is the only thing that can distinguish
173
+ # two builds this gem has never heard of;
174
+ # * a binary that cannot answer still validates. `--version` arrived in
175
+ # open-test-intent slice 6; an older build reads it as a filename,
176
+ # reports "no file(s) match" on stderr and exits 1. That must not cost
177
+ # a verdict, so the probe treats a non-zero exit, empty output, or
178
+ # output that cannot be rendered as one line as "identity unavailable"
179
+ # and never as a {ValidatorError}. `SystemCallError` is caught in the
180
+ # probe rather than left to {Runner#run}'s rescue for the same reason.
181
+ #
182
+ # "Unavailable" is then reported IN WORDS by {Runner#provenance}. Dropping
183
+ # the line instead would make a run that could not name its validator look
184
+ # exactly like a run nobody looked at — the silent-omission shape this
185
+ # project keeps naming, arrived at from a third direction.
186
+ #
187
+ # == Asking the answer a question: the schema contract across the seam
188
+ #
189
+ # The identity above was carried through and rendered, and nothing read it.
190
+ # One token in it is not decoration: open-test-intent's `VersionLine`
191
+ # (`cmd/validate-intent/version.go`) ends `schema sha256:<64-hex>`, the
192
+ # digest of the schema COMPILED INTO that binary, and `schema.go`'s
193
+ # `SchemaSHA256` says in as many words why it exists —
194
+ #
195
+ # "a gem that vendors schema A can be pointed at a binary built when
196
+ # canonical was B, and all three guards stay green while the two halves
197
+ # enforce different contracts."
198
+ #
199
+ # Every drift guard in this ecosystem compares a matched pair inside ONE
200
+ # checkout: `schema_test.go` digests the Go embed against the Go tree's
201
+ # `schemas/`, and `spec/specguard/rspec/schema_packaging_spec.rb` digests
202
+ # this gem's vendored copy against this gem's pin. Neither looks at an
203
+ # INSTALLED artifact, and neither can. The seam is ordinary, not contrived:
204
+ # `install.sh` fetches
205
+ # a released binary by version with nothing tying that release's vintage to
206
+ # the gem beside it, and {ENV_VAR} accepts any path on the host.
207
+ #
208
+ # And on this path the gem does not even read its own schema — `CLI#run`
209
+ # loads it only when the backend is nil, deliberately, because with the
210
+ # backend on the binary's copy is what governs. So the two contracts never
211
+ # met. {Runner#verify!} is where they now do, beside the three file checks
212
+ # and before anything is selected or scanned: the same fail-early placement
213
+ # this file already argues for, and for the same reason — a divergence is
214
+ # not a verdict about anyone's annotations.
215
+ #
216
+ # == CARRIED is not ENFORCED, and only one of them is the question
217
+ #
218
+ # The paragraphs above are how this check was first built, and they compare
219
+ # the wrong digest. `--version` reports the schema the artifact CARRIES —
220
+ # `SchemaSHA256` is a pure fold of the compiled-in bytes — while `LoadSchema`
221
+ # (`cmd/validate-intent/fileio.go`) gives a `schemas/open-test-intent.v1.json`
222
+ # found beside the executable priority over that copy and falls back to it
223
+ # only on ENOENT. `--version` returns some thirty lines above that decision
224
+ # and never reaches it; the binary's own `--help` trailer says the digest
225
+ # "is not a claim about what a given run enforced".
226
+ #
227
+ # So the carried digest is the one answer that cannot settle the question,
228
+ # and asking it fails in BOTH directions:
229
+ #
230
+ # * a binary whose embedded schema is ours, sitting beside a `schemas/`
231
+ # file that is not, reports a matching digest — the guard returns
232
+ # `:matched` and stays silent — and then enforces different bytes. That
233
+ # is precisely the case this check exists to refuse, passing;
234
+ # * the mirror: a binary whose embedded schema differs from ours but whose
235
+ # on-disk schema IS ours gets refused, for a run that would have been
236
+ # correct. Planting a schema beside the binary is not exotic — it is how
237
+ # open-test-intent's own release and cross-build checks operate.
238
+ #
239
+ # open-test-intent slice 19 added `--schema-source`, which calls the REAL
240
+ # loader and prints `schema <origin> sha256:<hex>` for the bytes a verdict
241
+ # run on this host would load. It is the only surface that answers the
242
+ # question, so {Runner#verify!} asks it — once per run, beside the identity
243
+ # probe — and the comparison uses the ENFORCED digest whenever one comes
244
+ # back, falling back to the carried digest, unchanged, when it does not.
245
+ #
246
+ # The bands, and the boundaries between them are the whole design:
247
+ #
248
+ # * ENFORCED AND EQUAL — the run proceeds, and {Runner#provenance} says so
249
+ # WITHOUT the hedge below, naming the origin the bytes were loaded from.
250
+ # This arm may state what the run enforces because that is the question
251
+ # `--schema-source` answers.
252
+ #
253
+ # * ENFORCED AND DIFFERENT — {ValidatorError}, exit 2, naming BOTH digests
254
+ # and the origin. This is the one addition to the exit-2 band, and it
255
+ # belongs there for the reason the band exists: a verdict produced under
256
+ # a different contract is not a verdict this gem can stand behind, and
257
+ # nothing downstream can notice — the report is a normal report, the
258
+ # exit code is a normal exit code, and the findings are whatever the
259
+ # other contract implies. Note this is the OPPOSITE of the identity
260
+ # rule above rather than an exception to it: "I could not ask" stays
261
+ # out of the band, "I asked and the answer was wrong" goes in.
262
+ #
263
+ # * CARRIED AND EQUAL, CARRIED AND DIFFERENT — the fallback, reached only
264
+ # when `--schema-source` did not answer, and byte for byte what this
265
+ # file did before it was asked: the same two outcomes, the same two
266
+ # messages, and the hedge kept on the matched arm because on that path
267
+ # it is still true.
268
+ #
269
+ # * NOT REPORTED — never a refusal, in any of its three shapes: a
270
+ # pre-slice-17 build whose `--version` carries no digest token, a binary
271
+ # that could not answer `--version` at all, and this gem being unable to
272
+ # read its own vendored schema. Each says so IN WORDS in its own
273
+ # wording, because "could not check" and "checked and clean" are two
274
+ # different statements and a checker owes both.
275
+ #
276
+ # == The rule the second probe inherits, and the one it cannot
277
+ #
278
+ # A binary predating slice 19 reads `--schema-source` as a filename, writes
279
+ # "no file(s) match" to stderr and exits 1; a binary whose schema exists and
280
+ # cannot be loaded exits 2 with the "could not load schema" diagnostic that
281
+ # belongs to it, which {#check} reaches a moment later anyway. Both are the
282
+ # identity probe's rule unchanged — a non-zero exit, empty output or output
283
+ # this cannot read is "unavailable" and never a {ValidatorError} — so with
284
+ # the flag absent the run is byte-identical to the one before this existed.
285
+ #
286
+ # That rule is also why {SCHEMA_SOURCE_PATTERN} is pinned against RECORDED
287
+ # output of the real binary in `spec/fixtures/validator/schema-source-probes.json`
288
+ # rather than against a hand-written line. A parse bug does not announce
289
+ # itself here: every miss reads as "this binary is too old", the guard
290
+ # silently reverts to comparing the carried digest, and the run goes green —
291
+ # the exact defect this file is closing, re-created inside the fix.
292
+ #
293
+ # What two probes CANNOT promise is what one probe did. {#verify_schema_contract!}
294
+ # reads {#identity} rather than re-asking, so the digest it compared and the
295
+ # name in the provenance line come from the same process; a second probe is
296
+ # a second process, so the enforced digest carries no such guarantee. It
297
+ # says what a run STARTED at that moment would load, which is the strongest
298
+ # thing any answer to this question can say — the schema beside a binary can
299
+ # change between two lines of a shell script — and the identity/carried pair
300
+ # keeps the promise it always had.
301
+ #
302
+ # That third shape is the one judgment call here, so it is recorded rather
303
+ # than left to be re-derived. The precedent was that an unreadable
304
+ # {SCHEMA_PATH} is exit 2 — but that was a schema the run was about to
305
+ # ENFORCE, and this one is not: `CLI#run` skips the load on this path
306
+ # precisely so an unrelated packaging accident cannot fail a run that never
307
+ # reads it, and making the digest fatal here would re-introduce exactly the
308
+ # dependency that comment removed. An unreadable vendored copy is also not
309
+ # what the exit-2 arm is for — divergence is a POSITIVE finding, two digests
310
+ # that differ, and a missing operand is not a difference. So it lands in the
311
+ # third band and is said out loud. (Hence {Digest::SHA256.file} rather than
312
+ # a digest needs bytes, not a parsed and validated document, so only an
313
+ # unreadable file can fail it and a schema this run does not use cannot
314
+ # fail it twice.)
315
+ #
316
+ # The digest is computed from {SCHEMA_PATH} at runtime and never written
317
+ # down here. A constant would be a fourth copy of a hex string that already
318
+ # exists in three places, drifting independently of the file it claims to
319
+ # describe — which is the precise failure this whole check was added to
320
+ # detect, re-created inside the detector.
321
+ module ValidatorBackend
322
+ # Names a `validate-intent` binary to validate through, overriding the
323
+ # default first-run auto-install. Blank or unset means the default:
324
+ # resolve a cached or freshly downloaded prebuilt binary ({Installer}).
325
+ ENV_VAR = "SPECGUARD_VALIDATE_INTENT"
326
+
327
+ # @param env [Hash, ENV]
328
+ # @return [Runner] always a runner — there is no Ruby fallback, so a
329
+ # binary that cannot be resolved raises rather than degrades
330
+ # @raise [ValidatorError] when no usable binary can be resolved
331
+ def self.resolve(env: ENV)
332
+ # Blank means unset, following `Configuration`'s `blank_to_nil` idiom:
333
+ # `SPECGUARD_VALIDATE_INTENT=` in a CI environment file is somebody
334
+ # asking for the default resolution, not for a binary named "".
335
+ path = env[ENV_VAR].to_s.strip
336
+ path = Installer.obtain(env: env) if path.empty?
337
+
338
+ Runner.new(path).tap(&:verify!)
339
+ end
340
+
341
+ # Escapes a POSIX path so the binary's globber matches it literally: wrap
342
+ # each of `*`, `?` and `[` in a character class, which makes it match
343
+ # itself. `]` is not escaped and does not need to be — outside a class it
344
+ # is already a literal.
345
+ #
346
+ # @param path [String]
347
+ # @return [String] a glob pattern matching exactly `path`
348
+ def self.escape_glob(path)
349
+ path.gsub(/([*?\[])/, '[\1]')
350
+ end
351
+
352
+ # Obtains a `validate-intent` binary for the DEFAULT-ON path: the
353
+ # platform-matched prebuilt asset from open-test-intent's GitHub release,
354
+ # verified against the release's `SHA256SUMS` manifest, cached under the
355
+ # user's cache dir so the network is touched once per machine.
356
+ #
357
+ # == Why the download is verified the way `Runner#verify!` verifies
358
+ #
359
+ # The posture is inherited from the schema-contract check: a downloaded
360
+ # blob is not a binary because we asked for one. The release publishes a
361
+ # `SHA256SUMS` manifest beside its assets, and the gem checks the ONE row
362
+ # that names this platform's asset — the same choice
363
+ # open-test-intent's own `scripts/install.sh` makes, for the reason its
364
+ # header gives (`sha256sum -c` would report the three rows this host did
365
+ # not stage as missing files). Only a row that matches BOTH the digest
366
+ # and the asset name counts, so a manifest that does not describe the
367
+ # fetched bytes is a refusal and not a pass.
368
+ #
369
+ # == Why a failed fetch is an exit 2 naming two remediations
370
+ #
371
+ # There is no Ruby fallback any more, so "no binary" must be loud: the
372
+ # message names the env var (point at a binary you already have) and the
373
+ # `install.sh` curl|sh path (the CI-pinned alternative). A first run with
374
+ # no network is the expected shape of this failure and the message is
375
+ # written for it.
376
+ module Installer
377
+ # The release the gem resolves against. Pinned by tag, not "latest":
378
+ # which validator validated a CI job must not change because somebody
379
+ # published a new release, and {Runner#verify_schema_contract!} pins
380
+ # the fetched binary to this gem's vendored schema anyway.
381
+ RELEASE_TAG = "v0.1.3"
382
+ REPOSITORY = "yatfa-ai/open-test-intent"
383
+ DOWNLOAD_BASE = "https://github.com/#{REPOSITORY}/releases/download/#{RELEASE_TAG}"
384
+
385
+ # The CI-pinned alternative to the auto-install: install the same
386
+ # verified release artifact onto PATH and point the env var at it.
387
+ INSTALL_SH = "curl -fsSL " \
388
+ "https://raw.githubusercontent.com/#{REPOSITORY}/#{RELEASE_TAG}/scripts/install.sh | sh"
389
+
390
+ # Both remediations, spelled once and reused by every refusal, so the
391
+ # message cannot drift between the ways the fetch can fail.
392
+ REMEDIATIONS = "set #{ValidatorBackend::ENV_VAR} to a validate-intent binary, " \
393
+ "or install one with: #{INSTALL_SH}"
394
+
395
+ # Where the cached binary lives when the user has not redirected it.
396
+ # Honours `SPECGUARD_CACHE_DIR` (hermetic CI caches), then XDG, then
397
+ # `~/.cache` for hosts without XDG set.
398
+ CACHE_DIR_VAR = "SPECGUARD_CACHE_DIR"
399
+
400
+ # `RUBY_PLATFORM` -> the release's os/arch vocabulary. Anything outside
401
+ # the four published assets is unsupported: guessable names
402
+ # (`freebsd`?) would produce a 404 masquerading as a policy, so the
403
+ # mapping is closed and the refusal names the platform it was asked
404
+ # about.
405
+ OS_FOR = { /linux/ => "linux", /darwin|mac/ => "darwin" }.freeze
406
+ ARCH_FOR = { /x86_64|amd64|x64/ => "amd64", /aarch64|arm64/ => "arm64" }.freeze
407
+
408
+ # `Net::HTTP` timeouts. Short on purpose: this runs at the front of a
409
+ # lint step, and a first-run fetch that hangs is worse than one that
410
+ # fails fast with the remediation message.
411
+ OPEN_TIMEOUT = 10
412
+ READ_TIMEOUT = 30
413
+ MAX_REDIRECTS = 5
414
+
415
+ class << self
416
+ # @param env [Hash, ENV]
417
+ # @return [String] path to an executable, SHA256SUMS-verified binary
418
+ # @raise [ValidatorError] when the platform is unsupported or the
419
+ # binary cannot be obtained or verified
420
+ def obtain(env: ENV)
421
+ asset = asset_name
422
+ destination = File.join(cache_dir(env), asset)
423
+
424
+ return destination if cached?(destination)
425
+
426
+ install(asset, destination)
427
+ end
428
+
429
+ # The release asset name for this host.
430
+ #
431
+ # @param platform [String] a `RUBY_PLATFORM`-shaped string;
432
+ # injectable so the mapping is testable off exotic hosts
433
+ # @raise [ValidatorError] on an OS or arch with no published asset
434
+ def asset_name(platform: RUBY_PLATFORM)
435
+ os = OS_FOR.find { |pattern, _| pattern.match?(platform) }&.last
436
+ arch = ARCH_FOR.find { |pattern, _| pattern.match?(platform) }&.last
437
+
438
+ if os.nil? || arch.nil?
439
+ raise ValidatorError,
440
+ "no prebuilt validate-intent release asset for #{platform} " \
441
+ "(#{RELEASE_TAG} publishes #{OS_FOR.values.uniq.product(ARCH_FOR.values.uniq) \
442
+ .map { |o, a| "#{o}/#{a}" }.join(', ')}) — #{REMEDIATIONS}"
443
+ end
444
+
445
+ "validate-intent-#{os}-#{arch}"
446
+ end
447
+
448
+ # @param env [Hash, ENV]
449
+ # @return [String]
450
+ def cache_dir(env)
451
+ override = env[CACHE_DIR_VAR].to_s.strip
452
+ xdg = env["XDG_CACHE_HOME"].to_s.strip
453
+ root = override.empty? ? (xdg.empty? ? File.join(Dir.home, ".cache") : xdg) : override
454
+ # Named for the gem, so a cached validator binary survives gem upgrades
455
+ # within one name and is re-downloaded once — and only once — when the
456
+ # gem itself changes name.
457
+ File.join(root, "specguard-ruby", "validate-intent", RELEASE_TAG)
458
+ end
459
+
460
+ # Downloads the asset plus its manifest, verifies, and installs
461
+ # atomically (temp file + rename, chmod 0755) so a killed download
462
+ # can never leave a half-written binary the next run mistakes for
463
+ # good — or worse, executes.
464
+ def install(asset, destination)
465
+ manifest = download("#{DOWNLOAD_BASE}/SHA256SUMS")
466
+ expected = manifest_digest(manifest, asset)
467
+
468
+ bytes = download("#{DOWNLOAD_BASE}/#{asset}")
469
+ actual = Digest::SHA256.hexdigest(bytes)
470
+ unless actual == expected
471
+ raise ValidatorError,
472
+ "downloaded #{DOWNLOAD_BASE}/#{asset} has sha256:#{actual}, but the release " \
473
+ "manifest says sha256:#{expected} — the download is not the artifact the " \
474
+ "release published, so it was not installed; #{REMEDIATIONS}"
475
+ end
476
+
477
+ dir = File.dirname(destination)
478
+ FileUtils.mkdir_p(dir)
479
+ tmp = "#{destination}.tmp.#{Process.pid}"
480
+ File.binwrite(tmp, bytes)
481
+ File.chmod(0o755, tmp)
482
+ File.rename(tmp, destination)
483
+ destination
484
+ rescue ValidatorError
485
+ raise
486
+ rescue StandardError => e
487
+ # Net::HTTP's failures (SocketError, OpenTimeout, EOFError, ...),
488
+ # and any filesystem failure writing the cache. All of them mean
489
+ # "no binary could be obtained", which is the one refusal this
490
+ # module has — exit 2, both remediations.
491
+ raise ValidatorError, "could not obtain validate-intent from #{DOWNLOAD_BASE}: " \
492
+ "#{e.class}: #{e.message}; #{REMEDIATIONS}"
493
+ end
494
+
495
+ private
496
+
497
+ def cached?(path)
498
+ File.file?(path) && File.executable?(path)
499
+ end
500
+
501
+ # The one row of `SHA256SUMS` naming this asset:
502
+ # `<64-hex> <asset-name>` (two spaces, the coreutils format the
503
+ # release workflow writes). A manifest without a row for the asset,
504
+ # or with a row this cannot read, is a refusal — an unverified
505
+ # install is the vacuous green this ecosystem keeps refusing.
506
+ def manifest_digest(manifest, asset)
507
+ manifest.each_line do |line|
508
+ match = /\A(?<digest>\h{64})\s+\*?(?<name>\S+)\s*\z/.match(line)
509
+ next if match.nil?
510
+ return match[:digest].downcase if match[:name] == asset
511
+ end
512
+
513
+ raise ValidatorError,
514
+ "the #{RELEASE_TAG} release manifest does not describe #{asset} — " \
515
+ "cannot verify the download, so it was not installed; #{REMEDIATIONS}"
516
+ end
517
+
518
+ # GET with redirects (the release download 302s to a signed object
519
+ # URL). Bounds the hop count so a misbehaving mirror cannot loop.
520
+ def download(url, redirects = MAX_REDIRECTS)
521
+ uri = URI(url)
522
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
523
+ open_timeout: OPEN_TIMEOUT,
524
+ read_timeout: READ_TIMEOUT) do |http|
525
+ request = Net::HTTP::Get.new(uri)
526
+ # GitHub's release endpoints require a client that names itself.
527
+ request["User-Agent"] = "specguard-ruby-installer"
528
+ http.request(request)
529
+ end
530
+
531
+ case response
532
+ when Net::HTTPRedirection
533
+ raise ValidatorError, "too many redirects fetching #{url}" if redirects.zero?
534
+
535
+ download(response["Location"], redirects - 1)
536
+ when Net::HTTPSuccess
537
+ response.body
538
+ else
539
+ raise ValidatorError,
540
+ "fetching #{url} answered HTTP #{response.code} — " \
541
+ "the release asset is not where the gem looked; #{REMEDIATIONS}"
542
+ end
543
+ end
544
+ end
545
+ end
546
+
547
+ # Invokes the binary and turns its report into {Linter::Result}s.
548
+ class Runner
549
+ # `kind` -> the gem's equivalent. `no-match` has no equivalent of its
550
+ # own and folds into KIND_READ; see the module comment.
551
+ KINDS = {
552
+ "schema" => Finding::KIND_SCHEMA,
553
+ "extraction" => Finding::KIND_EXTRACTION,
554
+ "parse" => Finding::KIND_PARSE,
555
+ "read" => Finding::KIND_READ,
556
+ "no-match" => Finding::KIND_READ
557
+ }.freeze
558
+
559
+ # Kinds that are a statement about a FILE rather than about an
560
+ # annotation site. They contribute nothing to `summary.annotations`,
561
+ # exactly as `CLI#summary_line` keeps unread files out of the gem's own
562
+ # annotation count.
563
+ #
564
+ # DERIVED from {KINDS}, not transcribed beside it: folding into
565
+ # KIND_READ is what "about a file, not about a site" MEANS here, so the
566
+ # filter is the rule rather than a copy of it. The copy could only go
567
+ # one way — {#failing_result} raises by name on a kind {KINDS} has not
568
+ # been taught, so the port growing its vocabulary forces an edit there
569
+ # and none here, and that one-line edit is exactly what would silence
570
+ # the guard while leaving a hand-written list behind. The set is pinned
571
+ # by name in the spec, so widening it stays a decision someone makes.
572
+ NON_ANNOTATION_KINDS = KINDS.select { |_kind, mapped| mapped == Finding::KIND_READ }.keys.freeze
573
+
574
+ # The report used to be all ASCII: paths, a fixed kind vocabulary, and
575
+ # error prose the tool wrote itself. Since SPGD-340 it also carries
576
+ # `intent` — WHAT THE PAYLOAD PARSED TO — so the DOCUMENT now inherits
577
+ # the PAYLOAD's parser-compatibility domain, and a payload the port
578
+ # accepts can be one Ruby's default reader will not read.
579
+ #
580
+ # ONE OF THE TWO OPTIONS IS LOAD-BEARING; THE OTHER IS BELT AND BRACES.
581
+ # Measured against the binary this gem is shipped alongside:
582
+ #
583
+ # max_nesting: false LOAD-BEARING. PROTOCOL.md §1.1(c) admits a
584
+ # payload nested to depth 100, and a finding WRAPS
585
+ # it, so the report nests two deeper than the
586
+ # payload while `JSON.parse` defaults to 100.
587
+ # Measured: payload depth 98 -> report nests 100,
588
+ # parses; depth 99 -> nests 101, raises
589
+ # JSON::NestingError. So depths 99-100 are
590
+ # PROTOCOL-LEGAL payloads whose report a default
591
+ # reader cannot parse at all.
592
+ #
593
+ # allow_nan: true NOT reachable from a current binary, and kept
594
+ # deliberately. An earlier revision of this
595
+ # comment said `1e400` decodes to inf and is
596
+ # re-emitted as `Infinity`; that was true of the
597
+ # CPython reference and is FALSE of the port,
598
+ # which now echoes a number from its own literal
599
+ # (`1e400` stays `1e400`) precisely because
600
+ # `Infinity` is not JSON and §1.1(b) refuses it.
601
+ # Measured: no probe payload produces a bare
602
+ # NaN/Infinity token anywhere in a report.
603
+ #
604
+ # Both are enabled rather than left to raise, because the alternative is
605
+ # a batch-wide exit 2 over one annotation's payload — and it would be an
606
+ # exit 2 for a payload the schema was going to REJECT anyway (a
607
+ # non-finite is a number and a deep nest is a container; neither can
608
+ # occupy a schema-legal slot, all of which are strings). Parsing them
609
+ # permissively changes no verdict. Refusing to parse them changes every
610
+ # verdict in the batch.
611
+ PARSE_OPTIONS = { allow_nan: true, max_nesting: false }.freeze
612
+
613
+ # A full audit passes every spec file in the repository — thousands of
614
+ # arguments on a large suite — and an argument vector has two separate
615
+ # kernel limits on Linux: `ARG_MAX` caps the TOTAL vector (a quarter of
616
+ # the stack rlimit, so typically ~2 MiB) and `MAX_ARG_STRLEN` caps each
617
+ # SINGLE argument at 128 KiB. Exceeding either is `E2BIG`. This budget
618
+ # is for the first, and is deliberately well under the typical value
619
+ # rather than derived from it: the limit is not portable (it is smaller
620
+ # on some kernels and much smaller on other Unixes), and there is
621
+ # nothing to gain from crowding it. The batches are an implementation
622
+ # detail: {#check} concatenates their findings in argument order, so the
623
+ # caller still gets one list and {CLI} still prints one selection line
624
+ # and one summary line.
625
+ MAX_ARG_BYTES = 96 * 1024
626
+ # A second, independent bound. Some kernels cap the argument *count*
627
+ # (and `MAX_ARG_BYTES` alone would allow ~90k one-character paths).
628
+ MAX_BATCH_FILES = 1_000
629
+
630
+ # The `--version` probe's own guard rail, and the only thing it asserts
631
+ # about the answer's SHAPE. The identity is passed through verbatim, so
632
+ # the question is not "is this the format I expect" — a future build may
633
+ # word it differently and still be telling the truth — but "can this be
634
+ # rendered as the one line {CLI} promises per run". A binary answering
635
+ # `--version` with a report document, a stack trace, or a megabyte of
636
+ # anything is answering a different question, and its output would break
637
+ # the line rather than fill it.
638
+ IDENTITY_MAX_BYTES = 200
639
+
640
+ # The one token of the identity line this file interprets, matched
641
+ # exactly as `cmd/validate-intent/version.go` writes it: the literal
642
+ # `schema sha256:` followed by 64 hex digits. Everything around it stays
643
+ # opaque — the version, the toolchain, the platform and anything a
644
+ # future build appends are still the binary's business.
645
+ #
646
+ # Anchored at both ends. Without the trailing boundary a 65-digit token
647
+ # would match its first 64 and be compared as if it were a digest; with
648
+ # it, such a token matches nothing and the run lands in the "not
649
+ # reported" band, which is the right answer for a line this gem cannot
650
+ # read rather than one it disagrees with.
651
+ #
652
+ # Case-insensitive, and the capture is compared downcased. Go's
653
+ # `hex.EncodeToString` is lower case and {Digest::SHA256#hexdigest} is
654
+ # too, so today this changes nothing; if a future build shouts, that is
655
+ # a spelling of the same digest and must not be reported as divergence.
656
+ SCHEMA_DIGEST_PATTERN = /\bschema sha256:(\h{64})\b/i
657
+
658
+ # The flag that answers what a run ENFORCES. Named as a constant for
659
+ # the reason `schemaSourceFlag` is one on the other side of the seam:
660
+ # the probe passes it, the diagnostics quote it, and the specs assert
661
+ # the argument vector — three places that must spell it the same way.
662
+ SCHEMA_SOURCE_FLAG = "--schema-source"
663
+
664
+ # `--schema-source` writes ONE line: `schema <origin> sha256:<64-hex>`,
665
+ # where the origin is either an absolute path or the literal
666
+ # `<embedded schema>` (`cmd/validate-intent/schemasource.go`,
667
+ # `SchemaSourceLine`).
668
+ #
669
+ # This is a SECOND pattern rather than a reuse of {SCHEMA_DIGEST_PATTERN},
670
+ # and the difference is not cosmetic: that one requires `schema`
671
+ # IMMEDIATELY followed by `sha256:`, which is how `--version` writes it,
672
+ # and the origin interposed here means it matches this surface never.
673
+ # Reusing it would have made every probe unreadable, every run fall back
674
+ # to the carried digest, and nothing go red — see the module comment.
675
+ #
676
+ # Anchored to the WHOLE line, unlike the identity token. There, the
677
+ # digest is one token inside a line that is otherwise the binary's own
678
+ # business; here the line IS the answer, so anything around it means
679
+ # this is not the surface being read and the run belongs in the
680
+ # unavailable band. Both captures matter: the digest is compared, and
681
+ # the origin is what lets {#provenance} say WHICH schema was enforced
682
+ # rather than that one was.
683
+ #
684
+ # The trailing anchor is what makes the digest the LAST `sha256:` token
685
+ # on the line rather than the first — the same reading Go's own comment
686
+ # prescribes for the shell (`${line##* }` "yields `sha256:<hex>`
687
+ # whatever the origin contains"). A path may contain spaces, and one may
688
+ # even be NAMED like this tail; ending the match at the end of the line
689
+ # is what keeps the answer the last token in both cases. (The origin is
690
+ # also captured greedily, but that is not what decides it: only one
691
+ # token can end the line.)
692
+ SCHEMA_SOURCE_PATTERN = /\Aschema (?<origin>\S(?:.*\S)?) sha256:(?<digest>\h{64})\z/i
693
+
694
+ # The `--schema-source` probe's renderability budget, and the reason it
695
+ # is not {IDENTITY_MAX_BYTES}. This line carries a filesystem path,
696
+ # which POSIX allows up to `PATH_MAX` (4096 on Linux) all by itself, so
697
+ # the 200-byte budget that suits a one-line version string would reject
698
+ # legitimate answers from correctly-installed binaries. It still bounds
699
+ # the line, for the same reason the other one does: a binary answering
700
+ # with a report document or a megabyte of anything is answering a
701
+ # different question, and its output would break the line rather than
702
+ # fill it.
703
+ SCHEMA_SOURCE_MAX_BYTES = 8 * 1024
704
+
705
+ attr_reader :path
706
+
707
+ # The binary's own `--version` line, or nil when it could not report
708
+ # one. Populated by {#verify!}; nil before it runs, which is why nothing
709
+ # constructs a Runner without it (see {ValidatorBackend.resolve}).
710
+ #
711
+ # Assigned unconditionally rather than memoized: a second {#verify!}
712
+ # re-probes, in step with the file checks it sits among, which also
713
+ # re-run. "Once per run" is a property of the single {#verify!} call
714
+ # {ValidatorBackend.resolve} makes, not of a cache here.
715
+ #
716
+ # @return [String, nil]
717
+ attr_reader :identity
718
+
719
+ # The result of the schema-contract comparison, one of:
720
+ #
721
+ # :enforced — the binary reported the schema its runs LOAD, and
722
+ # it is ours. The strong arm: this one is an answer
723
+ # about the contract a verdict would be produced
724
+ # under, not about the bytes the artifact carries
725
+ # :matched — `--schema-source` did not answer, so the carried
726
+ # digest was compared instead, and it is ours
727
+ # :unreported — it named itself but carries no digest token
728
+ # :unidentified — it could not name itself at all
729
+ # :unreadable — we could not read our own vendored schema
730
+ #
731
+ # There is no `:diverged` member: both comparisons raise out of
732
+ # {#verify!} on a difference, so no Runner ever exists holding one.
733
+ # Populated by {#verify!} beside {#identity}, and nil before it runs for
734
+ # the same reason.
735
+ #
736
+ # @return [Symbol, nil]
737
+ attr_reader :schema_contract
738
+
739
+ # What `--schema-source` answered: `{origin:, digest:}`, or nil when the
740
+ # binary could not answer it — a build predating open-test-intent slice
741
+ # 19, a schema that exists and will not load, or output this cannot
742
+ # read. Populated by {#verify!} beside {#identity}.
743
+ #
744
+ # The digest is the schema a run STARTED at that moment would enforce.
745
+ # It is a second process from {#identity}, so the two are not guaranteed
746
+ # to describe the same instant — see the module comment for what that
747
+ # does and does not cost.
748
+ #
749
+ # @return [Hash{Symbol => String}, nil]
750
+ attr_reader :enforced_schema
751
+
752
+ # @param path [String] the `validate-intent` binary
753
+ def initialize(path)
754
+ @path = path
755
+ end
756
+
757
+ # Fails before anything is selected or scanned, so "the backend you
758
+ # asked for is not there" is never mistaken for a verdict about
759
+ # anyone's annotations.
760
+ #
761
+ # The two probes ride along for the placement rather than for the
762
+ # check: asking here is what makes them once-per-run and ahead of
763
+ # selection. Neither can fail the run — see the module comment.
764
+ #
765
+ # The schema-contract comparison reads the answers the probes already
766
+ # obtained, so it costs no third process, and it CAN fail the run — see
767
+ # the module comment for why that one band belongs in exit 2 while the
768
+ # identity itself does not.
769
+ #
770
+ # @raise [ValidatorError]
771
+ def verify!
772
+ raise ValidatorError, "#{describe} does not exist#{path_hint}" unless File.exist?(@path)
773
+ raise ValidatorError, "#{describe} is not a file" unless File.file?(@path)
774
+ raise ValidatorError, "#{describe} is not executable" unless File.executable?(@path)
775
+
776
+ @identity = probe_identity
777
+ @enforced_schema = probe_schema_source
778
+ @schema_contract = verify_schema_contract!
779
+ self
780
+ end
781
+
782
+ # The active arm of {CLI}'s one-line-per-run provenance statement.
783
+ #
784
+ # The identity half is the binary's own words, uninterpreted. The path
785
+ # is spelled exactly as every diagnostic in this file spells it, so the
786
+ # line naming the validator and any error about it name the same thing.
787
+ #
788
+ # The clause after it is the gem's own, because it is a statement about
789
+ # a COMPARISON the gem made and not about the binary. Each of the five
790
+ # states it can report is worded distinctly: three of them mean the
791
+ # contract was not checked, for three different reasons, and collapsing
792
+ # them into one sentence would leave an operator unable to tell a build
793
+ # too old to answer from an installation missing its own schema. The
794
+ # remaining two are both "checked and clean" and must NOT be collapsed
795
+ # either — one of them answers what the run enforces and the other only
796
+ # what the binary carries, which is the whole distinction this file
797
+ # turns on.
798
+ #
799
+ # Composed from three pieces rather than branched on, because the
800
+ # identity and the schema contract are answers to two different
801
+ # questions and every combination of them is reachable: a binary can
802
+ # fail `--version` and answer `--schema-source`, and the line then has
803
+ # to say both things rather than the first one only.
804
+ #
805
+ # @return [String]
806
+ def provenance
807
+ "validated by #{@identity || 'the binary'} at #{@path} (#{ENV_VAR})" \
808
+ "#{identity_clause}#{schema_contract_clause}"
809
+ end
810
+
811
+ # @param paths [Enumerable<String>] spec files, as the caller named them
812
+ # @return [Array<Linter::Result>] in argument order
813
+ # @raise [ValidatorError] on any failure to obtain a report
814
+ def check(paths)
815
+ paths = paths.to_a
816
+ # `validate-intent --source` with no argument is a usage error (exit
817
+ # 2) — and there is nothing to ask it. `CLI#report_selection` has
818
+ # already warned about the empty selection.
819
+ return [] if paths.empty?
820
+
821
+ batch(paths).flat_map { |group| check_batch(group) }
822
+ end
823
+
824
+ private
825
+
826
+ def describe
827
+ "the validator backend at #{@path} (#{ENV_VAR})"
828
+ end
829
+
830
+ # A bare command name is refused rather than resolved against PATH, and
831
+ # the refusal says how to fix it. Which binary a CI job validated
832
+ # against should not depend on what else happens to be installed and in
833
+ # what order — and the failure that arrangement produces is a run that
834
+ # succeeded against a different validator, which nothing downstream can
835
+ # detect. One shell substitution buys a value that means one thing.
836
+ def path_hint
837
+ return "" if @path.include?(File::SEPARATOR)
838
+
839
+ " — #{ENV_VAR} takes a path, not a command name; try #{ENV_VAR}=\"$(command -v #{@path})\""
840
+ end
841
+
842
+ # Asks the binary who it is. Never raises: every way this can go wrong
843
+ # is "identity unavailable", which {#provenance} reports in words.
844
+ #
845
+ # `--version` is position-independent in the port and answers on stdout
846
+ # with exit 0 (`cmd/validate-intent/main.go`), so the whole answer is
847
+ # `stdout` and the exit code is a usable gate. A pre-slice-6 build has
848
+ # no such flag: it reads `--version` as a filename, writes "no file(s)
849
+ # match" to STDERR and exits 1 — caught by the exit check, with the
850
+ # stdout checks behind it in case some other build answers differently.
851
+ #
852
+ # The `SystemCallError` rescue is local rather than shared with {#run}'s
853
+ # because the two mean opposite things: there, a binary that will not
854
+ # execute is a run with no verdict and an exit 2; here it is a question
855
+ # that went unanswered, and {#check} will reach the same failure a
856
+ # moment later with the diagnostic that belongs to it.
857
+ #
858
+ # @return [String, nil]
859
+ def probe_identity
860
+ stdout, _stderr, status = Open3.capture3(@path, "--version")
861
+ return nil unless status.success?
862
+
863
+ identity_line(stdout)
864
+ rescue SystemCallError
865
+ nil
866
+ end
867
+
868
+ # The one shape check, and it is about renderability rather than
869
+ # format — see {IDENTITY_MAX_BYTES}. Anything that survives is returned
870
+ # byte for byte apart from surrounding whitespace, which is the
871
+ # newline `--version` ends with.
872
+ #
873
+ # The encoding test comes FIRST because `String#strip` raises
874
+ # `Encoding::CompatibilityError` on an invalid byte sequence, and an
875
+ # exception here would reach {CLI}'s backstop and turn a binary with an
876
+ # odd `--version` into `internal error:` and an exit 2 — the exact cost
877
+ # this probe is not allowed to have.
878
+ #
879
+ # @return [String, nil]
880
+ def identity_line(stdout)
881
+ return nil unless stdout.to_s.valid_encoding?
882
+
883
+ text = stdout.to_s.strip
884
+ return nil if text.empty? || text.bytesize > IDENTITY_MAX_BYTES
885
+ # A control character — a second line, an ANSI escape, a NUL — would
886
+ # break the single line this becomes, or forge extra ones.
887
+ return nil if text.match?(/[[:cntrl:]]/)
888
+
889
+ text
890
+ end
891
+
892
+ # Asks the binary which schema a run on this host would ENFORCE. Never
893
+ # raises, for the same reasons {#probe_identity} never does and with one
894
+ # more of its own.
895
+ #
896
+ # `--schema-source` is position-independent and answers on stdout with
897
+ # exit 0 (`cmd/validate-intent/schemasource.go`). Three ways it does
898
+ # not, and all three are "unavailable":
899
+ #
900
+ # * a build predating open-test-intent slice 19 reads the flag as a
901
+ # filename, writes `no file(s) match '--schema-source'` to stderr
902
+ # and exits 1. Refusing those runs would be enforcing a contract by
903
+ # breaking every binary too old to state one;
904
+ # * a schema that EXISTS beside the binary and cannot be read,
905
+ # decoded or compiled exits 2 with the "could not load schema"
906
+ # diagnostic. That is a real failure, but it is not this check's to
907
+ # report: {#check} reaches it a moment later, from the verdict path,
908
+ # with the diagnostic that belongs to it. Turning it into a schema
909
+ # CONTRACT error here would rename somebody's broken installation
910
+ # into a divergence that does not exist;
911
+ # * anything this cannot read as the one line the flag documents.
912
+ #
913
+ # @return [Hash{Symbol => String}, nil]
914
+ def probe_schema_source
915
+ stdout, _stderr, status = Open3.capture3(@path, SCHEMA_SOURCE_FLAG)
916
+ return nil unless status.success?
917
+
918
+ schema_source(stdout)
919
+ rescue SystemCallError
920
+ nil
921
+ end
922
+
923
+ # The parse, guarded exactly as {#identity_line} is and for exactly the
924
+ # same reasons — the encoding test first because `String#strip` raises
925
+ # on an invalid byte sequence, the control-character test because a
926
+ # second line or an ANSI escape would break or forge the single line
927
+ # {CLI} prints per run.
928
+ #
929
+ # Returning nil on a shape this does not recognise is the degrade rule,
930
+ # and it is also this method's one hazard: an unreadable answer and an
931
+ # old binary are indistinguishable downstream by design, so a bug here
932
+ # would be silent. Which is why {SCHEMA_SOURCE_PATTERN} is asserted
933
+ # against recorded output of the real binary rather than against a line
934
+ # written to fit it.
935
+ #
936
+ # @return [Hash{Symbol => String}, nil]
937
+ def schema_source(stdout)
938
+ return nil unless stdout.to_s.valid_encoding?
939
+
940
+ text = stdout.to_s.strip
941
+ return nil if text.empty? || text.bytesize > SCHEMA_SOURCE_MAX_BYTES
942
+ return nil if text.match?(/[[:cntrl:]]/)
943
+
944
+ match = SCHEMA_SOURCE_PATTERN.match(text)
945
+ return nil if match.nil?
946
+
947
+ { origin: match[:origin], digest: match[:digest].downcase }
948
+ end
949
+
950
+ # The comparison itself. Returns the state {#schema_contract} reports,
951
+ # and raises for the one band that is a refusal.
952
+ #
953
+ # The ENFORCED digest wins whenever there is one, because it is the only
954
+ # answer to the question being asked: a schema found beside the binary
955
+ # beats the compiled-in copy at validation time, so the carried digest
956
+ # can match while the run enforces other bytes, and can differ while the
957
+ # run enforces ours. The carried comparison below is what happens when
958
+ # the binary is too old to be asked — unchanged, hedge included.
959
+ #
960
+ # The carried arm reads {#identity}, which {#verify!} has just assigned
961
+ # — not a second `--version` — so the digest it compares and the name in
962
+ # the provenance line are guaranteed to have come from the same process.
963
+ # That promise is intact and this method still keeps it. It does NOT
964
+ # extend to the enforced digest, which is necessarily a second process:
965
+ # what that answer promises is what a run started at that moment would
966
+ # load, which is the most any answer to this question can promise, since
967
+ # the file beside a binary can change between two lines of a script.
968
+ #
969
+ # @return [Symbol]
970
+ # @raise [ValidatorError] when the compared digests are both known and differ
971
+ def verify_schema_contract!
972
+ return verify_enforced_contract! unless @enforced_schema.nil?
973
+ return :unidentified if @identity.nil?
974
+
975
+ carried = @identity[SCHEMA_DIGEST_PATTERN, 1]&.downcase
976
+ return :unreported if carried.nil?
977
+
978
+ verify_carried_contract!(carried)
979
+ end
980
+
981
+ # The strong arm: the binary told us which bytes its runs load.
982
+ def verify_enforced_contract!
983
+ vendored = vendored_schema_digest
984
+ return :unreadable if vendored.nil?
985
+ return :enforced if @enforced_schema[:digest] == vendored
986
+
987
+ # The origin joins the two digests here for the same reason they are
988
+ # both spelled in full: the reader's next question is which half to
989
+ # move, and `<embedded schema>` and `/usr/local/schemas/…` are fixed
990
+ # by entirely different actions — rebuild or reinstall the binary
991
+ # versus delete or replace a file on this host. Without it the
992
+ # message would state a disagreement and withhold the one fact that
993
+ # says where to go.
994
+ raise ValidatorError,
995
+ "#{describe} reports enforcing schema sha256:#{@enforced_schema[:digest]}, loaded from " \
996
+ "#{@enforced_schema[:origin]}, but this gem vendors sha256:#{vendored} — the two halves " \
997
+ "would enforce different contracts, so this run would produce a verdict this gem cannot " \
998
+ "stand behind; #{self_description}"
999
+ end
1000
+
1001
+ # The fallback, for a binary that cannot be asked what it enforces.
1002
+ # Byte for byte the comparison and the message this file had before
1003
+ # `--schema-source` existed.
1004
+ def verify_carried_contract!(carried)
1005
+ vendored = vendored_schema_digest
1006
+ return :unreadable if vendored.nil?
1007
+ return :matched if carried == vendored
1008
+
1009
+ # Both digests, spelled in full. A message naming only one of them
1010
+ # would send the reader to compute the other by hand, and the whole
1011
+ # point of this diagnostic is that the two halves are in different
1012
+ # places — one inside a binary, one inside an installed gem — and
1013
+ # neither is inspectable from where the other lives.
1014
+ #
1015
+ # The identity is named too, because the question that comes straight
1016
+ # after "these differ" is "WHICH build is this, so I know which half
1017
+ # to move" — and the path alone does not answer it: the same path can
1018
+ # hold a different binary tomorrow. The probe already holds the
1019
+ # version string, and this refusal happens above {#provenance}, so
1020
+ # unless the error says it nothing in the run ever does.
1021
+ raise ValidatorError,
1022
+ "#{describe} reports carrying schema sha256:#{carried}, but this gem vendors " \
1023
+ "sha256:#{vendored} — the two halves would enforce different contracts, so this run " \
1024
+ "would produce a verdict this gem cannot stand behind; the binary identifies itself " \
1025
+ "as #{@identity}"
1026
+ end
1027
+
1028
+ # How a refusal names the build it read a digest from. The carried arm
1029
+ # has an identity by construction; the enforced arm does not, because
1030
+ # the two probes are independent and a binary that answers one can fail
1031
+ # the other. Saying "identifies itself as " and then nothing would be
1032
+ # the worst of the three options.
1033
+ def self_description
1034
+ return "the binary could not identify itself" if @identity.nil?
1035
+
1036
+ "the binary identifies itself as #{@identity}"
1037
+ end
1038
+
1039
+ # This gem's own contract, digested from the file at runtime. Never a
1040
+ # constant: see the module comment.
1041
+ #
1042
+ # nil rather than an exception on an unreadable file, which puts a
1043
+ # broken installation in the "could not check" band instead of failing
1044
+ # a run that does not otherwise read this file at all. `Errno::ENOENT`
1045
+ # and `Errno::EACCES` are `SystemCallError`s; `IOError` covers the rest
1046
+ # of the ways a read can end without one.
1047
+ #
1048
+ # @return [String, nil] 64 lower-case hex digits
1049
+ def vendored_schema_digest
1050
+ Digest::SHA256.file(SCHEMA_PATH).hexdigest
1051
+ rescue SystemCallError, IOError
1052
+ nil
1053
+ end
1054
+
1055
+ # The identity half of the provenance line's tail: present exactly when
1056
+ # the binary could not name itself, and separate from the schema clause
1057
+ # because the two probes fail independently. With no `--schema-source`
1058
+ # answer this composes into the sentence this file printed before that
1059
+ # flag existed; with one, it stops a run whose identity is unknown from
1060
+ # silently dropping the fact.
1061
+ def identity_clause
1062
+ @identity.nil? ? ", which could not report its identity" : ""
1063
+ end
1064
+
1065
+ # The tail of the provenance line, one sentence per state.
1066
+ #
1067
+ # The ENFORCED arm is the only one that may speak about the run: it is
1068
+ # the answer to "which bytes would a verdict be produced under", asked
1069
+ # of the loader itself, and it names the origin so the reader can see
1070
+ # WHICH copy won. The matched (carried) arm below is careful twice over
1071
+ # instead — "reports carrying" attributes the claim to the binary, and
1072
+ # the clause after the dash refuses the stronger reading the reader
1073
+ # would otherwise supply for free. That hedge is kept rather than
1074
+ # retired because on the path that still reaches it — a binary too old
1075
+ # to answer `--schema-source` — it remains exactly true.
1076
+ def schema_contract_clause
1077
+ case @schema_contract
1078
+ when :enforced
1079
+ " — it reports enforcing the schema this gem vendors, loaded from #{@enforced_schema[:origin]}"
1080
+ when :matched
1081
+ ", which reports carrying the schema this gem vendors — the contract it carries, " \
1082
+ "not necessarily the one this run enforced"
1083
+ when :unreadable
1084
+ ", whose schema contract could not be checked: this gem could not read its own vendored copy"
1085
+ when :unidentified
1086
+ ", so the schema contract it carries could not be checked"
1087
+ else
1088
+ ", which reports no schema digest, so the contract it carries could not be checked"
1089
+ end
1090
+ end
1091
+
1092
+ # Greedy, order-preserving, and never empty: a single path longer than
1093
+ # the byte budget still gets its own batch rather than being silently
1094
+ # dropped from the run.
1095
+ #
1096
+ # That is a weaker promise than "it still gets checked", and
1097
+ # deliberately so. A path over `MAX_ARG_STRLEN` (128 KiB) is refused by
1098
+ # `execve` however it is batched — no grouping can make one argument
1099
+ # smaller — so it lands in {#run}'s `SystemCallError` rescue and the run
1100
+ # is exit 2. What batching buys is that the failure is LOUD: the file is
1101
+ # never quietly omitted from a report that then calls itself clean,
1102
+ # which is the silent-omission shape this project keeps naming.
1103
+ def batch(paths)
1104
+ groups = [[]]
1105
+ bytes = 0
1106
+
1107
+ paths.each do |path|
1108
+ size = ValidatorBackend.escape_glob(path).bytesize + 1
1109
+ if !groups.last.empty? && (bytes + size > MAX_ARG_BYTES || groups.last.length >= MAX_BATCH_FILES)
1110
+ groups << []
1111
+ bytes = 0
1112
+ end
1113
+ groups.last << path
1114
+ bytes += size
1115
+ end
1116
+
1117
+ groups
1118
+ end
1119
+
1120
+ def check_batch(paths)
1121
+ # The pattern list keeps DUPLICATES. `specguard-lint a a` reports
1122
+ # every annotation twice — the Ruby path checks the files it is
1123
+ # handed, in the order it is handed them, without de-duplicating —
1124
+ # and so does the port. Deriving the argument vector from the
1125
+ # lookup hash below instead would silently collapse them and halve
1126
+ # the report.
1127
+ patterns = paths.map { |path| ValidatorBackend.escape_glob(path) }
1128
+ # escaped pattern -> the path the caller named. Used to undo
1129
+ # {escape_glob} on a `no-match`, whose `file` is the pattern rather
1130
+ # than a path that exists. Collapsing duplicates here is harmless:
1131
+ # the same pattern always maps back to the same path.
1132
+ originals = patterns.zip(paths).to_h
1133
+
1134
+ document = run(patterns)
1135
+ findings = fetch_findings(document)
1136
+ check_annotation_count(document, findings)
1137
+
1138
+ findings.map { |finding| result_for(finding, originals) }
1139
+ end
1140
+
1141
+ # @return [Hash] the parsed report document
1142
+ def run(patterns)
1143
+ # No shell: the argument vector goes straight to execve, so a path
1144
+ # containing a space or a quote needs no quoting and cannot be
1145
+ # re-split. (`--json` is position-independent in the port; it is
1146
+ # written next to `--source` for readability.)
1147
+ stdout, stderr, status = Open3.capture3(@path, "--source", "--json", *patterns)
1148
+
1149
+ check_status(status, stderr)
1150
+ parse_document(stdout, stderr)
1151
+ rescue SystemCallError => e
1152
+ # ENOENT/EACCES between #verify! and here (a binary deleted or
1153
+ # chmod'ed mid-run), ENOEXEC for a file that is not a program, E2BIG
1154
+ # if the batching above is ever outgrown.
1155
+ raise ValidatorError, "#{describe} could not be executed: #{e.message}"
1156
+ end
1157
+
1158
+ # The port's own contract: 0 when everything checked was valid, 1 when
1159
+ # something was not. Anything else — 2 for a schema it could not load
1160
+ # or a mode it refuses, or death by signal — means it produced no
1161
+ # verdict, and neither may this run.
1162
+ def check_status(status, stderr)
1163
+ return if [0, 1].include?(status.exitstatus)
1164
+
1165
+ raise ValidatorError, "#{describe} #{exit_description(status)}#{trailing(stderr)}"
1166
+ end
1167
+
1168
+ def exit_description(status)
1169
+ return "exited #{status.exitstatus}" if status.exitstatus
1170
+
1171
+ "did not exit normally (#{status})"
1172
+ end
1173
+
1174
+ def parse_document(stdout, stderr)
1175
+ document = decode(stdout)
1176
+ unless document.is_a?(Hash)
1177
+ raise ValidatorError, "#{describe} emitted a #{document.class} where a JSON object was expected"
1178
+ end
1179
+
1180
+ # `--source` is the only mode this asks for. A document announcing
1181
+ # anything else means the argument vector above no longer says what
1182
+ # this code thinks it says — and a report read under the wrong mode's
1183
+ # counting rules is worse than no report.
1184
+ mode = document["mode"]
1185
+ raise ValidatorError, "#{describe} reported mode #{mode.inspect}, expected \"source\"" unless mode == "source"
1186
+
1187
+ document
1188
+ rescue JSON::ParserError => e
1189
+ raise ValidatorError,
1190
+ "#{describe} did not emit a JSON document: #{e.message}#{trailing(stderr)}"
1191
+ end
1192
+
1193
+ # An unpaired HIGH surrogate escape in the report text, e.g. `\ud800`
1194
+ # not followed by a low surrogate. Ruby's parser refuses it outright.
1195
+ #
1196
+ # NO CURRENT BINARY CAN EMIT ONE, and this is kept anyway. An earlier
1197
+ # revision of this comment said "CPython decodes it and keeps it, so the
1198
+ # port emits it" — that was true of the deleted Python reference and is
1199
+ # FALSE of the port since SPGD-403, which refuses an unpaired surrogate
1200
+ # escape at PARSE time under PROTOCOL.md §1.1(a) and reports the finding
1201
+ # with `intent: null`. Measured against the shipped binary: a payload
1202
+ # carrying `\ud800` yields `"kind": "parse"`, `"intent": null`, and a
1203
+ # report that parses with a plain `JSON.parse` and no options at all.
1204
+ #
1205
+ # It stays because the gem does not choose which binary it is pointed
1206
+ # at. `SPECGUARD_VALIDATE_INTENT` names an arbitrary path, and a report
1207
+ # from an OLDER build — or from a third-party implementation of the
1208
+ # protocol, which the vendor-neutral wording invites — is exactly the
1209
+ # input this class must not turn into a batch-wide exit 2. The recovery
1210
+ # is unreachable from the binary in this repository and cheap to keep;
1211
+ # deleting it would be trading a real safety net for tidiness.
1212
+ UNPAIRED_HIGH_SURROGATE = /\\u[dD][89abAB][0-9a-fA-F]{2}(?!\\u[dD][c-fC-F][0-9a-fA-F]{2})/
1213
+ private_constant :UNPAIRED_HIGH_SURROGATE
1214
+
1215
+ # What the escape above is rewritten to: U+FFFD REPLACEMENT CHARACTER,
1216
+ # chosen because it is always valid and is the same six characters wide
1217
+ # as the `\udXXX` it stands in for, so byte offsets in any parser error
1218
+ # still point where they did.
1219
+ UNREADABLE_ESCAPE = "\\ufffd"
1220
+ private_constant :UNREADABLE_ESCAPE
1221
+
1222
+ # Parse the report, recovering the VERDICTS from a document whose
1223
+ # PAYLOADS Ruby cannot read.
1224
+ #
1225
+ # Since the report began carrying `intent`, the document inherits the
1226
+ # payload's parser domain, and an unpaired high surrogate makes the
1227
+ # whole thing unparseable here. Letting that raise would cost the batch
1228
+ # an exit 2 — up to MAX_BATCH_FILES files reported as "the validator is
1229
+ # broken" — over one annotation that the port validated successfully and
1230
+ # that this gem, before the key existed, simply shipped as
1231
+ # `unannotated`. That is strictly worse than the behaviour the `intent`
1232
+ # key was added to improve, so it is not allowed to happen.
1233
+ #
1234
+ # THE RECOVERY DROPS EVERY PAYLOAD IN THE DOCUMENT AND KEEPS EVERY
1235
+ # VERDICT. Both halves are deliberate:
1236
+ #
1237
+ # * Keeping the verdicts is safe. `file`, `line`, `ok`, `kind` and
1238
+ # `errors` are the tool's own vocabulary and its own prose — no author
1239
+ # text reaches them — so they are unaffected by whatever the payload
1240
+ # contained. The exit code, the FAIL blocks and the counts come out
1241
+ # exactly as they would have.
1242
+ #
1243
+ # * Dropping ALL the payloads, rather than just the offending one, is
1244
+ # the conservative direction and the only one that cannot corrupt.
1245
+ # The substitution below operates on TEXT, and JSON text can contain
1246
+ # an escaped backslash — an author who literally wrote `\\ud800`
1247
+ # inside their annotation has a perfectly valid payload whose bytes
1248
+ # this regex cannot distinguish from a real escape without
1249
+ # re-implementing the scanner's backslash accounting. Rewriting that
1250
+ # author's payload and then SHIPPING it would put an annotation on the
1251
+ # platform that is not the one they wrote, undetectably (KB SPGD-78 in
1252
+ # value form). Dropping is a lie nobody acts on; corrupting is one
1253
+ # everybody does.
1254
+ #
1255
+ # The blast radius is bounded by never being worse than the status quo:
1256
+ # every annotation in such a document was ALREADY arriving unannotated
1257
+ # before this key existed, so the recovery costs nothing that was
1258
+ # previously being delivered.
1259
+ #
1260
+ # The rewrite SUBSTITUTES an equal-length escape rather than DELETING
1261
+ # the match, and that is structural rather than cosmetic. Where the
1262
+ # regex has landed on an author's literal `\\ud800`, the match begins at
1263
+ # the SECOND backslash — so deleting it strands the first, and an orphan
1264
+ # backslash escapes whatever now follows. Measured on a document
1265
+ # carrying a real lone surrogate and author-typed text together:
1266
+ # `lit\\ud800Xtail` deletes to `lit\Xtail` (`invalid escape character in
1267
+ # string`), and a match at the end of a value strands the backslash onto
1268
+ # the closing quote (`unexpected end of input`). Both raise HERE, inside
1269
+ # the recovery, and so become precisely the exit 2 the recovery exists
1270
+ # to prevent. Substituting keeps the backslash paired; all three cases
1271
+ # parse.
1272
+ #
1273
+ # The corruption objection above does not reach the substituted value,
1274
+ # because {#drop_every_payload} nils every `intent` on the very next
1275
+ # line: the rewritten text is read for its VERDICTS, and the value the
1276
+ # substitution produced is never shipped and never seen. Substitution is
1277
+ # strictly safer than deletion structurally and identical to it on
1278
+ # corruption — so the trade that bullet describes is not being re-made
1279
+ # here, only carried out without the self-inflicted parse error.
1280
+ def decode(stdout)
1281
+ JSON.parse(stdout, **PARSE_OPTIONS)
1282
+ rescue JSON::ParserError
1283
+ # Only a document that actually carries one gets rewritten. A parse
1284
+ # failure with no unpaired surrogate in it is a real one and is
1285
+ # re-raised for the handler above to report.
1286
+ raise unless stdout.match?(UNPAIRED_HIGH_SURROGATE)
1287
+
1288
+ # Block form: in a replacement STRING the backslash would be read as
1289
+ # the start of a backreference escape, which is the same class of bug
1290
+ # as the one being fixed.
1291
+ rewritten = stdout.gsub(UNPAIRED_HIGH_SURROGATE) { UNREADABLE_ESCAPE }
1292
+ drop_every_payload(JSON.parse(rewritten, **PARSE_OPTIONS))
1293
+ end
1294
+
1295
+ # Sets every finding's `intent` to nil in place. Written over the parsed
1296
+ # document rather than by not-parsing the key, because the findings are
1297
+ # what the rest of this class reads and `nil` is already its word for
1298
+ # "no payload here" — so nothing downstream needs to know a recovery
1299
+ # happened.
1300
+ def drop_every_payload(document)
1301
+ findings = document.is_a?(Hash) ? document["findings"] : nil
1302
+ return document unless findings.is_a?(Array)
1303
+
1304
+ findings.each { |finding| finding["intent"] = nil if finding.is_a?(Hash) }
1305
+ document
1306
+ end
1307
+
1308
+ def fetch_findings(document)
1309
+ findings = document["findings"]
1310
+ raise ValidatorError, "#{describe} emitted no `findings` array" unless findings.is_a?(Array)
1311
+ unless findings.all?(Hash)
1312
+ raise ValidatorError, "#{describe} emitted a `findings` entry that is not a JSON object"
1313
+ end
1314
+
1315
+ findings
1316
+ end
1317
+
1318
+ # The port counts ANNOTATION SITES EXAMINED in `summary.annotations`,
1319
+ # and a read failure or a no-match contributes none. So the count and
1320
+ # the findings are two independent statements about the same run, and
1321
+ # comparing them is what catches output truncated by a full pipe or a
1322
+ # killed batch — a case that would otherwise show up as a *smaller*
1323
+ # clean report, which is precisely the shape nobody notices.
1324
+ def check_annotation_count(document, findings)
1325
+ declared = document.dig("summary", "annotations")
1326
+ unless declared.is_a?(Integer)
1327
+ raise ValidatorError, "#{describe} emitted no integer `summary.annotations`"
1328
+ end
1329
+
1330
+ seen = findings.count { |finding| !NON_ANNOTATION_KINDS.include?(finding["kind"]) }
1331
+ return if seen == declared
1332
+
1333
+ raise ValidatorError,
1334
+ "#{describe} reported #{declared} annotation(s) but emitted #{seen} annotation finding(s)"
1335
+ end
1336
+
1337
+ def result_for(finding, originals)
1338
+ file = finding["file"]
1339
+ raise ValidatorError, "#{describe} emitted a finding with no `file`" unless file.is_a?(String)
1340
+
1341
+ kind = finding["kind"]
1342
+ errors = finding_errors(finding, file)
1343
+ # Absent on a binary too old to carry it, which reads as nil — the
1344
+ # same answer as "this finding had no payload". That is the intended
1345
+ # behaviour and not a gap to guard: an annotation whose payload could
1346
+ # not be obtained is `unannotated`, whatever the reason. There is no
1347
+ # Ruby re-parse to fall back to (SPGD-340 owner decision), so there is
1348
+ # no fallback to get wrong.
1349
+ intent = finding["intent"]
1350
+
1351
+ return passing_result(finding, file, kind, errors, intent) if finding["ok"] == true
1352
+
1353
+ failing_result(finding, file, kind, errors, originals, intent)
1354
+ end
1355
+
1356
+ def finding_errors(finding, file)
1357
+ errors = finding["errors"]
1358
+ unless errors.is_a?(Array) && errors.all?(String)
1359
+ raise ValidatorError, "#{describe} emitted a finding on #{file} whose `errors` is not a list of strings"
1360
+ end
1361
+
1362
+ errors
1363
+ end
1364
+
1365
+ def passing_result(finding, file, kind, errors, intent)
1366
+ unless kind.nil? && errors.empty?
1367
+ raise ValidatorError,
1368
+ "#{describe} emitted a passing finding on #{file} carrying kind #{kind.inspect} " \
1369
+ "and #{errors.length} error(s)"
1370
+ end
1371
+
1372
+ Linter::Result.new(file: file, line: line_of(finding, file), intent: intent)
1373
+ end
1374
+
1375
+ def failing_result(finding, file, kind, errors, originals, intent)
1376
+ mapped = KINDS[kind]
1377
+ # An unknown kind is a vocabulary the port grew and this file has not
1378
+ # been taught. Guessing would render it under whichever branch of
1379
+ # #report_failure it fell into by accident; refusing keeps the
1380
+ # divergence visible and costs one exit 2.
1381
+ raise ValidatorError, "#{describe} emitted the unknown kind #{kind.inspect} on #{file}" if mapped.nil?
1382
+ raise ValidatorError, "#{describe} emitted a failing finding on #{file} with no errors" if errors.empty?
1383
+
1384
+ return schema_result(finding, file, errors, intent) if kind == "schema"
1385
+
1386
+ # Extraction, parse, read and no-match findings have no payload by
1387
+ # construction — the port emits null for all four — so the intent is
1388
+ # not threaded past here. Reading it anyway would invent a case the
1389
+ # producer cannot produce.
1390
+ problem_result(finding, file, kind, errors, originals)
1391
+ end
1392
+
1393
+ def schema_result(finding, file, errors, intent)
1394
+ Linter::Result.new(file: file, line: line_of(finding, file),
1395
+ kind: Finding::KIND_SCHEMA, reasons: errors, intent: intent)
1396
+ end
1397
+
1398
+ def problem_result(finding, file, kind, errors, originals)
1399
+ unless errors.length == 1
1400
+ # See the module comment: joining these would collapse several
1401
+ # lines the tool meant to print into one em-dashed sentence.
1402
+ raise ValidatorError,
1403
+ "#{describe} emitted #{errors.length} errors on a #{kind} finding for #{file}, " \
1404
+ "where the report can render exactly one"
1405
+ end
1406
+
1407
+ return no_match_result(file, originals) if kind == "no-match"
1408
+
1409
+ Linter::Result.new(file: file, line: line_of(finding, file),
1410
+ kind: KINDS.fetch(kind), problem: errors.first)
1411
+ end
1412
+
1413
+ # A path that matched nothing. Reported against the path the caller
1414
+ # named — `originals` undoes {ValidatorBackend.escape_glob} — and in
1415
+ # the gem's own words, because Go's "no file(s) match <pattern>" is a
1416
+ # statement about a glob pattern this CLI does not have and would carry
1417
+ # the escaped spelling into the report.
1418
+ def no_match_result(pattern, originals)
1419
+ Linter::Result.new(file: originals.fetch(pattern, pattern), line: 0,
1420
+ kind: Finding::KIND_READ,
1421
+ problem: "could not read file: no file at this path")
1422
+ end
1423
+
1424
+ # `line` is null exactly where the finding is not line-scoped, which is
1425
+ # the same rule `Linter::Result#location` applies from the other side —
1426
+ # it drops the line for KIND_READ. 0 is the sentinel `Scanner` already
1427
+ # uses for a finding that never reached a line.
1428
+ def line_of(finding, file)
1429
+ line = finding["line"]
1430
+ return 0 if line.nil?
1431
+ raise ValidatorError, "#{describe} emitted a non-integer `line` on #{file}" unless line.is_a?(Integer)
1432
+
1433
+ line
1434
+ end
1435
+
1436
+ # The binary's stderr, appended to a diagnostic when it said anything.
1437
+ # A `--json` run that failed to produce a document has usually
1438
+ # explained itself there ("error: could not load schema …"), and
1439
+ # dropping it would leave the operator with "it did not emit a JSON
1440
+ # document" and nothing to act on.
1441
+ def trailing(stderr)
1442
+ text = stderr.to_s.strip
1443
+ return "" if text.empty?
1444
+
1445
+ " — it said: #{text.lines.first.strip}"
1446
+ end
1447
+ end
1448
+ end
1449
+ end
1450
+ end