Files
holocron/tests/run-tests.sh
Defame1297 d42f6368fe fix(docs): correct claims the first fix round asserted without measuring
Why: two blind verifiers re-ran the five preceding commits and found four
defects of the same class this branch exists to close -- a confidently stated
measured claim that does not survive re-measurement -- this time inside the
fixes themselves.

- AGENTS.md:41 still carried both phrasings c68e864 reports having corrected.
  `grep -rn repo-defined` returned exactly one hit repo-wide: that line, in the
  file every session preloads. 4d336bb edited the line directly above it.
- ADR-0021 asserted twice, in the section justifying that no gate is added,
  that the ADR-0020 validators "never open an apm.yml". All three open and
  yaml.safe_load it (skill-size-check.sh:342, both validate.sh). The conclusion
  survives -- none reads the description: key, and their globs are SKILL.md and
  *.agent.md only -- but the stated mechanism is falsified by one grep.
- architecture.md said the ADR directory holds 20 numbered ADRs; c7ba3d2 made
  it 21, and c68e864 audited that file for exactly this class of stale count.
  The number is dropped rather than corrected: `ls docs/adr/` is already the
  index, so a count in prose is a second thing to maintain.
- gates.md's new three-verdict table said `-> name` promotes an unresolved
  target to ERROR. Reproduced with fixtures: NAME_HYPH (skill-size-check.sh:543)
  requires a hyphen, so `-> gitea-prs` is checked and `-> triage` is not
  extracted at all, and the unicode arrow is never recognised. The SUGGESTION
  text advises that spelling, so taking its advice can silence the finding. The
  gap is now documented as a defect; nothing covers it, since the one arrow case
  in test-adr0020-targets.sh happens to use a hyphenated target.

Implementation notes:
- AGENTS.md:48's coverage claim is shrunk rather than chased. Restoring six
  glossary entries did not make it true: 12 more sampled terms are undefined,
  three of them (trigger/capability/boundary clause) used inside CONTEXT.md
  itself. It now says CONTEXT.md is the glossary and is not exhaustive.
- CONTEXT.md's output profile and near-miss entries are corrected against their
  sources. The first stated a false exclusion -- .github/plugin/plugin.json IS
  apm-generated; only the marketplace mirror has no profile. The second
  inverted its source's referent: description-quality.md defines a near-miss as
  a query, not a sibling skill.
- The strict-mode message named jq, which no suite guards on (`command -v jq`
  appears nowhere in tests/), while omitting python3/PyYAML, which three do.
- README's git and gitea bullets now name git-workflow and gitea-workflow.
  ADR-0021 leaves README the only inventory and architecture.md now points at
  it, so the two bullets that were short had to be completed.
- ADR-0018's 2026-08-14 correction is marked superseded in place. It asserted
  machine state in the present tense that its own 2026-08-17 note retracts.
- ADR-0021's remaining errors: six files -> four (measured from de84d1b), the
  wiki description's length 114 -> 96 chars, the codex self-contradiction, the
  cost argument overstating bumps already owed for any skill addition, and two
  claims about files this branch went on to edit.
- The "15 of 17 suites" figure is restored where I had removed it: it is a dated
  record of one incident, not a live count, and four sites now describe it the
  same way.

Impact: 16/16 pre-push hooks pass, suite 24 passed 0 skipped 0 failed. No
behaviour change; every edit is prose or a comment.

Refs: #105

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TmFqzpuExLJv3m114XVE9w
2026-08-17 13:18:55 +00:00

274 lines
12 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. README.md's Prerequisites table documents vale, apm and
# python3/PyYAML -- the dependencies these suites actually guard on -- as
# required pre-push, 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 once silently shipped a
# green gate having verified 15 of the 17 suites that existed then.
# 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
# Latched, then REMOVED from the environment. The value has done its only job by
# this line -- it is now held in the STRICT shell local -- and leaving it exported
# makes strictness leak down the whole process tree: every test-*.sh dispatched
# through batch_run below inherits it, and any of them that itself invokes
# run-tests.sh (tests/test-run-tests.sh drives a copy of this script over fixture
# trees) silently turns a deliberately non-strict fixture strict.
#
# That is not symmetric with `--strict`, which never leaked: the flag only ever
# sets the shell local above, so `bash tests/run-tests.sh --strict` (the spelling
# the run-tests pre-push hook uses) always gave children a clean environment. Only
# the env-var spelling leaked, and it broke exactly two assertions in
# tests/test-run-tests.sh -- its cases 10c and 10g. Unsetting here makes the two
# documented invocations equivalent in what a CHILD sees, not just in the parent's
# verdict, so no future suite has to defend itself the way test-run-tests.sh's
# run_fake() does with `env -u`.
unset RUN_TESTS_STRICT
# 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: README.md's Prerequisites table documents vale, apm and python3/PyYAML — what these suites guard on — 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"