#!/usr/bin/env bash
#
# Run every check this project has, in one command, and print one table saying
# which passed, which failed and which did not run at all.
#
#   bash scripts/verify.sh              # everything
#   bash scripts/verify.sh --quick      # skip the test step, name it as skipped
#   bash scripts/verify.sh --list       # show the plan, run nothing
#   bash scripts/verify.sh --only npm   # only steps whose id contains 'npm'
#   VERIFY_GUARD_DIR=scripts/verify.d bash scripts/verify.sh
#
# Exit codes: 0 everything that ran passed. 1 something failed, or the run
# could not start at all — a usage error, a bad VERIFY_GUARD_DIR, not a git
# repository. 2 nothing was verified: either no checks were detected, or every
# detected step skipped. Two is not a pass and CI must not treat it as one.
#
# One and two are both non-zero on purpose, so every refusal fails closed. If
# you need to tell "a check failed" apart from "it never got to run", read the
# message — a failed check always prints a table first.
#
# ===========================================================================
# TEMPLATE COPY — configure this before the first run
# ===========================================================================
#
# Copy to `scripts/verify.sh`, `chmod +x` it, and wire it in: for a Node
# project add `"verify": "bash scripts/verify.sh"` to package.json; for anything
# else call it from `pre-commit`. Then read the CONFIGURATION block below —
# two environment variables (VERIFY_GUARD_DIR, VERIFY_SLOW_STEP) and six lists
# of candidate script and target names. Every one is optional except the one
# you need, which is GUARD_DIR.
#
# Unlike its sibling release.sh this script has no host, image or container to
# get wrong, so it defaults to DETECTING rather than to refusing. The rule it
# inherits unchanged is the important half: it never claims to have checked
# something it did not check. A detector that finds nothing says so and exits
# non-zero; it does not print a green table.
#
# Assumes: bash, coreutils, git. Each detector additionally needs the toolchain
# it detects, and says so by name when that toolchain is missing instead of
# quietly dropping the step.
#
# ## Why this exists
#
# "Did I break anything" should not be a judgement call, and in a project with
# three separate check commands it always is. The commands live in different
# places — a package.json script, a Makefile target, a lint you have to
# remember — so the honest answer to "did you run everything" is usually "I ran
# the one I remembered". The suite passes, the typecheck was never run, and the
# breakage is found by the deploy.
#
# One entry point removes the judgement. It also removes the excuse: there is
# no "I ran the important one" when running all of them is the same amount of
# typing.
#
# The second reason is the summary. A chain of `&&` stops at the first failure,
# so a run tells you about one broken thing at a time and you pay the whole
# cost again to find the next. This runs every step even after one fails, so a
# single run tells you everything that is broken.
#
# ## Guard tests are the point
#
# This project family keeps getting bitten by bugs that are invisible to
# ordinary tests, because the test and the bug agree with each other:
#
#   - A Date serialised across a SQL boundary came back shifted by the server's
#     timezone. Every test asserted against the same shifted value, so the
#     suite was green and the dates were wrong.
#   - A React prop was spread over a form field and silently overwrote its
#     `name`. The component rendered, the test rendered it, and the field
#     submitted under the wrong key.
#   - A `redirect()` was called inside a `try` block. Next.js implements
#     redirect by throwing, so the `catch` swallowed it and every successful
#     action reported failure.
#
# None of those is a logic error a unit test would catch. All three are SHAPES
# in the source: a shape you can grep for. The fix is a guard test — a script
# that greps the source for the shape and exits non-zero when it reappears —
# and this script is where guard tests belong, because verify.sh is the thing
# that actually gets run.
#
# Write them as small executables in GUARD_DIR. One shape per file, named for
# the bug, exiting non-zero with a message naming the file and line. They cost
# milliseconds, they run first here for exactly that reason, and they are the
# only mechanism in the repository that catches a bug the tests cannot see.
#
# ## SKIPPED is not PASS
#
# A step that did not run gets its own state in the table and its own colour,
# and it is never folded into the pass count. This matters more than it looks:
# the failure this script exists to prevent is a green table produced by
# checking nothing, and every path to that failure runs through a skip that was
# reported as a success. So a run where nothing actually executed exits 2 even
# though nothing failed, and --quick names the step it dropped rather than
# quietly shortening the table.
#
# ## What it deliberately does not do
#
# It does not fix anything. No --fix, no formatter writing to your files: this
# runs immediately before a commit, and a verify that edits the tree changes
# what you were about to commit into something you have not read.
#
# It does not touch git — no staging, no committing, no stash, no branch check.
# It answers one question about the working tree as it stands.
#
# It does not walk into workspaces or sub-packages. Detection runs at the
# repository root, once. A monorepo wants a guard script per package, or a
# Makefile target that fans out, and either is a step this will find.
#
# It has no --dry-run because it writes nothing OF ITS OWN: no file is created,
# deleted, truncated or moved anywhere in this script. That is not the same as
# "changes nothing", and the difference matters. Every step it runs is somebody
# else's program — a package.json script, a Makefile target, an executable in
# GUARD_DIR — and those inherit no restraint from here. Dropping a test
# database is the usual one.
#
# --list is the dry-run equivalent: it shows the exact command each step would
# run, and runs none of them. Read it once, on a new project, before trusting
# this in a hook. That is the only place the full list is visible before it
# executes.

