#!/usr/bin/env bash
#
# preflight - every lint job this repository's pipeline runs, run HERE, with the
# pipeline's OWN configuration, before the push instead of after it.
#
# ---------------------------------------------------------------------------------------------
# WHY IT EXISTS. Three measurements taken in two days, all of the same shape.
#
#   phpcs      believed impossible to run on this host, and that belief was written down in
#              prose. It runs. EIGHT real errors sat undetected on this branch; a ninth turned
#              pipeline 949480 red.
#   stylelint  believed impossible to run here, repeated in a dispatch. It runs. THREE real
#              findings turned pipeline 949481 red.
#   eslint     never attempted at all. It runs, and it caught two formatting errors in freshly
#              written code before they reached CI.
#
# A tool nobody tried is indistinguishable from a tool that cannot run, and both of them look
# exactly like a tool with nothing to report. This file removes the ambiguity: it either runs
# the job, or it prints the job's name in the NOT COVERED list. There is no third state and
# there is deliberately no silent skip.
#
# ---------------------------------------------------------------------------------------------
# THE SECOND FAILURE MODE, WHICH IS SUBTLER THAN "WE DID NOT RUN IT".
#
# A local stylelint WAS run at one point. It reported 43 findings where the gate reported 3,
# because it resolved a different configuration. The 43 were read as "pre-existing, unchanged,
# not mine" - and that is how three real findings crossed a green local gate.
#
# So the rule this file is built around is not "run the linters". It is:
#
#     A LOCAL CHECK THAT DISAGREES WITH THE GATE IN EITHER DIRECTION IS NOT A PRE-FLIGHT.
#     Being NOISIER is more dangerous than being quieter, because noise gets dismissed.
#
# Everything below follows from that. The configuration is not written here; it is FETCHED from
# the same two places the pipeline fetches it from - the shared gitlab_templates repository and
# this repository's own .gitlab-ci.yml - and the resolved values are printed on every run. Where
# parity cannot be proved, the run says so in words instead of implying it.
#
# ---------------------------------------------------------------------------------------------
# WHAT EACH CHECK PRINTS. A result and a DENOMINATOR, always. A green linter is a statement
# about the set it opened, and most linters do not print that set unless asked (I-045). phpcs
# is asked with -p, stylelint with --formatter verbose, phpstan with --debug, eslint by counting
# its own JSON result objects. A check whose denominator is zero FAILS here, because "0 files
# scanned, 0 problems" and "everything is fine" are the same sentence (I-028).
#
# ---------------------------------------------------------------------------------------------
# WHERE THE WORK HAPPENS, AND WHY THE COPY IS MANDATORY.
#
# The Windows checkout may carry CRLF. phpcs counts the CARRIAGE RETURN as a character, so
# linting the checkout directly reports line-length violations that do not exist in the packaged
# file. The linters therefore run against a copy with line endings normalised.
#
# That copy must land OUTSIDE the docroot. Drupal's extension discovery walks the docroot, and a
# second copy of this theme under web/themes wins the discovery race and gets SERVED - which has
# already happened once on this machine and produced a rig serving stale code. The copy goes to
# <rig>/lint-work/, a sibling of web/, and never inside it.
#
# THIS SCRIPT ABSORBS THE STANDALONE lint-theme.sh HELPER that used to prepare that copy by hand.
# Two mechanisms that prepare the same tree are two mechanisms that can disagree about it, and a
# disagreement between them would be invisible - both would print a plausible number. There is
# now one, it is versioned, and it is this file.
#
# ---------------------------------------------------------------------------------------------
# WHAT THIS IS NOT.
#
# It is a REPLICA of the pipeline's lint stage, not the pipeline. It pins nothing about the
# runner's image. It cannot see the ref the pipeline resolves for the shared template repository,
# because the job log endpoint answers 401 to an anonymous request - so the shared assets are
# read from that repository's main branch and the run SAYS SO rather than claiming parity it has
# not measured. The gate remains the job list read from the API; this only tells you what that
# list is likely to say before you spend a pipeline finding out.
#
# It is also NOT wired into the gate runner, deliberately. It needs a running container, and
# making the gate depend on Docker is a trade-off for the maintainer to weigh, not for this file
# to decide. Running it is a habit; the gate is elsewhere.
#
# ---------------------------------------------------------------------------------------------
# THERE IS NO DEFAULT RIG, AND ONE RIG IS REFUSED BY NAME.
#
# Until 2026-09-24 a run that named no rig fell back to ~/agora-smoke. That directory is not a
# throwaway rig: it is [andres]'s live preview, the one site on this machine a person has open in
# a browser, and it must never be written into or taken down. This script WRITES into its rig -
# it deletes and rebuilds <rig>/lint-work/ on every run and keeps <rig>/lint-work-cache/ beside
# it - and it runs commands inside the rig's container. A tool whose default target is the one
# site somebody is looking at is a standing risk, whether or not it has gone off yet.
#
# It had already written there. Measured on 2026-09-24, before this change, by listing the
# preview rather than by reasoning about it: ~/agora-smoke held a complete lint-work/ tree and a
# lint-work-cache/ left by a run of this script at 2026-09-07 00:00 (+0200), and the site
# template's copy of this file had fetched into its own cache directory there at 2026-09-24
# 00:47. Nothing is known to have broken. That is luck, not a property of the script.
#
# So there are two rules, and both are applied BEFORE any job runs:
#
#   1. AGORA_RIG, or --rig, is REQUIRED whenever a rig-side job is selected - which a full run
#      always does. There is no fallback of any kind: a missing rig is an error that names the
#      variable and shows an example. --list and a host-side-only subset touch no rig and are
#      not asked for one.
#   2. A rig that RESOLVES to the preview is refused, by name. The comparison is made on the
#      container host, between PHYSICAL paths, never between the strings as typed: ~/agora-smoke,
#      the same directory written absolutely, with a trailing slash, through a symlink, or
#      through a bind mount (`-ef`: same device, same inode) are all the preview. So is anything
#      INSIDE it - a rig at ~/agora-smoke/web would put the lint tree in the preview's docroot,
#      and ddev finds its project by walking UP from the directory it is run in.
#
# There is deliberately no switch that turns the refusal off. A guard that can silence itself in
# one keystroke is the thing it was built to prevent.
#
# Usage - every example that needs a rig names one, because there is no default to fall back on:
#   AGORA_RIG='~/agora-t0615' bash tests/bin/preflight               # every covered job
#   AGORA_RIG='~/agora-t0615' bash tests/bin/preflight phpcs eslint  # a subset - the banner says SUBSET
#   bash tests/bin/preflight --rig '~/agora-t0615' stylelint         # --rig is AGORA_RIG as an option
#   bash tests/bin/preflight cspell agora-invariants                 # host side only - no rig needed
#   bash tests/bin/preflight --list                                  # the coverage split, nothing else
#
# THE QUOTES AROUND '~/...' ARE LOAD-BEARING ON WINDOWS. Unquoted, Git Bash expands the tilde to
# the WINDOWS home before this script ever sees it, and the rig is then looked for at a /c/Users
# path that does not exist inside WSL. Quoted, the tilde reaches the container host and is
# expanded there, against the account the rig actually belongs to.
#
# Exit: 0 clean · 1 a covered job reported a finding · 2 the run could not be made faithful,
# which includes a missing rig and a refused one.

set -u

C_OK=""; C_BAD=""; C_WARN=""; C_DIM=""; C_OFF=""
if [ -t 1 ]; then
  C_OK=$'\033[32m'; C_BAD=$'\033[31m'; C_WARN=$'\033[33m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m'
