Files
claude_mac/lib/source-scope.sh
T
bmottin f3919b6ace 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.
2026-09-13 17:20:57 -04:00

83 lines
3.8 KiB
Bash

#!/usr/bin/env bash
# Emit the directory exclusions that separate SOURCE from BUILD OUTPUT.
#
# EXCL="$(bash ~/.claude/lib/source-scope.sh grep)"
# grep -rl "gtag" $EXCL --include="*.html" . # note: $EXCL unquoted
#
# 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
# array. A flat string does not work: `find . $FEXCL ...` lets the shell glob
# `*/dist/*` against the CWD before find ever sees it, and the matches are then
# passed as search PATHS. Measured on zenquality: that turned 90 hits into 135
# and kept every dist/ file. The array form passes each token literally.
#
# WHY: grep and find disagree about what is in the repo, and seo-analyzer uses
# both.
#
# grep → Claude Code installs a shell function routing grep to ugrep with
# `--ignore-files`, i.e. .gitignore-aware. A gitignored dist/ is
# invisible to it when recursing from `.`. Verified 2026-07-17.
# find → knows nothing about .gitignore. It sees everything.
#
# So on zenquality (Astro, dist/ gitignored, built locally) the spec's image
# audit at seo-analyzer.md:497 returns 92 images of which 45 live in dist/ —
# every asset listed twice, source and generated copy, identical bytes. Two
# real consequences:
# 1. "top 20 by size" is half generated duplicates: ~10 real images audited
# while 20 are claimed.
# 2. Batch C (`cwebp -q 80 <img> -o <img>.webp`) can target dist/og-image.png.
# The .webp lands in dist/ and the `npm run build` that /seo runs to VERIFY
# the fix erases it. The fix lands, verification passes, nothing survives.
#
# The grep side is already safe by accident — do NOT "fix" it to match find.
# `grep` mode below is defence in depth for the cases the shim misses: a repo
# that COMMITS its build output (no .gitignore entry to honour), or a directory
# that is not a git repo at all.
#
# `public/` is deliberately NOT in the always-list: it is SOURCE for
# Astro/Vite/Next and holds the very files this audit checks — favicon.ico,
# apple-touch-icon.png, robots.txt, OG images. It is build OUTPUT only for
# Hugo and Gatsby, detected below. Blanket-excluding it would blind the audit
# to its own resource checks.
#
# Exclusions are by NAME, not path, so a monorepo's frontend/dist is caught
# exactly like a root ./dist.
set -uo pipefail
_die() { echo "source-scope: $1" >&2; exit 2; }
# Build output + tool caches. Never source.
ALWAYS=(node_modules .git dist build .next .nuxt .output _site .astro
.svelte-kit .cache out coverage .vercel .netlify .turbo)
# public/ is output for exactly these two generators.
_public_is_output() {
find . -maxdepth 3 \( -name "gatsby-config.js" -o -name "gatsby-config.ts" \
-o -name "gatsby-config.mjs" -o -name "hugo.toml" -o -name "hugo.yaml" \
-o -name "hugo.json" \) 2>/dev/null | read -r _ && return 0
# Hugo's legacy config.toml is ambiguous on its own — pair it with archetypes/
[ -d ./archetypes ] && [ -f ./config.toml ] && return 0
return 1
}
_list() {
printf '%s\n' "${ALWAYS[@]}"
_public_is_output && printf 'public\n'
return 0
}
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 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}" ;;
esac