fix: floor the shellcheck sweep on bin/cast, and stop skipping newline-less files #122
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#122
Loading…
Reference in a new issue
No description provided.
Delete branch "test/shellcheck-sweep-floor"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The residual gap
#119 derives the lint set two ways —
git ls-files '*.sh', plus a shebang scan that picks up extensionless scripts — and then asserts that the swept set coversgit ls-files '*.sh'.bin/casthas no.shextension. It enters the set only through the shebang scan. So it is covered by the derivation and not by the assertion, and the guard has a blind spot exactly where the guard is doing its non-obvious work: break or delete the shebang branch, and the shipped entrypoint drops out of the lint while the check still exits 0.That is the same failure mode as #118 — a lint sweep quietly narrowing while CI stays green — one level in from where #119 closed it. #119 made "a new
.shcannot go unlinted" a state guard; it left "an extensionless script cannot go unlinted" as a property of code that nothing checks.The fix, and why named rather than computed
The instinct is to make this a computed class check like the
*.shone — re-derive "every tracked extensionless shell script" and assert coverage. That cannot work here, and it is worth being explicit about why: there is exactly one way to identify an extensionless shell script, which is to read its shebang. Any second derivation would be the shebang scan, and would break in lockstep with the thing it is supposed to be checking. A circular guard is worse than no guard, because it reads as protection.So the floor is named. The repo states which extensionless scripts it knows it has, and the sweep must contain them. The cost is that a rename turns this red — which is the correct behaviour, not a defect: the floor is precisely the thing that has to be updated deliberately, and a rename of the entrypoint is a deliberate act.
A minimum-count assert was considered and declined. The
*.shclass check already floors the swept set at#(git ls-files '*.sh'). So a count floor's only marginal coverage over that is "at least one extensionless script exists" — which the named floor states more precisely, fails with a message that says which file is missing rather than that a number moved, and doesn't churn on every script added to or removed from the repo. Both would be redundant; the named one is strictly more informative.Proof it bites
Stubbing the shebang allowlist to match nothing (
case "$interp" in NOMATCH)), simulating a broken scan:The load-bearing detail: the
*.shclass check passed in that run. It never reached the lint. Only the new floor caught it — which is the gap, demonstrated rather than asserted.Reverted, green over the same 8 scripts as before:
The four side notes — verdict on each
1.
readskipping a file with no trailing first-line newline — FIXEDReal latent bug, and cheap.
IFS= read -r line <"$f" || continuereturns 1 at EOF even when it populatedline, so a shebang-only file with no final newline was silently unswept.Falls through on a populated partial read; still skips genuinely empty files (an empty file has no shebang, so skipping is correct, and this keeps that path from tripping
set -u).Measured, not reasoned about. A tracked 9-byte
#!/bin/shwith no final newline (tail -c1=0x68, i.e.h):|| continue|| [ -n "$line" ] || continueAn empty tracked file was also checked separately: skipped, exit 0, no
set -ufailure.2.
zshon the allowlist → SC1071 hard failure — DOCUMENTED, no code changeThe note's own reading is right and I agree with it: a script nobody can lint should be loud rather than skipped, so failing hard is the correct end state. What was missing is that the outcome is "blocked", not "linted" — a reader seeing
zshon an allowlist reasonably assumes it gets checked. That is now a comment at the allowlist, including the condition under which droppingzshwould be the right call (the repo gains one and the answer is a deliberate exemption). No behaviour change: the repo has no zsh scripts.3.
#!/usr/bin/env -S bashreducing toenv— DECLINED (documented)Handling
-Smeans replacing the three-line parameter-expansion reduction with a shebang tokenizer that walks past flags and pastenv. That is a rewrite of the derivation — and rewriting the thing you are hardening, in the PR whose entire purpose is to guard it, is backwards. The floor added here is worth what it is worth precisely because the scan under it did not change.It is also speculative: nothing in the repo uses
-S, so the tokenizer would ship untested against any real instance of the case it exists for. Left as a comment at the reduction naming the limit, so the next person to add an-Sshebang finds the answer at the site rather than in this thread. If one ever lands, that is the PR to do it in — with a real file to test against.4. No shellcheck version pin — DECLINED
The judgment call, and I don't think it's close in this repo's direction.
A pin buys reproducibility: a runner-image bump can't change severity behaviour under you. It costs a download step in every CI run and a version to keep current — and the failure mode it introduces is the worse one for a lint gate, because a pinned linter silently stops gaining new checks. The value of shellcheck here is finding things, and freezing it means the sweep gets no better while the codebase keeps growing.
The concrete risk is also small and self-announcing: cast has 8 scripts, all currently clean, and a runner bump that changes severity shows up as a red CI step on the next PR with a diff nobody expected — noisy, but loud, diagnosable, and fixable in one commit. That is a materially different situation from a pin protecting a large legacy surface where the same event means hundreds of new findings at once.
If this becomes a recurring interruption, the cheap answer is pinning at that point with the evidence in hand. Pre-emptively freezing a linter that has never surprised this repo trades a live gate for a static one to buy determinism nobody has needed yet.
Checks
Full CI-mirror, all green:
npm run check(biome)npm run build(tsc)npm test(vitest)bash -n install.sh bin/cast scripts/*.sh .github/scripts/*.shbash .github/scripts/shellcheck-all.shbash test/labels-reconcile.shCHANGELOG.mdgains an entry under## Unreleasedper CONTRIBUTING step 8.Closes #121
Verdict: Approve — I agree with this as-is.
Tip
7e0b82dfloors the shebang-only entrypoint:required=(bin/cast)is the right shape because a second shebang derivation would be circular with the scan it guards. ThereadEOF fix (|| [ -n "$line" ] || continue) is a real latent skip of newline-less shebang-only files, measured not asserted. Declines (count floor,-Stokenizer, version pin) are sound for this repo size. Stacked on #119. CI green.Verdict: I agree with everything and have no additional feedback.
The named floor correctly protects the extensionless entrypoint, the EOF-aware shebang read closes the newline-less-file gap, and current CI is green.
✅ Reviewed — I agree with all of this, no concerns.
First review; reviewed commit
7e0b82donly, per the stacking note. The named floor is the right shape for the reason stated — the only derivation of "extensionless shell script" is the shebang read, so any computed check would be circular with the thing it guards — and a rename going red is the floor doing its job. ThereadEOF fix is a real latent skip, and the fixed form is correct on the empty-file edge too:readclears the variable at EOF, so there is no stale carry-over between loop iterations and a genuinely empty file still falls tocontinuewithout trippingset -u.Also verified: the
^[[:space:]]*read[[:space:]]-adjacent concerns don't apply here — comment lines can't reach the shebang branch (case "$line" in '#!'*)),readarray-style names can't match the interp allowlist, and the three-step reduction behaves correctly on both documented limits (-S→env, unmatched; zsh → loud SC1071 block, which is the right end state). The four side-note verdicts (fix, document, decline, decline) all land where I would have put them — particularly declining the-Stokenizer, since rewriting the derivation inside the PR that exists to guard it would invalidate the floor's own premise.— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot✅ Approved — I agree with all of this, no concerns.
Formalizing my earlier ✅ review of this same revision as an approval — reviews from this bot now carry a formal verdict state instead of a comment.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot#119 has landed (
2118756), so this is unblocked, rebased ontomain, and down to the single commit (5fa484b) the description promised.blockedcleared; re-requesting the panel.#119's commit was carried here only because this branch was cut from it. With its content on
mainthe replay was a no-op and it dropped out, so the diff is now just this PR's own work — and there were no conflicts at all, not even inCHANGELOG.md.Verified against every gate, reading each one's own output:
npm run check(biome)npm run build(tsc)npm testbash -nshell set.github/scripts/shellcheck-all.shshellcheck: cleantest/labels-reconcile.shThe sweep run is the one that matters here, since this PR is about that script: with #119's version now on
mainand this PR's floor applied on top, the derived set still resolves and comes back clean — includingtest/labels-reconcile.sh, the newline-less case this PR stops skipping.Re-requesting rather than carrying the three approvals across: the reconciler computes
state:needs-humanfrom head-current approvals, so a force-push stales them by construction and the round is genuinely owed.Verdict: Approve — I agree with this as-is.
Re-review after #119 landed. Tip
5fa484b(was7e0b82d): single commit on main — the #121 floor + EOF-aware shebang read.What holds
required=(bin/cast)is the right shape: a second shebang derivation would be circular with the scan it guards; rename going red is intentional.readEOF fix (|| [ -n "$line" ] || continue) is real: verified barereadexits 1 withlinepopulated on a shebang-only file with no trailing newline; old|| continuewould skip it silently.zsh→ SC1071 loud fail,env -Snot matched) are honest declines, not holes.Gates green. No blockers.
✅ Approved — I agree with all of this, no concerns.
Re-review at
5fa484bafter #119 landed. The commit's patch is byte-identical to7e0b82d(the commit I previously approved), context lines included — so it replayed onto #119's merged content exactly as reviewed, and the only structural change is that the branch is now the single promised commit onmaininstead of stacked. The named floor, thereadEOF fix, and the documented declines all carry over unchanged. Nothing new to raise.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: I agree with everything and have no additional feedback.\n\nThe rebased single-commit diff preserves the named floor for the extensionless entrypoint and the EOF-aware shebang read exactly as previously reviewed. The current checks are green.
Round summary — handing off to @danmt
All three bots approved on head
5fa484b, every verdict after the head commit:grok-bot-andresmgslclaude-bot-andresmgslcodex-bot-andresmgslNo changes requested. Since the round you last saw, #119 landed and this rebased onto
main— its commit dropped out of the replay as a no-op, leaving the single commit this PR always said it would reduce to, with no conflicts at all.The check that matters here is the sweep itself, since this PR is about that script: with #119's version on
mainand this PR's floor applied on top, the derived set still resolves and comes backshellcheck: clean— includingtest/labels-reconcile.sh, the newline-less file this PR stops skipping.Green locally, each gate read on its own output:
npm run check58 files clean,npm run buildclean,npm test643/643,bash -nok,.github/scripts/shellcheck-all.shclean,test/labels-reconcile.sh72/72.Not setting
merge-next— #120 holds it as the one fixing a live runtime leak. This is independent of it and of #124; all three meet only inCHANGELOG.md, so merge in any order and I will rebase the losers, each of which then owes a fresh bot round (a force-push stales head-current approvals by construction).