#!/usr/bin/env bash
# darnlink-gate — the ONE generic darnlink quality-gate recipe for every repo that uses darnlink.
#
# WHY THIS EXISTS. darnlink is a pure link tool (it checks/reports; it deliberately knows nothing
# about gates, git, excludes-policy, or CI — see its Constitution). Every consumer repo needs the
# SAME orchestration around it, and until now each repo re-implemented it in its own `*_gate.sh`,
# so the wrappers drifted (the "strict ⊇ repair" myth, ignore-file vs ignore-links, un-pinned refs).
# This is that orchestration in ONE place. A consumer repo carries only a tiny config + a 3-line hook.
#
# WHAT IT DOES (all read-only — it never writes):
#   • runs darnlink at a PINNED ref (deterministic); fails OPEN if uv/uvx is missing (never
#     bootstraps) — unless fail-closed is on, see below;
#   • MODE picks which axes gate — three rungs of a one-way ratchet (each is a superset of the one
#     above, so raising MODE can only tighten the gate, never loosen it):
#     mode=repair → integrity only — a strict-only failure (3) is treated as clean (for repos that
#                   don't robustify their links yet);
#     mode=check  → integrity + strict (the default). Runs `darnlink check` (stable 0/2/3 contract);
#     mode=max    → integrity + strict + create-frontmatter = FAIL-CLOSED links: a link to a file
#                   that has no `uuid` fails the gate (see docs/elevating-your-link-gate.md). `check`
#                   has no create-frontmatter axis and the bare `--robustify --create-frontmatter` has
#                   no integrity axis, so max runs BOTH dry-run passes (check UNION create-frontmatter)
#                   and fails if either does — a true superset of check. WHOLE-REPO ONLY — the staged
#                   pre-commit stays at strict on purpose (fast, "is what I commit clean?"); the
#                   whole-repo wall (pre-push / CI) is where max is enforced.
#   • scope=repo  → judge the whole tree (the wall — use in CI);
#     scope=staged→ judge only the files you're committing (use in a multi-session pre-commit, so a
#                   teammate's in-flight plain link doesn't block your commit). The repo-wide wall
#                   stays in CI. [Option B of darnlink spec 008: git lives HERE, not in darnlink.]
#   • fails OPEN on a network/uvx error (offline commit isn't bricked) — UNLESS fail-closed is on.
#     ⚠️ FAIL-CLOSED (`DARNLINK_GATE_FAIL_CLOSED=1`, or `"fail_closed": true` in the json): in CI the
#     gate IS the wall, and failing open there means a GREEN BUILD WITH ZERO FILES VALIDATED on a
#     transient network/PyPI hiccup. Turn it ON in CI. It exits with code 4 (distinguishable from the
#     findings: 2 integrity / 3 strict).
#
# CONFIG. Reads `darnlink-gate.json` at the repo root (all keys optional):
#   { "ref": "git+https://github.com/txemi/darnlink@vX.Y.Z",
#     "excludes": ["secrets","node_modules",".venv"], "ignore_blocks": ["txmd-autogrid"],
#     "mode": "max", "scope": "repo", "fail_closed": true,
#     "web": true, "dangling": "repo", "create_readme": true,
#     "create_readme_excludes": ["mirrors"] }
#
#   ^ That is the TARGET, not a starting point: every axis at its strictest.
#
#   `vX.Y.Z` is a PLACEHOLDER ON PURPOSE, and it is the one thing here you must not copy verbatim.
#   Resolve it when you paste:
#       gh release view -R txemi/darnlink --json tagName -q .tagName
#   A concrete tag written into this comment is exactly how the example rotted last time: it sat at
#   v0.7.0 for thirteen releases and quietly recommended two rungs below what its own author was
#   running. Any pin printed here ages the moment it is committed, so nothing is printed.
#
#   ADOPTING AN EXISTING REPO? Do not paste the above on day one; it will fail on contact with a
#   tree that has never been gated, and a gate that fails on arrival gets deleted. Climb instead,
#   fixing content at each rung (the ratchet only goes up):
#     1. "mode": "repair"                  -> broken robust links + invalid YAML
#     2. "mode": "check"                   -> + every anchorable link actually anchored
#     3. "mode": "max"                     -> + every link points at a file that HAS a uuid
#     4. "create_readme": true             -> + a link to a FOLDER requires its README.md
#     5. "web": true                       -> + cross-repo GitHub links verified over the network
#     6. "dangling": "warn" -> "added-lines" -> "repo"
#        `warn` measures without failing; `added-lines` fails only on lines the commit ADDS (so old
#        debt never blocks anyone); `repo` fails on any.
#        ⚠️ `added-lines` needs a staged diff, so it only ever bites on `scope=staged` — i.e. the
#        pre-commit hook. On a whole-repo surface (`scope=repo`: pre-push, CI) there is no diff to
#        judge and it degrades to `warn`. So this rung is enforced ONLY by a local hook, which fails
#        open when uv/network is missing: nothing on the server catches a new dangling link. If you
#        want a wall rather than a habit, the rung that has one is `repo`.
#        Measured on a nine-repo fleet: 2,356 links pointing at files that do not exist, none of
#        them named by any other axis. Expect a number.
#   mode ∈ { repair | check | max } — see WHAT IT DOES. Raising it is a one-way ratchet: only up.
#   own_web (needs `web`): a LIST of GitHub owners you control. A destination owned by one of them
#     whose .md has no uuid stops being "someone else's problem" and FAILS the gate — you can fix it,
#     it is a missing two-line edit in a repo you own. `own_web_from_origin: true` adds this repo's
#     `origin` owner; it is a separate key, not a sentinel in the list, so an owner literally called
#     `origin` stays expressible. `own_web_max: <int>` budgets it, so the rung is adoptable before you
#     reach zero — same ratchet shape as `dangling_max`, and a non-numeric value counts as ABSENT,
#     never as infinite. A misconfiguration here (a budget with no owners, an unresolvable origin)
#     exits 1, and the recipe reports it as likely-config rather than as a verdict about the
#     repository — but ONLY when this run actually passed an own_* flag, because exit 1 is not
#     exclusively a usage error (uvx and an uncaught exception use it too). Under `fail_closed` it
#     still fails the gate with 4: in CI an axis that could not run is not a pass.
#   web (mode=max only): add a `web-check --online` pass — cross-repo links to OTHER GitHub repos must
#     resolve to the destination's uuid (read online). ⚠️ PUBLIC targets need a token too — not for
#     permission but for QUOTA: anonymous API calls are 60/h per public IP. Without one the pass
#     reports `web_unverifiable` (a warning, never a failure), which reads like "nothing to check".
#     Fail-closed on a broken public web link.
#   create_readme (ANY mode, since the mirror update): also run `--create-readme` — a directory link whose
#     target folder has no README (no uuid to anchor to) FAILS the gate (dry-run detects it; fix with
#     `--create-readme --write`). It runs as its OWN dry-run pass, filtered to the `create_readme` findings,
#     so it adds the folder axis on top of mode=check / mode=repair too — not only mode=max. (In mode=max
#     with NO create_readme_excludes it stays FOLDED into the max robustify pass, exactly as before — that
#     path is untouched.) The reason for the own-pass: it lets create_readme_excludes bite only this axis.
#   create_readme_excludes (default []): extra directory-name globs applied ONLY to the create-readme pass,
#     LAYERED ON TOP of `excludes`. The point for a repo with a big `mirrors/` tree: skip README-creation
#     under the mirror (we do not invent a README for an external system's export) WITHOUT excluding it from
#     the integrity/robustify axes — inbound links INTO the mirror must still validate. Absent = empty =
#     old behavior. When non-empty in mode=max, create-readme moves out to the separate pass so the excludes
#     never leak into robustify (folding it in would drop mirror dir-links from robustify too — wrong).
# ⚠️ In CI prefer the ENV VAR over the json key: reading the json needs python3, so if python3 is
#    missing the key is silently lost — and "python3 missing" is one of the very cases fail-closed
#    exists to catch. `DARNLINK_GATE_FAIL_CLOSED=1` is read by the shell and always applies.
# Env overrides (so one config serves both surfaces): DARNLINK_REF, DARNLINK_GATE_MODE,
# DARNLINK_GATE_SCOPE, DARNLINK_GATE_FAIL_CLOSED. Typical wiring: config says scope=repo; the
# pre-commit hook exports DARNLINK_GATE_SCOPE=staged; CI leaves it repo and sets FAIL_CLOSED=1.
#
# EXIT: 0 clean · 2 integrity failure · 3 strict-only failure · 1 usage / max-mode or create-readme
#       findings · 4 could-not-gate (fail-closed only) · 0 + a stderr warning if it skips (fail-open,
#       the default). (mode=max reports findings via the dry-run's own non-zero exit — any non-zero
#       fails the gate; the create-readme axis fails with 1 unless a higher code already applies.)
set -euo pipefail

