specguard-rspec 0.2.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/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +735 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-lint +34 -0
- data/lib/specguard/rspec/annotation_lookup.rb +286 -0
- data/lib/specguard/rspec/annotation_scanner.rb +144 -0
- data/lib/specguard/rspec/cli.rb +466 -0
- data/lib/specguard/rspec/configuration.rb +459 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +63 -0
- data/lib/specguard/rspec/formatter.rb +919 -0
- data/lib/specguard/rspec/json_reporter.rb +163 -0
- data/lib/specguard/rspec/linter.rb +134 -0
- data/lib/specguard/rspec/payload_normalizer.rb +139 -0
- data/lib/specguard/rspec/scanner.rb +217 -0
- data/lib/specguard/rspec/schema.rb +122 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +310 -0
- data/lib/specguard/rspec/validator_backend.rb +1071 -0
- data/lib/specguard/rspec/version.rb +7 -0
- data/lib/specguard/rspec/violation_renderer.rb +372 -0
- data/lib/specguard/rspec.rb +70 -0
- data/script/bump-version.sh +115 -0
- metadata +88 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module SpecGuard
|
|
6
|
+
module RSpec
|
|
7
|
+
# `specguard-lint`'s command line, and the whole of the exit contract.
|
|
8
|
+
#
|
|
9
|
+
# == The contract, and the reason it needs defending
|
|
10
|
+
#
|
|
11
|
+
# 0 every annotation checked is valid (including "there were none")
|
|
12
|
+
# 1 at least one annotation is malformed
|
|
13
|
+
# 2 the linter could not do its job — misuse, or the tool itself broken
|
|
14
|
+
#
|
|
15
|
+
# Ruby does not give you this for free; it actively works against it.
|
|
16
|
+
# `ruby -e 'raise "boom"'` exits **1**, and so does an uncaught
|
|
17
|
+
# `OptionParser::InvalidOption`. So on the obvious implementation, every
|
|
18
|
+
# internal failure lands on the one code the contract has already spent on
|
|
19
|
+
# "an annotation is malformed":
|
|
20
|
+
#
|
|
21
|
+
# * `specguard-lint --chnaged` — a typo — would exit 1, and CI would
|
|
22
|
+
# report a malformed annotation that does not exist;
|
|
23
|
+
# * a vendored schema missing from the packaged gem would exit 1, the
|
|
24
|
+
# same false accusation, in the field, on someone else's machine.
|
|
25
|
+
#
|
|
26
|
+
# The project has shipped this defect shape repeatedly — SPGD-35, SPGD-52,
|
|
27
|
+
# SPGD-56 — but always as **false green**: a gate reporting success having
|
|
28
|
+
# checked nothing. This is its inverse, **false red**: a tool failure
|
|
29
|
+
# wearing the costume of a content failure. Both are the same underlying
|
|
30
|
+
# bug, a gate whose failure states are indistinguishable, and for a linter
|
|
31
|
+
# the exit code *is* the product.
|
|
32
|
+
#
|
|
33
|
+
# {#run} therefore rescues in three bands and returns rather than exits:
|
|
34
|
+
# {UsageError} and {SchemaError} are named, and everything else that is not
|
|
35
|
+
# a deliberate interruption is caught by a backstop. Exit 1 is produced in
|
|
36
|
+
# exactly one place — a failed {Linter::Result} — so it means that and
|
|
37
|
+
# nothing else.
|
|
38
|
+
#
|
|
39
|
+
# `Interrupt`, `SignalException` and `SystemExit` are deliberately *not*
|
|
40
|
+
# caught. Ctrl-C must stay Ctrl-C; mapping it to "the linter is broken"
|
|
41
|
+
# would be its own small lie.
|
|
42
|
+
#
|
|
43
|
+
# == All failures, not the first
|
|
44
|
+
#
|
|
45
|
+
# SPGD-12 §1 step 4 says the linter "exits 1 on the *first* malformed
|
|
46
|
+
# annotation". Its own exit-code table, one paragraph later, says "1 | One
|
|
47
|
+
# or more annotations are malformed", and the reference tool reports every
|
|
48
|
+
# one: `bin/validate-intent --source broken_intent_spec.rb` emits 5 FAIL
|
|
49
|
+
# blocks. Stopping at the first would turn one file into five CI
|
|
50
|
+
# round-trips. Reporting all of them is the ratified behaviour (human
|
|
51
|
+
# decision recorded on SPGD-82); the "first" wording is a known spec defect
|
|
52
|
+
# with a correction filed against SPGD-12 §1 and the SPGD-73 roadmap text.
|
|
53
|
+
#
|
|
54
|
+
# == Two validators, one report
|
|
55
|
+
#
|
|
56
|
+
# {#run} has exactly one branch on which validator produced the verdicts —
|
|
57
|
+
# the in-gem {Linter}, or the Go port shelled out to by {ValidatorBackend}
|
|
58
|
+
# when `SPECGUARD_VALIDATE_INTENT` names a binary. Both arms return
|
|
59
|
+
# {Linter::Result}s, so the branch closes immediately and the reporting and
|
|
60
|
+
# exit-code logic below is shared rather than duplicated.
|
|
61
|
+
#
|
|
62
|
+
# Because both arms produce the same bytes, the run has to SAY which one it
|
|
63
|
+
# was, or the answer is unrecoverable from the output — see
|
|
64
|
+
# {#report_backend}, which states it in one line on stderr on both arms.
|
|
65
|
+
# That line is the only thing the default configuration gained: stdout, the
|
|
66
|
+
# exit code and every finding are what they were before this file learned
|
|
67
|
+
# about a second validator.
|
|
68
|
+
#
|
|
69
|
+
# The backend's failure modes are exit 2 by construction: {ValidatorError}
|
|
70
|
+
# is rescued beside {UsageError}, which is what makes "the binary you named
|
|
71
|
+
# is missing" read as `specguard-lint: error: …` rather than reaching the
|
|
72
|
+
# backstop and reading as an `internal error:`. Either way it is a 2, and
|
|
73
|
+
# that is the property that matters — a broken tool must not borrow the
|
|
74
|
+
# code that means "your annotations are malformed".
|
|
75
|
+
#
|
|
76
|
+
# == Two renderers, and what `--json` does NOT touch
|
|
77
|
+
#
|
|
78
|
+
# `--json` (SPGD-305) replaces the human report on stdout with one JSON
|
|
79
|
+
# document over the very same {Linter::Result} list — see {JSONReporter}.
|
|
80
|
+
# It is a renderer, so the three things that are the contract are untouched
|
|
81
|
+
# by it: the exit code (the decision below is one expression, evaluated on
|
|
82
|
+
# both paths), stderr (the provenance line and every warning are byte-for-
|
|
83
|
+
# byte what they were), and the default path (without the flag, stdout is
|
|
84
|
+
# what it was, pinned as a regression lock in
|
|
85
|
+
# `spec/specguard/rspec/regression_targets_spec.rb`).
|
|
86
|
+
#
|
|
87
|
+
# No exit-2 path emits a document, and that is a decision rather than an
|
|
88
|
+
# omission. Every `rescue` below means the linter produced NO VERDICTS —
|
|
89
|
+
# bad flags, `--changed` outside a repository, an unloadable schema, an
|
|
90
|
+
# unmet `--require-validator`. A document is a report about what was
|
|
91
|
+
# checked; emitting `{"ok": false, "findings": []}` for a run that checked
|
|
92
|
+
# nothing would hand a stdout-reading consumer the project's signature
|
|
93
|
+
# defect — an empty clean-looking report standing in for "could not check" —
|
|
94
|
+
# and dressing it as structure would make it *more* convincing, not less.
|
|
95
|
+
# Those runs write prose to stderr, where diagnostics about the linter
|
|
96
|
+
# already live, and say what happened with the exit code.
|
|
97
|
+
class CLI
|
|
98
|
+
BANNER = "Usage: specguard-lint [options] [files...]"
|
|
99
|
+
|
|
100
|
+
# Every annotation checked was valid — or there were none to check.
|
|
101
|
+
# "Lint, don't require": a missing annotation is never an error.
|
|
102
|
+
EXIT_OK = 0
|
|
103
|
+
# One or more annotations are malformed. The only code produced by
|
|
104
|
+
# inspecting content, and the only path that reaches it is a failed
|
|
105
|
+
# {Linter::Result}.
|
|
106
|
+
EXIT_MALFORMED = 1
|
|
107
|
+
# The linter could not do its job: bad flags, `--changed` outside a git
|
|
108
|
+
# repository, an unloadable schema, or an unexpected internal error.
|
|
109
|
+
EXIT_MISUSE = 2
|
|
110
|
+
|
|
111
|
+
def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
|
|
112
|
+
@stdout = stdout
|
|
113
|
+
@stderr = stderr
|
|
114
|
+
@env = env
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param argv [Array<String>]
|
|
118
|
+
# @return [Integer] 0, 1 or 2 — never anything else, and never by
|
|
119
|
+
# letting an exception reach the shell
|
|
120
|
+
def run(argv)
|
|
121
|
+
options = parse_options(argv)
|
|
122
|
+
return EXIT_OK if options.nil? # --help / --version already printed
|
|
123
|
+
|
|
124
|
+
# nil unless SPECGUARD_VALIDATE_INTENT names a usable binary, in which
|
|
125
|
+
# case this raises rather than returning one that is not. Resolved
|
|
126
|
+
# before anything is selected or scanned, for the same reason the
|
|
127
|
+
# schema is: "the validator you asked for is not there" must never
|
|
128
|
+
# surface as a run that checked nothing and called itself clean.
|
|
129
|
+
backend = ValidatorBackend.resolve(env: @env)
|
|
130
|
+
|
|
131
|
+
# Immediately after resolution and before selection: the line is above
|
|
132
|
+
# the empty-selection warnings, it costs the `--version` probe at most
|
|
133
|
+
# once per run, and a run that dies later still said what was about to
|
|
134
|
+
# validate it. See #report_backend.
|
|
135
|
+
report_backend(backend)
|
|
136
|
+
|
|
137
|
+
# After the provenance line, so stderr carries what DID validate the
|
|
138
|
+
# run before the reason that was not good enough, and before selection,
|
|
139
|
+
# so an unmet assertion selects, scans and reports nothing.
|
|
140
|
+
#
|
|
141
|
+
# Being before #select also fixes a precedence, and it was chosen
|
|
142
|
+
# rather than inherited: `--changed --require-validator foo_spec.rb`
|
|
143
|
+
# with the variable unset reports the missing backend and never reaches
|
|
144
|
+
# the "--changed cannot be combined with explicit files" UsageError
|
|
145
|
+
# below. Both are exit 2, and the backend is the earlier question —
|
|
146
|
+
# which files to check does not matter when nothing is going to check
|
|
147
|
+
# them with the implementation that was asked for.
|
|
148
|
+
require_backend!(backend) if options[:require_validator]
|
|
149
|
+
|
|
150
|
+
# Only on the Ruby path. When the backend is active the binary carries
|
|
151
|
+
# its own schema and this gem's vendored copy governs nothing, so
|
|
152
|
+
# loading it would let an unrelated packaging accident fail a run that
|
|
153
|
+
# never reads it. The guard the load provides is not lost — the port
|
|
154
|
+
# exits 2 on a schema it cannot load, and #run maps that to a
|
|
155
|
+
# ValidatorError.
|
|
156
|
+
schema = Schema.load if backend.nil?
|
|
157
|
+
|
|
158
|
+
selection = select(options)
|
|
159
|
+
report_selection(selection, json: options[:json])
|
|
160
|
+
|
|
161
|
+
results = check(selection.files, backend: backend, schema: schema)
|
|
162
|
+
|
|
163
|
+
# Computed once, here, and handed to whichever renderer runs. `--json`
|
|
164
|
+
# is a second renderer over this list, not a second code path: the exit
|
|
165
|
+
# code below is the same expression it always was, and the document's
|
|
166
|
+
# `ok` is derived FROM it rather than recomputed from the findings, so
|
|
167
|
+
# the two renderers cannot disagree about whether the run passed.
|
|
168
|
+
code = results.any?(&:failed?) ? EXIT_MALFORMED : EXIT_OK
|
|
169
|
+
report_results(results, files: selection.count, json: options[:json], ok: code == EXIT_OK)
|
|
170
|
+
|
|
171
|
+
code
|
|
172
|
+
rescue UsageError, ValidatorError => e
|
|
173
|
+
@stderr.puts "specguard-lint: error: #{e.message}"
|
|
174
|
+
EXIT_MISUSE
|
|
175
|
+
rescue SchemaError => e
|
|
176
|
+
# Wording and stream follow bin/validate-intent:861-862 exactly.
|
|
177
|
+
@stderr.puts "error: #{e.message}"
|
|
178
|
+
EXIT_MISUSE
|
|
179
|
+
rescue ScriptError, StandardError => e
|
|
180
|
+
# The backstop that makes exit 1 mean one thing. Anything reaching here
|
|
181
|
+
# is a bug in the linter, not a verdict about anyone's annotations, so
|
|
182
|
+
# it is a 2 and it says so in those words.
|
|
183
|
+
@stderr.puts "specguard-lint: internal error: #{e.class}: #{e.message}"
|
|
184
|
+
EXIT_MISUSE
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
# One line per run naming the implementation that produced the verdicts.
|
|
190
|
+
#
|
|
191
|
+
# Two validators can now answer for this tool, and until this line existed
|
|
192
|
+
# a report from the Go port and a report from {Linter} were the same
|
|
193
|
+
# bytes. That is the same hole {ValidatorBackend} closes by making a
|
|
194
|
+
# missing binary a hard exit 2 and refusing a bare command name — "which
|
|
195
|
+
# validator did this CI job actually run" must not be unanswerable — left
|
|
196
|
+
# open for every binary that does resolve.
|
|
197
|
+
#
|
|
198
|
+
# Three things about it are deliberate:
|
|
199
|
+
#
|
|
200
|
+
# * it is on STDERR. The findings and the two `checked …` lines are the
|
|
201
|
+
# product and are pinned byte-for-byte across the two backends
|
|
202
|
+
# (`spec/specguard/rspec/validator_backend_spec.rb`); a line about the
|
|
203
|
+
# linter's own configuration belongs where the warnings are, and
|
|
204
|
+
# putting it on stdout would make the backends' stdout differ for the
|
|
205
|
+
# first time.
|
|
206
|
+
# * BOTH arms speak. "Validated in Ruby" as a positive statement is the
|
|
207
|
+
# whole point: a line that appeared only when the backend was on would
|
|
208
|
+
# leave its absence meaning either "the Ruby path" or "an older gem
|
|
209
|
+
# that never printed one", which is not an answer.
|
|
210
|
+
# * the off arm distinguishes unset from blank. {ValidatorBackend.resolve}
|
|
211
|
+
# deliberately collapses them — a blank `SPECGUARD_VALIDATE_INTENT=` in
|
|
212
|
+
# a CI environment file is somebody turning the backend off — and
|
|
213
|
+
# returns nil for both, so the distinction is unrecoverable from the
|
|
214
|
+
# backend and is read from `@env` here instead. Somebody who set the
|
|
215
|
+
# variable and got the Ruby path anyway needs to see that the value,
|
|
216
|
+
# not the wiring, is why.
|
|
217
|
+
def report_backend(backend)
|
|
218
|
+
@stderr.puts "specguard-lint: #{backend ? backend.provenance : ruby_provenance}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def ruby_provenance
|
|
222
|
+
"validated in Ruby (#{ruby_reason})"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Why {ValidatorBackend.resolve} returned nil, in the words the
|
|
226
|
+
# provenance line uses. Shared with {#require_backend!} on purpose: the
|
|
227
|
+
# two sentences describe the same two states, and a run that says "is set
|
|
228
|
+
# but blank, which means off" on one line and something else on the next
|
|
229
|
+
# would be two vocabularies for one fact. There is one, and it is here.
|
|
230
|
+
def ruby_reason
|
|
231
|
+
var = ValidatorBackend::ENV_VAR
|
|
232
|
+
return "#{var} is unset" if @env[var].nil?
|
|
233
|
+
|
|
234
|
+
"#{var} is set but blank, which means off"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# `--require-validator`: turn "I asked for the binary and silently did
|
|
238
|
+
# not get it" into an exit 2.
|
|
239
|
+
#
|
|
240
|
+
# The named-but-broken cases were already hard failures — {Runner#verify!}
|
|
241
|
+
# raises and #run rescues it. The gap this closes is the case where the
|
|
242
|
+
# variable never arrived: a mistyped name, a conditional CI step that did
|
|
243
|
+
# not run, an environment file that was not loaded. The run then succeeds,
|
|
244
|
+
# validated by the OTHER implementation, and the only trace is a stderr
|
|
245
|
+
# line nothing reads.
|
|
246
|
+
#
|
|
247
|
+
# That is not "same answer, different engine". The two JSON parsers do not
|
|
248
|
+
# accept the same language (see {ValidatorBackend}'s ratified differences),
|
|
249
|
+
# and where such a payload is schema-valid the two backends return
|
|
250
|
+
# different EXIT CODES for the same file with nothing in the output to say
|
|
251
|
+
# so. A gate whose failure states are indistinguishable is the defect this
|
|
252
|
+
# tool exists to remove, met here for the third time.
|
|
253
|
+
#
|
|
254
|
+
# A flag rather than a second environment variable, and that is the point
|
|
255
|
+
# of the feature rather than a style call: `SPECGUARD_VALIDATE_INTENT_REQURED=1`
|
|
256
|
+
# would be silently no assertion at all — the same bug, one level up.
|
|
257
|
+
# `--requre-validator` cannot fail open; OptionParser raises, and
|
|
258
|
+
# #parse_options turns that into a UsageError and an exit 2.
|
|
259
|
+
#
|
|
260
|
+
# A blank value here is a contradiction, not a quiet win: asking for the
|
|
261
|
+
# binary and turning it off in the same breath is a 2, and the message
|
|
262
|
+
# says which of the two happened in {#ruby_reason}'s words.
|
|
263
|
+
def require_backend!(backend)
|
|
264
|
+
return if backend
|
|
265
|
+
|
|
266
|
+
raise ValidatorError,
|
|
267
|
+
"--require-validator was given, but #{ruby_reason}, " \
|
|
268
|
+
"so this run would have been validated in Ruby"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The one branch. Both arms produce the same {Linter::Result} list, so
|
|
272
|
+
# everything downstream — the FAIL blocks, the summary line, the exit
|
|
273
|
+
# code — is literally the same code rather than two renderers that have
|
|
274
|
+
# to be kept in step.
|
|
275
|
+
def check(files, backend:, schema:)
|
|
276
|
+
return backend.check(files) if backend
|
|
277
|
+
|
|
278
|
+
Linter.new(schema).check(Scanner.scan_files(files))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Naming files and asking for the diff are contradictory instructions,
|
|
282
|
+
# and honouring the first while dropping the second silently is the same
|
|
283
|
+
# class of quiet no-op this tool exists to remove: `--changed` would
|
|
284
|
+
# appear to have been applied. That is misuse — exit 2.
|
|
285
|
+
def select(options)
|
|
286
|
+
if options[:files].any?
|
|
287
|
+
if options[:changed]
|
|
288
|
+
raise UsageError,
|
|
289
|
+
"--changed cannot be combined with explicit files; drop one " \
|
|
290
|
+
"(named files are checked as given, --changed derives them from the diff)"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
return FileSelector::Selection.new(files: options[:files], mode: :explicit)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
FileSelector.select(changed: options[:changed], base: options[:base], root: options[:root])
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The honest-reporting half of the `--changed` fix: the selected-file
|
|
300
|
+
# count is always stated, and an empty selection is loud on stderr, so
|
|
301
|
+
# "checked 12 files, found nothing" can never be mistaken for
|
|
302
|
+
# "checked nothing". The exit code is not the lever here — the contract
|
|
303
|
+
# fixes 0 for "no annotations" — which is exactly why the warning has to
|
|
304
|
+
# carry the weight.
|
|
305
|
+
#
|
|
306
|
+
# Under `--json` the count is not dropped, it MOVES: stdout carries one
|
|
307
|
+
# document and nothing else, and this line's number is that document's
|
|
308
|
+
# `summary.files`. The warnings are diagnostics about the linter rather
|
|
309
|
+
# than findings, so they stay on stderr exactly as they are — a run that
|
|
310
|
+
# selected nothing is still loud, in both renderers.
|
|
311
|
+
def report_selection(selection, json:)
|
|
312
|
+
if selection.empty?
|
|
313
|
+
@stderr.puts "specguard-lint: warning: selected 0 spec files — #{empty_reason(selection)}"
|
|
314
|
+
@stderr.puts "specguard-lint: warning: #{selection.note}" if selection.note
|
|
315
|
+
elsif !json
|
|
316
|
+
@stdout.puts "specguard-lint: checked #{selection.count} spec file#{'s' unless selection.count == 1}" \
|
|
317
|
+
"#{" changed since #{selection.base}" if selection.mode == :changed}"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# `:explicit` is absent by construction — an explicit Selection is only
|
|
322
|
+
# built from a non-empty file list, so it can never be empty.
|
|
323
|
+
def empty_reason(selection)
|
|
324
|
+
case selection.mode
|
|
325
|
+
when :changed then changed_empty_reason(selection)
|
|
326
|
+
else "no *_spec.rb found under #{Dir.pwd}"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Names the filter that actually emptied the selection. Saying "nothing in
|
|
331
|
+
# the diff matched *_spec.rb" when a spec file demonstrably changed —
|
|
332
|
+
# just not under this directory — is worse than saying nothing: it reads
|
|
333
|
+
# as a conclusion and stops the reader looking.
|
|
334
|
+
def changed_empty_reason(selection)
|
|
335
|
+
stats = selection.stats
|
|
336
|
+
base = selection.base
|
|
337
|
+
|
|
338
|
+
return "nothing in the diff against #{base} matched *_spec.rb" if stats.nil?
|
|
339
|
+
|
|
340
|
+
if stats.changed.zero?
|
|
341
|
+
"nothing changed against #{base}"
|
|
342
|
+
elsif stats.spec_matches.zero?
|
|
343
|
+
"#{stats.changed} file#{'s' unless stats.changed == 1} changed against #{base}, " \
|
|
344
|
+
"none matching *_spec.rb"
|
|
345
|
+
elsif stats.outside_root.positive?
|
|
346
|
+
"#{stats.spec_matches} changed spec file#{'s' unless stats.spec_matches == 1} against #{base}, " \
|
|
347
|
+
"but #{stats.outside_root} #{stats.outside_root == 1 ? 'is' : 'are'} outside #{Dir.pwd} " \
|
|
348
|
+
"(--changed selects only files under the current directory)"
|
|
349
|
+
else
|
|
350
|
+
"#{stats.spec_matches} changed spec file#{'s' unless stats.spec_matches == 1} against #{base} " \
|
|
351
|
+
"could not be read"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The FAIL block, in the shape `bin/validate-intent --source` emits: two
|
|
356
|
+
# spaces after `FAIL`, an em-dash before an extraction problem, eight
|
|
357
|
+
# spaces and `-> ` before each schema reason.
|
|
358
|
+
#
|
|
359
|
+
# FAIL spec/order_spec.rb:9
|
|
360
|
+
# -> <root>: missing required property 'entity'
|
|
361
|
+
# FAIL spec/order_spec.rb:28 — unterminated object literal (...)
|
|
362
|
+
#
|
|
363
|
+
# Two deliberate differences from the reference's `--source` mode, which
|
|
364
|
+
# is a fixture self-test rather than a linter:
|
|
365
|
+
#
|
|
366
|
+
# * no `PASS` line per valid annotation — a CI linter that prints a
|
|
367
|
+
# line per healthy annotation buries the failures in a large repo.
|
|
368
|
+
# The summary line carries the count instead, so "checked nothing"
|
|
369
|
+
# is still impossible to mistake for "all clean".
|
|
370
|
+
# * findings go to **stdout**, where the reference puts them and where
|
|
371
|
+
# lint findings conventionally go. Diagnostics about the linter
|
|
372
|
+
# itself (warnings, misuse, schema failure) stay on stderr.
|
|
373
|
+
#
|
|
374
|
+
# That second bullet used to end "…and the exit code — not the stream —
|
|
375
|
+
# is the machine-readable signal." It justified the STREAM SPLIT, which
|
|
376
|
+
# stands unchanged; but read as a claim about the tool it is now false,
|
|
377
|
+
# and it was already dated when it was written. It comes from SPGD-82,
|
|
378
|
+
# before the Go port grew `--json` (SPGD-102), and a 3-valued exit code
|
|
379
|
+
# was then genuinely the only structured thing a consumer could read.
|
|
380
|
+
# SPGD-305 gives this renderer a sibling: with `--json`, stdout carries
|
|
381
|
+
# one JSON document — file, line, kind, errors — and the exit code is one
|
|
382
|
+
# signal of two rather than the only one. Nothing moved off stderr to make
|
|
383
|
+
# that true; see {JSONReporter}.
|
|
384
|
+
#
|
|
385
|
+
# == Two renderers, one result list
|
|
386
|
+
#
|
|
387
|
+
# The partition below is computed ONCE and handed to whichever renderer
|
|
388
|
+
# runs. Both the text summary line and the document's `summary` are
|
|
389
|
+
# statements about the same numbers, and a linter whose two renderers can
|
|
390
|
+
# disagree about how much it checked is worse than one that only prints
|
|
391
|
+
# prose: the disagreement is unfalsifiable from outside the process.
|
|
392
|
+
def report_results(results, files:, json:, ok:)
|
|
393
|
+
annotations, unread = results.partition { |result| result.kind != Finding::KIND_READ }
|
|
394
|
+
|
|
395
|
+
if json
|
|
396
|
+
@stdout.puts JSONReporter.render(results, files: files, annotations: annotations.length, ok: ok)
|
|
397
|
+
return
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
results.reject(&:ok?).each { |result| report_failure(result) }
|
|
401
|
+
|
|
402
|
+
@stdout.puts summary_line(annotations, unread)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# The summary line exists for one reason — so "checked nothing" can never
|
|
406
|
+
# read as "all clean" — which makes overstating what was inspected its
|
|
407
|
+
# own kind of lie. A file that could not be opened contributed no
|
|
408
|
+
# annotation, so counting its Result as one would report "checked 12
|
|
409
|
+
# @intent annotations, 12 malformed" having read none of them. Unread
|
|
410
|
+
# files get their own clause: neither folded into the annotation count
|
|
411
|
+
# nor dropped from the report.
|
|
412
|
+
def summary_line(annotations, unread)
|
|
413
|
+
line = "specguard-lint: checked #{annotations.length} @intent annotation" \
|
|
414
|
+
"#{'s' unless annotations.length == 1}, #{annotations.count(&:failed?)} malformed"
|
|
415
|
+
return line if unread.empty?
|
|
416
|
+
|
|
417
|
+
"#{line}; #{unread.length} file#{'s' unless unread.length == 1} could not be read"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def report_failure(result)
|
|
421
|
+
if result.problem
|
|
422
|
+
@stdout.puts "FAIL #{result.location} — #{result.problem}"
|
|
423
|
+
else
|
|
424
|
+
@stdout.puts "FAIL #{result.location}"
|
|
425
|
+
result.reasons.each { |reason| @stdout.puts " -> #{reason}" }
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def parse_options(argv)
|
|
430
|
+
options = { changed: false, base: nil, root: Dir.pwd, files: [], require_validator: false, json: false }
|
|
431
|
+
|
|
432
|
+
parser = OptionParser.new do |o|
|
|
433
|
+
o.banner = BANNER
|
|
434
|
+
o.on("--changed[=BASE]",
|
|
435
|
+
"Only spec files changed against BASE (default: the merge base with the default branch)") do |base|
|
|
436
|
+
options[:changed] = true
|
|
437
|
+
options[:base] = base
|
|
438
|
+
end
|
|
439
|
+
o.on("--require-validator",
|
|
440
|
+
"Fail (exit 2) unless #{ValidatorBackend::ENV_VAR} named a usable validator binary") do
|
|
441
|
+
options[:require_validator] = true
|
|
442
|
+
end
|
|
443
|
+
o.on("--json", "Emit one JSON document on stdout instead of the human report") do
|
|
444
|
+
options[:json] = true
|
|
445
|
+
end
|
|
446
|
+
o.on("-v", "--version", "Print the version and exit") do
|
|
447
|
+
@stdout.puts "specguard-rspec #{VERSION}"
|
|
448
|
+
return nil
|
|
449
|
+
end
|
|
450
|
+
o.on("-h", "--help", "Print this help and exit") do
|
|
451
|
+
@stdout.puts o
|
|
452
|
+
return nil
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
options[:files] = parser.parse(argv)
|
|
457
|
+
options
|
|
458
|
+
rescue OptionParser::ParseError => e
|
|
459
|
+
# Uncaught, this is the single likeliest way a user sees a false
|
|
460
|
+
# "malformed annotation": OptionParser raises, Ruby exits 1. Retyping
|
|
461
|
+
# it is what makes `--chnaged` a 2.
|
|
462
|
+
raise UsageError, e.message
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|