#!/bin/sh # scripts/bite --- prove a fix's tests BITE: run them against the # working tree (they must PASS), then against an older version of ONE # file (they must FAIL). # # scripts/bite [cargo-test-args...] # # Example (the PR #111 round-1 shape): # scripts/bite HEAD~1 builtin/runtime/editops.lua \ # --test editops_acceptance -- capitalize trim_on_save_unexpected # # Exit status: 0 when the named tests pass now and fail against # 's version of (the fix bites), 1 when they still pass # there (vacuous), 2 on usage/setup errors, 3 when the POSITIVE # CONTROL fails, 4 when the swapped run is INCONCLUSIVE (see the MIXED # arm below --- non-zero because its likeliest cause is vacuity, not # success). The working-tree file is restored on every exit path, # including interrupts. # # THE POSITIVE CONTROL, and why it is not optional. Before this # existed the script ran only the swapped tree, so it could not # distinguish "my fix is load-bearing" from "my test is broken": a # typo, an unrelated compile break, or a filter that matches nothing # all made the swapped run fail, and a failing swapped run was the # only thing it checked. It printed `bite: OK` and certified nothing. # So the control asserts two things, because passing alone is not # enough — `cargo test` with a filter matching zero tests exits 0, and # then a compile error in the old tree would still read as OK: # # * the named tests PASS against the working tree, and # * at least one test actually RAN. # # COMPILE errors of the old tree still count as "fails", and that is # correct but weaker than a clean assertion failure --- the swapped # file may simply not build against the current tree. The script now # says which kind it saw instead of leaving it to be eyeballed. Prefer # an assertion failure; treat a compile failure as a prompt to narrow # the swap to a file that compiles both ways. # # That classification reads libtest's own `test result:` summary and # deliberately does NOT grep for compiler text. A test may PRINT # compiler output on failure --- `compile_mode_acceptance` has a # fixture that emits `error[E0308]: mismatched types` at column 0, and # the compile-mode and editops suites are this script's primary # consumers --- so a `^error[E...]` grep would stamp "weaker evidence" # on exactly the strong result it was meant to identify. `test result:` # is emitted whenever the harness ran, is absent when the swapped file # did not build, and stays uncolored when piped. # # Why this exists: bite-verification is step 4 of the working method, # and the obvious shortcut --- git stash --- is a trap here. The stash # namespace is REPO-GLOBAL: shared across every worktree and with # humans, so a scripted push/pop can collide with (or pop!) someone # else's stashed work. This helper never touches git state beyond a # read-only `git show`, and it restores the working file from a # mktemp copy --- NOT from git, so uncommitted work in is # preserved. set -eu if [ "$#" -lt 2 ]; then echo "usage: scripts/bite [cargo-test-args...]" >&2 exit 2 fi ref=$1 path=$2 shift 2 if [ ! -f "$path" ]; then echo "bite: no such file: $path" >&2 exit 2 fi # Both cargo runs are CAPTURED rather than streamed, because their # output is parsed (passed-counts for the control, `test result:` for # the classification). A long acceptance suite therefore prints # nothing until it finishes --- that is the trade, not a bug. Do not # "fix" it back to streaming without giving the parser another source. # # Colour is pinned off on both runs, so an ambient CARGO_TERM_COLOR # cannot wrap the summary lines in escape codes and silently stop every # anchored match here from matching. Note the limit: an explicit # `--color always` in the passed-through args still beats the # environment. That is self-inflicted, and the failure is fail-closed # (a zeroed count reads as NO CONTROL), but do not pass it. export CARGO_TERM_COLOR=never # Sum the `N passed` figures across every `test result:` line, so a # multi-target invocation is counted correctly rather than only its # last binary. count_passed() { sed -n 's/^test result: ok\. \([0-9][0-9]*\) passed.*/\1/p' \ | awk '{ total += $1 } END { print total + 0 }' } # --- Positive control: the tests must pass, and must exist, NOW. --- echo "bite: positive control --- running against the working tree" >&2 control_status=0 control_out=$(cargo test "$@" 2>&1) || control_status=$? printf '%s\n' "$control_out" if [ "$control_status" -ne 0 ]; then echo "bite: NO CONTROL --- tests do not pass against the working tree." >&2 echo "bite: fix the tests first; a failure here makes the swapped run meaningless." >&2 exit 3 fi control_ran=$(printf '%s\n' "$control_out" | count_passed) if [ "$control_ran" -eq 0 ]; then echo "bite: NO CONTROL --- the filter matched no tests (0 passed)." >&2 echo "bite: check the test names; an empty filter passes everywhere and proves nothing." >&2 exit 3 fi echo "bite: control OK --- $control_ran test(s) pass against the working tree" >&2 saved=$(mktemp "${TMPDIR:-/tmp}/bite.XXXXXX") cp -- "$path" "$saved" restore() { cp -- "$saved" "$path" rm -f -- "$saved" } trap restore EXIT INT TERM # The `./` prefix makes the pathspec cwd-relative for git-show, so the # script works from any directory inside the repo. git show "$ref:./$path" > "$path" echo "bite: swapping in $ref:$path" >&2 swapped_status=0 swapped_out=$(cargo test "$@" 2>&1) || swapped_status=$? printf '%s\n' "$swapped_out" if [ "$swapped_status" -eq 0 ]; then echo "bite: VACUOUS --- tests still pass against $ref:$path" >&2 exit 1 fi # Distinguish the ways the swapped run can fail. An assertion failure # is the evidence we want; a build failure only says the old file does # not compile here, and the tests may never have run at all. # # Classified from libtest's summary line, NOT from compiler text --- a # failing test can print `error[E0308]: ...` at column 0 itself (see # the header). `test result: FAILED` means the harness ran and tests # failed; no `test result:` line at all means nothing ran. if printf '%s\n' "$swapped_out" | grep -q '^test result: FAILED'; then echo "bite: OK (assertion) --- tests fail against $ref:$path (the fix bites)" elif printf '%s\n' "$swapped_out" | grep -q '^test result:'; then # Some target ran to completion with no failure, yet cargo still # exited non-zero. # # NOT reachable the obvious way, which was checked rather than # assumed: `--test A --test B` where B's swapped file will not # build does NOT land here. Cargo builds every named target before # running any, so B's build failure stops A from running too and # there is no summary at all --- that is the COMPILE arm below. # What does reach here is a run that prints summaries and *then* # fails: doc-tests failing to compile after the lib tests pass, or # a harness dying after its summary line. This arm is therefore # defensive, and its trigger was not manufactured for validation. # # It exits NON-ZERO on purpose. The likeliest reading is not # success but VACUITY: the named tests ran clean against the old # tree (exactly the vacuous outcome) while an unrelated failure # supplied the exit status. Reporting "OK" and exiting 0 here would # let a scripted caller --- or a framing doc quoting "bite exited # 0" --- record a certified bite for a run that certified nothing. echo "bite: INCONCLUSIVE (MIXED) --- some tests ran, none failed, yet the run failed." >&2 echo "bite: the named tests may have PASSED against $ref:$path with an unrelated" >&2 echo "bite: build error supplying the exit status. Narrow to one target and re-run." >&2 exit 4 else echo "bite: OK (COMPILE) --- $ref:$path produced no test summary" >&2 echo "bite: (build failure, or a harness that died before printing one)." >&2 echo "bite: weaker evidence than an assertion failure --- the tests may never have run." >&2 fi