#!/bin/bash
export ROUTERTL_PRECOMMIT=1

# RTL-P1.44: unset GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE.
#
# `git commit` sets these to point at the committing repo when it runs
# us.  Any subprocess that does `git init` / `git config user.email X`
# in a tempdir (even with cwd=tmp) then targets the *committing repo's*
# config via the inherited GIT_DIR, silently injecting
# [user] email/name and sometimes core.bare=true / core.worktree=/tmp/...
# into the shared submodule config.  At commit time, git reads the
# polluted config — with core.bare=true or core.worktree pointing at a
# deleted /tmp/, it snapshots the wrong tree, producing the
# "catastrophic 895-file deletion commit" failure mode.
#
# Unsetting here protects every downstream subprocess (`make regression`,
# `python3 -m unittest discover ...`, `make clean sim-regression`)
# without requiring per-test discipline.  Tests that legitimately need
# these vars can re-export them from their setUp.
# RTL-P3.954: unset FORCE_COLOR so rich does not colorize CAPTURED test output (breaks plain-substring CLI assertions).
unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE FORCE_COLOR

# ── RTL-P3.230 — Index sweep guard (snapshot at hook entry) ──
# Captures the staged file list right at hook entry so the exit-time
# diff at the bottom of this file can tell the user EXACTLY which paths
# entered the index during the hook window.  Catches both: (1) a hook
# step that silently stages files (only `git add VERSION` below is
# sanctioned), and (2) a concurrent agent/shell running `git add` while
# our hook is mid-flight — the multi-agent failure mode that bit
# RTL-P2.478 (XPM lint commit also picked up an unrelated doc rename).
# This snapshot is read-only — no mutation, no exit on failure.
PRECOMMIT_INDEX_SNAPSHOT=$(git diff --cached --name-only --diff-filter=ACMRDT 2>/dev/null | sort)

# RTL-P2.885: every rr/Python gate must resolve from the committing worktree.
# Linked worktrees intentionally do not replace the host-wide editable install,
# so a bare `rr` would otherwise execute canonical-checkout code against these
# files and can discover the canonical checkout's tests.
RR_HOOK_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$RR_HOOK_ROOT" ] || ! cd "$RR_HOOK_ROOT"; then
    echo "❌ pre-commit: cannot resolve the committing worktree root." >&2
    exit 1
fi
# RTL-P2.994: only pin ROUTERTL_ROOT to the committing worktree when it
# actually holds the SDK. For a consumer repo (no local sdk/), pinning it to
# a bogus <consumer>/sdk path misdirects project-pre-commit's SDK lookup (it
# never falls through to vendor/routertl or the pip-installed SDK). Leave a
# caller-provided ROUTERTL_ROOT untouched in that case.
if [ -f "$RR_HOOK_ROOT/sdk/engine/project_manager.py" ]; then
    export ROUTERTL_ROOT="$RR_HOOK_ROOT"
fi
export PYTHONPATH="$RR_HOOK_ROOT:$RR_HOOK_ROOT/sim/cocotb${PYTHONPATH:+:$PYTHONPATH}"
RR=(python3 -m sdk.cli.main)