root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cfg="$root/darnlink-gate.json"

# --- read config (JSON, all optional) via python; env wins over file ---
read_cfg() { python3 - "$cfg" "$1" "$2" <<'PY' 2>/dev/null || printf '%s' "$2"
import json, sys
cfg, key, default = sys.argv[1], sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else ""
try:
    d = json.load(open(cfg, encoding="utf-8"))
except Exception:
    d = {}
v = d.get(key, default)
print("\n".join(v) if isinstance(v, list) else (v if v is not None else default))
PY
}

# How many entries a LIST key has — which `read_cfg` cannot tell you: it joins with newlines, so an
# absent key, `[]` and `[""]` all arrive as the empty string. `[""]` is the likeliest typo of the
# three (a template line left half-filled) and the only one that must be named out loud, so ask for
# the length separately instead of trying to infer it from the flattened value.
read_cfg_len() { python3 - "$cfg" "$1" <<'PY' 2>/dev/null || printf '0'
import json, sys
try:
    d = json.load(open(sys.argv[1], encoding="utf-8"))
except Exception:
    d = {}
v = d.get(sys.argv[2])
print(len(v) if isinstance(v, list) else 0)
PY
}

REF="${DARNLINK_REF:-$(read_cfg ref 'git+https://github.com/txemi/darnlink@v0.7.0')}"
MODE="${DARNLINK_GATE_MODE:-$(read_cfg mode check)}"
SCOPE="${DARNLINK_GATE_SCOPE:-$(read_cfg scope repo)}"
# FAIL-CLOSED. By default this recipe fails OPEN (don't brick an offline commit — see below). That
# is right for pre-commit and DANGEROUS in CI: there the gate IS the wall, and a transient network or
# PyPI hiccup would give a GREEN build with zero files validated. Turn it on in CI.
# Normalise first: `read_cfg` prints the raw Python value, so a JSON `false` arrives as the STRING
# "False" — a naive `!= "0"` test would read that as ON and brick the very consumer that asked to
# turn it OFF. Accept the obvious spellings on both sides.
FAIL_CLOSED="${DARNLINK_GATE_FAIL_CLOSED:-$(read_cfg fail_closed "")}"
case "${FAIL_CLOSED,,}" in ""|0|false|no|off) FAIL_CLOSED="" ;; *) FAIL_CLOSED=1 ;; esac
# WEB (opt-in): when on, mode=max adds a 3rd whole-repo pass — `web-check --online` — that verifies
# cross-repo Markdown links to OTHER GitHub repos still resolve to the destination file's uuid (read
# online, anchored with `<!-- web-uuid: X -->`). ⚠️ BOTH kinds of destination need $GITHUB_TOKEN:
# private ones for permission, PUBLIC ones for quota (anonymous API = 60/h per public IP, shared by
# every machine behind the same NAT). Without it they are reported `web_unverifiable` (a warning, NOT
# a failure — never a crash), and the gate now says so instead of printing a quiet zero.
# Fail-closed only on a genuinely broken/moved public web link. Same normalise-the-string dance as above.
WEB="${DARNLINK_GATE_WEB:-$(read_cfg web "")}"
case "${WEB,,}" in ""|0|false|no|off) WEB="" ;; *) WEB=1 ;; esac
# OWN_WEB (opt-in, needs `web`): the owners you control. A destination owned by one of them whose .md
# has no uuid stops being "someone else's problem" and becomes a gate failure — see feature 016. A
# LIST of names, never a sentinel: `own_web_from_origin` is its own key precisely so an owner literally
# called `origin` stays expressible, the same reason the CLI has a flag instead of `--own auto`.
mapfile -t OWN_WEB < <(read_cfg own_web "")
OWN_WEB_N="$(read_cfg_len own_web)"; [[ "$OWN_WEB_N" =~ ^[0-9]+$ ]] || OWN_WEB_N=0
OWN_WEB_FROM_ORIGIN="${DARNLINK_GATE_OWN_WEB_FROM_ORIGIN:-$(read_cfg own_web_from_origin "")}"
case "${OWN_WEB_FROM_ORIGIN,,}" in ""|0|false|no|off) OWN_WEB_FROM_ORIGIN="" ;; *) OWN_WEB_FROM_ORIGIN=1 ;; esac
# A budget, so the rung is adoptable before a repo reaches zero. Non-numeric counts as ABSENT, never as
# infinite: silently WIDENING an allowance is the one direction a config typo must not be able to go —
# the same rule `dangling_max` follows, and for the same reason.
OWN_WEB_MAX="${DARNLINK_GATE_OWN_WEB_MAX:-$(read_cfg own_web_max "")}"
if [ -n "$OWN_WEB_MAX" ] && ! [[ "$OWN_WEB_MAX" =~ ^[0-9]+$ ]]; then
  echo "darnlink-gate: own_web_max='$OWN_WEB_MAX' is not a non-negative integer — ignoring it." >&2
  OWN_WEB_MAX=""
