#!/bin/sh
# scripts/gates — unified runner for the six green gates.
#
# This is the canonical entrypoint. EXECUTION.md, the pre-push hook,
# and the Forgejo workflow all delegate here so the gate definition
# lives in exactly one place; drift between docs and reality is
# impossible by construction.
#
# **THE COUNT NAMES LAYERS, NOT `run_gate` CALLS — and the two differ
# on purpose.** The contract is six layers:
#
#     1. Lint          ruff check · ruff format --check · OP-03 scan
#     2. Types         mypy --strict
#     3. Commits       AG-03 trailers
#     4. Tests (Py)    pytest + coverage · import-linter · dep audit
#     5. Tests (Zig)   zig build test · zig coverage (informational)
#     6. Tier 3        e2e + stress + perf
#
# Two CI merge blockers are deliberately NOT gates here — `build` and
# `sbom` — and the script SAYS SO on every run rather than omitting them
# quietly. See the skip_gate calls at the bottom for the reasoning.
#
# The `run_gate` calls below are FINER-GRAINED than that, so a red
# names the exact check rather than a layer. Do not recount them and
# publish the total as the contract: an eleven that means the same as
# a six is how "four" came to mean three different sets. If you need
# the number, read the list above; it is the one the docs cite.
#
# **FOUR → FIVE → SIX, D14, in two passes and each one taught by a
# failure.**
#   * Tier 3 (e2e + stress + perf) was missing: `addopts` deselects
#     those markers from the pytest gate, so "all gates green" was
#     compatible with every tier-3 test being red — and that lane
#     measured 60% red under CPU contention on `main`, undetected for
#     an arc.
#   * Then CI pipeline 470 reddened the chunk that fixed it, on
#     `ruff format --check` — a merge blocker this script did not run.
#     Deriving the rest of `.woodpecker.yml`'s gating commands turned
#     up two more with no counterpart here: the OP-03 placeholder scan
#     (same CI step) and the AG-03 trailer check (its own step).
#
# The lesson both passes teach: what blocks a merge is what this
# script must run, and the way to know that is to READ
# `.woodpecker.yml`, not to remember it.
#
# Two facts about this runner that the docs got wrong, corrected in
# the same commit (M-D ruling 13):
#
#   * **CI does NOT invoke this script.** `.woodpecker.yml` contains no
#     `scripts/gates` call at all; it runs its own discrete steps
#     (`lint`, `typecheck`, `zig-build-and-test`, `test-3.11/12/13`,
#     `tier-3`, the supervisor tiers). `TESTING.md`, `EXECUTION.md` and
#     `hooks/pre-push` each claimed otherwise. So this is a purely LOCAL
#     runner — which is exactly why it must not be narrower than CI:
#     it is the thing a contributor reads before deciding to push, and
#     a local "all green" that CI then reds is the trap that cost this
#     arc a 60%-red lane.
#   * Consequently tier-3 does NOT run twice on a push. It runs once
#     here, locally, and once in CI's own `tier-3` step.
#
# Usage:
#   scripts/gates              # all six gates, stop on first failure
#   scripts/gates --fast       # skip the Zig + tier-3 gates (slow path); pre-push hook uses this
#   scripts/gates --keep-going # run every gate even if an earlier one failed
#
# Exit codes: 0 all green; 1 some gate failed; 2 usage error.
#
# Reusable note: every gate is a POSIX-shell run-and-report call.
# Federation siblings copy this verbatim; only the gate command
# bodies need adjusting (and most of them are the same anyway).

set -eu

REPO_ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$REPO_ROOT"

PY="${PY:-.venv/bin/python}"
ZIG="${ZIG:-.venv/lib/python3.13/site-packages/ziglang/zig}"

fast=0
keep_going=0
for arg in "$@"; do
    case "$arg" in
        --fast) fast=1 ;;
        --keep-going) keep_going=1 ;;
        -h|--help)
            sed -n '1,/^set/p' "$0" | sed 's/^# \{0,1\}//'
            exit 0
            ;;
        *)
            printf 'scripts/gates: unknown flag: %s\n' "$arg" >&2
            exit 2
            ;;
    esac
done

