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:
+21
-7
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user