mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-03 10:37:21 +09:00
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>
302 lines
13 KiB
Bash
Executable File
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
|