cast/.github/scripts/shellcheck-all.sh
dan-claude-bot 1c38ef8988 fix: lint every tracked shell script, and prove the set is complete
Filed as cast's record of heavy-duty/box#116: a `shopt -s globstar;
files=(bin/* **/*.sh)` sweep never descends into `.github/`, because globs
do not match dot-prefixed names without `dotglob`. cast has no such sweep —
it has no shellcheck step at all. Its only shell gate was

    bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.sh

a syntax check over a hand-maintained list. The reported symptom holds
(release-notes.sh and labels-reconcile.sh ship unlinted) but so does every
other script here, and `bash -n` parses without linting: it would not catch
a quoting or unset-variable bug in any of them.

.github/scripts/shellcheck-all.sh now runs `shellcheck -x` over the tracked
tree, from CI and from `npm run check:shell`. The file list comes from
`git ls-files`, not a glob. `dotglob` was measured and does work today —
cast's dependency tree ships zero `.sh` files, so sweeping after `npm ci`
pulls in nothing — but that is a property of somebody else's package tree,
re-decided by every install. `git ls-files` does not depend on it.
Extensionless scripts are matched by shebang, which covers bin/cast without
naming it.

It carries a class check in box#112's shape: the sweep asserts its own list
covers `git ls-files '*.sh'` and fails naming the strays otherwise. Verified
by swapping the derivation for the buggy globstar glob, which reports
exactly the two .github/scripts files.

All eight scripts pass as they stood; the three findings were intentional
($PATH written literally into a profile, advice text in backticks) or a
false positive, and are annotated in place. No behavior changes.

Refs #118
2026-07-21 12:30:52 +00:00

79 lines
3.5 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
IFS= read -r line <"$f" || 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##*[ /]}"
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
# --- lint ------------------------------------------------------------------
printf 'shellcheck: linting %d tracked scripts\n' "${#files[@]}"
printf ' %s\n' "${files[@]}"
shellcheck -x "${files[@]}"
echo "shellcheck: clean"