set -uo pipefail

say() { printf '\033[1mverify:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1mverify:\033[0m %s\n' "$*" >&2; exit 1; }

ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
  || die "not inside a git repository. Detection runs from the repository root so every detector looks in one place; cd into the repo and run this again."

# Empty is checked separately from failed. `cd ""` succeeds and stays put, so an
# empty ROOT would not fail here — it would silently detect in whatever
# directory you happened to be in, which is the wrong answer delivered
# confidently. A bare repository is the way it happens.
[ -n "$ROOT" ] \
  || die "git reported an empty repository root — this looks like a bare repository, which has no working tree to check. Run this from a normal checkout."

cd "$ROOT" || die "cannot cd to $ROOT"

# Resolved once, with symlinks collapsed, so the guard-directory containment
# check below compares two paths of the same kind.
ROOT_ABS=$(pwd -P)

# ---------------------------------------------------------------------------
# CONFIGURATION
# ---------------------------------------------------------------------------

# Where guard scripts live, relative to the repository root. Empty by default:
# an unset path is a path that cannot point at the wrong thing, and this is the
# one value worth setting by hand — see "Guard tests are the point" above.
#
# Set and missing is a hard failure, not a skip. A guard directory that got
# renamed is precisely the case where silently running zero guards looks
# identical to running them all.
#
# IT MUST BE A DEDICATED DIRECTORY. Every executable file in it is RUN, with no
# allowlist — unlike the npm and make candidate lists below, which name the
# handful of scripts they are willing to invoke. That asymmetry is the whole
# hazard: point this at scripts/ and verify.sh runs release.sh; point it at a
# bin directory and it runs every binary there. detect_guards refuses the cases
# it can prove wrong (outside the repository, the repository root itself, the
# directory holding this script), but it cannot tell a guard from a deploy
# script that happens to sit beside one. Give guards their own directory and
# put nothing else in it.
GUARD_DIR="${VERIFY_GUARD_DIR:-scripts/verify.d}"

# Which step --quick drops, by exact id (the left column of the table). Empty
# means "every step classified as a test", which is the right guess almost
# everywhere and is stated out loud when it is used.
SLOW_STEP="${VERIFY_SLOW_STEP:-}"

# Candidate script and target names, checked against what the project actually
# has. Nothing here is run unless it exists — these are search terms, not
# defaults, and the distinction is load-bearing: `npm run lint` on a project
# without a lint script fails, and a verify that invents work to do fails for
# reasons that have nothing to do with the code.
#
# It is also an allowlist, and that is the other half of why it exists. A
# package.json contains scripts named `deploy` and `db:reset`. Running
# everything found would be a verify that publishes.
NODE_TYPECHECK_SCRIPTS="typecheck type-check tsc types"
NODE_LINT_SCRIPTS="lint lint:ci eslint format:check fmt:check"
NODE_TEST_SCRIPTS="test test:ci"

# `verify` and `all` are absent on purpose: a Makefile in a repository holding
# this script very likely has a `verify` target that calls this script, and
# that is an infinite loop rather than a failed check. The guard below catches
# it anyway, for the `check` target that turns out to do the same thing.
MAKE_LINT_TARGETS="lint fmt-check format-check"
MAKE_TYPECHECK_TARGETS="typecheck type-check types"
MAKE_TEST_TARGETS="test check"