fi

# `pwd -W` is what makes paths work under Git Bash: the shell reports /c/Users/... but every
# tool this script hands a path to wants the native form. The same trick is in the spell-check
# pre-flight beside it, for the same reason and after the same debugging round.
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && { pwd -W 2>/dev/null || pwd; })
cd "$REPO_ROOT" || { printf 'FATAL: cannot reach the repository root\n' >&2; exit 2; }

CHECKS=0
FAILS=0

fatal() { # <reason>
  printf '\n%sFATAL:%s %s\n' "$C_BAD" "$C_OFF" "$1" >&2
  printf 'This run has NO result, which is not a clean result. A pre-flight that skips a check\n' >&2
  printf 'and exits 0 is the exact defect it was written to prevent.\n' >&2
  exit 2
}

note()  { printf '  %s%s%s\n' "$C_DIM" "$1" "$C_OFF"; }

# check <label> <obtained> <expected>
check() {
  CHECKS=$((CHECKS + 1))
  if [ "$2" = "$3" ]; then
    printf '  %-46s %-24s | %-24s | %sOK%s\n' "$1" "$2" "$3" "$C_OK" "$C_OFF"
  else
    FAILS=$((FAILS + 1))
    printf '  %-46s %-24s | %-24s | %sFAIL%s\n' "$1" "$2" "$3" "$C_BAD" "$C_OFF"
  fi
}

# check_positive <label> <obtained> - a denominator of zero is a failure, never a pass.
check_positive() {
  CHECKS=$((CHECKS + 1))
  case "${2:-}" in
    ''|*[!0-9]*) FAILS=$((FAILS + 1))
      printf '  %-46s %-24s | %-24s | %sFAIL%s\n' "$1" "${2:-<none>}" '>0' "$C_BAD" "$C_OFF" ;;
    *) if [ "$2" -gt 0 ]; then
         printf '  %-46s %-24s | %-24s | %sOK%s\n' "$1" "$2" '>0' "$C_OK" "$C_OFF"
       else
         FAILS=$((FAILS + 1))
         printf '  %-46s %-24s | %-24s | %sFAIL%s\n' "$1" "$2" '>0' "$C_BAD" "$C_OFF"
       fi ;;
  esac
}

group() {
  printf '\n%s\n' "$1"
  printf '  %-46s %-24s | %-24s | %s\n' 'check' 'obtained' 'expected' 'verdict'
  printf '  %s\n' '--------------------------------------------------------------------------------------------------------'
}