# ── TIC-P1.3 / RTL-P2.855 — fail-closed against a FOREIGN-worktree hook ──
# If core.hooksPath drifted to ANOTHER agent's worktree hooks (bypassing the
# shared coord-hooks multiplexer — the RTL-P2.855 root cause, before it has
# propagated to every worktree), git runs THAT checkout's pre-commit against
# OUR tree: it validates the wrong files, inherits unrelated agent state, or
# hangs behind unrelated work. Refuse (before the flock, so a foreign hook fails
# fast) rather than run blind. This snapshot capture above is read-only and stays
# the first non-comment line (RTL-P3.230); the guard follows it.
#
# Only a DIRECT hooksPath is suspect: an empty value (default .git/hooks) or one
# ending in /coord-hooks (the coordination chain deliberately delegates — incl.
# deepskopion→routertl and vendored consumers) is legitimate. For a direct path,
# the hook must live inside the worktree being committed; if it lives under a
# DIFFERENT worktree, that is the drift. Override (unsafe): RR_ALLOW_FOREIGN_HOOKS=1.
if [ "${RR_ALLOW_FOREIGN_HOOKS:-}" != "1" ]; then
  _tic_hp="$(git config core.hooksPath 2>/dev/null || true)"
  case "$_tic_hp" in
    ""|*/coord-hooks) : ;;  # default hooks or coord-managed multiplexer — fine
    *)
      _tic_self="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P || true)"
      _tic_wt="$(git rev-parse --show-toplevel 2>/dev/null || true)"
      [ -n "$_tic_wt" ] && _tic_wt="$(cd "$_tic_wt" 2>/dev/null && pwd -P || true)"
      if [ -n "$_tic_self" ] && [ -n "$_tic_wt" ]; then
        case "$_tic_self/" in
          "$_tic_wt"/*) : ;;  # our own worktree's (or vendored) hook — fine
          *)
            echo "❌ pre-commit REFUSED: core.hooksPath points at a FOREIGN worktree's hooks (TIC-P1.3)." >&2
            echo "   hook script : $_tic_self" >&2
            echo "   committing  : $_tic_wt" >&2
            echo "   core.hooksPath drifted (RTL-P2.855) — running it would validate the WRONG tree." >&2
            echo "   Repair the shared coord-hooks chain from the tich-super root, then retry:" >&2
            echo "     bash tools/install-coord-hooks.sh" >&2
            echo "   Override (unsafe, runs the foreign hook): RR_ALLOW_FOREIGN_HOOKS=1 git commit …" >&2
            exit 1
            ;;
        esac
      fi
      ;;
  esac
fi

# ── RTL-P3.1181 (epic TIC-P2.13) — agents should commit from a WORKTREE ──
# The shared MAIN checkout is where the index / source / hook races live: two
# agents in one .git/index clobber each other's staged sets (the flock below
# guards hook EXECUTION, not the `git add` window — see its note). A per-agent
# `git worktree` isolates the index. Detect the main checkout path-independently
# — a linked worktree's --git-dir differs from --git-common-dir; in the main
# checkout they are equal. WARN-FIRST rollout: advise, don't block, unless
# ROUTERTL_ENFORCE_WORKTREE=1. Humans exempt via TICH_HUMAN=1.
if [ "${TICH_HUMAN:-}" != "1" ]; then
  _p1181_gd="$(git rev-parse --git-dir 2>/dev/null || true)"
  _p1181_gcd="$(git rev-parse --git-common-dir 2>/dev/null || true)"
  _p1181_gd="$(cd "$_p1181_gd" 2>/dev/null && pwd -P || echo "$_p1181_gd")"
  _p1181_gcd="$(cd "$_p1181_gcd" 2>/dev/null && pwd -P || echo "$_p1181_gcd")"
  if [ -n "$_p1181_gd" ] && [ "$_p1181_gd" = "$_p1181_gcd" ]; then
    if [ "${ROUTERTL_ENFORCE_WORKTREE:-}" = "1" ]; then
      echo "❌ pre-commit REFUSED: committing in the shared MAIN checkout (RTL-P3.1181)." >&2
      echo "   Agents must commit from their own git worktree — the shared index is where" >&2
      echo "   cross-agent staging races happen. Override: TICH_HUMAN=1 (human quick-fix)." >&2
      exit 1
    fi
    echo "⚠️  pre-commit: committing in the shared MAIN checkout (RTL-P3.1181, warn-first)." >&2
    echo "   Agents should commit from their own worktree (index/source-race isolation);" >&2
    echo "   becomes fail-closed under ROUTERTL_ENFORCE_WORKTREE=1. Human? set TICH_HUMAN=1." >&2
  fi
fi

# ── RTL-P3.959 (RTL-P3.956 second half) — serialize concurrent pre-commit hooks ──
# Multiple agents share this ONE working tree on the bench. Two pre-commit hooks
# running at once trample shared build state (sim/work, .routertl_cache) and read
# project.yml while another `rr target set` rewrites it — the concurrent-hook race
# that false-failed an otherwise-clean VHDL lint (RTL-P3.817). Hold an advisory
# flock for this hook's lifetime so the heavy checks run one-at-a-time across
# agents; the lock auto-releases when this process exits or crashes (tied to fd 9).
# FAIL-OPEN: a missing flock(1) or a wait timeout proceeds UNSERIALIZED with a
# warning — never block a commit forever. Keyed on --git-common-dir so all
# worktrees of this repo share one lock. Placed AFTER the index snapshot above
# (which must stay within the first few non-comment lines, RTL-P3.230) — the
# snapshot is a read-only entry capture; the lock only guards the heavy steps.
# NB this serializes hook EXECUTION only; it does NOT cover the separate `git add`
# index-staging race (a parallel agent restaging between your `git add` and
# `git commit`) — that needs per-agent worktrees (a distinct follow-up).
if command -v flock >/dev/null 2>&1; then
    _RR_PRECOMMIT_LOCK="$(git rev-parse --git-common-dir 2>/dev/null || echo .)/rr-precommit.lock"
    if exec 9>"$_RR_PRECOMMIT_LOCK" 2>/dev/null; then
        if ! flock -w 1800 9 2>/dev/null; then
            echo "⚠️  rr-precommit: lock wait timed out (1800s) — proceeding unserialized (RTL-P3.959)."
        fi
    fi
fi

# ── RTL-T2.45 — validation-only commit guard (fail fast, BEFORE expensive steps) ──
# When a nightly runbook DRAIN agent runs validation-only (RR_RUNBOOK_VALIDATION=1,
# exported by ~/.claude/nightly_runbook.sh), it may commit ONLY logs + validation
# notes — never source / examples / rr artifacts / project.yml / submodule bumps.
# Outside that mode this is a no-op. (Born from the 2026-06-18 breach where drain
# agents committed example designs + dcp/imgui.ini junk to routertl main.)
_T245_GUARD="$(dirname "${BASH_SOURCE[0]:-$0}")/validation_commit_guard.sh"
if [ -x "$_T245_GUARD" ] || [ -f "$_T245_GUARD" ]; then
    bash "$_T245_GUARD" || exit 1
fi

# ── RTL-P4.37 — Staged-scope shape guard (entry-time, BEFORE hook fires) ──
# P3.230 catches index pollution that happens DURING the hook.  P4.37
# catches the more common pattern: pollution that lands BEFORE hook
# entry (Edit-tool auto-stage, parallel-agent `git add`, accidental
# `git commit -a` / `git add -u` sweeping unstaged tracked changes).
#
# Heuristic-only — there's no clean signal for "user's intent" in git's
# data model.  Surfaces the staged set's SHAPE (file count, top-level
# path groups, index-ctime spread) and flags it as suspicious when:
#   • > 4 distinct sdk-area / top-level path groups, OR
#   • > 50 staged files, OR
#   • ≥ 3 index-ctime buckets spanning > 600 s (10 min)
# Default mode: loud warning to stderr, commit proceeds.
# Strict mode (ROUTERTL_STAGE_GUARD_STRICT=1): exit 1 with the summary.
#
# Rationale for printing every commit (not just suspicious ones): the
# user / agent gets a one-glance scope check at the top of every hook
# run.  When pollution happens, the shape stands out before 20 min of
# sim-regression burns.  When the commit is clean, the summary still
# catches typos like "I meant to add 6 files but staged 60".
_p437_stage_scope_guard() {
    local _staged
    _staged=$(git diff --cached --name-only --diff-filter=ACMRDT 2>/dev/null)
    [ -z "$_staged" ] && return 0

    local _count
    _count=$(echo "$_staged" | wc -l | tr -d ' ')

    # Group by sdk/sim 2-level prefix, top-level otherwise.  Coarser than
    # full path (would explode to one-group-per-file), finer than depth=1
    # (would lump all sdk/* into one bucket and miss cross-area mixing).
    local _groups
    _groups=$(echo "$_staged" | awk -F/ '
        /^sdk\// && NF >= 2  { print $1"/"$2; next }
        /^sim\// && NF >= 2  { print $1"/"$2; next }
                             { print $1 }
    ' | sort | uniq -c | sort -rn)
    local _ngroups
    _ngroups=$(echo "$_groups" | wc -l | tr -d ' ')

    # Index-entry ctime spread.  Caveat: ctime here is the FILE ctime at
    # the moment of staging, not the moment of `git add` itself — but in
    # practice files staged together have similar filesystem ctimes, so
    # the spread is a useful proxy for "are these from the same work
    # session or were they cobbled together from different times?"
    local _staged_ctimes
    _staged_ctimes=$(git ls-files -s --debug 2>/dev/null | awk -v staged="$_staged" '
        BEGIN {
            n = split(staged, arr, "\n")
            for (i = 1; i <= n; i++) want[arr[i]] = 1
        }
        /^[0-7]+ / { path = $NF; capture = (path in want) }
        /^  ctime:/ && capture { split($2, a, ":"); print a[1] }
    ' | sort -un)
    local _nbuckets _earliest _latest _spread_sec
    _nbuckets=$(echo "$_staged_ctimes" | wc -l | tr -d ' ')
    _earliest=$(echo "$_staged_ctimes" | head -1)
    _latest=$(echo "$_staged_ctimes" | tail -1)
    _spread_sec=$((_latest - _earliest))

    echo ""
    echo "🔍 Staged scope (RTL-P4.37 guard):"
    echo "   ${_count} file(s) across ${_ngroups} path-group(s):"
    echo "$_groups" | sed 's/^/     /'
    echo "   index-ctime spread: ${_spread_sec}s across ${_nbuckets} bucket(s)"

    local _suspicious=0
    local _reasons=""
    if [ "$_ngroups" -gt 4 ]; then
        _suspicious=1
        _reasons="${_reasons}
     • Wide path scope: ${_ngroups} top-level/sdk-area groups (> 4 = unusual)"
    fi
    if [ "$_count" -gt 50 ]; then
        _suspicious=1
        _reasons="${_reasons}
     • Large bundle: ${_count} staged files (> 50 = unusual)"
    fi
    if [ "$_nbuckets" -ge 3 ] && [ "$_spread_sec" -gt 600 ]; then
        _suspicious=1
        _reasons="${_reasons}
     • Time spread: ${_nbuckets} ctime buckets across ${_spread_sec}s (> 10 min)"
    fi

    if [ "$_suspicious" -eq 1 ]; then
        echo ""
        echo "⚠️  Staged scope looks unusually broad. Reasons:${_reasons}"
        echo "   This may be the RTL-P4.37 pollution pattern (parallel-agent"
        echo "   git add, Edit-tool auto-stage, or 'git commit -a' sweep)."
        echo "   Review the staged set above before proceeding."
        if [ "${ROUTERTL_STAGE_GUARD_STRICT:-0}" = "1" ]; then
            echo ""
            echo "❌ ROUTERTL_STAGE_GUARD_STRICT=1 — aborting commit."
            echo "   Unstage what you didn't mean to commit, or re-run with"
            echo "   ROUTERTL_STAGE_GUARD_STRICT=0 if the bundle is intentional."
            return 1
        fi
        echo "   (Set ROUTERTL_STAGE_GUARD_STRICT=1 to abort on this signal.)"
    fi
    echo ""
    return 0
}

if ! _p437_stage_scope_guard; then
    exit 1
fi

# ── Context Guard: SDK vs Consumer ──
# When core.hooksPath points here, git runs THIS hook for both the SDK
# and consumer projects.  If we detect a consumer context (vendor/routertl
# exists), delegate to the consumer-specific project-pre-commit hook.
HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
# Consumer detection: submodule OR pip install (has project.yml, no sdk/ dir)
IS_CONSUMER=false
if [ -d "vendor/routertl" ]; then
    IS_CONSUMER=true
elif [ -f "project.yml" ] && [ ! -d "sdk" ]; then
    IS_CONSUMER=true
fi
if [ "$IS_CONSUMER" = true ] && [ -f "$HOOK_DIR/project-pre-commit" ]; then
    exec "$HOOK_DIR/project-pre-commit"
fi

# ── Markdown-Only Fast Path ──
# If the commit only touches .md files, run docs checks only (link
# validation + MkDocs strict build), then exit before heavyweight stages.
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)

# ── RTL-P4.138 — deletion-aware haystack for the suite-skip GATES ──
# STAGED_FILES (ACMR) drops Deletions, but the suite-skip gates below
# (HDL / Makefile-test / regbank / linux-utils) decide "does the staged
# set TOUCH this surface?" purely from PATH NAMES — never file contents.
# A DELETION is exactly the commit that can break a suite's subject
# (removing tcl/gen_synthesis.tcl, an sdk/mk/*.mk, or a regbank module),
# yet with D excluded the gate matches nothing and SKIPS on the very
# commit that breaks it. Build a parallel haystack that ALSO lists
# Deleted paths (ACMRD) and the OLD path of a Rename (a rename can move a
# gate-relevant file OUT of its subject dir; --name-only reports only the
# NEW path). STAGED_FILES itself STAYS ACMR for the content / routing
# consumers — the markdown fast path, the project.yml diff nudge, and the
# RTL-P3.552 regression-narrowing helper — that must NOT be handed deleted
# paths (a deleted module + its deleted tests would narrow to zero
# discovered tests and trip the "Found [1-9]" sanity abort).
STAGED_FILES_FOR_GATES=$(
  {
    git diff --cached --name-only --diff-filter=ACMRD
    git diff --cached --name-status --diff-filter=R | awk -F'\t' '{print $2}'
  } 2>/dev/null | sort -u
)

# ── project.yml change nudge ──
# project.yml is load-bearing config (paths.sources, top, hooks, packages).
# A silent or accidental change to it — e.g. a leftover scratch edit like a
# minimal `name: test` stub left over from probing the toolchain — is easy
# to commit by mistake and breaks the project in confusing ways. Surface any
# staged change to it loudly so it gets a deliberate look. ADVISORY ONLY:
# this never blocks the commit and never reverts the change (a hook that
# silently rewrites staged content would be the real footgun). It just makes
# a project.yml change impossible to miss.
if echo "$STAGED_FILES" | grep -qxF "project.yml"; then
    echo ""
    echo "⚠️  project.yml is staged — review this change (advisory; not a gate):"
    git --no-pager diff --cached -- project.yml | sed 's/^/     /'
    echo ""
fi

# ── RTL-P2.666 — project.yml canonical / anti-clobber guard ──
# project.yml is load-bearing: this very hook reads its test-exclude list from
# it (hooks.pre_commit.exclude.tests, below). A clobbered / truncated working
# copy (sections silently deleted) makes the gate mis-scope AND risks committing
# the broken file. Abort BEFORE any heavy step consumes it. Same guard family as
# RTL-P3.230 (index-sweep) / P4.37 (stage-scope). Override:
# RR_ALLOW_PROJECT_YML_SHRINK=1 for a deliberate section removal.
if [ -f "project.yml" ] && [ -f "sdk/cli/check_project_canonical.py" ]; then
    if ! python3 sdk/cli/check_project_canonical.py; then
        exit 1
    fi
fi

NON_MD_FILES=$(echo "$STAGED_FILES" | grep -v '\.md$' || true)
if [ -z "$NON_MD_FILES" ] && [ -n "$STAGED_FILES" ]; then
    echo "📝 Markdown-only commit — running docs checks only."

    # Link validation
    if [ -f "sdk/infra/docs/check_links.py" ]; then
        echo "🔗 Validating Markdown Links..."
        python3 sdk/infra/docs/check_links.py --dir docs --exclude docs/internal
        if [ $? -ne 0 ]; then
            echo "❌ Markdown link validation failed. Commit aborted."
            exit 1
        fi
        echo "✅ Markdown link validation passed."
    fi

    # MkDocs strict build
    if [ -f "mkdocs.yml" ]; then
        echo "📚 Building documentation (mkdocs build --strict)..."
        MKDOCS_TMPDIR="/tmp/mkdocs-precommit-$$"
        mkdocs build --strict --site-dir "$MKDOCS_TMPDIR" 2>&1
        if [ $? -ne 0 ]; then
            echo "❌ MkDocs build failed. Commit aborted."
            rm -rf "$MKDOCS_TMPDIR"
            exit 1
        fi
        rm -rf "$MKDOCS_TMPDIR"
        echo "✅ MkDocs build passed."
    fi

    echo "🎉 Docs checks passed — skipping code/sim checks."
    exit 0
fi

# ── RTL-P3.426 — No-HDL-touched fast path ──
# When the staged set doesn't touch any HDL surface, skip the two
# heavy steps — VHDL linting (`make linting`, ~minutes) and cocotb
# sim-regression (`make clean sim-regression`, ~1-3 min even with
# the eth_validator exclusion). All other checks (ruff, shellcheck,
# CLI help, CI imports, regbank unit tests, Makefile tests, project
# YAML regression, the sweep + scope guards) still run — they're
# cheap and surface real issues for non-HDL code.
#
# Trigger full hook on:
#   • HDL extensions:  .vhd .vhdl .v .sv .svh .vh  (matches everywhere,
#                      including test fixtures under routertl_core/sdk/engine)
#   • Path prefixes:   libs/  ip/  sim/  examples/  src/
#   • Specific files:  Makefile  project.yml  setup.py
#                      pyproject.toml  cythonize.py
#
# RTL-P3.533 (this commit): the previous wide prefixes
# ^routertl_core/ / ^sdk/engine/ / ^sdk/infra/{lint,build}/ fired the
# full sim-regression on every Python touch under those dirs (resolver
# helpers, scanner, install-time logic, registry plumbing), even when
# the change couldn't affect HDL semantics. SDK-internal .py code is
# already covered by the pytest suite that runs unconditionally; the
# sim-regression is reserved for changes that actually move HDL or
# build wiring, caught via the file-extension match above and the
# top-level HDL roots.
#
# Everything else (sdk/cli/, sdk/scripts/, sdk/engine/*.py,
# routertl_core/*.py, sdk/infra/docker/, sdk/infra/hooks/, docs/,
# admin/, ci/, scripts/, tools/, root-level *.md / VERSION /
# CHANGELOG / LICENSE / .gitignore) → fast path.
HDL_PATTERN='\.(vhd|vhdl|v|sv|svh|vh)$|^libs/|^ip/|^sim/|^examples/|^src/|^Makefile$|^project\.yml$|^setup\.py$|^pyproject\.toml$|^cythonize\.py$'
HDL_TOUCHED=0
HDL_TOUCHED_FILES=$(echo "$STAGED_FILES_FOR_GATES" | grep -E "$HDL_PATTERN" || true)
if [ -n "$HDL_TOUCHED_FILES" ]; then
    HDL_TOUCHED=1
fi

# RTL-T2.154: BFM/helper Python changes can alter every cocotb transaction
# without touching HDL.  Keep VHDL lint scoped to HDL_TOUCHED, but force the
# simulation leg for these semantic testbench surfaces.
SIM_PATTERN="${HDL_PATTERN}|^rr_cocotb_tb/|^sdk/cocotb_helpers/"
SIM_TOUCHED=0
SIM_TOUCHED_FILES=$(echo "$STAGED_FILES_FOR_GATES" | grep -E "$SIM_PATTERN" || true)
if [ -n "$SIM_TOUCHED_FILES" ]; then
    SIM_TOUCHED=1
fi

HDL_UNIT_SCOPE=0
HDL_UNIT_TOP=""
HDL_UNIT_TEST_PATTERNS=""
mapfile -t _HDL_UNIT_SCOPE < <(
    printf "%s\n" "$STAGED_FILES_FOR_GATES" | \
        python3 sdk/infra/hooks/_hdl_unit_scope.py 2>/dev/null
)
if [ "${#_HDL_UNIT_SCOPE[@]}" -eq 2 ]; then
    HDL_UNIT_SCOPE=1
    HDL_UNIT_TOP="${_HDL_UNIT_SCOPE[0]}"
    HDL_UNIT_TEST_PATTERNS="${_HDL_UNIT_SCOPE[1]}"
fi

if [ "$HDL_TOUCHED" -eq 0 ]; then
    echo "⚡ No-HDL-touched fast path (RTL-P3.426 / RTL-P3.533): skipping VHDL linting + cocotb sim-regression."
    echo "   Trigger paths/extensions for full hook: HDL files (any path),"
    echo "   libs/, ip/, sim/, examples/, src/, Makefile, project.yml,"
    echo "   setup.py, pyproject.toml, cythonize.py — none of these are staged."
else
    echo "🔬 HDL surface touched — full hook (linting + sim-regression). Triggering files:"
    echo "$HDL_TOUCHED_FILES" | sed 's/^/     /'
    if [ "$HDL_UNIT_SCOPE" -eq 1 ]; then
        echo "🎯 Single-unit scope: $HDL_UNIT_TOP"
        echo "$HDL_UNIT_TEST_PATTERNS" | tr ' ' '\n' | sed 's/^/     /'
    fi
fi

# ── RTL-P3.1126 — Scope the two UNCONDITIONAL heavy fast-path suites ──
# The Makefile-test suite (~95s) and the regbank unittest (~2s) ran on
# every commit regardless of what was staged, so a Python/YAML-only
# commit paid the full Makefile-test cost — enough to push the hook past
# the agent harness command timeout (300s) and get commits KILLED
# mid-run. Same precedent as the RTL-P3.426 no-HDL fast path and the
# RTL-P3.552 regression narrowing below: skip a stage ONLY when NO staged
# file touches that suite's subject surface, print an explicit skip line,
# and rely on CI running BOTH suites UNCONDITIONALLY (bitbucket-pipelines.yml
# fast-tests step: regbank line 42, makefile-tests line 44 — verified).
#
# Makefile-test subject surface — what sdk/infra/makefile-tests/run_tests.py
# actually drives via `make`:
#   • the SDK Makefile + the sdk/Common.mk / sdk/mk/*.mk include chain
#   • the tcl/ vendor scripts the mock tools invoke (gen_synthesis.tcl)
#   • project.yml (build_env.mk generation reads it)
#   • the sdk/engine entry points the Common.mk make targets shell out to.
#     RTL-P3.1138: this is NOT just project_manager.py + smart_linter.py —
#     Common.mk also shells out to scan_sources.py (update-src/sim/dc/tcl/
#     ips, 5 invocations), check_env.py, dependency_resolver.py,
#     init_project.py and show_target_help.py. A staged change to any of
#     these breaks the make wiring the makefile-tests integration-test but
#     was previously SKIPPED. The engine-script alternation below is kept
#     in lock-step with Common.mk's actual shell-outs by the guard test
#     TestMakefilePatternCoversCommonMkEngineScripts. Engine scripts driven
#     ONLY by sim.mk (run_tests.py, gen_schematic.py, update_cicd.py) are
#     deliberately excluded — the makefile-tests suite does not exercise
#     those targets, and they are covered by the unconditional pytest +
#     project-YAML regression suites (RTL-P3.533's "SDK-internal .py is
#     covered by pytest" carve-out).
#   • the test files / mock tools themselves (sdk/infra/makefile-tests/)
MAKEFILE_TEST_PATTERN='^Makefile$|^sdk/Common\.mk$|^sdk/mk/|^sdk/infra/makefile-tests/|^tcl/|^project\.yml$|^sdk/engine/(check_env|dependency_resolver|init_project|project_manager|scan_sources|show_target_help|smart_linter)\.py$'
MAKEFILE_TEST_TOUCHED=0
if echo "$STAGED_FILES_FOR_GATES" | grep -qE "$MAKEFILE_TEST_PATTERN"; then
    MAKEFILE_TEST_TOUCHED=1
fi

# Regbank unittest subject surface: the regbank generator package PLUS its
# cross-module import surface. RTL-P3.1137: the tests do NOT import only
# sdk.generators.regbank.* — test_constants_only_pkg_p3_633.py imports
# _diff_regbank_chain from sdk.cli.commands.ip (3 sites), so a change to
# ip.py that touched that helper skipped the only local stage running the
# regbank tests. The pattern therefore also matches the imported module.
# The AST guard TestRegbankImportSurfaceCovered derives the actual
# cross-module import surface of sdk/generators/regbank/tests and asserts
# this pattern covers each file, so a NEW cross-module import breaks the
# test (not silently the gate).
REGBANK_PATTERN='^sdk/generators/regbank/|^routertl_core/generators/regbank/|^sdk/cli/commands/ip\.py$'
REGBANK_TOUCHED=0
if echo "$STAGED_FILES_FOR_GATES" | grep -qE "$REGBANK_PATTERN"; then
    REGBANK_TOUCHED=1
fi

REA_REGBANK_PATTERN='^ip/routertl/rea/rea_regbank\.yml$|^ip/routertl/rea/rtl/rr_rea_pkg\.vhd$|^sdk/cli/rea/(client|registers)\.py$|^sdk/cli/tests/test_rea_address_drift\.py$'
REA_REGBANK_TOUCHED=0
if echo "$STAGED_FILES_FOR_GATES" | grep -qE "$REA_REGBANK_PATTERN"; then
    REA_REGBANK_TOUCHED=1
fi

# ── RTL-P3.1134 — Scope the linux-utils pytest leg of the regression suite ──
# The regression engine suite runs ~103 pytest under sdk/generators/linux/tests
# AFTER the narrowable Project-Manager suite, so a Python/YAML-only commit paid
# it. Gate it on the staged linux-utils surface: this signal drives the Stage-4
# invocation below. RTL-P3.955: it is now consumed rr-natively — 0 adds
# `--skip-linux-utils` to `rr sim regression`; unset/1 lets the leg run.
# FAIL-OPEN: a bare `rr sim regression` (no flag) and CI still run the leg
# unconditionally — only the hook, when CONFIDENT no linux-utils path is
# staged, sets the skip.
LINUX_UTILS_PATTERN='^sdk/generators/linux/'
HOOK_LINUX_UTILS_TOUCHED=1
if ! echo "$STAGED_FILES_FOR_GATES" | grep -qE "$LINUX_UTILS_PATTERN"; then
    HOOK_LINUX_UTILS_TOUCHED=0
fi

# Step 0: Clear all caches — tests must always run clean
# RTL-P3.955 switchover: the hook no longer shells out to `make clean`.
# The removal set below is an inline replica of sdk/Common.mk's `clean`
# target (sdk/Common.mk:193-197 — dcp / project ($(PROJECT_DIR) default) /
# sim_build / caches / .deps.ok, the vendor scratch globs, db/incremental_db),
# followed by the hook's own extra sweep (sim/work + a repeat of the caches).
# Keep in lock-step with Common.mk if that target's removal set changes.
echo "Clearing build caches..."
rm -rf dcp project sim_build __pycache__ .pytest_cache .deps.ok
rm -rf ./*.qpf ./*.qsf ./*.qws ./*.bak ./*.smsg ./*.rpt ./*.summary ./*.pin ./*.done ./*.jdi ./*.sld ./*.sldq ./*.slds
rm -rf db incremental_db
rm -rf sim/work/ sim_build/ __pycache__ .pytest_cache
echo "✅ Caches cleared."

# 0.5 Version sync — generate VERSION from pyproject.toml (single source of truth)
echo "Syncing VERSION from pyproject.toml..."
python3 sdk/cli/sync_version.py
if [ $? -ne 0 ]; then
    echo "❌ Version sync failed. Commit aborted."
    exit 1
fi
git add VERSION
# RTL-P2.741: only refresh the editable install from the CANONICAL tree.
# A `pip install -e .` rewrites the SHARED site-packages editable finder MAPPING
# (__editable___routertl_*_finder.py) to whatever tree it runs in — so a commit
# from a linked git worktree silently repoints the whole machine's `rr`/`routertl`
# at that worktree's in-flux code (this hijacked the live runbook drain twice on
# 2026-06-18). The canonical tree owns the install; a worktree must never touch it.
# Detect a linked worktree by git-dir != git-common-dir (true only in linked WTs).
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
    echo "↪️  Linked worktree — SKIPPING 'pip install -e .' (RTL-P2.741: reinstalling"
    echo "   from a worktree would hijack the shared 'rr' install for the whole host)."
else
    # Drop the silent 2>/dev/null (no-silent-failures): surface a broken reinstall
    # loudly, but don't abort the commit on a transient pip hiccup — the prior
    # install is still on disk; only the version metadata would be stale.
    echo "Refreshing editable install (pip install -e .)..."
    if ! pip_err=$(pip install -e . -q 2>&1); then
        echo "⚠️  'pip install -e .' FAILED (was silently swallowed pre-P2.741):"
        echo "$pip_err" | sed 's/^/     /'
        echo "⚠️  Continuing commit — editable install may be stale; rerun manually."
    fi
fi
echo "✅ VERSION synced and staged."

# ── RTL-P3.955 — rr-native gate self-check (stale editable-install guard) ──
# The heavy gates below now invoke `rr` (sim regression / project sync-env /
# linting) instead of `make`. The stage-0 `pip install -e .` guard above is
# SKIPPED in a linked worktree (RTL-P2.741) — so a stale editable finder (a
# worktree that never reinstalled from the canonical checkout, or a machine
# where `pip install -e .` was never run) can resolve an OLD `rr` that lacks
# the --suite surface these gates depend on. Detect that cheaply and FAIL
# LOUDLY — never silently fall back to a wrong/half-migrated gate.
if ! "${RR[@]}" sim regression --help 2>/dev/null | grep -q -- '--suite'; then
    echo "❌ 'rr sim regression' is missing the '--suite' flag — the editable"
    echo "   install is STALE or 'rr' is not on PATH. The pre-commit gates now"
    echo "   run rr-native commands (RTL-P3.955) and need a CURRENT 'rr'."
    echo "   Reinstall from the CANONICAL checkout (never a linked worktree —"
    echo "   RTL-P2.741 — that hijacks the shared install):"
    echo "       pip install -e ."
    exit 1
fi

# Helper: read the engine-suite test excludes from project.yml.
# RTL-P3.955: the lint-exclude plumbing (HOOK_EXCLUDE_LINT) is GONE — `rr
# linting` reads hooks.pre_commit.exclude.lint from project.yml itself
# (RTL-P3.504). The cocotb sim-regression excludes are likewise read by
# `rr sim regression --suite cocotb` itself, so no SIM_EXCLUDES here either.
# Only the ENGINE regression suite still needs the hook to pass excludes: the
# CLI does NOT read project.yml for the engine suite (C1), only for cocotb.
EXCLUDE_TESTS=$(python3 -c "
import yaml, sys
with open('project.yml') as f:
    cfg = yaml.safe_load(f) or {}
excludes = cfg.get('hooks',{}).get('pre_commit',{}).get('exclude',{}).get('tests',[])
# Convert dotted module names to filenames
for e in excludes:
    parts = e.rsplit('.', 1)
    print(parts[-1] + '.py')
" 2>/dev/null)

# Build the engine-suite exclude list from project.yml (space-separated).
REGRESSION_EXCLUDES=""
for t in $EXCLUDE_TESTS; do
    REGRESSION_EXCLUDES="$REGRESSION_EXCLUDES $t"
done

# 1. Python linting (ruff) — fastest check first
echo "Running Python linting (ruff)..."
ruff check sdk/ routertl_core/ sim/cocotb/ --config pyproject.toml
if [ $? -ne 0 ]; then
    echo "❌ Python linting failed. Commit aborted."
    echo "   Run 'ruff check --fix sdk/ routertl_core/ sim/cocotb/' to auto-fix."
    exit 1
fi
echo "✅ Python linting passed."

# 1b. AST catch-all Exception ratchet — no new heads on the hydra
echo "Checking catch-all Exception AST ratchet..."
if ! python3 sdk/infra/hooks/check_catch_all_exceptions.py; then
    echo "❌ catch-all Exception AST ratchet failed."
    exit 1
fi
echo "✅ catch-all Exception AST ratchet passed."

# 1b-defer. RR-DEFER-001 Python-source ratchet (RTL-P3.1300) — the .py
# companion to the HDL deferral-comment lint. A vague deferral comment
# ('for now', bare TODO/FIXME, 'not yet <verb>') hides an unimplemented gap
# as a design note (the H264-T3.5 CABAC-desync mechanism). Grandfathered
# baseline; fails only if the count GROWS. Opt a genuine descriptive comment
# out inline with '# rr-lint-disable-line RR-DEFER-001'.
echo "Checking RR-DEFER-001 Python deferral-comment ratchet..."
if ! python3 sdk/infra/hooks/check_deferral_comments_py.py; then
    echo "❌ RR-DEFER-001 Python deferral-comment ratchet failed."
    exit 1
fi
echo "✅ RR-DEFER-001 Python deferral-comment ratchet passed."

# RTL-P2.1018: bench-derived board knowledge must not reach an open path.
# The boundary set at 65af9bf7 was crossed again by 43024ca2 the same day
# because it existed only as a convention in a commit message.
echo "Checking board-knowledge provenance..."
if ! python3 sdk/infra/hooks/check_board_knowledge_provenance.py; then
    echo "❌ Board-knowledge provenance guard failed."
    exit 1
fi
echo "✅ Board-knowledge provenance guard passed."

# RTL-P3.1333: every high-signal CLI enforcement function must point to an
# effect-level test or an explicit verification-debt ticket.
echo "Checking CLI enforcement-effect evidence..."
if ! python3 sdk/infra/hooks/check_gate_effect_tests.py; then
    echo "❌ CLI enforcement-effect guard failed."
    exit 1
fi
echo "✅ CLI enforcement-effect guard passed."

# 1b-tb. Bare except Exception ratchet for the cocotb BFM layer (RTL-P3.1065).
# rr_cocotb_tb/ carries deliberate fail-open handlers (the _safe_int X/Z signal
# read pattern) that predate this ratchet and are grandfathered. This is a
# "no new heads" freeze: it fails only if the count GROWS, catching a new bare
# handler (like the i2c _I2cVectorBit one that slipped in under 32332abc,
# because the guard above only scanned sdk/) before it lands. For a signal read,
# prefer a typed `except (ValueError, TypeError, AttributeError)`.
EXCEPT_BASELINE_TB=47
echo "Checking for new bare except Exception in rr_cocotb_tb/..."
VIOLATIONS_TB=$(grep -rn 'except Exception' rr_cocotb_tb/ --include='*.py' \
  | grep -v '/tests/' || true)
if [ -z "$VIOLATIONS_TB" ]; then
    VIOLATION_COUNT_TB=0
else
    VIOLATION_COUNT_TB=$(echo "$VIOLATIONS_TB" | wc -l | tr -d ' ')
fi
if [ "$VIOLATION_COUNT_TB" -gt "$EXCEPT_BASELINE_TB" ]; then
    echo "❌ except Exception count in rr_cocotb_tb/ grew: $VIOLATION_COUNT_TB (baseline: $EXCEPT_BASELINE_TB)"
    echo "$VIOLATIONS_TB"
    echo "   Use a typed except (ValueError, TypeError, AttributeError) for signal reads."
    exit 1
fi
echo "✅ rr_cocotb_tb except Exception ratchet passed ($VIOLATION_COUNT_TB ≤ $EXCEPT_BASELINE_TB baseline)."

# 1c. CLI help coverage guard — every Click command needs a docstring,
#     every @click.option needs help=.  Agents discover commands via
#     rr_help (MCP), so missing help = invisible to AI.
echo "Checking CLI help coverage..."
CLI_HELP_RESULT=$(python3 -c "
import ast, sys
from pathlib import Path

missing = []
cmd_dir = Path('sdk/cli/commands')
if not cmd_dir.exists():
    sys.exit(0)

for f in sorted(cmd_dir.glob('*.py')):
    try:
        tree = ast.parse(f.read_text())
    except SyntaxError:
        continue
    for node in ast.walk(tree):
        if not isinstance(node, ast.FunctionDef):
            continue
        # Check Click commands/groups for docstrings
        for dec in node.decorator_list:
            dec_s = ast.dump(dec)
            if 'command' in dec_s or 'group' in dec_s:
                if not ast.get_docstring(node):
                    missing.append(f'{f}:{node.lineno} {node.name}() missing docstring')
                break
        # Check @click.option for help=
        for dec in node.decorator_list:
            if not isinstance(dec, ast.Call):
                continue
            func = dec.func
            # Match click.option or @group.command patterns
            if isinstance(func, ast.Attribute) and func.attr == 'option':
                has_help = any(kw.arg == 'help' for kw in dec.keywords)
                if not has_help:
                    opt = ''
                    if dec.args and isinstance(dec.args[0], ast.Constant):
                        opt = dec.args[0].value
                    missing.append(f'{f}:{dec.lineno} {node.name}() {opt} missing help=')

if missing:
    for m in missing:
        print(m)
    sys.exit(1)
" 2>&1)
CLI_HELP_RC=$?
if [ "$CLI_HELP_RC" -ne 0 ]; then
    echo "❌ CLI help coverage gaps found:"
    echo "$CLI_HELP_RESULT"
    echo "   Every @click.command/@click.group needs a docstring."
    echo "   Every @click.option needs help=\"...\"."
    exit 1
fi
echo "✅ CLI help coverage passed."

# 1d. CI requirements import check — catch dep drift before CI does
echo "Checking CI requirements importability..."
CI_REQS_FAIL=0
for reqfile in ci/requirements-fast.txt ci/requirements-sim.txt; do
    if [ -f "$reqfile" ]; then
        while IFS= read -r pkg; do
            # Skip comments and blank lines
            [[ "$pkg" =~ ^#.*$ || -z "$pkg" ]] && continue
            # RTL-P4.36: strip PEP 508 env marker (everything after `;`)
            # AND version specifiers (>=, ==, <=, <, >, !=, ~=, ===) before
            # treating the line as a package name. Without this, a line
            # like `tomli; python_version < '3.11'` was passed verbatim
            # into `python3 -c "import tomli; python_version < '3_11'"`,
            # producing a false positive (and blocked the v3.24.0 release).
            base_pkg=$(echo "$pkg" \
                | sed -E 's/[[:space:]]*;.*//' \
                | sed -E 's/[[:space:]]*([<>=!~]=?|===|~=).*//' \
                | tr -d '[:space:]')
            [ -z "$base_pkg" ] && continue
            # Normalize package name: cocotbext-axi → cocotbext.axi, rich-click → rich_click
            import_name=$(echo "$base_pkg" | sed 's/-/_/g; s/\./_/g' | tr '[:upper:]' '[:lower:]')
            # Special cases for known package→import mismatches
            case "$base_pkg" in
                pyyaml)       import_name="yaml" ;;
                pillow)       import_name="PIL" ;;
                cocotbext-axi) import_name="cocotbext.axi" ;;
                cocotb-bus)   import_name="cocotb_bus" ;;
                cocotb-test)  import_name="cocotb_test" ;;
                rich-click)   import_name="rich_click" ;;
            esac
            python3 -c "import ${import_name}" 2>/dev/null
            if [ $? -ne 0 ]; then
                echo "  ⚠️  $base_pkg (import $import_name) — not installed locally"
                CI_REQS_FAIL=1
            fi
        done < "$reqfile"
    fi
