From 448ce373ca511b2dead6b53c04dd35045a9e3463 Mon Sep 17 00:00:00 2001 From: David F Glidden Date: Sat, 8 Aug 2026 18:06:52 +0200 Subject: [PATCH] [HARDENING] The hook now says when it did nothing (REVIEWED-105, PENDING-123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five disarming faults were measured silent at exit 0, indistinguishable from each other and from a legitimate docs-only commit. All five now speak. (b) A malformed declaration REFUSES rather than skips: no separator, empty pathspec, empty command, or a pathspec git cannot resolve. The refusal names file, line number, fault, the offending text, the expected form, and --no-verify — a gate that blocks without saying why is replaced by habit within a week. (e) instead of a flag, on the ruling's reasoning that a flag nobody sets is a capability nobody has: the per-rule line prints in exactly the ambiguous case. A rule ran, the existing lines already say so and nothing is added. No triggers file, this block never runs, so no other repo gains noise. Rules declared and none matched is the only case a reader cannot otherwise tell from a broken hook, so it is the only case that gets a line. PRECOMMIT_VERBOSE adds per-rule detail for a suspect pathspec. Two things the implementation found that the ruling did not specify. A triggers file declaring no rules — comments-only or empty — left declared=0, so my first cut skipped the report and those two rows stayed silent. That state is a disarmed hook wearing an armed face: the file is present so the repo looks opted in, and every commit sails through. It now reports rather than refuses, since refusing would block a legitimately emptied file. And a rule that has never matched is honestly unknown, not passing and not failing; the hook holds no history and does not imply one. Matched-rule output is byte-identical to what REVIEWED-100's acceptance proved — the split reproduces `IFS='|' read` exactly, including the retained leading space in the display. One observable change: a docs-only commit still runs nothing but now says so. Acceptance, all seven rows: control FIRED · typo REPORTED-no-match · no separator REFUSED · empty command REFUSED · comments-only REPORTED-empty · empty file REPORTED-empty · docs-only REPORTED-no-match. Red direction still refuses. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01A35wiD55yRHj5U1ECZAX4t --- git/hooks/pre-commit | 72 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/git/hooks/pre-commit b/git/hooks/pre-commit index fb7bf0b..3760511 100755 --- a/git/hooks/pre-commit +++ b/git/hooks/pre-commit @@ -88,18 +88,59 @@ fi # a toolchain silently fails to travel to the next machine or repo, and a check # that silently does not run is worse than no check, because its absence reads as # a pass. That is why this departs from the YAML used by data python tools read. +# A DISARMED HOOK MUST NOT LOOK LIKE AN ARMED ONE (REVIEWED-105 / PENDING-123). +# Measured 2026-08-08: five distinct disarming faults — pathspec typo, missing '|', +# empty command, comments-only file, empty file — every one silent at exit 0, and +# indistinguishable both from each other and from the legitimate "docs-only commit" +# case. A malformed declaration therefore REFUSES (never skips), and when a triggers +# file exists but nothing matched, that fact is PRINTED rather than left to silence. +refuse_declaration() { # $1 line number, $2 fault, $3 the offending line + echo -e "${RED}Malformed .precommit-triggers — commit refused.${NC}" + echo " file: ${triggers_file}" + echo " line: $1" + echo " fault: $2" + echo " text: $3" + echo " expected: [more pathspecs] | " + echo " --no-verify bypasses this; it is a tripwire, not a boundary." + exit 1 +} + repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" triggers_file="${repo_root:-.}/.precommit-triggers" if [ -n "$repo_root" ] && [ -f "$triggers_file" ]; then + declared=0; matched=0; lineno=0; declared_paths=""; rule_detail="" # fd 3, so a check that reads stdin cannot swallow the rest of this file - while IFS='|' read -r paths cmd <&3 || [ -n "${paths:-}" ]; do - case "${paths%%[![:space:]]*}${paths#"${paths%%[![:space:]]*}"}" in \#*) continue ;; esac - cmd="$(printf '%s' "${cmd:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" - [ -n "$cmd" ] || continue + while IFS= read -r rawline <&3 || [ -n "${rawline:-}" ]; do + lineno=$((lineno + 1)) + trimmed="$(printf '%s' "$rawline" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + case "$trimmed" in ''|\#*) continue ;; esac + declared=$((declared + 1)) + + # ── (b) validate the declaration ──────────────────────────────────────── + case "$rawline" in + *"|"*) ;; + *) refuse_declaration "$lineno" "no '|' separator between pathspec and command" "$trimmed" ;; + esac + # split exactly as `IFS='|' read paths cmd` did, so matched-rule output stays + # byte-identical to what REVIEWED-100's acceptance test proved + paths="${rawline%%|*}" + cmd="$(printf '%s' "${rawline#*|}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + paths_trimmed="$(printf '%s' "$paths" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + [ -n "$paths_trimmed" ] || refuse_declaration "$lineno" "pathspec is empty" "$trimmed" + [ -n "$cmd" ] || refuse_declaration "$lineno" "command is empty" "$trimmed" + declared_paths="${declared_paths}${declared_paths:+, }${paths_trimmed}" + # shellcheck disable=SC2086 — word splitting is intended: multiple pathspecs - if [ -z "$(git diff --cached --name-only -- $paths 2>/dev/null)" ]; then + if ! staged="$(git diff --cached --name-only -- $paths 2>/dev/null)"; then + refuse_declaration "$lineno" "git cannot resolve this pathspec" "$trimmed" + fi + if [ -z "$staged" ]; then + rule_detail="${rule_detail} line ${lineno}: [${paths_trimmed}] — declared, no staged match +" continue fi + matched=$((matched + 1)) + echo -e "${YELLOW}Staged change touches [${paths# }] — running declared check:${NC} $cmd" if ! ( cd "$repo_root" && eval "$cmd" ) < /dev/null; then echo -e "${RED}Declared check FAILED — commit refused.${NC}" @@ -110,6 +151,27 @@ if [ -n "$repo_root" ] && [ -f "$triggers_file" ]; then fi echo -e "${GREEN}Declared check passed.${NC}" done 3< "$triggers_file" + + # ── (e) speak in exactly the ambiguous case ───────────────────────────────── + # A rule ran: the lines above already said so — add nothing. No triggers file: + # this block never runs, so no other repo gains noise. Rules declared and none + # matched is the ONLY case a reader cannot otherwise distinguish from a broken + # hook, so it is the only case that gets a line. + if [ "$declared" -eq 0 ]; then + # A triggers file that declares nothing is a DISARMED state wearing an armed + # face: the file is present, so the repo looks opted-in, and every commit + # sails through. Refusing would block a legitimately-emptied file, so this + # reports rather than refuses — but it must not be silent. + echo -e "${YELLOW}.precommit-triggers exists but declares no rules — this repo is opted in and unguarded.${NC}" + elif [ "$matched" -eq 0 ]; then + echo -e "${YELLOW}${declared} rule(s) declared in .precommit-triggers, none matched staged paths (${declared_paths}).${NC}" + # A rule that has NEVER matched is honestly unknown, not passing and not + # failing — the hook holds no history and must not imply one. PRECOMMIT_VERBOSE + # prints the per-rule detail for the reader who wants to check a suspect pathspec. + if [ -n "${PRECOMMIT_VERBOSE:-}" ]; then + printf '%s' "$rule_detail" + fi + fi fi echo -e "${GREEN}Pre-commit checks passed!${NC}"