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 to4d018afandaa8cc22(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:
@@ -9,6 +9,13 @@ FAIL=0
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Output assertions are `grep -q PATTERN <<< "$OUT"`, never `echo "$OUT" | grep -q`.
|
||||
# Under pipefail the pipe form is scheduling-dependent: bash's echo writes a
|
||||
# multi-line value one line at a time, `grep -q` exits on its first match, and a
|
||||
# later line then hits a closed pipe. echo dies of SIGPIPE, pipefail reports the
|
||||
# pipeline as failed, and a correct output reads as a missing match. The
|
||||
# here-string has no writer process to race.
|
||||
|
||||
# Both entry shapes the real .pre-commit-hooks.yaml ships: a bare script with no
|
||||
# bundled data, and a bare script whose sibling assets/ tree it self-locates at
|
||||
# runtime. Neither carries arguments — pre-commit only rewrites entry[0] to the
|
||||
@@ -142,7 +149,7 @@ FIXTURE4="$(make_tagged_fixture)"; track "$FIXTURE4"
|
||||
echo "v2" > "$FIXTURE4/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE4" && git add -A && git commit -q -m "update release-relevant script")
|
||||
OUT4=$(run_check "$FIXTURE4" "refs/heads/main" || true)
|
||||
if echo "$OUT4" | grep -q "skill-size-check.sh"; then
|
||||
if grep -q "skill-size-check.sh" <<< "$OUT4"; then
|
||||
pass "exits non-zero and names the changed file when a release-relevant path changed since the tag"
|
||||
else
|
||||
fail "did not flag the release-relevant file that changed since the tag"
|
||||
@@ -167,7 +174,7 @@ FIXTURE6="$(make_tagged_fixture)"; track "$FIXTURE6"
|
||||
rm -f "$FIXTURE6/$HOOK_DIR/assets/vale/.vale.ini"
|
||||
(cd "$FIXTURE6" && git add -A && git commit -q -m "delete the bundled vale config")
|
||||
OUT6=$(run_check "$FIXTURE6" "refs/heads/main" || true)
|
||||
if echo "$OUT6" | grep -q "assets/vale/.vale.ini"; then
|
||||
if grep -q "assets/vale/.vale.ini" <<< "$OUT6"; then
|
||||
pass "flags a deleted release-relevant path instead of silently dropping it from the diff"
|
||||
else
|
||||
fail "did not flag deletion of a release-relevant path since the tag"
|
||||
@@ -196,7 +203,7 @@ echo "checkpoint" > "$FIXTURE8/scripts/skill-size-check.sh"
|
||||
echo "v2" > "$FIXTURE8/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE8" && git add -A && git commit -q -m "real release-relevant change")
|
||||
OUT8=$(run_check "$FIXTURE8" "refs/heads/main" || true)
|
||||
if echo "$OUT8" | grep -q "skill-size-check.sh"; then
|
||||
if grep -q "skill-size-check.sh" <<< "$OUT8"; then
|
||||
pass "still flags the release-relevant change since v1.0.0, ignoring the non-version checkpoint tag"
|
||||
else
|
||||
fail "an incidental non-version tag shifted the baseline and hid a real release-relevant change"
|
||||
@@ -225,7 +232,7 @@ FIXTURE10="$(make_tagged_fixture)"; track "$FIXTURE10"
|
||||
echo "rule: v2" > "$FIXTURE10/$HOOK_DIR/assets/vale/styles/Kyberforge/DemoRule.yml"
|
||||
(cd "$FIXTURE10" && git add -A && git commit -q -m "tighten a vale rule")
|
||||
OUT10=$(run_check "$FIXTURE10" "refs/heads/main" || true)
|
||||
if echo "$OUT10" | grep -q "assets/vale/styles/Kyberforge/DemoRule.yml"; then
|
||||
if grep -q "assets/vale/styles/Kyberforge/DemoRule.yml" <<< "$OUT10"; then
|
||||
pass "flags a change confined to a hook's bundled assets/vale/styles/ tree"
|
||||
else
|
||||
fail "a bundled Vale style rule changed since the tag without demanding a release"
|
||||
@@ -259,7 +266,7 @@ FIXTURE12="$(make_tagged_fixture)"; track "$FIXTURE12"
|
||||
rm -rf "${FIXTURE12:?}/$HOOK_DIR/assets"
|
||||
(cd "$FIXTURE12" && git add -A && git commit -q -m "delete the whole bundled assets tree")
|
||||
OUT12=$(run_check "$FIXTURE12" "refs/heads/main" || true)
|
||||
if echo "$OUT12" | grep -q "assets/vale/.vale.ini"; then
|
||||
if grep -q "assets/vale/.vale.ini" <<< "$OUT12"; then
|
||||
pass "flags a wholesale deletion of a hook's bundled assets/ tree"
|
||||
else
|
||||
fail "a hook's entire bundled assets/ tree vanished since the tag without demanding a release"
|
||||
@@ -276,7 +283,7 @@ FIXTURE13="$(make_tagged_fixture)"; track "$FIXTURE13"
|
||||
rm -f "$FIXTURE13/$HOOK_DIR/scripts/vale-wrap.sh"
|
||||
(cd "$FIXTURE13" && git add -A && git commit -q -m "delete a hook script, keep its manifest entry")
|
||||
OUT13=$(run_check "$FIXTURE13" "refs/heads/main" || true)
|
||||
if echo "$OUT13" | grep -q "vale-wrap.sh"; then
|
||||
if grep -q "vale-wrap.sh" <<< "$OUT13"; then
|
||||
pass "flags a hook script deleted out from under a surviving manifest entry"
|
||||
else
|
||||
fail "a manifest entry's script vanished since the tag without demanding a release"
|
||||
@@ -299,7 +306,7 @@ EOF
|
||||
rm -rf "${FIXTURE14:?}/$HOOK_DIR"
|
||||
(cd "$FIXTURE14" && git add -A && git commit -q -m "retire the vale hook entirely")
|
||||
OUT14=$(run_check "$FIXTURE14" "refs/heads/main" || true)
|
||||
if echo "$OUT14" | grep -q "vale-wrap.sh" && echo "$OUT14" | grep -q "assets/vale/.vale.ini"; then
|
||||
if grep -q "vale-wrap.sh" <<< "$OUT14" && grep -q "assets/vale/.vale.ini" <<< "$OUT14"; then
|
||||
pass "names the retired hook's script and bundled assets, not just the manifest edit"
|
||||
else
|
||||
fail "reported only the manifest change and hid which shipped paths the retirement removed"
|
||||
@@ -320,9 +327,9 @@ FIXTURE15="$(make_malformed_fixture "bash scripts/skill-size-check.sh")"; track
|
||||
OUT15=$(run_check "$FIXTURE15" "refs/heads/main" || true)
|
||||
if run_check "$FIXTURE15" "refs/heads/main" > /dev/null; then
|
||||
fail "silently exited 0 on a multi-token entry, dropping that hook's paths from the gate"
|
||||
elif echo "$OUT15" | grep -q "fake-size-check" \
|
||||
&& echo "$OUT15" | grep -q "bash scripts/skill-size-check.sh" \
|
||||
&& echo "$OUT15" | grep -q "ADR-0014"; then
|
||||
elif grep -q "fake-size-check" <<< "$OUT15" \
|
||||
&& grep -q "bash scripts/skill-size-check.sh" <<< "$OUT15" \
|
||||
&& grep -q "ADR-0014" <<< "$OUT15"; then
|
||||
pass "rejects a multi-token entry, quoting it back and naming the hook and ADR-0014"
|
||||
else
|
||||
fail "rejected the multi-token entry without naming the hook, the entry, and ADR-0014"
|
||||
@@ -339,7 +346,7 @@ FIXTURE16="$(make_malformed_fixture "vale")"; track "$FIXTURE16"
|
||||
OUT16=$(run_check "$FIXTURE16" "refs/heads/main" || true)
|
||||
if run_check "$FIXTURE16" "refs/heads/main" > /dev/null; then
|
||||
fail "silently exited 0 on an entry that names no shipped file"
|
||||
elif echo "$OUT16" | grep -q "fake-size-check" && echo "$OUT16" | grep -q "ADR-0014"; then
|
||||
elif grep -q "fake-size-check" <<< "$OUT16" && grep -q "ADR-0014" <<< "$OUT16"; then
|
||||
pass "rejects an entry that resolves to no file, naming the hook and the ADR-0014 constraint"
|
||||
else
|
||||
fail "rejected the unresolvable entry without naming the hook and the ADR-0014 constraint"
|
||||
@@ -358,7 +365,7 @@ echo "v2" > "$FIXTURE17/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE17" && git add -A && git commit -q -m "release-relevant change" \
|
||||
&& git branch pushed-tip && git reset -q --hard v1.0.0)
|
||||
OUT17=$(run_check "$FIXTURE17" "refs/heads/main" "pushed-tip" || true)
|
||||
if echo "$OUT17" | grep -q "skill-size-check.sh"; then
|
||||
if grep -q "skill-size-check.sh" <<< "$OUT17"; then
|
||||
pass "gates the pushed ref's tip, not HEAD, when HEAD is behind it"
|
||||
else
|
||||
fail "diffed HEAD instead of PRE_COMMIT_TO_REF and missed a release-relevant change"
|
||||
@@ -431,7 +438,7 @@ FIXTURE21="$(make_tagged_fixture)"; track "$FIXTURE21"
|
||||
echo "v2" > "$FIXTURE21/scripts/skill-size-check.sh"
|
||||
(cd "$FIXTURE21" && git add -A && git commit -q -m "real release-relevant change" && git tag v1.0.1-checkpoint)
|
||||
OUT21=$(run_check "$FIXTURE21" "refs/heads/main" || true)
|
||||
if echo "$OUT21" | grep -q "skill-size-check.sh"; then
|
||||
if grep -q "skill-size-check.sh" <<< "$OUT21"; then
|
||||
pass "still flags the release-relevant change since v1.0.0, ignoring the vX.Y.Z-checkpoint tag"
|
||||
else
|
||||
fail "a vX.Y.Z-checkpoint tag satisfied the glob and hid a real release-relevant change"
|
||||
|
||||
Reference in New Issue
Block a user