fix: floor the shellcheck sweep on bin/cast, and stop skipping newline-less files #122
2 changed files with 56 additions and 1 deletions
45
.github/scripts/shellcheck-all.sh
vendored
45
.github/scripts/shellcheck-all.sh
vendored
|
|
@ -38,7 +38,13 @@ mapfile -t files < <(
|
||||||
git ls-files | while IFS= read -r f; do
|
git ls-files | while IFS= read -r f; do
|
||||||
case "$f" in *.sh) continue ;; esac
|
case "$f" in *.sh) continue ;; esac
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
IFS= read -r line <"$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
|
case "$line" in '#!'*) ;; *) continue ;; esac
|
||||||
# reduce the shebang to a bare interpreter name: drop the '#!', drop any
|
# 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'
|
# flags, then keep the last path/word component — so both '#!/bin/sh -e'
|
||||||
|
|
@ -46,6 +52,15 @@ mapfile -t files < <(
|
||||||
interp="${line#\#!}"
|
interp="${line#\#!}"
|
||||||
interp="${interp%% -*}"
|
interp="${interp%% -*}"
|
||||||
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
|
case "$interp" in sh | bash | dash | ksh | zsh) printf '%s\n' "$f" ;; esac
|
||||||
done
|
done
|
||||||
} | sort -u
|
} | sort -u
|
||||||
|
|
@ -71,6 +86,34 @@ if [ -n "$unlinted" ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 ------------------------------------------------------------------
|
# --- lint ------------------------------------------------------------------
|
||||||
|
|
||||||
printf 'shellcheck: linting %d tracked scripts\n' "${#files[@]}"
|
printf 'shellcheck: linting %d tracked scripts\n' "${#files[@]}"
|
||||||
|
|
|
||||||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -292,6 +292,18 @@ actually cutting it, and this file starts there.
|
||||||
with a class check in box#112's shape: the sweep asserts its own list
|
with 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, so a
|
covers `git ls-files '*.sh'` and fails naming the strays otherwise, so a
|
||||||
future sweep that quietly narrows is red rather than green over nothing.
|
future sweep that quietly narrows is red rather than green over nothing.
|
||||||
|
- **The shellcheck sweep's own blind spot: extensionless scripts** (#121) —
|
||||||
|
the class check above asserts the swept set covers `git ls-files '*.sh'`,
|
||||||
|
which says nothing about scripts with no `.sh` extension. `bin/cast` is
|
||||||
|
one, and it enters the set only through the shebang scan — covered by the
|
||||||
|
derivation, not by the assertion. Break that scan and the shipped
|
||||||
|
entrypoint drops out of the lint while the check still exits 0: #118's
|
||||||
|
failure mode one level in. The sweep now also asserts a named floor of
|
||||||
|
known extensionless scripts, verified to go red when the shebang branch is
|
||||||
|
broken. Fixed alongside it: `IFS= read -r line <"$f" || continue` skipped
|
||||||
|
any file whose first line had no trailing newline, because `read` returns
|
||||||
|
1 at EOF even having populated `line` — a shebang-only file with no final
|
||||||
|
newline was silently unswept.
|
||||||
|
|
||||||
## 0.1.1 — 2026-07-19
|
## 0.1.1 — 2026-07-19
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue