#!/usr/bin/env bash
#
# claims-match-readme - agora_theme - fails when a number README.md states about this theme
# disagrees with the thing the number is about: in this repository always, and - with --online -
# in the CI job trace that produced it.
#
# WHY IT EXISTS, and it is not a hypothetical. On 2026-09-21 this file was audited against disk
# and THREE of its figures were wrong at once:
#
#   * templates/ was said to hold 19 Twig files in one place and 18 in another, inside a total of
#     22 that did not equal its own two parts. The tree held 19 and 4.
#   * The fixture module was said to expose "Five routes". It exposed seven; the front page and
#     the filtered register had landed without the sentence being told.
#   * The PHP suite was stated twice - "156 tests, 797 assertions" in the file table and
#     "OK (203 tests, 959 assertions)" in the CI section. Both were written from a real trace.
#     The first was two pipelines old and nothing in this repository could see that.
#
# The sibling site template learned this the expensive way and wrote the lesson into its own
# CLAUDE.md: "a number written down twice is a number that goes stale in one place first". It then
# built tests/bin/claims-match-sources to keep that promise, because prose cannot keep it alone.
# This is that machine for THIS repository, whose prose is README.md.
#
# THE FIRST FIX IS NOT THIS SCRIPT. Every figure above existed in two places before it went wrong
# in one of them, so the change this guard arrived with DELETED the second copies: the template
# count, the block-placement count, the axe figures, the PHPUnit counts, the pipeline id and the
# nightwatch job id are each stated ONCE now, and the other mention is a cross-reference with no
# number in it. Refreshing both copies faithfully is the wrong fix - it is what was being done,
# carefully, on each of the days those three figures went stale.
#
# -------------------------------------------------------------------------------------------
# WHAT IS CHECKED OFFLINE - twenty-three comparisons, all in well under a second, no network:
#
#   gate_checks         README     vs  the GATE-CLAIM line in gate-a-theme.sh
#   gate_groups         README     vs  the highest "group 'GN'" line in that runner
#   gate_invariants     GATE-CLAIM vs  that runner's own "group 'G[1-9]'" lines
#   twig_templates      README     vs  git ls-files templates/*.twig
#   twig_split          README's overrides + own hooks  vs  its own stated total
#   twig_total          README     vs  git ls-files *.twig
#   stylesheets         README     vs  git ls-files css/*.css
#   block_placements    README     vs  git ls-files config/optional/*.yml
#   components          README     vs  git ls-files components/*/*.component.yml
#   tests_bin_files     README     vs  git ls-files tests/bin/*
#   manifest_records    README     vs  the data rows of shared-invariants.manifest
#   local_only          README     vs  the LOCAL_ONLY list in shared-invariants
#   css_lines           README     vs  wc -l css/components.css
#   css_families        README     vs  the distinct "^.agora-" selectors in that file
#   theme_lines         README     vs  wc -l agora_theme.theme
#   theme_functions     README     vs  the "^function " lines in that file
#   fixture_routes      README     vs  the route keys in the fixture module's .routing.yml
#   axe_page_floor      README     vs  the "totals.pages >=" assertion in axe.js
#   job_names           README's job table   vs  EXPECTED_agora_theme in watch-gate
#   jobs_total          README's "Ten jobs"  vs  the rows of its own job table
#   pipeline_id         README's prose       vs  the API URL printed beside it
#   license_bytes       README     vs  git cat-file -s on LICENSE.txt
#   license_blob        README     vs  git rev-parse HEAD:LICENSE.txt
#
# WHAT IS CHECKED WITH --online - the figures ONLINE_FIGURES names, read from the job traces of
# the ONE pipeline README names, and never during a gate run. How many that is, this header does
# not say: the list is the count, and every run prints it. The pipeline's ref and commit are
# compared against the prose; its whole job table - name, stage, status, allow_failure - is
# compared against the four columns README prints; then the nightwatch and phpunit traces are
# fetched and every figure quoted from them is looked for in the log itself - the per-check
# assertion counts included, which the nightwatch trace prints section by section, and the table
# scroller's, which it labels template by template.
#
# THOSE SEVEN WERE LISTED AS UNREADABLE UNTIL 2026-09-24, AND THE REASON WAS FALSE ON THE DAY IT
# WAS WRITTEN. The NOT CHECKED entry said the trace "prints only the TOTAL - verified by grepping
# the trace for a per-page figure and finding none". Nightwatch closes every passing test section
# with "PASSED. N assertions.", and the pipeline of the very commit that wrote that entry - 969928
# on d8079ad, nightwatch job 12332875 - printed 5, 14, 27, 8, 4, 16 and 13 for the seven checks
# README counts as 4, 13, 24, 7, 3, 15 and 12. The figures were in the trace all along; they were
# not README's figures.
#
# THE RULE THAT MAPS ONE TO THE OTHER. Nightwatch prints one tick line per assertion it counts,
# and it counts every waitForElementPresent as an assertion, printed as "Element <...> was present
# after N milliseconds." README counts the assertions axe.js makes. So, per section:
#
#     README's figure  =  the section's PASSED count  -  its "Element <...> was present" lines
#
# It is NOT "plus one": the exposed filter waits three times - once for each of its two requests
# and once more for the register its "Clear all filters" link leads back to - and 27 - 3 = 24.
#
# THE RULE IS CHECKED ON EVERY SECTION OF THE RUN, NOT ASSUMED FROM ONE, because a subtraction
# whose premise fails is not a measurement. Every section must print exactly as many tick lines as
# its PASSED count, and the section counts plus the ticks printed outside any section - the
# suite's before() hook - must add up to the total the gate prints. If either fails, or the run
# printed a failure line, every figure read from a section is NOT READ. Measured 2026-09-24 on
# jobs 12402165, 12402361 and 12402667: 21 of 21 sections held, and 809 + 3 = 812 on all three.
#
# THE TABLE SCROLLER IS THE SAME RULE, READ PER TEMPLATE. Its one section runs once for each
# table template, and README counts it that way - a section total, and on each template "its
# eight assertions and the wait for the scroller before them". A section total cannot say "each":
# seven and nine average to eight and make the sentence false. So the section is split by the
# label every one of its assertions carries - "Table scroller (<template>, <width>px):" - and each
# wait is credited to the template whose assertions follow it. On the three jobs above: 18
# counted - 2 waits = 16, which is 8 labelled assertions and 1 wait on each of 2 templates.
#
# THE TRACES NEED NO CREDENTIAL, and the reason this is worth a sentence is that the opposite was
# believed for weeks in the sibling repository. /api/v4/projects/<id>/jobs/<id>/trace answers 401
# to an anonymous request; /project/agora_theme/-/jobs/<id>/raw answers 200 to anybody. The -L is
# MANDATORY: without it the 302 is 622 bytes and looks exactly like a failure.
#
# --online IS OPT-IN AND THE GATE NEVER PASSES IT. This script is wired into gate-a-theme.sh as
# G11 precisely because it is offline: every invariant in this repository runs with no network,
# and that property is worth more than any single check. A gate that needs a network round trip
# is a gate somebody makes permissive within the week - arriving, as these things do, through the
# door marked "more rigour".
#
# THE ABSENCE OF THE ONLINE HALF IS A THIRD STATE AND CAN NEVER READ AS A PASS. No network, no
# curl, no python3, a non-200, a redirect not followed, a pipeline that has not finished, a job id
# that no longer exists - each prints "NOT READ - <reason>" naming the figure it could not reach,
# and a run asked for --online that could not read something EXITS NON-ZERO. An absence of
# information reading as good news is the defect this whole family of checks exists to refuse, and
# a guard that answers "no findings" when it looked at nothing is the purest form of it (I-028).
#
# -------------------------------------------------------------------------------------------
# WHAT IS NOT CHECKED is printed by name on every run with a reason, and the length of that list
# is asserted non-empty. A guard that silently covers half its subject reads exactly like one that
# covers all of it.
#
# THE REASONS IN THAT LIST MUST BE TRUE, and that is not a platitude either. The sibling's
# equivalent list carried TWO FALSE REASONS for weeks - it said a job trace could not be read
# anonymously, which was inferred from an API endpoint's 401 and never tested against the web
# route that answers 200 - and while they stood, figures it could have been reading went stale. A
# list of what a guard cannot reach decays into a list of what nobody has tried lately unless it
# is re-read rather than inherited. Some entries below say "readable and deliberately not bound",
# which is a true reason and a weaker one; they are written that way rather than dressed up as
# impossibilities.
#
# -------------------------------------------------------------------------------------------
# AMBIGUITY IS A FAILURE, NOT A TIE-BREAK. Every claim below must match its pattern EXACTLY ONCE
# in README.md. A second copy is refused here rather than silently preferred over the first,
# because two copies of a number is the precondition for the drift this file exists to catch. A
# pattern that matches NOTHING is likewise fatal: a regex matching nothing prints "0 findings"
# exactly like a clean tree.
#
# WHAT THIS BUYS IS NARROWER THAN "THE README IS TRUE", and the boundary is worth stating. Each
# comparison binds ONE canonical sentence. A figure restated in different words somewhere else in
# the file is not matched and not compared - it is simply unguarded, which is why the change this
# script arrived with deleted such restatements instead of trying to pattern-match them all.
#
# Contract:
#   claims extracted:  <N>   - numbers and lists read out of README.md; must be > 0
#   comparisons:       <N>   - offline; must be > 0
#   not checked:       <N>   - named exclusions; must be > 0
#   findings:          <N>   - disagreements; the exit code is 0 only when this is 0
#
# House rules for tests/bin/ (T-321(c)): "wc -l" never "grep -c" for a compared value; numeric
# guards defaulted ONLY where zero means FAIL; grep rc >= 2 is an error, never "no match".