done
if [ "$CI_REQS_FAIL" -ne 0 ]; then
    echo "❌ CI requirements not importable locally — CI will likely fail."
    echo "   Install missing packages or update ci/requirements-*.txt"
    exit 1
fi
echo "✅ CI requirements import check passed."

# 1e. Shell script linting (shellcheck) — catch bash bugs early
echo "Running shell script linting (shellcheck)..."
if command -v shellcheck > /dev/null 2>&1; then
    # Scope: all maintained script directories
    SHELL_FILES=$(find sdk/infra/docker/ sdk/scripts/ sdk/infra/hooks/ sdk/scripts/ -name '*.sh' -type f 2>/dev/null)
    if [ -n "$SHELL_FILES" ]; then
        # SC1091: Can't follow sourced files (expected in container scripts)
        # SC1090: Can't follow non-constant source (dynamic settings paths)
        # SC2046: Quote $(cmd) to prevent word splitting ($(nproc) is idiomatic)
        # SC2053: Quote RHS of == in [[ ]] (intentional glob matching)
        # SC2086: Double-quote word splitting (intentional in many Makefile-adjacent scripts)
        SHELLCHECK_FAIL=0
        echo "$SHELL_FILES" | xargs shellcheck --severity=warning --exclude=SC1090,SC1091,SC2046,SC2053,SC2086 2>&1 || SHELLCHECK_FAIL=1
        if [ "$SHELLCHECK_FAIL" -ne 0 ]; then
            echo "❌ Shell script linting failed. Commit aborted."
            echo "   Run 'shellcheck --severity=warning --exclude=SC1090,SC1091,SC2046,SC2053,SC2086 <file>' to see details."
            exit 1
        fi
        echo "✅ Shell linting passed ($(echo "$SHELL_FILES" | wc -l | tr -d ' ') scripts)."
    fi
