Skip to content

fix(scripts): close the bash 4.0 operator gap in check-bash32-floor's denylist - #12764

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-12634-bash32-pipe-both
Aug 27, 2026
Merged

fix(scripts): close the bash 4.0 operator gap in check-bash32-floor's denylist#12764
os-zhuang merged 1 commit into
mainfrom
claude/issue-12634-bash32-pipe-both

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12634

check-bash32-floor.mjs refused &>> (append-both) but not |& (pipe-both) — same bash release, same class, one caught and one not. Because the table is a denylist, that absence read as an approval. Triage set the scope wider than the one entry for exactly that reason, so this sweeps the whole bash 4.0 operator set in one pass.

Premise, re-derived on this base rather than inherited

The dispatch brief carried a reading; it was re-measured here on 168941cea, with the control that makes the zero mean something:

construct ids present: 15
"append-both" -> 2 hits (control: fires)
"pipe-both" -> 0 hits

The absence is real, not a grep that could not match.

What the sweep adds

idoperatorbashwhat 3.2 does
pipe-both|&4.0hard syntax error — the script does not start
case-fallthrough-next;&4.0hard syntax error — the script does not start
brace-increment{x..y..incr}4.0silent — brace word left literal, loop runs once, exit 0
bashpid$BASHPID4.0unbound: fatal under set -u, otherwise EMPTY

15 to 19 constructs. Two things worth calling out:

Versions were read, not asserted. The dispatch brief's own assumption — that |& is 4.0 and its 3.2 behaviour is a hard syntax error, louder than &>> merely backgrounding — was checked against the bash NEWS text rather than taken on faith, and it holds: |& is "a synonym for 2>&1 |", ;& and ;;& are the two new case terminators, $BASHPID is "a new variable", all four under 4.0. The file's header records that these four rows differ from the pre-existing tier-2 rows in exactly that respect.

The sweep found a construct whose 3.2 failure is quiet, and the entry says so.{x..y..incr} is not an error on a shell that cannot parse it — bash leaves any unparseable sequence expression literal. Measured on this host with a deliberately invalid increment: echo {1..10..x} prints back its own ten characters. So the 3.2 symptom is a loop that runs exactly ONCE over a nonsense value at exit 0. The row's breaks text encodes that distinction the way append-both already encodes "BACKGROUNDED".

bashpid is the one row here that is a variable rather than an operator. It is included deliberately: it is the last remaining bash 4.0 addition absent from this table, it is a one-row mirror of the existing epoch-vars declaration, and leaving it out would have re-paid this card's cost on the next pass — which is the precise thing triage asked to stop. Flagged here rather than slipped in.

What is deliberately NOT in the table, and why

