Files
holocron/tests/test-run-bats.sh
Defame1297 ffcbed6c41 fix(tests): replace pipefail-racy echo | grep -q with here-strings
Why

Two suites failed intermittently — tests/test-vale-wrap.sh case 21 and
tests/test-check-release-needed.sh cases 4 and 15 — on correct output, and never
when run alone. The cause is the `echo "$OUT" | grep -q P` idiom under
`set -o pipefail`: grep -q exits as soon as it has an answer, bash's echo can
hand a multi-line value to the pipe one line at a time, and a write after the
reader is gone kills echo with SIGPIPE. pipefail then reports the writer's
death, so output that DID match reads as "no match". Every observed failure had
lines after its match; case 15's match is on line 1 of 6, the widest window in
that file.

Forced with a pause before the writer's last line, the pipe form failed 50 of 50
runs; a here-string, a match on the last line, and the same pipe without
pipefail each passed 50 of 50. Unforced the rate is about 1 per 670 suite runs,
which is why it read as a flaky gate rather than a bug.

The failures at review time are consistent with this, but were not proven to be
it: the suite was running while agents edited live config files in place, and a
brief change to .vale.ini or .pre-commit-hooks.yaml would produce the same two
failures. The race is real and fixed either way.

Implementation Notes

`grep -q P <<< "$VAR"` has no separate writer process, so there is nothing to
race. It is not a retry or a sleep. 121 sites converted across 9 files, three of
them scripts rather than tests: new-agent.sh, new-skill.sh and
check-executables-allow-sync.sh. None ships via .pre-commit-hooks.yaml, so no
external consumer pins them, and all three are single-pipeline checks whose
verdict cannot change.

Left alone deliberately: 14 sites whose writer is a command, not a shell
builtin — they either absorb the writer's status with `|| true` or are python3
and awk, which write once at exit — and one file with no pipefail. `printf '%s'`
sites differ from a here-string only by a trailing newline, which no -q verdict
on a non-empty pattern depends on.

tests/test-no-pipefail-early-exit-grep.sh is a static guard against new
occurrences, discovered automatically by run-tests.sh. It only scans files that
set pipefail, joins continuation lines, skips comments, and flags only
echo/printf writers. Its first case proves the scanner can fail before its
second trusts a clean verdict on the tree.

A guard covers exactly the spellings its regex models, so the miss surface was
measured rather than assumed. Four were found and closed: pipefail declared as
`set -o errexit -o pipefail` (where the old pattern required pipefail to follow
the FIRST -o, and a file-level miss skips every site in that file); a writer
separated from grep by an intermediate stage; a pipeline wrapped on a trailing
`|` rather than a backslash; and readers spelled egrep, fgrep, /bin/grep,
`command grep` or with an env-var prefix. Segment characters exclude a bare `&`
so `echo ok && other | grep -q x`, whose writer is `other`, does not false-fire.
Widening surfaced 5 live sites invisible to the original scanner, all in
tests/test-apm-current-hook.sh, all `echo "$out" | json_field ... | grep -q`;
they are safe today only because json_field is python3, which reads to EOF and
writes once. Fixtures go 4 to 12 vulnerable spellings plus near-miss negatives.

Two `grep ... | head -1` sites (test-vale-wrap.sh) are the same race with a
different early-exiting reader, and are fixed by absorbing the writer. The
scanner deliberately does not model `head`, `sed -n 1p` or a bare `read`: most
legitimate uses in this tree are already absorbed with `|| true` and the scanner
cannot see absorption from pipeline text, so a high false-positive rate would be
how this guard gets weakened. Heredoc bodies are scanned as code; none in the
tree trips it today.

Impact

The bug predates the factory-audit merge: every converted site in
check-release-needed and case 21 dates to 4d018af and aa8cc22 (2026-08-09).

Test suites go 19 to 20. `run-tests.sh --strict` passes 20/20 with 0 skipped,
four consecutive runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YR2CjVumUbEGWcMikcoXBD
2026-09-16 09:14:01 +00:00

416 lines
17 KiB
Bash