set -u
# No `set -e`: a finding must be recorded and counted, not abort the run.

SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
cd "$REPO_ROOT" || { echo "FATAL: cannot cd to repo root $REPO_ROOT"; exit 2; }

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

ONLINE=0
for _arg in "$@"; do
  case "$_arg" in
    --online) ONLINE=1 ;;
    -h|--help)
      printf 'usage: %s [--online]\n' "$(basename -- "$0")"
      printf '  (no flag)  twenty-three offline comparisons against this repository. No network.\n'
      printf '  --online   also re-reads the figures README quotes from the CI job traces it names.\n'
      exit 0 ;;
    *) printf 'FATAL: unknown argument "%s". Try --help.\n' "$_arg" >&2; exit 2 ;;
  esac
done

README='README.md'
RUNNER='tests/bin/gate-a-theme.sh'
PROJECT='agora_theme'

CLAIMS=0
COMPARISONS=0
FINDINGS=0
UNREAD=0

fatal() { # <reason> - prints NO summary line: a run with no result is not a clean result (I-029)
  printf '\n%sFATAL:%s %s\n' "$C_BAD" "$C_OFF" "$1"
  printf 'This run produced NO verdict, which is not the same as a clean one.\n'
  exit 2
}

grep_fatal() { # <rc> <where>
  fatal "grep exited $1 while reading $2. A grep exit code of 2 or more is an error, never 'no match', and treating it as one would turn a broken read into a clean run."
}

# ---------------------------------------------------------------------- number words --
# README reads better with "Seven routes" than with "7 routes", so the extractor accepts either.
# The map stops at thirty ON PURPOSE: an unmapped word yields an empty string, which compare()
# reports as an EMPTY SIDE finding rather than as agreement, so a count that outgrows this table
# fails loudly and gets rewritten as a numeral.
to_num() { # <word-or-numeral> -> integer on stdout, nothing on failure
  local w
  w=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d ',')
  case "$w" in
    ''|*[!0-9]*) : ;;
    *) printf '%s' "$w"; return 0 ;;
  esac
  case "$w" in
    zero) printf '0' ;;      one) printf '1' ;;        two) printf '2' ;;
    three) printf '3' ;;     four) printf '4' ;;       five) printf '5' ;;
    six) printf '6' ;;       seven) printf '7' ;;      eight) printf '8' ;;
    nine) printf '9' ;;      ten) printf '10' ;;       eleven) printf '11' ;;
    twelve) printf '12' ;;   thirteen) printf '13' ;;  fourteen) printf '14' ;;
    fifteen) printf '15' ;;  sixteen) printf '16' ;;   seventeen) printf '17' ;;
    eighteen) printf '18' ;; nineteen) printf '19' ;;  twenty) printf '20' ;;
    twenty-one) printf '21' ;;   twenty-two) printf '22' ;;
    twenty-three) printf '23' ;; twenty-four) printf '24' ;;
    twenty-five) printf '25' ;;  twenty-six) printf '26' ;;
    twenty-seven) printf '27' ;; twenty-eight) printf '28' ;;
    twenty-nine) printf '29' ;;  thirty) printf '30' ;;
    *) : ;;
  esac
}

# ------------------------------------------------------------------------- the README --
[ -r "$README" ] || fatal "$README is not readable from $REPO_ROOT."
# CR is stripped once, here. This checkout holds README.md as CRLF (core.autocrlf on Windows) and
# the CI runner holds it as LF; a pattern anchored on a line end would therefore match in one
# place and not the other, which is a difference between environments and not between claims.
README_TEXT=$(tr -d '\r' < "$README")
[ -n "$README_TEXT" ] || fatal "$README read as empty."
# The same text with its line breaks joined, the way it renders. ONE claim needs it: a sentence
# README wraps over four lines, two of its labels broken mid-phrase, which a grep that reads a
# line at a time can never see whole. Every other claim is read from README_TEXT.
README_FLOWED=$(printf '%s' "$README_TEXT" | tr '\n' ' ' | tr -s ' ')

CLAIM=""
claim_once() { # <label> <ERE> [<text>] - sets CLAIM; FATAL on 0 matches or on 2+
  local label="$1" ere="$2" text="${3-$README_TEXT}" out rc n
  out=$(printf '%s\n' "$text" | grep -oE "$ere")
  rc=$?
  [ "$rc" -ge 2 ] && grep_fatal "$rc" "$README while extracting '$label'"
  if [ "$rc" -ne 0 ]; then
    fatal "no sentence in $README matches the pattern for '$label': /$ere/
  A pattern that matches nothing reports zero disagreements exactly like a clean file does, so an
  unmatched claim is a failure and never a pass. Either the sentence was reworded - in which case
  this pattern moves with it, in the same commit - or the claim was deleted, and so should this
  comparison be."
  fi
  n=$(printf '%s\n' "$out" | wc -l | tr -d '[:space:]')
  if [ "${n:-0}" -ne 1 ]; then
    printf '\n%sAMBIGUOUS:%s %s matches %s times in %s:\n' "$C_BAD" "$C_OFF" "$label" "$n" "$README"
    printf '%s\n' "$out" | sed 's/^/    /'
    fatal "a figure stated twice is the precondition for the drift this file exists to catch, so a second copy is refused here rather than silently preferred over the first. Delete one copy, or make it a cross-reference with no number in it."
  fi
  CLAIM="$out"
  CLAIMS=$((CLAIMS + 1))
}

# The substitution delimiter is # and not /, because one of the patterns below reads a
# capture out of a URL path, and a / inside an s/// expression is not an error message
# worth debugging twice.
# The substitution delimiter is # and not /, because one of the patterns below reads a
# capture out of a URL path, and a / inside an s/// expression is not an error message
# worth debugging twice.
field() { printf '%s' "$CLAIM" | sed -nE "s#$2#\\$1#p"; }

compare() { # <label> <claimed> <measured> <source>
  local label="$1" claimed="$2" measured="$3" src="$4"
  COMPARISONS=$((COMPARISONS + 1))
  if [ -z "$claimed" ] || [ -z "$measured" ]; then
    FINDINGS=$((FINDINGS + 1))
    printf '  %s%-18s README [%s]  ??  %s [%s]  EMPTY SIDE%s\n' \
      "$C_BAD" "$label" "$claimed" "$src" "$measured" "$C_OFF"
    printf '      An empty side is never agreement: one of the two was not read at all.\n'
    return
  fi
  if [ "$claimed" = "$measured" ]; then
    printf '  %s%-18s%s README [%s]  =  %s [%s]  %sOK%s\n' \
      "$C_DIM" "$label" "$C_OFF" "$claimed" "$src" "$measured" "$C_OK" "$C_OFF"
  else
    FINDINGS=$((FINDINGS + 1))
    printf '  %s%-18s README [%s]  !=  %s [%s]  MISMATCH%s\n' \
      "$C_BAD" "$label" "$claimed" "$src" "$measured" "$C_OFF"
  fi
}

count_tracked() { git ls-files -- "$@" 2>/dev/null | wc -l | tr -d '[:space:]'; }

