Skip to content

Lesson Bash32 Empty Array Hazard

Priscila Saboia Moreira edited this page Jun 25, 2026 · 1 revision

type: synthesis up: "Home_llm-wiki-memory-template" criticizes: "Adopt-Existing-Repo-Design" related: "Lesson-Parallel-File-Drift" tags: [lesson, footgun, bash-3.2, macos, set-u, empty-array]

Lesson: bash 3.2 + set -u empty-array hazard

On macOS (default bash 3.2.57), expanding "${arr[@]}" of an array that was declared with arr=() but never had an element pushed to it triggers unbound variable under set -u. The same expression is fine on bash 4 and 5 (the Linux default), so the bug is invisible in Linux CI and only surfaces when a real macOS shell hits the code path. Discovered 2026-06-25 while validating the Tier-1 fixture adopt-apply-touch-missing-target against macOS CI for feature/adopt-sh; fixed in commit 57457db.

The incident

The fixture stages a virgin host with a .llm-wiki-adopt-grants.yml that grants .claude/settings.json: merge against a host where .claude/settings.json does not exist. Adopt classifies that grant as TOUCH_MISSING and the report code enters the GRANT WARNINGS block:

# scripts/adopt.sh, pre-fix
if [[ ${#ACT_TOUCH_INVALID[@]} -gt 0 || ${#ACT_TOUCH_MISSING[@]} -gt 0 ]]; then
    echo "GRANT WARNINGS (entries in grants file that did not produce a TOUCH)"
    for p in "${ACT_TOUCH_INVALID[@]}"; do echo "  ! $p"; done   # <-- crash on bash 3.2
    for p in "${ACT_TOUCH_MISSING[@]}"; do echo "  ? $p"; done
    echo ""
fi

The if guard uses ${#arr[@]} (safe; returns 0 cleanly). The for-loops use "${arr[@]}" directly. When ACT_TOUCH_INVALID is empty (no invalid grants) and ACT_TOUCH_MISSING has the one missing entry, the script enters the block correctly and dies on the first for-loop:

+ [[ 0 -gt 0 ]]
+ [[ 1 -gt 0 ]]
+ echo 'GRANT WARNINGS ...'
scripts/adopt.sh: line 434: ACT_TOUCH_INVALID[@]: unbound variable

adopt.sh exits non-zero before reaching the manifest write. The dry-run report file exists but is truncated mid-section. CI Linux ran the same fixture green (47 of 49 assertions passing on the bug; the only failing ones were the two assertions that depended on the truncated output).

What made it visible

Three things had to combine:

  • The fixture deliberately exercises TOUCH_MISSING with no TOUCH_INVALID (asymmetric empty-array case).
  • CI runs on macos-latest (bash 3.2.57) in addition to ubuntu-latest (bash 5).
  • The fixture's assertions check for GRANT WARNINGS text in the output and for the manifest file existing, both of which the truncated output drops.

Removing any of the three would have hidden the bug. The synthetic Linux-only suite that preceded this iteration never tripped it, even though three earlier commits already had unguarded "${arr[@]}" loops in the same function. The lesson is not that the bug is exotic; it is that the signal required a fixture whose grant pattern matched the asymmetric empty case, on the platform where it manifests.

The fix

Wrap each for-loop in its own ${#arr[@]} -gt 0 guard, matching the existing pattern at the Phase-1 ADD loop (adopt.sh:496) and the comment that explained why:

# scripts/adopt.sh, post-fix
if [[ ${#ACT_TOUCH_INVALID[@]} -gt 0 || ${#ACT_TOUCH_MISSING[@]} -gt 0 ]]; then
    echo "GRANT WARNINGS (entries in grants file that did not produce a TOUCH)"
    # Bash 3.2 (macOS default) treats "${arr[@]}" on an empty declared
    # array as an unbound variable under set -u, so each for-loop needs
    # its own ${#arr[@]} guard. Same pattern as the ACT_ADD loop below.
    if [[ ${#ACT_TOUCH_INVALID[@]} -gt 0 ]]; then
        for p in "${ACT_TOUCH_INVALID[@]}"; do echo "  ! $p"; done
    fi
    if [[ ${#ACT_TOUCH_MISSING[@]} -gt 0 ]]; then
        for p in "${ACT_TOUCH_MISSING[@]}"; do echo "  ? $p"; done
    fi
    echo ""
fi

Alternative idiom (parameter expansion with default) is also safe: "${arr[@]+\"\${arr[@]}\"}". The codebase already prefers the explicit ${#arr[@]} -gt 0 guard for readability, and the fix follows that.

Discipline observed

The fix was driven by a real failure trace, not a theoretical concern. Sequence:

  1. Tier-1 fixture written against the Linux harness; passes locally and on Linux CI.
  2. Same fixture fails on macOS CI with two specific assertion failures.
  3. Reproduced on Priscila's macOS shell with bash -x scripts/adopt.sh --apply against the fixture's host; the -x trace identified the exact line and the unbound variable cause.
  4. Patch applied at the single causal site; same fixture went green on macOS CI without changing any other behavior.
  5. Audit of the other five scripts in the codebase that share set -euo pipefail (scripts/check-template-version.sh, scripts/update-from-template.sh, wiki/init-wiki.sh, wiki/agents/claude-code/setup.sh, wiki/agents/cursor/setup.sh) confirmed every other "${arr[@]}" expansion already sits inside a ${#arr[@]} -gt 0 guard, a ${arr[*]:-default} parameter expansion, or an else branch that proves non-emptiness via a preceding -eq 0 check. The hazard was localized; the patch is one site, not a sweep.
  6. End-to-end re-run on macOS bash 3.2.57 against a fresh virgin host with the same grants pattern: RC=0, GRANT WARNINGS emitted, manifest written, .claude/ not created. The failure that the fixture's macOS run had observed is gone, and the rest of the flow is unchanged.

Generalising

The pattern is straightforward: in any script with set -u, before iterating an array via "${arr[@]}", check ${#arr[@]} -gt 0 (or wrap the iteration in an if/else whose other branch handles the empty case). The audit confirmed that the rest of the codebase already follows this discipline; the adopt.sh GRANT WARNINGS block was an oversight, not a systemic gap.

Two observations worth carrying forward:

  • Tier-1 fixtures earn their place when they reach a path single-OS testing cannot. The same TOUCH-grants flow had been covered by adopt-apply-touch since the start of feature/adopt-sh, but only the asymmetric TOUCH_MISSING shape under adopt-apply-touch-missing-target reached the buggy line on macOS. A test that only exercises the symmetric case (both arrays populated, or both empty) would have shipped this bug unnoticed.
  • bash -x against the fixture's host is the right next step after a CI-only failure. The trace identified the exact line and the exact cause in one run, with no speculation. The two cycles before that (precreate CLAUDE.md, then reduce to a single grant) were wasted because they were guessing at the failure shape instead of asking the platform.

Related work

  • Adopt-Existing-Repo-Design is the page whose Tier-1 fixture surfaced this; the lesson sits as a criticizes: edge against the implicit assumption there that set -u on bash 4+ generalises cleanly.
  • Lesson-Parallel-File-Drift is a different kind of "what your test suite didn't see" finding from the same feature/adopt-sh window.

Clone this wiki locally