cast/.github/scripts/shellcheck-all.sh
dan-claude-bot 5fa484b5d2 fix: floor the shellcheck sweep on bin/cast, and stop skipping newline-less files
#119's class check asserts the swept set covers `git ls-files '*.sh'`.
bin/cast has no `.sh` extension: it enters the set through the shebang
scan, so it is covered by the DERIVATION and not by the ASSERTION. Break
or delete that scan and the shipped entrypoint drops out of the lint
while the check still exits 0 — #118's failure mode (a sweep quietly
narrowing while CI stays green) one level in from where #119 closed it.

There is no non-circular way to re-derive "every extensionless shell
script" inside the script; any second derivation would be the same
shebang scan and would break with it. So the floor is named rather than
computed: `required=(bin/cast)`, asserted present in the swept set. A
rename turns it red, which is correct — the floor is the thing that has
to be updated deliberately. A minimum-count assert was considered and
declined: given the *.sh class check already floors the set, a count
floor's only marginal coverage is "at least one extensionless script
exists", which the named floor states more precisely and with a better
error message, and it would churn on every script added or removed.

Proven to bite. With the shebang allowlist stubbed to match nothing, the
*.sh class check still PASSES and the new floor fails:

    shellcheck-all: 'bin/cast' is not in the swept set
    it has no .sh extension, so it enters only via the shebang scan above —
    that scan is broken, or the file moved. See #121.

Reverted, the sweep is green over 8 scripts again.

Also fixed, from the same review: `IFS= read -r line <"$f" || continue`
skipped any file whose FIRST line lacked a trailing newline, because
`read` returns 1 at EOF even when it populated `line`. A shebang-only
file with no final newline was silently unswept. Now
`|| [ -n "$line" ] || continue`, which falls through on a populated
partial read and still skips genuinely empty files. Measured against a
tracked 9-byte `#!/bin/sh` with no final newline: the fixed scan sweeps 9
scripts including it, the old line sweeps 8 and omits it silently.

Closes #121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:00:53 +00:00

