Files
cozystack/hack/shellcheck.sh
Aleksei Sviridkin c5f352829c ci(tests): gate the shell scripts this repository owns on shellcheck
shellcheck ran nowhere: not from the Makefile, not from a workflow, not
from pre-commit. Seven files under hack/ nevertheless carried
`# shellcheck disable=` directives, twelve of the twenty-eight with a
rationale beside them, so the tree was written as though the check
existed.

hack/shellcheck.sh lints every shell script this repository owns and
compares the result with hack/shellcheck-baseline.txt. Enumeration is a
union of shebang and `.sh` suffix, and neither half is redundant. The
suffix alone misses fifty-seven files with no extension, fifty-three of
them the platform migrations run-migrations.sh runs on a cluster
upgrade and which no linter had seen before this; the shebang
alone misses thirteen held by the suffix, the sourced libraries that
open with a `# shellcheck shell=` directive and four with no marker at
all.
Narrowing to either one loses files, and what the run then reports
never names the enumeration as the cause. Measured, and it differs by
half: a script that leaves scope carrying baseline entries goes STALE,
one carrying none -- sixty-six of the hundred and ninety -- leaves in
silence, and dropping the suffix branch also removes the sourced
libraries from the command line, so their sourcers gain SC1091 as NEW.
The reader is shown a fixed finding to regenerate away, or a new
finding to go fix, and in neither case a narrower gate.

The baseline records the findings that predate the gate as
`path check count` triples. Among the scripts in scope no file is
skipped and nothing is excluded on the command line, though in-file
`# shellcheck disable=` directives are still honoured, so a finding
suppressed that way is absent from the list rather than recorded in
it; two classes sit outside the
scan and say so in the baseline header, the vendored chart scripts
that make update regenerates and the .bats files, whose bats shebang
the enumeration does not match. shellcheck parses those perfectly
well, with a dialect and checks of its own for them; they are not
baselined yet, and the .bats arm of the filter is a guard for one that
acquires a shell shebang rather than an exclusion that does anything
today.
Dropping line and column numbers keeps an unrelated edit from reddening
the ratchet, while the count still catches a new finding in a script
already listed. An entry whose findings are gone fails the run too, so
fixing a script forces the list to shrink rather than leaving a standing
licence to reintroduce what was removed.

The regenerate advice is withheld whenever the same run also reports
a NEW finding. Printed together, the last line read wins, and
regenerating a mixed run records the new findings rather than
reporting them -- the cure offered for one half launders the other.

Three ways the gate could have passed without checking anything are
closed. A missing binary fails rather than skips. The version is pinned
on both sides and the script refuses to run when they disagree, because
findings differ between shellcheck releases and an unpinned binary
reports against a baseline not written for it. And shellcheck's own exit
status is kept instead of being piped through xargs, which collapses it:
a status above 1 means an input was never analysed, and that now fails
rather than reading as a clean file.

A path holding a space or a tab is refused instead of linted. A
baseline entry is three space-separated fields, so such a path cannot
be recorded: the summary splits it and the entry names a file that
does not exist. Refusing is also what licenses the word splitting the
enumeration relies on, which until now was a comment asserting the
tree had no such name rather than a check that it does not.

hack/shellcheck.bats drives the gate through a mutation that introduces
a violation and its removal, both directions of the ratchet, each of
the four refusals above, and the enumeration each half of the union
catches, the shrink half in the case where a count drops without
reaching zero as well as the one where an entry vanishes, and the
whitespace refusal on a tab as well as on a space. Every red path
asserts the exit status before it asserts the message: `! cmd` alone
proves nothing, because `set -e` never fires on
an inverted command, and a gate that printed each ERROR line while
exiting 0 satisfied all thirteen output greps when that was measured.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-08-07 16:28:42 +03:00

302 lines
13 KiB
Bash
Executable File