# ------------------------------------------------------------------- the subject repository ---
# The machine name is DERIVED from the single root info file rather than written down. A theme
# package declares exactly one extension; if this tree declares none, or more than one, then it
# is not the tree this script was written for and guessing is worse than stopping.
INFO_LIST=$(ls -1 ./*.info.yml 2>/dev/null | sed 's|^\./||')
INFO_COUNT=$(printf '%s' "$INFO_LIST" | grep -c . 2>/dev/null)
[ "${INFO_COUNT:-0}" -eq 1 ] || fatal "expected exactly one root *.info.yml, found ${INFO_COUNT:-0} - this is not the theme checkout, and this script must never be pointed at the site template"
MACHINE=${INFO_LIST%.info.yml}

command -v git >/dev/null 2>&1 || fatal 'git not found'
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || fatal 'not inside a git work tree'

# ------------------------------------------------------------------ the pipeline's job list ---
# NOT restated here. tests/bin/watch-gate already carries the DECLARED job list for this
# repository, checked against the API on every pipeline it watches, and a list written down twice
# goes stale in one place first. It is read with a pattern rather than by name so that this file
# does not itself have to spell the product out.
WATCH="$REPO_ROOT/tests/bin/watch-gate"
[ -f "$WATCH" ] || fatal "tests/bin/watch-gate is missing - the declared job list lives there and this script refuses to invent one"
JOBS_DECLARED=$(sed -n "/^EXPECTED_[a-z_]*theme='/,/'\$/p" "$WATCH" \
  | sed "s/^EXPECTED_[a-z_]*theme='//; s/'\$//" | grep -c . 2>/dev/null)
JOBS_LIST=$(sed -n "/^EXPECTED_[a-z_]*theme='/,/'\$/p" "$WATCH" \
  | sed "s/^EXPECTED_[a-z_]*theme='//; s/'\$//" | grep . 2>/dev/null | LC_ALL=C sort)
[ "${JOBS_DECLARED:-0}" -gt 0 ] || fatal 'could not read the declared job list out of tests/bin/watch-gate'

# The split. Every declared job is in exactly one of these two, and that is ASSERTED below: a
# pipeline that grows an eleventh job fails this script until somebody decides which side it is
# on. A pre-flight that silently covers 9 of 10 is the shape that let stylelint through.
COVERED='agora-invariants composer-lint cspell eslint phpcs phpstan stylelint'
UNCOVERED='composer nightwatch phpunit'

WHY_composer='builds the CI environment itself - it resolves core plus core-dev and installs them. Reproducing it means rebuilding the rig, not running a linter.'
WHY_nightwatch='needs Chrome and chromedriver, neither of which is installed on this host. This is the axe gate: its absence here is the single largest hole in this pre-flight and it is stated rather than implied.'
WHY_phpunit='needs an installed site and a database, and takes minutes rather than seconds. Run it in the rig against a built site; it is not folded into a pre-flight that has to be cheap enough to actually get run.'

SPLIT=$(printf '%s %s' "$COVERED" "$UNCOVERED" | tr ' ' '\n' | grep . | LC_ALL=C sort)
if [ "$SPLIT" != "$JOBS_LIST" ]; then
  # The colour token gets its own conversion specifier and is never glued to the word after it.
  # A specifier followed immediately by lowercase letters reads to a spell checker as ONE token,
  # and that is how two words that exist nowhere in the running program ended up being reported
  # as unknown by the blocking job. Costs nothing; avoids a dictionary entry for a non-word.
  printf '%s%s%s\n' "$C_BAD" 'declared by watch-gate:' "$C_OFF" >&2
  printf '%s\n' "$JOBS_LIST" | sed 's/^/    /' >&2
  printf '%s%s%s\n' "$C_BAD" 'split by this script:' "$C_OFF" >&2
  printf '%s\n' "$SPLIT" | sed 's/^/    /' >&2
  fatal 'the declared job list and this script disagree. A job this pre-flight has not decided about is a job it would silently not cover.'
fi

# ------------------------------------------------------------------------------ arguments ---
LIST_ONLY=0
RIG_OVERRIDE=${AGORA_RIG:-}
WANTED=''
while [ $# -gt 0 ]; do
  case "$1" in
    --list) LIST_ONLY=1 ;;
    --rig)  shift; [ $# -gt 0 ] || fatal '--rig needs a path'; RIG_OVERRIDE=$1 ;;
    -*)     fatal "unknown option: $1" ;;
    *)      printf '%s\n' "$COVERED" | tr ' ' '\n' | grep -qxF "$1" \
              || fatal "not a covered job: $1 (covered: $COVERED)"
            WANTED="$WANTED $1" ;;
  esac
  shift
done
WANTED=${WANTED# }
SUBSET=0
[ -n "$WANTED" ] && SUBSET=1
[ -z "$WANTED" ] && WANTED="$COVERED"

wanted() { printf '%s\n' "$WANTED" | tr ' ' '\n' | grep -qxF "$1"; }

# ------------------------------------------------------------------------ the rig, required ---
# WHICH JOBS NEED A RIG IS DECIDED HERE, before anything runs, because the rule below depends on
# it and a requirement discovered after the host-side jobs have run is discovered minutes late.
RIG_JOBS=''
for j in composer-lint eslint phpcs phpstan stylelint; do
  wanted "$j" && RIG_JOBS="$RIG_JOBS $j"
done
RIG_JOBS=${RIG_JOBS# }

# NO DEFAULT RIG (see the header). This half needs only the string, so it stops the run before
# the banner and the reason is the first thing on the screen. Resolving the rig and refusing the
# preview need the container host, and happen below - still before any job.
if [ "$LIST_ONLY" -eq 0 ] && [ -n "$RIG_JOBS" ] && [ -z "$RIG_OVERRIDE" ]; then
  fatal "AGORA_RIG is not set and no --rig was given, and this run includes rig-side jobs ($RIG_JOBS). There is NO default rig: the old one was ~/agora-smoke, the live preview, which this script must never write into. Name a throwaway rig, quoted so the tilde reaches the container host - for example:  AGORA_RIG='~/agora-t0615' bash tests/bin/preflight"
fi

# --------------------------------------------------------------------------------- banner ---
printf '=========================================================================================================\n'
printf 'preflight - every lint job the pipeline runs, with the pipeline'"'"'s own configuration\n'
printf 'repo:      %s (%s)\n' "$REPO_ROOT" "$MACHINE"
printf 'commit:    %s\n' "$(git rev-parse --short HEAD 2>/dev/null || echo '<none>')"
printf 'date:      %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '=========================================================================================================\n'

printf '\nCOVERAGE - printed on every run, because a pre-flight that silently covers 9 of 10 is the\n'
printf 'shape that let three stylelint findings through. %s jobs are declared for this repository.\n' "$JOBS_DECLARED"
printf '  covered (%s):     %s\n' "$(printf '%s' "$COVERED" | wc -w | tr -d ' ')" "$COVERED"
printf '  %sNOT COVERED (%s): %s%s\n' "$C_WARN" "$(printf '%s' "$UNCOVERED" | wc -w | tr -d ' ')" "$UNCOVERED" "$C_OFF"
for j in $UNCOVERED; do
  eval "_why=\${WHY_$j}"
  printf '    %s%-11s%s %s\n' "$C_WARN" "$j" "$C_OFF" "$_why"
done

if [ "$SUBSET" -eq 1 ]; then
  printf '\n%sSUBSET RUN: only [%s] will be run. This is NOT a pre-flight verdict.%s\n' "$C_WARN" "$WANTED" "$C_OFF"
fi

[ "$LIST_ONLY" -eq 1 ] && exit 0

# ==============================================================================================
# THE RIG - reached, resolved and vetted BEFORE any job runs, the host-side ones included.
# ==============================================================================================
# The refusal belongs before the first job, not merely before the first write. A run that spends
# minutes on the host-side jobs and THEN refuses has been pointed at the preview for all of them,
# and to anyone watching it reads like a run that went there and failed.
if [ -n "$RIG_JOBS" ]; then

  # ------------------------------------------------------------------ where the tools answer ---
  # ON A WINDOWS HOST THIS ALWAYS HOPS INTO WSL, and it does NOT ask whether a Windows-side ddev
  # exists first. It does exist on this machine, in C:\Program Files - and taking it was the bug
  # this comment replaces. The container engine and every rig live INSIDE WSL2; the Windows ddev
  # resolves happily, runs against a different world, and its very first act is to look for the
  # rig under a Windows home directory that a Linux path can never be. It failed loudly here only
  # by luck. "A tool that resolves" and "the tool the work needs" are different claims, and this
  # whole file exists because the gap between them keeps costing pipelines.
  HOP=''; WSL_EXE=''
  case "$(uname -s 2>/dev/null)" in
    MINGW*|MSYS*|CYGWIN*)
      WSL_EXE=$(command -v wsl.exe 2>/dev/null)
      [ -z "$WSL_EXE" ] && WSL_EXE=$(command -v wsl 2>/dev/null)
      [ -n "$WSL_EXE" ] || fatal 'this is a Windows host and there is no wsl.exe to reach the rig through'
      MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$WSL_EXE" -e bash -lc 'command -v ddev >/dev/null 2>&1' \
        || fatal 'no ddev inside WSL, which is where this machine keeps its container engine and its rigs. A ddev on the Windows PATH is not a substitute and is deliberately not used.'
      HOP=wsl ;;
    *)
      command -v ddev >/dev/null 2>&1 || fatal 'ddev is not on PATH and this host is not Windows, so there is nowhere to hop to'
      HOP=none ;;
  esac

  # The repository as the container host will see it.
  if [ "$HOP" = wsl ]; then
    RIG_REPO=$(MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' \
      "$WSL_EXE" -e wslpath -a "$REPO_ROOT" 2>/dev/null | tr -d '\r' | tr -d '\0')
    [ -n "$RIG_REPO" ] || RIG_REPO=$(printf '%s' "$REPO_ROOT" | sed -E 's|^([a-zA-Z]):|/mnt/\L\1|')
  else
    RIG_REPO="$REPO_ROOT"
  fi

  # The rig script is piped in rather than written to disk: nothing this script does leaves an
  # artefact in the repository, so there is no cache directory to ignore and nothing downloaded
  # can ever be committed by accident.
  run_in_rig() { # <stdin: the script> <args...>
    if [ "$HOP" = wsl ]; then
      MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$WSL_EXE" -e bash -s -- "$@"
    else
      bash -s -- "$@"
    fi
  }

  # ------------------------------------------------------------ the rig, resolved and vetted ---
  # ONE mechanism decides where the work lands, and it runs on the CONTAINER HOST, because that
  # is the only place a path like ~/agora-t0615 means anything. It expands the tilde, resolves
  # the result to a physical path and refuses the preview; what it prints back is the resolved
  # path, and THAT - never the string as typed - is what the rig side below is handed. The rig
  # side expands nothing and has no default, so nothing downstream can quietly disagree with it.
  RIG_VET_OUT=$(run_in_rig "$RIG_OVERRIDE" <<'RIG_VET'
set -u
RIG=${1:-}
die() { printf '##FATAL|%s\n' "$*"; exit 2; }

# HOME IS NOT USABLE HERE, and this cost a run. When this script is piped into WSL from Git Bash
# the Windows HOME comes with it, so `$HOME` reads /c/Users/... inside a Linux shell and the rig
# is looked for at a path that cannot exist. The home directory is therefore read from the
# password database, which describes the account this shell is actually running as.
REAL_HOME=$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6)
[ -n "$REAL_HOME" ] || REAL_HOME=$(cd ~ 2>/dev/null && pwd)
[ -n "$REAL_HOME" ] || die 'cannot determine this account'"'"'s home directory, so neither a ~ in the rig path nor the preview can be located - and a rig that cannot be compared with the preview is not used'
[ -n "$RIG" ] || die 'no rig reached the container host. There is no default, and this layer never invents one.'
case "$RIG" in
  '~')   RIG=$REAL_HOME ;;
  '~/'*) RIG="$REAL_HOME/${RIG#\~/}" ;;
esac

# PHYSICAL PATHS, NEVER STRINGS. `cd -P` then `pwd -P` resolves every symlink, `.`, `..`, doubled
# slash and trailing slash away, so every spelling of one directory prints the same line. The
# preview is resolved the same way; if it does not exist on this machine its plain path stands in,
# which is safe - the rig has just been shown to exist, so it cannot be a directory that does not.
physical() { ( cd -P -- "$1" 2>/dev/null && pwd -P ); }
RIG_REAL=$(physical "$RIG")
[ -n "$RIG_REAL" ] || die "no rig at $RIG. Point AGORA_RIG, or --rig, at a ddev project holding a built Drupal. If that path begins /c/Users, the tilde was expanded by the calling shell before it got here - quote it: AGORA_RIG='~/agora-t0615'."
PREVIEW="$REAL_HOME/agora-smoke"
PREVIEW_REAL=$(physical "$PREVIEW")
[ -n "$PREVIEW_REAL" ] || PREVIEW_REAL=$PREVIEW

REFUSED="REFUSED: the rig resolves to $RIG_REAL, and that is ~/agora-smoke ($PREVIEW_REAL) or a directory inside it - the LIVE PREVIEW, not a throwaway rig. This script deletes and rebuilds a lint tree in its rig and runs commands in the rig's container, and the preview must never be written into or taken down. Point AGORA_RIG at a throwaway rig instead, for example AGORA_RIG='~/agora-t0615'. Nothing turns this refusal off."
case "$RIG_REAL/" in "$PREVIEW_REAL/"*) die "$REFUSED" ;; esac
# The same directory reached through a bind mount has a different path and the same inode.
if [ -e "$PREVIEW" ] && [ "$RIG_REAL" -ef "$PREVIEW" ]; then die "$REFUSED"; fi

printf '##RIG|%s\n' "$RIG_REAL"
printf '##PREVIEW|%s\n' "$PREVIEW_REAL"
RIG_VET
  )
  RIG_VET_RC=$?
  if printf '%s\n' "$RIG_VET_OUT" | grep -q '^##FATAL|'; then
    fatal "$(printf '%s\n' "$RIG_VET_OUT" | sed -n 's/^##FATAL|//p' | head -1)"
  fi
  RIG_RESOLVED=$(printf '%s\n' "$RIG_VET_OUT" | sed -n 's/^##RIG|//p' | tr -d '\r' | head -1)
  PREVIEW_RESOLVED=$(printf '%s\n' "$RIG_VET_OUT" | sed -n 's/^##PREVIEW|//p' | tr -d '\r' | head -1)
  [ "$RIG_VET_RC" -eq 0 ] && [ -n "$RIG_RESOLVED" ] \
    || fatal "the rig could not be resolved on the container host (exit $RIG_VET_RC). A rig that was not compared with the preview is not used."

  printf '\nRIG - resolved on the container host before any job runs\n'
  printf '  given:     %s\n' "$RIG_OVERRIDE"
  printf '  resolved:  %s\n' "$RIG_RESOLVED"
  printf '  preview:   %s - refused by name; the rig above is neither it nor inside it\n' "$PREVIEW_RESOLVED"
else
  printf '\nRIG - not needed: no rig-side job is selected, so none is resolved and none is touched\n'
fi

# ==============================================================================================
# HOST SIDE - the two jobs that need no container.
# ==============================================================================================

# ------------------------------------------------------------------------- agora-invariants ---
# TWO parts, and the first is the one that cannot be done any other way on this host: a script
# committed without its executable bit passes every check here and fails on the runner, because
# `[ -x ]` asks the FILESYSTEM and git is configured here not to track that bit. Pipeline 950124
# is what that costs - a branch red for a full push cycle behind a local 32 checks, 0 failures.
#
# THE RULE ITSELF IS NOT WRITTEN HERE. It lives in tests/bin/executable-bit, which reads the mode
# out of the GIT INDEX and selects files by the shebang on line 1, across the whole tracked tree
# rather than one directory. That script is now also gate-a-theme.sh's G10, so this pre-flight
# and the gate run the SAME implementation and cannot drift apart - which is the failure
# ~/lint-theme.sh had before it was absorbed into this file, arriving from the other direction.
#
# It therefore runs TWICE when this job is selected: once here, and once inside part 2 as G10.
# That is deliberate and costs a fraction of a second of pure git. Running it here surfaces its
# two denominators as rows of their own, which is the discipline this whole file exists for; part
# 2 prints only the runner's FAILING lines, and a denominator that appears solely on failure is
# not a denominator.
if wanted agora-invariants; then
  group 'agora-invariants (host) - part 1: the executable bit, read from the git index'
  EB="$REPO_ROOT/tests/bin/executable-bit"
  [ -f "$EB" ] || fatal 'tests/bin/executable-bit is missing - the rule this half checks lives there, and this script refuses to grow a second copy of it'
  EB_OUT=$(bash "$EB" 2>&1); EB_RC=$?
  EB_FILES=$(printf '%s\n' "$EB_OUT" | grep -oE 'examined: [0-9]+' | tail -1 | grep -oE '[0-9]+')
  EB_SCRIPTS=$(printf '%s\n' "$EB_OUT" | grep -oE 'scripts:  [0-9]+' | tail -1 | grep -oE '[0-9]+')
  note "$(printf '%s\n' "$EB_OUT" | grep -E '^(examined|scripts|findings):' | tr '\n' ' ')"
  check          'executable-bit (exit)'           "$EB_RC" '0'
  check_positive 'executable-bit (files examined)' "$EB_FILES"
  check_positive 'executable-bit (scripts found)'  "$EB_SCRIPTS"
  if [ "$EB_RC" -ne 0 ]; then
    printf '%s\n' "$EB_OUT" | grep -E '^  [^ ]+:[0-9<]' | sed 's/^/  /'
  fi

  group 'agora-invariants (host) - part 2: the gate runner itself'
  GATE="$REPO_ROOT/tests/bin/gate-a-theme.sh"
  [ -f "$GATE" ] || fatal 'tests/bin/gate-a-theme.sh is missing'
  GATE_OUT=$(bash "$GATE" 2>&1); GATE_RC=$?
  GATE_CHECKS=$(printf '%s\n' "$GATE_OUT" | grep -oE '[0-9]+ checks' | tail -1 | grep -oE '[0-9]+')
  GATE_FAILS=$(printf '%s\n' "$GATE_OUT" | grep -oE '[0-9]+ failures' | tail -1 | grep -oE '[0-9]+')
  note "gate-a-theme.sh: ${GATE_CHECKS:-<none>} checks, ${GATE_FAILS:-<none>} failures"
  check          'gate-a-theme.sh (exit)'    "$GATE_RC" '0'
  check_positive 'gate-a-theme.sh (checks)'  "$GATE_CHECKS"
  if [ "$GATE_RC" -ne 0 ]; then
    printf '%s\n' "$GATE_OUT" | grep -E 'FAIL|FATAL' | sed 's/^/    /'
  fi
fi

# ------------------------------------------------------------------------------------ cspell ---
# DELEGATED, not reimplemented. tests/bin/spellcheck already reproduces this job faithfully and
# already carries the parity check that matters: it reads the shared template's CORE_STABLE and
# refuses to run against a newer dictionary branch than the one CI installs, because a newer
# dictionary accepts words the blocking job rejects. Writing a second cspell runner here would be
# a second mechanism that can disagree with the first.
if wanted cspell; then
  group 'cspell (host) - delegated to tests/bin/spellcheck'
  SPELL="$REPO_ROOT/tests/bin/spellcheck"
  [ -f "$SPELL" ] || fatal 'tests/bin/spellcheck is missing - the cspell pre-flight lives there'
  SPELL_OUT=$(bash "$SPELL" 2>&1); SPELL_RC=$?
  SPELL_FILES=$(printf '%s\n' "$SPELL_OUT" | grep -oE 'Files checked: [0-9]+' | tail -1 | grep -oE '[0-9]+')
  printf '%s\n' "$SPELL_OUT" | grep -E '^  (parity|UNVERIFIED)' | sed 's/^ */  /'
  note "files opened by cspell: ${SPELL_FILES:-<none>}"
  check          'cspell (exit)'          "$SPELL_RC" '0'
  check_positive 'cspell (files checked)' "$SPELL_FILES"
  if [ "$SPELL_RC" -ne 0 ]; then
    printf '%s\n' "$SPELL_OUT" | grep -E 'Unknown word|Issues found' | sed 's/^/    /' | head -30
  fi
fi

# ==============================================================================================
# RIG SIDE - the five jobs that need core's node modules and the project's PHP toolchain.
# ==============================================================================================
# The rig was reached, resolved and vetted above, before the host-side jobs ran. What is handed
# over here is that RESOLVED path, never the string as typed.
if [ -n "$RIG_JOBS" ]; then

  RIG_OUT=$(run_in_rig "$RIG_REPO" "$RIG_RESOLVED" "$MACHINE" "$RIG_JOBS" <<'RIG_SCRIPT'
set -u
REPO=$1
RIG=${2:-}
MACHINE=$3
JOBS=$4

say()  { printf '%s\n' "$*"; }
die()  { printf '##FATAL|%s\n' "$*"; exit 2; }
res()  { printf '##RESULT|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4"; }

# THE RIG ARRIVES RESOLVED AND VETTED, and it is used exactly as given. The step that ran on this
# host before any job expanded the tilde, resolved the physical path and refused the preview;
# this layer expands nothing and has NO DEFAULT, so there is one place that decides where the
# work lands. An empty argument is a caller that skipped that step, and it stops here rather
# than being filled in.
[ -n "$RIG" ] || die 'no rig was handed to the rig side. There is no default, and this layer never invents one.'
[ -d "$RIG" ] || die "no rig at $RIG. Point this at a ddev project holding a built Drupal, with AGORA_RIG or --rig."
command -v ddev   >/dev/null 2>&1 || die 'ddev is not on PATH inside the container host'
command -v rsync  >/dev/null 2>&1 || die 'rsync is not on PATH - it is how the line-ending-normalised copy is made'
command -v curl   >/dev/null 2>&1 || die 'curl is not on PATH - the shared configuration is fetched, never guessed'

cd "$RIG" || die "cannot enter $RIG"
# EVERY ddev CALL IN THIS SCRIPT REDIRECTS ITS STANDARD INPUT, and leaving that off cost a
# debugging round with the most confusing symptom available: no output, no error and exit 0.
# This script is fed to bash on STDIN. ddev reads stdin, so the first `ddev exec` swallowed
# the REST OF THE SCRIPT, bash hit end-of-file and stopped - cleanly, silently, and looking
# exactly like a run in which nothing was wrong.
CONTAINER_ROOT=$(ddev exec pwd </dev/null 2>/dev/null | tr -d '\r\n')
[ -n "$CONTAINER_ROOT" ] || die "the rig at $RIG has no running container. Start it with: cd $RIG && ddev start"

CORE="$RIG/web/core"
[ -d "$CORE" ] || die "no Drupal core in the rig at $CORE - the stylelint and eslint jobs read their configuration out of it"

# --------------------------------------------------------------- upstream inputs, fetched ---
CACHE="$RIG/lint-work-cache"
mkdir -p "$CACHE" || die "cannot create $CACHE"
TPL='https://git.drupalcode.org/project/gitlab_templates/-/raw/main'

fetch() { # <url-path> <dest> <label>
  code=$(curl -sS -o "$CACHE/$2.tmp" -w '%{http_code}' "$TPL/$1" 2>/dev/null)
  if [ "$code" = '200' ]; then
    mv "$CACHE/$2.tmp" "$CACHE/$2"; say "  fetched  $3"
  else
    rm -f "$CACHE/$2.tmp"
    if [ -s "$CACHE/$2" ]; then
      say "  STALE    $3 (HTTP $code; using the cached copy - the result may lag the job)"
    else
      die "cannot fetch $3 (HTTP $code) and there is no cached copy. The gate cannot be reproduced without it."
    fi
  fi
}

say 'PARITY - the configuration this run resolved, against the configuration the gate resolves.'
say '  shared template ref: main. CI resolves its own ref at run time and the job log endpoint'
say '  answers 401 to an anonymous request, so THIS ONE IS UNVERIFIED and is stated rather than'
say '  implied. Everything else below is read, not assumed.'
fetch 'includes/include.drupalci.variables.yml'        vars.yml    'gitlab_templates variables (job defaults)'
fetch 'includes/include.drupalci.hidden-variables.yml' hidden.yml  'gitlab_templates hidden variables (CORE_STABLE)'
fetch 'assets/phpcs.xml.dist'                          phpcs.xml.dist 'gitlab_templates assets/phpcs.xml.dist'
fetch 'assets/phpstan.neon'                            phpstan.neon   'gitlab_templates assets/phpstan.neon'

# The rig's core must be the core the pipeline installs. stylelint, eslint and prettier all read
# their configuration out of web/core, so a different core is a different configuration - the
# exact failure that made a local run report 43 findings against the gate's 3.
CORE_STABLE=$(sed -n "s/^[[:space:]]*CORE_STABLE:[[:space:]]*.\([0-9.]*\).*/\1/p" "$CACHE/hidden.yml" | head -1)
RIG_CORE=$(sed -n "s/.*const VERSION = '\([^']*\)'.*/\1/p" "$CORE/lib/Drupal.php" | head -1)
if [ -z "$CORE_STABLE" ]; then
  say "  UNVERIFIED  could not read CORE_STABLE upstream - the core parity check did NOT run this time."
  say "              The rig is on $RIG_CORE. If that is not what CI installs, this run may be quieter than the gate."
elif [ "$CORE_STABLE" != "$RIG_CORE" ]; then
  die "the rig is on Drupal $RIG_CORE and CI installs $CORE_STABLE. stylelint, eslint and prettier read their configuration out of web/core, so this pre-flight would resolve a DIFFERENT configuration from the gate - in an unknown direction. Update the rig, or run against one on $CORE_STABLE."
else
  say "  parity    CORE_STABLE=$CORE_STABLE upstream, and the rig's core is $RIG_CORE - the same tree the job reads its lint configuration from"
fi

# ------------------------------------------------------- the job flags, read not written ---
# Each flag is the upstream default unless this repository's own .gitlab-ci.yml overrides it, in
# exactly the order CI applies them. Nothing below is a constant chosen here.
default_of() { awk -v k="$1" '
  $0 ~ "^[[:space:]]*"k":[[:space:]]*$" { f=1; next }
  f && $0 ~ /^[[:space:]]*value:/ {
    sub(/^[[:space:]]*value:[[:space:]]*/, "")
    sub(/^['"'"'"]/, ""); sub(/['"'"'"][[:space:]]*$/, ""); print; exit }
  f && $0 ~ /^[[:space:]]*[A-Za-z_]+:[[:space:]]*$/ { exit }' "$CACHE/vars.yml"; }

override_of() { sed -n "s/^[[:space:]]*$1:[[:space:]]*['\"]\{0,1\}\([^'\"]*\)['\"]\{0,1\}[[:space:]]*\$/\1/p" \
  "$REPO/.gitlab-ci.yml" | head -1; }

flag_of() { v=$(override_of "$1"); if [ -n "$v" ]; then printf '%s' "$v"; else default_of "$1"; fi; }

PHPCS_EXTRA=$(flag_of _PHPCS_EXTRA)
PHPSTAN_EXTRA=$(flag_of _PHPSTAN_EXTRA)
PHPSTAN_LEVEL=$(flag_of _PHPSTAN_LEVEL)
PHPSTAN_BASELINE=$(flag_of _PHPSTAN_BASELINE_FILENAME)
STYLELINT_EXTRA=$(flag_of _STYLELINT_EXTRA)
ESLINT_EXTRA=$(flag_of _ESLINT_EXTRA)
PARALLEL_LINT_EXTRA=$(flag_of _PARALLEL_LINT_EXTRA)
PHP_TYPES=$(sed -n "s/^[[:space:]]*DRUPAL_PHP_FILE_TYPES:[[:space:]]*'\([^']*\)'.*/\1/p" "$CACHE/hidden.yml" | head -1)
[ -n "$PHP_TYPES" ] || die 'could not read DRUPAL_PHP_FILE_TYPES upstream - the composer-lint file set would be invented'
[ -n "$PHPSTAN_BASELINE" ] || PHPSTAN_BASELINE='phpstan-baseline.neon'

say "  flags     _PHPCS_EXTRA=[$PHPCS_EXTRA] _ESLINT_EXTRA=[$ESLINT_EXTRA] _STYLELINT_EXTRA=[$STYLELINT_EXTRA] _PHPSTAN_EXTRA=[$PHPSTAN_EXTRA] _PHPSTAN_LEVEL=[$PHPSTAN_LEVEL]"
say "  rig       $RIG (container root $CONTAINER_ROOT)"

# ---------------------------------------------------------------------- the linted copy ---
# Outside the docroot, always. A second copy of this theme under web/themes wins Drupal's
# extension discovery race and gets served.
LW="$RIG/lint-work"
case "$LW" in "$RIG/web/"*) die 'the lint tree would land inside the docroot' ;; esac
rm -rf "$LW"; mkdir -p "$LW/base/$MACHINE" || die "cannot create $LW"
rsync -a --exclude '.git' --exclude 'node_modules' --exclude '.cspell-cache' \
  "$REPO/" "$LW/base/$MACHINE/" || die 'rsync of the working copy failed'
find "$LW/base/$MACHINE" -type f -exec sed -i 's/\r$//' {} + 2>/dev/null || true
BASE_FILES=$(find "$LW/base/$MACHINE" -type f | wc -l | tr -d ' ')
[ "${BASE_FILES:-0}" -gt 0 ] || die 'the linted copy came out empty'
say "  copy      $BASE_FILES files, carriage returns stripped, at $LW (outside $RIG/web)"

# One copy PER JOB. Two of these jobs write configuration files into the tree they lint -
# eslint drops a prettier ignore file, stylelint links a prettier configuration in - and in CI
# each job gets its own container. Sharing one tree here would let one job's setup change the
# next job's answer, silently.
for j in $JOBS; do
  mkdir -p "$LW/$j" && cp -a "$LW/base/$MACHINE" "$LW/$j/$MACHINE" || die "cannot stage the copy for $j"
done

TOOLS="$RIG/lint-work-tools"
PARALLEL_LINT="$TOOLS/vendor/bin/parallel-lint"

# ------------------------------------------------------------ tools, exercised not located ---
for j in $JOBS; do
  case $j in
    phpcs)   [ -x "$RIG/vendor/bin/phpcs" ]   || die "phpcs is not installed in the rig ($RIG/vendor/bin/phpcs). It is a core-dev dependency; install it rather than skipping the check." ;;
    phpstan) [ -x "$RIG/vendor/bin/phpstan" ] || die "phpstan is not installed in the rig ($RIG/vendor/bin/phpstan). It is a core-dev dependency; install it rather than skipping the check." ;;
    stylelint) [ -x "$CORE/node_modules/.bin/stylelint" ] || die "stylelint is not installed in the rig ($CORE/node_modules/.bin/stylelint). CI runs core's own copy; install core's node modules in the rig." ;;
    eslint)    [ -x "$CORE/node_modules/.bin/eslint" ]    || die "eslint is not installed in the rig ($CORE/node_modules/.bin/eslint). CI runs core's own copy; install core's node modules in the rig." ;;
    composer-lint) [ -x "$PARALLEL_LINT" ] || die "parallel-lint is not installed at $PARALLEL_LINT. CI adds php-parallel-lint/php-parallel-lint:^1.2 to the environment it builds. Create it once with:  mkdir -p $TOOLS && cd $RIG && ddev exec 'cd $CONTAINER_ROOT/lint-work-tools && composer require php-parallel-lint/php-parallel-lint:^1.2'" ;;
  esac