else
    echo "⚠️  shellcheck not installed — skipping shell linting."
    echo "   Install: sudo apt install shellcheck"
fi

# 1f. requirements.yml schema validation (RTL-P2.747)
echo "Validating requirements.yml files..."
if ! python3 sdk/infra/hooks/validate_requirements_yml.py; then
    echo "❌ requirements.yml schema validation failed. Commit aborted."
    echo "   Fix the schema errors shown above."
    exit 1
fi
echo "✅ requirements.yml validation passed."

# 1g. SPDX header guard (D.31 / RTL-P3.970)
echo "Checking SPDX headers..."
python3 -m pytest sdk/cli/tests/test_spdx_headers.py -q
if [ $? -ne 0 ]; then
    echo "❌ SPDX header check failed. Commit aborted."
    exit 1
fi
echo "✅ SPDX header check passed."

# 1h. REA HW/SW register-map drift gate (RTL-P2.891)
if [ "$REA_REGBANK_TOUCHED" -eq 1 ]; then
    echo "Checking REA generated register-map contract..."
    if ! python3 -m sdk.cli.main regbank diff ip/routertl/rea/rea_regbank.yml; then
        echo "❌ REA register-map drift detected. Commit aborted."
        echo "   Run: rr regbank generate ip/routertl/rea/rea_regbank.yml"
        exit 1
    fi
    echo "✅ REA generated register-map contract is in sync."