command -v git >/dev/null 2>&1 || fatal "git is not on PATH. Most of the measured side of this guard asks 'what does this repository actually contain', and git ls-files is how that is asked: the TRACKED set, not the working tree, because the tracked set is what ships."
git rev-parse --git-dir >/dev/null 2>&1 || fatal "$REPO_ROOT is not a git working tree."
[ -r "$RUNNER" ] || fatal "$RUNNER is not readable; the gate claims cannot be compared against anything."

printf '=========================================================================================================\n'
printf 'claims-match-readme - agora_theme - README.md figures against the things that produce them\n'
printf 'repo: %s\n' "$REPO_ROOT"
printf 'date: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
if [ "$ONLINE" -eq 1 ]; then
  printf 'mode: offline + --online (CI job traces)\n'
else
  printf 'mode: offline only (no network; --online is opt-in and the gate never passes it)\n'
fi
printf '=========================================================================================================\n'

# =========================================================================== OFFLINE ==
printf '\nOFFLINE - README.md against this repository\n'
printf -- '---------------------------------------------------------------------------------------------------------\n'

# -- the gate runner's own totals ------------------------------------------------------
# README states these in the "what is planned" table; the runner states them in its
# GATE-CLAIM header line and derives them from its own group list.
claim_once 'gate totals' '\*\*G0-G[0-9]+ and [0-9]+ checks\*\*'
R_GATE_HIGH=$(field 1 '.*G0-G([0-9]+) and [0-9]+ checks.*')
R_GATE_CHECKS=$(field 1 '.*G0-G[0-9]+ and ([0-9]+) checks.*')

CLAIM_LINE=$(grep -E "^#[[:space:]]*GATE-CLAIM:" "$RUNNER")
_rc=$?
[ "$_rc" -ge 2 ] && grep_fatal "$_rc" "$RUNNER"
[ "$_rc" -eq 0 ] || fatal "$RUNNER carries no GATE-CLAIM line, so the figure README states about it has nothing to be compared against."
_n=$(printf '%s\n' "$CLAIM_LINE" | wc -l | tr -d '[:space:]')
[ "${_n:-0}" -eq 1 ] || fatal "$RUNNER carries $_n GATE-CLAIM lines, expected exactly 1."
G_CHECKS=$(printf '%s' "$CLAIM_LINE" | sed -nE 's/.*checks=([0-9]+).*/\1/p')
G_INV=$(printf '%s' "$CLAIM_LINE" | sed -nE 's/.*invariants=([0-9]+).*/\1/p')

GROUPS_HIGH=$(grep -oE "^group 'G[0-9]+" "$RUNNER" | sed 's/.*G//' | sort -n | tail -1)
_rc=$?
[ "$_rc" -ge 2 ] && grep_fatal "$_rc" "$RUNNER"
# No default of 0 here: zero groups must surface as an EMPTY SIDE finding, not as a number.
GROUPS_INV=$(grep -E "^group 'G[1-9]" "$RUNNER" | wc -l | tr -d '[:space:]')

compare 'gate_checks'     "$R_GATE_CHECKS" "$G_CHECKS"     "$RUNNER GATE-CLAIM"
compare 'gate_groups'     "$R_GATE_HIGH"   "$GROUPS_HIGH"  "$RUNNER highest group"
compare 'gate_invariants' "$G_INV"         "$GROUPS_INV"   "$RUNNER group G1..Gn"

# -- what the theme ships --------------------------------------------------------------
claim_once 'templates row' '\*\*[0-9]+ Twig files: [0-9]+ overrides of Drupal templates and [a-z]+ for'
R_TWIG=$(field 1 '.*\*\*([0-9]+) Twig files.*')
R_TWIG_OVR=$(field 1 '.*Twig files: ([0-9]+) overrides.*')
R_TWIG_OWN=$(to_num "$(field 1 '.*Drupal templates and ([a-z]+) for.*')")
compare 'twig_templates' "$R_TWIG" "$(count_tracked 'templates/*.twig')" 'git ls-files'
# The split is checked against the total it is a split OF. It costs one line and it caught the
# shape of error that started this file: a breakdown that does not add up to its own header.
if [ -n "$R_TWIG_OVR" ] && [ -n "$R_TWIG_OWN" ]; then
  compare 'twig_split' "$R_TWIG" "$((R_TWIG_OVR + R_TWIG_OWN))" 'README overrides + own hooks'
else
  compare 'twig_split' "$R_TWIG" '' 'README overrides + own hooks'
fi

claim_once 'twig total' 'holds \*\*[0-9]+\*\* Twig files'
compare 'twig_total' "$(field 1 '.*\*\*([0-9]+)\*\*.*')" "$(count_tracked '*.twig')" 'git ls-files'

claim_once 'stylesheets' '\*\*[a-z]+ stylesheets\*\* built from those tokens'
compare 'stylesheets' "$(to_num "$(field 1 '.*\*\*([a-z]+) stylesheets\*\*.*')")" \
  "$(count_tracked 'css/*.css')" 'git ls-files'

claim_once 'block placements' '\*\*[A-Za-z]+ default block placements\*\*'
compare 'block_placements' "$(to_num "$(field 1 '.*\*\*([A-Za-z]+) default.*')")" \
  "$(count_tracked 'config/optional/*.yml')" 'git ls-files'

claim_once 'components' '\*\*[A-Za-z]+ single-directory components'
compare 'components' "$(to_num "$(field 1 '.*\*\*([A-Za-z]+) single-directory.*')")" \
  "$(count_tracked 'components/*/*.component.yml')" 'git ls-files'

claim_once 'tests/bin files' '\*\*[A-Za-z]+ files, and the reason this table exists\.\*\*'
compare 'tests_bin_files' "$(to_num "$(field 1 '.*\*\*([A-Za-z]+) files,.*')")" \
  "$(count_tracked 'tests/bin/*')" 'git ls-files'

claim_once 'manifest split' '[0-9]+ manifest records, [0-9]+ files declared local-only'
R_MANIFEST=$(field 1 '([0-9]+) manifest records.*')
R_LOCAL=$(field 1 '.*records, ([0-9]+) files declared local-only.*')
MANIFEST_ROWS=$(grep -vE '^[[:space:]]*#' tests/bin/shared-invariants.manifest 2>/dev/null \
  | grep -vE '^[[:space:]]*$' | wc -l | tr -d '[:space:]')
LOCAL_ROWS=$(sed -n "/^LOCAL_ONLY='/,/'\$/p" tests/bin/shared-invariants 2>/dev/null \
  | sed "s/^LOCAL_ONLY='//; s/'\$//" | wc -l | tr -d '[:space:]')
compare 'manifest_records' "$R_MANIFEST" "$MANIFEST_ROWS" 'manifest data rows'
compare 'local_only'       "$R_LOCAL"    "$LOCAL_ROWS"    'shared-invariants LOCAL_ONLY'

claim_once 'components.css' '\*\*[0-9,]+ lines across [0-9]+ class families\*\*'
R_CSS_LINES=$(field 1 '.*\*\*([0-9,]+) lines across.*' | tr -d ',')
R_CSS_FAM=$(field 1 '.*across ([0-9]+) class families.*')
compare 'css_lines' "$R_CSS_LINES" "$(wc -l < css/components.css | tr -d '[:space:]')" 'wc -l'
CSS_FAM=$(grep -oE '^\.agora-[a-z-]+' css/components.css | sort -u | wc -l | tr -d '[:space:]')
compare 'css_families' "$R_CSS_FAM" "$CSS_FAM" 'distinct ^.agora- selectors'

claim_once 'agora_theme.theme' '\*\*The theme.s PHP [^0-9]*[0-9,]+ lines, [0-9]+ functions\*\*'
R_PHP_LINES=$(field 1 '.*. ([0-9,]+) lines, [0-9]+ functions.*' | tr -d ',')
R_PHP_FN=$(field 1 '.*lines, ([0-9]+) functions.*')
compare 'theme_lines' "$R_PHP_LINES" "$(wc -l < agora_theme.theme | tr -d '[:space:]')" 'wc -l'
# `grep -E ... | wc -l` and never `grep -c`: house rule T-321(a), and this value IS compared.
PHP_FN=$(grep -E '^function ' agora_theme.theme | wc -l | tr -d '[:space:]')
compare 'theme_functions' "$R_PHP_FN" "$PHP_FN" 'lines matching ^function '