done

# The two configuration files CI downloads when the project supplies none. The DETECTION is
# reproduced too, not just the download: if this repository ever grows its own, both CI and this
# script switch to it on the same condition.
PHPCS_CFG='(none in the project - gitlab_templates assets/phpcs.xml.dist)'
if ls "$REPO"/phpcs.xml "$REPO"/phpcs.xml.dist "$REPO"/.phpcs.xml "$REPO"/.phpcs.xml.dist >/dev/null 2>&1; then
  PHPCS_CFG='the project has its own phpcs configuration - CI uses it, and so does this run'
else
  case " $JOBS " in *' phpcs '*) cp "$CACHE/phpcs.xml.dist" "$LW/phpcs/$MACHINE/phpcs.xml.dist" || die 'cannot place the phpcs configuration' ;; esac
fi
PHPSTAN_CFG='(none in the project - gitlab_templates assets/phpstan.neon)'
if ls "$REPO"/phpstan.neon "$REPO"/phpstan.neon.dist "$REPO"/phpstan.dist.neon >/dev/null 2>&1; then
  PHPSTAN_CFG='the project has its own phpstan configuration - CI uses it, and so does this run'
else
  case " $JOBS " in *' phpstan '*)
    sed "s#BASELINE_PLACEHOLDER#$PHPSTAN_BASELINE#g" "$CACHE/phpstan.neon" > "$LW/phpstan/$MACHINE/phpstan.neon" \
      || die 'cannot place the phpstan configuration'
    : > "$LW/phpstan/$MACHINE/$PHPSTAN_BASELINE" ;;
  esac
