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.
@@ -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