fi
# CREATE_README (opt-in): when on, mode=max also runs `--create-readme` — a directory link whose target
# folder has no README (so no uuid to anchor to) FAILS the gate (dry-run: it's DETECTED, not written).
# Robustify it by hand with `--create-readme --write`. Raises the max ceiling from "files" to "folders".
CREATE_README="${DARNLINK_GATE_CREATE_README:-$(read_cfg create_readme "")}"
case "${CREATE_README,,}" in ""|0|false|no|off) CREATE_README="" ;; *) CREATE_README=1 ;; esac
# DANGLING (opt-in, default off): links whose target does not exist (darnlink feature 015). It is a
# SEPARATE axis, never folded into MODE, and that is the whole point — every consumer already carries
# years of these (measured: thousands across a nine-repo fleet), so folding it into check/max would
# turn each gate red on upgrade and the only escape would be LOWERING mode. A ladder that can only be
# climbed by first stepping down is not a ladder. Four settings:
#   off          (default) not gated, not printed — upgrading the pin changes nothing;
#   warn         printed, never fails: the honest way to see your backlog before gating it;
#   added-lines  fails only on findings on lines THIS COMMIT ADDS (scope=staged). The adoption rung:
#                old debt never blocks you, new debt cannot enter. Per-FILE would not do — touching
#                one line of a README that carries old danglers would block the commit, and a gate
#                people bypass gates nothing;
#   repo         fails on ANY finding. The wall, for a repo already at zero.
#
# `dangling_max` (int, default 0) turns `repo` into a BUDGET: fail only above that count. It exists
# because between `added-lines` and `repo` there is no rung — and `added-lines` needs a staged diff,
# so it only ever bites in pre-commit. A repo with old debt therefore has NO server-side dangling
# wall at all, on any surface, until the day it reaches exactly zero. That is a long time with no
# wall, and it is precisely when a regression slips in unseen.
# With a budget you get the wall TODAY at your current number, and every cleanup lowers it. Same
# shape as a coverage floor: the number is a receipt of where you are, not permission to stay.
# To keep it a ratchet and not a permanent allowance, the gate SAYS SO when you come in under
# budget — an unlowered budget is the failure mode, and nothing else would catch it.
# Only `repo` has a wall, so the budget only applies there. A non-numeric value is treated as 0
# (strictest) on purpose: a typo must never silently WIDEN an allowance.
DANGLING="${DARNLINK_GATE_DANGLING:-$(read_cfg dangling off)}"
DANGLING_MAX="${DARNLINK_GATE_DANGLING_MAX:-$(read_cfg dangling_max 0)}"
case "$DANGLING_MAX" in
  ''|*[!0-9]*)
    echo "darnlink-gate: dangling_max='$DANGLING_MAX' is not a non-negative integer — using 0." >&2
    DANGLING_MAX=0 ;;
esac
case "${DANGLING,,}" in
  off|""|0|false|no) DANGLING=off ;;
  warn) DANGLING=warn ;;
  added-lines|added_lines) DANGLING=added-lines ;;
  repo|all|on|1|true|yes) DANGLING=repo ;;
  *) echo "darnlink-gate: unknown dangling=$DANGLING (use off|warn|added-lines|repo) — treating as off." >&2; DANGLING=off ;;
esac
mapfile -t EXCLUDES < <(read_cfg excludes "")
mapfile -t IGNORE_BLOCKS < <(read_cfg ignore_blocks "")
# CREATE_README_EXCLUDES: extra directory globs that apply ONLY to the create-readme pass (see the
# create_readme_excludes note in CONFIG). Absent = one empty element → treated as empty everywhere.
mapfile -t CREATE_README_EXCLUDES < <(read_cfg create_readme_excludes "")

# --- guard: this recipe is READ-ONLY. Never let a --write slip through it. ---
for a in "$@"; do
  case "$a" in
    --write) echo "darnlink-gate: refusing --write (this gate is read-only; robustify by hand: 'uvx --from $REF darnlink . --robustify --write')." >&2; exit 1;;
  esac
done

# ONE place decides what to do when the gate could NOT run: skip (default, pre-commit) or abort red
# (CI). Never "green without validating", which is the expensive silent failure.
bail() {  # $1 = reason
  if [ -n "$FAIL_CLOSED" ]; then
    echo "darnlink-gate: $1 -> FAILING (fail-closed is on: nothing was validated)." >&2
    exit 4
  fi
  echo "darnlink-gate: $1 -> SKIP; CI covers the wall." >&2
  exit 0
}