fi

# stylelint and eslint: CI links core's prettier configuration in when the project has none, and
# hangs core's eslint configuration in the directory ABOVE the project. Both are reproduced with
# the same condition CI tests.
PRETTIER_NOTE='(none in the project - core'"'"'s .prettierrc.json linked in, exactly as CI does)'
if ls "$REPO"/.prettierrc* "$REPO"/prettier.config.* >/dev/null 2>&1; then
  PRETTIER_NOTE='the project has its own prettier configuration - CI uses it, and so does this run'
else
  case " $JOBS " in *' stylelint '*) ln -sfn "$CONTAINER_ROOT/web/core/.prettierrc.json" "$LW/stylelint/$MACHINE/.prettierrc.json" ;; esac
  case " $JOBS " in *' eslint '*)    ln -sfn "$CONTAINER_ROOT/web/core/.prettierrc.json" "$LW/eslint/$MACHINE/.prettierrc.json" ;; esac
fi
STYLELINT_CFG_ARG="--config $CONTAINER_ROOT/web/core/.stylelintrc.json"
STYLELINT_NOTE='(none in the project - core'"'"'s .stylelintrc.json, exactly as CI does)'
if ls "$REPO"/.stylelintrc* "$REPO"/stylelint.config.* >/dev/null 2>&1; then
  STYLELINT_CFG_ARG=''; STYLELINT_NOTE='the project has its own stylelint configuration - CI uses it, and so does this run'