122 lines
5.9 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
# Lint every tracked shell script in the repo, and prove the set is complete
# (#118). Note for editors: a comment line here must not BEGIN with the word
# "shellcheck" — that is directive syntax, and prose in that position is a
# parse error. This file's own sweep catches it, which is how this note exists.
#
# WHY THE LIST COMES FROM GIT, NOT FROM A GLOB
#
# The obvious sweep is a globstar one:
#
# shopt -s globstar; files=(bin/* **/*.sh)
#
# and it is quietly wrong. Globs do not match dot-prefixed names without
# `dotglob`, so `**/` never descends into `.github/` — which is where
# release-notes.sh lives, the script that produces the published release body.
#
# `shopt -s globstar dotglob` does fix that, and measured in this repo it
# pulls in nothing unwanted: the sweep runs after `npm ci`, but cast's current
# dependency tree happens to ship zero `.sh` files, so `**/*.sh` stays clean.
# "Happens to" is the problem — that is a property of somebody else's package
# tree, re-decided by every `npm install`, and the day a transitive dep vendors
# a shell script the lint silently becomes partly about their code. `git
# ls-files` does not depend on that: it sees the tracked tree exactly, with no
# dotfile blind spot and no untracked noise, and it stays right when files move.
#
# Extensionless scripts (bin/cast) are found by shebang rather than named, so
# adding one does not require editing this file.
cd "$(git rev-parse --show-toplevel)"
# --- the set to lint -------------------------------------------------------
mapfile -t files < <(
{
git ls-files '*.sh'
git ls-files | while IFS= read -r f; do
case "$f" in *.sh) continue ;; esac
[ -f "$f" ] || continue
# `read` returns 1 at EOF even when it populated `line` — which is what
# happens on a file whose FIRST line has no trailing newline (a
# shebang-only file with no final newline). A bare `|| continue` would
# skip exactly that file, silently. Fall through whenever `line` is
# non-empty; the empty case is a genuinely empty file, which has no
# shebang and is meant to be skipped.
IFS= read -r line <"$f" || [ -n "$line" ] || continue
case "$line" in '#!'*) ;; *) continue ;; esac
# reduce the shebang to a bare interpreter name: drop the '#!', drop any
# flags, then keep the last path/word component — so both '#!/bin/sh -e'
# and '#!/usr/bin/env bash' come out as 'sh' and 'bash'.
interp="${line#\#!}"
interp="${interp%% -*}"
interp="${interp##*[ /]}"
# Two known limits of this reduction, both theoretical in this repo:
# - `zsh` is on the allowlist, but shellcheck has no zsh support and
# emits SC1071 for it. So a tracked zsh script makes the sweep fail
# HARD rather than get linted. That is the right end state — a
# script nobody can lint should be loud, not skipped — but the
# outcome is "blocked", not "clean". Drop zsh here only if the repo
# ever gains one and the answer is to exempt it on purpose.
# - `#!/usr/bin/env -S bash` reduces to `env` and is not matched.
# Nothing in the repo uses `-S`; see #121 for why that is left.
case "$interp" in sh | bash | dash | ksh | zsh) printf '%s\n' "$f" ;; esac
done
} | sort -u
)
[ "${#files[@]}" -gt 0 ] || { echo "shellcheck-all: found no shell scripts — the sweep is broken" >&2; exit 1; }
# --- the class check -------------------------------------------------------
#
# Assert the set we are about to lint COVERS every tracked *.sh. Today this
# can't fail, because the list above is derived from the same `git ls-files`
# — and that is the point. It is a guard on the STATE ("no tracked script
# goes unlinted"), not on the instance that broke: the day someone rewrites
# the derivation above into something cheaper that skips a directory, this
# fails loudly instead of the lint silently passing over nothing. #118 stayed
# latent precisely because a shrinking sweep looks exactly like a green one.
unlinted="$(comm -23 <(git ls-files '*.sh' | sort -u) <(printf '%s\n' "${files[@]}" | sort -u))"
if [ -n "$unlinted" ]; then
echo "shellcheck-all: tracked shell scripts that this sweep does not lint:" >&2
printf '%s\n' "$unlinted" | sed 's/^/ /' >&2
echo "the sweep must cover every tracked *.sh — see #118" >&2
exit 1
fi
# --- the floor: extensionless scripts --------------------------------------
#
# The check above is derived from `git ls-files '*.sh'`, so it says nothing
# about scripts that have no `.sh` extension — those enter the set only via
# the shebang scan. `bin/cast` is one, and it is the shipped entrypoint. So
# it is covered by the DERIVATION and not by the ASSERTION: break or delete
# the shebang branch and bin/cast drops out of the sweep while this script
# still exits 0. That is #118's failure mode — a lint quietly narrowing while
# CI stays green — one level in from where the *.sh check closed it (#121).
#
# There is no non-circular way to re-derive "every extensionless shell
# script" here; any second derivation would be the same shebang scan, and
# would break with it. So the floor is named rather than computed: the known
# extensionless scripts are listed, and the sweep must contain them. A rename
# turns this red, which is correct — the floor is the thing that has to be
# updated deliberately.
required=(bin/cast)
for req in "${required[@]}"; do
if ! printf '%s\n' "${files[@]}" | grep -qxF "$req"; then
echo "shellcheck-all: '$req' is not in the swept set" >&2
echo "it has no .sh extension, so it enters only via the shebang scan above —" >&2
echo "that scan is broken, or the file moved. See #121." >&2
exit 1
fi
done
# --- lint ------------------------------------------------------------------
printf 'shellcheck: linting %d tracked scripts\n' "${#files[@]}"
printf ' %s\n' "${files[@]}"
shellcheck -x "${files[@]}"
echo "shellcheck: clean"