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
This commit is contained in:
2026-09-15 21:08:13 +00:00
parent 620f20b0fd
commit ffcbed6c41
13 changed files with 365 additions and 121 deletions

View File

@@ -112,7 +112,7 @@ 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 echo "$FAKE_OUT" | grep -q "no TAP output at all"; then
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"
@@ -134,9 +134,9 @@ 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 echo "$FAKE_OUT" | grep -q "declared 0 tests"; then
elif grep -q "declared 0 tests" <<< "$FAKE_OUT"; then
pass "a plan-only TAP stream fails the run and names the removed tests"
elif echo "$FAKE_OUT" | grep -q "no TAP output at all"; then
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"
@@ -160,7 +160,7 @@ 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 echo "$FAKE_OUT" | grep -q "^4 tests, 0 failures$"; then
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"
@@ -187,9 +187,9 @@ else
run_fake "$DIR4"
if [[ $FAKE_RC -ne 0 ]]; then
fail "an empty .bats file beside a real one failed the run: $FAKE_OUT"
elif ! echo "$FAKE_OUT" | grep -q "^1\.\.0$"; then
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 echo "$FAKE_OUT" | grep -q "^1 tests, 0 failures$"; then
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"
@@ -215,7 +215,7 @@ 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 echo "$FAKE_OUT" | grep -q "^2 tests, 2 failures$"; then
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"
@@ -242,7 +242,7 @@ 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 echo "$FAKE_OUT" | grep -q "^2 tests, 0 failures$"; then
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"
@@ -282,7 +282,7 @@ run_fake "$DIR7"
# 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 ! echo "$FAKE_OUT" | grep -q "^1 tests, 0 failures$"; then
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"
@@ -312,9 +312,9 @@ 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 ! echo "$FAKE_OUT" | grep -q "tracked .bats file(s) were not discovered"; then
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 echo "$FAKE_OUT" | grep -q "^ tests/b.bats$"; then
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"
@@ -339,7 +339,7 @@ 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 echo "$FAKE_OUT" | grep -q "^2 tests, 0 failures$"; then
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"
@@ -356,7 +356,7 @@ 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 echo "$FAKE_OUT" | grep -q "^ tests/b.bats$"; then
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"
@@ -380,9 +380,9 @@ EOF
run_fake "$DIR8D"
if [[ $FAKE_RC -ne 0 ]]; then
fail "a non-git tree failed the run: $FAKE_OUT"
elif ! echo "$FAKE_OUT" | grep -q "not a git worktree root"; then
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 echo "$FAKE_OUT" | grep -q "^2 tests, 0 failures$"; then
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"
@@ -404,7 +404,7 @@ 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 echo "$FAKE_OUT" | grep -q "found 0 .bats file"; then
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"