#!/bin/sh
set -eu
# Runs shellcheck over every shell script this repository owns and compares the
# result against hack/shellcheck-baseline.txt.
#
# Two things make this more than a `shellcheck **/*.sh`, and the tests in
# hack/shellcheck.bats pin both:
#
# Enumeration is a union of shebang and `.sh` suffix, and dropping either half
# loses files. What the run then reports never names the enumeration as the
# cause, and differs by half -- measured, not assumed. A script that leaves
# scope carrying baseline entries goes STALE; one carrying none, which is most
# of them, leaves in silence. Dropping the suffix branch also removes the
# sourced libraries from the command line, so the scripts that source them gain
# SC1091 as NEW. So the reader sees a fixed finding to regenerate away, or a new
# finding to go fix, and in neither case a narrower gate.
#
# The comparison is a ratchet in both directions: an unrecorded finding fails,
# and so does a baseline entry whose findings are gone. Without the second half
# a fixed script leaves a standing licence to reintroduce what was removed.
#
# Note for anyone editing these comments: shellcheck reads any comment whose
# first word is `shellcheck` as a directive, and a malformed one costs more than
# the SC1073 it prints -- analysis of that file STOPS there, so everything below
# goes unlinted while the run still looks like an ordinary finding. Measured: a
# parse error from a redirection recovers and reports the rest of the file, one
# from a directive does not. No script in scope has the second kind; keep it
# that way by never wrapping a line onto that word.
#
# Usage:
# hack/shellcheck.sh lint the tree, compare against the baseline
# hack/shellcheck.sh --regenerate rewrite the baseline from the current tree
# hack/shellcheck.sh --list print the scripts the gate covers
# hack/shellcheck.sh FILE... lint exactly these files (used by the tests)
#
# The two do not combine into a partial refresh: `--regenerate FILE...` rewrites
# the whole baseline from those files alone and drops every other entry. Only
# the tests pass both, against a baseline of their own.
#
# Environment:
# SHELLCHECK_BIN shellcheck executable, default `shellcheck`
# SHELLCHECK_BASELINE baseline path, default hack/shellcheck-baseline.txt
# Pinned: shellcheck emits different findings across releases, so an unpinned
# binary turns the baseline into a version detector. The same version is
# installed by the unit-test job in .github/workflows/pull-requests.yaml. Move
# both together and regenerate the baseline in the same commit.
SHELLCHECK_PINNED_VERSION=0.11.0
SHELLCHECK_BIN=${SHELLCHECK_BIN:-shellcheck}
SHELLCHECK_BASELINE=${SHELLCHECK_BASELINE:-hack/shellcheck-baseline.txt}
REGENERATE=0
LIST_ONLY=0
case "${1:-}" in
--regenerate)
REGENERATE=1
shift
;;
--list)
LIST_ONLY=1
shift
;;
esac
# Every tracked file that is a shell script: a .sh suffix, or a shell shebang on
# the first line. Vendored upstream charts are excluded because `make update`
# regenerates them. The .bats arm is a guard rather than an exclusion: those
# files open with `#!/usr/bin/env bats`, which neither branch below matches, so
# dropping the arm changes nothing today. shellcheck does parse them -- it has a
# bats dialect and its own bats checks -- they are simply not baselined here.
#
# The list is newline separated, so a tracked filename containing a newline
# would be mis-split -- the one whitespace character reject_whitespace_paths
# below cannot see, because it is the separator. No such name exists in the
# tree. BATS_UNIT_FILES in the Makefile carries a related caveat about a
# different character: `$(wildcard)` splits its result on spaces.
#
# The existence check covers both branches. A tracked .sh deleted but not yet
# staged is still listed by git, and handing one to shellcheck exits 2, which
# this script correctly refuses to read as clean -- but the report is then the
# whole findings dump around one "does not exist", for an ordinary local state.
list_scripts() {
git ls-files | while IFS= read -r f; do
case "$f" in
*/charts/*) continue ;;
*.bats) continue ;;
esac
[ -f "$f" ] || continue
case "$f" in
*.sh)
printf '%s\n' "$f"
continue
;;
esac
first=$(head -n 1 "$f" 2>/dev/null || true)
case "$first" in
'#!'*sh | '#!'*sh[!a-zA-Z0-9_]*) printf '%s\n' "$f" ;;
esac
done
}
if [ "$LIST_ONLY" -eq 1 ]; then
list_scripts
exit 0
fi
# A missing shellcheck is a failure, never a skip. A gate that quietly passes
# when its tool is absent is the exact shape of protection this script exists
# to replace.
if ! command -v "$SHELLCHECK_BIN" >/dev/null 2>&1; then
echo "ERROR: shellcheck not found (SHELLCHECK_BIN=$SHELLCHECK_BIN)." >&2
echo "Install v$SHELLCHECK_PINNED_VERSION from https://github.com/koalaman/shellcheck/releases" >&2
exit 1
fi
found_version=$("$SHELLCHECK_BIN" --version 2>/dev/null | sed -n 's/^version: //p')
if [ "$found_version" != "$SHELLCHECK_PINNED_VERSION" ]; then
echo "ERROR: shellcheck $SHELLCHECK_PINNED_VERSION is pinned, found '${found_version:-unknown}'." >&2
echo "Findings differ between releases, so another version reports against a baseline not written for it." >&2
exit 1
fi
# `path SCxxxx count`, sorted. Line and column numbers are dropped so an edit
# that shifts a line does not redden the ratchet; the count is what still
# catches a new finding in an already-listed script.
#
# The gap this leaves, stated so nobody has to rediscover it: one change that
# both removes and introduces a finding of the same check in the same script
# leaves the count equal and passes. Everything else is caught -- a new script, a
# check the script did not have, one more of a check it did -- and fixing a
# finding without adding one fails as STALE. Closing the last case means pinning
# identity to something inside the line, which puts the noise back; the diff is
# the reviewer's instrument there, not this file.
summarise() {
sed -nE 's/^(.*):[0-9]+:[0-9]+: [a-z]+: .*\[(SC[0-9]+)\]$/\1 \2/p' |
sort | uniq -c |
awk '{ print $2, $3, $1 }' | sort
}
count_lines() {
grep -c . || true
}
# A baseline line is `path SCxxxx count`, separated by spaces, so a path holding
# a space or a tab cannot be written down: the summary would split it into
# fields and record an entry naming something that is not the file. Refusing is
# the only honest handling of one, and refusing here is also what licenses the
# word splitting below -- the tree having no such name becomes a checked fact
# rather than a comment asserting it.
reject_whitespace_paths() {
bad=$(printf '%s\n' "$1" | grep '[[:space:]]' || true)
if [ -n "$bad" ]; then
echo "ERROR: path contains whitespace, which a baseline entry cannot record:" >&2
printf '%s\n' "$bad" >&2
exit 1
fi
}
# Explicit arguments are checked as given and left in "$@". Routing them through
# the same newline-joined string as the enumeration would split one on its own
# spaces before the check above ever saw them.
if [ "$#" -eq 0 ]; then
FILES=$(list_scripts)
if [ -z "$FILES" ]; then
echo "ERROR: no shell scripts found to lint." >&2
exit 1
fi
reject_whitespace_paths "$FILES"
# Splitting is wanted here; globbing is not. Unquoted, a tracked name
# holding [ or * would also be matched against the working directory,
# which drops the real file and can count an unrelated one in its place.
set -f
# shellcheck disable=SC2086
set -- $FILES
set +f
else
reject_whitespace_paths "$(printf '%s\n' "$@")"
fi
# Not piped through xargs, which reports a status of its own and so cannot tell
# findings from a failure to analyse: a child exiting 1 and a child exiting 2
# reach the caller as the same xargs status, and that distinction is the whole
# point of the check below. shellcheck
# exits 0 clean and 1 with findings; anything above means it could not do the
# job -- a file it could not read, an option it did not understand. Reporting
# "no drift" off a run that never analysed some of its inputs, or regenerating a
# baseline shrunk by the files it skipped, is exactly the silent pass this gate
# exists to remove.
RAW=$("$SHELLCHECK_BIN" --format=gcc "$@" 2>&1) && rc=0 || rc=$?
if [ "$rc" -gt 1 ]; then
echo "ERROR: shellcheck exited $rc; some scripts were not analysed." >&2
printf '%s\n' "$RAW" >&2
exit 1
fi
CURRENT=$(printf '%s\n' "$RAW" | summarise)
if [ "$REGENERATE" -eq 1 ]; then
{
echo "# shellcheck baseline. Regenerate with: make shellcheck-baseline"
echo "#"
echo "# Generated $(date -u +%Y-%m-%d) against shellcheck $SHELLCHECK_PINNED_VERSION."
echo "#"
echo "# One line per (script, check) pair with the number of findings hack/shellcheck.sh"
echo "# tolerates there today. The list is allowed to shrink and nothing else: a finding"
echo "# it does not record fails the run, and so does an entry whose findings have been"
echo "# fixed. Adding a line is a visible act in review, not a side effect of writing"
echo "# new code."
echo "#"
echo "# Every shell script this repository owns is scanned, at shellcheck's default"
echo "# severity: no file among them is skipped, and nothing is excluded on the"
echo "# command line. In-file '# shellcheck disable=' directives are still honoured,"
echo "# so a finding suppressed that way is absent from this list rather than"
echo "# recorded in it."
echo "#"
echo "# Two classes sit outside the scan and are not represented here at all: the"
echo "# vendored chart scripts under packages/*/*/charts/, which make update"
echo "# regenerates, and the .bats files, whose bats shebang the enumeration does"
echo "# not match. shellcheck can lint those with --shell=bats; they are not"
echo "# baselined here."
echo "#"
echo "# These are the findings that predate the gate."
printf '%s\n' "$CURRENT"
} > "$SHELLCHECK_BASELINE"
echo "Wrote $SHELLCHECK_BASELINE ($(printf '%s\n' "$CURRENT" | count_lines) entries)."
exit 0
fi
if [ ! -f "$SHELLCHECK_BASELINE" ]; then
echo "ERROR: baseline $SHELLCHECK_BASELINE not found. Create it with: make shellcheck-baseline" >&2
exit 1
fi
BASE=$(grep -v '^[[:space:]]*#' "$SHELLCHECK_BASELINE" | grep . || true)
# Each stream is tagged rather than counted into awk, so an empty baseline and a
# clean tree are both ordinary inputs instead of edge cases in an offset.
DRIFT=$(
{
printf '%s\n' "$BASE" | sed 's/^/B /'
printf '%s\n' "$CURRENT" | sed 's/^/C /'
} | awk '
NF == 4 && $1 == "B" { base[$2 " " $3] = $4 }
NF == 4 && $1 == "C" { cur[$2 " " $3] = $4 }
END {
for (k in cur) {
b = (k in base) ? base[k] : 0
if (cur[k] > b)
printf "NEW %s %d %d\n", k, cur[k], b
else if (cur[k] < b)
printf "STALE %s %d %d\n", k, cur[k], b
}
for (k in base)
if (!(k in cur))
printf "STALE %s 0 %d\n", k, base[k]
}
' | sort
)
if [ -z "$DRIFT" ]; then
echo "shellcheck: $# scripts, $(printf '%s\n' "$BASE" | count_lines) baselined entries, no drift."
exit 0
fi
echo "ERROR: shellcheck results do not match $SHELLCHECK_BASELINE:" >&2
printf '%s\n' "$DRIFT" |
awk '{ printf " %-6s %s %s: %s finding(s), baseline records %s\n", $1, $2, $3, $4, $5 }' >&2
NEW_FILES=$(printf '%s\n' "$DRIFT" | awk '$1 == "NEW" { print $2 }' | sort -u)
if [ -n "$NEW_FILES" ]; then
echo >&2
echo "Findings in the affected scripts:" >&2
printf '%s\n' "$NEW_FILES" | while IFS= read -r f; do
printf '%s\n' "$RAW" | grep -F "$f:" >&2 || true
done
echo >&2
echo "Fix them, or -- where the construct is genuinely intended -- annotate the" >&2
echo "line with a '# shellcheck disable=SCxxxx' carrying the reason." >&2
fi
# The regenerate advice is withheld while anything is NEW, even if something is
# also STALE. Printed together, the last line read wins, and regenerating a run
# that carries both writes the new findings into the baseline as well: the fix
# offered for one half launders the other, and the next run is green.
if printf '%s\n' "$DRIFT" | grep -q '^STALE'; then
echo >&2
echo "STALE means the baseline records findings the tree no longer has --" >&2
echo "fixed, or in a script that was deleted or renamed." >&2
if [ -n "$NEW_FILES" ]; then
echo "Do not regenerate yet: this run also has NEW findings, which a" >&2
echo "regenerate would record instead of reporting. Fix those first --" >&2
echo "unless the NEW and STALE entries are the same findings under a" >&2
echo "different path, which is a rename, and regenerating is correct." >&2
else
echo "Run: make shellcheck-baseline" >&2
fi
fi
exit 1