Class: SpecGuard::RSpec::CLI

Inherits:
Object
  • Object
show all
Defined in:
lib/specguard/rspec/cli.rb

Overview

specguard-lint's command line, and the whole of the exit contract.

The contract, and the reason it needs defending

0  every annotation checked is valid (including "there were none")
1  at least one annotation is malformed
2  the linter could not do its job — misuse, or the tool itself broken

Ruby does not give you this for free; it actively works against it. ruby -e 'raise "boom"' exits 1, and so does an uncaught OptionParser::InvalidOption. So on the obvious implementation, every internal failure lands on the one code the contract has already spent on "an annotation is malformed":

* `specguard-lint --chnaged` — a typo — would exit 1, and CI would
report a malformed annotation that does not exist;
* a vendored schema missing from the packaged gem would exit 1, the
same false accusation, in the field, on someone else's machine.

The project has shipped this defect shape repeatedly — SPGD-35, SPGD-52, SPGD-56 — but always as false green: a gate reporting success having checked nothing. This is its inverse, false red: a tool failure wearing the costume of a content failure. Both are the same underlying bug, a gate whose failure states are indistinguishable, and for a linter the exit code is the product.

#run therefore rescues in two bands and returns rather than exits: UsageError and ValidatorError are named, and everything else that is not a deliberate interruption is caught by a backstop. Exit 1 is produced in exactly one place — a failed Linter::Result — so it means that and nothing else.

Interrupt, SignalException and SystemExit are deliberately not caught. Ctrl-C must stay Ctrl-C; mapping it to "the linter is broken" would be its own small lie.

All failures, not the first

SPGD-12 §1 step 4 says the linter "exits 1 on the first malformed annotation". Its own exit-code table, one paragraph later, says "1 | One or more annotations are malformed", and the validator reports every one: validate-intent --source broken_intent_spec.rb emits 5 FAIL blocks. Stopping at the first would turn one file into five CI round-trips. Reporting all of them is the ratified behaviour (human decision recorded on SPGD-82); the "first" wording is a known spec defect with a correction filed against SPGD-12 §1 and the SPGD-73 roadmap text.

One validator, one report

Since the SPGD-867 cutover there is exactly one validator: the validate-intent binary ValidatorBackend resolves (an explicit SPECGUARD_VALIDATE_INTENT path, or the first-run auto-install). The Ruby hand-rolled validation arm is gone — when no binary can be resolved the run exits 2 naming both remediations, and it NEVER silently validates some other way. The verdicts arrive as Linter::Results, and the reporting and exit-code logic is shared with nothing because there is nothing to share it with.

Because both arms produce the same bytes, the run has to SAY which one it was, or the answer is unrecoverable from the output — see #report_backend, which states it in one line on stderr on both arms. That line is the only thing the default configuration gained: stdout, the exit code and every finding are what they were before this file learned about a second validator.

The backend's failure modes are exit 2 by construction: ValidatorError is rescued beside UsageError, which is what makes "the binary you named is missing" read as specguard-lint: error: … rather than reaching the backstop and reading as an internal error:. Either way it is a 2, and that is the property that matters — a broken tool must not borrow the code that means "your annotations are malformed".

Two renderers, and what --json does NOT touch

--json (SPGD-305) replaces the human report on stdout with one JSON document over the very same Linter::Result list — see JSONReporter. It is a renderer, so the three things that are the contract are untouched by it: the exit code (the decision below is one expression, evaluated on both paths), stderr (the provenance line and every warning are byte-for- byte what they were), and the default path (without the flag, stdout is what it was, pinned as a regression lock in spec/specguard/rspec/regression_targets_spec.rb).

No exit-2 path emits a document, and that is a decision rather than an omission. Every rescue below means the linter produced NO VERDICTS — bad flags, --changed outside a repository, a validator that could not be resolved or produced no verdict. A document is a report about what was checked; emitting {"ok": false, "findings": []} for a run that checked nothing would hand a stdout-reading consumer the project's signature defect — an empty clean-looking report standing in for "could not check" — and dressing it as structure would make it more convincing, not less. Those runs write prose to stderr, where diagnostics about the linter already live, and say what happened with the exit code.

Constant Summary collapse

"Usage: specguard-lint [options] [files...]"
EXIT_OK =

Every annotation checked was valid — or there were none to check. "Lint, don't require": a missing annotation is never an error.

0
EXIT_MALFORMED =

One or more annotations are malformed. The only code produced by inspecting content, and the only path that reaches it is a failed Linter::Result.

1
EXIT_MISUSE =

The linter could not do its job: bad flags, --changed outside a git repository, a validator that could not be resolved, or an unexpected internal error.

2

Instance Method Summary collapse

Constructor Details

#initialize(stdout: $stdout, stderr: $stderr, env: ENV) ⇒ CLI

Returns a new instance of CLI.



114
115
116
117
118
# File 'lib/specguard/rspec/cli.rb', line 114

def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
  @stdout = stdout
  @stderr = stderr
  @env = env
end

Instance Method Details

#run(argv) ⇒ Integer

Returns 0, 1 or 2 — never anything else, and never by letting an exception reach the shell.

Parameters:

  • argv (Array<String>)

Returns:

  • (Integer)

    0, 1 or 2 — never anything else, and never by letting an exception reach the shell



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/specguard/rspec/cli.rb', line 123

def run(argv)
  options = parse_options(argv)
  return EXIT_OK if options.nil? # --help / --version already printed

  # Resolved — or the run has already exited 2 — before anything is
  # selected or scanned, for the same reason it always was: "the
  # validator could not be obtained" must never surface as a run that
  # checked nothing and called itself clean. There is no Ruby arm to
  # fall back to, so a resolution failure IS the run's failure.
  backend = ValidatorBackend.resolve(env: @env)

  # One line per run naming the implementation that produced the
  # verdicts. Since the cutover there is exactly one implementation.
  report_backend(backend)

  selection = select(options)
  report_selection(selection, json: options[:json])

  results = backend.check(selection.files)

  # Computed once, here, and handed to whichever renderer runs. `--json`
  # is a second renderer over this list, not a second code path: the exit
  # code below is the same expression it always was, and the document's
  # `ok` is derived FROM it rather than recomputed from the findings, so
  # the two renderers cannot disagree about whether the run passed.
  code = results.any?(&:failed?) ? EXIT_MALFORMED : EXIT_OK
  report_results(results, files: selection.count, json: options[:json], ok: code == EXIT_OK)

  code
rescue UsageError, ValidatorError => e
  @stderr.puts "specguard-lint: error: #{e.message}"
  EXIT_MISUSE
rescue ScriptError, StandardError => e
  # The backstop that makes exit 1 mean one thing. Anything reaching here
  # is a bug in the linter, not a verdict about anyone's annotations, so
  # it is a 2 and it says so in those words.
  @stderr.puts "specguard-lint: internal error: #{e.class}: #{e.message}"
  EXIT_MISUSE
end