Now recorded in the gate's own header so the next reader inherits the list instead of re-deriving it:

  • ** (globstar) — not a construct on its own. Without shopt -s globstar, ** is two ordinary globs and legal on 3.2, so what is refusable is the option, and the shopt-4 row already refuses it. A literal ** token would also fire on arithmetic exponentiation and on every doubled asterisk in a find argument.
  • test -v / [ -v ] — a real hole, and NOT closed here. [ -v is also the opening of an ordinary bracket expression (tr -d '[ -v]' is the character range space-to-v), so widening the has-v row needs a false-positive judgement this sweep did not take, and a wrong one reddens the tree on correct 3.2 code. Filed separately and out of scope for this PR: check-bash32-floor's has-v row sees only the [[ -v ]] spelling — test -v and [ -v ] pass #12760.
  • New flags on already-refused builtins (mapfile -d, readarray -C) — a builtin row refuses its builtin at every flag.
  • Variables added after 4.0 (BASH_XTRACEFD, BASH_ARGV0, SRANDOM, PROMPT_DIRTRIM) — outside the 4.0 line this sweep drew, named in the header so they are a written worklist rather than silent approvals.

Two patterns that carry a judgement, both pinned

;& needs a lookbehind.;;&contains;&, so a bare token double-flags every ;;& line and half of each pair names the wrong construct and the wrong repair. The row's token is (?<!;);&, and the disjointness is pinned in both directions — the ablation below shows why: stripping the lookbehind reds only the ;;& case, so a one-sided pin would have passed.

brace-increment is tighter than its sibling syntax rows. A loose "two dot-runs inside braces" pattern matches an ordinary brace LIST of relative paths — cp {../a,../b} . — so the token requires a real sequence-expression shape. Pinned both ways.

The negative half, which is the load-bearing one

A |& pattern that also matched 2>&1 | would redden every correct pipeline in the repo — the gate refusing the very remedy its failure text prescribes. Pinned: t('`2>&1 |`, the replacement `|&` is a synonym FOR, is not flagged', ...), plus an ordinary pipe, an ordinary background &, {1..10}, {a..z}, and the relative-path brace list.

A coverage floor, because deleting a row was silent

Every existing leg iterates CONSTRUCTS, so deleting a row deleted its own tests and the suite stayed green — the same species this gate exists to refuse, one level up, in the instrument instead of the tree. Five new cases are not parameterised by the table: they name real lines and require that something still refuses each. Written against behaviour rather than ids, so renaming a row is free and removing its coverage is loud. &>> is in that list as the control.

Evidence

End to end, both directions, one fixture tree, five bash-4.0 constructs on five lines:

BEFORE (gate at 168941cea): exit 1 — 1 finding (only scripts/deploy.sh:7, &>>)
AFTER (this branch): exit 1 — 5 findings (lines 3,4,5,6,7)

Ablation — every pin added, mutate to RED, restore to GREEN. Restores are git checkout HEAD -- ABS_PATH (never bare, which reads a polluted index); each mutation proved on disk by counting the anchor before and after, never by the editor's exit code; each restore proved by git hash-object equality against the HEAD blob plus an empty git diff HEAD, with an empty hash treated as failure. The harness carries a trap ... EXIT INT TERM, used as a crash convenience only, not as proof.

delete row pipe-both anchor 1 -> 0 RED: "the table still refuses |& (pipe-both)"
delete row case-fallthrough-next anchor 1 -> 0 RED: "the table still refuses ;& (case fall-through)"
delete row brace-increment anchor 1 -> 0 RED: "the table still refuses {x..y..incr} ..."
delete row bashpid anchor 1 -> 0 RED: "the table still refuses $BASHPID"
delete row append-both (control) anchor 1 -> 0 RED: "the table still refuses &>> (append-both)"
strip the (?<!;) lookbehind anchor 1 -> 0 RED: "`;;&` is the case-fallthrough row ALONE"
loosen the sequence shape anchor 1 -> 0 RED: "a brace LIST of relative paths is not ..."
ALL ABLATIONS PASSED (each followed by: bytes match HEAD blob, self-test GREEN again)

Gate union, run at 1a3bee27c — the final commit on this branch:

check:agent-test-spelling exit=0 check:parse-guard exit=0
check:bash32-floor exit=0 check:pnpm-filter-targets exit=0
check:cli-command-ids exit=0 check-ci-filter-parity.mjs exit=0
check:cross-package-test-inputs exit=0 check-cross-package-test-inputs exit=0
check:entry-guard exit=0 check:nul-bytes exit=0
bare-root-worklist --self-test exit=0 check:pm-dispatch-gates exit=0

The gate's own verdict lines, quoted rather than an exit code:

✓ check-bash32-floor self-test: 130 cases pass. (98 before this change)
✓ check-bash32-floor: 22 tracked shell file(s) ... census: 20 by .sh extension,
2 by shebang alone; 19 constructs checked, floor bash 3.2.

The list was re-derived for the actual diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reads its own changeset from the merge base) and matched the brief's nine families with nothing added. The two convention-triggered obligations the tool names for a gate-script edit — bare-root-worklist --self-test and check:pm-dispatch-gates — were run and are green; neither needed a ledger row, because POPULATION_ROOTS is untouched by this diff.

check-ci-filter-parity.mjs first exited 1 with PREREQUISITE NOT MET — the dependency yaml is not installed. That is a missing pnpm install in a fresh worktree, not a finding; it is green above after installing.

Declared narrowing: repo-wide pnpm lint was not run locally. eslint was run on the changed file only (--no-inline-config --format json: 1 file, 0 errors, 0 warnings). CI runs the full farm regardless, and this is a declared narrowing rather than a proven one — no claim is made here about untouched files.

Notes

  • Three |& occurrences already exist in the population, all in scripts/pm/os-verify-lock.sh and all in full-line comments, so E1 exempts them and the tree stays at 0 findings. That was checked before writing the pattern, not after.
  • No changeset: root scripts/ is not a published package, so this ships nothing to consumers. skip-changeset applied.
  • Observed and deliberately untouched (different file, outside this card's surface): the file-scoped bash4Constructs list inside scripts/objectui-changeset-digest.mjs carries a narrower construct set than this table. It is not a coverage hole — the repo-wide gate covers scripts/bump-objectui.sh with a superset — so no issue was filed for it.

Generated by Claude Code

check-bash32-floor's construct table carried `&>>` (append-both) but not
`|&` (pipe-both) — same bash release, same failure class, one caught and
one not. A denylist's absences read as approvals, so this sweeps the whole
bash 4.0 operator set rather than adding the one entry:
pipe-both `|&` hard syntax error on 3.2
case-fallthrough-next `;&` hard syntax error on 3.2
brace-increment `{x..y..incr}` SILENT — left literal, exit 0
bashpid `$BASHPID` unbound (the 4.0 sibling of epoch-vars)
Versions read off the bash NEWS text, not asserted. The operators left out
(`**`, `test -v`/`[ -v ]`, new flags on already-refused builtins, post-4.0
variables) are now written down in the header with their reasons, so the
next reader inherits the list instead of re-deriving it.
The `;&` row carries a lookbehind because `;;&` contains `;&`; both
directions of that disjointness are pinned. A new coverage floor makes row
DELETION loud: every other leg is parameterised by the table, so removing a
row removed its own tests and the suite stayed green.
15 -> 19 constructs; self-test 98 -> 130 cases; real tree still 0 findings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check-bash32-floor's construct table has &>> (append-both) but not |& (pipe-both) — both are bash 4.0, so one is caught and the other is not

2 participants

@os-zhuang@claude