# Colour helpers — disabled when stdout isn't a tty (CI logs stay clean).
if [ -t 1 ]; then
    BOLD="$(printf '\033[1m')"
    GREEN="$(printf '\033[32m')"
    RED="$(printf '\033[31m')"
    RESET="$(printf '\033[0m')"
else
    BOLD=""; GREEN=""; RED=""; RESET=""
fi

failed_gates=""

run_gate() {
    name="$1"; shift
    printf '%s==> %s%s\n' "$BOLD" "$name" "$RESET"
    if "$@"; then
        printf '%s    PASS%s  %s\n' "$GREEN" "$RESET" "$name"
    else
        printf '%s    FAIL%s  %s\n' "$RED" "$RESET" "$name"
        failed_gates="$failed_gates $name"
        if [ "$keep_going" -eq 0 ]; then
            printf '\n%sFAILED:%s%s\n' "$RED" "$RESET" "$failed_gates"
            printf '(run with --keep-going to see every gate)\n'
            exit 1
        fi
    fi
}

# Announce a merge-blocking CI check that this runner deliberately does NOT
# perform, with the reason and the by-hand command.
#
# **The defect was never that the local runner is narrower than CI — it is
# that it was narrower SILENTLY.** Twice in one day a branch was pushed on
# "all gates green" and reddened by a lane this script did not run: tier 3
# (60% red on `main`, unnoticed for an arc) and `ruff format --check` (CI
# pipeline 470). Both are gates now. The two below cannot be, and saying so
# out loud is the difference between a known boundary and a trap.
skip_gate() {
    printf '%s    SKIP%s  %s\n' "$BOLD" "$RESET" "$1"
    printf '            why: %s\n' "$2"
    printf '            by hand: %s\n' "$3"
}

# Gate 1 — lint. THREE checks, because CI's `lint` step runs three and
# this script ran one of them (D14, second pass). `ruff format --check`
# blocks every merge and had no counterpart here, so a branch could be
# "all gates green" locally and red on CI's very first step — the same
# gap as the missing tier-3, on a second axis, found the same way: by
# CI reddening a chunk whose subject is that gap.
#
# Note the paths: `ruff format --check` covers `scripts/` too (the
# repo's generators live there) while `ruff check` stays on src+tests,
# because the scaffold's subprocess use trips flake8-bandit. Copied
# from `.woodpecker.yml`'s lint step deliberately — if they drift, the
# local runner is the one that is wrong.
run_gate "ruff (lint)" "$PY" -m ruff check src tests
run_gate "ruff format --check" "$PY" -m ruff format --check src tests scripts

# Gate 1c — OP-03: no scaffold placeholders left in the two files a
# consumer reads first. The third command of CI's lint step.
run_gate "no scaffold placeholders (OP-03)" \
    sh -c "! grep -rEn '<org>|<your-|TODO-URL|FIXME-URL|placeholder-' pyproject.toml README.md"

# Gate 2 — types
run_gate "mypy --strict" "$PY" -m mypy --strict src

# Gate 2.5 — AG-03 commit trailers. CI's `commit-trailers` step, which
# had no counterpart here either. Cheap, purely local, and the failure
# it catches is the one that is most annoying to fix AFTER a push:
# the remedy is `git rebase --signoff`, i.e. a rewrite of the branch.
run_gate "commit trailers (AG-03)" "$PY" scripts/check_commit_trailers.py

# Gate 3 — Python tests + coverage. `--cov` (no source arg) honours
# `[tool.coverage.run].source = ["mgf.hrb"]` in pyproject.toml.
run_gate "pytest (default suite + coverage)" "$PY" -m pytest -q --cov --cov-fail-under=80

# Gate 3.5 — import-linter. Verifies the SDK's layering posture
# (PR-01: SDK forbids UI frameworks; PR-06: fmt is stricter still).
# A5 (presentation-layer plan) added these contracts; this gate
# wires them into the canonical runner so a future contributor
# accidentally importing PySide6 from `mgf.hrb.diagnostics` fails
# CI immediately. Fast (~1s); no reason to skip on --fast.
run_gate "import-linter (layering)" .venv/bin/lint-imports --config pyproject.toml