# --- build darnlink args (excludes + ignore-blocks) ---
DL_ARGS=()
for e in "${EXCLUDES[@]}";      do [ -n "$e" ] && DL_ARGS+=(--exclude "$e"); done
for b in "${IGNORE_BLOCKS[@]}"; do [ -n "$b" ] && DL_ARGS+=(--ignore-block "$b"); done

# --- fail OPEN if uv/uvx unreachable (don't brick offline commits; CI covers it) ---
if ! command -v uvx >/dev/null 2>&1; then bail "uvx not found"; fi

cd "$root"
run() { uvx --from "$REF" darnlink "$@"; }   # single source of the darnlink invocation

# Pre-flight: can uvx actually BUILD+RUN darnlink at this ref? darnlink's own exit codes (0/1/2/3)
# overlap a uvx fetch failure (bad ref / no network also exits low), so we can't tell "darnlink ran
# and found issues" from "couldn't run darnlink" by the check's exit code alone. `--help` runs iff
# darnlink is reachable. If it isn't → fail OPEN (don't brick a commit; CI covers the wall).
if ! run --help >/dev/null 2>&1; then bail "can't run darnlink at $REF (bad ref / no network)"; fi

# --- create-readme axis (opt-in, ANY mode) ---------------------------------------------------------
# Runs `--create-readme` (a dry-run) and reports ONLY the `create_readme` findings — a directory link
# whose target folder has no README. It takes its OWN excludes (CREATE_README_EXCLUDES) layered ON TOP
# of the global ones, so a repo can skip README-creation under `mirrors/` (external export — we don't
# invent a README for it) WITHOUT dropping the mirror from integrity/robustify (inbound links into the
# mirror must still validate). This is the SAME JSON-by-kind filter the staged scope uses below, reused
# here to filter by kind on a whole-repo pass. Prints the offender count on stdout ("ERR" if it could
# not evaluate); the offending files go to stderr for the human. `--create-readme` implies
# `--create-frontmatter`, so the JSON also carries `robustify` findings — we deliberately keep only
# `create_readme`, which is what keeps the mirror's dir-links from flooding in as robustify offenders.
# Dangling axis (015). Prints one line per finding on stdout as `file<TAB>line<TAB>detail`, or the
# single token ERR if it could not be evaluated — same contract as create_readme_offenders, so the
# caller can apply the never-turn-a-red-gate-green rule.
dangling_findings() {
  command -v python3 >/dev/null 2>&1 || { echo ERR; return 0; }
  local json jrc tmp
  set +e
  json="$(run check . --json "${DL_ARGS[@]}" 2>/dev/null)"; jrc=$?
  set -e
  if [ "$jrc" -gt 3 ] || [ -z "$json" ]; then echo ERR; return 0; fi
  # Temp file, not env/argv: a whole-repo dump on a large consumer runs to megabytes (see above).
  tmp="$(mktemp)"; printf '%s' "$json" > "$tmp"
  # Capture, THEN clean up, THEN speak. Cleaning up only after a successful run leaks the file on any
  # python failure, because `set -e` leaves the function before the `rm` (a gate that runs on every
  # commit leaks once per commit).
  local out prc
  set +e
  out="$(python3 - "$tmp" <<'PY'
import json, sys
try:
    with open(sys.argv[1], encoding="utf-8") as fh:
        data = json.load(fh)
except Exception:
    print("ERR"); sys.exit(0)
for f in data.get("dangling", {}).get("findings", []):
    print(f"{f.get('file','')}\t{f.get('line') or ''}\t{f.get('detail','')}")
PY
)"; prc=$?
  set -e
  rm -f "$tmp"
  [ "$prc" -ne 0 ] && { echo ERR; return 0; }
  printf '%s\n' "$out"
}

create_readme_offenders() {
  command -v python3 >/dev/null 2>&1 || { echo ERR; return 0; }
  local cr_args=("${DL_ARGS[@]}") e
  for e in "${CREATE_README_EXCLUDES[@]}"; do [ -n "$e" ] && cr_args+=(--exclude "$e"); done
  local json jrc tmp
  set +e
  # `--create-readme` only fires under `--robustify` (without it, darnlink runs the repair/integrity path
  # and never plans a README — see cli.py dispatch). `--create-readme` implies `--create-frontmatter`; we
  # pass it explicitly for clarity. We keep ONLY the `create_readme` findings from the JSON, so carrying
  # `--robustify` here does NOT add robustify offenders to this axis — the caller's robustify axis is the
  # separate `check` (or max) pass.
  json="$(run . --robustify --create-frontmatter --create-readme "${cr_args[@]}" --json 2>/dev/null)"; jrc=$?
  set -e
  # rc>3 (e.g. 127 network) or empty output = couldn't run darnlink → let the caller bail, never green.
  if [ "$jrc" -gt 3 ] || [ -z "$json" ]; then echo ERR; return 0; fi
  # Pass the JSON via a TEMP FILE, not an env var / argv — a whole-repo create-readme dump carries every
  # ignore-links finding too and can run to megabytes, well past ARG_MAX (a big mirror is exactly the case
  # this feature exists for). The path is short; python reads the file.
  tmp="$(mktemp)"; printf '%s' "$json" > "$tmp"
  # Same capture-then-clean-then-speak order as dangling_findings(): cleaning up only on the success
  # path leaks the temp file whenever python fails, because `set -e` leaves before the `rm`.
  # (stderr is NOT captured — the per-offender lines are meant to reach the user as they are printed.)
  local out prc
  set +e
  out="$(python3 - "$tmp" <<'PY'
import json, sys
try:
    with open(sys.argv[1], encoding="utf-8") as fh:
        d = json.load(fh)
except Exception:
    print("ERR"); sys.exit(0)
off = [f for f in d.get("findings", []) if f.get("kind") == "create_readme"]
for f in off:
    sys.stderr.write(f"  [create-readme] {f.get('file')}: {f.get('detail')}\n")
print(len(off))
PY
)"; prc=$?
  set -e
  rm -f "$tmp"
  [ "$prc" -ne 0 ] && { echo ERR; return 0; }
  printf '%s\n' "$out"
}