# ---------------------------------------------------------------------------
# Refuse to be re-entered.
#
# A detected step that calls this script back — `make check` running
# scripts/verify.sh is the way it happens — recurses until the machine gives
# up, and the symptom is a hang rather than an error. Dying on the second entry
# turns that into one legible failure naming the step that did it.
# ---------------------------------------------------------------------------
if [ -n "${VERIFY_RUNNING:-}" ]; then
  die "verify.sh invoked itself — a detected step calls this script back, which would recurse forever. Remove that target from the candidate lists in this file, or stop it calling verify."
fi
export VERIFY_RUNNING=1

QUICK=""
LIST=""
ONLY=""

while [ $# -gt 0 ]; do
  case "$1" in
    --quick) QUICK="yes" ;;
    --list)  LIST="yes" ;;
    --only)
      shift
      [ $# -gt 0 ] || die "--only needs a pattern. Usage: --only <substring of a step id>"
      ONLY="$1" ;;
    --only=*) ONLY="${1#--only=}" ;;
    *) die "unknown argument '$1'. Usage: verify.sh [--quick] [--list] [--only <pattern>]" ;;
  esac
  shift
done

if [ -t 2 ]; then
  C_PASS=$'\033[32m'; C_FAIL=$'\033[31;1m'; C_SKIP=$'\033[33m'; C_OFF=$'\033[0m'
else
  C_PASS=""; C_FAIL=""; C_SKIP=""; C_OFF=""
fi

# ---------------------------------------------------------------------------
# The plan. Built completely before anything runs, so that "nothing was
# detected" is discovered before a single command executes rather than after a
# five-minute suite.
#
# Parallel arrays indexed together, iterated with a counted loop: `${arr[@]}` on
# an empty array is an unbound-variable error under `set -u` in bash 3.2, which
# is the bash on every stock macOS, and the empty case is the one that matters
# most here.
# ---------------------------------------------------------------------------
STEP_ID=()
STEP_CLASS=()
STEP_CMD=()
STEP_SKIP=()
STEP_RESULT=()
STEP_SECS=()

add_step() { STEP_ID+=("$1"); STEP_CLASS+=("$2"); STEP_CMD+=("$3"); STEP_SKIP+=(""); }

# A step that was found but cannot run. Recorded rather than dropped, because
# "this project has a mypy config and mypy is not installed" is information,
# and a silently shorter table is not.
add_skip() { STEP_ID+=("$1"); STEP_CLASS+=("skip"); STEP_CMD+=(""); STEP_SKIP+=("$2"); }

q() { printf '%q' "$1"; }

have() { command -v "$1" >/dev/null 2>&1; }