# Gate 3.6 — transitive-dep audit (PR-01 supply-chain). import-linter
# only sees the SDK's own source; it can't catch a UI framework
# arriving via a transitive dep of `mgf-common` (or any future
# production dep). This walks the actual installed dep closure from
# `mgf-hrb` and fails on any forbidden framework. Hardening unit S7
# of the presentation-layer hardening plan (2026-05-26).
run_gate "audit transitive deps (PR-01 supply-chain)" "$PY" scripts/audit_transitive_deps.py

# Gate 4 — Zig tests. Slow (~10s on a warm cache, ~60s cold). Pre-push
# hook skips this; CI runs it; local `scripts/gates` runs it by default.
if [ "$fast" -eq 1 ]; then
    printf '%s    SKIP%s  zig build test (--fast)\n' "$BOLD" "$RESET"
    printf '%s    SKIP%s  pytest tier 3 (--fast)\n' "$BOLD" "$RESET"
else
    run_gate "zig build test" "$ZIG" build --build-file hrb-code/build.zig --cache-dir hrb-code/.zig-cache test --summary all

    # Gate 4.5 — Zig coverage (informational). The script self-skips
    # (exit 0) when kcov is absent, so this never blocks contributors
    # without the tool; where kcov IS installed (CI), it prints the
    # broker's line-coverage number. No threshold here — measurement,
    # not enforcement, until a baseline is agreed.
    run_gate "zig coverage (informational)" scripts/zig-coverage

    # Gate 5 — tier 3 (e2e + stress + perf). `addopts` deselects these
    # markers from gate 3, so nothing above this line runs a single one
    # of them. CI has gated them on every push and PR all along
    # (`.woodpecker.yml`'s `tier-3` step, no `failure: ignore`); the gap
    # was between CI and the runner a contributor actually consults.
    #
    # ~3s idle at MGF_HRB_STRESS_FACTOR=1, the pre-merge magnitude CI
    # uses — so leaving it out was never about cost. Held out of --fast
    # with the Zig gate, because the broker binary this lane drives is
    # exactly what --fast declines to rebuild; a stale binary here would
    # fail the freshness guard rather than test anything.
    run_gate "pytest (tier 3: e2e + stress + perf)" \
        "$PY" -m pytest -q -m 'e2e or stress or perf'
fi

# ---------------------------------------------------------------------------
# The boundary, announced rather than hidden.
#
# `build` and `sbom` gate every pull request in CI (neither step carries a
# `when:`, so both run on every event) and neither has a counterpart here.
# That is deliberate, and it is a designer ruling rather than an oversight:
#
#   * `build`, `twine` and `cyclonedx-bom` are RELEASE-CHAIN tools, not
#     development tools. Putting them in `[dev]` would make every
#     contributor and every CI test step install them for a gate that
#     matters at release. CI installs them per-step instead.
#   * A `--release` flag that ran them "when the tools happen to be present"
#     was considered and measured to be worth less than it looks. On a stock
#     checkout there is nothing to run: none of the three is importable from
#     `.venv`. And `pyproject.toml` declares `requires = ["hatchling>=1.21"]`,
#     so `python -m build` provisions its build environment FROM THE INDEX —
#     it needs the network exactly as `sbom` does, which sits badly against
#     TS-02 (the suite runs with no network, no creds; see
#     `docs/claude/CLAUDE.md` and the conformance tracker's TS-02 row).
#     A gate that is green or red depending on connectivity is not a gate.
#
# So they are named, not run. A contributor who reads this output knows the
# boundary of what "All gates green" means, which is the whole property the
# silent tier-3 omission destroyed.
# ---------------------------------------------------------------------------
skip_gate "packaging (CI step: build)" \
    "gates every PR; needs build+twine — release-chain tools, deliberately not [dev] deps" \
    "pip install build twine && python -m build && twine check dist/*.whl dist/*.tar.gz"
skip_gate "SBOM (CI step: sbom)" \
    "gates every PR; needs cyclonedx-bom AND the network, which TS-02 keeps out of this suite" \
    "pip install 'cyclonedx-bom>=7,<8' && cyclonedx-py environment -o dist/mgf-hrb.cdx.json"

if [ -n "$failed_gates" ]; then
    printf '\n%sFAILED:%s%s\n' "$RED" "$RESET" "$failed_gates"
    exit 1
fi

printf '\n%sAll gates green.%s\n' "$GREEN" "$RESET"