claim_once 'fixture routes' '\*\*[A-Za-z]+ routes\*\*: a composed front page'
ROUTING='tests/modules/agora_theme_test/agora_theme_test.routing.yml'
[ -r "$ROUTING" ] || fatal "$ROUTING is not readable; the route count has nothing to be compared against."
ROUTES=$(grep -E '^[a-z][a-z0-9_]*\.[a-z0-9_]+:' "$ROUTING" | wc -l | tr -d '[:space:]')
compare 'fixture_routes' "$(to_num "$(field 1 '.*\*\*([A-Za-z]+) routes\*\*.*')")" "$ROUTES" "$ROUTING"

# -- the floor the gate itself enforces ------------------------------------------------
# This is the one figure README states that is NOT an observation: it is what axe.js refuses to
# pass below. A stale copy of it here would misdescribe the gate's own strictness.
claim_once 'axe page floor' 'asserts .totals\.pages >= [0-9]+.'
AXE_JS='tests/src/Nightwatch/Tests/axe.js'
[ -r "$AXE_JS" ] || fatal "$AXE_JS is not readable; the asserted page floor has nothing to be compared against."
AXE_FLOOR_ALL=$(grep -oE 'totals\.pages >= [0-9]+' "$AXE_JS")
_rc=$?
[ "$_rc" -ge 2 ] && grep_fatal "$_rc" "$AXE_JS"
_n=$(printf '%s\n' "$AXE_FLOOR_ALL" | wc -l | tr -d '[:space:]')
if [ "$_rc" -ne 0 ] || [ "${_n:-0}" -ne 1 ]; then
  fatal "$AXE_JS contains $_n 'totals.pages >=' assertions, expected exactly 1. Two floors in one file is an ambiguous measurement, and an ambiguous measurement is not a measurement."
fi
compare 'axe_page_floor' "$(field 1 '.*>= ([0-9]+).*')" \
  "$(printf '%s' "$AXE_FLOOR_ALL" | sed -nE 's/.*>= ([0-9]+).*/\1/p')" "$AXE_JS"

# -- the job table ---------------------------------------------------------------------
# README prints the ten jobs of the observed pipeline; watch-gate carries the list it applies
# D-023(5) against. These are two hand-maintained copies of one list, which is exactly the
# condition this file exists for.
README_JOBS=$(printf '%s\n' "$README_TEXT" \
  | sed -nE 's/^\| `([a-z-]+)` \| [a-z*]+ \| [a-z]+ \| yes \|$/\1/p; s/^\| `([a-z-]+)` \| \*\*[a-z]+\*\* \| [a-z]+ \| yes \|$/\1/p' \
  | LC_ALL=C sort)
WG_JOBS=$(sed -n "/^EXPECTED_agora_theme='/,/'\$/p" tests/bin/watch-gate 2>/dev/null \
  | sed "s/^EXPECTED_agora_theme='//; s/'\$//" | LC_ALL=C sort)
R_JOB_N=$(printf '%s\n' "$README_JOBS" | grep -v "^$" | wc -l | tr -d "[:space:]")
W_JOB_N=$(printf '%s\n' "$WG_JOBS" | grep -v "^$" | wc -l | tr -d "[:space:]")
if [ "${R_JOB_N:-0}" -eq 0 ] || [ "${W_JOB_N:-0}" -eq 0 ]; then
  fatal "one of the two job lists came back EMPTY (README $R_JOB_N, watch-gate $W_JOB_N). Two empty lists compare equal, which is the degenerate agreement I-028 names, so this is refused before the comparison rather than after it."
fi
COMPARISONS=$((COMPARISONS + 1))
if [ "$README_JOBS" = "$WG_JOBS" ]; then
  printf '  %s%-18s%s README [%s jobs]  =  watch-gate [%s jobs]  %sOK%s\n' \
    "$C_DIM" 'job_names' "$C_OFF" "$R_JOB_N" "$W_JOB_N" "$C_OK" "$C_OFF"
else
  FINDINGS=$((FINDINGS + 1))
  printf '  %s%-18s README [%s jobs]  !=  watch-gate [%s jobs]  MISMATCH%s\n' \
    "$C_BAD" 'job_names' "$R_JOB_N" "$W_JOB_N" "$C_OFF"
  printf '      only in README:     %s\n' "$(comm -23 <(printf '%s\n' "$README_JOBS") <(printf '%s\n' "$WG_JOBS") | tr '\n' ' ')"
  printf '      only in watch-gate: %s\n' "$(comm -13 <(printf '%s\n' "$README_JOBS") <(printf '%s\n' "$WG_JOBS") | tr '\n' ' ')"
fi

claim_once 'jobs total' '\*\*[A-Za-z]+ jobs [^A-Za-z]*all blocking'
compare 'jobs_total' "$(to_num "$(field 1 '.*\*\*([A-Za-z]+) jobs.*')")" "$R_JOB_N" "README job table rows"

# -- the pipeline named twice in one paragraph, bound to itself ------------------------
# The id appears in the API URL a reader is invited to re-run AND in the sentence below it. That
# is a duplicate that cannot be deleted - the URL has to be complete to be re-runnable - so it is
# bound instead. This is the third option beside "delete it" and "refresh both": make the second
# copy something a machine compares.
claim_once 'pipeline prose' 'Pipeline \*\*[0-9]+\*\*, branch `[^`]+`, commit `[0-9a-f]+`'
R_PIPE=$(field 1 '.*Pipeline \*\*([0-9]+)\*\*.*')
R_REF=$(field 1 '.*branch `([^`]+)`.*')
R_SHA=$(field 1 '.*commit `([0-9a-f]+)`.*')
claim_once 'pipeline url' 'pipelines/[0-9]+/jobs'
compare 'pipeline_id' "$R_PIPE" "$(field 1 '.*pipelines/([0-9]+)/jobs.*')" 'the API URL beside it'

