From 0efdff0d558000aea7c1a0cb0bb647f027e132c3 Mon Sep 17 00:00:00 2001 From: bchanot Date: Tue, 6 Oct 2026 15:10:20 +0200 Subject: [PATCH] fix(portability): make test green on macOS, GNU-only idioms replaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- install-plugins.sh | 35 +++++++++++++--- lib/design-tool-gate.sh | 28 +++++++++---- lib/doc-shape.sh | 3 +- lib/gitflow-test.sh | 52 +++++++++++------------ lib/gstack-links.sh | 21 +++++++++- lib/profile.sh | 22 ++++++---- lib/seo-data/seo-data.test.sh | 12 ++++-- lib/tests/design-tool-gate.test.sh | 2 +- lib/tests/doctrine-citers.test.sh | 23 ++++++++-- lib/tests/effort-pins.test.sh | 6 +-- lib/tests/fast-libs.test.sh | 8 ++-- lib/tests/floor-guard.test.sh | 3 +- lib/tests/gstack-links.test.sh | 8 ++++ lib/tests/gstack-playwright.test.sh | 5 ++- lib/tests/portability-census.test.sh | 60 +++++++++++++++++++++++++++ lib/tests/profile-census.test.sh | 3 +- lib/tests/profile-default.test.sh | 3 +- lib/tests/profile-set-managed.test.sh | 21 ++++++++++ lib/tests/run-doc-commit.sh | 2 +- lib/tests/run-release-candidate.sh | 5 ++- lib/tests/source-scope.test.sh | 6 +-- lib/toggle-external.sh | 2 +- update-all.sh | 3 +- 23 files changed, 255 insertions(+), 78 deletions(-) create mode 100644 lib/tests/portability-census.test.sh diff --git a/install-plugins.sh b/install-plugins.sh index 38c5e7f..c590355 100644 --- a/install-plugins.sh +++ b/install-plugins.sh @@ -471,7 +471,8 @@ echo "" install_plugin() { local name="$1" local source="$2" - if claude plugin list 2>/dev/null | grep -qi "$name"; then + # CLI call inside the condition: no pipe (SIGPIPE on macOS), no errexit abort + if grep -qi "$name" <<<"$(claude plugin list 2>/dev/null)"; then ok "$name (already installed)" return fi @@ -1140,7 +1141,8 @@ apply_effort_pins "$REPO" || warn "effort pins: map lines rejected — fix lib/e # the session. Mirrors the ctx7 auth block (Step 6), stdin-only test included. if command -v 21st &>/dev/null; then # `whoami` is a local token read (no network): "Logged in as (saved …)." - TFD_WHO="$(21st whoami 2>/dev/null | head -1)" + TFD_WHO="$(21st whoami 2>/dev/null)" || true + TFD_WHO="${TFD_WHO%%$'\n'*}" # first line, no head(1) in a pipeline if [[ "$TFD_WHO" == "Logged in as "* ]]; then ok "21st: ${TFD_WHO%.}" elif [ -t 0 ]; then @@ -1196,16 +1198,37 @@ fi # Remove obsolete effort config — effort is now set in settings.json # ("effortLevel"), which supersedes both the old CLAUDE_EFFORT env var and the # `claude --effort max` alias (the alias would even override settings.json). +# sed_profile — in-place sed on $SHELL_PROFILE. BSD in-place sed needs +# a suffix, and -i.bak would clobber a hand-made .bak, so write a unique +# sibling temp and copy it back. rc 1 leaves the profile as it was. +sed_profile() { + local tmp + tmp="$(mktemp "$SHELL_PROFILE.XXXXXX")" || return 1 + # the profile is truncated by the copy-back: past that point $tmp is the + # only complete copy, so a failed write keeps it + sed "$1" "$SHELL_PROFILE" >"$tmp" || { rm -f "$tmp"; return 1; } + if ! cat "$tmp" >"$SHELL_PROFILE"; then + warn "profile write failed — full copy kept at $tmp" >&2 + return 1 + fi + rm -f "$tmp" +} EFFORT_CLEANED=0 if grep -qF 'export CLAUDE_EFFORT=max' "$SHELL_PROFILE" 2>/dev/null; then - sed -i '/export CLAUDE_EFFORT=max/d' "$SHELL_PROFILE"; EFFORT_CLEANED=1 + if sed_profile '/export CLAUDE_EFFORT=max/d'; then + EFFORT_CLEANED=1 + else + warn "could not remove CLAUDE_EFFORT from $SHELL_PROFILE" + fi fi if grep -qF "alias claude='claude --effort max'" "$SHELL_PROFILE" 2>/dev/null; then - sed -i "\#alias claude='claude --effort max'#d" "$SHELL_PROFILE"; EFFORT_CLEANED=1 + if sed_profile "\#alias claude='claude --effort max'#d"; then + EFFORT_CLEANED=1 + else + warn "could not remove the claude effort alias from $SHELL_PROFILE" + fi fi if [ "$EFFORT_CLEANED" -eq 1 ]; then - # Remove orphaned comment lines left before the deleted entries - sed -i '/^# Claude Code — added by install-plugins.sh$/{ N; /^\n$/d; }' "$SHELL_PROFILE" info "Removed obsolete effort alias/env from $SHELL_PROFILE (effort set in settings.json)" fi diff --git a/lib/design-tool-gate.sh b/lib/design-tool-gate.sh index 4a920a0..52404ae 100755 --- a/lib/design-tool-gate.sh +++ b/lib/design-tool-gate.sh @@ -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:. 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 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 —" \ diff --git a/lib/doc-shape.sh b/lib/doc-shape.sh index 4bfb9a3..52e09c4 100755 --- a/lib/doc-shape.sh +++ b/lib/doc-shape.sh @@ -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 diff --git a/lib/gitflow-test.sh b/lib/gitflow-test.sh index 0b95df5..bdefd10 100644 --- a/lib/gitflow-test.sh +++ b/lib/gitflow-test.sh @@ -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 # :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 ]' diff --git a/lib/gstack-links.sh b/lib/gstack-links.sh index b3b0c6c..39719e5 100644 --- a/lib/gstack-links.sh +++ b/lib/gstack-links.sh @@ -49,6 +49,25 @@ if ! declare -F info >/dev/null 2>&1; then info() { echo -e "${BLUE}→${NC} $1"; } fi +# _gstack_links_realpath_m — prints 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 — removes a stale symlink # (gstack ./setup plants `skills/gstack -> skills-external/gstack` when # the dir is absent), refuses ever writing INTO (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 diff --git a/lib/profile.sh b/lib/profile.sh index 8dbb0d5..9c1f911 100755 --- a/lib/profile.sh +++ b/lib/profile.sh @@ -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" diff --git a/lib/seo-data/seo-data.test.sh b/lib/seo-data/seo-data.test.sh index d13d260..bc24cbf 100644 --- a/lib/seo-data/seo-data.test.sh +++ b/lib/seo-data/seo-data.test.sh @@ -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 \ diff --git a/lib/tests/design-tool-gate.test.sh b/lib/tests/design-tool-gate.test.sh index d6f8a46..83a92b0 100755 --- a/lib/tests/design-tool-gate.test.sh +++ b/lib/tests/design-tool-gate.test.sh @@ -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)) diff --git a/lib/tests/doctrine-citers.test.sh b/lib/tests/doctrine-citers.test.sh index a21eb3c..7281874 100644 --- a/lib/tests/doctrine-citers.test.sh +++ b/lib/tests/doctrine-citers.test.sh @@ -16,16 +16,27 @@ check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); # _citers_extract … → "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 → rc 0 when a heading or a bold label starts with +# _citers_resolve → rc 0 when a heading starts with +# (then end, space, `:`, `(` or `—`) or a bold label `**` 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 … → 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 diff --git a/lib/tests/effort-pins.test.sh b/lib/tests/effort-pins.test.sh index 1e66381..329136c 100755 --- a/lib/tests/effort-pins.test.sh +++ b/lib/tests/effort-pins.test.sh @@ -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" diff --git a/lib/tests/fast-libs.test.sh b/lib/tests/fast-libs.test.sh index 9db21dc..4678a86 100644 --- a/lib/tests/fast-libs.test.sh +++ b/lib/tests/fast-libs.test.sh @@ -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":"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/floor-guard.test.sh b/lib/tests/floor-guard.test.sh index ccba234..b0b0323 100644 --- a/lib/tests/floor-guard.test.sh +++ b/lib/tests/floor-guard.test.sh @@ -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' diff --git a/lib/tests/gstack-links.test.sh b/lib/tests/gstack-links.test.sh index 803496a..1783cbe 100755 --- a/lib/tests/gstack-links.test.sh +++ b/lib/tests/gstack-links.test.sh @@ -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 ] diff --git a/lib/tests/gstack-playwright.test.sh b/lib/tests/gstack-playwright.test.sh index 1baa684..5cce44d 100644 --- a/lib/tests/gstack-playwright.test.sh +++ b/lib/tests/gstack-playwright.test.sh @@ -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 ─── diff --git a/lib/tests/portability-census.test.sh b/lib/tests/portability-census.test.sh new file mode 100644 index 0000000..cc3dc90 --- /dev/null +++ b/lib/tests/portability-census.test.sh @@ -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 … → 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" diff --git a/lib/tests/profile-census.test.sh b/lib/tests/profile-census.test.sh index 3aadcc1..8d150d0 100755 --- a/lib/tests/profile-census.test.sh +++ b/lib/tests/profile-census.test.sh @@ -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 diff --git a/lib/tests/profile-default.test.sh b/lib/tests/profile-default.test.sh index 56987b7..b80acd8 100644 --- a/lib/tests/profile-default.test.sh +++ b/lib/tests/profile-default.test.sh @@ -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" diff --git a/lib/tests/profile-set-managed.test.sh b/lib/tests/profile-set-managed.test.sh index e38434a..7d4fdab 100644 --- a/lib/tests/profile-set-managed.test.sh +++ b/lib/tests/profile-set-managed.test.sh @@ -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 ] diff --git a/lib/tests/run-doc-commit.sh b/lib/tests/run-doc-commit.sh index 5d16d22..e5cd8ed 100755 --- a/lib/tests/run-doc-commit.sh +++ b/lib/tests/run-doc-commit.sh @@ -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" diff --git a/lib/tests/run-release-candidate.sh b/lib/tests/run-release-candidate.sh index 3bfe4ae..5e39f49 100644 --- a/lib/tests/run-release-candidate.sh +++ b/lib/tests/run-release-candidate.sh @@ -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 diff --git a/lib/tests/source-scope.test.sh b/lib/tests/source-scope.test.sh index b674b6c..02946c2 100644 --- a/lib/tests/source-scope.test.sh +++ b/lib/tests/source-scope.test.sh @@ -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 --- diff --git a/lib/toggle-external.sh b/lib/toggle-external.sh index 9e9657b..5b54f9f 100755 --- a/lib/toggle-external.sh +++ b/lib/toggle-external.sh @@ -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 ;; diff --git a/update-all.sh b/update-all.sh index 87fb47d..f83d907 100644 --- a/update-all.sh +++ b/update-all.sh @@ -603,8 +603,9 @@ fi echo "" echo "── Updating marketplace plugins..." if command -v claude &>/dev/null; then + # sed -n: BSD grep has no PCRE mode (rc 2 emptied the list) _plugins=$(claude plugin list 2>/dev/null \ - | grep -oP '(?<=❯ )\S+' || true) + | sed -n 's/.*❯ \([^[:space:]][^[:space:]]*\).*/\1/p' || true) if [ -n "$_plugins" ]; then while IFS= read -r _p; do _name="${_p%%@*}"