ci: lint the shell scripts that decide whether a build ships (#592) (#656)

Seven of this repo's CI gates are shell scripts, five of them ship with
shell self-tests, and nothing linted any of them. Verified against the
base this branched from: `shellcheck` appears in zero of the seven
workflow files (control: `ktlint` appears in two, so the search reaches
them). Every agent that touched a gate ran shellcheck locally and
reported it clean -- a convention, not a gate.

Add `scripts/check-shell-lint.sh` and wire it into the Lint job, ahead of
the grep gates: those gates ARE shell, so if one is malformed the reason
lands in this step instead of as a confusing failure inside the gate.

Scope is every TRACKED `*.sh` in the repo, discovered rather than listed
-- 19 files, which is the seven gates plus five self-tests plus
deploy/deploy.sh, deploy/setup-vps.sh and relay/scripts/backup-acme-key.sh.
A hand-maintained list is the thing that drifted in #547 and an exemption
list is the thing that rots into matching nothing (#579), so there is
neither. `production-source-dirs.sh` is in because CI runs it and every
gate shells out to it; `backfill-relay-secrets.sh` is in because it
writes repo secrets against the live VPS, where a quoting bug is worse
than a red build. `gradlew` is vendored and is excluded by not being
named `*.sh`.

Severity: shellcheck's default, which is the strictest of its four
levels. Measured across the 19 scripts: 0 findings at `error`, 1 at
`warning`, `info` and `style` alike. That one is a dead `EXIT`
accumulator in check-zombie-screens.sh (SC2034) -- assigned in the loop,
never read, because the script has always exited on the ZOMBIES array --
and it is removed here, so the gate lands green at the strictest setting.
The OPTIONAL checks are not enabled: `-o all` is 501 findings, 485 of
them SC2250 bracing and SC2292 `[[` preference. The other 15 are
SC2310/SC2312 masked-return notes, which are on-topic but each need a
judgement call, and are left for follow-up rather than blocking this.

Honest scoping, because the obvious claim is wrong: this would NOT have
caught the #628 bug. Both #628 gates shipped with `grep` exiting 1 on no
match and `set -euo pipefail` propagating that out of a command
substitution, killing the script before it printed its finding. Probed
against that exact shape at default severity AND with
`-o check-extra-masked-returns,check-set-e-suppressed`: shellcheck 0.11.0
reports nothing, and cannot, because dying on non-zero status is what
`set -e` is for. It equally would not have caught #577/#590/#594/#597,
which are unanchored-filter logic defects in valid shell. What it does
catch, at default severity, is the neighbouring class: SC2155 masked
return values, SC2086 unquoted expansions, SC2181, SC2015, SC2034 --
and SC1073, which it hit in its own header on the first run, where a
comment line beginning `# shellcheck ...` parsed as a directive.

shellcheck is installed from a checksum-pinned release rather than taken
from the runner image. The image does ship one and the step prints which,
but `ubuntu-latest` is a moving alias, and a release that DROPPED a check
would weaken this silently. The script asserts the version it got, so
deleting the install step fails loudly instead of handing the gate back
to whatever the image carries.

A linter wrapper is the easiest gate of all to make vacuous -- hand
shellcheck an empty file list and it reads stdin, prints nothing and
exits 0, which is byte-identical to a clean tree, the same shape as the
`0 == 0` coverage control in #629. `scripts/tests/check-shell-lint-test.sh`
runs the real gate against throwaway work trees and asserts eleven cases:
that violations go red NAMING the file and rule code, that coverage
reaches scripts/tests/ and arbitrary depth, and that an empty tree, an
untracked-only tree, a non-git directory, a missing root, a mismatched
shellcheck version and a missing shellcheck all go RED rather than
reporting clean.

The one suppression in the tree is an SC2123 in that test, on the line
that deliberately narrows PATH until `command -v shellcheck` finds
nothing -- which is the case being tested.


Proven to fire in the real CI environment, not only in the self-test
harness. A probe commit added a file carrying a known masked-return
finding; the Lint job went red naming the file, line and rule code, and
the six steps after it (ktlint, detekt, the other four gates and their
self-tests) all show `skipped`, so nothing downstream could paper over
it. Discovery picked the new file up on its first run -- "over 19 tracked
shell script(s)" became "over 20" with no list edited anywhere. The probe
was then reverted to a byte-identical tree (6e753aad) and is not present
here.

It also caught a real defect in its own implementation while this branch
was being written: a header line that wrapped so the word after `# ` was
`shellcheck` parsed as a directive and failed the file with SC1073/SC1072.
Twice. That was caught locally rather than by CI -- the Lint run on that
commit was cancelled during the Debounce step before the gate ran -- and
it is the better of the two proofs precisely because it was not arranged.

Closes #592

Reviewer notes (monkopedia-reviewer), recorded here because the PR body
does not survive into history:

- The measured numbers in the check-shell-lint.sh header and in the
  paragraph above about optional checks ("501 findings, 485 of them ...
  the remaining 15", "all 18 tracked scripts") reproduce no tree.
  Re-measured with the pinned shellcheck 0.11.0: the base tree is
  514/495/18 over 17 scripts, and the tree merged here is 573/553/20 over
  19. The PR body's figures are the correct ones. Tracked in #658 along
  with the discovery-predicate question, which is that `*.sh` is a
  name-based test for a content-based class -- correct today, since
  gradlew is the only non-`*.sh` file that is either executable or
  shell-shebanged, but able to drift the same way the hand-maintained
  lists in #547 and #579 did.
- The workflow files carry 70 inline `run:` blocks, 372 lines of shell,
  which this gate does not read; five live default-severity findings sit
  in them today. Tracked in #659.
- #657 tracks the deferred SC2310/SC2312 triage. Its headline count of 18
  is correct; its per-site list covers 15 of them, missing
  deploy/deploy.sh:20 (x2) and relay/scripts/backup-acme-key.sh:38.
This commit is contained in:
monkopedia-coder 2026-08-31 23:14:40 -04:00 • committed by GitHub
parent 54fdcd4637
commit 9b50f469d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 414 additions and 2 deletions

View file

@ -18,6 +18,35 @@ jobs:
- name: Debounce
run: sleep 60
- uses: actions/checkout@v4
# shellcheck is INSTALLED at a pinned version rather than taken from the
# runner image. The runner does ship one (this step prints which, so the
# claim stays checkable), but `ubuntu-latest` is a moving alias that rolls
# to a new image on GitHub's schedule, not ours -- and a shellcheck
# release that DROPS or relaxes a check would weaken the gate below with
# no signal at all. The checksum makes it a pin and not just a download.
#
# Bump in lockstep with EXPECTED_SHELLCHECK_VERSION in
# scripts/check-shell-lint.sh, which asserts the two agree; that assertion
# is what makes deleting this step fail loudly instead of quietly handing
# the gate back to whatever the image happens to carry.
- name: Install shellcheck (pinned)
env:
SHELLCHECK_VERSION: v0.11.0
SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198
run: |
set -euo pipefail
echo "runner image ships: $(shellcheck --version 2>/dev/null | sed -n 's/^version: /shellcheck /p' || echo 'no shellcheck')"
url="https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
curl -fsSL --retry 3 --retry-delay 5 -o /tmp/shellcheck.tar.xz "$url"
echo "${SHELLCHECK_SHA256} /tmp/shellcheck.tar.xz" | sha256sum -c -
tar -xJf /tmp/shellcheck.tar.xz -C /tmp
sudo install -m 0755 "/tmp/shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
shellcheck --version
# The gates below ARE shell scripts, and so are their self-tests, so this
# runs first: if one of them is malformed, the reason lands here instead
# of as a confusing failure inside the gate itself (#592).
- name: "Shell scripts pass shellcheck (issue #592)"
run: bash scripts/check-shell-lint.sh
# Grep gates run before the JDK/Gradle setup: they need nothing but the
# checkout and finish in under a second, so a violation reports in seconds
# instead of after a multi-minute ktlint/detekt pass.
@ -40,6 +69,13 @@ jobs:
run: bash scripts/tests/check-no-sensitive-logging-test.sh
- name: "runBlocking gate self-test (issue #590)"
run: bash scripts/tests/check-no-production-runblocking-test.sh
# A linter wrapper is the easiest gate of all to make vacuous: hand
# `shellcheck` an empty file list and it reads stdin, prints nothing and
# exits 0 -- identical output to a clean tree. This asserts the discovery
# goes RED on an empty set and that real violations are named by file and
# rule code.
- name: "shellcheck gate self-test (issue #592)"
run: bash scripts/tests/check-shell-lint-test.sh
- uses: actions/setup-java@v4
with:
distribution: temurin

152
scripts/check-shell-lint.sh Executable file
View file

@ -0,0 +1,152 @@
#!/usr/bin/env bash
# Runs shellcheck over every tracked shell script in the repo (issue #592).
#
# WHY THIS EXISTS
# ---------------
# Seven of this repo's CI gates ARE shell scripts, and five of them ship with
# their own self-tests -- also shell. Nothing linted any of them. Every agent
# that touched one ran `shellcheck` locally and reported it clean, which is a
# convention, not a gate: the next change is one forgotten local run away from
# landing unlinted, and these scripts decide whether a build ships.
#
# WHAT IT DOES AND DOES NOT CATCH -- measured, not assumed
# --------------------------------------------------------
# It would NOT have caught #577/#590/#594/#597: those are unanchored-filter
# LOGIC defects in entirely valid shell, and no linter sees them.
#
# It would also NOT have caught the #628 defect, contrary to the obvious guess.
# Both #628 gates shipped the same bug -- `grep` exits 1 on no match, and under
# `set -euo pipefail` that propagated out of a command substitution and killed
# the script BEFORE it printed its finding (exit 1, no output, reading as a
# crash rather than as the finding it was). That was probed against this exact
# shape at default severity AND with `-o check-extra-masked-returns,
# check-set-e-suppressed`: shellcheck 0.11.0 reports nothing on it. It cannot,
# because dying on a non-zero status is what `set -e` is FOR; only the author
# knows the empty result was meant to be a finding. Those two bugs were caught
# by their self-tests asserting the red cases actually SAY what is wrong, and
# that remains the mechanism that catches them.
#
# What this gate does catch is the neighbouring class the gates are genuinely
# exposed to, and at default severity: SC2155 (`local x=$(cmd)` masking a return
# value), SC2086 (unquoted expansion word-splitting a path or a grep pattern),
# SC2181 (`$?` checked indirectly), SC2015 (`a && b || c` read as if/else),
# SC2034 (an accumulator assigned and never read -- one of those was live in
# check-zombie-screens.sh and is fixed in the change that added this gate), and
# SC1073 (a comment line beginning `# shellcheck ...` silently parsed as a
# directive, which this file tripped on its own first run).
#
# WHAT IS COVERED
# ---------------
# Every tracked `*.sh` in the repo, discovered -- not listed. A hand-maintained
# file list is the thing that rots (#547 drifted exactly that way), and an
# exemption list is the thing that quietly grows until the gate matches nothing
# (#579). There are no exemptions. `gradlew` is a vendored, upstream-owned
# Gradle wrapper and is excluded by virtue of not being named `*.sh`; that is
# deliberate, not an oversight.
#
# Discovery is `git ls-files`, so gitignored build output is never scanned. The
# trade is that an UNTRACKED new script is invisible to this gate locally --
# harmless in CI, where the checkout is fully tracked by construction, and the
# reason `git add` before running this locally is worth the habit.
#
# SEVERITY
# --------
# The default severity (`style`) -- the strictest of the four levels, reporting
# error + warning + info + style. Measured on the tree this landed against,
# across all 18 tracked scripts: 0 findings at `error`, and 1 at `warning`,
# `info` and `style` alike (the dead `EXIT` in check-zombie-screens.sh, fixed in
# the same change). The strictest level lands clean, so there is no reason to
# accept a weaker one, and no `-S` flag is passed.
#
# The OPTIONAL checks (`-o all`) are NOT enabled: 501 findings, 485 of them
# SC2250 (`${var}` bracing) and SC2292 (`[[` over `[`) -- pure style, a
# whole-tree rewrite for no defect-detection gain. The remaining 15 are
# SC2310/SC2312 masked-return notes; those are on-topic for gates whose `grep`
# exit status is load-bearing, but each needs its own judgement call (some of
# the masking is deliberate), so they are tracked as follow-up rather than
# either blocking this gate or being silenced wholesale.
#
# VERSION
# -------
# The version is asserted, not assumed. New shellcheck releases add checks (a
# loud red) but can also relax or drop them (a SILENT weakening), and
# `ubuntu-latest` is a moving target that will roll to a new image on GitHub's
# schedule, not ours. CI installs the pinned build; this assertion is what makes
# removing that install step fail loudly instead of silently handing the gate
# back to whatever the runner image happens to ship.
#
# Not a theoretical gap: measured on the runner at the time this landed, the
# `ubuntu-latest` image ships shellcheck 0.9.0 -- two feature releases behind
# the 0.11.0 every finding count above was measured with. "The runner already
# has it" is true and not sufficient. (And note the line break: a comment line
# that STARTS with the word after `# ` being `shellcheck` is parsed as a
# directive and fails the file with SC1072/SC1073. This header tripped that
# twice while being written; the gate caught it both times.)
#
# Usage: check-shell-lint.sh [repo-root]
set -euo pipefail
# Bump deliberately, with the finding-count delta recorded in the PR. See the
# pinned install in .github/workflows/ci.yml, which must move in lockstep.
EXPECTED_SHELLCHECK_VERSION=0.11.0
root=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}
if [[ ! -d "$root" ]]; then
echo "FAIL: no such directory: $root" >&2
exit 1
fi
cd "$root"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "FAIL: $root is not a git work tree." >&2
echo " Discovery is 'git ls-files', so outside a work tree this gate" >&2
echo " would find nothing and 'pass'. It fails instead." >&2
exit 1
fi
if ! command -v shellcheck >/dev/null 2>&1; then
echo "FAIL: shellcheck is not installed." >&2
echo " Install v$EXPECTED_SHELLCHECK_VERSION -- see the pinned install in" >&2
echo " .github/workflows/ci.yml, or your distro's package." >&2
exit 1
fi
actual_version=$(shellcheck --version | sed -n 's/^version: //p')
if [[ "$actual_version" != "$EXPECTED_SHELLCHECK_VERSION" ]]; then
echo "FAIL: shellcheck $actual_version, expected $EXPECTED_SHELLCHECK_VERSION." >&2
echo " Version drift is not cosmetic here: a release that DROPS a check" >&2
echo " weakens this gate silently. If the new version is intended, bump" >&2
echo " EXPECTED_SHELLCHECK_VERSION here and the pinned install in" >&2
echo " .github/workflows/ci.yml together, and record the finding-count" >&2
echo " delta in the PR." >&2
exit 1
fi
# `git ls-files -z` + `mapfile -d ''` rather than a glob: NUL-delimited so a
# path with a space cannot split, and recursive so scripts/tests/ and any future
# directory are covered without this list being edited.
files=()
mapfile -d '' -t files < <(git ls-files -z -- '*.sh')
# A gate that finds nothing to check must go RED. `shellcheck` over an empty
# argument list reads stdin and exits 0, and a `scripts/*.sh` glob that matches
# nothing expands to a literal that "just" errors -- both of which are how a
# gate rots into one that passes everything (#579). Asserted, so it cannot.
if [[ ${#files[@]} -eq 0 ]]; then
echo "FAIL: no tracked *.sh files found under $root." >&2
echo " This gate has nothing to check, which is a broken gate, not a" >&2
echo " clean tree. Either the scripts moved or discovery is wrong." >&2
exit 1
fi
echo "shellcheck $actual_version over ${#files[@]} tracked shell script(s):"
printf ' %s\n' "${files[@]}"
echo
# No `-S`: the default severity is the strictest, and `-f gcc` puts the file,
# line and rule code on one grep-able line so a CI log names what to fix.
shellcheck -f gcc -- "${files[@]}"
echo "OK: shellcheck clean across ${#files[@]} shell script(s)"

View file

@ -11,7 +11,8 @@ set -euo pipefail
SCREENS_DIR="app/src/main/java/com/rousecontext/app/ui/screens"
PROD_ROOT="app/src/main"
EXIT=0
# Dead `EXIT` accumulator removed (#592): the script has always exited on the
# ZOMBIES array below, so it was assigned and never read -- shellcheck SC2034.
ZOMBIES=()
# Tracked-but-known zombies. Remove entries here once the corresponding issue
@ -60,7 +61,6 @@ for file in "$SCREENS_DIR"/*.kt; do
if [[ "$found" == "no" ]]; then
ZOMBIES+=("$file (functions: ${funcs[*]})")
EXIT=1
fi
done

View file

@ -0,0 +1,224 @@
#!/usr/bin/env bash
# Regression tests for scripts/check-shell-lint.sh (#592).
#
# The gate is a linter wrapper, which is precisely the shape that rots into a
# no-op: point it at a path that no longer exists, hand `shellcheck` an empty
# argument list, and it prints nothing and exits 0 -- indistinguishable from a
# clean tree. #629's Roborazzi coverage control passed at `0 == 0` for exactly
# that reason, so this asserts the gate FIRES and asserts it cannot pass
# vacuously.
#
# Same harness shape as #577/#590/#594/#628: a throwaway git work tree, the REAL
# gate run end-to-end against it.
set -euo pipefail
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
gate="$repo_root/scripts/check-shell-lint.sh"
sandbox=$(mktemp -d)
trap 'rm -rf "$sandbox"' EXIT
failures=0
# Reset the sandbox to an empty, tracked-nothing git work tree.
reset_sandbox() {
rm -rf "${sandbox:?}/repo"
mkdir -p "$sandbox/repo"
git -C "$sandbox/repo" init -q
}
# write <relative-path> <<'EOF' ... : create a script and stage it, so
# `git ls-files` sees it.
write_script() {
local rel=$1
mkdir -p "$(dirname "$sandbox/repo/$rel")"
cat > "$sandbox/repo/$rel"
chmod +x "$sandbox/repo/$rel"
git -C "$sandbox/repo" add -- "$rel"
}
# expect <exit-status> <name> [<substring the output must contain>]...
#
# The substrings are the point. A gate that exits non-zero without naming what
# is wrong is the #628 failure mode (exit 1, no output), so a red case here has
# to SAY the file and the rule code, not merely go red.
expect() {
local want=$1 name=$2
shift 2
local out status must
set +e
out=$(bash "$gate" "$sandbox/repo" 2>&1)
status=$?
set -e
if [[ "$status" -ne "$want" ]]; then
echo "FAIL: $name -- expected exit $want, got $status"
printf '%s\n' "$out" | sed 's/^/ /'
failures=$((failures + 1))
return
fi
for must in "$@"; do
if ! printf '%s\n' "$out" | grep -qF -- "$must"; then
echo "FAIL: $name -- exited $status but never said '$must'"
printf '%s\n' "$out" | sed 's/^/ /'
failures=$((failures + 1))
return
fi
done
echo "ok: $name (exit $status)"
}
# --- green -------------------------------------------------------------------
reset_sandbox
write_script scripts/clean.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
target=${1:-.}
echo "checking ${target}"
EOF
expect 0 "a clean tracked script passes" "OK: shellcheck clean" "scripts/clean.sh"
# --- the gate must FIRE ------------------------------------------------------
# SC2155 is the masked-return-value check that IS on by default: `local x=$(cmd)`
# discards cmd's exit status because `local` itself succeeds. This is the
# closest default-severity relative of the load-bearing-exit-status shape these
# gates are built out of.
reset_sandbox
write_script scripts/bad.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
index_of() {
local hit=$(grep -nF -- "$1" /dev/null | head -1)
echo "$hit"
}
index_of x
EOF
expect 1 "a masked return value is caught, named by file and rule" \
"scripts/bad.sh" "SC2155"
# SC2086: an unquoted expansion word-splits. Every gate in scripts/ passes
# directory lists and grep patterns through variables, so this is the live risk.
reset_sandbox
write_script scripts/bad.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
dirs=$(cat /dev/null)
for d in $dirs; do
grep -r foo $d
done
EOF
expect 1 "an unquoted expansion is caught" "scripts/bad.sh" "SC2086"
# --- coverage is recursive, not just scripts/ --------------------------------
# The self-tests under scripts/tests/ are the things that caught #577, #590 and
# both #628 bugs, and they are shell too. A `scripts/*.sh` glob would miss them
# entirely, so a violation one directory down must still go red.
reset_sandbox
write_script scripts/clean.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo ok
EOF
write_script scripts/tests/nested-test.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [ $? -ne 0 ]; then echo bad; fi
EOF
expect 1 "a violation under scripts/tests/ is covered too" \
"scripts/tests/nested-test.sh" "SC2181"
# Any depth, not a hardcoded two levels: deploy/ and relay/scripts/ hold shell
# too, and a future directory must not need this gate edited to be covered.
reset_sandbox
write_script deploy/deep/nested/thing.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [ $? -ne 0 ]; then echo bad; fi
EOF
expect 1 "a violation at arbitrary depth is covered" \
"deploy/deep/nested/thing.sh" "SC2181"
# --- the gate must not pass vacuously ----------------------------------------
# THE case this test exists for. `shellcheck` with no file arguments reads stdin
# and exits 0, so a discovery that matches nothing would report success over an
# empty set -- a gate that passes everything, forever, silently.
reset_sandbox
expect 1 "an empty work tree goes RED, not vacuously green" \
"no tracked *.sh files"
# Untracked-only is the same hazard wearing a different hat: the files exist on
# disk, so a human glancing at the directory would say the gate had work to do.
reset_sandbox
mkdir -p "$sandbox/repo/scripts"
cat > "$sandbox/repo/scripts/untracked.sh" <<'EOF'
#!/usr/bin/env bash
if [ $? -ne 0 ]; then echo bad; fi
EOF
expect 1 "an untracked-only tree goes RED rather than reporting clean" \
"no tracked *.sh files"
# Outside a git work tree, `git ls-files` yields nothing; without the explicit
# guard that would look identical to a clean tree.
rm -rf "${sandbox:?}/repo"
mkdir -p "$sandbox/repo"
expect 1 "a non-git directory goes RED" "is not a git work tree"
# A path that does not exist at all -- the "someone moved scripts/" case.
rm -rf "${sandbox:?}/repo"
expect 1 "a missing root goes RED" "no such directory"
mkdir -p "$sandbox/repo"
# --- the version pin is enforced ---------------------------------------------
# The pin is the only thing standing between this gate and whatever shellcheck
# the runner image happens to ship. If it were advisory, a release that DROPPED
# a check would weaken the gate with no signal at all. Proven by putting a fake
# `shellcheck` reporting a different version first on PATH.
reset_sandbox
write_script scripts/clean.sh <<'EOF'
#!/usr/bin/env bash
echo ok
EOF
mkdir -p "$sandbox/fakebin"
cat > "$sandbox/fakebin/shellcheck" <<'EOF'
#!/usr/bin/env bash
echo "ShellCheck - shell script analysis tool"
echo "version: 0.0.1-fake"
exit 0
EOF
chmod +x "$sandbox/fakebin/shellcheck"
saved_path=$PATH
PATH="$sandbox/fakebin:$PATH"
expect 1 "a mismatched shellcheck version goes RED" "expected"
PATH=$saved_path
# ...and with no shellcheck on PATH at all, it must fail rather than skip. The
# minimal PATH below carries exactly what the gate and this harness need to run
# (bash, git, sed, grep) and nothing else, so `command -v shellcheck` genuinely
# finds nothing.
mkdir -p "$sandbox/minbin"
for tool in bash git sed grep; do
ln -sf "$(command -v "$tool")" "$sandbox/minbin/$tool"
done
saved_path=$PATH
# The only suppression in the tree, and it is the point of the case: SC2123
# warns when PATH is REPLACED by a single directory rather than extended,
# because that is normally a typo. Here it is exactly what is being tested --
# a PATH deliberately narrowed until `command -v shellcheck` finds nothing.
# Restored on the next line but one.
# shellcheck disable=SC2123
PATH="$sandbox/minbin"
export PATH
expect 1 "a missing shellcheck goes RED rather than being skipped" \
"shellcheck is not installed"
PATH=$saved_path
export PATH
if [[ "$failures" -ne 0 ]]; then
echo
echo "$failures case(s) failed: scripts/check-shell-lint.sh is not behaving as"
echo "its own tests describe. Fix the gate, not these tests."
exit 1
fi
echo
echo "All check-shell-lint.sh cases behaved as expected."