fi

# 2. Regbank-utils unit tests (gated by RTL-P3.1126 on regbank paths)
if [ "$REGBANK_TOUCHED" -eq 1 ]; then
    echo "Running regbank-utils unit tests..."
    python3 -m unittest discover sdk/generators/regbank/tests/
    if [ $? -ne 0 ]; then
        echo "❌ Unit tests failed. Commit aborted."
        exit 1
    fi
    echo "✅ Unit tests passed."
else
    echo "⚡ Regbank unit tests skipped (no sdk/generators/regbank/ files staged; CI runs it unconditionally)."
fi

# 3. Makefile tests (gated by RTL-P3.1126 on Makefile / sdk-mk / tcl / project.yml paths)
if [ "$MAKEFILE_TEST_TOUCHED" -eq 1 ]; then
    echo "Running Makefile tests..."
    python3 sdk/infra/makefile-tests/run_tests.py
    if [ $? -ne 0 ]; then
        echo "❌ Makefile tests failed. Commit aborted."
        exit 1
    fi
    echo "✅ Makefile tests passed."
else
    echo "⚡ Makefile tests skipped (no Makefile/sdk-mk/tcl/project.yml files staged; CI runs it unconditionally)."
fi

# 4. Project YAML Regression Tests (excludes from project.yml)
#
# ── RTL-P3.552 — Touched-paths scope narrowing ──
# When the staged set is fully CLI-only (sdk/cli/commands/ + docs/ +
# tools/ + admin/ + ci/ + VERSION + meta), narrow `make regression`
# to test files matching the touched <group>(s). The helper at
# sdk/infra/hooks/_regression_scope.py contains the allow-list +
# group-inference rules; tests pin the contract.
#
# Empty stdout from the helper = no narrowing (current default —
# full 168-file suite runs). Non-empty stdout = space-separated
# patterns passed via HOOK_TEST_PATTERNS.
#
# CI is the source of truth: the bitbucket pipeline runs the full
# suite unconditionally, so a bad heuristic still surfaces on push.
HOOK_TEST_PATTERNS=$(printf "%s\n" "$STAGED_FILES" | \
    python3 sdk/infra/hooks/_regression_scope.py 2>/dev/null || true)
