diff --git a/scripts/bump-objectui.sh b/scripts/bump-objectui.sh index 8217d6fceb..c1ff2049c4 100755 --- a/scripts/bump-objectui.sh +++ b/scripts/bump-objectui.sh @@ -320,11 +320,37 @@ report_objectui_reachability() { # Enumerated only on this branch: with ~941 branches a --contains walk is not # free, and the healthy path must not pay for it. + # + # READ LOOP, NOT `mapfile` — THE BASH 3.2 FLOOR. `mapfile`/`readarray` are + # bash 4 builtins, and `/usr/bin/env bash` is bash 3.2.57 on macOS (Apple has + # not shipped bash 4+ for licensing reasons). This script is run BY HAND, BY + # AN OPERATOR, on a laptop — the pin-bump procedure in + # `docs/releases-maintenance.md` has no CI path — so a bash-4 builtin here + # does not fail on a fringe host, it fails on the ordinary one. And it fails + # on exactly the branch that must not fail: `mapfile` is only ever reached + # once the verdict is "NOT on origin/main", i.e. the #10495 warning is the + # single thing this shell cannot deliver. Measured with `mapfile` disabled: + # `mapfile: command not found`, then `set -e` kills the run at status + # 127 BEFORE the pin is written, and the operator is handed a bare builtin + # error where the warning should have been — an error whose obvious remedy + # (edit `.objectui-sha` by hand) walks around every guard in this file. + # CI runs bash 5, so neither the defect nor this repair is observable in a + # normal CI run; the digest self-test pins it with the builtin disabled + # (`enable -n mapfile readarray` via `BASH_ENV`) plus a static scan. + # Keep this loop bash-3.2-clean: no `mapfile`, no `readarray`, no + # `declare -A`, no `${x^^}`/`${x,,}`. The `if` (rather than `[[ … ]] &&`) is + # load-bearing under `set -e`: a trailing false `&&`-list would make the + # whole `while` return 1 and kill the run on an empty ref list. local -a local_refs=() remote_refs=() - mapfile -t local_refs < <( + local ref_line='' + while IFS= read -r ref_line; do + if [[ -n "$ref_line" ]]; then local_refs+=("$ref_line"); fi + done < <( git -C "$OBJECTUI_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/heads 2>/dev/null || true ) - mapfile -t remote_refs < <( + while IFS= read -r ref_line; do + if [[ -n "$ref_line" ]]; then remote_refs+=("$ref_line"); fi + done < <( git -C "$OBJECTUI_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/remotes 2>/dev/null || true ) diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index 1b304f1d95..cd0600ce71 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -2505,7 +2505,9 @@ function selfTest() { return dir; }; // Offline by construction — a self-test must never reach the network. - const runBump = (fwDir, uiRoot, args) => + // `extraEnv` exists for R7 below, which re-runs one of these cases under a + // shell with the bash 4 builtins taken away. + const runBump = (fwDir, uiRoot, args, extraEnv = {}) => spawnSync('bash', [join(fwDir, 'scripts', 'bump-objectui.sh'), ...args], { encoding: 'utf8', env: { @@ -2513,6 +2515,7 @@ function selfTest() { OBJECTUI_ROOT: uiRoot, OBJECTUI_NO_DEEPEN: '1', GIT_TERMINAL_PROMPT: '0', + ...extraEnv, }, }); const mkUi = (name) => { @@ -2720,6 +2723,93 @@ function selfTest() { r6.stderr, ); + // --- R7: the bash 3.2 floor -------------------------------------------- + // + // `bump-objectui.sh` is run BY HAND by an operator (docs/releases- + // maintenance.md), and `/usr/bin/env bash` is bash 3.2.57 on macOS. Every + // case above ran on whatever bash the host has — bash 5 in CI — so the + // whole R1..R6 block is blind to a bash-4-only construct: it went green on + // a script that could not complete a single one of these runs on a Mac. + // Measured: with `mapfile` unavailable, R2/R2b/R2c/R2d/R3/R3b/R6 all fail, + // two of them with `status=127`, and the operator run dies at 127 with + // `mapfile: command not found` as its ONLY output — the #10495 warning, + // which is reached on no other branch, cannot print at all. + // + // Both halves are needed and they catch different things: the static scan + // sees parse-level constructs that `enable -n` cannot simulate, and the + // simulated run proves the real code path completes without the builtins. + const bash4Constructs = new RegExp( + [ + 'exec\\s+\\{[A-Za-z_]', // bash 4.1 fd auto-allocation + '\\$\\{[A-Za-z_][A-Za-z0-9_]*(\\[[^\\]]*\\])?(\\^|,)', // bash 4.0 ${x^^} / ${x,,} + '(declare|local|typeset)\\s+-[A-Za-z]*A\\s', // bash 4.0 associative arrays + '(mapfile|readarray)\\s', // bash 4.0 builtins — the one this card is about + '&>>', // bash 4.0 append-both redirection + 'EPOCH(SECONDS|REALTIME)', // bash 5.0 variables + ].join('|'), + ); + const bumpSrc = readFileSync(join(__dirname, 'bump-objectui.sh'), 'utf8'); + // Comments are exempt: this file has to NAME the constructs it refuses in + // order to explain why, and the simulated run below covers what actually + // executes. + const bash4Hits = bumpSrc + .split('\n') + .map((line, i) => [i + 1, line]) + .filter(([, line]) => !/^\s*#/.test(line)) + .filter(([, line]) => bash4Constructs.test(line)) + .map(([n, line]) => `${n}: ${line.trim()}`); + check( + '#12071 R7 bump-objectui.sh names no bash 4+/5 construct (mapfile, ${x^^}, declare -A, &>>, EPOCH*)', + bash4Hits.length === 0, + bash4Hits.join('\n'), + ); + + // Bash 3.2's world imposed on this host: `enable -n` really does make + // `mapfile`/`readarray` "command not found" — the same status 127 macOS + // produces — and BASH_ENV is sourced by every non-interactive bash, so the + // script under test inherits it through `spawnSync`. + const noBash4 = join(tmp, 'no-bash4-builtins.sh'); + writeFileSync(noBash4, 'enable -n mapfile readarray 2>/dev/null\n'); + // The instrument must not be vacuous. If BASH_ENV were ever ignored (a + // posix-mode bash, a future harness change), R7b would pass by proving + // nothing, so the disabling is measured on a probe FIRST, both ways. + const mapfileProbe = join(tmp, 'mapfile-probe.sh'); + writeFileSync(mapfileProbe, 'mapfile -t x < <(printf "a\\n") && echo MAPFILE-WORKS\n'); + const probePlain = spawnSync('bash', [mapfileProbe], { encoding: 'utf8' }); + const probeSim = spawnSync('bash', [mapfileProbe], { + encoding: 'utf8', + env: { ...process.env, BASH_ENV: noBash4 }, + }); + check( + '#12071 R7a the simulated-3.2 harness really removes the builtin (else R7b proves nothing)', + probePlain.stdout.includes('MAPFILE-WORKS') && + !probeSim.stdout.includes('MAPFILE-WORKS') && + probeSim.stderr.includes('mapfile'), + `plain=${probePlain.stdout.trim()} sim.out=${probeSim.stdout.trim()} sim.err=${probeSim.stderr.trim()}`, + ); + + // The R2 shape again — pushed, never merged — through a shell that has no + // bash 4 builtins. This is the operator's macOS run, and the assertion is + // the WARNING, not merely a zero exit: the defect's signature was a run + // that produced the builtin error INSTEAD of the warning. + const fwNoB4 = mkFramework('fw-reach-nobash4', rBase); + const r7 = runBump(fwNoB4, uiR.dir, ['--no-commit', rPushed], { BASH_ENV: noBash4 }); + check( + '#12071 R7b on a shell without bash 4 builtins the #10495 warning still fires, in full', + r7.stderr.includes('is NOT reachable from origin/main') && + r7.stderr.includes('origin/feature/pushed-never-merged') && + r7.stderr.includes('It IS pushed, but only onto branch(es) that have not merged'), + `status=${r7.status}\nSTDERR:${r7.stderr}`, + ); + check( + '#12071 R7c … and the run completes as a warning, not a 127: exit 0, pin written, verdict on stdout', + r7.status === 0 && + !r7.stderr.includes('command not found') && + readFileSync(join(fwNoB4, '.objectui-sha'), 'utf8').trim() === rPushed && + r7.stdout.includes('(NOT on origin/main)'), + `status=${r7.status}\nSTDOUT:${r7.stdout}\nSTDERR:${r7.stderr}`, + ); + } finally { rmSync(tmp, { recursive: true, force: true }); }