152 lines
5.8 KiB
Bash
152 lines
5.8 KiB
Bash
|
|
#!/usr/bin/env bash
|
||
|
|
#
|
||
|
|
# Prove a guard fails before you believe it passes.
|
||
|
|
#
|
||
|
|
# ## The failure this catches
|
||
|
|
#
|
||
|
|
# A guard that cannot fail is worse than no guard, because it is trusted.
|
||
|
|
# `docs/architecture/GUARDS.md` opens with that sentence and its first rule is
|
||
|
|
# this procedure, written out as a manual recipe: back the file up, break exactly
|
||
|
|
# the thing the guard protects, run the guard, expect one failure, restore.
|
||
|
|
#
|
||
|
|
# The recipe is thirty seconds and it is skipped anyway, for two reasons this
|
||
|
|
# script removes:
|
||
|
|
#
|
||
|
|
# - **Restoring is a step you can forget**, and forgetting is silent. The tests
|
||
|
|
# pass again once the mutation is undone in your head but not on disk, so the
|
||
|
|
# reverted code ships looking green. Here the restore is a `trap`, which runs
|
||
|
|
# on success, on failure, and on Ctrl-C.
|
||
|
|
# - **Counting the failures is the part people skip.** GUARDS.md §1: "If
|
||
|
|
# breaking the guard's target fails three tests, two of them are coincidental
|
||
|
|
# and will mask a real regression later." A human doing this by hand sees red
|
||
|
|
# and stops reading.
|
||
|
|
#
|
||
|
|
# ## Usage
|
||
|
|
#
|
||
|
|
# bash scripts/prove-guard.sh <file> <find> <replace> <test command…>
|
||
|
|
#
|
||
|
|
# bash scripts/prove-guard.sh src/lib/thing.ts \
|
||
|
|
# 'if (body.error)' 'if (false)' \
|
||
|
|
# npx vitest run tests/thing.test.ts
|
||
|
|
#
|
||
|
|
# Everything after the third argument is the command that runs the guard, so any
|
||
|
|
# runner works. `$PROVE_GUARD_CMD` is used when no command is given.
|
||
|
|
#
|
||
|
|
# ## Counting the failures
|
||
|
|
#
|
||
|
|
# "Exactly one" is a claim about test *cases*, and counting matching log lines
|
||
|
|
# does not measure that: Gradle reports a single failing test on six lines — the
|
||
|
|
# task, the test, its assertion, the summary, and twice more for the build — and
|
||
|
|
# a naive count calls that six coincidental failures. Tried that first; it fired
|
||
|
|
# on the very first run against a guard that was behaving perfectly.
|
||
|
|
#
|
||
|
|
# So the summary line is preferred, because almost every runner prints one and it
|
||
|
|
# is the runner's own count: `1 failed` from vitest, `6 tests completed, 1 failed`
|
||
|
|
# from Gradle, `1 failed, 5 passed` from pytest. The **last** such line wins, and
|
||
|
|
# only if none is found does it fall back to counting lines matching
|
||
|
|
# `$PROVE_GUARD_FAIL_PATTERN` — saying so, because an approximate count presented
|
||
|
|
# as an exact one is the kind of thing this script exists to object to.
|
||
|
|
#
|
||
|
|
# ## Exit codes
|
||
|
|
#
|
||
|
|
# 0 the guard caught it, and nothing else did — the outcome you want
|
||
|
|
# 1 the guard stayed GREEN with its target broken. It is not testing what you
|
||
|
|
# think it is, and you have just learned that for the price of one edit
|
||
|
|
# 2 nothing was proven: bad arguments, missing file, or a find-string that is
|
||
|
|
# absent or ambiguous. **Two is not a pass**
|
||
|
|
# 3 the guard caught it, but so did something else. Red for more than one
|
||
|
|
# reason hides the next regression behind a failure you have learned to
|
||
|
|
# expect — narrow the guard, or the mutation
|
||
|
|
#
|
||
|
|
# The file is restored in every one of those cases.
|
||
|
|
set -euo pipefail
|
||
|
|
|
||
|
|
FAIL_PATTERN="${PROVE_GUARD_FAIL_PATTERN:-(FAIL|✗|[0-9]+ (tests? )?failed|FAILED|AssertionError)}"
|
||
|
|
|
||
|
|
if [ "$#" -lt 3 ]; then
|
||
|
|
sed -n '2,30p' "$0" >&2
|
||
|
|
exit 2
|
||
|
|
fi
|
||
|
|
|
||
|
|
FILE="$1"; FIND="$2"; REPLACE="$3"; shift 3
|
||
|
|
|
||
|
|
if [ "$#" -gt 0 ]; then
|
||
|
|
CMD=("$@")
|
||
|
|
elif [ -n "${PROVE_GUARD_CMD:-}" ]; then
|
||
|
|
# shellcheck disable=SC2206
|
||
|
|
CMD=($PROVE_GUARD_CMD)
|
||
|
|
else
|
||
|
|
echo "prove-guard: no test command given and PROVE_GUARD_CMD is unset." >&2
|
||
|
|
echo "Nothing was proven, which is not the same as nothing being wrong." >&2
|
||
|
|
exit 2
|
||
|
|
fi
|
||
|
|
|
||
|
|
[ -f "$FILE" ] || { echo "prove-guard: no such file: $FILE" >&2; exit 2; }
|
||
|
|
|
||
|
|
BACKUP="$(mktemp)"
|
||
|
|
cp "$FILE" "$BACKUP"
|
||
|
|
restore() {
|
||
|
|
cp "$BACKUP" "$FILE"
|
||
|
|
rm -f "$BACKUP"
|
||
|
|
echo "prove-guard: restored $FILE"
|
||
|
|
}
|
||
|
|
trap restore EXIT INT TERM
|
||
|
|
|
||
|
|
# Exact-string replacement, and it must be unique. A mutation that lands in two
|
||
|
|
# places proves nothing about either, and a regex here would make the mutation
|
||
|
|
# itself the thing to debug.
|
||
|
|
python3 - "$FILE" "$FIND" "$REPLACE" <<'PY'
|
||
|
|
import sys
|
||
|
|
path, find, replace = sys.argv[1], sys.argv[2], sys.argv[3]
|
||
|
|
text = open(path, encoding="utf-8").read()
|
||
|
|
count = text.count(find)
|
||
|
|
if count == 0:
|
||
|
|
sys.exit(f"prove-guard: the string to break is not in {path}")
|
||
|
|
if count > 1:
|
||
|
|
sys.exit(
|
||
|
|
f"prove-guard: {count} occurrences of that string; a mutation in "
|
||
|
|
"two places proves neither. Pick a longer, unique one."
|
||
|
|
)
|
||
|
|
open(path, "w", encoding="utf-8").write(text.replace(find, replace))
|
||
|
|
PY
|
||
|
|
|
||
|
|
LOG="$(mktemp)"
|
||
|
|
trap 'restore; rm -f "$LOG"' EXIT INT TERM
|
||
|
|
|
||
|
|
echo "prove-guard: broke $FILE — expecting '${CMD[*]}' to go red"
|
||
|
|
echo
|
||
|
|
|
||
|
|
if "${CMD[@]}" >"$LOG" 2>&1; then
|
||
|
|
echo "prove-guard: FAILED — the guard stayed GREEN with its target broken." >&2
|
||
|
|
echo >&2
|
||
|
|
echo "It is not checking what you think. Either the assertion does not reach" >&2
|
||
|
|
echo "the mutated code, or it would pass without it. Log: $LOG" >&2
|
||
|
|
tail -20 "$LOG" >&2
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
|
||
|
|
echo "--- what failed ---"
|
||
|
|
grep -E "$FAIL_PATTERN" "$LOG" | head -12 || true
|
||
|
|
echo
|
||
|
|
|
||
|
|
# The runner's own count, from the last summary line that states one. Preferred
|
||
|
|
# over counting log lines for the reason in the header: one failing test is
|
||
|
|
# routinely reported on half a dozen lines.
|
||
|
|
COUNT="$(grep -oiE '[0-9]+ (tests? )?failed' "$LOG" | tail -1 | grep -oE '^[0-9]+' || true)"
|
||
|
|
COUNTED_BY="the runner's summary"
|
||
|
|
if [ -z "$COUNT" ]; then
|
||
|
|
COUNT="$(grep -cE "$FAIL_PATTERN" "$LOG" || true)"
|
||
|
|
COUNTED_BY="matching log lines, approximately — this runner printed no summary"
|
||
|
|
fi
|
||
|
|
|
||
|
|
if [ "$COUNT" -gt 1 ]; then
|
||
|
|
echo "prove-guard: the guard caught it — but $COUNT failures, by $COUNTED_BY."
|
||
|
|
echo
|
||
|
|
echo "GUARDS.md §1: if breaking one thing fails three tests, two are"
|
||
|
|
echo "coincidental and will mask a real regression later behind a red you have"
|
||
|
|
echo "learned to expect. Narrow the guard, or narrow the mutation."
|
||
|
|
exit 3
|
||
|
|
fi
|
||
|
|
|
||
|
|
echo "prove-guard: good — the guard caught it, and only it ($COUNTED_BY)."
|