if [ "$HDL_UNIT_SCOPE" -eq 1 ]; then
    HOOK_TEST_PATTERNS="test_client_integration.py"
fi
if [ -n "$HOOK_TEST_PATTERNS" ]; then
    REGRESSION_EXCLUDES=$(python3 sdk/infra/hooks/_regression_scope.py \
        --filter-excludes "$HOOK_TEST_PATTERNS" "$REGRESSION_EXCLUDES")
    echo "⚡ CLI-only fast path (RTL-P3.552): narrowing regression to:"
    echo "$HOOK_TEST_PATTERNS" | tr ' ' '\n' | sed 's|^|     |'
fi
HOOK_EXCLUDE_MARKERS="nightly"
if [ "${RR_RUN_NIGHTLY_TESTS:-0}" = "1" ]; then
    HOOK_EXCLUDE_MARKERS=""
elif [ -n "$HOOK_EXCLUDE_MARKERS" ]; then
    echo "🌙 Skipping nightly-tier regression markers: $HOOK_EXCLUDE_MARKERS"
fi

echo "Running Project YAML Regression Tests..."
export TMPDIR="/dev/shm/rt$$"
mkdir -p "$TMPDIR"
# RTL-P2.884: clean TMPDIR once at hook EXIT, not mid-run. Steps further down
# (VHDL lint via linting.sh's mktemp, cocotb smoke) still use it; the regression
# block below used to delete it right after running, so any commit that triggers
# full linting (HDL touched, or a pyproject.toml/config touch) then hit
# "mktemp: /dev/shm/rtNNN: No such file or directory" and aborted the commit.
trap 'rm -rf "$TMPDIR"' EXIT
REG_LOG="/tmp/rr-regression-$$.log"
# RTL-P3.955 switchover: `rr sim regression` (engine suite) replaces
# `make regression`. Map the make vars onto the CLI flags:
#   HOOK_TEST_PATTERNS   → repeated --pattern   (empty ⇒ CLI defaults test_*.py)
#   REGRESSION_EXCLUDES  → repeated --exclude    (C1: the engine suite CLI does
#                          NOT read project.yml, so the hook passes them; the
#                          docker-smoke default-exclude is added by the CLI)
#   HOOK_EXCLUDE_MARKERS → repeated --exclude-marker (nightly, unless RR_RUN_NIGHTLY_TESTS=1) [C7]
#   HOOK_LINUX_UTILS_TOUCHED==0 → --skip-linux-utils (C6; fail-open: unset/1 ⇒ leg runs)
REG_ARGS=()
for p in $HOOK_TEST_PATTERNS; do REG_ARGS+=(--pattern "$p"); done
for t in $REGRESSION_EXCLUDES; do REG_ARGS+=(--exclude "$t"); done
for m in $HOOK_EXCLUDE_MARKERS; do REG_ARGS+=(--exclude-marker "$m"); done
if [ "$HOOK_LINUX_UTILS_TOUCHED" = "0" ]; then REG_ARGS+=(--skip-linux-utils); fi
# RTL-P3.1454: run the regression leg in its OWN session (setsid -w keeps
# the exit code). A test that signals its parent/process group then kills
# the regression run — a loud, diagnosable failure — instead of silently
# SIGKILLing the entire commit (gate died 'killed' at the same batch
# position twice during the v4.5.0 ship; culprit isolation still open).
# Fallback without util-linux setsid: run un-insulated as before.
if command -v setsid >/dev/null 2>&1; then
    setsid -w "${RR[@]}" sim regression "${REG_ARGS[@]}" 2>&1 | tee "$REG_LOG"
