fix(portability): make test green on macOS, GNU-only idioms replaced

The suite and seven libs assumed a GNU userland: `cmd | grep -q` under
pipefail (grep exits at the first match, the producer takes SIGPIPE,
rc 141 → 15 false "merged into" FAILs in gitflow-test), `sed -i` with no
suffix, `wc -l` padding compared as a string, `stat -c`, `touch -d`,
`realpath -m` (gstack-links refusal never fired), bare `timeout` off the
sanitized PATH (design gate READY BUT UNVERIFIED), `grep -oP` (update-all
emptied the plugin list). Thirteen suites were red on this machine.

Portable forms on the native userland of both OS: producer captured out
of the pipeline, `sed -i.bak` in tests and a temp-sibling `sed_profile`
on the user dotfile, `tr -d ' '`, python3 perms, `touch -t`, a
`realpath -m` emulation that refuses `..`, perl `alarm` for the 15 s
bound, `sed -n` token extraction. Regression tests: gstack-links T4b,
doctrine-citers Alphabet/Alpha flip, profile-set-managed T18 (failing CLI
no longer aborts `set`), new portability-census suite over the tracked
shell files. Linux run deferred (see TODO).
This commit is contained in:
bchanot
2026-10-06 15:10:20 +02:00
parent 4258a092e8
commit 0efdff0d55
23 changed files with 255 additions and 78 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ bad() { echo "FAIL $1 — $2"; FAIL=$((FAIL + 1)); }
# own probes must never resolve a REAL 21st — else CLI_ABSENT_10 (and every
# other case) would silently exercise this machine's CLI instead of the stub.
if PATH=/usr/bin:/bin command -v 21st >/dev/null 2>&1 \
|| [ -e /usr/local/bin/21st ]; then
|| [ -e /usr/local/bin/21st ] || [ -e /opt/homebrew/bin/21st ]; then
echo "FAIL precondition: system-wide 21st present," \
"CLI_ABSENT case not hermetic"
FAIL=$((FAIL + 1))
+19 -4
View File
@@ -16,16 +16,27 @@ check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1));
# _citers_extract <file>… → "file:line:name" per cited section (quoted) or label (§)
_citers_extract() {
/usr/bin/grep -nHoE 'CLAUDE(\.global)?\.md[^"“]{0,12}["“][^"”]{2,60}["”]' "$@" 2>/dev/null \
/usr/bin/grep -nHoE 'CLAUDE(\.global)?\.md[^"“]{0,12}["“][^"”[:space:]][^"”]{1,59}["”]' "$@" 2>/dev/null \
| sed -E 's/^([^:]+:[0-9]+):.*["“]([^"”]+)["”]$/\1:\2/'
/usr/bin/grep -nHoE 'CLAUDE(\.global)?\.md[^§]{0,60}§ ?[A-Z][A-Za-z][A-Za-z -]{1,40}' "$@" 2>/dev/null \
| sed -E 's/^([^:]+:[0-9]+):.*§ ?([A-Za-z][A-Za-z -]+)$/\1:\2/; s/[[:space:]]+$//'
}
# _citers_resolve <doctrine> <name> → rc 0 when a heading or a bold label starts with <name>
# _citers_resolve <doctrine> <name> → rc 0 when a heading starts with <name>
# (then end, space, `:`, `(` or `—`) or a bold label `**<name>` appears.
# Fixed-string awk: the name is never read as a regex (BSD grep -E rejects
# some, and "Alpha" must not resolve against "## Alphabet").
_citers_resolve() {
/usr/bin/grep -qE "^#+ ${2}( |$|:|\(|—)" "$1" && return 0
/usr/bin/grep -qF -- "**${2}" "$1"
awk -v n="$2" '
/^#+ / {
t = $0; sub(/^#+ /, "", t)
if (substr(t, 1, length(n)) == n) {
r = substr(t, length(n) + 1)
if (r == "" || r ~ /^[ :(]/ || index(r, "—") == 1) found = 1
}
}
index($0, "**" n) { found = 1 }
END { exit !found }' "$1"
}
# citers_check <doctrine> <file>… → prints DANGLING lines; rc = their count (capped 99)
@@ -45,6 +56,10 @@ FIX="$(mktemp -d)"; trap 'rm -rf "$FIX"' EXIT
printf '## Alpha\n\n**Always English, always caveman**: rule.\n\n## Memory registries (`x`)\n' > "$FIX/doctrine.md"
printf 'ok: see CLAUDE.md "Alpha" and CLAUDE.md "Memory registries" (Always English, always caveman)\n' > "$FIX/good.md"
printf 'bad: (see CLAUDE.md "Memory registries" § Language) and CLAUDE.md "Beta"\n' > "$FIX/bad.md"
printf '## Alphabet\n' > "$FIX/prefix.md"
printf 'prefix: see CLAUDE.md "Alpha"\n' > "$FIX/alpha.md"
citers_check "$FIX/prefix.md" "$FIX/alpha.md" >/dev/null
check T2d-name-prefix-of-heading-stays-dangling "$?" 1
citers_check "$FIX/doctrine.md" "$FIX/good.md" >/dev/null; check T1-resolving-citations-pass "$?" 0
out=$(citers_check "$FIX/doctrine.md" "$FIX/bad.md"); rc=$?
check T2-dangling-section-and-label-caught "$rc" 2
+3 -3
View File
@@ -38,7 +38,7 @@ check T6b-no-name-still-frontmatter "$(fm_effort "$EXT/noname/SKILL.md")" "low"
snap="$(cat "$EXT"/*/SKILL.md)"
bash "$LIB" "$REPO" >/dev/null 2>&1
check T7-idempotent "$(cat "$EXT"/*/SKILL.md)" "$snap"
check T7b-no-tmp-left "$(find "$EXT" -name '*.tmp' | wc -l)" 0
check T7b-no-tmp-left "$(find "$EXT" -name '*.tmp' | wc -l | tr -d ' ')" 0
# rejections: nothing written, rc 1
for bad in 'alpha turbo' '../evil high' 'alpha high extra'; do
@@ -96,7 +96,7 @@ else
printf 'ro high\n' > "$WORK/h14/lib/effort-pins.txt"
chmod 555 "$d14"; out="$(bash "$LIB" "$WORK/h14" 2>&1)"; rc=$?; chmod 755 "$d14"
check T14-write-failure-no-temp \
"$rc|$(printf '%s' "$out" | grep -c 'ERR ')|$(find "$d14" -name 'SKILL.md.*' | wc -l)" "1|1|0"
"$rc|$(printf '%s' "$out" | grep -c 'ERR ')|$(find "$d14" -name 'SKILL.md.*' | wc -l | tr -d ' ')" "1|1|0"
fi
# T15: SIGINT during the awk write removes the temp sibling, exit 130
@@ -106,7 +106,7 @@ bash -c 'source "$1"; awk() { kill -INT $$; sleep 2; }
_effort_pin_write "$2" sig high' _ "$LIB" "$d15/SKILL.md" >/dev/null 2>&1
rc=$?
check T15-sigint-removes-temp \
"$rc|$(find "$d15" -name 'SKILL.md.*' | wc -l)" "130|0"
"$rc|$(find "$d15" -name 'SKILL.md.*' | wc -l | tr -d ' ')" "130|0"
# T15b: previous INT trap restored on a normal return, no EXIT trap set
mkrepo h15b tr; d15b="$WORK/h15b/skills-external/tr"
+4 -4
View File
@@ -36,17 +36,17 @@ check T4-none "$(bash "$L" detect "$tmp/cpp" >/dev/null 2>&1; echo $?)" 1
check T5-missing "$(bash "$L" cache-status "$tmp/js" || true)" missing
mkdir -p "$tmp/js/.ctx7-cache"; touch "$tmp/js/.ctx7-cache/react-core.md"
check T6-fresh "$(bash "$L" cache-status "$tmp/js")" fresh
touch -d '10 days ago' "$tmp/js/.ctx7-cache/react-core.md"
touch -t 200001010000 "$tmp/js/.ctx7-cache/react-core.md"
check T7-stale "$(bash "$L" cache-status "$tmp/js" || true)" stale
# --- hook: fires once per session, silent on stable projects ---
hook() { printf '{"prompt":"add a hook","session_id":"%s","cwd":"%s"}' \
"$1" "$2" | TMPDIR="$tmp" bash "$H"; }
check H1-fires "$(hook s1 "$tmp/js" | grep -c 'Fast-moving')" 1
check H2-once "$(hook s1 "$tmp/js" | wc -l)" 0
check H3-cpp-quiet "$(hook s2 "$tmp/cpp" | wc -l)" 0
check H2-once "$(hook s1 "$tmp/js" | wc -l | tr -d ' ')" 0
check H3-cpp-quiet "$(hook s2 "$tmp/cpp" | wc -l | tr -d ' ')" 0
check H4-notif-quiet \
"$(printf '{"prompt":"<task-notification>x","session_id":"s3","cwd":"%s"}' \
"$tmp/js" | TMPDIR="$tmp" bash "$H" | wc -l)" 0
"$tmp/js" | TMPDIR="$tmp" bash "$H" | wc -l | tr -d ' ')" 0
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
+2 -1
View File
@@ -101,7 +101,8 @@ check_kind STUB "$rc" 2 "$out" 'FLOOR STUB'
# ── THRESHOLD_DOWN ────────────────────────────────────────────────────────
d=$(mk_repo threshold); base=$(git -C "$d" rev-parse HEAD)
sed -i 's/lines: 80/lines: 60/' "$d/vitest.config.ts"
# BSD sed -i needs a suffix argument
sed -i.bak 's/lines: 80/lines: 60/' "$d/vitest.config.ts" && rm -f "$d/vitest.config.ts.bak"
out=$(cd "$d" && bash "$LIB" "$base" 2>&1); rc=$?
check_kind THRESHOLD_DOWN "$rc" 2 "$out" 'FLOOR THRESHOLD_DOWN'
+8
View File
@@ -98,4 +98,12 @@ check T4-nothing-created "$([ -e "$DST4" ] && echo present || echo absent)" \
check T4-warns "$(printf '%s' "$out4" | grep -qi 'refusing' \
&& echo yes || echo no)" yes
# ── T4b: dst under src with a missing parent — refused, nothing created ──
DST4B="$SRC/missing/x"
link_gstack_helpers "$SRC" "$DST4B" >/dev/null 2>&1
rc4b=$?
check T4b-rc "$rc4b" 1
check T4b-nothing-created \
"$([ -e "$SRC/missing" ] && echo present || echo absent)" absent
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
+3 -2
View File
@@ -123,8 +123,9 @@ check T7-update-conflict-nondestructive "$t7_state" "1:Y:Y"
# ── T8 — no destructive command anywhere in the lib source ───────────────
d8=OK
sed 's/#.*//' "$L" | grep -qE 'git [^|;]*(checkout|reset|clean|stash)' && d8=BAD
sed 's/#.*//' "$L" | grep -qwE '(rm|rmdir|unlink|truncate|mv)' && d8=BAD
# producer out of the pipe: grep -q SIGPIPEs it under pipefail on BSD
grep -qE 'git [^|;]*(checkout|reset|clean|stash)' < <(sed 's/#.*//' "$L") && d8=BAD
grep -qwE '(rm|rmdir|unlink|truncate|mv)' < <(sed 's/#.*//' "$L") && d8=BAD
check T8-no-destructive-command "$d8" OK
# ── T9-T14 — browsers-report, fixture cache + playwright-core installs ───
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# lib/tests/portability-census.test.sh — regression guard for GNU-only shell
# idioms that break on macOS (BSD userland). Deterministic idioms only:
# sed -i with no suffix, stat -c, realpath -m, touch -d, grep -P,
# a bare /bin/grep (LRN-074: pin /usr/bin/grep).
# Not covered on purpose: `cmd | grep -q` under pipefail (not decidable by
# text; fixed structurally by taking the producer out of the pipe).
# Usage: portability-census.test.sh [file…]
# no args: flip-test, then scan tracked *.sh + hooks/*
# args : scan only those files; exit 2 on any hit
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
RE="sed -i ['\"]|stat -c|realpath -m|touch -d|grep -[A-Za-z]*P([^A-Za-z]|$)"
RE="$RE|(^|[^a-z])/bin/grep"
# file:line (or file:*) exempt from the scan, each with its reason.
ALLOW=(
"lib/tests/guard-bash.test.sh:221" # deny fixture: GNU spelling
"lib/tests/guard-bash.test.sh:225" # deny fixture: GNU spelling
"lib/tests/portability-census.test.sh:*" # spells the idioms
)
_allowed() {
local entry
for entry in "${ALLOW[@]}"; do
entry="${entry%% #*}"
[ "$entry" = "$1:$2" ] || [ "$entry" = "$1:*" ] && return 0
done
return 1
}
# scan <file>… → prints file:line:text per hit, rc 2 when any
scan() {
local f rel line lno text hits=0
for f in "$@"; do
rel="${f#"$ROOT"/}"
while IFS= read -r line; do
lno="${line%%:*}"; text="${line#*:}"
case "$text" in [[:space:]]*"#"*|"#"*) continue ;; esac
_allowed "$rel" "$lno" && continue
printf 'GNU-ONLY: %s:%s:%s\n' "$rel" "$lno" "$text"; hits=$((hits+1))
done < <(grep -nE -- "$RE" "$f" 2>/dev/null)
done
[ "$hits" -eq 0 ] || return 2
}
if [ "$#" -gt 0 ]; then scan "$@"; exit $?; fi
# flip-test: a planted GNU idiom must be caught before the real census runs
PLANT="$(mktemp -d)" || exit 1; trap 'rm -rf "$PLANT"' EXIT
printf '#!/usr/bin/env bash\nsed -i '"'"'s/a/b/'"'"' x\n' > "$PLANT/planted.sh"
scan "$PLANT/planted.sh" >/dev/null; flip=$?
if [ "$flip" -ne 2 ]; then echo "FAIL flip: plant not caught"; exit 1; fi
mapfile -t FILES < <(cd "$ROOT" && git ls-files '*.sh' 'hooks/*' \
| sed "s#^#$ROOT/#")
scan "${FILES[@]}"; rc=$?
[ "$rc" -eq 0 ] && echo "PASS portability census (${#FILES[@]} files)"
exit "$rc"
+2 -1
View File
@@ -178,7 +178,8 @@ run_mutant T3-mutant-removed "$M1" 'REMOVED_LISTED:qa:ship' \
# Mutant 2: the superset profile drops a name full carries.
M2="$WORK/mutant-superset"; cp -r "$BASE" "$M2"
sed -i '/^beta$/d' "$M2/max.profile"
# BSD sed -i needs a suffix argument
sed -i.bak '/^beta$/d' "$M2/max.profile" && rm -f "$M2/max.profile.bak"
run_mutant T4-mutant-superset "$M2" 'SUPERSET_GAP:beta' \
FIXTURE_SUPERSET_DETECTED
+2 -1
View File
@@ -127,7 +127,8 @@ printf ' none \r' > "$FX/.active-profile"
out="$(statusline)"
check_has T10-full "$out" "profile: full"
sed -i 's/^DEFAULT_PROFILE="full"/DEFAULT_PROFILE="otherish"/' "$FX/lib/profile.sh"
# BSD sed -i needs a suffix argument
sed -i.bak 's/^DEFAULT_PROFILE="full"/DEFAULT_PROFILE="otherish"/' "$FX/lib/profile.sh" && rm -f "$FX/lib/profile.sh.bak"
rm -f "$FX/.active-profile"
out="$(statusline)"
check_has T11-otherish "$out" "profile: otherish"
+21
View File
@@ -80,4 +80,25 @@ check T16b-obs-back "$([ -e "$FX/skills/observability-and-instrumentation" ] &&
check T16c-obs-park-gone "$([ -e "$FX/skills-disabled/observability-and-instrumentation" ] && echo p || echo n)" n
check T17-no-mcp-ever "$(grep -c '^mcp ' "$FX/claude-calls.log" || true)" 0
# --- a failing `claude plugin enable` must warn, never abort `set` ---
# Guards the capture-inside-the-condition form: a bare out="$(claude …)"
# would trip errexit and skip every entry after the plugin.
mkdir -p "$FX/failbin"
cat > "$FX/failbin/claude" <<'EOF'
#!/usr/bin/env bash
[ "$1 $2" = "plugin enable" ] && exit 1
exit 0
EOF
chmod +x "$FX/failbin/claude"
cat > "$FX/lib/profiles/pluginish.profile" <<'EOF'
fake-plug plugin@fake-market
gs-a
EOF
rm -f "$FX/skills/gs-a"
PATH="$FX/failbin:$PATH" PROFILE_REPO_OVERRIDE="$FX" \
TOGGLE_EXTERNAL_REPO_OVERRIDE="$FX" bash "$FX/lib/profile.sh" set pluginish \
>/dev/null 2>&1
check T18-failing-plugin-enable-keeps-going \
"$([ -e "$FX/skills/gs-a" ] && echo on || echo off)" on
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
+1 -1
View File
@@ -101,7 +101,7 @@ printf ' err: %s\n' "$(printf '%s' "$ERR" | grep -i decisions | head -1)"
if [ "$RC" -eq 4 ]; then ok "mixed → exit 4"; else ko "expected 4, got $RC"; fi
if [ "$(git -C "$R" rev-parse HEAD)" = "$BEFORE" ]; then ok "NOTHING committed (README not half-committed)"; else ko "a commit slipped through"; fi
if printf '%s' "$ERR" | grep -q '.claude/memory/decisions.md'; then ok "stderr names the offender"; else ko "offender not named"; fi
if git -C "$R" status --porcelain | grep -q ' M README.md'; then ok "README left dirty (not embarked)"; else ko "README state wrong"; fi
if grep -q ' M README.md' < <(git -C "$R" status --porcelain); then ok "README left dirty (not embarked)"; else ko "README state wrong"; fi
rm -rf "$R"
echo "T2 — dynamic pathspec: clean passed path filtered, no abort"
+3 -2
View File
@@ -40,7 +40,8 @@ echo
( cd "$WORK" || exit 1
bash "$GITFLOW" start release 4.0.0 >/dev/null # base develop → release/4.0.0 (lib L49/L71)
printf '4.0.0\n' > version.txt # prep: version bump
sed -i 's/## \[Unreleased\]/## [Unreleased]\n\n## [4.0.0] — 2026-06-30/' CHANGELOG.md
# BSD sed -i needs a suffix argument: -i.bak then drop the backup
sed -i.bak 's/## \[Unreleased\]/## [Unreleased]\n\n## [4.0.0] — 2026-06-30/' CHANGELOG.md && rm -f CHANGELOG.md.bak
git commit -qam "chore(release): 4.0.0 — version.txt + CHANGELOG"
bash "$GITFLOW" finish >/dev/null # fan-out main+develop+delete (lib L108-111)
# TAG = the gap. Lives in the SKILL (lib untouched). RED skips it, GREEN does it.
@@ -52,7 +53,7 @@ echo "=== assertions (RC_TAG=$RC_TAG) ==="
if [ "$(git -C "$WORK" show main:version.txt 2>/dev/null)" = "4.0.0" ]; then ok "fan-out: main carries the release (version.txt 4.0.0)"; else no "fan-out: main version.txt != 4.0.0"; fi
if [ "$(git -C "$WORK" show develop:version.txt 2>/dev/null)" = "4.0.0" ]; then ok "merge-back: develop carries 4.0.0"; else no "merge-back failed"; fi
if git -C "$WORK" show-ref --verify -q refs/heads/release/4.0.0; then no "release/4.0.0 NOT deleted"; else ok "release/4.0.0 branch deleted"; fi
if git -C "$WORK" show main:CHANGELOG.md | $GREP -q '## \[4.0.0\]'; then ok "CHANGELOG [4.0.0] on main"; else no "CHANGELOG not finalized"; fi
if $GREP -q '## \[4.0.0\]' < <(git -C "$WORK" show main:CHANGELOG.md); then ok "CHANGELOG [4.0.0] on main"; else no "CHANGELOG not finalized"; fi
if git -C "$WORK" rev-parse -q --verify refs/tags/v4.0.0 >/dev/null; then
if [ "$(git -C "$WORK" rev-list -n1 v4.0.0)" = "$(git -C "$WORK" rev-parse main)" ]; then ok "tag v4.0.0 on main's release-merge commit"; else no "tag v4.0.0 exists but not on main HEAD"; fi
else
+3 -3
View File
@@ -45,8 +45,8 @@ 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)"
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)" '!'
@@ -63,7 +63,7 @@ check E1-excludes-dist "$(find . "${FEXCL[@]}" -name 'a.png' | grep -c '/dist/'
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 ---