# F4: `own_web` rides on `web` and on mode=max. Configured where the pass never runs it is a silent
# no-op that READS like protection — exactly what the CLI refuses to do with `--own-max` and no owners.
if { [ "$OWN_WEB_N" -gt 0 ] || [ -n "$OWN_WEB_FROM_ORIGIN$OWN_WEB_MAX" ]; } \
   && { [ -z "$WEB" ] || [ "$MODE" != "max" ]; }; then
  echo "darnlink-gate: own_web* is configured but the web pass does not run here (web=${WEB:-off}," >&2
  echo "  mode=$MODE — it needs web on AND mode=max). The 016 rule is NOT being applied." >&2
fi

if [ "$SCOPE" != "staged" ]; then
  # ---- whole-repo (the wall). darnlink's own exit code is the gate. ----
  # set +e around the run: darnlink exits 0/1/2/3 (findings ARE non-zero) — set -e must not kill us
  # before we read rc and run the rc>3 fail-open.
  set +e
  if [ "$MODE" = "max" ]; then
    # LEVEL 3 = `check` (integrity + strict) UNION the create-frontmatter axis. Neither half alone is
    # a superset of check: `check` runs plan_repairs+plan_robustify but has no create-frontmatter axis;
    # the bare `--robustify --create-frontmatter` ADDS create-frontmatter but DROPS integrity (it never
    # runs plan_repairs — a broken/moved robust link sails through). So run BOTH dry-run passes and
    # fail if either does. This makes max a TRUE superset of check (the ratchet holds). Both read-only.
    # create_readme raises the ceiling: directory targets must have a README too (dry-run = detect it).
    MAX_ROBUSTIFY=(--robustify --create-frontmatter)
    # create_readme FOLDS into the max robustify pass ONLY in the legacy shape (no create_readme_excludes):
    # keeps existing mode=max behavior byte-for-byte. WITH create_readme_excludes it moves to the separate
    # create-readme pass below, so those excludes hit README-creation ONLY (never robustify/integrity — a
    # mirror must still have its inbound links validated). FOLDED_CR marks that the axis is already covered
    # here, so the fall-through does not run it a second time.
    if [ -n "$CREATE_README" ] && [ -z "${CREATE_README_EXCLUDES[*]}" ]; then
      MAX_ROBUSTIFY+=(--create-readme); FOLDED_CR=1
    fi
    run check . "${DL_ARGS[@]}"; rc=$?
    if [ "$rc" -eq 0 ]; then run . "${MAX_ROBUSTIFY[@]}" "${DL_ARGS[@]}"; rc=$?; fi
    # WEB pass (opt-in, mode=max only): cross-repo web-link robustness, only if the core passed (so a
    # core failure surfaces first). web-check takes the SAME `--exclude` and `--ignore-block` as the core
    # (since 0.12.0) — pass both so it skips vendored clones / mirrors instead of fetching and anchoring
    # web links INSIDE someone else's checkout. Fail-closed on a broken public web link; web_unverifiable
    # (no token — public OR private — or offline) is exit 0. Needs a ref with web-check + its --exclude (v0.12.0+).
    if [ "$rc" -eq 0 ] && [ -n "$WEB" ]; then
      # web-check needs $GITHUB_TOKEN. If it's not already exported, read it from a read-only PAT FILE
      # (default ~/.config/github_token_ro; override with DARNLINK_GATE_TOKEN_FILE) — a git hook's env
      # usually lacks it. Missing file → stays unverifiable.
      #
      # ⚠️ It is NOT only about private destinations. PUBLIC ones need it too, for a different reason:
      # anonymous GitHub API calls are capped at 60/h PER PUBLIC IP, shared by every machine behind the
      # same NAT. Measured on a public repo linking only to itself, same tree, minutes apart:
      #     without token -> ok 0 | unverifiable 52
      #     with token    -> ok 7 | unverifiable 45
      # So the axis silently stops verifying depending on WHAT TIME the build runs.
      if [ -z "${GITHUB_TOKEN:-}" ]; then
        _tf="${DARNLINK_GATE_TOKEN_FILE:-$HOME/.config/github_token_ro}"
        # -f: a REGULAR file (not a directory — `-r` alone passes on a dir, then `< dir` fails).
        [ -f "$_tf" ] && [ -r "$_tf" ] && { GITHUB_TOKEN="$(tr -d '\r\n' < "$_tf")"; export GITHUB_TOKEN; }
      fi
      # SAY IT when the axis cannot actually verify. `ok 0 | unverifiable N` reads as "nothing to
      # check" and means "could not look" — and it is indistinguishable from a repo that genuinely has
      # no cross-repo links. Staying quiet is how this axis ran for months without measuring anything
      # while the build stayed green. Not a failure: committing offline must keep working.
      if [ -z "${GITHUB_TOKEN:-}" ]; then
        echo "darnlink-gate: web axis running WITHOUT a token → anonymous 60/h-per-IP quota." >&2
        echo "  An 'ok 0 | unverifiable N' from web-check in this run means COULD NOT LOOK," >&2
        echo "  not 'nothing to verify' — and it is indistinguishable from a repo that has no" >&2
        echo "  cross-repo links at all." >&2
        echo "  ⚠️ THIS CAN BE A FALSE GREEN, not just a missing measurement: an un-anchored web" >&2
        echo "  link is only discoverable if the destination can be READ. Measured on one tree:" >&2
        echo "  without token rc=0 (green), with token rc=3 (the link needed anchoring)." >&2
        echo "  Export GITHUB_TOKEN, and quote the token condition next to any web figure —" >&2
        echo "  without it the number is not comparable between two runs, let alone two repos." >&2
      fi
      WEB_ARGS=()
      for e in "${EXCLUDES[@]}";      do [ -n "$e" ] && WEB_ARGS+=(--exclude "$e"); done
      for b in "${IGNORE_BLOCKS[@]}"; do [ -n "$b" ] && WEB_ARGS+=(--ignore-block "$b"); done
      OWN_PASSED=""; OWN_WEB_USED=0
      for o in "${OWN_WEB[@]}"; do
        [ -n "$o" ] && { WEB_ARGS+=(--own "$o"); OWN_PASSED=1; OWN_WEB_USED=$((OWN_WEB_USED + 1)); }
      done
      [ -n "$OWN_WEB_FROM_ORIGIN" ] && { WEB_ARGS+=(--own-from-origin); OWN_PASSED=1; }
      [ -n "$OWN_WEB_MAX" ]         && { WEB_ARGS+=(--own-max "$OWN_WEB_MAX"); OWN_PASSED=1; }
      # An owner entry that is present but empty is a typo, and a silent one: the axis would run with
      # less ownership than the config claims and go green looking like it had checked all of it. It
      # is counted, not inferred from the joined value — `[""]` flattens to exactly what an absent key
      # gives, and `[""]` is the shape a half-filled template line actually takes.
      if [ "$OWN_WEB_N" -gt 0 ] && [ "$OWN_WEB_USED" -eq 0 ]; then
        echo "darnlink-gate: own_web is set but every entry is empty — no ownership was applied." >&2
      elif [ "$OWN_WEB_USED" -lt "$OWN_WEB_N" ]; then
        echo "darnlink-gate: own_web has $((OWN_WEB_N - OWN_WEB_USED)) empty entry/entries out of" >&2
        echo "  $OWN_WEB_N — they were ignored, so fewer owners are enforced than the config lists." >&2
      fi
      run web-check . --online "${WEB_ARGS[@]}"; rc=$?
      set -e
      # Exit 1 CAN be a usage error — feature 016 makes it reachable from CONFIGURATION: own_web_max
      # without own_web, an empty owner name, own_web_from_origin in a tree with no GitHub `origin`.
      # But it is NOT only that: `uvx` exits 1 on its own failures and an uncaught Python exception
      # exits 1 too. So this only fires when THIS RUN passed an own_* flag — otherwise a repo that
      # never adopted 016 would have its exit 1 turned green, which is a worse guarantee than before
      # this key existed.
      #
      # And even then it respects `fail_closed`: under fail-open it drops the axis with a warning, but
      # in CI — where the gate IS the wall — a config error that cannot be validated must not be a
      # pass. It becomes 4 ("could not gate") and is marked final, so the run still fails and the
      # remaining axes still execute. It must NOT go to `bail()`, which exits the script and would skip
      # them, the bug this pass was fixed for once already.
      if [ "$rc" -eq 1 ] && [ -n "$OWN_PASSED" ]; then
        echo "darnlink-gate: web-check rejected its own arguments (exit 1) — most likely a CONFIG" >&2
        echo "  error rather than a finding about this repository. Check own_web /" >&2
        echo "  own_web_from_origin / own_web_max in darnlink-gate.json. The web axis did NOT run." >&2
        if [ -n "$FAIL_CLOSED" ]; then
          echo "  fail_closed is on: an axis that could not run is not a pass. -> 4" >&2
          rc=4
        else
          rc=0
        fi
      fi
      # web-check's contract (all in 0..4, none of them "unreachable"): 0 = clean (a network/token
      # failure is reported web_unverifiable, still exit 0 — it NEVER exits high); 3 = a plain web link
      # not yet anchored (dry-run: it COULD be robustified) — a real gate failure, which is the point
      # (detect un-robustified web links); 4 = a broken/moved/mismatched public web link. So EVERY
      # non-zero is fail-closed, NOT the core's rc>3 "unreachable" case: it must NOT go through the
      # `rc>3 -> bail` heuristic below, which would swallow a genuine 4 as "network hiccup" and go green.
      #
      # ⚠️ This used to be a bare `exit "$rc"`, and that was a BUG: it left the function before the
      # create-readme axis further down, so in a repo with `mode=max` + `web: true` +
      # `create_readme_excludes` the create-readme axis NEVER RAN. The config said the axis was on, the
      # gate reported a clean exit 0, and a directory link to a folder with no README sailed through.
      # Measured on two consuming repos before the fix: injecting exactly that case returned 0 with web
      # on and 1 with web off — same tree, same config, opposite verdicts.
      #
      # So: remember the web verdict, mark it as already-final (no bail heuristic), and FALL THROUGH so
      # the remaining axes still run. The exit happens once, at the end, with the worst verdict.
      RC_IS_FINAL=1   # `rc` already carries the web verdict; this only suppresses the heuristic below
    fi
  else
    run check . "${DL_ARGS[@]}"; rc=$?   # `darnlink check` → stable 0/2/3 contract (mode=check|repair)
  fi
  set -e
  # rc>3 (e.g. 127 network) → fail open + warn; darnlink's own low exit codes pass through.
  # RC_IS_FINAL marks a verdict that is already fail-closed BY CONTRACT (today: the web pass, whose
  # codes are all in 0..4 and none of them mean "unreachable"), so a 4 must not be re-read as a hiccup.
  #
  # But that immunity has a ceiling, and forgetting it was a bug in the first draft of this fix: a code
  # ABOVE the contract (127 = no uvx, 126 = permissions, a runtime blowing up mid-pass) is NOT a verdict
  # about the repo — it means the tool could not run, and it must go through bail() so fail-open skips
  # and fail-closed exits 4. Otherwise a machine without `uv` would get a hard 127 out of a gate that
  # promises to fail open. Hence: >4 always bails, regardless of RC_IS_FINAL.
  if [ "$rc" -gt 4 ] || { [ -z "${RC_IS_FINAL:-}" ] && [ "$rc" -gt 3 ]; }; then
    bail "darnlink unreachable (rc=$rc)"
  fi
  # mode=repair gates on integrity only: a strict-only failure (3) is clean. (Set rc=0 rather than exit
  # so the create-readme axis below still runs — it is an independent axis, not part of strict.)
  if [ "$MODE" = "repair" ] && [ "$rc" -eq 3 ]; then rc=0; fi
  # create-readme axis: works under check/repair too (and mode=max WITH create_readme_excludes). Skipped
  # when already folded into the max robustify pass above (legacy max, no create_readme_excludes).
  if [ -n "$CREATE_README" ] && [ -z "${FOLDED_CR:-}" ]; then
    cr="$(create_readme_offenders)"
    if [ "$cr" = "ERR" ]; then
      # Couldn't evaluate the OPTIONAL create-readme axis. Never turn an ALREADY-red gate green: if the
      # core check/max pass already failed (rc!=0 — integrity/strict), keep that failure. Only when the
      # core was clean do we defer to the could-not-gate policy (bail: fail-open skip / fail-closed 4).
      if [ "$rc" -ne 0 ]; then
        echo "darnlink-gate: create-readme axis could not run, but the core gate is already failing (rc=$rc) — keeping it." >&2
        exit "$rc"
      fi
      bail "create-readme pass could not run (darnlink unreachable or python3 missing)"
    fi
    if [ "$cr" -gt 0 ]; then
      echo "darnlink-gate: $cr directory link(s) point at a folder with no README (create-readme axis; fix: 'uvx --from $REF darnlink . --create-readme --write')." >&2
      [ "$rc" -eq 0 ] && rc=1
    fi
  fi
  # Dangling axis, whole-repo. `added-lines` has no meaning without a staged diff, so here it reads as
  # `warn`: the ratchet belongs to the pre-commit surface, and the wall must not invent a scope.
  if [ "$DANGLING" != off ]; then
    dl_out="$(dangling_findings)"
    if [ "$dl_out" = "ERR" ]; then
      if [ "$rc" -ne 0 ]; then
        echo "darnlink-gate: dangling axis could not run, but the core gate is already failing (rc=$rc) — keeping it." >&2
        exit "$rc"
      fi
      bail "dangling pass could not run (darnlink unreachable or python3 missing)"
    fi
    dl_n=0; [ -n "$dl_out" ] && dl_n="$(printf '%s\n' "$dl_out" | grep -c . || true)"
    if [ "$dl_n" -gt 0 ]; then
      printf '%s\n' "$dl_out" | while IFS=$'\t' read -r df dline ddet; do
        echo "  [dangling] $df: $ddet" >&2
      done
    fi
    # The budget verdict runs even at dl_n=0. Nesting it under "there are findings" would make the
    # gate go SILENT exactly when the last dangler dies — the one moment the stale budget is both
    # visible and free to lower. A ratchet whose reminder disappears on success is not a ratchet.
    if [ "$dl_n" -gt 0 ] || { [ "$DANGLING" = repo ] && [ "$DANGLING_MAX" -gt 0 ]; }; then
      if [ "$DANGLING" = repo ]; then
        if [ "$dl_n" -gt "$DANGLING_MAX" ]; then
          if [ "$DANGLING_MAX" -gt 0 ]; then
            echo "darnlink-gate: $dl_n link(s) point at a path that does not exist — over the budget of $DANGLING_MAX (dangling axis)." >&2
          else
            echo "darnlink-gate: $dl_n link(s) point at a path that does not exist (dangling axis)." >&2
          fi
          [ "$rc" -eq 0 ] && rc=1
        elif [ "$dl_n" -lt "$DANGLING_MAX" ]; then
          # The whole point of a budget is that it goes down. Nothing else would ever notice that it
          # is stale, so say it here, where whoever just lowered the count is looking.
          if [ "$dl_n" -eq 0 ]; then
            echo "darnlink-gate: no dangling links left — drop dangling_max (still $DANGLING_MAX) so the wall is a wall again." >&2
          else
            echo "darnlink-gate: $dl_n dangling link(s), under the budget of $DANGLING_MAX — lower dangling_max to $dl_n to keep the ratchet." >&2
          fi
        else
          echo "darnlink-gate: $dl_n dangling link(s) — exactly at the budget (dangling_max=$DANGLING_MAX)." >&2
        fi
      else
        echo "darnlink-gate: $dl_n dangling link(s) — informational (dangling=$DANGLING; does not fail the gate)." >&2
      fi
    fi
  fi
  exit "$rc"