# -- the licence blob -------------------------------------------------------------------
claim_once 'licence blob' '[0-9,]+-byte blob, `[0-9a-f]+`'
R_LIC_SIZE=$(field 1 '([0-9,]+)-byte blob.*' | tr -d ',')
R_LIC_SHA=$(field 1 '.*blob, `([0-9a-f]+)`.*')
LIC_OBJ=$(git rev-parse HEAD:LICENSE.txt 2>/dev/null)
if [ -n "$LIC_OBJ" ]; then
  LIC_SIZE=$(git cat-file -s "$LIC_OBJ" 2>/dev/null | tr -d '[:space:]')
  LIC_SHA=$(printf '%s' "$LIC_OBJ" | cut -c1-${#R_LIC_SHA})
else
  LIC_SIZE=''; LIC_SHA=''
fi
compare 'license_bytes' "$R_LIC_SIZE" "$LIC_SIZE" 'git cat-file -s HEAD:LICENSE.txt'
compare 'license_blob'  "$R_LIC_SHA"  "$LIC_SHA"  'git rev-parse HEAD:LICENSE.txt'

# ============================================================================ ONLINE ==
# Figures quoted FROM a CI job trace, re-read from that trace. Never during a gate run.
# The names here are the comparison LABELS printed when the online half runs, so a figure is
# named identically whether it was read or not. A NOT READ list shorter than the set of things
# it stands in for is the same defect one level up.
#
# The seven DOM checks README gives an assertion count for, in README's order, one row each:
#   <comparison label>|<README's words for the check>|<the section title the nightwatch trace prints>
# The middle column BUILDS the pattern that finds README's sentence and the last is how the trace
# is read, so neither can drift from the other without failing to match. A test renamed in axe.js
# is a NOT READ naming the title looked for - never a figure silently skipped.
DOM_BINDINGS='dom_table|the `#type: table` page|Table semantics - caption and scope
dom_views_table|the Views table|Views table - the theme override is what rendered it
dom_exposed|the exposed filter|Exposed filter - the action row, and what an empty result says
dom_node_field|the node and field render path|Node and field templates - the entity render path
dom_pager|the pager heading level|Pager heading level - the theme decides it
dom_layout|the layout components|Layout components - the kit composes and adds no landmarks
dom_signin|the sign-in page|Sign-in page - the panel, the way out, and the form left alone'
DOM_FIGURES=$(printf '%s\n' "$DOM_BINDINGS" | cut -d'|' -f1 | tr '\n' ' ')
# The table scroller has its own binding because README counts it PER TABLE TEMPLATE, which no
# section total can say. Its three figures, and how they are read, are beside the parse below.
SCROLLER_TITLE='Table scroller - keyboard focus is followed into a partly shown column'
SCROLLER_FIGURES='scroller_count scroller_each scroller_asserts'
ONLINE_FIGURES="pipeline_ref pipeline_commit job_table axe_pages axe_rules axe_rules_max axe_violations axe_heading axe_heading_of axe_assertions $DOM_FIGURES $SCROLLER_FIGURES phpunit_tests phpunit_assertions csv_ceilings"

printf '\nONLINE - figures quoted from a CI job trace, re-read from that trace\n'
printf -- '---------------------------------------------------------------------------------------------------------\n'

not_read() { # <reason> - every online figure is unreachable for this one reason
  local f
  for f in $ONLINE_FIGURES; do
    UNREAD=$((UNREAD + 1))
    printf '  %sNOT READ%s  %-18s %s\n' "$C_BAD" "$C_OFF" "$f" "$1"
  done
  printf '\n  That is NOT "the figures are fine"; it is "nothing here could see them". An absence of\n'
  printf '  information is not good news, and it is never counted as a comparison.\n'
}

# The claims are extracted whether or not --online runs, so the OFFLINE pass still refuses a
# figure stated twice, and so a reader of the offline output can see what --online would fetch.
claim_once 'axe pages'      '^\| Pages scanned \| \*\*[0-9]+\*\* \|$'
A_PAGES=$(field 1 '.*\*\*([0-9]+)\*\*.*')
claim_once 'axe rules'      '^\| axe rules run \| \*\*[0-9]+ on each page\*\* \|$'
A_RULES=$(field 1 '.*\*\*([0-9]+) on each page\*\*.*')
claim_once 'axe violations' '^\| Violations \| \*\*[0-9]+\*\* \|$'
A_VIOL=$(field 1 '.*\*\*([0-9]+)\*\*.*')
claim_once 'axe heading'    '\*\*[0-9]+ of [0-9]+ pages\*\*'
A_HEAD=$(field 1 '.*\*\*([0-9]+) of [0-9]+ pages\*\*.*')
A_HEAD_OF=$(field 1 '.*\*\*[0-9]+ of ([0-9]+) pages\*\*.*')
claim_once 'axe assertions' '^\| Assertions \| \*\*[0-9]+\*\* \|$'
A_ASSERT=$(field 1 '.*\*\*([0-9]+)\*\*.*')
claim_once 'nightwatch job' 'job \*\*`[0-9]+`\*\* of the pipeline recorded under'
A_JOB=$(field 1 '.*`([0-9]+)`.*')
claim_once 'phpunit figures' '\*\*`OK \([0-9]+ tests, [0-9]+ assertions\)`\*\* on the same pipeline, job `[0-9]+`'
P_TESTS=$(field 1 '.*OK \(([0-9]+) tests.*')
P_ASSERT=$(field 1 '.*tests, ([0-9]+) assertions.*')
P_JOB=$(field 1 '.*job `([0-9]+)`.*')
claim_once 'csv ceilings' 'agora_theme CSV distribution: [0-9]+ bytes, [0-9]+ rows, [0-9]+ columns'
C_CEIL="$CLAIM"
# ONE sentence carries all seven per-check counts, so it is ONE claim, read from README_FLOWED.
# The pattern is built from DOM_BINDINGS' middle column in its order, which is what makes the
# k-th number in the match belong to the k-th row.
DOM_ERE='are:'
while IFS='|' read -r _lbl _said _title; do
  DOM_ERE="$DOM_ERE $_said \\*\\*[0-9]+\\*\\*,"
done < <(printf '%s\n' "$DOM_BINDINGS")
claim_once 'dom check counts' "${DOM_ERE%,}" "$README_FLOWED"
DOM_CLAIMED=$(printf '%s' "$CLAIM" | grep -oE '\*\*[0-9]+\*\*' | tr -d '*')
_n=$(printf '%s\n' "$DOM_CLAIMED" | grep -v '^$' | wc -l | tr -d '[:space:]')
_m=$(printf '%s\n' "$DOM_BINDINGS" | grep -v '^$' | wc -l | tr -d '[:space:]')
[ "${_n:-0}" -eq "${_m:-0}" ] && [ "${_m:-0}" -gt 0 ] \
  || fatal "the per-check sentence yielded $_n figures for $_m bindings. A figure paired with the wrong check is worse than one not read at all."
# The table scroller's share of the rise README accounts for, stated ONCE: the section total, and
# on each table template its assertions plus the wait before them. The check's own description,
# further down README, carries no count and says where this one is.
claim_once 'scroller counts' '\*\*[0-9]+\*\* are the table-scroller check described further down, [a-z-]+ on each table template: its [a-z-]+ assertions and the wait for the scroller before them' "$README_FLOWED"
S_COUNT=$(field 1 '^\*\*([0-9]+)\*\*.*')
S_EACH=$(to_num "$(field 1 '.*down, ([a-z-]+) on each.*')")
S_ASSERTS=$(to_num "$(field 1 '.*its ([a-z-]+) assertions.*')")

if [ "$ONLINE" -ne 1 ]; then
  not_read 'not requested. Pass --online to re-read it; the gate deliberately does not.'
elif ! command -v curl >/dev/null 2>&1; then
  not_read 'curl is not on PATH, so no trace can be fetched from this shell.'
elif ! command -v python3 >/dev/null 2>&1; then
  not_read 'python3 is not on PATH, so the pipeline JSON cannot be parsed.'
else
  API="https://git.drupalcode.org/api/v4/projects/project%2F$PROJECT"
  RAW="https://git.drupalcode.org/project/$PROJECT/-/jobs"
  PJ=$(curl -sSL --max-time 40 "$API/pipelines/$R_PIPE" 2>/dev/null)
  JJ=$(curl -sSL --max-time 40 "$API/pipelines/$R_PIPE/jobs?per_page=100" 2>/dev/null)
  if [ -z "$PJ" ] || [ -z "$JJ" ]; then
    not_read "the GitLab API returned nothing for pipeline $R_PIPE. No network, or the host is unreachable from here."
  else
    PIPE_OUT=$(PJ="$PJ" JJ="$JJ" python3 -c '
import json, os, sys
# cspell:ignore isinstance
try:
    p = json.loads(os.environ["PJ"])
    jobs = json.loads(os.environ["JJ"])
except Exception as exc:
    print("ERR the API answered something that is not JSON: %s" % exc); sys.exit(0)
if not isinstance(p, dict) or "status" not in p:
    print("ERR the pipeline object carries no status key, so nothing was examined."); sys.exit(0)
if p.get("status") not in ("success", "failed", "canceled", "skipped"):
    print("ERR pipeline is %s, not a terminal state. A job list read mid-pipeline is not a measurement." % p.get("status")); sys.exit(0)
if not isinstance(jobs, list) or not jobs:
    print("ERR the jobs endpoint returned no job list at all, which is a failure and not an empty result."); sys.exit(0)
print("REF %s" % p.get("ref", ""))
print("SHA %s" % p.get("sha", ""))
for j in sorted(jobs, key=lambda x: x.get("name", "")):
    print("JOB %s|%s|%s|%s" % (j.get("name",""), j.get("stage",""), j.get("status",""),
                               "no" if j.get("allow_failure") else "yes"))
' 2>/dev/null)
    if [ -z "$PIPE_OUT" ] || [ "${PIPE_OUT#ERR }" != "$PIPE_OUT" ]; then
      not_read "${PIPE_OUT#ERR }"
    else
      O_REF=$(printf '%s\n' "$PIPE_OUT" | sed -n 's/^REF //p')
      O_SHA=$(printf '%s\n' "$PIPE_OUT" | sed -n 's/^SHA //p')
      compare 'pipeline_ref'    "$R_REF" "$O_REF" "pipeline $R_PIPE"
      compare 'pipeline_commit' "$R_SHA" "$(printf '%s' "$O_SHA" | cut -c1-${#R_SHA})" "pipeline $R_PIPE"

      # README's four columns against the API's four fields, sorted, compared whole.
      O_TABLE=$(printf '%s\n' "$PIPE_OUT" | sed -n 's/^JOB //p' | LC_ALL=C sort)
      R_TABLE=$(printf '%s\n' "$README_TEXT" \
        | sed -nE 's/^\| `([a-z-]+)` \| \*\*([a-z]+)\*\* \| ([a-z]+) \| (yes|no) \|$/\1|\2|\3|\4/p; s/^\| `([a-z-]+)` \| ([a-z]+) \| ([a-z]+) \| (yes|no) \|$/\1|\2|\3|\4/p' \
        | LC_ALL=C sort)
      COMPARISONS=$((COMPARISONS + 1))
      if [ "$R_TABLE" = "$O_TABLE" ]; then
        printf '  %s%-18s%s README [%s rows]  =  API [%s rows]  %sOK%s\n' "$C_DIM" 'job_table' "$C_OFF" \
          "$(printf '%s\n' "$R_TABLE" | grep -v "^$" | wc -l | tr -d "[:space:]")" "$(printf '%s\n' "$O_TABLE" | grep -v "^$" | wc -l | tr -d "[:space:]")" "$C_OK" "$C_OFF"
      else
        FINDINGS=$((FINDINGS + 1))
        printf '  %s%-18s README != API  MISMATCH (name|stage|status|blocking)%s\n' "$C_BAD" 'job_table' "$C_OFF"
        printf '      only in README: %s\n' "$(comm -23 <(printf '%s\n' "$R_TABLE") <(printf '%s\n' "$O_TABLE") | tr '\n' ' ')"
        printf '      only in API:    %s\n' "$(comm -13 <(printf '%s\n' "$R_TABLE") <(printf '%s\n' "$O_TABLE") | tr '\n' ' ')"
      fi

      # -- the two traces ---------------------------------------------------------------
      # -L is MANDATORY. Without it the 302 is 622 bytes and looks exactly like a failure,
      # which is an absence of information dressed as a measurement.
      NW=$(curl -sSL --max-time 60 "$RAW/$A_JOB/raw" 2>/dev/null)
      if [ "$(printf '%s' "$NW" | wc -c | tr -d '[:space:]')" -lt 2000 ]; then
        for f in axe_pages axe_rules axe_rules_max axe_violations axe_heading axe_heading_of axe_assertions $DOM_FIGURES $SCROLLER_FIGURES; do
          UNREAD=$((UNREAD + 1))
          printf '  %sNOT READ%s  %-18s job %s returned %s bytes, which is a redirect or an error, not a log.\n' \
            "$C_BAD" "$C_OFF" "$f" "$A_JOB" "$(printf '%s' "$NW" | wc -c | tr -d '[:space:]')"
        done
      else
        AXE_LINE=$(printf '%s\n' "$NW" | grep -oE 'axe gate: [0-9]+ pages scanned, [0-9]+-[0-9]+ axe rules run per page, [0-9]+ violations, heading-order reported on [0-9]+ of [0-9]+ pages' | sort -u)
        _n=$(printf '%s\n' "$AXE_LINE" | grep -v "^$" | wc -l | tr -d "[:space:]")
        ASSERT_LINE=$(printf '%s\n' "$NW" | grep -oE '[0-9]+ total assertions' | sort -u)
        _m=$(printf '%s\n' "$ASSERT_LINE" | grep -v "^$" | wc -l | tr -d "[:space:]")
        if [ "${_n:-0}" -ne 1 ] || [ "${_m:-0}" -ne 1 ]; then
          for f in axe_pages axe_rules axe_rules_max axe_violations axe_heading axe_heading_of axe_assertions; do
            UNREAD=$((UNREAD + 1))
            printf '  %sNOT READ%s  %-18s the trace printed %s summary lines and %s assertion totals, expected 1 of each. A log stating one figure twice is an ambiguous measurement, and an ambiguous measurement is not a measurement.\n' \
              "$C_BAD" "$C_OFF" "$f" "$_n" "$_m"
          done
        else
          compare 'axe_pages'      "$A_PAGES"  "$(printf '%s' "$AXE_LINE" | sed -nE 's/.*gate: ([0-9]+) pages.*/\1/p')"       "job $A_JOB"
          compare 'axe_rules'      "$A_RULES"  "$(printf '%s' "$AXE_LINE" | sed -nE 's/.*, ([0-9]+)-[0-9]+ axe rules.*/\1/p')" "job $A_JOB"
          compare 'axe_rules_max'  "$A_RULES"  "$(printf '%s' "$AXE_LINE" | sed -nE 's/.*-([0-9]+) axe rules.*/\1/p')"         "job $A_JOB"
          compare 'axe_violations' "$A_VIOL"   "$(printf '%s' "$AXE_LINE" | sed -nE 's/.*page, ([0-9]+) violations.*/\1/p')"   "job $A_JOB"
          compare 'axe_heading'    "$A_HEAD"   "$(printf '%s' "$AXE_LINE" | sed -nE 's/.*reported on ([0-9]+) of.*/\1/p')"     "job $A_JOB"
          compare 'axe_heading_of' "$A_HEAD_OF" "$(printf '%s' "$AXE_LINE" | sed -nE 's/.*of ([0-9]+) pages.*/\1/p')"          "job $A_JOB"
          compare 'axe_assertions' "$A_ASSERT" "$(printf '%s' "$ASSERT_LINE" | sed -nE 's/([0-9]+) total.*/\1/p')"             "job $A_JOB"
        fi

        # -- the seven per-check counts, one trace section each ----------------------------
        # The rule, and why it is checked on every section rather than assumed from one, is in
        # the header. Colour codes are stripped FIRST: Nightwatch colours a section's title and
        # its count separately, so a grep of the raw log for either finds nothing - and finding
        # nothing reads exactly like there being no per-section figure to find. The GitLab
        # timestamp goes too, so a tick is recognised by where it starts its line, never by a
        # tick character quoted inside some assertion's message.
        DOM_ERR=''
        if ! command -v awk >/dev/null 2>&1; then
          DOM_ERR='awk is not on PATH, so the trace cannot be split into its sections'
        else
          _esc=$(printf '\033')
          NW_PLAIN=$(printf '%s\n' "$NW" | tr -d '\r' \
            | sed -E "s/${_esc}\\[[0-9;]*[A-Za-z]//g; s/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z [0-9]{2}[OE][+]? //")
          DOM_PARSE=$(printf '%s\n' "$NW_PLAIN" | awk '
              function fail(msg) { if (err == "") err = msg }
              function count_after_passed(line) { sub(/^.*PASSED\. /, "", line); sub(/[^0-9].*$/, "", line); return line }
              /^\[.*\] Test Suite$/ { suites++; in_run = 1; next }
              !in_run { next }
              /✖/ || /^[[:space:]]*FAILED:/ { fail("the run printed a failure line, and a failing run does not print the counts README quotes") }
              /^[[:space:]]*✨ PASSED\. [0-9]+ total assertions/ {
                if (in_section) fail("section \"" title "\" printed no PASSED line")
                total = count_after_passed($0)
                totals++; in_section = 0; in_run = 0; next
              }
              /^[[:space:]]*Running .+:$/ {
                if (in_section) fail("section \"" title "\" printed no PASSED line")
                title = $0; sub(/^[[:space:]]*Running /, "", title); sub(/:$/, "", title)
                in_section = 1; ticks = 0; waits = 0; next
              }
              /^[[:space:]]*✔ / {
                if (!in_section) { outside++; next }
                ticks++
                if ($0 ~ /^[[:space:]]*✔ Element <.*> was present after [0-9]+ milliseconds\.$/) waits++
                next
              }
              /^[[:space:]]*✨ PASSED\. [0-9]+ assertions\./ {
                if (!in_section) { fail("a PASSED line was printed outside any section"); next }
                printf "SECTION %s|%d|%d|%s\n", count_after_passed($0), ticks, waits, title
                in_section = 0; next
              }
              END {
                if (suites == 0) fail("no \"[...] Test Suite\" line, so the run was not found in the log")
                else if (totals != 1) fail("the run printed " totals + 0 " total-assertion lines, expected 1")
                if (in_section) fail("section \"" title "\" printed no PASSED line")
                printf "OUTSIDE %d\nTOTAL %s\n", outside, total
                if (err != "") printf "ERR %s\n", err
              }')
          DOM_ERR=$(printf '%s\n' "$DOM_PARSE" | sed -n 's/^ERR //p')
          DOM_ROWS=$(printf '%s\n' "$DOM_PARSE" | sed -n 's/^SECTION //p')
          DOM_OUT=$(printf '%s\n' "$DOM_PARSE" | sed -n 's/^OUTSIDE //p')
          DOM_TOTAL=$(printf '%s\n' "$DOM_PARSE" | sed -n 's/^TOTAL //p')
          DOM_SECTIONS=$(printf '%s\n' "$DOM_ROWS" | grep -v '^$' | wc -l | tr -d '[:space:]')
          DOM_SUM=$(printf '%s\n' "$DOM_ROWS" | awk -F'|' 'NF >= 4 { s += $1 } END { print s + 0 }')
          # The premise, section by section: as many tick lines as the section says it counted.
          DOM_BAD=$(printf '%s\n' "$DOM_ROWS" | awk -F'|' 'NF >= 4 && $1 != $2 { printf "%s%s (%s counted, %s ticks)", sep, $4, $1, $2; sep = "; " }')
          # Zero is a FAIL for every default below: no sections, or a total of 0, cannot balance.
          if [ -n "$DOM_ERR" ]; then
            :
          elif [ "${DOM_SECTIONS:-0}" -eq 0 ]; then
            DOM_ERR='the run printed no test section at all'
          elif [ -n "$DOM_BAD" ]; then
            DOM_ERR="the rule does not hold - a section printed a different number of tick lines than it counted: $DOM_BAD"
          elif [ "$((DOM_SUM + ${DOM_OUT:-0}))" -ne "${DOM_TOTAL:-0}" ]; then
            DOM_ERR="the sections count $DOM_SUM and ${DOM_OUT:-0} ticks sit outside them, but the run printed a total of ${DOM_TOTAL:-nothing} - a section was missed or counted twice"
          fi
        fi
        if [ -n "$DOM_ERR" ]; then
          for f in $DOM_FIGURES $SCROLLER_FIGURES; do
            UNREAD=$((UNREAD + 1))
            printf '  %sNOT READ%s  %-18s job %s: %s.\n' "$C_BAD" "$C_OFF" "$f" "$A_JOB" "$DOM_ERR"
          done
        else
          printf '  %s%-18s job %s: %s of %s sections print one tick per assertion counted; %s in sections + %s outside = %s, the total it printed%s\n' \
            "$C_DIM" 'dom rule' "$A_JOB" "$DOM_SECTIONS" "$DOM_SECTIONS" "$DOM_SUM" "$DOM_OUT" "$DOM_TOTAL" "$C_OFF"
          _k=0
          while IFS='|' read -r _lbl _said _title; do
            _k=$((_k + 1))
            _row=$(printf '%s\n' "$DOM_ROWS" | awk -F'|' -v t="$_title" '$4 == t')
            _matches=$(printf '%s\n' "$_row" | grep -v '^$' | wc -l | tr -d '[:space:]')
            if [ "${_matches:-0}" -ne 1 ]; then
              UNREAD=$((UNREAD + 1))
              if [ "${_matches:-0}" -eq 0 ]; then
                _why='renamed or removed in axe.js, and DOM_BINDINGS moves with it'
              else
                _why='a title printed twice is an ambiguous measurement, and an ambiguous measurement is not a measurement'
              fi
              printf '  %sNOT READ%s  %-18s job %s has %s sections titled "%s", expected 1 - %s.\n' \
                "$C_BAD" "$C_OFF" "$_lbl" "$A_JOB" "$_matches" "$_title" "$_why"
              continue
            fi
            _cnt=$(printf '%s' "$_row" | cut -d'|' -f1)
            _wait=$(printf '%s' "$_row" | cut -d'|' -f3)
            compare "$_lbl" "$(printf '%s\n' "$DOM_CLAIMED" | sed -n "${_k}p")" "$((_cnt - _wait))" \
              "job $A_JOB, $_cnt counted - $_wait wait(s)"
          done < <(printf '%s\n' "$DOM_BINDINGS")

          # -- the table scroller, split by template ----------------------------------------
          # Why per template, and the label that makes it possible, are in the header. A tick
          # that is neither a wait nor labelled, or a wait with no labelled assertion after it,
          # leaves the split unable to account for the section, so all three are NOT READ rather
          # than compared against a split that is missing something.
          SCROLL_ERR=''
          _row=$(printf '%s\n' "$DOM_ROWS" | awk -F'|' -v t="$SCROLLER_TITLE" '$4 == t')
          _matches=$(printf '%s\n' "$_row" | grep -v '^$' | wc -l | tr -d '[:space:]')
          if [ "${_matches:-0}" -ne 1 ]; then
            SCROLL_ERR="job $A_JOB has $_matches sections titled \"$SCROLLER_TITLE\", expected 1 - renamed or removed in axe.js, and SCROLLER_TITLE moves with it"
          else
            SCROLL_COUNTED=$(printf '%s' "$_row" | cut -d'|' -f1)
            SCROLL_WAITS=$(printf '%s' "$_row" | cut -d'|' -f3)
            SCROLL_PARSE=$(printf '%s\n' "$NW_PLAIN" | awk -v want="$SCROLLER_TITLE" '
              function fail(msg) { if (err == "") err = msg }
              /^[[:space:]]*Running .+:$/ {
                t = $0; sub(/^[[:space:]]*Running /, "", t); sub(/:$/, "", t)
                inside = (t == want); next
              }
              !inside { next }
              /^[[:space:]]*✨ PASSED\. [0-9]+ assertions\./ {
                if (pending) fail(pending " wait(s) came after the last labelled assertion, so no template can be credited with them")
                inside = 0; pending = 0; next
              }
              /^[[:space:]]*✔ Element <.*> was present after [0-9]+ milliseconds\.$/ { pending++; next }
              /^[[:space:]]*✔ / {
                name = $0
                if (name !~ /Table scroller \([^,()]+, [0-9]+px\):/) { bare++; next }
                sub(/^.*Table scroller \(/, "", name); sub(/, [0-9]+px\):.*$/, "", name)
                if (!(name in asserts)) order[++n] = name
                asserts[name]++; waits[name] += pending; pending = 0; next
              }
              END {
                for (i = 1; i <= n; i++) printf "TEMPLATE %s|%d|%d\n", order[i], asserts[order[i]], waits[order[i]]
                printf "BARE %d\n", bare
                if (err != "") printf "ERR %s\n", err
              }')
            SCROLL_ERR=$(printf '%s\n' "$SCROLL_PARSE" | sed -n 's/^ERR //p')
            SCROLL_ROWS=$(printf '%s\n' "$SCROLL_PARSE" | sed -n 's/^TEMPLATE //p')
            SCROLL_BARE=$(printf '%s\n' "$SCROLL_PARSE" | sed -n 's/^BARE //p')
            SCROLL_TEMPLATES=$(printf '%s\n' "$SCROLL_ROWS" | grep -v '^$' | wc -l | tr -d '[:space:]')
            SCROLL_SUM=$(printf '%s\n' "$SCROLL_ROWS" | awk -F'|' 'NF >= 3 { s += $2 + $3 } END { print s + 0 }')
            if [ -n "$SCROLL_ERR" ]; then
              :
            elif [ "${SCROLL_TEMPLATES:-0}" -eq 0 ]; then
              SCROLL_ERR='no assertion in the section carries a template label, so nothing can be counted per template'
            elif [ "${SCROLL_BARE:-0}" -ne 0 ]; then
              SCROLL_ERR="$SCROLL_BARE tick(s) in the section are neither a wait nor labelled with a template"
            elif [ "$SCROLL_SUM" -ne "${SCROLL_COUNTED:-0}" ]; then
              SCROLL_ERR="the templates account for $SCROLL_SUM ticks and the section counted ${SCROLL_COUNTED:-nothing}"
            fi
          fi
          if [ -n "$SCROLL_ERR" ]; then
            for f in $SCROLLER_FIGURES; do
              UNREAD=$((UNREAD + 1))
              printf '  %sNOT READ%s  %-18s %s.\n' "$C_BAD" "$C_OFF" "$f" "$SCROLL_ERR"
            done
          else
            # Distinct values across templates, joined by "|": equal counts read as one number,
            # and unequal ones as "7|9" - which no README figure equals, so the MISMATCH names both.
            SCROLL_EACH=$(printf '%s\n' "$SCROLL_ROWS" | awk -F'|' 'NF >= 3 { v = $2 + $3; if (!(v in seen)) { seen[v] = 1; out = out sep v; sep = "|" } } END { print out }')
            SCROLL_ASSERTS=$(printf '%s\n' "$SCROLL_ROWS" | awk -F'|' 'NF >= 3 { v = $2; if (!(v in seen)) { seen[v] = 1; out = out sep v; sep = "|" } } END { print out }')
            SCROLL_SPLIT=$(printf '%s\n' "$SCROLL_ROWS" | awk -F'|' 'NF >= 3 { printf "%s%s %d+%d", sep, $1, $2, $3; sep = ", " }')
            compare 'scroller_count'   "$S_COUNT"   "$SCROLL_COUNTED" "job $A_JOB, the section's own count"
            compare 'scroller_each'    "$S_EACH"    "$SCROLL_EACH"    "job $A_JOB, assertions+wait per template: $SCROLL_SPLIT"
            compare 'scroller_asserts' "$S_ASSERTS" "$SCROLL_ASSERTS" \
              "job $A_JOB, $SCROLL_COUNTED counted - $SCROLL_WAITS wait(s) over $SCROLL_TEMPLATES template(s)"
          fi
        fi
      fi

      PU=$(curl -sSL --max-time 60 "$RAW/$P_JOB/raw" 2>/dev/null)
      if [ "$(printf '%s' "$PU" | wc -c | tr -d '[:space:]')" -lt 2000 ]; then
        for f in phpunit_tests phpunit_assertions csv_ceilings; do
          UNREAD=$((UNREAD + 1))
          printf '  %sNOT READ%s  %-18s job %s returned %s bytes, which is a redirect or an error, not a log.\n' \
            "$C_BAD" "$C_OFF" "$f" "$P_JOB" "$(printf '%s' "$PU" | wc -c | tr -d '[:space:]')"
        done
      else
        OK_LINE=$(printf '%s\n' "$PU" | grep -oE 'OK \([0-9]+ tests, [0-9]+ assertions\)' | sort -u)
        _n=$(printf '%s\n' "$OK_LINE" | grep -v "^$" | wc -l | tr -d "[:space:]")
        if [ "${_n:-0}" -ne 1 ]; then
          # Upstream flipped _PHPUNIT_CONCURRENT to '1' in September 2026 and run-tests.sh prints
          # no OK(...) line at all. A criterion written around that string would then match
          # nothing and read, to anyone not opening the trace, exactly like a suite that stopped
          # reporting. It is a NOT READ here and never a pass.
          for f in phpunit_tests phpunit_assertions; do
            UNREAD=$((UNREAD + 1))
            printf '  %sNOT READ%s  %-18s the trace carries %s distinct "OK (N tests, M assertions)" lines, expected 1. run-tests.sh prints none at all when _PHPUNIT_CONCURRENT is 1.\n' \
              "$C_BAD" "$C_OFF" "$f" "$_n"
          done
        else
          compare 'phpunit_tests'      "$P_TESTS"  "$(printf '%s' "$OK_LINE" | sed -nE 's/OK \(([0-9]+) tests.*/\1/p')"    "job $P_JOB"
          compare 'phpunit_assertions' "$P_ASSERT" "$(printf '%s' "$OK_LINE" | sed -nE 's/.*tests, ([0-9]+) assertions.*/\1/p')" "job $P_JOB"
        fi
        CEIL=$(printf '%s\n' "$PU" | grep -oE 'agora_theme CSV distribution: [0-9]+ bytes, [0-9]+ rows, [0-9]+ columns' | sort -u)
        _n=$(printf '%s\n' "$CEIL" | grep -v "^$" | wc -l | tr -d "[:space:]")
        if [ "${_n:-0}" -ne 1 ]; then
          UNREAD=$((UNREAD + 1))
          printf '  %sNOT READ%s  %-18s the trace carries %s distinct ceiling lines, expected 1.\n' \
            "$C_BAD" "$C_OFF" 'csv_ceilings' "$_n"
        else
          compare 'csv_ceilings' "$C_CEIL" "$CEIL" "job $P_JOB"
        fi
      fi
    fi
  fi
fi

# ======================================================================= NOT CHECKED ==
# Named with a reason that is TRUE, and the length of this list is asserted non-empty below.
NOT_CHECKED=(
"Public Sans: 565 codepoints per face, 18 OpenType features, and the three sha256 sums against the upstream release archive. Reading them needs a woff2 decompressor and public-sans-v2.001.zip from github.com. Neither is in this repository, and neither is a CI job trace - which is the only external thing --online reads."
"Simple Icons: the 5-of-5 path identity against release 16.29.0. Same shape - it needs the pinned upstream release from the npm registry mirror, which no job here downloads and no trace prints."
"Release facts. README states no count or range of published releases: it points at the project's release history, because that count moves with every release, by a person submitting a form, and no copy of it here stays true. What it does state - that the tags it names have no release node behind them - lives in Drupal.org's release data. --online is deliberately scoped to the job traces of the ONE pipeline README names, so widening it is a decision and not an omission to be fixed quietly."
"Olivero's 14 and Claro's 8 block placements on core 11.4.5. That needs a Drupal core checkout. CI installs core, but no job prints a count of another theme's placements, so there is no trace to read it out of."
"File-header and byte-size facts about shipped binaries: screenshot.png at 588 by 438, and the two woff2 faces at 67,276 bytes together. READABLE FROM THE FILES THEMSELVES and deliberately not bound in this pass. That is a weaker reason than the ones above it and it is written as one rather than dressed up as an impossibility."
"The rest of the sentence that accounts for the latest rise in axe assertions: the rise itself, and the target-size share of it with its per-page split. The table-scroller share of that sentence IS compared above. The target-size share is READABLE FROM THE SAME TRACE - each scanned page's section prints axe's pass line for the rule and the gate's own assertion that it ran - and is deliberately not bound in the pass that bound its neighbour; the rise is a difference against the observation the table replaced. Named here so that binding half a sentence does not read as binding all of it."
"The twig-cs-fixer observation (7 files linted, 0 errors). The tool is not installed on this host and its CI job never materialises, so nothing in this repository or in any trace produces the figure. README already says the gap is named rather than papered over."
"Historical and superseded figures: every figure README attributes to an observation other than the one it names as current - the page-and-assertion progression the Accessibility table has replaced, the pipeline ids of those observations, every struck value. The category is named rather than sampled, because a sample of it has to be refreshed whenever an observation is added, which is how this entry came to stop one short of README. These are deliberately frozen records of past measurements; a guard that refreshed them would destroy the record they exist to be (rule 8, append-only)."
"Figures restated in different words elsewhere in README. Each comparison above binds ONE canonical sentence by a tight pattern. The change this script arrived with deleted the restatements it found rather than trying to match them all, so this entry is about the NEXT one somebody writes."
)

printf '\nNOT CHECKED - named, with a reason, because a guard that silently covers half its subject\n'
printf 'reads exactly like one that covers all of it\n'
printf -- '---------------------------------------------------------------------------------------------------------\n'
for _e in "${NOT_CHECKED[@]}"; do
  printf '  %s*%s %s\n' "$C_DIM" "$C_OFF" "$_e"
done

# ============================================================================ SUMMARY ==
NC=${#NOT_CHECKED[@]}
printf '\n=========================================================================================================\n'
printf 'claims extracted: %d · comparisons: %d · not checked: %d · online figures unread: %d · findings: %d\n' \
  "$CLAIMS" "$COMPARISONS" "$NC" "$UNREAD" "$FINDINGS"
printf '=========================================================================================================\n'

RC=0
if [ "$CLAIMS" -eq 0 ]; then
  printf '%sFAIL:%s no claim was extracted from %s at all. A run that read nothing found nothing.\n' "$C_BAD" "$C_OFF" "$README"
  RC=1
fi
if [ "$COMPARISONS" -eq 0 ]; then
  printf '%sFAIL:%s no comparison was made. Zero comparisons and zero findings is the degenerate\n' "$C_BAD" "$C_OFF"
  printf '      agreement I-028 names: it prints exactly like a clean file.\n'
  RC=1
fi
if [ "$NC" -eq 0 ]; then
  printf '%sFAIL:%s the NOT CHECKED list is empty. An empty exclusion list passes by construction,\n' "$C_BAD" "$C_OFF"
  printf '      and it is the shape somebody deletes an entry into rather than answering it.\n'
  RC=1
fi
if [ "$FINDINGS" -ne 0 ]; then
  printf '%sFAIL:%s %d figure(s) in %s disagree with the thing they are about.\n' "$C_BAD" "$C_OFF" "$FINDINGS" "$README"
  printf '      Each line above names the file, the claimed value and the measured one. Fix the\n'
  printf '      PROSE if the measurement is right; fix the CODE if the prose was the promise.\n'
  RC=1
fi
if [ "$ONLINE" -eq 1 ] && [ "$UNREAD" -ne 0 ]; then
  printf '%sFAIL:%s --online was requested and %d figure(s) could not be read.\n' "$C_BAD" "$C_OFF" "$UNREAD"
  printf '      This exits non-zero ON PURPOSE. A request to verify something, answered with\n'
  printf '      silence, must not leave behind an exit code that means "verified".\n'
  RC=1
fi
[ "$RC" -eq 0 ] && printf '%s%s%s\n' "$C_OK" 'claims-match-readme: clean' "$C_OFF"
exit "$RC"
