forked from bchanot/claude
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:
+14
-10
@@ -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
|
||||
|
||||
+22
-4
@@ -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 <msg> <file>..." >&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
|
||||
|
||||
+15
-2
@@ -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
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
+5
-2
@@ -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}" ;;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user