aboutsummaryrefslogtreecommitdiff
path: root/scripts/tests
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/tests')
-rw-r--r--scripts/tests/agent-page.bats68
-rw-r--r--scripts/tests/ai-launcher-runtime.bats97
-rw-r--r--scripts/tests/install-agents-entry.bats51
-rw-r--r--scripts/tests/install-ai.bats23
-rw-r--r--scripts/tests/install-lang-collision.bats112
-rw-r--r--scripts/tests/pre-commit-secret-scan.bats144
6 files changed, 495 insertions, 0 deletions
diff --git a/scripts/tests/agent-page.bats b/scripts/tests/agent-page.bats
new file mode 100644
index 0000000..071e4b9
--- /dev/null
+++ b/scripts/tests/agent-page.bats
@@ -0,0 +1,68 @@
+#!/usr/bin/env bats
+# agent-page — the runtime-neutral phone pager. Pages Craig over Signal from
+# any machine on the tailnet: runs signal-cli directly on velox (where the
+# pager identity lives), ssh-relays to velox from everywhere else. These tests
+# stub ssh/uname/signal-cli on PATH to verify command construction without a
+# network or a phone.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ PAGE="$REPO_ROOT/claude-templates/bin/agent-page"
+ STUBS="$(mktemp -d)"
+ LOG="$STUBS/calls.log"
+ cat > "$STUBS/ssh" <<EOF
+#!/bin/bash
+echo "ssh \$*" >> "$LOG"
+exit 0
+EOF
+ cat > "$STUBS/signal-cli" <<EOF
+#!/bin/bash
+echo "signal-cli \$*" >> "$LOG"
+exit 0
+EOF
+ chmod +x "$STUBS/ssh" "$STUBS/signal-cli"
+}
+
+teardown() {
+ rm -rf "$STUBS"
+}
+
+@test "no message exits 2 with usage" {
+ run bash "$PAGE"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"usage"* ]]
+}
+
+@test "relays through ssh to velox with the pager account and Craig's UUID" {
+ PATH="$STUBS:$PATH" run bash "$PAGE" build finished
+ [ "$status" -eq 0 ]
+ grep -q "^ssh .*velox" "$LOG"
+ grep -q "15045173983" "$LOG"
+ grep -q "b1b5601e-6126-47f8-afaa-0a59f5188fde" "$LOG"
+ # printf %q escapes the space, so the relayed message reads build\ finished.
+ grep -qF 'build\ finished' "$LOG"
+}
+
+@test "on velox itself, calls signal-cli directly (no ssh)" {
+ cat > "$STUBS/uname" <<'EOF'
+#!/bin/bash
+[ "$1" = "-n" ] && { echo velox; exit 0; }
+exec /usr/bin/uname "$@"
+EOF
+ chmod +x "$STUBS/uname"
+ PATH="$STUBS:$PATH" run bash "$PAGE" hello
+ [ "$status" -eq 0 ]
+ grep -q "^signal-cli " "$LOG"
+ ! grep -q "^ssh " "$LOG"
+}
+
+@test "a failed relay reports the desktop fallback and propagates failure" {
+ cat > "$STUBS/ssh" <<'EOF'
+#!/bin/bash
+exit 255
+EOF
+ chmod +x "$STUBS/ssh"
+ PATH="$STUBS:$PATH" run bash "$PAGE" urgent thing
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"notify"* ]]
+}
diff --git a/scripts/tests/ai-launcher-runtime.bats b/scripts/tests/ai-launcher-runtime.bats
new file mode 100644
index 0000000..3f0ad69
--- /dev/null
+++ b/scripts/tests/ai-launcher-runtime.bats
@@ -0,0 +1,97 @@
+#!/usr/bin/env bats
+# The ai launcher's --runtime flag: pick the agent CLI (claude, codex) that
+# a project window launches. Part of the generic-agent-runtime arc — the
+# tmux-side counterpart of .emacs.d's ai-term multi-LLM handoff. The
+# --print-launch mode exists for exactly these tests: it prints the launch
+# command a real run would send to the pane, without touching tmux or fzf.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ AI="$REPO_ROOT/claude-templates/bin/ai"
+ PROJ="$(mktemp -d)"
+ mkdir -p "$PROJ/.ai"
+ touch "$PROJ/.ai/protocols.org"
+ STUB_BIN="$(mktemp -d)"
+}
+
+teardown() {
+ rm -rf "$PROJ" "$STUB_BIN"
+}
+
+@test "default runtime launches claude" {
+ run bash "$AI" --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == claude\ * ]]
+ [[ "$output" == *"protocols.org"* ]]
+}
+
+@test "--runtime codex launches codex with the same opening line" {
+ run bash "$AI" --runtime codex --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == codex\ * ]]
+ [[ "$output" == *"protocols.org"* ]]
+ [[ "$output" == *"$(basename "$PROJ")"* ]]
+}
+
+@test "AI_RUNTIME env selects the runtime without the flag" {
+ AI_RUNTIME=codex run bash "$AI" --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == codex\ * ]]
+}
+
+@test "--runtime local launches codex --oss over ollama with the default local model" {
+ run bash "$AI" --runtime local --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "codex --oss --local-provider=ollama -m gpt-oss:120b "* ]]
+ [[ "$output" == *"protocols.org"* ]]
+}
+
+@test "AI_LOCAL_MODEL overrides the local runtime's model" {
+ AI_LOCAL_MODEL=qwen3-coder:30b run bash "$AI" --runtime local --print-launch "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" == "codex --oss --local-provider=ollama -m qwen3-coder:30b "* ]]
+}
+
+@test "an unknown runtime errors and names the valid ones" {
+ run bash "$AI" --runtime frobnitz --print-launch "$PROJ"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"claude"* ]]
+ [[ "$output" == *"codex"* ]]
+}
+
+@test "--print-launch refuses a non-project directory" {
+ run bash "$AI" --print-launch "$BATS_TEST_TMPDIR"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"protocols.org"* ]]
+}
+
+@test "--print-runtimes lists claude, codex, and one line per ollama model" {
+ cat > "$STUB_BIN/ollama" <<'STUB'
+#!/bin/bash
+if [ "$1" = "list" ]; then
+ printf 'NAME ID SIZE MODIFIED\n'
+ printf 'gpt-oss:120b aaa 65 GB 1 hour ago\n'
+ printf 'qwen3-coder:30b bbb 18 GB 1 hour ago\n'
+fi
+STUB
+ chmod +x "$STUB_BIN/ollama"
+ PATH="$STUB_BIN:$PATH" run bash "$AI" --print-runtimes
+ [ "$status" -eq 0 ]
+ [ "${lines[0]%% *}" = "claude" ]
+ [[ "$output" == *"codex"* ]]
+ [[ "$output" == *"local:gpt-oss:120b"* ]]
+ [[ "$output" == *"local:qwen3-coder:30b"* ]]
+}
+
+@test "a dead ollama server just drops the local lines" {
+ cat > "$STUB_BIN/ollama" <<'STUB'
+#!/bin/bash
+echo "Error: could not connect to a running Ollama instance" >&2
+exit 1
+STUB
+ chmod +x "$STUB_BIN/ollama"
+ PATH="$STUB_BIN:$PATH" run bash "$AI" --print-runtimes
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"claude"* ]]
+ [[ "$output" != *"local:"* ]]
+}
diff --git a/scripts/tests/install-agents-entry.bats b/scripts/tests/install-agents-entry.bats
new file mode 100644
index 0000000..03343a6
--- /dev/null
+++ b/scripts/tests/install-agents-entry.bats
@@ -0,0 +1,51 @@
+#!/usr/bin/env bats
+# make install must link the runtime-neutral agent entry file (AGENTS.md)
+# into CODEX_DIR so Codex-style harnesses bootstrap from the same
+# protocols/rules/skills the Claude side reads. The thin-pointer shape and
+# the decision trail live in docs/design/2026-07-13-runtime-portability-
+# inventories.org and the generic-agent-runtime task.
+
+setup() {
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+ TMPHOME="$(mktemp -d)"
+}
+
+teardown() {
+ rm -rf "$TMPHOME"
+}
+
+run_install() {
+ make -C "$REPO_ROOT" install \
+ SKILLS_DIR="$TMPHOME/skills" \
+ RULES_DIR="$TMPHOME/rules" \
+ HOOKS_DIR="$TMPHOME/hooks" \
+ CLAUDE_DIR="$TMPHOME/claude" \
+ CODEX_DIR="$TMPHOME/codex" \
+ LOCAL_BIN="$TMPHOME/bin"
+}
+
+@test "install links AGENTS.md into CODEX_DIR" {
+ run run_install
+ [ "$status" -eq 0 ]
+ [ -L "$TMPHOME/codex/AGENTS.md" ]
+ grep -q "protocols.org" "$TMPHOME/codex/AGENTS.md"
+}
+
+@test "install is idempotent on the agent entry (second run skips)" {
+ run run_install
+ [ "$status" -eq 0 ]
+ run run_install
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"skip AGENTS.md (already linked)"* ]]
+ [ -L "$TMPHOME/codex/AGENTS.md" ]
+}
+
+@test "install warns on a non-symlink AGENTS.md collision and leaves it alone" {
+ mkdir -p "$TMPHOME/codex"
+ echo "hand-written entry" > "$TMPHOME/codex/AGENTS.md"
+ run run_install
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"WARN AGENTS.md exists and is not a symlink"* ]]
+ [ ! -L "$TMPHOME/codex/AGENTS.md" ]
+ grep -q "hand-written entry" "$TMPHOME/codex/AGENTS.md"
+}
diff --git a/scripts/tests/install-ai.bats b/scripts/tests/install-ai.bats
index 8e91770..a7eb3c0 100644
--- a/scripts/tests/install-ai.bats
+++ b/scripts/tests/install-ai.bats
@@ -149,3 +149,26 @@ EOF
[ -d "$TEST_HOME/code/pickme/.ai" ]
[ ! -d "$TEST_HOME/code/skipme/.ai" ]
}
+
+@test "install-ai: seeds AGENTS.md at the project root" {
+ mkdir -p "$TEST_HOME/code/fresh"
+ (cd "$TEST_HOME/code/fresh" && git init -q)
+
+ run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/fresh"
+
+ [ "$status" -eq 0 ]
+ [ -f "$TEST_HOME/code/fresh/AGENTS.md" ]
+ grep -q "protocols.org" "$TEST_HOME/code/fresh/AGENTS.md"
+}
+
+@test "install-ai: never overwrites an existing AGENTS.md" {
+ mkdir -p "$TEST_HOME/code/fresh"
+ (cd "$TEST_HOME/code/fresh" && git init -q)
+ echo "project-owned entry file" > "$TEST_HOME/code/fresh/AGENTS.md"
+
+ run bash "$INSTALL_AI" --gitignore "$TEST_HOME/code/fresh"
+
+ [ "$status" -eq 0 ]
+ grep -q "project-owned entry file" "$TEST_HOME/code/fresh/AGENTS.md"
+ ! grep -q "protocols.org" "$TEST_HOME/code/fresh/AGENTS.md"
+}
diff --git a/scripts/tests/install-lang-collision.bats b/scripts/tests/install-lang-collision.bats
new file mode 100644
index 0000000..36abb5b
--- /dev/null
+++ b/scripts/tests/install-lang-collision.bats
@@ -0,0 +1,112 @@
+#!/usr/bin/env bats
+# Tests for install-lang's cross-bundle collision guard.
+#
+# Several bundles ship files at the same path. gitignore-add.txt merges
+# (appended, deduped) and CLAUDE.md is seed-only, so both compose across
+# bundles. Three do not:
+#
+# claude/settings.json elisp, bash, go — cp -rT, silently overwritten
+# githooks/pre-commit elisp, bash, go — cp -rT, silently overwritten
+# coverage-makefile.txt 4 bundles — [skip]ped, fragment dropped
+#
+# Installing a second bundle used to replace the first's settings.json and
+# pre-commit while printing [ok], so a project could lose its paren check or
+# secret scan and read the output as success. The guard refuses instead, naming
+# what would be replaced. FORCE=1 still overrides.
+
+INSTALL="${BATS_TEST_DIRNAME}/../install-lang.sh"
+
+setup() {
+ PROJ="$(mktemp -d)"
+ git init -q "$PROJ"
+}
+
+teardown() {
+ [ -n "${PROJ:-}" ] && rm -rf "$PROJ"
+}
+
+# ---- Normal: single-bundle installs are unaffected ----
+
+@test "install-lang: a fresh single-bundle install succeeds" {
+ run bash "$INSTALL" elisp "$PROJ"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJ/.claude/settings.json" ]
+ grep -q 'validate-el.sh' "$PROJ/.claude/settings.json"
+}
+
+@test "install-lang: reinstalling the SAME bundle is idempotent, not a collision" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" elisp "$PROJ"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"collision"* ]]
+ grep -q 'validate-el.sh' "$PROJ/.claude/settings.json"
+}
+
+# ---- The guard: a second, different bundle must not silently replace ----
+
+@test "install-lang: a second bundle sharing settings.json and githooks is refused" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" bash "$PROJ"
+ [ "$status" -ne 0 ] || { echo "second bundle installed without refusal"; return 1; }
+ [[ "$output" == *"elisp"* ]] || { echo "refusal does not name the existing bundle"; return 1; }
+}
+
+@test "install-lang: the refusal names each file that would be replaced" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" bash "$PROJ"
+ [[ "$output" == *"settings.json"* ]] || { echo "refusal omits settings.json"; return 1; }
+ [[ "$output" == *"pre-commit"* ]] || { echo "refusal omits githooks/pre-commit"; return 1; }
+}
+
+@test "install-lang: a refused install leaves the first bundle intact" {
+ bash "$INSTALL" elisp "$PROJ"
+ bash "$INSTALL" bash "$PROJ" || true
+ grep -q 'validate-el.sh' "$PROJ/.claude/settings.json" \
+ || { echo "elisp settings.json was replaced despite refusal"; return 1; }
+ grep -q 'check-parens' "$PROJ/githooks/pre-commit" \
+ || { echo "elisp pre-commit was replaced despite refusal"; return 1; }
+}
+
+@test "install-lang: bundles colliding only on coverage-makefile.txt are refused too" {
+ # python and typescript ship no settings.json or githooks, but both ship a
+ # coverage fragment. The second one's used to be silently dropped.
+ bash "$INSTALL" python "$PROJ"
+ run bash "$INSTALL" typescript "$PROJ"
+ [ "$status" -ne 0 ] || { echo "typescript installed over python's coverage fragment"; return 1; }
+ [[ "$output" == *"coverage-makefile.txt"* ]]
+}
+
+@test "install-lang: two bundles that share no overwritten file install together" {
+ # bash ships settings.json + githooks and no coverage fragment; python ships
+ # only the coverage fragment. Nothing overlaps, so the guard must stay out of
+ # the way. This is the path where a false refusal would be easiest to
+ # introduce: the bundle IS detected, and only the empty file-list stops it.
+ bash "$INSTALL" bash "$PROJ"
+ run bash "$INSTALL" python "$PROJ"
+ [ "$status" -eq 0 ] || { echo "guard falsely refused a non-colliding pair: $output"; return 1; }
+ [ -f "$PROJ/coverage-makefile.txt" ] || { echo "python's coverage fragment did not land"; return 1; }
+ # bash's config survives untouched.
+ grep -q 'validate-bash.sh' "$PROJ/.claude/settings.json"
+ [ -f "$PROJ/.claude/rules/bash.md" ] && [ -f "$PROJ/.claude/rules/python-testing.md" ]
+}
+
+# ---- The escape hatch ----
+
+@test "install-lang: FORCE=1 overrides the collision guard" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" bash "$PROJ" 1
+ [ "$status" -eq 0 ] || { echo "FORCE=1 did not override: $output"; return 1; }
+ grep -q 'validate-bash.sh' "$PROJ/.claude/settings.json"
+}
+
+@test "install-lang: the refusal points at FORCE=1 and warns it re-seeds CLAUDE.md" {
+ bash "$INSTALL" elisp "$PROJ"
+ run bash "$INSTALL" bash "$PROJ"
+ # Assert the refusal fired first: the pre-existing "[skip] CLAUDE.md already
+ # exists (use FORCE=1 to overwrite)" line mentions both strings on its own, so
+ # without this the test passes against an unguarded install.
+ [ "$status" -ne 0 ] || { echo "no refusal fired"; return 1; }
+ refusal="$(printf '%s\n' "$output" | grep -v '^ \[skip\]')"
+ [[ "$refusal" == *"FORCE=1"* ]] || { echo "refusal does not name the override"; return 1; }
+ [[ "$refusal" == *"CLAUDE.md"* ]] || { echo "refusal does not warn about the CLAUDE.md re-seed"; return 1; }
+}
diff --git a/scripts/tests/pre-commit-secret-scan.bats b/scripts/tests/pre-commit-secret-scan.bats
new file mode 100644
index 0000000..013129e
--- /dev/null
+++ b/scripts/tests/pre-commit-secret-scan.bats
@@ -0,0 +1,144 @@
+#!/usr/bin/env bats
+# Tests for the secret-scan block shared by the elisp, bash, and go pre-commit
+# hooks. The block greps added lines in the staged diff for credential
+# patterns; a hit blocks the commit (exit 1), a clean scan falls through to the
+# variant's language check (exit 0).
+#
+# Every case stages a .txt file, so the language checks that follow the scan
+# (check-parens, shellcheck, gofmt) all skip and the scan is what's under test.
+#
+# The two boundary cases exist because of a live false-positive in a downstream
+# project: an embedded PNG sprite data URI blocked a real commit and forced
+# --no-verify. Root cause was `grep -iE` applying case-insensitivity to the
+# fixed-case AWS token AKIA[0-9A-Z]{16}, so any mixed-case 20-char run inside a
+# random base64 blob matched. Measured at ~6% of 100KB blobs; case-sensitive
+# matching drops it to 0 across ~10MB.
+
+VARIANTS="elisp bash go"
+
+setup() {
+ REPO="$(mktemp -d)"
+ cd "$REPO" || return 1
+ git init -q .
+ git config user.email t@example.com
+ git config user.name Test
+}
+
+teardown() {
+ cd /tmp || true
+ [ -n "${REPO:-}" ] && rm -rf "$REPO"
+}
+
+# Stage $2 as the content of file $1 (default staged.txt).
+stage() {
+ local file="${2:-staged.txt}"
+ printf '%s\n' "$1" > "$file"
+ git add "$file"
+}
+
+# Run a variant's hook in the temp repo. $1 = variant name.
+run_hook() {
+ bash "${BATS_TEST_DIRNAME}/../../languages/$1/githooks/pre-commit"
+}
+
+# ---- Normal: real secrets still block, clean content still passes ----
+
+@test "secret-scan: clean content passes in every variant" {
+ stage 'const greeting = "hello world";'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v blocked clean content: $output"; return 1; }
+ done
+}
+
+@test "secret-scan: a real uppercase AWS access key blocks in every variant" {
+ stage 'aws_key = "AKIAIOSFODNN7EXAMPLE"'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ] || { echo "$v missed an AWS key"; return 1; }
+ [[ "$output" == *"potential secret"* ]]
+ done
+}
+
+@test "secret-scan: a keyword=value credential blocks in every variant" {
+ stage 'api_key: "sk_live_9f3b2a7c1e4d8f0a6b5"'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ] || { echo "$v missed an api_key assignment"; return 1; }
+ done
+}
+
+@test "secret-scan: a PEM private-key header blocks in every variant" {
+ stage '-----BEGIN RSA PRIVATE KEY-----'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ] || { echo "$v missed a PEM header"; return 1; }
+ done
+}
+
+# ---- Boundary: the case-sensitivity fix ----
+
+@test "secret-scan: a lowercase akia-like run does not block (AWS keys are uppercase)" {
+ # Under `grep -iE` this matched the AKIA token and blocked a legitimate commit.
+ stage 'const blob = "akiaiosfodnn7examplexyz";'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v false-positived on a lowercase run: $output"; return 1; }
+ done
+}
+
+@test "secret-scan: an embedded base64 data URI carrying a mixed-case akia run does not block" {
+ # The live failure: a sprite blob whose random base64 contained a mixed-case
+ # 20-char run. Case-sensitive matching is what clears it.
+ stage 'const SPRITE = "data:image/png;base64,iVBORw0KGgoAkIaIOSFODNN7ExAMPLEqQmCC";'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v false-positived on a sprite data URI: $output"; return 1; }
+ done
+}
+
+# ---- Boundary: the scan must not go blind on data-URI lines ----
+
+@test "secret-scan: a real credential sharing a line with a base64 data URI still blocks" {
+ # Minified bundles put a whole file on one line, so a data URI and a real key
+ # can share it. Skipping any line containing ';base64,' would hide the key.
+ stage 'const S="data:image/png;base64,iVBORw0KGgoAAAANS";const c={api_key:"sk_live_9f3b2a7c1e4d8f0a6b5"};'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ] || { echo "$v went blind on a data-URI line and missed the key"; return 1; }
+ done
+}
+
+@test "secret-scan: a line matching both passes is reported once, not twice" {
+ # The scan runs a case-sensitive and a case-insensitive pass. A line carrying
+ # both an AWS key and a keyword=value credential hits both; reporting it twice
+ # reads as two separate leaks.
+ stage 'api_key = "AKIAIOSFODNN7EXAMPLE_padding"'
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 1 ]
+ hits="$(printf '%s\n' "$output" | grep -c 'AKIAIOSFODNN7EXAMPLE_padding')"
+ [ "$hits" -eq 1 ] || { echo "$v reported the line $hits times, want 1"; return 1; }
+ done
+}
+
+# ---- Error / edge ----
+
+@test "secret-scan: an empty staged diff passes" {
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v failed on an empty diff: $output"; return 1; }
+ done
+}
+
+@test "secret-scan: a secret only on a removed line does not block" {
+ # The scan reads added lines. Deleting a key should never block the deletion.
+ stage 'aws_key = "AKIAIOSFODNN7EXAMPLE"'
+ git commit -qm "seed" --no-verify
+ printf 'clean\n' > staged.txt
+ git add staged.txt
+ for v in $VARIANTS; do
+ run run_hook "$v"
+ [ "$status" -eq 0 ] || { echo "$v blocked a removal: $output"; return 1; }
+ done
+}