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
+21 -7
View File
@@ -70,6 +70,7 @@ ensure_claude_on_path() {
for cand in \
"$HOME/.claude/local/claude" \
"$HOME/.local/bin/claude" \
/opt/homebrew/bin/claude \
/usr/local/bin/claude; do
[ -x "$cand" ] && { PATH="$(dirname "$cand"):$PATH"; return; }
done
@@ -94,6 +95,7 @@ ensure_21st_on_path() {
local cand
for cand in \
"$HOME/.local/bin/21st" \
/opt/homebrew/bin/21st \
/usr/local/bin/21st; do
[ -x "$cand" ] && { PATH="$(dirname "$cand"):$PATH"; return; }
done
@@ -113,7 +115,8 @@ ensure_21st_on_path
# out; the FIRST LINE of stdout does. A token env, when already exported by
# the user's shell profile, wins without a CLI call (never requested here:
# tool calls don't share a shell, and a secret doesn't belong in a comment
# or the transcript). `timeout 15` bounds a hung CLI; stdin is closed so a
# or the transcript). A perl `alarm` of 15 s bounds a hung CLI (GNU
# `timeout` is absent from the macOS system PATH); stdin is closed so a
# CLI that reads stdin can't eat the gate's own `read` loop; stderr never
# enters the match (stdout only). Echoes: in | out | unknown:<diagnostic>.
twentyfirst_auth_state() {
@@ -122,11 +125,13 @@ twentyfirst_auth_state() {
return
fi
local line rc
if line="$(timeout 15 21st whoami 2>/dev/null </dev/null | head -1)"; then
if line="$(perl -e 'alarm shift; exec @ARGV' 15 21st whoami \
2>/dev/null </dev/null)"; then
rc=0
else
rc=$?
fi
line="${line%%$'\n'*}" # first line, without head(1) in a pipeline
if [ "$rc" -eq 0 ]; then
case "$line" in
"Logged in as "*) echo in; return ;;
@@ -160,14 +165,22 @@ tool_active() {
;;
plugin)
if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then echo unknown; return; fi
if "$CLAUDE_BIN" plugin list 2>/dev/null \
| awk -v p="^[[:space:]]*❯ ${name}@" '$0 ~ p {f=1; next} f && /Status:/ {print; exit}' \
| grep -q "✔ enabled"
# capture first, then match: an early-exit awk/grep -q in a pipe
# SIGPIPEs the producer (rc 141 under pipefail on macOS)
local plist
plist="$("$CLAUDE_BIN" plugin list 2>/dev/null)" || true
if grep -q "✔ enabled" < <(awk -v p="^[[:space:]]*❯ ${name}@" \
'$0 ~ p {f=1; next} f && /Status:/ {print; exit}' <<<"$plist")
then echo active; else echo inactive; fi
;;
mcp)
if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then echo unknown; return; fi
if "$CLAUDE_BIN" mcp list 2>/dev/null | grep -q "^${name}"; then echo active; else echo inactive; fi
if grep -q "^${name}" \
<<<"$("$CLAUDE_BIN" mcp list 2>/dev/null)"; then
echo active
else
echo inactive
fi
;;
cli)
command -v "$name" >/dev/null 2>&1 || { echo inactive; return; }
@@ -222,7 +235,8 @@ print_unverified() {
echo " also unverified (claude CLI unreachable): ${unverified[*]}"
fi
local entry name diag
for entry in "${unverified_cli[@]}"; do
# bash 3.2 (macOS /bin/bash) errors on an empty array under set -u
for entry in ${unverified_cli[@]+"${unverified_cli[@]}"}; do
name="${entry%% (*}"
diag="${entry#*\(}"; diag="${diag%\)}"
echo " $name could not answer: $diag —" \
+2 -1
View File
@@ -67,7 +67,8 @@ _path_exceeds_reason() {
printf 'new/untracked doc (a creation, not a MINOR drift-patch): %s\n' "$p"
return
fi
if git diff HEAD -- "$p" | grep -Eq '^\+#{1,6}[ \t]'; then
# producer out of the pipe: grep -q would SIGPIPE git (fails open on macOS)
if grep -Eq '^\+#{1,6}[ \t]' < <(git diff HEAD -- "$p"); then
printf 'adds a section heading (structural change, not a factual tweak): %s\n' "$p"
return
fi
+26 -26
View File
@@ -48,7 +48,7 @@ chk "socle: re-ignore PENDING" 'grep -qxF ".claude/deploy/PENDING.json" .gitigno
chk "hook installed" '[ -x .githooks/pre-commit ] && [ "$(git config core.hooksPath)" = .githooks ]'
chk "tree CLEAN after init" '[ -z "$(git status --porcelain)" ]'
chk "hook TRACKED in commit" 'git ls-files --error-unmatch .githooks/pre-commit >/dev/null 2>&1'
chk "socle IN root commit" 'git show HEAD:.gitignore | grep -qxF ".claude/deploy/PENDING.json"'
chk "socle IN root commit" 'grep -qxF ".claude/deploy/PENDING.json" < <(git show HEAD:.gitignore)'
echo "T2b — init existing (master→main rename + adoption via chore/gitflow-adopt merge)"
newrepo existing
@@ -59,10 +59,10 @@ hookon
gitflow_init >/dev/null 2>&1
chk "master→main renamed" 'git rev-parse --verify -q refs/heads/main >/dev/null && ! git rev-parse --verify -q refs/heads/master >/dev/null'
chk "develop created" 'git rev-parse --verify -q refs/heads/develop >/dev/null'
chk "adoption commit" 'git log main --oneline | grep -q "adopt gitflow"'
chk "adoption commit" 'grep -q "adopt gitflow" < <(git log main --oneline)'
chk "existing tree CLEAN" '[ -z "$(git status --porcelain)" ]'
chk "existing hook tracked" 'git ls-files --error-unmatch .githooks/pre-commit >/dev/null 2>&1'
chk "kept project rule" 'git show HEAD:.gitignore | grep -qxF "node_modules/"'
chk "kept project rule" 'grep -qxF "node_modules/" < <(git show HEAD:.gitignore)'
echo "T2c — init existing under a LIVE pre-commit (global hooks simulated): socle lands via merge"
newrepo live; git symbolic-ref HEAD refs/heads/master
@@ -73,9 +73,9 @@ git config core.hooksPath "$WORK/globalhooks" # stands in for git's GLOBAL
# shellcheck disable=SC2034
live_rc=0; GITFLOW_NO_PUSH=1 gitflow_init >/dev/null 2>&1 || live_rc=$?
chk "T2c init succeeds under the live hook (rc 0)" "[ $live_rc -eq 0 ]"
chk "T2c socle reached main via a merge commit" 'git log main --oneline -1 | grep -q "Merge chore/gitflow-adopt"'
chk "T2c .gitignore socle on main" 'git show main:.gitignore | grep -qxF ".claude/deploy/PENDING.json"'
chk "T2c hooks tracked on main" 'git ls-tree -r main --name-only | grep -q "^.githooks/pre-commit$"'
chk "T2c socle reached main via a merge commit" 'grep -q "Merge chore/gitflow-adopt" < <(git log main --oneline -1)'
chk "T2c .gitignore socle on main" 'grep -qxF ".claude/deploy/PENDING.json" < <(git show main:.gitignore)'
chk "T2c hooks tracked on main" 'grep -q "^.githooks/pre-commit$" < <(git ls-tree -r main --name-only)'
chk "T2c adoption branch deleted" '! git rev-parse --verify -q refs/heads/chore/gitflow-adopt >/dev/null'
chk "T2c develop created from main" '[ "$(git rev-parse develop)" = "$(git rev-parse main)" ]'
chk "T2c repo hook active afterwards" '[ "$(git config core.hooksPath)" = .githooks ]'
@@ -107,7 +107,7 @@ newrepo finfeat; echo a>a; hookon; gitflow_init >/dev/null 2>&1
gitflow_start feature f1 >/dev/null 2>&1; echo w>w.txt; git add w.txt; git commit -q -m w
main_before="$(git rev-parse main)"
gitflow_finish >/dev/null 2>&1
chk "merged into develop" 'git log develop --oneline | grep -q "Merge feature/f1 into develop"'
chk "merged into develop" 'grep -q "Merge feature/f1 into develop" < <(git log develop --oneline)'
chk "main untouched" "[ \"\$(git rev-parse main)\" = \"$main_before\" ]"
chk "branch deleted" '! git rev-parse --verify -q refs/heads/feature/f1 >/dev/null'
@@ -117,7 +117,7 @@ gitflow_start chore c1 >/dev/null 2>&1
mkdir -p .claude/memory; echo m>.claude/memory/x.md; git add -A; git commit -q -m "chore(memory)"
main_before="$(git rev-parse main)"
gitflow_finish >/dev/null 2>&1
chk "chore merged into develop" 'git log develop --oneline | grep -q "Merge chore/c1 into develop"'
chk "chore merged into develop" 'grep -q "Merge chore/c1 into develop" < <(git log develop --oneline)'
chk "chore main untouched" "[ \"\$(git rev-parse main)\" = \"$main_before\" ]"
chk "chore branch deleted" '! git rev-parse --verify -q refs/heads/chore/c1 >/dev/null'
@@ -125,8 +125,8 @@ echo "T7 — finish hotfix → main + develop fan-out"
newrepo finhot; echo a>a; hookon; gitflow_init >/dev/null 2>&1
gitflow_start hotfix h1 >/dev/null 2>&1; echo p>patch.txt; git add patch.txt; git commit -q -m patch
gitflow_finish >/dev/null 2>&1
chk "hotfix in main" 'git log main --oneline | grep -q "Merge hotfix/h1 into main"'
chk "hotfix in develop" 'git log develop --oneline | grep -q "Merge hotfix/h1 into develop"'
chk "hotfix in main" 'grep -q "Merge hotfix/h1 into main" < <(git log main --oneline)'
chk "hotfix in develop" 'grep -q "Merge hotfix/h1 into develop" < <(git log develop --oneline)'
chk "hotfix branch gone" '! git rev-parse --verify -q refs/heads/hotfix/h1 >/dev/null'
echo "T8 — finish hotfix also lands in OPEN release"
@@ -134,7 +134,7 @@ newrepo finhotrel; echo a>a; hookon; gitflow_init >/dev/null 2>&1
gitflow_start release 1.0 >/dev/null 2>&1; echo r>rel.txt; git add rel.txt; git commit -q -m relwork
gitflow_start hotfix h2 >/dev/null 2>&1; echo p>p2.txt; git add p2.txt; git commit -q -m patch2
gitflow_finish >/dev/null 2>&1
chk "hotfix in open release" 'git log release/1.0 --oneline | grep -q "Merge hotfix/h2 into release/1.0"'
chk "hotfix in open release" 'grep -q "Merge hotfix/h2 into release/1.0" < <(git log release/1.0 --oneline)'
echo "T9 — reconcile is additive + idempotent + preserves project rules"
newrepo recon; echo a>a; git add a; git commit -q -m a
@@ -181,11 +181,11 @@ mism_out="$(gitflow_finish bugfix other 2>&1)"; mism_rc=$?
chk "arg-mismatch → nonzero rc" "[ $mism_rc -ne 0 ]"
chk "arg-mismatch → HEAD untouched" '[ "$(git symbolic-ref --short HEAD)" = feature/standon ]'
chk "arg-mismatch → branch kept" 'git rev-parse --verify -q refs/heads/feature/standon >/dev/null'
chk "arg-mismatch → develop NOT merged" '! git log develop --oneline | grep -q "Merge feature/standon into develop"'
chk "arg-mismatch → develop NOT merged" '! grep -q "Merge feature/standon into develop" < <(git log develop --oneline)'
chk "arg-mismatch → message names both" 'printf "%s" "$mism_out" | grep -q "current branch" && printf "%s" "$mism_out" | grep -q "bugfix/other"'
# match: naming the current branch explicitly finishes exactly like the no-arg path
gitflow_finish feature standon >/dev/null 2>&1
chk "arg-match → merged into develop" 'git log develop --oneline | grep -q "Merge feature/standon into develop"'
chk "arg-match → merged into develop" 'grep -q "Merge feature/standon into develop" < <(git log develop --oneline)'
chk "arg-match → branch deleted" '! git rev-parse --verify -q refs/heads/feature/standon >/dev/null'
echo "T13 — finish release fan-out (main+develop+delete), 2 open releases + bugfix→develop-only"
@@ -193,8 +193,8 @@ newrepo finrel; echo a>a; hookon; gitflow_init >/dev/null 2>&1
gitflow_start release 9.9.9 >/dev/null 2>&1; echo v>VERSION; git add VERSION; git commit -q -m "bump 9.9.9"
finish_rc=0; gitflow_finish >/dev/null 2>&1 || finish_rc=$?
chk "T13a finish rc 0" "[ $finish_rc -eq 0 ]"
chk "T13a main has release commit" 'git log main --oneline | grep -q "bump 9.9.9"'
chk "T13a develop has release commit" 'git log develop --oneline | grep -q "bump 9.9.9"'
chk "T13a main has release commit" 'grep -q "bump 9.9.9" < <(git log main --oneline)'
chk "T13a develop has release commit" 'grep -q "bump 9.9.9" < <(git log develop --oneline)'
chk "T13a release branch deleted" '! git rev-parse --verify -q refs/heads/release/9.9.9 >/dev/null'
newrepo finrel2; echo a>a; hookon; gitflow_init >/dev/null 2>&1
@@ -202,14 +202,14 @@ gitflow_start release 1.0 >/dev/null 2>&1; echo r1>r1; git add r1; git commit -q
gitflow_start release 2.0 >/dev/null 2>&1; echo r2>r2; git add r2; git commit -q -m rel2
gitflow_start hotfix hboth >/dev/null 2>&1; echo p>p; git add p; git commit -q -m hotfixboth
gitflow_finish >/dev/null 2>&1
chk "T13b hotfix in release/1.0" 'git log release/1.0 --oneline | grep -q "Merge hotfix/hboth into release/1.0"'
chk "T13b hotfix in release/2.0" 'git log release/2.0 --oneline | grep -q "Merge hotfix/hboth into release/2.0"'
chk "T13b hotfix in release/1.0" 'grep -q "Merge hotfix/hboth into release/1.0" < <(git log release/1.0 --oneline)'
chk "T13b hotfix in release/2.0" 'grep -q "Merge hotfix/hboth into release/2.0" < <(git log release/2.0 --oneline)'
newrepo finbugfix; echo a>a; hookon; gitflow_init >/dev/null 2>&1
gitflow_start bugfix bx >/dev/null 2>&1; echo w>w.txt; git add w.txt; git commit -q -m bugfixwork
main_before="$(git rev-parse main)"
gitflow_finish >/dev/null 2>&1
chk "T13c develop has bugfix commit" 'git log develop --oneline | grep -q "Merge bugfix/bx into develop"'
chk "T13c develop has bugfix commit" 'grep -q "Merge bugfix/bx into develop" < <(git log develop --oneline)'
chk "T13c main untouched" "[ \"\$(git rev-parse main)\" = \"$main_before\" ]"
chk "T13c bugfix branch deleted" '! git rev-parse --verify -q refs/heads/bugfix/bx >/dev/null'
@@ -259,7 +259,7 @@ git add secret.txt
gl_out="$(git commit -q -m "add secret" 2>&1)"; gl_rc=$?
chk "T16a fake secret on feature branch → blocked" "[ $gl_rc -ne 0 ]"
chk "T16a message mentions gitleaks" 'printf "%s" "$gl_out" | grep -qi gitleaks'
chk "T16a nothing committed" '! git log --oneline 2>/dev/null | grep -q "add secret"'
chk "T16a nothing committed" '! grep -q "add secret" < <(git log --oneline 2>/dev/null)'
git restore --staged secret.txt 2>/dev/null || true; rm -f secret.txt
# T16b — a clean commit is unaffected
@@ -295,19 +295,19 @@ gitflow_finish >/dev/null 2>&1
# <sha>:path` proves BDR-065's "git history = the archive" recovery.
# shellcheck disable=SC2034 # pf_add_sha is used in the deferred chk eval string
pf_add_sha="$(git log develop --full-history --format=%H -- docs/superpowers/specs/s.md | tail -1)"
chk "T17a merged into develop" 'git log develop --oneline | grep -q "Merge feature/pf into develop"'
chk "T17a merged into develop" 'grep -q "Merge feature/pf into develop" < <(git log develop --oneline)'
chk "T17a develop TIP has no transient" '[ -z "$(git ls-tree -r develop --name-only -- docs/superpowers)" ]'
chk "T17a purge commit on record" 'git log develop --oneline | grep -q "purge transient planning artifacts"'
chk "T17a purge commit on record" 'grep -q "purge transient planning artifacts" < <(git log develop --oneline)'
chk "T17a artifact recoverable from history" '[ "$(git show "$pf_add_sha":docs/superpowers/specs/s.md 2>/dev/null)" = spec ]'
chk "T17a non-transient code survives" 'git ls-tree -r develop --name-only | grep -qx feat.txt'
chk "T17a non-transient code survives" 'grep -qx feat.txt < <(git ls-tree -r develop --name-only)'
chk "T17a feature branch deleted" '! git rev-parse --verify -q refs/heads/feature/pf >/dev/null'
# T17b — no artifacts → purge is a silent no-op, no spurious commit
newrepo purgenone; echo a>a; hookon; gitflow_init >/dev/null 2>&1
gitflow_start feature pn >/dev/null 2>&1; echo w>w.txt; git add w.txt; git commit -q -m w
gitflow_finish >/dev/null 2>&1
chk "T17b merged into develop" 'git log develop --oneline | grep -q "Merge feature/pn into develop"'
chk "T17b no purge commit created" '! git log develop --oneline | grep -q "purge transient"'
chk "T17b merged into develop" 'grep -q "Merge feature/pn into develop" < <(git log develop --oneline)'
chk "T17b no purge commit created" '! grep -q "purge transient" < <(git log develop --oneline)'
# T17c — opt-out (GITFLOW_PURGE_TRANSIENT=0) keeps the artifacts on develop
newrepo purgeoff; echo a>a; hookon; gitflow_init >/dev/null 2>&1
@@ -330,7 +330,7 @@ newrepo pushsrc; echo a>a; hookon; gitflow_init >/dev/null 2>&1
bare="$WORK/pushsrc.git"; git init -q --bare "$bare"; git remote add origin "$bare"
git push -q origin main develop 2>/dev/null
gitflow_start feature ap >/dev/null 2>&1
chk "T18a start pushed the branch" 'git ls-remote --heads origin feature/ap | grep -q feature/ap'
chk "T18a start pushed the branch" 'grep -q feature/ap < <(git ls-remote --heads origin feature/ap)'
echo w>w; git add w; git commit -q -m w 2>/dev/null
chk "T18b commit pushed by post-commit" '[ "$(git rev-parse HEAD)" = "$(git -C "$bare" rev-parse feature/ap)" ]'
echo w2>>w; git add w; GITFLOW_NO_PUSH=1 git commit -q -m w2 2>/dev/null
@@ -381,7 +381,7 @@ chk "T20b pre-commit rewritten == emitted" 'diff -q <(_gitflow_emit_pre_commit)
chk "T20c post-commit restored" '[ -x .githooks/post-commit ]'
chk "T20d second run is silent" '[ -z "$(gitflow_reconcile_hooks 2>/dev/null)" ]'
mkdir -p sub; cd sub || exit 1; echo "# stale" >> ../.githooks/post-merge
chk "T20e works from a subdirectory" 'gitflow_reconcile_hooks 2>/dev/null | grep -q post-merge'
chk "T20e works from a subdirectory" 'grep -q post-merge < <(gitflow_reconcile_hooks 2>/dev/null)'
cd .. || exit 1
newrepo plain; echo a>a; git add a; git commit -q -m a
chk "T20f non-gitflow repo → silent, no .githooks created" '[ -z "$(gitflow_reconcile_hooks 2>/dev/null)" ] && [ ! -d .githooks ]'
+20 -1
View File
@@ -49,6 +49,25 @@ if ! declare -F info >/dev/null 2>&1; then
info() { echo -e "${BLUE}→${NC} $1"; }
fi
# _gstack_links_realpath_m <path> — prints <path> resolved through its
# nearest existing ancestor (BSD realpath has no -m). rc 1 + warning when
# the path holds a `..` component: it cannot be resolved lexically.
_gstack_links_realpath_m() {
local path="${1%/}" rest="" dir base
case "/$path/" in
*/../*) warn "refusing path with '..': $1" >&2; return 1 ;;
esac
dir="$path"
while [ -n "$dir" ] && [ ! -d "$dir" ]; do
base="${dir##*/}"
rest="/$base$rest"
case "$dir" in */*) dir="${dir%/*}" ;; *) dir=. ;; esac
done
[ -n "$dir" ] || dir=/
dir="$(CDPATH='' cd -P -- "$dir" && pwd -P)" || return 1
printf '%s%s\n' "${dir%/}" "$rest"
}
# _gstack_links_guard_dst <src> <dst> — removes a stale <dst> symlink
# (gstack ./setup plants `skills/gstack -> skills-external/gstack` when
# the dir is absent), refuses ever writing INTO <src> (dst resolving
@@ -61,7 +80,7 @@ _gstack_links_guard_dst() {
rm -f "$dst"
fi
real_src="$(realpath "$src")"
real_dst="$(realpath -m "$dst")"
real_dst="$(_gstack_links_realpath_m "$dst")" || return 1
case "$real_dst" in
"$real_src"/*|"$real_src")
warn "refusing to write into the gstack submodule: $dst" >&2
+14 -8
View File
@@ -265,13 +265,15 @@ skill_status() {
# `claude plugin list` is the source of truth — settings.json may be
# ahead of or behind reality if the user toggled outside this tool.
if command -v "$CLAUDE_BIN" >/dev/null 2>&1; then
# Match the plugin block by name then check Status line
if "$CLAUDE_BIN" plugin list 2>/dev/null \
| awk -v p="$skill" '
# Match the plugin block by name then check Status line. List is
# captured first: an early-exit awk/grep -q in a pipe SIGPIPEs the
# producer (rc 141 under pipefail on macOS).
local plist
plist="$("$CLAUDE_BIN" plugin list 2>/dev/null)" || true
if grep -q "✔ enabled" < <(awk -v p="$skill" '
/^[[:space:]]*❯ '"$skill"'@/ { found=1; next }
found && /Status:/ { print; exit }
' \
| grep -q "✔ enabled"; then
' <<<"$plist"); then
echo "enabled"
else
echo "disabled"
@@ -282,7 +284,7 @@ skill_status() {
;;
mcp)
if command -v "$CLAUDE_BIN" >/dev/null 2>&1 && \
"$CLAUDE_BIN" mcp list 2>/dev/null | grep -q "^${skill}"; then
grep -q "^${skill}" <<<"$("$CLAUDE_BIN" mcp list 2>/dev/null)"; then
echo "enabled"
else
echo "disabled"
@@ -371,7 +373,10 @@ enable_skill() {
if [ "$(skill_status "$skill" "$type")" = "enabled" ]; then
: # already on
elif command -v "$CLAUDE_BIN" >/dev/null 2>&1; then
if "$CLAUDE_BIN" plugin enable "${skill}@${marketplace}" 2>&1 | grep -qiE "enabled|already"; then
# CLI call stays inside the condition: a failing CLI must not abort
if grep -qiE "enabled|already" \
<<<"$("$CLAUDE_BIN" plugin enable \
"${skill}@${marketplace}" 2>&1)"; then
ok "enabled plugin: ${skill}@${marketplace}"
else
warn "could not enable plugin: ${skill}@${marketplace}"
@@ -441,7 +446,8 @@ disable_skill() {
if [ "$(skill_status "$skill" "$type")" = "disabled" ]; then
: # already off
elif command -v "$CLAUDE_BIN" >/dev/null 2>&1; then
if "$CLAUDE_BIN" plugin disable "$key" 2>&1 | grep -qiE "disabled|already"; then
if grep -qiE "disabled|already" \
<<<"$("$CLAUDE_BIN" plugin disable "$key" 2>&1)"; then
ok "disabled plugin: $key"
else
warn "could not disable plugin: $key"
+8 -4
View File
@@ -23,9 +23,13 @@ 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")"
# stat -c is GNU only; python3 gives the same octal mode on BSD and GNU
octal_perm() {
python3 -I -c 'import os,stat,sys; print(oct(stat.S_IMODE(os.stat(sys.argv[1]).st_mode))[2:])' "$1"
}
PERM="$(octal_perm "$STORE")"
[ "$PERM" = "600" ] && ok "store file is 0600" || no "store file 0600" "got $PERM"
DPERM="$(stat -c '%a' "$(dirname "$STORE")")"
DPERM="$(octal_perm "$(dirname "$STORE")")"
[ "$DPERM" = "700" ] && ok "store dir is 0700" || no "store dir 0700" "got $DPERM"
rm -rf "$TMP"
@@ -136,7 +140,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="$(/usr/bin/grep -E "^(import|from) " "$SD/safe_fetch.py" | /usr/bin/grep -cvE "gzip|http\.client|ipaddress|socket|ssl|urllib\.parse")"
[ "$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 +481,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="$(octal_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 \
+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 ---
+1 -1
View File
@@ -139,7 +139,7 @@ pack_hints() {
21st)
if ! command -v 21st >/dev/null 2>&1; then
warn "the \`21st\` CLI is not on PATH — install it: npm i -g @21st-dev/cli"
elif ! 21st whoami 2>/dev/null | grep -q '^Logged in as '; then
elif ! grep -q '^Logged in as ' <<<"$(21st whoami 2>/dev/null)"; then
warn "not signed in to 21st — component retrieval and 21st AI need: 21st login"
fi
;;