fi

# ---- staged scope (Option B): darnlink judges the whole tree; WE filter findings to staged files.
#      darnlink stays git-agnostic; the git lives here. Only fail on findings in files you're committing.
#      NOTE mode=max here behaves as strict (level 2), by design: the create-frontmatter axis needs
#      whole-tree reasoning, and per the wall architecture the staged pre-commit stays fast — max is
#      enforced at the whole-repo wall (pre-push / CI). See docs/elevating-your-link-gate.md §7.
# python3 does the filtering — fail OPEN if it's missing (don't brick a commit; CI covers the wall).
if ! command -v python3 >/dev/null 2>&1; then
  bail "(staged) python3 not found"
fi
mapfile -t STAGED < <(git diff --cached --name-only --diff-filter=ACMR -- '*.md' 2>/dev/null || true)
[ "${#STAGED[@]}" -eq 0 ] && { echo "darnlink-gate (staged): no staged .md — nothing to judge."; exit 0; }

# darnlink check exits 0/2/3 on findings (expected — we still get JSON); only rc>3 (e.g. 127) is
# "unreachable". Don't let set -e or a findings-exit trip the fail-open path.
set +e
DL_JSON="$(run check . --json "${DL_ARGS[@]}" 2>/dev/null)"; rc=$?
set -e
if [ "$rc" -gt 3 ] || [ -z "$DL_JSON" ]; then
  bail "(staged) darnlink unreachable (rc=$rc)"
