Uh oh!
There was an error while loading. Please reload this page.
ci(lint): gate the bash-3.2 floor repo-wide over every shell file the repo ships - #12319
Conversation
… repo ships
`/usr/bin/env bash` is bash 3.2.57 on macOS, and CI runs bash 5 — so a
bash-4-only construct in a hand-run script is invisible to a normal green
run in both directions: the defect passes and so does its repair. Two
incidents, four sites, both found by hand and late.
Adds `scripts/check-bash32-floor.mjs`, its `--self-test` (98 cases,
including a known-bad fixture tree the gate is shown to fail on, and a
simulated-3.2 harness proving the flagged constructs really break once
the bash-4 capability is removed), a `check:bash32-floor` alias, and a
`lint.yml` step.
The population is tracked shell under declared subtree globs — a `.sh`
name OR a shell shebang, which is the half a `*.sh` glob misses: it adds
`.githooks/pre-commit` and `.githooks/pre-push`, whose `#!/bin/sh` floor
is tighter than bash 3.2, not looser (18 -> 20 files).
Legitimate mentions are exempted by three properties of the shell, never
by a filename allowlist: full-line comments, guarded `${VAR:-}` reads,
and command position for builtins.
The two existing file-scoped scans are NOT superseded — they pair their
scan with a simulated run that reaches runtime-assembled commands a
static scan cannot see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6yinlianghui
commented
Aug 25, 2026
ACCEPT — PM review of #12221. I did not read this off your table. Four claims re-measured here, on 1. The census, re-measured independentlySame two files, same shebangs, same 18→20. Note what falls out of the two numbers together: all 18 2. The delta is coverage, not a bigger number — ablated in the file the argument is aboutYour ablation was in The 3. The stale-base reading, taken on the tree CI will actually buildYour union was derived at E1 holds against the newest text in the population, not just the text that existed when you measured. 4. Both ledgers — checked at the source, not from the quoted verdictA tool's own if(hint.includes('/')||plain.startsWith('.'))continue;All three declared roots carry On the one limit you flaggedThe egress-blocked Your refusal to supersede the two file-scoped scans is also correct and I am not going to re-open it: a static scan cannot see Landing conditions
Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12221
A repo-wide
check:for bash-4-only constructs in the shell this repo ships, with a--self-testthat runs the gate against a known-bad fixture tree so it can be shown to fail rather than asserted to work./usr/bin/env bashis bash 3.2.57 on macOS (Apple ships no bash 4+, for licensing reasons) and CI runs bash 5, where every construct this gate refuses works perfectly. So the class is invisible to a normal green run in both directions — the defect passes CI and so does its repair — and it surfaces on an operator's Mac at status 127, at the moment they most needed the script to work.Surface
scripts/check-bash32-floor.mjs--self-test(98 cases)package.jsoncheck:bash32-flooralias.github/workflows/lint.ymlNothing else. In particular: the two existing file-scoped scans are not touched.
⛔ This does not supersede the two file-scoped scans
check:objectui-changeset's R7 leg is load-bearing for #12071's property and is paired with R7a/R7a2/R7b, a simulation harness —enable -n mapfile readarrayviaBASH_ENV, plusunsetof the bash-5 variables — that proves the real code path completes without the builtins. The verify lock's own self-test does the same for its acquisition path.That pairing is not redundant with a static scan, and this PR depends on it: a static scan cannot see a construct assembled at runtime (
eval "mapfile -t x < f",bash -c 'mapfile ...'). That hole is stated in the gate's header as a deliberate limit, and it is precisely the half the simulated runs hold. Two instruments, one class, different blind spots. I found nothing redundant to remove.Question 1 — the construct set, and why each exclusion
15 constructs, all of them. Every candidate on the card cleared the two bars that matter: it is above the 3.2 floor, and it can be anchored precisely enough not to fire on 3.2-legal text.
mapfile/readarray(4.0) ·declare -A/local -A/typeset -A/readonly -A(4.0) ·declare -n/-g(4.3/4.2) ·coproc(4.0) ·wait -n(4.3) ·shopt -s globstar/lastpipe(4.0/4.2) ·exec {fd}>(4.1) ·${x^^}/${x,,}(4.0) ·${x@Q}(4.4) ·${arr[-1]}(4.2) ·;;&(4.0) ·[[ -v(4.2) ·printf '%(fmt)T'(4.2) ·&>>(4.0) ·EPOCHSECONDS/EPOCHREALTIME(5.0).Excluded, with the reason:
&>(non-append) — checked, not recalled: this is a near neighbour of&>>and it is not bash 4. It redirects both streams on 3.x, so flagging it would refuse a portable line.&>>is 4.0 and stays in. Pinned green in--self-test.;&(single-semicoloncasefall-through) — genuinely 4.0, and deliberately out: unlike;;&the two-character literal is not anchorable, occurring insedprograms, URLs, and ordinarycmd ;sequences. A pattern that cannot be anchored buys false positives, and a false positive on this gate is what re-opens the "just add an allowlist" door.globstar/lastpipe— narrowed rather than dropped: matched only asshoptarguments, the one position where they change shell behaviour.declare -a/-r/-i/-p,${x:0:1},${x//,/ },${#arr[@]},read -n 1,exec 9>— checked and found 3.2-legal; each is pinned green in--self-testso a later widening of a pattern cannot quietly start refusing portable shell.gnu.org,tiswww.case.edu,savannah.gnu.org) are egress-blocked from this seat, so the per-rowsincecould not be re-verified against upstreamNEWS. The tier-1 six carry the versions this repo already recorded beside its own repairs; the rest carry the reference manual's. This is stated in the table's docblock rather than hidden, and it is not load-bearing: nothing branches onsince— the floor is 3.2 and every row is above it, so the verdict is identical whether a construct arrived in 4.0 or 5.0. The column exists so a failure message can tell a reader why their green local run proves nothing.What is verified, on every run: each pattern matches a real,
bash -n-parseable instance of the construct it claims to describe, and does not match the 3.2 replacement beside it. That is the property that actually decides findings, and a scanner's silent death is a pattern matching nothing real.Question 2 — telling a hunter from a user
Measured first. With the tree's existing rule (full-line comments exempt, everything else a use) a repo-wide scan produces 8 findings, every one a false positive, all in
scripts/pm/os-verify-lock.sh— the card's "reddens itself on day one", reproduced. So a comment-only exemption is not enough, and this is why:local s="${EPOCHSECONDS:-}":-supplies a value when the name is unbound, so the line behaves identically on 3.2unset EPOCHSECONDS EPOCHREALTIMEenable -n mapfile readarrayst_case '…and mapfile disabled' …⛔ Not an allowlist of filenames — that rots, and it is how this class survived. ⛔ Not a waiver comment either:
os-verify-lock.shis out of surface here, so any rule requiring an edit there is not a rule this PR can adopt. The exemptions are three properties of the shell, each mechanically decidable from the line alone:^\s*#). Inherited verbatim fromobjectui-changeset-digest.mjs, whose own comment states the reason: a file refusing a construct has to name it. Full-line only; a trailing comment does not exempt the code beside it.$EPOCHSECONDS/${EPOCHSECONDS}are unguarded reads → RED.${EPOCHSECONDS:-}is not a tolerated mention, it is the repair. A bare word is not a read.VAR=valueprefixes.Each is pinned in both directions in
--self-test, including the exact line shapes from the table above.Question 3 — the population, measured
.shis the wrong population, and the measurement says so on this tree. Membership is a.shname or a shell shebang whatever the name:Two tracked hooks a
*.shglob runs straight past — and their#!/bin/shfloor is tighter than bash 3.2, not looser (/bin/shis bash 3.2 in posix mode on macOS, dash on Debian). An extension-only census would have declared this population complete at 18.Deliberately out:
package.jsonscript bodies and heredocs inside.mjs. Both really can carry shell; both are excluded for one reason — the scanner would have to decide which spans of a non-shell file are shell before it could judge a line, and a wrong answer there fabricates a finding out of JavaScript. The population is files whose whole content is shell, which is decidable from the name and the first line and from nothing else.Discovery reads the git index, so an ignored or generated file is never scanned and a newly tracked script is covered the moment it is staged. An empty population is a refusal, not a quiet pass (#4690).
Question 4 — the census, re-measured on this base
b9438a425d)*.sh.shThe
.shcount is unchanged; the population grew by 2 the moment the census stopped being extension-shaped. That is itself the answer to question 3.Landing obligations — both made unreachable, no ledger moved
Rather than buying a row in either shrink-only ledger, the population roots are spelled as subtree globs, and the walk roots are derived from them so declaration and scan cannot drift:
They are source literals on purpose — an assembled
`${r}/**`builds no watch hint at all, which is the invisible half of the bare-root species. Pinned in--self-test, including that no walk root appears anywhere in the file as a bare literal.Both tools run deliberately, verdicts quoted:
bare-root-worklistnames no row for this gate and reports0 untriaged row(s), so theTRIAGEmap needs no verdict.escapableLiteralRowsskips any hint carrying/, so noescapable-literalrow either. Neither ledger moves.And the declaration does the job it exists for — the gate is nameable by a dispatch brief:
Ablation — direction predicted in writing, then measured
Predicted before running: planting
mapfile -t _abl < /dev/nullat command position in a real tracked script turns the production leg RED with exactly one finding naming that file and line asmapfile / readarray — bash 4.0;--self-teststays green, because its verdict does not read tree contents.Observed, exactly that. The mutation was confirmed on disk before any reading was taken — the edit tool's exit code is not evidence, since
perl -iexits 0 on zero matches:Restore proven byte-identical, with a
trap … EXIT INT TERMso a container SIGTERM mid-mutation could not leave the tree mutated:No build step is involved — the gate is a plain
.mjsread directly bynode, with nodist/between the edit and the run — so there is no stale-artifact direction for this ablation to fall into.Gates
Union derived at the final commit
185e17abc4withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack. The first derivation printed⚠️ STALE TREE(3 commits behind,objectui-changeset-digest.mjsanddispatch-gates.mjsboth changed across the range — #12298 landing);origin/mainwas merged in and the union re-derived from185e17abc4, where the warning is gone. Every exit code captured before any pipe.All 25 matched families green, plus
check:nul-bytesand the two obligation tools:check:bash32-floor·check:agent-test-spelling·check:cli-command-ids·check:cross-package-test-inputs·check:entry-guard·check:node-version·check:parse-guard·check:pnpm-acquisition·check:pnpm-filter-targets·check:required-contexts·check:shard-attestation·check:type-check-coverage·check:workflow-status-functions·check-aggregator-roster·check-ci-filter-parity·check-cross-package-test-inputs·check-required-contexts·check-self-test-wired·check-self-test-workflow-commands·check-shard-attestation·check-step-collectors·check-whole-set-label-write·docs-audit/check-drift-comment·pm/ci-failure --self-test·check:nul-bytes·check:pm-dispatch-gates·pm/bare-root-worklist --self-testThe self-test-wiring obligation is discharged rather than assumed:
Repo-wide ESLint run in full, not narrowed:
One declared narrowing, with its warrant:
check:type-check-debt(check-type-check-coverage.mjs --re-measure) was not run. It refuses by design without the built dependency closure on disk —Error: --re-measure cannot run: 56 workspace dependenc(ies) … have no built type entry point, which is the gate correctly refusing to measure a different world (#6376), not a failure. Building that closure is a full-farm turbo build, and this diff contains zero TypeScript files and notsconfig.jsonor packagetypecheckscript, so no number inDEBT/TEST_DEBTcan move. The structural half ran green:✓ check:type-check-coverage --self-test — 47 semantic + 65 observation + 29 re-measure + 28 built-closure + 19 auto-lowering case(s) holdandcheck-type-check-coverage: OK — 65/78 workspace packages type-checked. CI builds the closure before that step and runs it regardless.Changeset
None —
skip-changeset.scripts/**+ a workflow step + one rootpackage.jsoncheck:*alias publish nothing.Out of surface, on purpose
bump-objectui.sh's bash-3.2 note states the wrong mechanism for the trailing-&&trap — measured, and the wrong reason invites the next author to dismiss the whole warning #12222 (bump-objectui.sh's comment, PR fix(scripts): correct bash-3.2 comment's stated mechanism in bump-objectui.sh #12306) is not addressed here.origin/mainstate was merged in only to clear theSTALE TREEwarning.Generated by Claude Code