Why:
The repo published apm packages but consumed them the old way — `claude plugin install
<name>@holocron`, six plugins enabled per project. Dogfooding stopped one layer short of the
install tooling kyberforge itself ships.
Implementation notes:
- Root apm.yml declares the six packages as dependencies.apm git+path objects against the
holocron remote. Object form over `<name>@holocron` aliases on purpose: an alias first needs
`apm marketplace add`, which writes to ~/.apm/marketplaces.json — user scope, absent on a fresh
clone. Unpinned against the default branch, matching the autoUpdate the native install had.
- apm.lock.yaml is committed; .claude/skills/, .claude/agents/ and apm_modules/ are gitignored
regenerable install output. Committing the deployed skills would add a third mirror of content
ADR-0017 already governs two copies of.
- .mcp.json is generated by apm from plugins/bin/.mcp.json, so the obsidian MCP server survives
the switch.
- .claude/settings.json is reduced to {"hooks": {}}. apm replays the install into a scratch tree
and diffs, so any repo-owned key there is permanent drift that fails apm-audit-ci. Nothing was
lost: enabledPlugins was empty after the uninstall and the only hooks entry was PreToolUse: [].
- tests/run-bats.sh and tests/run-tests.sh exclude apm_modules/. It holds a full copy of every
plugin, and a copied .bats file resolves its helpers against the dependency root rather than
this repo — 334 tests, 167 failures before the exclusion.
Impact:
Skills are now unnamespaced — `git-commits`, not `git:git-commits` — because apm deploys plain
project skills with no plugin to prefix. AGENTS.md, CONTEXT.md and docs/spec/architecture.md are
updated accordingly. Root apm.yml now declares dependencies, which arms apm-audit-ci's
lockfile-exists check for the root manifest. External consumers are unaffected: the marketplace
manifests are untouched and `apm pack --check-clean` stays clean. Project scope only.
ADR: 0018
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
256 lines
11 KiB
Bash
Executable File
256 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Run all test-*.sh files in the repo (including plugins) and the bats suite.
|
|
# Usage: bash tests/run-tests.sh [--bats-only] [--strict]
|
|
#
|
|
# A script exiting 77 (the automake convention) is reported as SKIPPED, not
|
|
# passed — a suite that can't run for lack of a binary must not read as green.
|
|
#
|
|
# --strict (or RUN_TESTS_STRICT=1) additionally makes any skip FAIL the run. Two
|
|
# different readings of a skip are both correct, and which one applies depends on
|
|
# who is running:
|
|
#
|
|
# * ad-hoc, on a laptop: skipping gracefully is the point. You are missing a
|
|
# dev binary, the other 15 suites still tell you something, and turning that
|
|
# into a red run would just train people to ignore red.
|
|
# * as a GATE (the run-tests pre-push hook): a skip is a SETUP ERROR, not a
|
|
# legitimate state. AGENTS.md documents vale, apm and jq as required pre-push
|
|
# dependencies, so a suite that cannot run on the machine doing the pushing
|
|
# means the machine is misconfigured -- and pre-commit prints NOTHING for a
|
|
# passing hook, so the skip list below is swallowed entirely. On a vale-less
|
|
# PATH that silently shipped a green gate having verified 15 of 17 suites.
|
|
# Exactly the vacuous-pass class the rest of this file exists to close.
|
|
#
|
|
# Deliberately its own switch, NOT folded into
|
|
# CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE. That one governs whether
|
|
# check-vale-style-sync may downgrade itself; this one governs whether the test
|
|
# dispatcher tolerates an unrunnable suite. They are separate decisions and one
|
|
# flag disarming both gates is how an opt-out quietly grows blast radius.
|
|
#
|
|
# TEST_DIR — override root to search for test-*.sh (default: REPO_ROOT); used by tests.
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
BATS="$REPO_ROOT/tests/run-bats.sh"
|
|
BATS_ONLY=false
|
|
STRICT=false
|
|
if [[ "${RUN_TESTS_STRICT:-}" == "1" ]]; then
|
|
STRICT=true
|
|
fi
|
|
# A loop rather than the `[[ "${1:-}" == --bats-only ]]` test this used to be, so
|
|
# the two flags compose and an unknown flag is rejected instead of ignored. A
|
|
# silently-ignored `--strict` is the one typo that would turn the gate back off.
|
|
for arg in ${@+"$@"}; do
|
|
case "$arg" in
|
|
--bats-only) BATS_ONLY=true ;;
|
|
--strict) STRICT=true ;;
|
|
*)
|
|
echo "Usage: $0 [--bats-only] [--strict]" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
SEARCH_ROOT="${TEST_DIR:-$REPO_ROOT}"
|
|
|
|
FAILED=()
|
|
SKIPPED=()
|
|
# Parallel array, index-matched to SKIPPED. Not an associative array: bash 3.2
|
|
# (macOS) has none, and tests/test-vale-wrap.sh's bash-3.2 scan rejects
|
|
# `declare -A` outright.
|
|
SKIP_REASONS=()
|
|
PASSED=0
|
|
SKIP_EXIT=77
|
|
|
|
# A missing or non-executable run-bats.sh is a hard error, never a silent skip.
|
|
# This was `if [[ -x "$BATS" ]]; then ... fi` with no else and no assertion that
|
|
# bats ran at all, so renaming, moving, or dropping the executable bit off
|
|
# run-bats.sh made the entire bats suite vanish with zero diagnostic and the run
|
|
# still printed "Summary: N passed, 0 failed" and exited 0 -- and --bats-only
|
|
# degraded to a no-op that printed nothing and exited 0. That is the same
|
|
# green-either-way hole run-bats.sh's own zero-count guard closes one level down;
|
|
# this closes it in the dispatcher that pre-push actually invokes.
|
|
#
|
|
# Present and executable is still not "it ran". `bash "$BATS"` on an EMPTY
|
|
# run-bats.sh exits 0 having printed nothing, and the dispatcher printed
|
|
# `=== bats ===`, a blank line, and a green summary -- the same green-either-way
|
|
# defect one spelling over. Truncation, a partial write, an editor saving an
|
|
# empty buffer, and a `set -e` abort in a future run-bats.sh preamble all land
|
|
# there. So the runner's own summary line is required, and its count must be
|
|
# non-zero: that line is run-bats.sh's contract with this script, and it is only
|
|
# emitted after run-bats.sh's own zero-count guard has passed.
|
|
#
|
|
# Stdout is captured (the summary is on stdout) while stderr passes straight
|
|
# through, so a failing runner's diagnostics still reach the terminal live. The
|
|
# capture costs no streaming that was not already lost: run-bats.sh buffers its
|
|
# per-file output and flushes it at the end regardless.
|
|
run_bats() {
|
|
if [[ ! -x "$BATS" ]]; then
|
|
echo "Error: bats runner not found or not executable at $BATS — the bats suite cannot be skipped silently" >&2
|
|
exit 1
|
|
fi
|
|
echo "=== bats ==="
|
|
local out rc=0 summary count
|
|
out="$(bash "$BATS")" || rc=$?
|
|
[[ -z "$out" ]] || printf '%s\n' "$out"
|
|
echo ""
|
|
if [[ $rc -ne 0 ]]; then
|
|
exit "$rc"
|
|
fi
|
|
summary="$(printf '%s\n' "$out" | grep -E '^[0-9]+ tests, [0-9]+ failures$' | tail -n 1 || true)"
|
|
if [[ -z "$summary" ]]; then
|
|
echo "Error: $BATS exited 0 without reporting an 'N tests, M failures' summary — it ran but produced nothing, so the bats suite was not verified" >&2
|
|
exit 1
|
|
fi
|
|
count="${summary%% *}"
|
|
if [[ "$count" -eq 0 ]]; then
|
|
echo "Error: $BATS reported 0 tests — the bats suite executed nothing" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
if $BATS_ONLY; then
|
|
run_bats
|
|
exit 0
|
|
fi
|
|
|
|
run_bats
|
|
|
|
# Collected with a `while read` loop rather than `mapfile` — macOS ships
|
|
# /bin/bash 3.2, which has no `mapfile`. Process substitution (not a pipe)
|
|
# keeps the loop in this shell so the appends survive. `sort` is still fed
|
|
# newline-delimited output, exactly as before.
|
|
#
|
|
# apm_modules/ is excluded for the same reason tests/run-bats.sh excludes it:
|
|
# `apm install` materializes dependency copies of this repo's own plugins there,
|
|
# and re-running a dependency's tests re-runs what plugins/ already covers.
|
|
SCRIPTS=()
|
|
while IFS= read -r script; do
|
|
SCRIPTS+=("$script")
|
|
done < <(
|
|
find "$SEARCH_ROOT" -name "test-*.sh" \
|
|
-not -path "*/.git/*" \
|
|
-not -path "*/.claude/worktrees/*" \
|
|
-not -path "*/apm_modules/*" \
|
|
| sort
|
|
)
|
|
|
|
# Each test-*.sh is independent (fixtures live under its own mktemp dir, none
|
|
# write back into the live repo tree -- verified before adding this), so they
|
|
# run concurrently in fixed-size batches instead of one at a time. Dispatch and
|
|
# throttling is scripts/lib/batch-run.sh's batch_run -- shared with
|
|
# scripts/sync-plugin-content.sh and tests/run-bats.sh so a batching bug fix
|
|
# only needs to land once; see that file for why this is batched rather than a
|
|
# rolling `wait -n` pool.
|
|
SCRATCH_ROOT="$(mktemp -d)"
|
|
trap 'rm -rf "$SCRATCH_ROOT"' EXIT
|
|
# The source= path below is repo-root-relative, matching scripts/install.sh:5 --
|
|
# NOT script-dir-relative. The source-path used to resolve it is the cwd
|
|
# pre-commit invokes the linter from, which is the repo root, so `lib/...`
|
|
# (resolving to a nonexistent tests/lib/) and `../scripts/...` (escaping the repo
|
|
# entirely) both fail. Both spellings were live until issue #97, and neither was
|
|
# visible: the resulting SC1091 is `info` while .pre-commit-config.yaml pins
|
|
# `--severity=warning`. A directive that does not resolve also blinds
|
|
# test-vale-wrap.sh's `sourced_files()` exemption, which reads these same
|
|
# directives to find array seeding that lives in the sourced file.
|
|
#
|
|
# Do not start a comment line here with the linter's name -- it is parsed as a
|
|
# directive and errors out (SC1073).
|
|
# shellcheck source=scripts/lib/batch-run.sh
|
|
source "$REPO_ROOT/scripts/lib/batch-run.sh"
|
|
|
|
declare -a batch_args=()
|
|
idx=0
|
|
for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do
|
|
idx=$((idx + 1))
|
|
cmd="$(printf 'rc=0; bash %q || rc=$?; echo "$rc" >%q' "$script" "$SCRATCH_ROOT/$idx.status")"
|
|
batch_args+=("$idx" "$cmd")
|
|
done
|
|
batch_run "$SCRATCH_ROOT" ${batch_args[@]+"${batch_args[@]}"}
|
|
|
|
idx=0
|
|
for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do
|
|
idx=$((idx + 1))
|
|
rel="${script#"$SEARCH_ROOT/"}"
|
|
echo "=== $rel ==="
|
|
cat "$SCRATCH_ROOT/$idx.log"
|
|
rc="$(cat "$SCRATCH_ROOT/$idx.status" 2>/dev/null || echo 1)"
|
|
# String comparison, not `-eq`. `-eq` is arithmetic, and bash evaluates an
|
|
# empty string as 0 there -- `[[ "" -eq 0 ]]` is true -- so an *empty* status
|
|
# file counted as a pass. The `|| echo 1` fallback above only covers a
|
|
# *missing* file; a file that exists but is empty is what you get when the job
|
|
# is killed between the `>` truncating it and the `echo` completing, or on
|
|
# ENOSPC. Under `==` an empty status falls through to FAILED, which is the only
|
|
# safe reading of "the job did not report a result".
|
|
if [[ "$rc" == "0" ]]; then
|
|
PASSED=$((PASSED + 1))
|
|
elif [[ "$rc" == "$SKIP_EXIT" ]]; then
|
|
SKIPPED+=("$rel")
|
|
# Capture WHY, not just that. The reason is printed by the suite itself and
|
|
# is otherwise swallowed with the rest of its log, which leaves the reader
|
|
# knowing something was skipped but not which binary to install. There is no
|
|
# single house format for it -- three suites print `SKIP: <reason>` on stdout
|
|
# and one prints `apm not installed -- skipping (...)` on stderr -- so this
|
|
# tries the shapes in decreasing order of confidence and falls back to the
|
|
# last thing the suite said before exiting 77, which for a guard that exits
|
|
# immediately is the reason by construction. batch-run.sh folds stderr into
|
|
# the same log, so the stderr spelling is reachable here.
|
|
reason="$(grep -E '^[[:space:]]*SKIP' "$SCRATCH_ROOT/$idx.log" 2>/dev/null | head -n 1 || true)"
|
|
if [[ -z "$reason" ]]; then
|
|
reason="$(grep -iE 'skip' "$SCRATCH_ROOT/$idx.log" 2>/dev/null | head -n 1 || true)"
|
|
fi
|
|
if [[ -z "$reason" ]]; then
|
|
reason="$(grep -vE '^[[:space:]]*$' "$SCRATCH_ROOT/$idx.log" 2>/dev/null | tail -n 1 || true)"
|
|
fi
|
|
if [[ -z "$reason" ]]; then
|
|
reason="(exited $SKIP_EXIT without printing a reason)"
|
|
fi
|
|
# Trimmed of leading whitespace so the reasons line up under their suite
|
|
# names regardless of how each suite indents its own message.
|
|
SKIP_REASONS+=("${reason#"${reason%%[![:space:]]*}"}")
|
|
else
|
|
FAILED+=("$rel")
|
|
fi
|
|
echo ""
|
|
done
|
|
|
|
echo "=== Summary: $PASSED passed, ${#SKIPPED[@]} skipped, ${#FAILED[@]} failed ==="
|
|
# Suppressed under --strict: the strict block below reports the same suites with
|
|
# the same reasons, and printing both left the reader scrolling past one list to
|
|
# reach an identical one. Under strict the failure block IS the list.
|
|
if [[ ${#SKIPPED[@]} -gt 0 && "$STRICT" != true ]]; then
|
|
echo "Skipped scripts:"
|
|
sidx=0
|
|
for s in ${SKIPPED[@]+"${SKIPPED[@]}"}; do
|
|
echo " $s"
|
|
echo " ${SKIP_REASONS[$sidx]}"
|
|
sidx=$((sidx + 1))
|
|
done
|
|
fi
|
|
|
|
RC=0
|
|
if [[ ${#FAILED[@]} -gt 0 ]]; then
|
|
echo "Failed scripts:"
|
|
for s in ${FAILED[@]+"${FAILED[@]}"}; do
|
|
echo " $s"
|
|
done
|
|
RC=1
|
|
fi
|
|
|
|
# Strict mode turns every skip into a failure. Reported separately from FAILED
|
|
# above rather than folded into it: a skipped suite did not fail, the machine
|
|
# did, and a message that says so points at the fix. Named with reasons again
|
|
# here (not just referenced) because this block goes to stderr and is what a
|
|
# pre-push reader actually gets handed.
|
|
if [[ "$STRICT" == true && ${#SKIPPED[@]} -gt 0 ]]; then
|
|
echo "Error: --strict and ${#SKIPPED[@]} suite(s) skipped. Run as a gate, a skip is a SETUP ERROR on this machine, not a legitimate state: AGENTS.md documents vale, apm and jq as required pre-push dependencies, so every suite is expected to be runnable here. Install what each suite names below and re-run; do not skip the hook." >&2
|
|
sidx=0
|
|
for s in ${SKIPPED[@]+"${SKIPPED[@]}"}; do
|
|
echo " $s" >&2
|
|
echo " ${SKIP_REASONS[$sidx]}" >&2
|
|
sidx=$((sidx + 1))
|
|
done
|
|
RC=1
|
|
fi
|
|
|
|
exit "$RC"
|