fi
# ⚠️ The payloads go through TEMP FILES, never through the environment. Linux caps a SINGLE env var
# (or argv entry) at MAX_ARG_STRLEN = 32 pages = 128 KiB — a per-string limit, quite separate from the
# ~2 MB ARG_MAX total, and exceeding it makes the next `exec` fail with E2BIG. A whole-repo
# `check --json` on a large consumer is ~200 KB, so exporting it broke the staged path outright:
# `sed: Argument list too long`, and a gate that cannot run is a gate that gates nothing.
# (`create_readme_offenders` already used a temp file for exactly this reason; the staged path did
# not, and only tipped over once the JSON grew.)
DL_JSON_FILE="$(mktemp)"; printf '%s' "$DL_JSON" > "$DL_JSON_FILE"
DL_STAGED_FILE="$(mktemp)"; printf '%s\n' "${STAGED[@]}" > "$DL_STAGED_FILE"
DL_ADDED_FILE="$(mktemp)"
# Best-effort cleanup on every exit path, including the failure ones (`set -e`, a signal).
trap 'rm -f "$DL_JSON_FILE" "$DL_STAGED_FILE" "$DL_ADDED_FILE"' EXIT
export DL_JSON_FILE DL_STAGED_FILE DL_ADDED_FILE
export DL_ROOT="$root" DL_REF="$REF" DL_MODE="$MODE" DL_DANGLING="$DANGLING"