# ---------------------------------------------------------------------------
# Guards first. They are greps: they finish before the toolchain has finished
# starting, and they are the only steps that catch the bug class described in
# the header. Putting them last would mean the cheapest answer arrives after
# the most expensive one.
# ---------------------------------------------------------------------------
detect_guards() {
  [ -n "$GUARD_DIR" ] || return 0

  [ -d "$GUARD_DIR" ] \
    || die "VERIFY_GUARD_DIR is set to '$GUARD_DIR' but there is no such directory. Create it, or unset the variable — a missing guard directory would otherwise run zero guards and look exactly like running all of them."

  # -------------------------------------------------------------------------
  # Containment. Everything below this point EXECUTES every file it finds, so a
  # mis-set GUARD_DIR is not a wrong answer — it is arbitrary code with the
  # developer's credentials, launched by the one command they were told is safe
  # to run before every commit.
  #
  # The three refusals below are the cases that can be proven wrong rather than
  # guessed at. Resolved with `cd`+`pwd -P` so that symlinks, `..` and relative
  # paths all collapse to one comparable form before being judged.
  # -------------------------------------------------------------------------
  local guard_abs self_abs
  guard_abs=$(cd "$GUARD_DIR" 2>/dev/null && pwd -P) \
    || die "VERIFY_GUARD_DIR is set to '$GUARD_DIR' but that directory could not be entered (permissions?)."

  # Outside the repository. `..`, an absolute path and a symlink pointing out of
  # the tree all land here. A guard is a check on THIS repository's source; a
  # directory outside it holds someone else's programs.
  case "$guard_abs/" in
    "$ROOT_ABS"/*) ;;
    *) die "VERIFY_GUARD_DIR '$GUARD_DIR' resolves to '$guard_abs', which is outside this repository ('$ROOT_ABS'). Every executable file in it would be RUN. Guard scripts belong in a dedicated directory inside the repository." ;;
  esac

  # The repository root itself. Running every executable at the root means
  # running whatever release, deploy or reset script the project keeps there.
  [ "$guard_abs" != "$ROOT_ABS" ] \
    || die "VERIFY_GUARD_DIR points at the repository root. Every executable file at the root would be RUN as a guard, including any release, deploy or database script. Put guards in a dedicated subdirectory — scripts/verify.d is the convention."

  # The directory holding this script. This is the likely mistake, because it is
  # where scripts live and it reads as the obvious answer: this template ships
  # verify.sh beside release.sh, backup.sh and migrate.sh, and pointing the
  # guard directory here would run all three and call the result a passing
  # check. Skipped when $0 cannot be resolved, which loses nothing — the two
  # refusals above still apply.
  self_abs=$(cd "$(dirname -- "$0")" 2>/dev/null && pwd -P) || self_abs=""
  if [ -n "$self_abs" ] && [ "$guard_abs" = "$self_abs" ]; then
    die "VERIFY_GUARD_DIR points at the directory holding verify.sh itself ('$guard_abs'). Every executable file beside this script — release.sh, backup.sh, migrate.sh — would be RUN as a guard. Put guards in a dedicated subdirectory of their own."
  fi

  local f path found=""
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    found="yes"

    # Made explicitly relative so the guard runs whether or not its directory is
    # on PATH — but only when it is not already absolute, since './' in front of
    # an absolute path silently resolves somewhere else entirely.
    case "$f" in /*) path="$f" ;; *) path="./$f" ;; esac

    # A guard that lost its executable bit never runs and nothing notices. That
    # is the same failure the whole script is about, one directory down. So is a
    # guard symlinked in from a shared directory whose target has since moved:
    # both are recorded, neither is dropped.
    if [ -x "$f" ]; then
      add_step "guard:$(basename "$f")" guard "$(q "$path")"
    elif [ ! -e "$f" ]; then
      add_skip "guard:$(basename "$f")" "broken symlink — it points at nothing, so this guard has not run since the target moved"
    else
      add_skip "guard:$(basename "$f")" "not executable — chmod +x it, or it will never run again either"
    fi

    # -L so a symlinked guard, and a symlinked guard DIRECTORY, are both seen.
    # Sharing one guard across sibling repositories by symlink is the normal way
    # to do it, and plain `-type f` does not match a symlink: those guards were
    # not skipped, not listed and not run, which is the silent-zero failure this
    # script exists to prevent. `! -type d` rather than `-type f` so a broken
    # symlink still surfaces above instead of vanishing again.
  done < <(find -L "$GUARD_DIR" -maxdepth 1 ! -type d 2>/dev/null | LC_ALL=C sort)

  [ -n "$found" ] || say "note: $GUARD_DIR is configured but empty. See 'Guard tests are the point' at the top of this file for what belongs there."
}

# ---------------------------------------------------------------------------
# Node. The script names come from package.json and nowhere else.
#
# Read with node rather than grepped, because a grep for '"test"' matches keys
# in devDependencies, in a nested tool config, and in lint-staged — and
# `npm run` on a name that is not a script exits non-zero, so the guess would
# surface as a failing check with no failing code behind it.
# ---------------------------------------------------------------------------
detect_node() {
  [ -f package.json ] || return 0

  if ! have npm || ! have node; then
    add_skip "npm" "package.json is here but node/npm is not on PATH — none of its checks could run"
    return 0
  fi

  local scripts rc
  scripts=$(node -e 'try{const s=require(process.cwd()+"/package.json").scripts||{};process.stdout.write(Object.keys(s).join("\n"))}catch(e){process.exit(1)}' 2>/dev/null); rc=$?

  # Kept separate from "there are no scripts" below. Both end in zero npm steps
  # and they need different answers: one is a project that has no checks yet,
  # the other is a file this script could not read, which is also about to break
  # every npm command anyone else runs today.
  if [ "$rc" -ne 0 ]; then
    add_skip "npm" "package.json could not be parsed by node — fix the JSON; no npm check could be read from it"
    return 0
  fi

  if [ -z "$scripts" ]; then
    add_skip "npm" "package.json declares no scripts — this Node project was not checked at all"
    return 0
  fi

  local class list name matched=""
  for class in typecheck lint test; do
    case "$class" in
      typecheck) list="$NODE_TYPECHECK_SCRIPTS" ;;
      lint)      list="$NODE_LINT_SCRIPTS" ;;
      test)      list="$NODE_TEST_SCRIPTS" ;;
    esac

    for name in $list; do
      printf '%s\n' "$scripts" | grep -qx -- "$name" || continue
      add_step "npm:$name" "$class" "npm run $(q "$name")"
      matched="yes"
      # One per class. Two matches usually means `test` and `test:ci` are the
      # same suite twice, and paying for a suite twice is how people stop
      # running verify.
      break
    done
  done

  if [ -z "$matched" ]; then
    say "note: package.json has scripts, but none named like a check. Looked for:"
    say "      $NODE_TYPECHECK_SCRIPTS $NODE_LINT_SCRIPTS $NODE_TEST_SCRIPTS"
    say "      Add the real names to the candidate lists near the top of this file."
  fi
}

detect_make() {
  local mk="" f
  for f in Makefile makefile GNUmakefile; do
    if [ -f "$f" ]; then mk="$f"; break; fi
  done
  [ -n "$mk" ] || return 0

  if ! have make; then
    add_skip "make" "$mk is here but make is not on PATH"
    return 0
  fi

  local class list target matched=""
  for class in typecheck lint test; do
    case "$class" in
      typecheck) list="$MAKE_TYPECHECK_TARGETS" ;;
      lint)      list="$MAKE_LINT_TARGETS" ;;
      test)      list="$MAKE_TEST_TARGETS" ;;
    esac

    for target in $list; do
      # Anchored at the start of the line so this matches a rule and not a
      # .PHONY declaration or a variable that happens to contain the word.
      grep -qE "^${target}[[:space:]]*:" "$mk" || continue
      add_step "make:$target" "$class" "make $(q "$target")"
      matched="yes"
      break
    done
  done

  # Said out loud, for the same reason the npm detector says it. A Makefile
  # whose target is `tests` or `ci` contributes nothing here, and in a project
  # that ALSO has a package.json the run still prints a full green table — one
  # that silently excludes the Makefile's suite. Contributing zero steps is
  # information; contributing zero steps quietly is the failure.
  if [ -z "$matched" ]; then
    say "note: $mk is here, but none of its targets are named like a check. Looked for:"
    say "      $MAKE_TYPECHECK_TARGETS $MAKE_LINT_TARGETS $MAKE_TEST_TARGETS"
    say "      Add the real names to the candidate lists near the top of this file."
  fi
}

detect_cargo() {
  [ -f Cargo.toml ] || return 0

  if ! have cargo; then
    add_skip "cargo" "Cargo.toml is here but cargo is not on PATH"
    return 0
  fi

  if cargo fmt --version >/dev/null 2>&1; then
    add_step "cargo:fmt" lint "cargo fmt --all -- --check"
  else
    add_skip "cargo:fmt" "rustfmt is not installed (rustup component add rustfmt)"
  fi

  if cargo clippy --version >/dev/null 2>&1; then
    # `-D warnings` is not strictness for its own sake: without it clippy prints
    # its findings and exits 0, so the step passes whatever it found — a check
    # that cannot fail. Loosen it here if this project has warnings it has
    # decided to keep, but loosen it visibly.
    add_step "cargo:clippy" lint "cargo clippy --all-targets -- -D warnings"
  else
    add_skip "cargo:clippy" "clippy is not installed (rustup component add clippy)"
  fi

  add_step "cargo:test" test "cargo test"
}

detect_python() {
  [ -f pyproject.toml ] || return 0

  PY_MATCHED=""

  # Configured in pyproject means the project uses it. A tool that merely
  # happens to be installed on this machine is not a check this project has,
  # and running it would invent a standard the repository never agreed to.
  py_tool() { # section-regex  binary  class  command
    grep -qE "$1" pyproject.toml || return 0
    PY_MATCHED="yes"
    if have "$2"; then
      add_step "py:$2" "$3" "$4"
    else
      add_skip "py:$2" "configured in pyproject.toml but '$2' is not on PATH — activate the virtualenv, or install it"
    fi
  }

  py_tool '^\[tool\.ruff'  ruff  lint      "ruff check ."
  py_tool '^\[tool\.black' black lint      "black --check ."
  py_tool '^\[tool\.mypy'  mypy  typecheck "mypy ."
  # pytest is matched anywhere in the file, not only as a [tool.pytest] section:
  # most projects configure nothing and simply depend on it.
  py_tool 'pytest'         pytest test     "pytest"

  if [ -z "$PY_MATCHED" ]; then
    say "note: pyproject.toml is here, but it configures none of the tools this"
    say "      looks for (ruff, black, mypy, pytest). No Python check was run."
  fi
}

detect_go() {
  [ -f go.mod ] || return 0

  if ! have go; then
    add_skip "go" "go.mod is here but go is not on PATH"
    return 0
  fi

  add_step "go:vet"  lint "go vet ./..."
  add_step "go:test" test "go test ./..."
}

detect_guards
detect_node
detect_make
detect_cargo
detect_python
detect_go

# ---------------------------------------------------------------------------
# Nothing detected is a failure, and the loudest one here.
#
# A verify that checks nothing and prints success is worse than no verify at
# all: it converts "I have not checked" into "it passed" for everyone
# downstream, including the hook that trusts the exit code.
# ---------------------------------------------------------------------------
if [ "${#STEP_ID[@]}" -eq 0 ]; then
  say "NO CHECKS DETECTED. Nothing was verified and nothing passed."
  say ""
  say "Looked at the repository root for: package.json scripts, a Makefile"
  say "target, Cargo.toml, pyproject.toml, go.mod, and VERIFY_GUARD_DIR"
  say "(currently ${GUARD_DIR:-unset})."
  say ""
  say "Give it something to run: add a check script to package.json, add a"
  say "target to the Makefile, or set VERIFY_GUARD_DIR to a directory of"
  say "executable guard scripts. Then run this again."
  exit 2
fi

if [ -n "$ONLY" ]; then
  n=${#STEP_ID[@]}
  kept=""
  for ((i = 0; i < n; i++)); do
    case "${STEP_ID[$i]}" in
      *"$ONLY"*) kept="yes" ;;
      # An existing reason wins. A step already skipped because its tool is not
      # installed is not skipped BY --only, and overwriting the reason would
      # report that it was filtered out when in fact it could not have run —
      # turning a real gap in the checks into a deliberate-looking choice.
      *) [ -n "${STEP_SKIP[$i]}" ] || STEP_SKIP[$i]="--only $ONLY" ;;
    esac
  done
  [ -n "$kept" ] || die "--only '$ONLY' matched none of the detected steps: $(printf '%s ' "${STEP_ID[@]}")"
fi

# --quick, resolved against the plan rather than assumed. A SLOW_STEP naming a
# step this project does not have is a stale config that would silently skip
# nothing while reporting that it skipped, so it is refused.
if [ -n "$QUICK" ]; then
  n=${#STEP_ID[@]}
  hit=""
  for ((i = 0; i < n; i++)); do
    if [ -n "$SLOW_STEP" ]; then
      [ "${STEP_ID[$i]}" = "$SLOW_STEP" ] || continue
    else
      [ "${STEP_CLASS[$i]}" = "test" ] || continue
    fi
    [ -n "${STEP_SKIP[$i]}" ] || STEP_SKIP[$i]="--quick"
    hit="yes"
  done

  if [ -z "$hit" ]; then
    if [ -n "$SLOW_STEP" ]; then
      die "VERIFY_SLOW_STEP is '$SLOW_STEP' but no detected step has that id: $(printf '%s ' "${STEP_ID[@]}")"
    fi
    say "note: --quick had no effect — no detected step is a test step."
  elif [ -z "$SLOW_STEP" ]; then
    say "--quick: skipping the test steps (set VERIFY_SLOW_STEP to an id to pick a different one)."
  fi
fi

n=${#STEP_ID[@]}

if [ -n "$LIST" ]; then
  say "$n step(s) detected. Nothing was run:"
  for ((i = 0; i < n; i++)); do
    if [ -n "${STEP_SKIP[$i]}" ]; then
      say "  $(printf '%-18s' "${STEP_ID[$i]}") SKIPPED — ${STEP_SKIP[$i]}"
    else
      say "  $(printf '%-18s' "${STEP_ID[$i]}") ${STEP_CMD[$i]}"
    fi
  done
  exit 0
fi

# ---------------------------------------------------------------------------
# Run everything. No early exit on failure: the point of one entry point is one
# answer, and an answer that stops at the first broken thing makes you pay the
# whole runtime again for the second.
#
# Output is streamed rather than captured, so a step that hangs is visible
# while it hangs instead of after it is killed.
# ---------------------------------------------------------------------------
started=$SECONDS
failed=0
passed=0
skipped=0

for ((i = 0; i < n; i++)); do
  if [ -n "${STEP_SKIP[$i]}" ]; then
    STEP_RESULT[$i]="SKIPPED"
    STEP_SECS[$i]="-"
    skipped=$((skipped + 1))
    continue
  fi

  say "── ${STEP_ID[$i]} — ${STEP_CMD[$i]}"
  t0=$SECONDS
  eval "${STEP_CMD[$i]}"
  code=$?
  STEP_SECS[$i]=$((SECONDS - t0))

  if [ "$code" -eq 0 ]; then
    STEP_RESULT[$i]="PASS"
    passed=$((passed + 1))
  else
    STEP_RESULT[$i]="FAIL"
    failed=$((failed + 1))
    say "${STEP_ID[$i]} failed (exit $code) — continuing so this run reports everything."
  fi
done

# ---------------------------------------------------------------------------
# The table. Three states, three words, three colours, and the word alone is
# enough when the colour is gone — this gets piped into files and hook output
# where nothing is a terminal.
# ---------------------------------------------------------------------------
say ""
say "───────────────────────────────────────────────────────────"

for ((i = 0; i < n; i++)); do
  case "${STEP_RESULT[$i]}" in
    PASS) colour="$C_PASS" ;;
    FAIL) colour="$C_FAIL" ;;
    *)    colour="$C_SKIP" ;;
  esac

  line="$(printf ' %s%-8s%s %-26s %5s' \
    "$colour" "${STEP_RESULT[$i]}" "$C_OFF" "${STEP_ID[$i]}" "${STEP_SECS[$i]}")"

  if [ "${STEP_RESULT[$i]}" = "SKIPPED" ]; then
    line="${line}   ${STEP_SKIP[$i]}"
  else
    line="${line}s"
  fi

  say "$line"
done

say "───────────────────────────────────────────────────────────"

elapsed=$((SECONDS - started))

# Counts are stated only for things that were measured. "0 failed" alongside
# four skips is true and misleading, so the skip count is never omitted and the
# exit code below refuses to call an all-skipped run a pass.
summary="${passed} passed"
[ "$failed" -eq 0 ]  || summary="${summary}, ${failed} FAILED"
[ "$skipped" -eq 0 ] || summary="${summary}, ${skipped} SKIPPED (not run — not verified)"

say "${summary}   [${elapsed}s]"

if [ "$failed" -gt 0 ]; then
  say ""
  say "Fix the FAILED step(s) above. To re-run one on its own:"
  for ((i = 0; i < n; i++)); do
    # Both halves shell-quoted, because both routinely contain spaces: this
    # family of projects lives under directories like "Privacy LLC", and a step
    # id is a guard's filename. Printed unquoted, the command this suggests
    # parses as extra arguments and dies on the copy-paste.
    [ "${STEP_RESULT[$i]}" = "FAIL" ] \
      && say "  bash $(q "$0") --only $(q "${STEP_ID[$i]}")"
  done
  exit 1
fi

# Nothing failed and nothing ran. That is not a pass, and the exit code is the
# only part of this output a hook or a CI job will read.
if [ "$passed" -eq 0 ]; then
  say ""
  say "NOTHING WAS VERIFIED — every detected step skipped. Exiting non-zero:"
  say "a clean table over an empty run is the one outcome this script exists"
  say "to prevent."
  exit 2
fi

exit 0