#!/usr/bin/env bash
# Regression test for tests/run-bats.sh's TAP-result accounting.
#
# run-bats.sh runs each .bats file in its own process and aggregates the TAP
# streams. It used to derive its test count from that text without ever asserting
# the count was non-zero, so a `bats` that produced no output and exited 0 was
# reported as "0 tests, 0 failures" with exit 0 -- a total harness failure
# rendered as a clean pass. The guard added for that distinguishes two causes,
# because `1..0` is itself valid TAP: no plan lines at all means bats emitted
# nothing to parse, while plans present with zero results means bats ran fine and
# the files genuinely declare no tests.
#
# Both branches were code-only and asserted by nothing, which is the same
# "green either way" hole the guard itself closes. This file covers them.
#
# It also covers the aggregation those counts feed, which the parallelization
# rewrite left unasserted: three separate mutations of the `if [[ "$file_not_ok"
# -gt 0 || "$status" != "0" ]]` line -- dropping either half, or deleting the
# whole branch -- all survived this file. They survived because every stub here
# exited 0 and emitted no `not ok`, so neither signal was ever exercised, and
# because real bats emits both at once each half masks the other. The cases
# below produce each signal *without* the other, so each mutation dies alone.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUN_BATS="$REPO_ROOT/tests/run-bats.sh"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
FIXTURES=()
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
trap cleanup EXIT
# Builds a throwaway tree that a copy of run-bats.sh will resolve as its own
# REPO_ROOT (it derives that from its own location), so these cases drive the
# real script without the repo's actual .bats files being involved. The fixtures
# live under TMPDIR, never inside the repo, so the real suite cannot pick them up.
#
# This prints the directory and does NOT register it for cleanup: every caller
# invokes it as `$(make_fake_repo)`, and an append made in here would land in the
# command substitution's subshell and be lost. Registration is the caller's job.
make_fake_repo() {
local dir
dir="$(mktemp -d)"
mkdir -p "$dir/tests" "$dir/scripts/lib"
cp "$REPO_ROOT/scripts/lib/batch-run.sh" "$dir/scripts/lib/batch-run.sh"
cp "$RUN_BATS" "$dir/tests/run-bats.sh"
echo "$dir"
}
# Writes a stub `bats` from stdin. Executable, so run-bats.sh never falls through
# to its submodule-init branch.
install_stub_bats() {
mkdir -p "$1/tests/bats/bin"
cat > "$1/tests/bats/bin/bats"
chmod +x "$1/tests/bats/bin/bats"
}
# Two .bats files whose tests would fail if anything actually ran them. Their
# content is irrelevant to the stub cases -- what matters is that files exist, so
# run-bats.sh gets past its "no .bats test files found" early exit and the zero
# count it then sees can only have come from the TAP stream.
seed_bats_files() {
printf '@test "a" { false; }\n' > "$1/tests/a.bats"
printf '@test "b" { false; }\n' > "$1/tests/b.bats"
}
# Runs the fixture's run-bats.sh, capturing output and exit code separately.
#
# No file-count knob is passed any more, and none is needed: the expected file
# set is derived from `git ls-files`, and a mktemp fixture is not a git worktree
# root, so run-bats.sh announces that it could not derive an expectation and
# falls back to the unconditional zero-file check. Cases 8-8c drive the derived
# path deliberately by `git init`-ing their fixtures.
#
# TMPDIR is a private per-run directory so a stub can find the scratch dir
# run-bats.sh mktemp'd for itself -- see case 7.
FAKE_OUT=""
FAKE_RC=0
run_fake() {
local priv
priv="$(mktemp -d)"
FIXTURES+=("$priv")
FAKE_RC=0
FAKE_OUT="$(TMPDIR="$priv" bash "$1/tests/run-bats.sh" 2>&1)" || FAKE_RC=$?
}
# A fixture whose root IS a git worktree root, so run-bats.sh derives its
# expected set from the index instead of degrading. Only `git add` is used --
# `git ls-files` reads the index, so nothing needs committing and no user
# identity is required.
make_git_fake_repo() {
local dir
dir="$(make_fake_repo)"
git -C "$dir" init -q
echo "$dir"
}
# --- 1. A stub emitting nothing at all is a broken harness, not a clean run ---
echo ""
echo "--- a bats that emits no TAP output at all fails the run ---"
DIR1="$(make_fake_repo)"
FIXTURES+=("$DIR1")
seed_bats_files "$DIR1"
install_stub_bats "$DIR1" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
run_fake "$DIR1"
if [[ $FAKE_RC -eq 0 ]]; then
fail "a bats emitting nothing exited 0 — a total harness failure reported as a pass"
elif grep -q "no TAP output at all" <<< "$FAKE_OUT"; then
pass "an empty TAP stream fails the run and names the broken harness"
else
fail "the run failed but not with the broken-harness message: $FAKE_OUT"
fi
# --- 2. A stub emitting only a plan ran fine but declares no tests ---
# This is what real bats produces for a .bats file with every @test removed, so
# it must fail for a different, accurately-worded reason than case 1.
echo ""
echo "--- a bats emitting only a zero plan fails with the no-tests-declared message ---"
DIR2="$(make_fake_repo)"
FIXTURES+=("$DIR2")
seed_bats_files "$DIR2"
install_stub_bats "$DIR2" <<'EOF'
#!/usr/bin/env bash
echo "1..0"
exit 0
EOF
run_fake "$DIR2"
if [[ $FAKE_RC -eq 0 ]]; then
fail "every test declaring zero tests exited 0 — wholesale @test removal reported as a pass"
elif grep -q "declared 0 tests" <<< "$FAKE_OUT"; then
pass "a plan-only TAP stream fails the run and names the removed tests"
elif grep -q "no TAP output at all" <<< "$FAKE_OUT"; then
fail "a valid '1..0' plan was misreported as a broken harness — the two branches are not distinguished"
else
fail "the run failed but not with the no-tests-declared message: $FAKE_OUT"
fi
# --- 3. A healthy TAP stream still passes and still counts correctly. The guard
# must not turn into a blanket failure: this is the case that proves the two
# above fail for their stated reason rather than because the guard fails always.
echo ""
echo "--- a healthy TAP stream passes with its full count ---"
DIR3="$(make_fake_repo)"
FIXTURES+=("$DIR3")
seed_bats_files "$DIR3"
install_stub_bats "$DIR3" <<'EOF'
#!/usr/bin/env bash
echo "1..2"
echo "ok 1 first"
echo "ok 2 second"
exit 0
EOF
run_fake "$DIR3"
if [[ $FAKE_RC -ne 0 ]]; then
fail "a healthy TAP stream was failed by the zero-count guard: $FAKE_OUT"
elif grep -q "^4 tests, 0 failures$" <<< "$FAKE_OUT"; then
pass "two files reporting two passing tests each aggregate to 4 tests, 0 failures"
else
fail "a healthy TAP stream produced the wrong count: $FAKE_OUT"
fi
# --- 4. The same shapes out of the real bats binary. The stubs above encode an
# assumption about what real bats emits; this pins that assumption. A genuinely
# empty .bats file yields `1..0` and exit 0, so a suite holding one alongside a
# real test file must still pass -- the zero-count guard fires on the aggregate,
# not per file, and one declared test is enough to clear it.
echo ""
echo "--- an empty .bats file beside a real one still passes under the real bats ---"
if [[ ! -x "$REPO_ROOT/tests/bats/bin/bats" ]]; then
echo " SKIP: real bats is not initialized — run tests/run-bats.sh once to fetch the submodule"
else
DIR4="$(make_fake_repo)"
FIXTURES+=("$DIR4")
# Symlinked rather than copied: bats resolves its libexec relative to its own
# path, so the tree has to stay intact. run-bats.sh excludes */tests/bats/* from
# its own file search, so bats's bundled .bats suites are not collected here.
ln -s "$REPO_ROOT/tests/bats" "$DIR4/tests/bats"
: > "$DIR4/tests/empty.bats"
printf '@test "a real passing test" { true; }\n' > "$DIR4/tests/real.bats"
run_fake "$DIR4"
if [[ $FAKE_RC -ne 0 ]]; then
fail "an empty .bats file beside a real one failed the run: $FAKE_OUT"
elif ! grep -q "^1\.\.0$" <<< "$FAKE_OUT"; then
fail "real bats did not emit '1..0' for an empty file, so the case-2 stub no longer matches it: $FAKE_OUT"
elif grep -q "^1 tests, 0 failures$" <<< "$FAKE_OUT"; then
pass "an empty .bats file contributes a '1..0' plan and the suite still passes"
else
fail "the real-bats run passed with an unexpected count: $FAKE_OUT"
fi
fi
# --- 5. `not ok` lines with a zero exit still fail the run. This is the half of
# the aggregation that a bats wrapper swallowing the exit status would leave as
# the only surviving evidence of a failure, and it is the case that kills the
# `drop "$file_not_ok" -gt 0 ||` mutation: without that half the run reports
# "2 tests, 1 failures" and exits 0, calling a failing test suite green.
echo ""
echo "--- a failing test whose process still exits 0 fails the run ---"
DIR5="$(make_fake_repo)"
FIXTURES+=("$DIR5")
seed_bats_files "$DIR5"
install_stub_bats "$DIR5" <<'EOF'
#!/usr/bin/env bash
echo "1..1"
echo "not ok 1 a failing test"
exit 0
EOF
run_fake "$DIR5"
if [[ $FAKE_RC -eq 0 ]]; then
fail "a 'not ok' TAP result exited 0 — a failing test reported as a pass because only the process status was consulted"
elif grep -q "^2 tests, 2 failures$" <<< "$FAKE_OUT"; then
pass "'not ok' lines fail the run even when every bats process exits 0"
else
fail "the run failed but with the wrong count: $FAKE_OUT"
fi
# --- 6. A non-zero exit with no `not ok` line still fails the run. This is the
# other half: a crash, a timeout, or an unbound variable in setup_file kills bats
# before it can emit a result line, so the exit status is the only evidence. It
# kills the `drop || "$status" -ne 0` mutation. The stub emits a passing result
# first so the zero-count guard cannot be what fails the run -- without the
# status half this stub reports "2 tests, 0 failures" and exits 0.
echo ""
echo "--- a bats exiting non-zero with no 'not ok' line fails the run ---"
DIR6="$(make_fake_repo)"
FIXTURES+=("$DIR6")
seed_bats_files "$DIR6"
install_stub_bats "$DIR6" <<'EOF'
#!/usr/bin/env bash
echo "1..2"
echo "ok 1 first"
echo "bats: setup_file failed" >&2
exit 1
EOF
run_fake "$DIR6"
if [[ $FAKE_RC -eq 0 ]]; then
fail "a bats process exiting 1 was reported as a pass because only the TAP text was consulted"
elif grep -q "^2 tests, 0 failures$" <<< "$FAKE_OUT"; then
pass "a non-zero bats exit fails the run even with no 'not ok' line to find"
else
fail "the run failed but with the wrong count: $FAKE_OUT"
fi
# --- 7. An empty status file is a failure, not a pass. The status is read back
# with `cat ... || echo 1`, which covers a *missing* file; a file that exists but
# is empty is what a job killed between the `>` truncating it and the `echo`
# completing leaves behind, and what ENOSPC leaves behind. Under the arithmetic
# `-ne` that used to compare it, `[[ "" -ne 0 ]]` is false and the job read as a
# clean exit.
#
# The stub reproduces that state exactly: it emits a healthy TAP stream, creates
# the empty status file itself, then SIGKILLs the subshell that would have
# written the real status. It finds the scratch directory through the private
# TMPDIR run_fake sets -- run-bats.sh mktemp -d's under it, and the log file for
# job 1 is already open by the time the stub runs.
echo ""
echo "--- an empty status file fails the run rather than counting as exit 0 ---"
DIR7="$(make_fake_repo)"
FIXTURES+=("$DIR7")
printf '@test "a" { false; }\n' > "$DIR7/tests/a.bats"
install_stub_bats "$DIR7" <<'EOF'
#!/usr/bin/env bash
echo "1..1"
echo "ok 1 looked fine"
for d in "$TMPDIR"/*/; do
if [[ -e "$d/1.log" ]]; then
: > "$d/1.status"
fi
done
kill -9 $PPID
sleep 5
EOF
run_fake "$DIR7"
# The count line is asserted alongside the exit code so this can only pass for
# the stated reason: the TAP stream the stub emitted is healthy, so "1 tests, 0
# failures" proves the zero-count guard did not fire and the empty status is the
# only thing left that can have failed the run.
if ! grep -q "^1 tests, 0 failures$" <<< "$FAKE_OUT"; then
fail "the killed job did not leave the healthy TAP stream the case needs: $FAKE_OUT"
elif [[ $FAKE_RC -eq 0 ]]; then
fail "an empty status file was counted as a clean exit — a killed job reported as a pass"
else
pass "an empty status file fails the run despite a healthy TAP stream"
fi
# --- 8. A tracked .bats file the walk did not discover is a hard error. This
# replaces a `BATS_FILE_FLOOR=8` guess against a real count of 10 -- two files of
# slack, which is not hypothetical: deleting two real .bats files left the suite
# reporting "155 tests, 0 failures" and exiting 0 with 11 tests silently gone.
# The expectation is now derived from `git ls-files`, so it is exact and needs no
# magic number.
echo ""
echo "--- a tracked .bats file missing from the walk fails the run and names it ---"
DIR8="$(make_git_fake_repo)"
FIXTURES+=("$DIR8")
seed_bats_files "$DIR8"
install_stub_bats "$DIR8" <<'EOF'
#!/usr/bin/env bash
echo "1..1"
echo "ok 1 first"
exit 0
EOF
git -C "$DIR8" add tests/a.bats tests/b.bats
rm "$DIR8/tests/b.bats"
run_fake "$DIR8"
if [[ $FAKE_RC -eq 0 ]]; then
fail "a tracked .bats file gone from the worktree passed — a deleted suite reads as green"
elif ! grep -q "tracked .bats file(s) were not discovered" <<< "$FAKE_OUT"; then
fail "the run failed but not with the undiscovered-tracked-file message: $FAKE_OUT"
elif grep -q "^ tests/b.bats$" <<< "$FAKE_OUT"; then
pass "a tracked .bats file missing from the walk fails the run and names the file"
else
fail "the run failed without naming the missing file: $FAKE_OUT"
fi
# --- 8b. An UNTRACKED .bats file is not a finding. The derived expectation runs
# one way only: every tracked file must have been discovered, but a discovered
# file need not be tracked. Without this the check would fail on ordinary
# not-yet-committed work, which is how a correct guard gets disabled.
echo ""
echo "--- an untracked new .bats file does not fail the run ---"
DIR8B="$(make_git_fake_repo)"
FIXTURES+=("$DIR8B")
seed_bats_files "$DIR8B"
install_stub_bats "$DIR8B" <<'EOF'
#!/usr/bin/env bash
echo "1..1"
echo "ok 1 first"
exit 0
EOF
git -C "$DIR8B" add tests/a.bats
run_fake "$DIR8B"
if [[ $FAKE_RC -ne 0 ]]; then
fail "an untracked .bats file was reported as a finding: $FAKE_OUT"
elif grep -q "^2 tests, 0 failures$" <<< "$FAKE_OUT"; then
pass "an untracked .bats file is run without being demanded of the index"
else
fail "the untracked-file run passed with the wrong count: $FAKE_OUT"
fi
# --- 8c. A newly added .bats file joins the expectation immediately. This is the
# half a floor can never have: adding files only ever widens a floor's slack,
# while `git add` alone makes the new file required from the next run on, with no
# edit to this script and no number to bump.
echo ""
echo "--- a newly git-added .bats file is required from the next run on ---"
git -C "$DIR8B" add tests/b.bats
rm "$DIR8B/tests/b.bats"
run_fake "$DIR8B"
if [[ $FAKE_RC -eq 0 ]]; then
fail "the .bats file added to the index a moment ago was not demanded back: $FAKE_OUT"
elif grep -q "^ tests/b.bats$" <<< "$FAKE_OUT"; then
pass "a file added to the index joins the expected set with no floor to bump"
else
fail "the run failed but did not name the newly tracked file: $FAKE_OUT"
fi
# --- 8d. Outside a git worktree the run still works, and says the expectation
# could not be derived. That degradation is what every other fixture here relies
# on, and it must be announced rather than silent -- an unannounced fallback is
# how a derived check quietly becomes no check at all on a tarball export.
echo ""
echo "--- a non-git tree runs, and announces that no expectation could be derived ---"
DIR8D="$(make_fake_repo)"
FIXTURES+=("$DIR8D")
seed_bats_files "$DIR8D"
install_stub_bats "$DIR8D" <<'EOF'
#!/usr/bin/env bash
echo "1..1"
echo "ok 1 first"
exit 0
EOF
run_fake "$DIR8D"
if [[ $FAKE_RC -ne 0 ]]; then
fail "a non-git tree failed the run: $FAKE_OUT"
elif ! grep -q "not a git worktree root" <<< "$FAKE_OUT"; then
fail "a non-git tree silently skipped the derived expectation with no note: $FAKE_OUT"
elif grep -q "^2 tests, 0 failures$" <<< "$FAKE_OUT"; then
pass "a non-git tree runs the suite and says the expected set could not be derived"
else
fail "the non-git run passed with the wrong count: $FAKE_OUT"
fi
# --- 9. Zero discovered .bats files is a hard error regardless, and is checked
# unconditionally rather than through the derived set: a tree with nothing
# tracked at all (a tarball export, a fresh scaffold) must still not run on an
# empty set and call it green. It is the state the old `exit 0` branch handled by
# name, and the one a path change actually produces.
echo ""
echo "--- zero discovered .bats files fails the run ---"
DIR9="$(make_fake_repo)"
FIXTURES+=("$DIR9")
install_stub_bats "$DIR9" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
run_fake "$DIR9"
if [[ $FAKE_RC -eq 0 ]]; then
fail "finding no .bats files at all exited 0 — the whole suite can vanish and the run stays green"
elif grep -q "found 0 .bats file" <<< "$FAKE_OUT"; then
pass "finding no .bats files fails the run and says so"
else
fail "the run failed but not with the zero-files message: $FAKE_OUT"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]