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,375 @@
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 two bands and returns rather than exits:
34
+ # {UsageError} and {ValidatorError} are named, and everything else that is
35
+ # not a deliberate interruption is caught by a backstop. Exit 1 is produced
36
+ # in 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 validator reports every one:
48
+ # `validate-intent --source broken_intent_spec.rb` emits 5 FAIL blocks. Stopping at the first would turn one file into five CI
49
+ # round-trips. Reporting all of them is the ratified behaviour (human
50
+ # decision recorded on SPGD-82); the "first" wording is a known spec defect
51
+ # with a correction filed against SPGD-12 §1 and the SPGD-73 roadmap text.
52
+ #
53
+ # == One validator, one report
54
+ #
55
+ # Since the SPGD-867 cutover there is exactly one validator: the
56
+ # `validate-intent` binary {ValidatorBackend} resolves (an explicit
57
+ # `SPECGUARD_VALIDATE_INTENT` path, or the first-run auto-install). The
58
+ # Ruby hand-rolled validation arm is gone — when no binary can be resolved
59
+ # the run exits 2 naming both remediations, and it NEVER silently
60
+ # validates some other way. The verdicts arrive as {Linter::Result}s, and
61
+ # the reporting and exit-code logic is shared with nothing because there
62
+ # is nothing to share it with.
63
+ #
64
+ # Because both arms produce the same bytes, the run has to SAY which one it
65
+ # was, or the answer is unrecoverable from the output — see
66
+ # {#report_backend}, which states it in one line on stderr on both arms.
67
+ # That line is the only thing the default configuration gained: stdout, the
68
+ # exit code and every finding are what they were before this file learned
69
+ # about a second validator.
70
+ #
71
+ # The backend's failure modes are exit 2 by construction: {ValidatorError}
72
+ # is rescued beside {UsageError}, which is what makes "the binary you named
73
+ # is missing" read as `specguard-lint: error: …` rather than reaching the
74
+ # backstop and reading as an `internal error:`. Either way it is a 2, and
75
+ # that is the property that matters — a broken tool must not borrow the
76
+ # code that means "your annotations are malformed".
77
+ #
78
+ # == Two renderers, and what `--json` does NOT touch
79
+ #
80
+ # `--json` (SPGD-305) replaces the human report on stdout with one JSON
81
+ # document over the very same {Linter::Result} list — see {JSONReporter}.
82
+ # It is a renderer, so the three things that are the contract are untouched
83
+ # by it: the exit code (the decision below is one expression, evaluated on
84
+ # both paths), stderr (the provenance line and every warning are byte-for-
85
+ # byte what they were), and the default path (without the flag, stdout is
86
+ # what it was, pinned as a regression lock in
87
+ # `spec/specguard/rspec/regression_targets_spec.rb`).
88
+ #
89
+ # No exit-2 path emits a document, and that is a decision rather than an
90
+ # omission. Every `rescue` below means the linter produced NO VERDICTS —
91
+ # bad flags, `--changed` outside a repository, a validator that could not
92
+ # be resolved or produced no verdict. A document is a report about what was
93
+ # checked; emitting `{"ok": false, "findings": []}` for a run that checked
94
+ # nothing would hand a stdout-reading consumer the project's signature
95
+ # defect — an empty clean-looking report standing in for "could not check" —
96
+ # and dressing it as structure would make it *more* convincing, not less.
97
+ # Those runs write prose to stderr, where diagnostics about the linter
98
+ # already live, and say what happened with the exit code.
99
+ class CLI
100
+ BANNER = "Usage: specguard-lint [options] [files...]"
101
+
102
+ # Every annotation checked was valid — or there were none to check.
103
+ # "Lint, don't require": a missing annotation is never an error.
104
+ EXIT_OK = 0
105
+ # One or more annotations are malformed. The only code produced by
106
+ # inspecting content, and the only path that reaches it is a failed
107
+ # {Linter::Result}.
108
+ EXIT_MALFORMED = 1
109
+ # The linter could not do its job: bad flags, `--changed` outside a git
110
+ # repository, a validator that could not be resolved, or an unexpected
111
+ # internal error.
112
+ EXIT_MISUSE = 2
113
+
114
+ def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
115
+ @stdout = stdout
116
+ @stderr = stderr
117
+ @env = env
118
+ end
119
+
120
+ # @param argv [Array<String>]
121
+ # @return [Integer] 0, 1 or 2 — never anything else, and never by
122
+ # letting an exception reach the shell
123
+ def run(argv)
124
+ options = parse_options(argv)
125
+ return EXIT_OK if options.nil? # --help / --version already printed
126
+
127
+ # Resolved — or the run has already exited 2 — before anything is
128
+ # selected or scanned, for the same reason it always was: "the
129
+ # validator could not be obtained" must never surface as a run that
130
+ # checked nothing and called itself clean. There is no Ruby arm to
131
+ # fall back to, so a resolution failure IS the run's failure.
132
+ backend = ValidatorBackend.resolve(env: @env)
133
+
134
+ # One line per run naming the implementation that produced the
135
+ # verdicts. Since the cutover there is exactly one implementation.
136
+ report_backend(backend)
137
+
138
+ selection = select(options)
139
+ report_selection(selection, json: options[:json])
140
+
141
+ results = backend.check(selection.files)
142
+
143
+ # Computed once, here, and handed to whichever renderer runs. `--json`
144
+ # is a second renderer over this list, not a second code path: the exit
145
+ # code below is the same expression it always was, and the document's
146
+ # `ok` is derived FROM it rather than recomputed from the findings, so
147
+ # the two renderers cannot disagree about whether the run passed.
148
+ code = results.any?(&:failed?) ? EXIT_MALFORMED : EXIT_OK
149
+ report_results(results, files: selection.count, json: options[:json], ok: code == EXIT_OK)
150
+
151
+ code
152
+ rescue UsageError, ValidatorError => e
153
+ @stderr.puts "specguard-lint: error: #{e.message}"
154
+ EXIT_MISUSE
155
+ rescue ScriptError, StandardError => e
156
+ # The backstop that makes exit 1 mean one thing. Anything reaching here
157
+ # is a bug in the linter, not a verdict about anyone's annotations, so
158
+ # it is a 2 and it says so in those words.
159
+ @stderr.puts "specguard-lint: internal error: #{e.class}: #{e.message}"
160
+ EXIT_MISUSE
161
+ end
162
+
163
+ private
164
+
165
+ # One line per run naming the implementation that produced the verdicts.
166
+ # Since the SPGD-867 cutover there is exactly one implementation — the
167
+ # resolved `validate-intent` binary — and the line says which one,
168
+ # including its identity and schema contract, so "which validator did
169
+ # this CI job actually run" stays answerable from the output.
170
+ #
171
+ # It is on STDERR: the findings and the two `checked …` lines are the
172
+ # product and are pinned byte-for-byte; a line about the linter's own
173
+ # configuration belongs where the warnings are.
174
+ def report_backend(backend)
175
+ @stderr.puts "specguard-lint: #{backend.provenance}"
176
+ end
177
+
178
+ # Naming files and asking for the diff are contradictory instructions,
179
+ # and honouring the first while dropping the second silently is the same
180
+ # class of quiet no-op this tool exists to remove: `--changed` would
181
+ # appear to have been applied. That is misuse — exit 2.
182
+ def select(options)
183
+ if options[:files].any?
184
+ if options[:changed]
185
+ raise UsageError,
186
+ "--changed cannot be combined with explicit files; drop one " \
187
+ "(named files are checked as given, --changed derives them from the diff)"
188
+ end
189
+
190
+ return FileSelector::Selection.new(files: options[:files], mode: :explicit)
191
+ end
192
+
193
+ FileSelector.select(changed: options[:changed], base: options[:base], root: options[:root])
194
+ end
195
+
196
+ # The honest-reporting half of the `--changed` fix: the selected-file
197
+ # count is always stated, and an empty selection is loud on stderr, so
198
+ # "checked 12 files, found nothing" can never be mistaken for
199
+ # "checked nothing". The exit code is not the lever here — the contract
200
+ # fixes 0 for "no annotations" — which is exactly why the warning has to
201
+ # carry the weight.
202
+ #
203
+ # Under `--json` the count is not dropped, it MOVES: stdout carries one
204
+ # document and nothing else, and this line's number is that document's
205
+ # `summary.files`. The warnings are diagnostics about the linter rather
206
+ # than findings, so they stay on stderr exactly as they are — a run that
207
+ # selected nothing is still loud, in both renderers.
208
+ def report_selection(selection, json:)
209
+ if selection.empty?
210
+ @stderr.puts "specguard-lint: warning: selected 0 spec files — #{empty_reason(selection)}"
211
+ @stderr.puts "specguard-lint: warning: #{selection.note}" if selection.note
212
+ elsif !json
213
+ @stdout.puts "specguard-lint: checked #{selection.count} spec file#{'s' unless selection.count == 1}" \
214
+ "#{" changed since #{selection.base}" if selection.mode == :changed}" \
215
+ "#{" under #{Dir.pwd}" if selection.mode == :all}"
216
+ end
217
+ end
218
+
219
+ # `:explicit` is absent by construction — an explicit Selection is only
220
+ # built from a non-empty file list, so it can never be empty.
221
+ def empty_reason(selection)
222
+ case selection.mode
223
+ when :changed then changed_empty_reason(selection)
224
+ else "no *_spec.rb found under #{Dir.pwd}"
225
+ end
226
+ end
227
+
228
+ # Names the filter that actually emptied the selection. Saying "nothing in
229
+ # the diff matched *_spec.rb" when a spec file demonstrably changed —
230
+ # just not under this directory — is worse than saying nothing: it reads
231
+ # as a conclusion and stops the reader looking.
232
+ def changed_empty_reason(selection)
233
+ stats = selection.stats
234
+ base = selection.base
235
+
236
+ return "nothing in the diff against #{base} matched *_spec.rb" if stats.nil?
237
+
238
+ if stats.changed.zero?
239
+ "nothing changed against #{base}"
240
+ elsif stats.spec_matches.zero?
241
+ "#{stats.changed} file#{'s' unless stats.changed == 1} changed against #{base}, " \
242
+ "none matching *_spec.rb"
243
+ else
244
+ changed_excluded_reason(stats, base)
245
+ end
246
+ end
247
+
248
+ # `outside_root` and `unreadable` are independent counters over disjoint
249
+ # branches of the same partition (`file_selector.rb`), so both can be
250
+ # positive at once. Naming only the first one found is the failure the
251
+ # comment above forbids: the reader does the arithmetic, sees that
252
+ # `spec_matches - outside_root` files are unaccounted for, and concludes
253
+ # they were checked. They were not — the selection is empty. So the
254
+ # clauses are additive, and a selection emptied by both causes says so.
255
+ def changed_excluded_reason(stats, base)
256
+ matched = "#{stats.spec_matches} changed spec file#{'s' unless stats.spec_matches == 1} against #{base}"
257
+
258
+ # Nothing outside the root: `unreadable` is then the only filter left
259
+ # that can have emptied the selection, so it needs no count of its own.
260
+ return "#{matched} could not be read" unless stats.outside_root.positive?
261
+
262
+ reason = "#{matched}, but #{stats.outside_root} #{stats.outside_root == 1 ? 'is' : 'are'} " \
263
+ "outside #{Dir.pwd} (--changed selects only files under the current directory)"
264
+ reason += " and #{stats.unreadable} could not be read" if stats.unreadable.positive?
265
+ reason
266
+ end
267
+
268
+ # The FAIL block, in the shape `bin/validate-intent --source` emits: two
269
+ # spaces after `FAIL`, an em-dash before an extraction problem, eight
270
+ # spaces and `-> ` before each schema reason.
271
+ #
272
+ # FAIL spec/order_spec.rb:9
273
+ # -> <root>: missing required property 'entity'
274
+ # FAIL spec/order_spec.rb:28 — unterminated object literal (...)
275
+ #
276
+ # Two deliberate differences from the reference's `--source` mode, which
277
+ # is a fixture self-test rather than a linter:
278
+ #
279
+ # * no `PASS` line per valid annotation — a CI linter that prints a
280
+ # line per healthy annotation buries the failures in a large repo.
281
+ # The summary line carries the count instead, so "checked nothing"
282
+ # is still impossible to mistake for "all clean".
283
+ # * findings go to **stdout**, where the reference puts them and where
284
+ # lint findings conventionally go. Diagnostics about the linter
285
+ # itself (warnings, misuse, schema failure) stay on stderr.
286
+ #
287
+ # That second bullet used to end "…and the exit code — not the stream —
288
+ # is the machine-readable signal." It justified the STREAM SPLIT, which
289
+ # stands unchanged; but read as a claim about the tool it is now false,
290
+ # and it was already dated when it was written. It comes from SPGD-82,
291
+ # before the Go port grew `--json` (SPGD-102), and a 3-valued exit code
292
+ # was then genuinely the only structured thing a consumer could read.
293
+ # SPGD-305 gives this renderer a sibling: with `--json`, stdout carries
294
+ # one JSON document — file, line, kind, errors — and the exit code is one
295
+ # signal of two rather than the only one. Nothing moved off stderr to make
296
+ # that true; see {JSONReporter}.
297
+ #
298
+ # == Two renderers, one result list
299
+ #
300
+ # The partition below is computed ONCE and handed to whichever renderer
301
+ # runs. Both the text summary line and the document's `summary` are
302
+ # statements about the same numbers, and a linter whose two renderers can
303
+ # disagree about how much it checked is worse than one that only prints
304
+ # prose: the disagreement is unfalsifiable from outside the process.
305
+ def report_results(results, files:, json:, ok:)
306
+ annotations, unread = results.partition(&:line_scoped?)
307
+
308
+ if json
309
+ @stdout.puts JSONReporter.render(results, files: files, annotations: annotations.length, ok: ok)
310
+ return
311
+ end
312
+
313
+ results.reject(&:ok?).each { |result| report_failure(result) }
314
+
315
+ @stdout.puts summary_line(annotations, unread)
316
+ end
317
+
318
+ # The summary line exists for one reason — so "checked nothing" can never
319
+ # read as "all clean" — which makes overstating what was inspected its
320
+ # own kind of lie. A file that could not be opened contributed no
321
+ # annotation, so counting its Result as one would report "checked 12
322
+ # @intent annotations, 12 malformed" having read none of them. Unread
323
+ # files get their own clause: neither folded into the annotation count
324
+ # nor dropped from the report.
325
+ def summary_line(annotations, unread)
326
+ line = "specguard-lint: checked #{annotations.length} @intent annotation" \
327
+ "#{'s' unless annotations.length == 1}, #{annotations.count(&:failed?)} malformed"
328
+ return line if unread.empty?
329
+
330
+ "#{line}; #{unread.length} file#{'s' unless unread.length == 1} could not be read"
331
+ end
332
+
333
+ def report_failure(result)
334
+ if result.problem
335
+ @stdout.puts "FAIL #{result.location} — #{result.problem}"
336
+ else
337
+ @stdout.puts "FAIL #{result.location}"
338
+ result.reasons.each { |reason| @stdout.puts " -> #{reason}" }
339
+ end
340
+ end
341
+
342
+ def parse_options(argv)
343
+ options = { changed: false, base: nil, root: Dir.pwd, files: [], json: false }
344
+
345
+ parser = OptionParser.new do |o|
346
+ o.banner = BANNER
347
+ o.on("--changed[=BASE]",
348
+ "Only spec files changed against BASE (default: the merge base with the default branch)") do |base|
349
+ options[:changed] = true
350
+ options[:base] = base
351
+ end
352
+ o.on("--json", "Emit one JSON document on stdout instead of the human report") do
353
+ options[:json] = true
354
+ end
355
+ o.on("-v", "--version", "Print the version and exit") do
356
+ @stdout.puts "specguard-ruby #{VERSION}"
357
+ return nil
358
+ end
359
+ o.on("-h", "--help", "Print this help and exit") do
360
+ @stdout.puts o
361
+ return nil
362
+ end
363
+ end
364
+
365
+ options[:files] = parser.parse(argv)
366
+ options
367
+ rescue OptionParser::ParseError => e
368
+ # Uncaught, this is the single likeliest way a user sees a false
369
+ # "malformed annotation": OptionParser raises, Ruby exits 1. Retyping
370
+ # it is what makes `--chnaged` a 2.
371
+ raise UsageError, e.message
372
+ end
373
+ end
374
+ end
375
+ end