fix(portability): stop assuming GNU coreutils flags on macOS
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.
This commit is contained in:
+33
-1
@@ -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 <combined-output>
|
||||
|
||||
# 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 <secs>.
|
||||
# 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 <secs> <cmd>...
|
||||
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 <idx>
|
||||
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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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":"<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 ]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user