fi
case " $JOBS " in *' eslint '*)
  ln -sfn "$CONTAINER_ROOT/web/core/.eslintrc.passing.json" "$LW/eslint/.eslintrc.json"
  ln -sfn "$CONTAINER_ROOT/web/core/.eslintrc.jquery.json"  "$LW/eslint/.eslintrc.jquery.json"
  [ -e "$LW/eslint/$MACHINE/.prettierignore" ] || printf '*.yml\n' > "$LW/eslint/$MACHINE/.prettierignore" ;;
esac

# ------------------------------------------------------------------- run it in the container ---
# THE SETTINGS TRAVEL IN A FILE, NOT IN ARGUMENTS, and that is not a style choice. `ddev exec`
# re-joins its arguments into a single shell string, so an argument that is the EMPTY STRING -
# which four of these routinely are, because the upstream default for those flags is empty -
# simply disappears, and every argument after it shifts one place left. The symptom was an
# unbound-variable error; the danger is the case where it does not error and a flag silently
# lands in the wrong slot.
q() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
{
  printf 'CONTAINER_ROOT=%s\n' "$(q "$CONTAINER_ROOT")"
  printf 'MACHINE=%s\n'        "$(q "$MACHINE")"
  printf 'JOBS=%s\n'           "$(q "$JOBS")"
  printf 'PHPCS_EXTRA=%s\n'    "$(q "$PHPCS_EXTRA")"
  printf 'PHPSTAN_EXTRA=%s\n'  "$(q "$PHPSTAN_EXTRA")"
  printf 'PHPSTAN_LEVEL=%s\n'  "$(q "$PHPSTAN_LEVEL")"
  printf 'STYLELINT_EXTRA=%s\n' "$(q "$STYLELINT_EXTRA")"
  printf 'ESLINT_EXTRA=%s\n'   "$(q "$ESLINT_EXTRA")"
  printf 'PARALLEL_LINT_EXTRA=%s\n' "$(q "$PARALLEL_LINT_EXTRA")"
  printf 'PHP_TYPES=%s\n'      "$(q "$PHP_TYPES")"
  printf 'STYLELINT_CFG_ARG=%s\n' "$(q "$STYLELINT_CFG_ARG")"
} > "$LW/run-inside.env" || die 'cannot write the settings for the in-container runner'

