diff --git a/HARNESS.md b/HARNESS.md index 6aece25..4151949 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -256,9 +256,15 @@ that describe it, in the same change.** say why. The hook also regenerates `harness/surface.md` and stages it, so a generated file is never something you have to remember — and it refuses a commit with unstaged `.go` changes, because what it generated describes the working tree rather than what you are committing. It carries **no file - extension**, so a repo-wide sweep filtered by `--include='*.sh'` walks straight past it: ADR-0082's - rename left it staging a path that no longer existed, and the failure surfaced as a `fatal:` from inside - a commit that otherwise succeeded. When renaming anything the gate names, grep without an include filter. + extension** because git locates a hook by exact name — the one exception to "shell scripts end in `.sh`" + (`conventions.md`), confirmed by renaming it in a throwaway repo and watching a commit succeed with the + hook silent. A `.sh` suffix there would leave a gate that looks present and does nothing. +- **Paths inside `scripts/` are checked whether or not they are quoted.** The dangling-path gate had only + ever matched backticked citations, so `git add docs/surface.md` in the hook — an argument, not a + citation — survived ADR-0082's rename and surfaced as a `fatal:` inside a commit that otherwise + succeeded. Backslashes are stripped first, so a path written as a regex is compared as the file it + means. Prose is still checked only in backticks: a sentence saying "under `harness/`" is making a point + a filesystem cannot check, while a script naming a path either has it right or is broken. - `harness/decisions.md` registers every ADR number ever used, entries and withdrawals alike, so a citation can resolve to a decision or to a deferral but never to nothing. - `./scripts/verify.sh --list` names every gate that exists. A doc claiming enforcement is checkable diff --git a/harness/conventions.md b/harness/conventions.md index 9f4c8fc..f18f55d 100644 --- a/harness/conventions.md +++ b/harness/conventions.md @@ -43,6 +43,13 @@ wants splitting. because a forgotten expiry serves staleness silently. - Comments explain *why*, never *what*. Delete a comment narrating the next line. One stating a non-obvious invariant is worth ten describing control flow. +- **Shell scripts end in `.sh`, with exactly one exception: `scripts/hooks/pre-commit`.** git locates a + hook by exact filename, so a `.sh` suffix makes it invisible and the gate stops running on every commit + while still appearing to exist — verified by renaming it in a throwaway repository and watching a commit + succeed with the hook silent. The name belongs to git, not to this convention. Consequence worth + remembering: a repo-wide sweep filtered by `--include='*.sh'` walks past that file, which is how a stale + path survived in it (ADR-0082), so `verify.sh` now checks the paths inside `scripts/` whether or not they + are quoted. ## Documentation diff --git a/scripts/verify.sh b/scripts/verify.sh index 61635d8..3f8f43a 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -92,7 +92,7 @@ if [ -d .git ] && command -v git >/dev/null 2>&1; then if [ -n "$harnesschanged" ] && ! echo "$changed" | grep -qx 'HARNESS.md'; then bad "the harness changed (CLAUDE.md, scripts/ or .claude/) but HARNESS.md did not — the guide to the machine is part of the machine" else - pass "harness/HARNESS.md coupling" + pass "harness and HARNESS.md move together" fi fi @@ -142,8 +142,18 @@ if [ -d .git ] && command -v git >/dev/null 2>&1; then [ -d .scratch ] && scratchpat='|\.scratch' refs=$(grep -rhoE "\`(harness|scripts|ideas|reference|\.claude$scratchpat)/[A-Za-z0-9_./-]+\`" \ harness CLAUDE.md HARNESS.md ideas reference .claude scripts 2>/dev/null | tr -d '`' | sort -u) + # Bare paths in the gate's own scripts, where a backtick is prose but the path is what actually runs. + # `git add harness/surface.md` in the pre-commit hook is not a citation, it is an argument — and the hook + # staged a deleted path for two commits because this check had only ever looked inside backticks. Only + # scripts/ is scanned bare: prose says "under harness/" for reasons a filesystem cannot check, while a + # shell script naming a path either has it right or is broken. + # + # Backslashes are stripped first, so a path written as a regex — `\.claude/settings\.json` — is compared + # as the file it means rather than flagged as the file it is not. + bare=$(sed 's/\\//g' $(find scripts -type f) 2>/dev/null | + grep -ohE '(harness|docs|scripts|ideas|reference)/[A-Za-z0-9_./-]+' | sed 's/[.]$//' | sort -u) dangling="" - for f in $refs; do [ -e "$f" ] || dangling="$dangling $f"; done + for f in $refs $bare; do [ -e "$f" ] || dangling="$dangling $f"; done [ -n "$dangling" ] && bad "reference to a path that does not exist:$dangling" adrs=$(grep -rhoE 'ADR-[0-9]{4}' harness CLAUDE.md HARNESS.md ideas reference .claude scripts cmd internal 2>/dev/null | sort -u)