From 25149af6153724abb52b0b5369baf737cb6952aa Mon Sep 17 00:00:00 2001 From: Omer Kocaoglu Date: Wed, 10 Jun 2026 22:38:13 -0400 Subject: [PATCH] fix(hack): guard empty-array expansion in lint-drift for bash 3.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock macOS ships bash 3.2 (GPLv2 freeze), which treats "${arr[@]}" on an empty array as an unbound variable under set -u — bash 4.4+ does not. drift_grep expands its exclude array unconditionally and several call sites pass no excludes, so make lint-style (and therefore make audit, the mandatory pre-PR gate) aborted on every stock Mac before running a single drift check. Guard with the parameter-expansion alternate form, ${arr[@]+"${arr[@]}"}: empty expands to zero words, populated reproduces the original quoted expansion, bash 4+ behavior unchanged. The other hack/ array expansions are safe today (count-guarded, defaulted, or hardcoded non-empty); details in the spec. Captures the gotcha in LEARNINGS.md so the next session does not re-debug it. Spec corrections per #117 review (josealekhine): the no-exclude drift_grep sites are checks 2 and 3 only (check 8 uses a direct grep); the verification section now states that no CI/make gate lints hack/ scripts (lint-shellcheck excludes them) rather than implying make audit covers the change; and the out-of-scope array-expansion audit now characterises each site accurately (lint-powershell is count-guarded like lint-shellcheck; detect-ai-typography's EXTS is defaulted via ${EXT:-md}, not a hardcoded literal). Spec: specs/fix-lint-drift-bash32-empty-array.md Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Omer Kocaoglu --- .context/LEARNINGS.md | 11 +++ hack/lint-drift.sh | 6 +- specs/fix-lint-drift-bash32-empty-array.md | 92 ++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 specs/fix-lint-drift-bash32-empty-array.md diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 5ce72a475..51f3b36a9 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -17,6 +17,7 @@ DO NOT UPDATE FOR: | Date | Learning | |----|--------| +| 2026-06-10 | Stock macOS bash 3.2 treats empty-array expansion as unbound under set -u | | 2026-06-07 | Pin an on-disk contract before splitting work across parallel agents | | 2026-06-07 | site/ is tracked build output — rebuild and bundle it with doc commits | | 2026-06-07 | ctx-dream is headless-first; invoking /ctx-dream interactively is debugging, not the UX | @@ -107,6 +108,16 @@ DO NOT UPDATE FOR: --- +## [2026-06-10-223128] Stock macOS bash 3.2 treats empty-array expansion as unbound under set -u + +**Context**: make audit aborted at hack/lint-drift.sh line 39 on a stock Mac (bash 3.2.57) with 'exclude_args[@]: unbound variable' while gating the #93 TOCTOU fix; the script works fine on Linux bash 4+ + +**Lesson**: bash 3.2 (what every stock macOS ships, GPLv2 freeze) treats "${arr[@]}" on an empty array as an unbound-variable error under set -u; bash 4.4+ does not. Any 'set -u' script that expands a possibly-empty array breaks for every Mac contributor + +**Application**: Guard with ${arr[@]+"${arr[@]}"} (parameter-expansion alternate form) wherever a possibly-empty array is expanded in hack/ scripts; test hack/ scripts with /bin/bash, not just homebrew bash + +--- + ## [2026-06-07-162840] Pin an on-disk contract before splitting work across parallel agents **Context**: The ctx-dream skill (proposal writer) and the ctx dream CLI (proposal reader) were built by parallel tracks; each independently invented the proposals layout — one per-file proposals/.json, the other a single proposals.json array — requiring reconciliation at integration. diff --git a/hack/lint-drift.sh b/hack/lint-drift.sh index d01b85b5e..8a5d5e8de 100755 --- a/hack/lint-drift.sh +++ b/hack/lint-drift.sh @@ -36,7 +36,11 @@ drift_grep() { for ex in "$@"; do exclude_args+=(--exclude="$ex") done - grep -rn --include='*.go' --exclude='*_test.go' "${exclude_args[@]}" \ + # ${arr[@]+...} guards the empty-array expansion: bash 3.2 + # (stock macOS) treats "${arr[@]}" on an empty array as unbound + # under `set -u` and aborts the script. + grep -rn --include='*.go' --exclude='*_test.go' \ + ${exclude_args[@]+"${exclude_args[@]}"} \ -E "$pattern" internal/ 2>/dev/null || true } diff --git a/specs/fix-lint-drift-bash32-empty-array.md b/specs/fix-lint-drift-bash32-empty-array.md new file mode 100644 index 000000000..3f38a9ccc --- /dev/null +++ b/specs/fix-lint-drift-bash32-empty-array.md @@ -0,0 +1,92 @@ +# Fix lint-drift Empty-Array Expansion on Bash 3.2 + +`hack/lint-drift.sh` aborted on stock macOS before running a +single check, which silently broke `make audit` (and therefore +the contributing guide's mandatory pre-PR gate) for every Mac +contributor. + +## Problem + +The `drift_grep` helper builds its `--exclude` flags in an +array and expands it unconditionally: + +```bash +local exclude_args=() +for ex in "$@"; do + exclude_args+=(--exclude="$ex") +done +grep -rn --include='*.go' --exclude='*_test.go' "${exclude_args[@]}" \ + -E "$pattern" internal/ 2>/dev/null || true +``` + +The script runs under `set -euo pipefail`. On bash 4.4+ an +empty `"${arr[@]}"` expands to zero words; on bash 3.2 — the +newest bash Apple ships, frozen at the GPLv2 boundary — the +same expansion is an **unbound variable** error under `set -u`: + +``` +./hack/lint-drift.sh: line 39: exclude_args[@]: unbound variable +``` + +Two `drift_grep` call sites pass no exclude globs (checks 2 and +3). Check 8 (`strings.Join`) scans with a *direct* `grep`, not +`drift_grep`, so it never touches the array and is unaffected. +The script dies on the first no-exclude `drift_grep` call, so +`make lint-style` → `make audit` fail before any drift check +executes. + +## Solution + +Guard the expansion with the parameter-expansion alternate +form, the canonical bash-3.2-safe idiom: + +```bash +${exclude_args[@]+"${exclude_args[@]}"} +``` + +When the array is empty the outer expansion produces nothing; +when populated it reproduces the original quoted expansion +verbatim. Behavior on bash 4+ is unchanged. A comment at the +call site documents why the guard exists so a future cleanup +doesn't "simplify" it back. + +Verified: `bash hack/lint-drift.sh` reproduced the +`line 39: exclude_args[@]: unbound variable` abort on stock +macOS bash 3.2.57 before the guard, and exits 0 +(`lint-drift: clean`) after — so `make lint-style` → `make audit` +again run end-to-end on a stock Mac. + +Note on automated coverage: no CI or `make` gate actually *lints* +this file. `hack/lint-shellcheck.sh` explicitly excludes `hack/` +scripts ("they run only on developer machines"), and CI runs +`lint-shellcheck` but not `lint-drift`/`audit`, so a Linux/bash-5 +runner could not reproduce a 3.2-only bug anyway. The guarded +`${arr[@]+"${arr[@]}"}` is the canonical shellcheck-clean idiom; +a contributor with `shellcheck` installed can confirm with +`shellcheck hack/lint-drift.sh`. The inline call-site comment +plus the `LEARNINGS.md` entry are the durable guard against a +future "simplification" reintroducing the bug. + +## Out of Scope + +- Hardening the other `hack/` scripts' array expansions. A + `grep -rn '\[@\]' hack/` sweep plus empirical bash 3.2 + checks show the remaining sites are all safe today, though + for different reasons: + - `lint-shellcheck.sh` and `lint-powershell.sh` both exit on + a `${#TARGETS[@]} -eq 0` count guard (count expansion does + not trip `set -u` on 3.2) before reaching their + `"${TARGETS[@]}"` element expansion. + - `build-all.sh` expands a `TARGETS` array populated from a + hardcoded non-empty literal. + - `detect-ai-typography.sh` expands `"${EXTS[@]}"`, parsed + from `EXT`. `EXT` is *defaulted* (`EXT="${EXT:-md}"`), not + a literal, so even `--ext ""` collapses to `md` and the + array is never empty — safe, but via the default rather + than a count guard. + These are latent-only hazards (a future edit that lets an + array reach the expansion empty), not failures today, and + belong to a separate sweep if ever. +- Requiring bash 4+ (e.g. a version check or `#!/usr/bin/env + bash4`). Contributors should not need a homebrew bash to run + the project's own audit gate.