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 three bands and returns rather than exits: UsageError and SchemaError 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 reference tool reports every one: bin/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.

Two validators, one report

#run has exactly one branch on which validator produced the verdicts — the in-gem Linter, or the Go port shelled out to by ValidatorBackend when SPECGUARD_VALIDATE_INTENT names a binary. Both arms return Linter::Results, so the branch closes immediately and the reporting and exit-code logic below is shared rather than duplicated.

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, an unloadable schema, an unmet --require-validator. 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, an unloadable schema, 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.



111
112
113
114
115
# File 'lib/specguard/rspec/cli.rb', line 111

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



120
121
122
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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/specguard/rspec/cli.rb', line 120

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

  # nil unless SPECGUARD_VALIDATE_INTENT names a usable binary, in which
  # case this raises rather than returning one that is not. Resolved
  # before anything is selected or scanned, for the same reason the
  # schema is: "the validator you asked for is not there" must never
  # surface as a run that checked nothing and called itself clean.
  backend = ValidatorBackend.resolve(env: @env)

  # Immediately after resolution and before selection: the line is above
  # the empty-selection warnings, it costs the `--version` probe at most
  # once per run, and a run that dies later still said what was about to
  # validate it. See #report_backend.
  report_backend(backend)

  # After the provenance line, so stderr carries what DID validate the
  # run before the reason that was not good enough, and before selection,
  # so an unmet assertion selects, scans and reports nothing.
  #
  # Being before #select also fixes a precedence, and it was chosen
  # rather than inherited: `--changed --require-validator foo_spec.rb`
  # with the variable unset reports the missing backend and never reaches
  # the "--changed cannot be combined with explicit files" UsageError
  # below. Both are exit 2, and the backend is the earlier question —
  # which files to check does not matter when nothing is going to check
  # them with the implementation that was asked for.
  require_backend!(backend) if options[:require_validator]

  # Only on the Ruby path. When the backend is active the binary carries
  # its own schema and this gem's vendored copy governs nothing, so
  # loading it would let an unrelated packaging accident fail a run that
  # never reads it. The guard the load provides is not lost — the port
  # exits 2 on a schema it cannot load, and #run maps that to a
  # ValidatorError.
  schema = Schema.load if backend.nil?

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

  results = check(selection.files, backend: backend, schema: schema)

  # 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 SchemaError => e
  # Wording and stream follow bin/validate-intent:861-862 exactly.
  @stderr.puts "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