#!/usr/bin/env bash # Test spec-revocation-check against throwaway git repos. # # Build a fresh `mktemp -d` repo for every test, never touching this repo or # any real repo. Use `git -c commit.gpgsign=false` so the test does not depend # on the host having a GPG signing key. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT="$SCRIPT_DIR/../scripts/spec-revocation-check" RED='\033[0;31m' GREEN='\033[0;32m' NC='\033[0m' pass=0 fail=0 assert_exit() { local desc="$1" expected="$2" actual="$3" if [[ "$expected" == "$actual" ]]; then echo -e "${GREEN}PASS${NC}: $desc" pass=$((pass + 1)) else echo -e "${RED}FAIL${NC}: $desc (expected exit $expected, got $actual)" echo " stdout: $4" echo " stderr: $5" fail=$((fail + 1)) fi } assert_contains() { local desc="$1" pattern="$2" actual="$3" if [[ "$actual" == *"$pattern"* ]]; then echo -e "${GREEN}PASS${NC}: $desc" pass=$((pass + 1)) else echo -e "${RED}FAIL${NC}: $desc" echo " expected substring: $pattern" echo " actual: $actual" fail=$((fail + 1)) fi } # --- Fixture helpers (shell-safe, written via Python) ------------------- # Common glob pattern used in most tests. GLOB='spec/**/*.md' mk_repo() { local d="$1" mkdir -p "$d" git -C "$d" init -q git -C "$d" -c commit.gpgsign=false config user.email "t@t.invalid" git -C "$d" -c commit.gpgsign=false config user.name "t" } # write_file_via_py — write content as a file via # Python so embedded quotes / newlines / backslashes survive intact. write_file_via_py() { local d="$1" rel="$2" content="$3" python3 - "$d" "$rel" "$content" <<'PYEOF' import os, sys d, rel, content = sys.argv[1], sys.argv[2], sys.argv[3] full = os.path.join(d, rel) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "w") as f: f.write(content) PYEOF } commit_all() { local d="$1" msg="${2:-x}" git -C "$d" -c commit.gpgsign=false add -A git -C "$d" -c commit.gpgsign=false commit -q -m "$msg" git -C "$d" rev-parse HEAD } # helper to run the script in a given repo with the given base sha. # usage: run_check [extra args...] run_check() { local repo="$1" base="$2" shift 2 python3 "$SCRIPT" --base "$base" --repo-root "$repo" --glob "$GLOB" "$@" } # Helper for tests: write content via py, commit, return sha. commit_file() { local d="$1" rel="$2" content="$3" msg="${4:-base}" write_file_via_py "$d" "$rel" "$content" commit_all "$d" "$msg" } # --- Tests --------------------------------------------------------------- SCRATCH="$(mktemp -d)" trap 'rm -rf "$SCRATCH"' EXIT # ---- Test 1: clean amendment (exit 0) ----------------------------------- echo "=== clean amendment ===" T="$SCRATCH/t1"; mk_repo "$T" BASE=$(commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n') write_file_via_py "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nRevokes prior text (TEST): "A line that gets revoked."\n\n' out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "clean amendment exits 0" 0 "$rc" "$out" "" assert_contains "clean amendment says clean" "clean" "$out" # ---- Test 2: unquoted deletion (exit 1, names the missing line) --------- echo "=== unquoted deletion ===" T="$SCRATCH/t2"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) write_file_via_py "$T" "spec/foo.md" $'# Spec\n\nA line that stays.\n\n' out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "unquoted deletion exits 1" 1 "$rc" "$out" "" assert_contains "unquoted deletion names path" "spec/foo.md" "$out" assert_contains "unquoted deletion names text" "A line that gets revoked" "$out" assert_contains "unquoted deletion under correct header" "unquoted deletions" "$out" # ---- Test 3: fabricated quote (exit 1, quotes non-existent text) -------- echo "=== fabricated quote ===" T="$SCRATCH/t3"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) write_file_via_py "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nRevokes prior text (TEST): "Text that was never in the base."\n\n' out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "fabricated quote exits 1" 1 "$rc" "$out" "" assert_contains "fabricated quote under correct header" "fabricated quotes" "$out" assert_contains "fabricated quote names quote text" "Text that was never in the base" "$out" # ---- Test 4: quote with bold dropped (exit 1) --------------------------- echo "=== quote with bold dropped ===" T="$SCRATCH/t4"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\n**Important** line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) write_file_via_py "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nRevokes prior text (TEST): "Important line that gets revoked."\n\n' out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "quote with bold dropped exits 1" 1 "$rc" "$out" "" assert_contains "fabricated quote names dropped-bold" "fabricated quotes" "$out" # ---- Test 5: quote containing " (exit 0, greedy match handles it) ------- echo "=== quote containing double-quote ===" T="$SCRATCH/t5"; mk_repo "$T" # Base text contains literal '"' inside a quoted phrase. BASE=$(commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that has a "quoted" word.\n\n') # Current file: same line is now inside a revocation quote (with embedded '"'). write_file_via_py "$T" "spec/foo.md" \ $'# Spec\n\nRevokes prior text (TEST): "A line that has a "quoted" word."\n\n' out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "quote with embedded quote exits 0" 0 "$rc" "$out" "" assert_contains "quote with embedded quote says clean" "clean" "$out" # ---- Test 6: malformed marker (exit 1) ---------------------------------- echo "=== malformed marker ===" T="$SCRATCH/t6"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) # Marker present but no closing quote (so the line fails to parse). write_file_via_py "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nRevokes prior text (TEST) no quote\n\n' out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "malformed marker exits 1" 1 "$rc" "$out" "" assert_contains "malformed marker under correct header" "malformed markers" "$out" assert_contains "malformed marker names raw line" "no quote" "$out" # ---- Test 7: deleted file (exit 1) -------------------------------------- echo "=== deleted file ===" T="$SCRATCH/t7"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) # Delete the file entirely. rm "$T/spec/foo.md" out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "deleted file exits 1" 1 "$rc" "$out" "" assert_contains "deleted file under correct header" "deleted since base" "$out" assert_contains "deleted file names path" "spec/foo.md" "$out" # ---- Test 8: renamed file with quote (exit 0) --------------------------- echo "=== renamed file with quote ===" T="$SCRATCH/t8"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) # Rename + quote the old content in the new file. git -C "$T" mv "spec/foo.md" "spec/bar.md" >/dev/null write_file_via_py "$T" "spec/bar.md" \ $'# Spec\n\nA line that stays.\n\nRevokes prior text (TEST): "A line that gets revoked."\n\n' out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "renamed file with quote exits 0" 0 "$rc" "$out" "" assert_contains "renamed file with quote says clean" "clean" "$out" # ---- Test 9: short / markdown-only line removed (exit 0) ---------------- echo "=== short / markdown-only line removed ===" T="$SCRATCH/t9"; mk_repo "$T" # Base file has: a heading, a very short line (< 12 chars), a markdown table # (with separator row + a data row), a horizontal rule, and a long real # content line. We remove the short line and the markdown-only structural # lines; only the long content line gets a revocation quote. BASE=$(commit_file "$T" "spec/foo.md" \ $'# Spec\n\nshort\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n---\n\nReal content here that is plenty long enough.\n') write_file_via_py "$T" "spec/foo.md" \ $'# Spec\n\n| a | b |\n| 1 | 2 |\n\nRevokes prior text (TEST): "Real content here that is plenty long enough."\n' out=$(run_check "$T" "$BASE" --tag TEST); rc=$? assert_exit "short/markdown-only removed exits 0" 0 "$rc" "$out" "" assert_contains "short/markdown-only removed says clean" "clean" "$out" # ---- Test 10: untagged marker with no --tag (exit 0) ------------------- echo "=== untagged marker with no --tag ===" T="$SCRATCH/t10"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) # Use the untagged marker (no parens). write_file_via_py "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nRevokes prior text: "A line that gets revoked."\n\n' # No --tag → should accept untagged marker. out=$(run_check "$T" "$BASE"); rc=$? assert_exit "untagged marker no --tag exits 0" 0 "$rc" "$out" "" assert_contains "untagged marker no --tag says clean" "clean" "$out" # ---- Test 11: --tag filters other tags --------------------------------- echo "=== --tag filters other tags ===" T="$SCRATCH/t11"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) # Quote uses M33, but we run with --tag M99, so the marker is malformed # (no closing quote because the regex requires "M99"). write_file_via_py "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nRevokes prior text (M33): "A line that gets revoked."\n\n' out=$(run_check "$T" "$BASE" --tag M99); rc=$? assert_exit "--tag filters other tag exits 1" 1 "$rc" "$out" "" # Without --tag, the marker WOULD have parsed and the line would survive. out_notag=$(run_check "$T" "$BASE"); rc_notag=$? assert_exit "no --tag accepts M33 marker exits 0" 0 "$rc_notag" "$out_notag" "" # ---- Test 12: bad --base (exit 2) --------------------------------------- echo "=== bad --base ===" T="$SCRATCH/t12"; mk_repo "$T" commit_file "$T" "spec/foo.md" $'# Spec\n\nA line.\n' >/dev/null out=$(run_check "$T" "0000000000000000000000000000000000000000" --tag TEST 2>&1); rc=$? assert_exit "bad --base exits 2" 2 "$rc" "$out" "" assert_contains "bad --base names offending arg" "does not resolve" "$out" # ---- Test 13: not a git repo (exit 2) ----------------------------------- echo "=== not a git repo ===" T="$SCRATCH/t13"; mkdir -p "$T" write_file_via_py "$T" "spec/foo.md" $'# Spec\n\nA line.\n' out=$(run_check "$T" "HEAD" --tag TEST 2>&1); rc=$? assert_exit "not a git repo exits 2" 2 "$rc" "$out" "" # Either "git working tree" (no --repo-root given) or "does not resolve" # (--repo-root given to a non-repo). assert_contains "not a git repo error" "does not resolve" "$out" # ---- Test 14: --json parses (exit 1, valid JSON) ------------------------ echo "=== --json parses ===" T="$SCRATCH/t14"; mk_repo "$T" commit_file "$T" "spec/foo.md" \ $'# Spec\n\nA line that stays.\n\nA line that gets revoked.\n\n' >/dev/null BASE=$(git -C "$T" rev-parse HEAD) write_file_via_py "$T" "spec/foo.md" $'# Spec\n\nA line that stays.\n\n' out=$(run_check "$T" "$BASE" --tag TEST --json); rc=$? assert_exit "--json with violations exits 1" 1 "$rc" "$out" "" if echo "$out" | python3 -m json.tool >/dev/null 2>&1; then echo -e "${GREEN}PASS${NC}: --json parses with python3 -m json.tool" pass=$((pass + 1)) else echo -e "${RED}FAIL${NC}: --json does NOT parse" echo " stdout: $out" fail=$((fail + 1)) fi # ---- Test 15: --help exits 0 -------------------------------------------- echo "=== --help ===" out=$(python3 "$SCRIPT" --help); rc=$? assert_exit "--help exits 0" 0 "$rc" "$out" "" assert_contains "--help shows usage" "spec-revocation-check" "$out" assert_contains "--help mentions --base" "--base" "$out" # ---- Negative-test verification (would each negative fail if disabled?) - # A "negative" test would PASS against a no-op script that always exits 0. # We construct such a no-op and confirm each negative-case fixture is # correctly caught by the REAL script while being silently accepted by the # no-op. This proves the real script is what detected each issue. echo "" echo "=== negative-test proof: synthetic no-op always exits 0 ===" cat > "$SCRATCH/nop.py" <<'PYEOF' #!/usr/bin/env python3 import json, os, sys out = { "repo_root": os.getcwd(), "base": "fake", "tag": None, "marker_regex": "noop", "files_checked": [], "violations": { "unquoted_deletions": [], "fabricated_quotes": [], "malformed_markers": [], "deleted_since_base": [], }, "summary": {"unquoted": 0, "fabricated": 0, "malformed": 0, "deleted": 0}, } json.dump(out, sys.stdout) sys.stdout.write("\n") sys.exit(0) PYEOF chmod +x "$SCRATCH/nop.py" for case in t2 t3 t4 t6 t7; do T="$SCRATCH/$case" BASE=$(git -C "$T" rev-parse HEAD 2>/dev/null || echo "HEAD") out_nop=$(python3 "$SCRATCH/nop.py" --base HEAD --repo-root "$T" --glob "spec/**/*.md" 2>&1); rc_nop=$? # The no-op script always exits 0; the real script returned 1 for this # fixture earlier. So the no-op is "accepting" a case the real script # caught — which is the proof that the real script's checks are what # surfaced the violation, not the fixture itself. if [ "$rc_nop" -eq 0 ]; then echo -e "${GREEN}PASS${NC}: negative-case $case: real=1, noop=0 (check is doing the work)" pass=$((pass + 1)) else echo -e "${RED}FAIL${NC}: noop unexpectedly returned $rc_nop on case $case" fail=$((fail + 1)) fi done # ---- Done --------------------------------------------------------------- echo "" echo "Results: $pass passed, $fail failed" [[ $fail -eq 0 ]]