else
    "${RR[@]}" sim regression "${REG_ARGS[@]}" 2>&1 | tee "$REG_LOG"
fi
RC=${PIPESTATUS[0]}
if [ $RC -ne 0 ]; then
    echo "❌ Project YAML Regression failed. Commit aborted."
    rm -f "$REG_LOG"
    rm -rf "$TMPDIR"
    exit 1
fi
# Sanity: ensure tests were actually discovered (not silently skipped)
if ! grep -q 'Found [1-9]' "$REG_LOG"; then
    echo "❌ SANITY FAIL: Project Manager test discovery returned 0 tests."
    echo "   This likely means filter_git_tracked() is broken in hook context."
    rm -f "$REG_LOG"
    rm -rf "$TMPDIR"
    exit 1
fi
rm -f "$REG_LOG"
# RTL-P2.884: TMPDIR cleanup deferred to the EXIT trap — the VHDL-lint and
# cocotb-smoke steps below still need it. (Do NOT rm it here.)
echo "✅ Project YAML Regression passed."

# 5. VHDL Linting (gated by RTL-P3.426 fast path)
if [ "$HDL_TOUCHED" -eq 1 ]; then
    echo "Running VHDL Linting (Smart)..."
    # RTL-P3.955 switchover: `rr project sync-env` (was `make update-config`)
    # regenerates build_env.mk BEFORE linting — rr linting reads it (C5). C5
    # decision: FAIL-LOUD. The old `make update-config 2>/dev/null` swallowed
    # failures (violates the no-silent-failures rule); the rr command exits
    # non-zero and we gate on it here rather than `|| true`.
    "${RR[@]}" project sync-env
    if [ $? -ne 0 ]; then
        echo "❌ build_env regeneration (rr project sync-env) failed. Commit aborted."
        exit 1
    fi
    # `rr linting` (was `make linting`) reads hooks.pre_commit.exclude.lint from
    # project.yml itself (RTL-P3.504 — no HOOK_EXCLUDE_LINT plumbing) and exits
    # from sim/lint.status ITSELF (RTL-P2.699 — non-zero on lint errors, exit 1
    # on a missing status file), so the previous post-lint status-file block is
    # redundant and has been dropped (C4). SCOPE CHANGE (C8 / follow-up
    # RTL-P4.143): with no explicit TOP, rr linting scopes to the project's TOP
    # CLOSURE (auto --top), NOT the `make linting` forest walk over every
    # parentless entity — a deliberate, owned narrowing.
    if [ "$HDL_UNIT_SCOPE" -eq 1 ]; then
        "${RR[@]}" linting "$HDL_UNIT_TOP" --src src/units
    else
        "${RR[@]}" linting
    fi
    if [ $? -ne 0 ]; then
        echo "❌ VHDL Linting failed. Commit aborted."
        exit 1
    fi
    echo "✅ VHDL Linting passed."
