From f3919b6acea3d78df7459378c49373f35bbf6bfd Mon Sep 17 00:00:00 2001 From: bmottin Date: Sun, 13 Sep 2026 17:20:57 -0400 Subject: [PATCH] fix(portability): replace bash 4 builtins absent from macOS bash 3.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hooks/session-start.sh | 24 ++++++++++++++---------- lib/deploy-commit.sh | 26 ++++++++++++++++++++++---- lib/doc-commit.sh | 17 +++++++++++++++-- lib/memory-commit.sh | 15 ++++++++++++++- lib/source-scope.sh | 7 +++++-- lib/tests/run-reconcile.sh | 5 ++++- lib/tests/source-scope.test.sh | 12 ++++++++---- 7 files changed, 82 insertions(+), 24 deletions(-) diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 36b9584..a40890e 100644 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -102,17 +102,21 @@ esac _passive_t=0 detect_superpowers 2>/dev/null && _passive_t=$((_passive_t + 800)) -# Token costs for toggle plugins — map display name to cost -declare -A _plugin_costs=( - [gstack]=2750 - [ui-ux-pro-max]=400 - [plugin-dev]=100 - [context7]=200 - [graphify]=300 -) +# Token cost per toggle plugin, by display name. A `case`, not an associative +# array: macOS ships bash 3.2 as /bin/bash, where `declare -A` is rejected — +# every cost then read as 0 and the budget warning below never fired. +_plugin_cost() { + case "$1" in + gstack) echo 2750 ;; + 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 - _cost="${_plugin_costs[$_p]:-0}" - _passive_t=$((_passive_t + _cost)) + _passive_t=$((_passive_t + $(_plugin_cost "$_p"))) done _budget_pct=$((_passive_t * 100 / _budget)) if [ "$_budget_pct" -gt 50 ]; then diff --git a/lib/deploy-commit.sh b/lib/deploy-commit.sh index d237385..1b10404 100644 --- a/lib/deploy-commit.sh +++ b/lib/deploy-commit.sh @@ -15,6 +15,19 @@ # not just stderr, so it can't share rc 1's "nothing to do" (J4-22). 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; } _unsafe_state() { # 0 = unsafe @@ -48,7 +61,8 @@ _in_git_repo || { echo "deploy-commit: not a git repo" >&2; exit 2; } case "$cmd" in pending) [ "$#" -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 { echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:"; printf ' - %s\n' "${violations[@]}"; @@ -59,21 +73,25 @@ case "$cmd" in commit) msg="${1:-}"; shift || true [ -n "$msg" ] && [ "$#" -gt 0 ] || { echo "deploy-commit: commit needs ..." >&2; exit 2; } - mapfile -t violations < <(_scope_violations "$@") + violations=() + _read_lines_into violations < <(_scope_violations "$@") if [ "${#violations[@]}" -gt 0 ]; then { echo "deploy-commit: REFUSED — path(s) outside .claude/deploy/ allowlist:"; printf ' - %s\n' "${violations[@]}"; echo "deploy-commit: NOTHING committed. Caller must pass only .claude/deploy/ files."; } >&2 exit 4 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 { 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 exit 5 fi _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 git add -- "${changed[@]}" if git diff --cached --quiet -- "${changed[@]}"; then diff --git a/lib/doc-commit.sh b/lib/doc-commit.sh index 686672f..33f7159 100755 --- a/lib/doc-commit.sh +++ b/lib/doc-commit.sh @@ -25,6 +25,19 @@ 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; } # 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 # name the offenders — never filter-and-commit-the-rest (that masks the bug). local violations - mapfile -t violations < <(_scope_violations "$@") + _read_lines_into violations < <(_scope_violations "$@") if [ "${#violations[@]}" -gt 0 ]; then { 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 fi local changed - mapfile -t changed < <(_changed_paths "$@") + _read_lines_into changed < <(_changed_paths "$@") if [ "${#changed[@]}" -eq 0 ]; then echo "doc-commit: nothing pending — no-op" >&2 return 0 diff --git a/lib/memory-commit.sh b/lib/memory-commit.sh index 1f55f59..9b72349 100755 --- a/lib/memory-commit.sh +++ b/lib/memory-commit.sh @@ -19,6 +19,19 @@ set -uo pipefail 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; } # True (0) when the repo is in a state where we must NOT auto-commit: @@ -59,7 +72,7 @@ commit_memory() { return 3 fi local changed - mapfile -t changed < <(_changed_paths) + _read_lines_into changed < <(_changed_paths) if [ "${#changed[@]}" -eq 0 ]; then echo "memory-commit: nothing pending — no-op" >&2 return 0 diff --git a/lib/source-scope.sh b/lib/source-scope.sh index ad63839..347eb48 100644 --- a/lib/source-scope.sh +++ b/lib/source-scope.sh @@ -4,7 +4,9 @@ # EXCL="$(bash ~/.claude/lib/source-scope.sh grep)" # 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! # # findargs emits ONE TOKEN PER LINE and MUST be consumed through a quoted @@ -72,7 +74,8 @@ case "${1:-}" in list) _list ;; # Safe unquoted: --exclude-dir=NAME carries no glob character. 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). findargs) _list | while read -r d; do printf '!\n-path\n*/%s/*\n' "$d"; done ;; *) _die "usage: source-scope.sh {list|grep|findargs}" ;; diff --git a/lib/tests/run-reconcile.sh b/lib/tests/run-reconcile.sh index b4583ce..4ea8429 100755 --- a/lib/tests/run-reconcile.sh +++ b/lib/tests/run-reconcile.sh @@ -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 ===" 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 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) diff --git a/lib/tests/source-scope.test.sh b/lib/tests/source-scope.test.sh index b674b6c..dadbbb5 100644 --- a/lib/tests/source-scope.test.sh +++ b/lib/tests/source-scope.test.sh @@ -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 # --- findargs: one token per line, 3 tokens per dir --- -N="$(cd "$TMP/plain" && bash "$S" findargs | wc -l)" -D="$(cd "$TMP/plain" && bash "$S" list | wc -l)" +# BSD wc -l pads its count with leading spaces, GNU does not — strip them +# 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 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/node_modules/dep.png" 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 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 # 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 # --- usage ---