#!/usr/bin/env bash # Mutation check: prove a test can fail. Replaces an exact snippet in one # source file (must match exactly once), runs the tests, and ALWAYS restores # the file. Exit 0 = mutant killed (some test failed, as it should); 1 = # mutant SURVIVED (tests too weak); 3 = mutant doesn't build (invalid — # replace a statement with `{ }` instead of deleting a loop body line). # # Usage (from the repo whose tests must catch it — a game root, or this lib's root): # /mutate.sh # e.g. scripts/mutate.sh src/MyGame.Core/Loop/InputLatch.cs \ # '_jump |= sample.JumpPressed;' '_jump = sample.JumpPressed;' # /mutate.sh -- # runs that command instead of the whole `dotnet test` (a filtered dotnet test, or any other runner: python # unittest, ...); a dotnet command keeps the build / [FAIL] parsing, any other is killed by a non-zero exit. set -uo pipefail file=$1 old=$2 new=$3 shift 3 cmd=(dotnet test --nologo) if [[ ${1:-} == -- ]]; then shift cmd=("$@") fi backup=$(mktemp) cp "$file" "$backup" trap 'cp "$backup" "$file"; rm -f "$backup"' EXIT python3 - "$file" "$old" "$new" <<'EOF' || exit 2 import sys path, old, new = sys.argv[1:] text = open(path, encoding="utf-8").read() n = text.count(old) if n != 1: sys.exit(f"snippet matches {n} times in {path} (need exactly 1)") open(path, "w", encoding="utf-8").write(text.replace(old, new)) EOF result=$("${cmd[@]}" 2>&1) status=$? if [[ ${cmd[0]} != dotnet ]]; then if (( status != 0 )); then echo "KILLED (exit $status):"; tail -3 <<<"$result"; exit 0 fi echo "SURVIVED: no test failed — strengthen the tests"; exit 1 fi if grep -q "error CS" <<<"$result"; then echo "INVALID: mutant doesn't build"; grep -m3 "error CS" <<<"$result"; exit 3 fi if grep -q "Failed!" <<<"$result"; then echo "KILLED by:"; grep "\[FAIL\]" <<<"$result" | sed -E 's/^\[xUnit[^]]*\] +//; s/ \[FAIL\]$//' | sort -u; exit 0 fi echo "SURVIVED: no test failed — strengthen the tests"; exit 1