cat > "$LW/run-inside.sh" <<'INNER'
set -u
SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
. "$SELF_DIR/run-inside.env"
LW="$CONTAINER_ROOT/lint-work"; CORE="$CONTAINER_ROOT/web/core"
res() { printf '##RESULT|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4"; }

for job in $JOBS; do
  P="$LW/$job/$MACHINE"
  case $job in

  phpcs)
    printf '\n--- phpcs -------------------------------------------------------------------------------\n'
    printf 'command: vendor/bin/phpcs <copy> -s --report-full --report-summary --report-source --basepath=<copy> %s --report-width=120\n' "$PHPCS_EXTRA"
    printf 'note:    the job also passes --report-junit and --colors. The first writes a CI artefact\n'
    printf '         and the second only paints; neither changes which files are read or what is found.\n'
    cd "$P" || exit 2
    OUT=$("$CONTAINER_ROOT"/vendor/bin/phpcs "$P" -s --report-full --report-summary --report-source \
            --basepath="$P" $PHPCS_EXTRA --report-width=120 2>&1); RC=$?
    printf '%s\n' "$OUT" | tail -60
    N=$(printf '%s\n' "$OUT" | grep -oE '[0-9]+ / [0-9]+ \(100%\)' | tail -1 | sed -E 's|.*/ ([0-9]+) .*|\1|')
    res phpcs "$RC" "${N:-}" 'files offered to phpcs (its own -p progress line)'
    ;;

  phpstan)
    printf '\n--- phpstan -----------------------------------------------------------------------------\n'
    [ -n "$PHPSTAN_LEVEL" ] && LEVEL="--level=$PHPSTAN_LEVEL" || LEVEL=''
    printf 'command: phpstan analyze . --autoload-file=%s/vendor/autoload.php %s %s --no-progress --debug\n' "$CONTAINER_ROOT" "$LEVEL" "$PHPSTAN_EXTRA"
    printf 'note:    --debug is added ONLY so the run states which files it opened; upstream passes it\n'
    printf '         through _PHPSTAN_EXTRA, so it is inside the sanctioned flag surface, not around it.\n'
    cd "$P" || exit 2
    OUT=$(php "$CONTAINER_ROOT"/vendor/bin/phpstan analyze . --autoload-file="$CONTAINER_ROOT/vendor/autoload.php" \
            $LEVEL $PHPSTAN_EXTRA --no-progress --debug 2>&1); RC=$?
    printf '%s\n' "$OUT" | grep -v "^$P/" | tail -40
    N=$(printf '%s\n' "$OUT" | grep -c "^$P/")
    res phpstan "$RC" "${N:-}" 'files analysed (listed by phpstan itself)'
    ;;

  stylelint)
    printf '\n--- stylelint ---------------------------------------------------------------------------\n'
    printf 'command: core/node_modules/.bin/stylelint --ignore-path ./.stylelintignore %s ./**/*.css --formatter verbose %s\n' "$STYLELINT_CFG_ARG" "$STYLELINT_EXTRA"
    cd "$P" || exit 2
    OUT=$("$CORE"/node_modules/.bin/stylelint --ignore-path ./.stylelintignore $STYLELINT_CFG_ARG \
            ./**/*.css --formatter verbose $STYLELINT_EXTRA 2>&1); RC=$?
    printf '%s\n' "$OUT" | tail -50
    N=$(printf '%s\n' "$OUT" | grep -oE '[0-9]+ sources checked' | tail -1 | grep -oE '[0-9]+')
    # ADVISORY, and it never touches the verdict. `./**/*.css` is a SHELL glob, and globstar is
    # OFF in the runner's shell, so those two stars behave like one and the pattern reaches
    # exactly one directory level. Any stylesheet nested deeper is invisible to this job - in CI
    # first, and therefore here. The set difference is computed from the files stylelint itself
    # reported opening, never from a guess about the glob, and the extra files are linted in a
    # SEPARATE run whose result is printed as information. Being stricter than the gate is how a
    # local run gets dismissed as noise, and dismissed noise is what let three findings through.
    printf '%s\n' "$OUT" | sed -n 's|^ *'"$P"'/||p' | LC_ALL=C sort > /tmp/sl-reached.txt
    find . -name '*.css' -not -path './node_modules/*' | sed 's|^\./||' | LC_ALL=C sort > /tmp/sl-all.txt
    comm -13 /tmp/sl-reached.txt /tmp/sl-all.txt > /tmp/sl-outside.txt
    ALL=$(wc -l < /tmp/sl-all.txt | tr -d ' ')
    OUTSIDE=$(wc -l < /tmp/sl-outside.txt | tr -d ' ')
    printf 'advisory: %s .css files in this tree · %s opened by the job · %s NOT reachable by its glob.\n' \
      "$ALL" "${N:-0}" "$OUTSIDE"
    if [ "$OUTSIDE" -gt 0 ]; then
      sed 's|^|          |' /tmp/sl-outside.txt
      OUT2=$(xargs -a /tmp/sl-outside.txt "$CORE"/node_modules/.bin/stylelint \
               --ignore-path ./.stylelintignore $STYLELINT_CFG_ARG --formatter verbose $STYLELINT_EXTRA 2>&1); RC2=$?
      printf '          those files, linted separately: exit=%s %s\n' "$RC2" \
        "$(printf '%s\n' "$OUT2" | grep -E 'problems? found|sources checked' | tr '\n' ' ')"
      printf '          INFORMATION ONLY - the verdict above is the gate'"'"'s, and the gate never opens them.\n'
    fi
    res stylelint "$RC" "${N:-}" 'sources checked (stylelint verbose formatter)'
    ;;

  eslint)
    printf '\n--- eslint ------------------------------------------------------------------------------\n'
    printf 'command: core/node_modules/.bin/eslint --no-error-on-unmatched-pattern --ignore-pattern=*.es6.js --resolve-plugins-relative-to=%s --ext=.js,.yml %s .\n' "$CORE" "$ESLINT_EXTRA"
    cd "$P" || exit 2
    OUT=$("$CORE"/node_modules/.bin/eslint --no-error-on-unmatched-pattern --ignore-pattern='*.es6.js' \
            --resolve-plugins-relative-to="$CORE" --ext=.js,.yml $ESLINT_EXTRA . 2>&1); RC=$?
    printf '%s\n' "$OUT" | tail -50
    # The denominator needs a SECOND run, and the reason is worth the extra three seconds:
    # --quiet, which is the upstream default and therefore part of the gate, drops from the JSON
    # every file that ended with no message. Counting the first run's output would report zero
    # files opened on a perfectly clean tree - I-028's degenerate case wearing the right answer.
    "$CORE"/node_modules/.bin/eslint --no-error-on-unmatched-pattern --ignore-pattern='*.es6.js' \
      --resolve-plugins-relative-to="$CORE" --ext=.js,.yml --format=json --output-file=/tmp/eslint-preflight.json . >/dev/null 2>&1
    N=$(python3 -c 'import json;print(len(json.load(open("/tmp/eslint-preflight.json"))))' 2>/dev/null)
    W=$(python3 -c 'import json;print(sum(f["warningCount"] for f in json.load(open("/tmp/eslint-preflight.json"))))' 2>/dev/null)
    printf 'note:    %s warning(s) in the tree. The gate hides warnings via %s and so does the verdict above.\n' "${W:-?}" "$ESLINT_EXTRA"
    res eslint "$RC" "${N:-}" 'files opened (js and yml, counted from eslint JSON)'
    ;;

  composer-lint)
    printf '\n--- composer-lint -----------------------------------------------------------------------\n'
    printf 'command: composer validate  +  parallel-lint -e %s --exclude web --exclude vendor --exclude node_modules --no-progress . %s\n' "$PHP_TYPES" "$PARALLEL_LINT_EXTRA"
    printf 'note:    CI validates TWO files - the composer.json it generates to build the environment,\n'
    printf '         and this project'"'"'s. Only the second exists outside CI, so only the second is checked.\n'
    cd "$P" || exit 2
    OUT=$(composer validate 2>&1); RC=$?
    printf '%s\n' "$OUT" | sed 's/^/  /'
    res composer-validate "$RC" 1 'composer.json validated (the project'"'"'s only)'
    OUT=$("$CONTAINER_ROOT"/lint-work-tools/vendor/bin/parallel-lint -e "$PHP_TYPES" --exclude web --exclude vendor \
            --exclude node_modules --no-progress $PARALLEL_LINT_EXTRA . 2>&1); RC=$?
    printf '%s\n' "$OUT" | sed 's/^/  /' | tail -30
    N=$(printf '%s\n' "$OUT" | grep -oE 'Checked [0-9]+ file' | tail -1 | grep -oE '[0-9]+')
    res parallel-lint "$RC" "${N:-}" 'files syntax-checked (parallel-lint'"'"'s own count)'
    ;;

  esac