else
    echo "⏭️  Skipping VHDL Linting (RTL-P3.426 fast path — no HDL touched)."
fi

# 6. Cocotb Sim Regression (gated by RTL-P3.426 fast path; excludes from project.yml)
if [ "$SIM_TOUCHED" -eq 1 ]; then
    echo "Running Cocotb Unit Smoke Tests..."
    SIM_LOG="/tmp/rr-sim-regression-$$.log"
    # RTL-P3.955 switchover: `rr sim regression --suite cocotb --clean` replaces
    # `make clean sim-regression SIM_EXTRA_ARGS=...`. The cocotb suite reads
    # hooks.pre_commit.exclude.tests from project.yml ITSELF (so no SIM_EXCLUDES
    # plumbing — every former SIM_EXCLUDES entry came from project.yml). --clean
    # removes only the sim-relevant subset (sim/work, sim_build, __pycache__,
    # .pytest_cache) — NOT dcp/ or project/, a deliberate RTL-P3.955 deviation
    # from `make clean` so a sim regression never wipes a synthesis build.
    SIM_ARGS=(--suite cocotb --clean)
    if [ "$HDL_UNIT_SCOPE" -eq 1 ]; then
        for p in $HDL_UNIT_TEST_PATTERNS; do SIM_ARGS+=(--pattern "$p"); done
    fi
    "${RR[@]}" sim regression "${SIM_ARGS[@]}" 2>&1 | tee "$SIM_LOG"
    RC=${PIPESTATUS[0]}
    if [ $RC -ne 0 ]; then
        echo "❌ Cocotb tests failed. Commit aborted."
        rm -f "$SIM_LOG"
        exit 1
    fi
    # Sanity: ensure tests were actually discovered (not silently skipped)
    if ! grep -q 'Found [1-9]' "$SIM_LOG"; then
        echo "❌ SANITY FAIL: Cocotb sim test discovery returned 0 tests."
        echo "   This likely means filter_git_tracked() is broken in hook context."
        rm -f "$SIM_LOG"
        exit 1
    fi
    rm -f "$SIM_LOG"
    echo "✅ Cocotb tests passed."
else
    echo "⏭️  Skipping Cocotb sim-regression (RTL-P3.426 fast path — no HDL touched)."
fi

# ── RTL-P3.230 — Index sweep guard (compare against snapshot) ──
# Diff the index now against the snapshot taken at hook entry.  Any
# path that newly appears — beyond the explicit `git add VERSION`
# above — is either a hook bug or a concurrent process staging files
# during the commit window.  Either way, surface it.  Loud warning
# only: the commit still proceeds (medium-impact per the ticket — the
# committed content isn't lost, but mis-attribution is bad enough to
# call out).
PRECOMMIT_INDEX_AFTER=$(git diff --cached --name-only --diff-filter=ACMRDT 2>/dev/null | sort)
PRECOMMIT_NEW_STAGED=$(comm -13 <(echo "$PRECOMMIT_INDEX_SNAPSHOT") <(echo "$PRECOMMIT_INDEX_AFTER"))
PRECOMMIT_UNEXPECTED=$(echo "$PRECOMMIT_NEW_STAGED" | grep -vxF "VERSION" | grep -v '^$' || true)
if [ -n "$PRECOMMIT_UNEXPECTED" ]; then
    echo ""
    echo "⚠️  Index sweep guard tripped (RTL-P3.230)."
    echo "   These paths entered the index DURING the pre-commit chain — they"
    echo "   were not staged when the hook started, and only 'VERSION' is"
    echo "   sanctioned to be auto-staged here:"
    echo "$PRECOMMIT_UNEXPECTED" | sed 's|^|     |'
    echo ""
    echo "   They will land in this commit.  Likely causes:"
    echo "     • A concurrent agent / shell ran 'git add' between your"
    echo "       'git commit' and now (multi-agent index race)."
    echo "     • A hook step is silently staging files (it shouldn't —"
    echo "       audit the chain or open a follow-up to RTL-P3.230)."
    echo ""
    echo "   To split them out: amend HEAD, 'git reset HEAD~ -- <file>',"
    echo "   then re-commit your set alone."
fi

echo "🎉 All pre-commit checks passed!"
exit 0
