diff --git a/Tools/audit_guards.sh b/Tools/audit_guards.sh new file mode 100755 index 0000000..e937b2e --- /dev/null +++ b/Tools/audit_guards.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════════════════════ +# PURPOSE +# Find guards that cannot fire. A script that writes +# +# do_the_work || STEP_OK=false +# +# is only telling the truth if do_the_work can actually return non-zero. When it cannot, the +# flag stays true no matter what happened, and the run reports a step it never completed. +# +# This is the most expensive bug shape in this repo — a summary that says ✅ is the thing the +# operator trusts instead of reading the log. adapter.sh already carries the scar: "every +# caller that wrote `platform_push_setup_state || X=false` was testing a constant." +# +# Written 2026-08-24. Its first run found partnership_offboard.sh Step 3 reporting a stack +# cleanup that could not fail and, on a second path, one that had not run at all. +# +# OPERATIONAL MODEL +# For every `cmd || VAR=false` in the tree, the command left of the || is resolved and +# classified: +# +# CANFAIL a shell function with an explicit non-zero exit path — the guard is real +# EXTERNAL chown, docker, ssh, rsync and friends — can fail, nothing to check +# IDIOM [[ … ]] && X=true || X=false — a conditional, not a guard +# NEVERFAILS a function with no non-zero path — the guard is decorative +# UNKNOWN could not be resolved; reported rather than assumed either way +# +# A function "can fail" if it contains `return` or `exit` with a non-zero literal or any +# variable. The variable case matters: cleanup_partner_containers ends `return "$_rc"`, and a +# pattern that only looked for digits reported a working guard as broken. +# +# UNKNOWN is a first-class verdict, not a failure. Deciding what a bare `done` returns means +# evaluating the last command of the last loop, and a checker that guesses at that would +# produce confident wrong answers in both directions. +# +# DESIGN PRINCIPLES +# Resolve the command, never word-split it. +# An early build stripped a leading "do" to handle `for x in …; do cmd || F=false; done` +# and turned do_final_sync into a phantom _final_sync that resolved to nothing. The +# inline-loop case is matched on "; do " with required trailing space; nothing else is +# trimmed from a command name. +# +# Comments are excluded before anything else. +# Two of the matches in this repo are prose describing the bug, in adapter.sh and +# rsync_stop.sh. A checker that reports the documentation of a fixed bug as the bug +# teaches the operator to skim its output. +# +# Every verdict names where it looked. +# A NEVERFAILS line carries the file and line of the function it judged, because the +# first question is always "which definition did it find" — a name can exist twice. +# +# OPERATIONAL SAFEGUARDS +# Read-only. Greps and reads; writes nothing, executes nothing it finds. +# Exits 1 when any NEVERFAILS is reported, 0 otherwise — safe to gate a commit on. +# UNKNOWN never fails the run. It is a prompt to look, not a defect claim. +# +# RUNTIME MODES +# ══════════════════════════════════════════════════════════════════════════════════════════════ +# +# audit_guards.sh +# Classify every guard in the repo. Exits 1 if any guard cannot fire. +# +# audit_guards.sh --all +# Show every verdict, including the guards that are sound. +# +# audit_guards.sh --unknown +# Show only the guards that could not be resolved. +# +# ══════════════════════════════════════════════════════════════════════════════════════════════ + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +SHOW_ALL=false +ONLY_UNKNOWN=false +for a in "$@"; do + case "$a" in + --all) SHOW_ALL=true ;; + --unknown) ONLY_UNKNOWN=true ;; + *) echo "unknown argument: $a" >&2; exit 2 ;; + esac +done + +cd "$REPO_ROOT" || { echo "cannot reach repo root: $REPO_ROOT" >&2; exit 2; } + +# Locate a function definition and print "file:line" followed by its body. +_fn_body() { + local fn="$1" loc f ln + loc=$(grep -rnE "^[[:space:]]*(function[[:space:]]+)?${fn}[[:space:]]*\(\)" \ + --include='*.sh' . 2>/dev/null | grep -v '^./.git' | head -1) + [[ -z "$loc" ]] && return 1 + f=${loc%%:*} + ln=$(echo "$loc" | cut -d: -f2) + echo "${f#./}:$ln" + awk -v s="$ln" 'NR>=s { print; if (NR > s && /^\}/) exit }' "$f" +} + +dead=0 sound=0 unknown=0 other=0 + +while IFS= read -r hit; do + file=${hit%%:*} + lno=$(echo "$hit" | cut -d: -f2) + code=$(echo "$hit" | cut -d: -f3-) + + # Everything left of the ||, with an inline loop header removed. The trailing space after + # "do" is required — without it this eats the prefix of do_final_sync. + cmd=$(echo "$code" | sed 's/[[:space:]]*||.*//' \ + | sed 's/^[[:space:]]*//' \ + | sed 's/^.*;[[:space:]]*do[[:space:]]\{1,\}//') + head=$(echo "$cmd" | awk '{print $1}') + + verdict=""; detail="" + case "$head" in + '[['|'[' | test ) + verdict=IDIOM; detail="conditional, not a guard" ;; + chown|chmod|find|rm|mv|cp|docker|ssh|rsync|systemctl|timeout|curl|git|mkdir|ln|tar ) + verdict=EXTERNAL; detail="external command, can fail" ;; + -*|2\>*|1\>*|\>* ) + # A guard written across several lines: the match landed on a continuation of an + # external command (find … -exec …, a redirect) rather than on its head. Reading + # back to the head would mean parsing line continuations; the classification is + # the same either way, so it is recorded as what it is. + verdict=EXTERNAL; detail="continuation of a multi-line external command" ;; + esac + + if [[ -z "$verdict" ]]; then + if body=$(_fn_body "$head"); then + where=$(echo "$body" | head -1) + body=$(echo "$body" | tail -n +2) + # Non-zero literal, or any variable — quoted or bare. + if echo "$body" | grep -qE '^[[:space:]]*(return|exit)[[:space:]]+("?\$|[1-9])'; then + verdict=CANFAIL; detail="$where" + else + verdict=NEVERFAILS + detail="$where — no non-zero exit path" + fi + else + verdict=UNKNOWN; detail="could not resolve '$head'" + fi + fi + + case "$verdict" in + NEVERFAILS) dead=$((dead+1)) ;; + CANFAIL) sound=$((sound+1)) ;; + UNKNOWN) unknown=$((unknown+1)) ;; + *) other=$((other+1)) ;; + esac + + if $ONLY_UNKNOWN; then + show=false + [[ "$verdict" == UNKNOWN ]] && show=true + elif $SHOW_ALL || [[ "$verdict" == NEVERFAILS ]]; then + show=true + else + show=false + fi + if $show; then + printf '%-11s %s:%s\n %s\n %s\n' \ + "$verdict" "$file" "$lno" "$(echo "$cmd" | cut -c1-76)" "$detail" + fi + +done < <(grep -rnE '\|\|[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=false' --include='*.sh' . 2>/dev/null \ + | grep -v '^./.git' \ + | grep -vE ':[0-9]+:[[:space:]]*#' \ + | sed 's|^\./||') + +echo +echo "──────────────────────────────────────────────────────────────" +printf 'sound %s external/idiom %s unresolved %s CANNOT FIRE %s\n' \ + "$sound" "$other" "$unknown" "$dead" +[[ $dead -gt 0 ]] && exit 1 +exit 0