From a4565c80c3b2d06475f762819928dad6fd8c95ce Mon Sep 17 00:00:00 2001 From: bmottin Date: Sun, 13 Sep 2026 17:21:09 -0400 Subject: [PATCH] fix(portability): stop assuming GNU coreutils flags on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BSD userland rejects or silently ignores several GNU spellings the repo used: - `timeout` is not in a stock macOS at all (Homebrew installs it, and also as `gtimeout`). Without it every gates.sh check exited 127 and was recorded NOT-MET whatever the check actually did — a systematic false negative. The binary is now resolved once, with a pure-bash deadline behind it so the 124 contract still holds where neither binary exists. GATES_TIMEOUT_BIN is overridable with `-` not `:-`, so an empty value forces that fallback: the suite passes 64/64 both ways, timeout cases included. - `touch -d '10 days ago'` is GNU-only; perl's utime is the one spelling both platforms ship. - BSD `wc -l` pads its count with leading spaces, so string compares failed as got[ 48] want[48]. - `sed -i` needs a suffix argument on BSD AND does not expand \n in the replacement, so the release-candidate CHANGELOG edit silently did nothing and the assertion failed for the wrong reason. Rewritten in awk; the RC_TAG=0 mode still REDs on the absent tag, so the test keeps its teeth. - `/bin/grep` does not exist on macOS (grep lives in /usr/bin), and `stat -c` is GNU-only. fast-libs 11/11, seo-data 221/221, release-candidate 5 GREEN / 0 RED. --- lib/gates.sh | 34 +++++++++++++++++++++++++++++- lib/seo-data/seo-data.test.sh | 11 ++++++---- lib/tests/fast-libs.test.sh | 11 ++++++---- lib/tests/run-release-candidate.sh | 6 +++++- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/lib/gates.sh b/lib/gates.sh index 36b5404..90b2e61 100644 --- a/lib/gates.sh +++ b/lib/gates.sh @@ -30,6 +30,13 @@ set -uo pipefail TIMEOUT="${GATES_TIMEOUT:-120}" +# GNU coreutils' `timeout` ships on Linux but NOT on a stock macOS (Homebrew +# installs it as both `timeout` and `gtimeout`). Resolve it once: without it +# every check exits 127 and reports NOT-MET whatever the check actually did. +# Overridable, and with `-` not `:-` so an explicitly EMPTY value forces the +# pure-bash path — that is how the fallback gets exercised on a machine that +# does have the binary. +GATES_TIMEOUT_BIN="${GATES_TIMEOUT_BIN-$(command -v timeout || command -v gtimeout || true)}" EVIDENCE_CAP=140 # Module-level parse tables, index-aligned. Bash has no record type; threading @@ -165,9 +172,34 @@ _decisive() { # _decisive # Fail-closed: exit 0 AND the marker. A nonzero process never passes because # its error text happens to contain the expected token. +# Same contract as `timeout`: run the command, return 124 if it outruns . +# Pure-bash stand-in for a platform shipping neither binary, so the deadline +# stays real instead of silently degrading into "every gate NOT-MET". +_gates_timeout() { # _gates_timeout ... + local secs="$1"; shift + [ -n "$GATES_TIMEOUT_BIN" ] && { "$GATES_TIMEOUT_BIN" "$secs" "$@"; return $?; } + local waited=0 pid + "$@" & + pid=$! + while kill -0 "$pid" 2>/dev/null; do + if [ "$waited" -ge "$secs" ]; then + # Park the shell's stderr: bash announces a signal-killed job on ITS + # stderr, which the caller captures with 2>&1 and would read as output. + exec 3>&2 2>/dev/null + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + exec 2>&3 3>&- + return 124 + fi + sleep 1 + waited=$((waited + 1)) + done + wait "$pid" +} + _run_one() { # _run_one local i="$1" out rc - out="$(timeout "$TIMEOUT" bash -c "${_CHECK[i]}" 2>&1)" + out="$(_gates_timeout "$TIMEOUT" bash -c "${_CHECK[i]}" 2>&1)" rc=$? _STATUS[i]="NOT-MET" if [ "$rc" -eq 124 ]; then diff --git a/lib/seo-data/seo-data.test.sh b/lib/seo-data/seo-data.test.sh index d13d260..cabeea2 100644 --- a/lib/seo-data/seo-data.test.sh +++ b/lib/seo-data/seo-data.test.sh @@ -9,6 +9,9 @@ no() { echo " FAIL $1 — $2"; FAIL=$((FAIL+1)); } # assert stdout of a command contains / omits a fixed string has() { if printf '%s' "$2" | grep -qF -- "$3"; then ok "$1"; else no "$1" "missing: $3"; fi; } hasnt(){ if printf '%s' "$2" | grep -qF -- "$3"; then no "$1" "forbidden: $3"; else ok "$1"; fi; } +# Octal permission bits. GNU stat spells it -c %a, BSD stat (macOS) -f %OLp; +# neither accepts the other's flag, so try one then the other. +perm() { stat -c '%a' "$1" 2>/dev/null || stat -f '%OLp' "$1"; } echo "── tokenstore ──" TMP="$(mktemp -d)"; STORE="$TMP/tokens.json" @@ -23,9 +26,9 @@ has "list shows client-a" "$LIST" '"client-a"' has "list shows client-b" "$LIST" '"client-b"' has "list shows a property" "$LIST" 'sc-domain:a.com' hasnt "list redacts refresh tokens" "$LIST" 'RT_AAA' -PERM="$(stat -c '%a' "$STORE")" +PERM="$(perm "$STORE")" [ "$PERM" = "600" ] && ok "store file is 0600" || no "store file 0600" "got $PERM" -DPERM="$(stat -c '%a' "$(dirname "$STORE")")" +DPERM="$(perm "$(dirname "$STORE")")" [ "$DPERM" = "700" ] && ok "store dir is 0700" || no "store dir 0700" "got $DPERM" rm -rf "$TMP" @@ -136,7 +139,7 @@ import safe_fetch as sf try: sf.safe_fetch("file:///etc/passwd"); print("OK") except sf.UnsafeTarget: print("REFUSED")')" has "non-http scheme refused" "$SCHEME" 'REFUSED' -IMP="$(/bin/grep -E "^(import|from) " "$SD/safe_fetch.py" | /bin/grep -cvE "gzip|http\.client|ipaddress|socket|ssl|urllib\.parse")" +IMP="$(grep -E "^(import|from) " "$SD/safe_fetch.py" | grep -cvE "gzip|http\.client|ipaddress|socket|ssl|urllib\.parse" | tr -d ' ')" [ "$IMP" = "0" ] && ok "safe_fetch is stdlib-only" || no "safe_fetch is stdlib-only" "$IMP non-stdlib imports" hasnt "no requests dependency" "$(cat "$SD/safe_fetch.py")" 'import requests' @@ -477,7 +480,7 @@ has "clear reports ok" "$CL" '"status": "ok"' has "clear reports count" "$CL" '"cleared": 1' L7="$(python3 "$SD/tokenstore.py" list --file "$S6")" has "clear empties store" "$L7" '"accounts": []' -PERM6="$(stat -c '%a' "$S6")" +PERM6="$(perm "$S6")" [ "$PERM6" = "600" ] && ok "store stays 0600 after clear" || no "store 0600 after clear" "got $PERM6" # via the real fetch.sh dispatch layer python3 "$SD/tokenstore.py" set --file "$S6" --label back --refresh-token RT_BACK \ diff --git a/lib/tests/fast-libs.test.sh b/lib/tests/fast-libs.test.sh index 9db21dc..2d7e1c5 100644 --- a/lib/tests/fast-libs.test.sh +++ b/lib/tests/fast-libs.test.sh @@ -36,17 +36,20 @@ 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 -d '10 days ago'` is GNU-only; BSD touch (macOS) wants -t with an +# absolute stamp. perl's utime is the one spelling both platforms ship. +perl -e 'my $t = time - 10*86400; utime $t, $t, $ARGV[0]' \ + "$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":"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 ] diff --git a/lib/tests/run-release-candidate.sh b/lib/tests/run-release-candidate.sh index 8875016..340011c 100644 --- a/lib/tests/run-release-candidate.sh +++ b/lib/tests/run-release-candidate.sh @@ -38,7 +38,11 @@ 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 + # awk, not `sed -i`: BSD sed (macOS) needs a suffix argument after -i AND + # does not expand \n in the replacement — the edit silently did nothing + # there, so the CHANGELOG assertion below failed for the wrong reason. + awk '{ print; if ($0 == "## [Unreleased]") { print ""; print "## [4.0.0] — 2026-06-30" } }' \ + CHANGELOG.md > CHANGELOG.tmp && cat CHANGELOG.tmp > CHANGELOG.md && rm -f CHANGELOG.tmp 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.