fix(portability): replace bash 4 builtins absent from macOS bash 3.2

macOS ships bash 3.2 as /bin/bash, which `#!/usr/bin/env bash` resolves to
when no newer bash is on PATH. Two builtins the repo relies on do not exist
there, and both failed SILENTLY:

- `mapfile` in the three surgical-commit helpers left every array empty, so
  the scope guards passed on nothing (fail-OPEN) and the commits degraded to
  "nothing pending — no-op" while reporting success. deploy-commit.test.sh
  went 4/16; memory and doc commits simply never happened.
- `declare -A` in the session-start hook errored on every session and left
  each plugin cost at 0, so the passive-budget warning could never fire.

`_read_lines_into` is the portable equivalent of `mapfile`, space-safe and
resetting its target first — expanding a never-assigned array trips `set -u`
on bash < 4.4, which is how the empty arrays surfaced as "unbound variable".
Plugin costs move to a `case`.

source-scope.sh's header prescribed `mapfile` to its callers; it now shows
the read loop, and its own test plus run-reconcile.sh stop using the builtin.

deploy-commit 16/16, source-scope 34/34, run-reconcile 25 GREEN / 0 RED,
session-start stderr empty.
This commit is contained in:
2026-09-13 17:20:57 -04:00
parent a53a5a26a8
commit f3919b6ace
7 changed files with 82 additions and 24 deletions
+14 -10
View File
@@ -102,17 +102,21 @@ esac
_passive_t=0 _passive_t=0
detect_superpowers 2>/dev/null && _passive_t=$((_passive_t + 800)) detect_superpowers 2>/dev/null && _passive_t=$((_passive_t + 800))
# Token costs for toggle plugins — map display name to cost # Token cost per toggle plugin, by display name. A `case`, not an associative
declare -A _plugin_costs=( # array: macOS ships bash 3.2 as /bin/bash, where `declare -A` is rejected —
[gstack]=2750 # every cost then read as 0 and the budget warning below never fired.
[ui-ux-pro-max]=400 _plugin_cost() {
[plugin-dev]=100 case "$1" in
[context7]=200 gstack) echo 2750 ;;
[graphify]=300 ui-ux-pro-max) echo 400 ;;
) plugin-dev) echo 100 ;;
context7) echo 200 ;;
graphify) echo 300 ;;
*) echo 0 ;;
esac
}
for _p in "${TOGGLE_ACTIVE[@]}"; do for _p in "${TOGGLE_ACTIVE[@]}"; do
_cost="${_plugin_costs[$_p]:-0}" _passive_t=$((_passive_t + $(_plugin_cost "$_p")))
_passive_t=$((_passive_t + _cost))
done done
_budget_pct=$((_passive_t * 100 / _budget)) _budget_pct=$((_passive_t * 100 / _budget))
if [ "$_budget_pct" -gt 50 ]; then if [ "$_budget_pct" -gt 50 ]; then
+22 -4
View File
@@ -15,6 +15,19 @@
# not just stderr, so it can't share rc 1's "nothing to do" (J4-22). # not just stderr, so it can't share rc 1's "nothing to do" (J4-22).
set -uo pipefail set -uo pipefail
# bash 3.2 (macOS /bin/bash) predates the `mapfile` builtin; this is the portable
# equivalent. Reads stdin's lines into the array named by $1, space-safe
# (IFS= read -r). The array is reset first, so empty input yields an empty array
# rather than a stale or unset one — `set -u` on bash < 4.4 trips on expanding
# an array that was never assigned.
_read_lines_into() {
local _name="$1" _line
eval "$_name=()"
while IFS= read -r _line; do
eval "$_name+=(\"\$_line\")"
done
}
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; } _in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
_unsafe_state() { # 0 = unsafe _unsafe_state() { # 0 = unsafe
@@ -48,7 +61,8 @@ _in_git_repo || { echo "deploy-commit: not a git repo" >&2; exit 2; }
case "$cmd" in case "$cmd" in
pending) pending)
[ "$#" -gt 0 ] || { echo "deploy-commit: pending needs file args" >&2; exit 2; } [ "$#" -gt 0 ] || { echo "deploy-commit: pending needs file args" >&2; exit 2; }
mapfile -t violations < <(_scope_violations "$@") violations=()
_read_lines_into violations < <(_scope_violations "$@")
if [ "${#violations[@]}" -gt 0 ]; then if [ "${#violations[@]}" -gt 0 ]; then
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:"; { echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
printf ' - %s\n' "${violations[@]}"; printf ' - %s\n' "${violations[@]}";
@@ -59,21 +73,25 @@ case "$cmd" in
commit) commit)
msg="${1:-}"; shift || true msg="${1:-}"; shift || true
[ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs <msg> <file>..." >&2; exit 2; } [ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs <msg> <file>..." >&2; exit 2; }
mapfile -t violations < <(_scope_violations "$@") violations=()
_read_lines_into violations < <(_scope_violations "$@")
if [ "${#violations[@]}" -gt 0 ]; then if [ "${#violations[@]}" -gt 0 ]; then
{ echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:"; { echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:";
printf ' - %s\n' "${violations[@]}"; printf ' - %s\n' "${violations[@]}";
echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2 echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2
exit 4 exit 4
fi fi
mapfile -t ignored_paths < <(for p in "$@"; do _ignored "$p" && printf '%s\n' "$p"; done) ignored_paths=()
_read_lines_into ignored_paths \
< <(for p in "$@"; do _ignored "$p" && printf '%s\n' "$p"; done)
if [ "${#ignored_paths[@]}" -gt 0 ]; then if [ "${#ignored_paths[@]}" -gt 0 ]; then
{ echo "deploy-commit: REFUSED — path(s) are git-ignored and will NOT persist; \`.claude/deploy/\` must be committable in this project:"; { echo "deploy-commit: REFUSED — path(s) are git-ignored and will NOT persist; \`.claude/deploy/\` must be committable in this project:";
printf ' - %s\n' "${ignored_paths[@]}"; } >&2 printf ' - %s\n' "${ignored_paths[@]}"; } >&2
exit 5 exit 5
fi fi
_unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; } _unsafe_state && { echo "deploy-commit: unsafe git state (detached/merge/rebase) — not committing" >&2; exit 3; }
mapfile -t changed < <(_changed_only "$@") changed=()
_read_lines_into changed < <(_changed_only "$@")
[ "${#changed[@]}" -gt 0 ] || exit 1 [ "${#changed[@]}" -gt 0 ] || exit 1
git add -- "${changed[@]}" git add -- "${changed[@]}"
if git diff --cached --quiet -- "${changed[@]}"; then if git diff --cached --quiet -- "${changed[@]}"; then
+15 -2
View File
@@ -25,6 +25,19 @@
set -uo pipefail set -uo pipefail
# bash 3.2 (macOS /bin/bash) predates the `mapfile` builtin; this is the portable
# equivalent. Reads stdin's lines into the array named by $1, space-safe
# (IFS= read -r). The array is reset first, so empty input yields an empty array
# rather than a stale or unset one — `set -u` on bash < 4.4 trips on expanding
# an array that was never assigned.
_read_lines_into() {
local _name="$1" _line
eval "$_name=()"
while IFS= read -r _line; do
eval "$_name+=(\"\$_line\")"
done
}
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; } _in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
# True (0) when the repo is in a state where we must NOT auto-commit: # True (0) when the repo is in a state where we must NOT auto-commit:
@@ -89,7 +102,7 @@ commit_docs() {
# (doc-syncer must never patch .claude/ or CLAUDE.md). Abort the WHOLE commit and # (doc-syncer must never patch .claude/ or CLAUDE.md). Abort the WHOLE commit and
# name the offenders — never filter-and-commit-the-rest (that masks the bug). # name the offenders — never filter-and-commit-the-rest (that masks the bug).
local violations local violations
mapfile -t violations < <(_scope_violations "$@") _read_lines_into violations < <(_scope_violations "$@")
if [ "${#violations[@]}" -gt 0 ]; then if [ "${#violations[@]}" -gt 0 ]; then
{ {
echo "doc-commit: REFUSED — out-of-scope path(s) in the doc list (upstream BDR-022 violation):" echo "doc-commit: REFUSED — out-of-scope path(s) in the doc list (upstream BDR-022 violation):"
@@ -100,7 +113,7 @@ commit_docs() {
return 4 return 4
fi fi
local changed local changed
mapfile -t changed < <(_changed_paths "$@") _read_lines_into changed < <(_changed_paths "$@")
if [ "${#changed[@]}" -eq 0 ]; then if [ "${#changed[@]}" -eq 0 ]; then
echo "doc-commit: nothing pending — no-op" >&2 echo "doc-commit: nothing pending — no-op" >&2
return 0 return 0
+14 -1
View File
@@ -19,6 +19,19 @@ set -uo pipefail
MC_PATHS=(".claude/memory" ".claude/tasks") MC_PATHS=(".claude/memory" ".claude/tasks")
# bash 3.2 (macOS /bin/bash) predates the `mapfile` builtin; this is the portable
# equivalent. Reads stdin's lines into the array named by $1, space-safe
# (IFS= read -r). The array is reset first, so empty input yields an empty array
# rather than a stale or unset one — `set -u` on bash < 4.4 trips on expanding
# an array that was never assigned.
_read_lines_into() {
local _name="$1" _line
eval "$_name=()"
while IFS= read -r _line; do
eval "$_name+=(\"\$_line\")"
done
}
_in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; } _in_git_repo() { git rev-parse --git-dir >/dev/null 2>&1; }
# True (0) when the repo is in a state where we must NOT auto-commit: # True (0) when the repo is in a state where we must NOT auto-commit:
@@ -59,7 +72,7 @@ commit_memory() {
return 3 return 3
fi fi
local changed local changed
mapfile -t changed < <(_changed_paths) _read_lines_into changed < <(_changed_paths)
if [ "${#changed[@]}" -eq 0 ]; then if [ "${#changed[@]}" -eq 0 ]; then
echo "memory-commit: nothing pending — no-op" >&2 echo "memory-commit: nothing pending — no-op" >&2
return 0 return 0
+5 -2
View File
@@ -4,7 +4,9 @@
# EXCL="$(bash ~/.claude/lib/source-scope.sh grep)" # EXCL="$(bash ~/.claude/lib/source-scope.sh grep)"
# grep -rl "gtag" $EXCL --include="*.html" . # note: $EXCL unquoted # grep -rl "gtag" $EXCL --include="*.html" . # note: $EXCL unquoted
# #
# mapfile -t FEXCL < <(bash ~/.claude/lib/source-scope.sh findargs) # FEXCL=(); while IFS= read -r t; do FEXCL+=("$t"); done \
# < <(bash ~/.claude/lib/source-scope.sh findargs)
# (a read loop, not mapfile: macOS /bin/bash is 3.2 and has no mapfile)
# find . "${FEXCL[@]}" -iname '*.jpg' -printf '%s %p\n' # quoted array! # find . "${FEXCL[@]}" -iname '*.jpg' -printf '%s %p\n' # quoted array!
# #
# findargs emits ONE TOKEN PER LINE and MUST be consumed through a quoted # findargs emits ONE TOKEN PER LINE and MUST be consumed through a quoted
@@ -72,7 +74,8 @@ case "${1:-}" in
list) _list ;; list) _list ;;
# Safe unquoted: --exclude-dir=NAME carries no glob character. # Safe unquoted: --exclude-dir=NAME carries no glob character.
grep) _list | while read -r d; do printf -- '--exclude-dir=%s ' "$d"; done; echo ;; grep) _list | while read -r d; do printf -- '--exclude-dir=%s ' "$d"; done; echo ;;
# One token per line — consume with mapfile + a QUOTED array, never a flat # One token per line — consume with a read loop into a QUOTED array (see
# the header: mapfile is bash 4+), never a flat
# string (see header: the shell would glob */dist/* against the CWD). # string (see header: the shell would glob */dist/* against the CWD).
findargs) _list | while read -r d; do printf '!\n-path\n*/%s/*\n' "$d"; done ;; findargs) _list | while read -r d; do printf '!\n-path\n*/%s/*\n' "$d"; done ;;
*) _die "usage: source-scope.sh {list|grep|findargs}" ;; *) _die "usage: source-scope.sh {list|grep|findargs}" ;;
+4 -1
View File
@@ -21,7 +21,10 @@ has() { printf '%s\n' "$1" | $GREP -qF -- "$2"; } # substring present in multi
echo "=== T1 recursive coherence — enumerate from BODY, never the ## Index ===" echo "=== T1 recursive coherence — enumerate from BODY, never the ## Index ==="
DRIFT="$FIX/registry-index-drift.md" DRIFT="$FIX/registry-index-drift.md"
mapfile -t IDS < <(reconcile_enumerate_ids "$DRIFT" LRN) # bash 3.2 (macOS /bin/bash) has no mapfile; read the ids explicitly. IDS is
# seeded empty so `set -u` cannot trip on an unset array below.
IDS=(); while IFS= read -r _id; do IDS+=("$_id"); done \
< <(reconcile_enumerate_ids "$DRIFT" LRN)
if [ "${#IDS[@]}" -eq 72 ]; then ok "T1a enumerated 72 body ids"; else no "T1a got ${#IDS[@]}, expected 72 (an Index-reader gives 51)"; fi if [ "${#IDS[@]}" -eq 72 ]; then ok "T1a enumerated 72 body ids"; else no "T1a got ${#IDS[@]}, expected 72 (an Index-reader gives 51)"; fi
if printf '%s\n' "${IDS[@]}" | $GREP -qx "LRN-020"; then ok "T1b includes body-only canary LRN-020"; else no "T1b dropped LRN-020 — read the Index, not the body"; fi if printf '%s\n' "${IDS[@]}" | $GREP -qx "LRN-020"; then ok "T1b includes body-only canary LRN-020"; else no "T1b dropped LRN-020 — read the Index, not the body"; fi
# teeth: an Index-based enumerator would RED here (the fixture discriminates) # teeth: an Index-based enumerator would RED here (the fixture discriminates)
+8 -4
View File
@@ -45,8 +45,10 @@ case "$G" in *"*"*) check C2-grep-has-no-glob "has-glob" ok ;;
*) check C2-grep-has-no-glob ok ok ;; esac *) check C2-grep-has-no-glob ok ok ;; esac
# --- findargs: one token per line, 3 tokens per dir --- # --- findargs: one token per line, 3 tokens per dir ---
N="$(cd "$TMP/plain" && bash "$S" findargs | wc -l)" # BSD wc -l pads its count with leading spaces, GNU does not — strip them
D="$(cd "$TMP/plain" && bash "$S" list | wc -l)" # or the string compare below fails on macOS with got[ 48] want[48].
N="$(cd "$TMP/plain" && bash "$S" findargs | wc -l | tr -d ' ')"
D="$(cd "$TMP/plain" && bash "$S" list | wc -l | tr -d ' ')"
check D1-findargs-3-tokens-per-dir "$N" "$((D * 3))" check D1-findargs-3-tokens-per-dir "$N" "$((D * 3))"
check D2-findargs-first-token "$(cd "$TMP/plain" && bash "$S" findargs | head -1)" '!' check D2-findargs-first-token "$(cd "$TMP/plain" && bash "$S" findargs | head -1)" '!'
@@ -58,12 +60,14 @@ W="$TMP/work"; mkdir -p "$W/src" "$W/dist" "$W/public" "$W/node_modules"
: > "$W/src/a.png"; : > "$W/dist/a.png"; : > "$W/public/favicon.ico" : > "$W/src/a.png"; : > "$W/dist/a.png"; : > "$W/public/favicon.ico"
: > "$W/node_modules/dep.png" : > "$W/node_modules/dep.png"
cd "$W" || exit 1 cd "$W" || exit 1
mapfile -t FEXCL < <(bash "$S" findargs) # bash 3.2 (macOS /bin/bash) has no mapfile — read the lines explicitly.
FEXCL=(); while IFS= read -r _tok; do FEXCL+=("$_tok"); done \
< <(bash "$S" findargs)
check E1-excludes-dist "$(find . "${FEXCL[@]}" -name 'a.png' | grep -c '/dist/')" 0 check E1-excludes-dist "$(find . "${FEXCL[@]}" -name 'a.png' | grep -c '/dist/')" 0
check E2-keeps-src "$(find . "${FEXCL[@]}" -name 'a.png' | grep -c '/src/')" 1 check E2-keeps-src "$(find . "${FEXCL[@]}" -name 'a.png' | grep -c '/src/')" 1
check E3-excludes-nodem "$(find . "${FEXCL[@]}" -name '*.png' | grep -c 'node_modules')" 0 check E3-excludes-nodem "$(find . "${FEXCL[@]}" -name '*.png' | grep -c 'node_modules')" 0
# public/ survives: the audit's own resource checks live there # public/ survives: the audit's own resource checks live there
check E4-keeps-public "$(find . "${FEXCL[@]}" -name 'favicon.ico' | wc -l)" 1 check E4-keeps-public "$(find . "${FEXCL[@]}" -name 'favicon.ico' | wc -l | tr -d ' ')" 1
cd / || exit 1 cd / || exit 1
# --- usage --- # --- usage ---