# The SAME --exclude/--ignore-block this run was configured with, shell-quoted, so the command the
# error message suggests can carry them. Without this the suggestion applies a DIFFERENT policy
# than the gate that just failed you -- which is the whole defect the suggestion exists to avoid.
DL_ARGS_STR=""
for a in "${DL_ARGS[@]}"; do DL_ARGS_STR+=" $(printf '%q' "$a")"; done
export DL_ARGS_STR
# The added-lines ratchet needs the LINES this commit adds, and git lives here, not in darnlink
# (spec 008, Option B). `-U0` gives hunks with no context, so every `+` line in the header range is
# genuinely new. Collected per staged file as `path:start,count`.
if [ "$DANGLING" = added-lines ]; then
  for p in "${STAGED[@]}"; do
    [ -n "$p" ] || continue
    git diff --cached -U0 -- "$p" 2>/dev/null \
      | sed -n 's/^@@ -[^ ]* +\([0-9]*\)\(,\([0-9]*\)\)\{0,1\} @@.*/\1,\3/p' \
      | sed "s#^#$p:#" >> "$DL_ADDED_FILE" || true
  done
fi

# Paths via env, PAYLOADS via files (see the MAX_ARG_STRLEN note above). No interpolation into the
# script either way → no injection from finding text.
python3 <<'PY'
import json, os, sys
root = os.environ["DL_ROOT"]
mode = os.environ.get("DL_MODE", "check")

def _read(var):
    path = os.environ.get(var)
    if not path or not os.path.exists(path):
        return ""
    with open(path, encoding="utf-8") as fh:
        return fh.read()

# realpath both sides: darnlink emits resolved absolute paths, so resolve symlinks here too to match.
staged = {os.path.realpath(os.path.join(root, p)) for p in _read("DL_STAGED_FILE").split("\n") if p.strip()}
data = json.loads(_read("DL_JSON_FILE"))
def hits(axis):
    return [f for f in data.get(axis, {}).get("findings", []) if os.path.realpath(f["file"]) in staged]
integ = hits("integrity")
strict = [] if mode == "repair" else hits("strict")   # mode=repair gates on integrity only

# --- dangling axis (015) ---------------------------------------------------------------------
# `added-lines` is the rung that makes this adoptable: judge only the lines the commit ADDS, so
# years of existing debt never block anyone and nothing new gets in. Per-FILE was tried and is not
# enough — editing one line of a README that already carries old danglers would block the commit,
# and a gate people bypass gates nothing.
dangling_mode = os.environ.get("DL_DANGLING", "off")
dangling = []
if dangling_mode != "off":
    found = hits("dangling")
    if dangling_mode == "added-lines":
        added = {}   # realpath -> set of added line numbers
        for row in _read("DL_ADDED_FILE").split("\n"):
            if not row.strip():
                continue
            path, _, span = row.rpartition(":")
            start, _, count = span.partition(",")
            try:
                start_i = int(start)
            except ValueError:
                continue
            # `+N @@` with no count means exactly one line; `+N,0` is a pure deletion (adds nothing).
            count_i = 1 if count == "" else int(count)
            key = os.path.realpath(os.path.join(root, path))
            added.setdefault(key, set()).update(range(start_i, start_i + count_i))
        dangling = [f for f in found
                    if f.get("line") in added.get(os.path.realpath(f["file"]), ())]
    else:
        dangling = found

for f in integ:  print(f"  [integrity/{f['kind']}] {f['file']}: {f['detail']}")
for f in strict: print(f"  [strict/{f['kind']}] {f['file']}: {f['detail']}")
for f in dangling: print(f"  [dangling] {f['file']}: {f['detail']}")

if integ:  print("darnlink-gate (staged): integrity failure in a file you're committing."); sys.exit(2)
if strict:
    # The suggested command MUST be safe to paste. It used to be a bare
    # `darnlink . --robustify --write`, which is correct as a capability and a trap as advice: it
    # rewrites the WHOLE repo and, worse, it does NOT carry the `--exclude` list this very gate was
    # configured with, so it applies a different policy than the one that just failed you. Measured
    # in a consuming monorepo: 110 links anchored across 26 files when ONE was asked for -- pasted
    # by someone who trusted the message, on a tree shared by several concurrent sessions.
    #
    # The piped `--only-from -` form is the one this project's own CLI help recommends: it scans
    # the repo (needed, the link's TARGET must be seen) but writes only the staged files.
    # `--create-frontmatter` is spelled out because without it darnlink skips a target that has no
    # frontmatter yet, reporting `skipped (no frontmatter)` and writing nothing -- a "did nothing"
    # that reads as "nothing to do", and the anchor never appears.
    print("darnlink-gate (staged): un-anchored plain link in a file you're committing.")
    print("  Anchor the files you are committing (and give their targets a uuid):")
    print("    git diff --cached --name-only -- '*.md' | \\")
    print(f"      uvx --from {os.environ['DL_REF']} darnlink ."
          f"{os.environ.get('DL_ARGS_STR', '')} --robustify --create-frontmatter --write --only-from -")
    print("  It writes the staged files AND creates the missing `uuid` in the files they link to --")
    print("  an anchor needs a uuid at BOTH ends. Nothing else in the repo is touched.")
    print("  (Do NOT run a bare `darnlink . --robustify --write`: it rewrites the whole repo and")
    print("   drops this gate's --exclude/--ignore-block, applying a different policy.)")
    sys.exit(3)
if dangling and dangling_mode != "warn":
    print(f"darnlink-gate (staged): {len(dangling)} link(s) you are adding point at a path that does "
          "not exist. Fix the path, or drop the link if the target is gone.")
    sys.exit(1)
if dangling:
    print(f"darnlink-gate (staged): {len(dangling)} dangling link(s) — informational (dangling=warn).")
print("darnlink-gate (staged): clean."); sys.exit(0)
PY