done
INNER

say ''
say "  phpcs config     $PHPCS_CFG"
say "  phpstan config   $PHPSTAN_CFG"
say "  stylelint config $STYLELINT_NOTE"
say "  prettier config  $PRETTIER_NOTE"

ddev exec bash "$CONTAINER_ROOT/lint-work/run-inside.sh" </dev/null
RC=$?
[ "$RC" -eq 2 ] && die 'the in-container runner could not start'
exit 0
RIG_SCRIPT
  )
  RIG_RC=$?

  # Both machine-readable prefixes are filtered out of the passthrough: the result rows become the
  # table below, and the fatal line is re-raised through fatal() so it carries the same wording
  # every other unfaithful run gets. Printing them raw as well would say each thing twice.
  printf '%s\n' "$RIG_OUT" | grep -v '^##RESULT|' | grep -v '^##FATAL|'
  if printf '%s\n' "$RIG_OUT" | grep -q '^##FATAL|'; then
    fatal "$(printf '%s\n' "$RIG_OUT" | sed -n 's/^##FATAL|//p' | head -1)"
  fi
  [ "$RIG_RC" -ne 0 ] && fatal "the rig side exited $RIG_RC without a reason - treat this as an unfaithful run, not a pass"

  group 'rig-side jobs - result and denominator, one row each'
  RESULT_ROWS=$(printf '%s\n' "$RIG_OUT" | grep -c '^##RESULT|')
  [ "${RESULT_ROWS:-0}" -gt 0 ] || fatal 'the rig side produced no results at all - a run with nothing in it is not a clean run'
  printf '%s\n' "$RIG_OUT" | sed -n 's/^##RESULT|//p' | while IFS='|' read -r _j _rc _n _lbl; do
    printf '  %s%-24s%s %s\n' "$C_DIM" "$_j" "$C_OFF" "$_n $_lbl"
  done
  # The rows are tallied from a FILE, read with a redirect rather than through a pipe. A `while`
  # on the right of a pipe runs in a subshell, so every counter it increments dies with it - and
  # the summary then prints "0 failures" with complete confidence over checks that did fail.
  ROWS_FILE=$(mktemp -t preflight-rows.XXXXXX) || fatal 'mktemp failed'
  printf '%s\n' "$RIG_OUT" | sed -n 's/^##RESULT|//p' > "$ROWS_FILE"
  while IFS='|' read -r _j _rc _n _lbl; do
    [ -n "$_j" ] || continue
    check          "$_j (exit)"   "$_rc" '0'
    check_positive "$_j (opened)" "$_n"
  done < "$ROWS_FILE"
  rm -f "$ROWS_FILE"
fi

# ------------------------------------------------------------------------------- summary ---
printf '\n=========================================================================================================\n'
COVERED_N=$(printf '%s' "$WANTED" | wc -w | tr -d ' ')
UNCOVERED_N=$(printf '%s' "$UNCOVERED" | wc -w | tr -d ' ')
# The colour token is its own conversion specifier here too, for the reason given at the job-list
# check above: glued to the word after it, a spell checker reads the pair as one token.
SUMMARY=$(printf 'preflight: %s checks — %s failures over %s job(s)' "$CHECKS" "$FAILS" "$COVERED_N")
if [ "$FAILS" -eq 0 ]; then
  printf '%s%s%s · %s job(s) NOT COVERED: %s\n' "$C_OK" "$SUMMARY" "$C_OFF" "$UNCOVERED_N" "$UNCOVERED"
else
  printf '%s%s%s · %s job(s) NOT COVERED: %s\n' "$C_BAD" "$SUMMARY" "$C_OFF" "$UNCOVERED_N" "$UNCOVERED"
fi
if [ "$SUBSET" -eq 1 ]; then
  printf '%sSUBSET RUN - [%s] only. Do not read this as a pre-flight verdict.%s\n' "$C_WARN" "$WANTED" "$C_OFF"
fi
printf '=========================================================================================================\n'

[ "$FAILS" -eq 0 ] && exit 0
exit 1
