diff --git a/.claude/hooks/pretooluse-bom-guard.sh b/.claude/hooks/pretooluse-bom-guard.sh new file mode 100755 index 000000000..b8430b2a1 --- /dev/null +++ b/.claude/hooks/pretooluse-bom-guard.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# PreToolUse / Bash — deny `git commit` / `git push` when a .cs file this branch touches carries a +# UTF-8 BOM. `.editorconfig` sets charset=utf-8 (no BOM), and the #311 fix-as-you-touch gate +# ("Formatting (changed .cs conform to .editorconfig)") FAILS THE PR for any touched file that has one. +# +# Why a hook and not a note: the ~2500 legacy .cs files carry a BOM, so it becomes *your* problem the +# moment you touch one — and the usual ways of touching them re-add it silently. Python +# `io.open(..., encoding='utf-8-sig')` WRITES a BOM back; perl/sed round-trips preserve it. On +# 2026-07-17 this cost two separate sessions a red CI job on the same day (PR #405 x6 files; +# #70/PR #402 x19), and a memory describing the trap did not prevent either — the second session +# re-added a BOM an hour after writing that memory down. A check that runs is worth more than one you +# have to remember. +# +# Generated files are excluded: dotnet format skips *.Designer.cs and TvContextModelSnapshot.cs as +# generated code, and so does the CI verify, so `dotnet ef` may leave its BOM there. +# +# Fail-open by design: any parse/lookup trouble → allow (exit 0, no output). This gate must never be +# the reason a commit can't happen; CI is still the backstop. +set -uo pipefail + +input=$(cat) +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true) +[ -n "$cmd" ] || exit 0 + +# Only gate real `git commit` / `git push` invocations (allowing global flags like `git -c x=y commit`). +# Matched in command position so the words inside a commit message or an echo never false-trip. +printf '%s' "$cmd" \ + | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]]+)?)*[[:space:]]+(commit|push)([[:space:]]|$)' \ + || exit 0 + +# Which tree does this act on? Commits here are typically `cd ` followed by git, and the +# harness resets the shell cwd between calls, so an in-command `cd` is the most reliable signal. +# Fall back to the payload cwd, then the project dir. +dir=$(printf '%s' "$cmd" \ + | grep -oE '(^|[;&|(]|&&|\|\|)[[:space:]]*cd[[:space:]]+[^;&|)]+' \ + | tail -1 | sed -E 's/.*cd[[:space:]]+//; s/[[:space:]]+$//' | tr -d "\"'" || true) +if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then + dir=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true) +fi +if [ -z "${dir:-}" ] || [ ! -d "$dir" ]; then + dir="${CLAUDE_PROJECT_DIR:-$PWD}" +fi + +root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || exit 0 + +# Scoped to this repo — the .editorconfig rule it enforces is ours. +case "$root" in + *ersatztv*) ;; + *) exit 0 ;; +esac + +# The touched set: what this branch changes vs origin/main, plus anything staged or dirty right now +# (a commit can introduce a BOM that isn't in the pushed diff yet). +base=$(git -C "$root" rev-parse --verify --quiet origin/main 2>/dev/null || true) +{ + [ -n "$base" ] && git -C "$root" diff --name-only --diff-filter=ACM "$base"...HEAD -- '*.cs' 2>/dev/null + git -C "$root" diff --name-only --diff-filter=ACM --cached -- '*.cs' 2>/dev/null + git -C "$root" diff --name-only --diff-filter=ACM -- '*.cs' 2>/dev/null +} | sort -u > /tmp/.bom-guard-files.$$ 2>/dev/null || { rm -f /tmp/.bom-guard-files.$$; exit 0; } + +bad="" +while IFS= read -r f; do + [ -n "$f" ] || continue + case "$f" in + *.Designer.cs|*TvContextModelSnapshot.cs) continue ;; + esac + p="$root/$f" + [ -f "$p" ] || continue + if [ "$(head -c3 "$p" 2>/dev/null | xxd -p 2>/dev/null)" = "efbbbf" ]; then + bad="${bad} ${f}"$'\n' + fi +done < /tmp/.bom-guard-files.$$ +rm -f /tmp/.bom-guard-files.$$ + +[ -n "$bad" ] || exit 0 + +reason="Blocked: these .cs files carry a UTF-8 BOM, which .editorconfig forbids (charset=utf-8). The #311 Formatting CI job fails the PR for any file this branch touches that has one: + +${bad} +Strip it, then re-run this command: + + python3 - <<'EOF' + import subprocess + def g(*a): return subprocess.run(['git','diff','--name-only',*a,'--','*.cs'], + capture_output=True, text=True).stdout.split() + # same detection set as the guard: branch diff + staged + dirty (a brand-new staged + # file is exactly what fires the deny and is absent from origin/main...HEAD) + fs = set(g('origin/main...HEAD')) | set(g('--cached')) | set(g()) + for f in sorted(fs): + try: b = open(f,'rb').read() + except OSError: continue + if b[:3] == b'\xef\xbb\xbf': + open(f,'wb').write(b[3:]); print('stripped', f) + EOF + +Usual cause: an edit that rewrote a legacy file preserved its BOM — Python io.open(..., encoding='utf-8-sig') WRITES one back; sed/perl round-trips keep it. Touching a legacy file makes its inherited BOM yours to remove (docs/contributing.md; ersatztv#311). Generated *.Designer.cs / TvContextModelSnapshot.cs are exempt and not listed here." + +jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index ac251eb55..8cc4caaa1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,6 +14,11 @@ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-worktree-guard.sh\"", "timeout": 10 + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-bom-guard.sh\"", + "timeout": 10 } ] }, diff --git a/docs/contributing.md b/docs/contributing.md index 544444427..30a1b0c38 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -103,10 +103,23 @@ talks exclusively to the REST API. Every former Blazor route now 302-redirects t carry a UTF-8 BOM, which violates `.editorconfig`'s `charset=utf-8`. We do **not** mass-reformat. Instead, **when you modify a file for other work, normalize it in that same PR** — `dotnet format ErsatzTV.sln --include `, which strips the BOM and fixes style. Files you did - not touch stay as-is. This is enforced two ways so it can't be silently skipped (ersatztv#311): the - pre-commit hook verifies staged `.cs`, and a blocking **`format`** CI job re-verifies the `.cs` this - PR changed against `.editorconfig` (a `.cs`-free PR passes trivially). Never `--no-verify` past a - format failure on a file you changed — de-BOM/format it instead. (A one-time repo-wide normalization + not touch stay as-is. This is enforced three ways so it can't be silently skipped (ersatztv#311): the + pre-commit hook verifies staged `.cs`; a blocking **`format`** CI job re-verifies the `.cs` this + PR changed against `.editorconfig` (a `.cs`-free PR passes trivially); and a Claude `PreToolUse` + guard (`.claude/hooks/pretooluse-bom-guard.sh`) refuses a `git commit`/`git push` whose touched `.cs` + still carry a BOM. The guard exists because the first two are skippable in practice: worktree hook + friction means `--no-verify` is routine, which bypasses the pre-commit check, leaving CI — a ~10-minute + round trip — as the first thing that tells you. It fires *before* git runs, so `--no-verify` can't + skip it, and it names the files plus the fix. Fail-open by design; generated `*.Designer.cs` / + `TvContextModelSnapshot.cs` are exempt, matching what `dotnet format` skips. + Never `--no-verify` past a format failure on a file you changed — de-BOM/format it instead. + **Watch for the re-add**: an edit that rewrites a legacy file usually preserves its BOM — Python + `io.open(..., encoding='utf-8-sig')` *writes one back*, and sed/perl round-trips keep it. This cost + two sessions a red CI job on 2026-07-17 alone (PR #405, 6 files; PR #402, 19), which is what the + guard is for. To check by hand: + `for f in $(git diff --name-only origin/main...HEAD --cached -- '*.cs'; git diff --name-only -- '*.cs') ; do head -c3 "$f" | xxd -p | grep -q '^efbbbf' && echo "BOM: $f"; done` (covers branch + staged + dirty — a brand-new staged file is absent from `origin/main...HEAD`) + (note `dotnet format --include` needs **bash** — `mapfile` is bash-only, and under zsh the file list + comes out empty, which looks exactly like the tool silently doing nothing). (A one-time repo-wide normalization is a separate, unmade decision; the touched-file rule is the standing one.) - **`TreatWarningsAsErrors=true`** in the app projects — a warning fails the build. `NoWarn` carries a small, documented exemption list (e.g. `VSTHRD200`, `CA1873`); NuGet-audit `NU1901-1903` are demoted