#!/usr/bin/env bash
#
# shared-invariants - agora_theme - task T-508, decision D-028.
#
# THE DETECTOR THAT MAKES THE MANIFEST WORTH HAVING. D-028 chose option B:
#
#   "A named subset, copied, plus shared-invariants.manifest (sha256 per script
#    + the source sha it came from) and an invariant that fails when a local
#    copy has been edited."
#
# This is that invariant. Without it the manifest is decoration - a file of
# hashes nobody compares against anything. D-028's own words for why option B
# beat copying wholesale: it is "honest that this is a copy rather than
# pretending it is sharing". Honesty here is mechanical or it is nothing.
#
# WHAT IT CATCHES, AND WHAT IT DOES NOT.
#   CATCHES  local drift: a shared script edited in THIS repository. That is
#            the failure D-028 was written to prevent - a copied invariant
#            quietly adjusted until it passes, which is how a gate gets
#            weakened by someone acting in good faith. The prime example is
#            no-code-in-template, which asserts ZERO *.info.yml files: true of
#            the site template, false of a theme. D-028 forbids copying it at
#            all, and this script is the second line of defence for the five
#            that WERE copied.
#   CATCHES  an undeclared copy: a script appearing in tests/bin/ that is
#            neither in the manifest nor declared local. Closed world, same
#            pattern as identity-strings assertion 5.
#   CATCHES  a source commit nobody declared - see DECLARED_COMMITS below.
#   CATCHES  a manifest that contradicts itself: a record marked `verbatim`
#            whose local and source hashes differ, or `adapted` whose hashes
#            are equal. Either means somebody updated one hash and not the
#            other, and the status has stopped describing the file.
#   DOES NOT upstream drift, IN THE DEFAULT MODE. The site template changing a
#            shared script after the copy was taken is invisible here: it needs
#            the other repository present, and a gate runner has no sibling
#            checkout. D-028 assigned it to "a dated review task in unit 006,
#            not by magic". Do not read a clean default run as "the copies are
#            up to date"; read it as "the copies are what the manifest says
#            they are". `--upstream` is the mode that answers the other half,
#            and the default run now names it on every run, so the half this
#            script cannot see is stated rather than left silent.
#
# ---------------------------------------------------------------------------
# `--upstream` - THE OTHER HALF, ADDED AT T-0622 UNDER D-063.
#
# WHY IT EXISTS, AND IT IS A MEASUREMENT RATHER THAN A WORRY. D-063's option A
# was "stay a dated review", and the measurement that ended that argument is
# unit 006 wave 1's: identity-strings stood 28 days and 125 genuinely-absent
# lines behind its recorded commit WHILE THIS SCRIPT PRINTED CLEAN. It printed
# clean correctly - the hashes matched the copies, which is all the default
# mode ever claimed - and that is the point. A detector that is right about its
# own subject and silent about the neighbouring one reads, from any distance,
# exactly like a detector that covers both.
#
# AND THE SAME MEASUREMENT FOUND THE FIELD NOBODY COMPARES. spellcheck's copy
# here had NO drift at all - 0 of 54 upstream-added lines absent - while its
# source_commit still named a commit 28 days old. The hashes are compared on
# every run; source_commit is compared by nothing, so it went stale silently
# and made the debt look LARGER than it was. A number wrong in the safe
# direction is still a number nobody checked, which is why this mode RE-DERIVES
# source_sha256 from the recorded commit instead of trusting the field.
#
# WHAT IT DOES per record, when the sibling checkout is present:
#   1. RE-DERIVES source_sha256 by hashing the blob at source_commit and
#      comparing it against the field. A mismatch is a FINDING, not a delta: it
#      means the recorded provenance is fiction, which is the failure the
#      manifest's own header refuses - "a manifest that names a commit nobody
#      opened".
#   2. PRINTS THE LINE DELTA between source_commit and the sibling's HEAD for
#      that one path: +added/-deleted, the number of commits that touched it,
#      and the age of the recorded commit in days.
#
# A DELTA IS DATA AND NEVER A VERDICT, and the exit status says so: drift alone
# does NOT fail this script. A copy may legitimately sit behind - the whole
# argument of T-0621 is that 125 of identity-strings' 132 upstream-added lines
# must NOT come across - so failing on a delta would teach the next reader to
# silence the number rather than read it. What DOES fail is a provenance that
# cannot be verified, and what can never pass is the third state below.
#
# THE SIBLING BEING ABSENT IS A THIRD STATE AND IT CAN NEVER READ AS A PASS.
# The standard is already in this repository: tests/bin/watch-gate's mirror
# section prints "NOT READ - ... That is not 'the mirror is fine'; it is
# 'nothing here can see the mirror'." This mode matches it, exits non-zero, and
# prints no per-record table at all, because a table of blanks is how a scope
# that collapsed to nothing gets read as a clean result (I-007, I-029).
#
# SO IT IS A PREFLIGHT AND NOT A GATE, and the distinction is mechanical rather
# than stylistic: the drupalci runner has ONE repository checked out, so wiring
# this into gate-a-theme.sh would install a check that reports NOT READ on
# every push forever - a permanently third-state check, which is worse than no
# check, because it teaches people to skip a line of the gate's output.
#
# HOW IT ACTUALLY GETS RUN, stated plainly, because a preflight nobody runs is
# the defect one level up from the one it fixes. Three places, one mechanical:
#   * EVERY default run prints `upstream: NOT CHECKED - run ...` in its summary
#     block, so the blocking agora-invariants job carries the instruction in
#     its own log on every push. gate-a-theme.sh's G6 asserts that line is
#     present, so deleting the only pointer to this mode fails the gate.
#   * shared-invariants.manifest's regeneration block names it as step 1, which
#     is where a person is standing at the moment they need it.
#   * README.md's tests/bin row names it.
#
# FINDING THE SIBLING, and how it is PROVED to be the right repository rather
# than assumed. Candidates, first hit wins: --template <path>, then
# $AGORA_TEMPLATE, then ../agora, then ../agora_transparency. A candidate is
# accepted only if EVERY commit in DECLARED_COMMITS resolves in it - the
# manifest's own precedent, quoted in its watch-gate block: "`git cat-file -t
# bc10c00` fails in THIS repository, which is what makes the commit
# agora_transparency's rather than something read back out of this working
# directory". A directory that is a git repository but does not hold those
# commits is NOT READ, never a pass.
#
# ON REGENERATING THE MANIFEST. When a shared script legitimately changes, the
# manifest is regenerated - D-028 names that as option B's cost and accepts it.
# The regeneration must be a deliberate, reviewed edit that also updates
# source_commit, never a `--fix` flag on this script. There is deliberately no
# such flag: a detector that can silence itself in one keystroke is the thing
# it was built to prevent.
#
# Contract (shared by every invariant in tests/bin/):
#   scope:    the exact set of files inspected
#   scanned:  N records, and N must be > 0 or this script fails
#   findings: M, each printed as  path:field  reason
#   exit 0 if and only if M == 0
#
# FATAL, not an ordinary finding, and printing NO summary line (I-029): the
# manifest missing or unreadable; zero records parsed; a declared script absent
# from disk; no working sha256 tool; the sha256 tool failing its canary.
#
# 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"; never `-F` with `-i`.
#
# cspell:ignore numstat
# ^ the git flag `--numstat`, on the one line of the upstream section that calls
# it. It is an argument passed to git, so there is nothing to reword. Scoped
# here rather than added to .cspell-project-words.txt because that file's own
# header prefers rewording and narrowest scope, and because this script is
# declared LOCAL_ONLY - a directive inside it edits no shared copy and drifts
# nothing. ⚠️ EIGHT OTHER WORDS THIS SECTION INTRODUCED WERE REWORDED RATHER
# THAN DECLARED - CANDIDATE_TOP, candidate, U_UNREAD, DELTA_RAW, COMMIT_COUNT,
# U_LARGEST_ADD and the NO-DIGEST label - which is the remedy that block of the
# word list asks for first and the reason only one word needed a directive.
#
# Usage: tests/bin/shared-invariants [--upstream [--template <path>]]
#        (run from anywhere; it cd's to the repo root)
#
#        --upstream           also report the per-record delta against the site
#                             template. Needs the sibling checkout; NOT READ and
#                             a non-zero exit when it is absent.
#        --template <path>    where that checkout is, overriding the search.

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

# ---------------------------------------------------------------- repo root --
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 1; }

# ------------------------------------------------------------------ options --
# Unknown options are refused rather than ignored. A preflight that silently
# accepts a misspelled flag runs its DEFAULT mode and prints a clean result,
# which is indistinguishable from the mode the reader asked for having passed.
UPSTREAM=0
TEMPLATE_ARG=''
while [ "$#" -gt 0 ]; do
  case "$1" in
    --upstream) UPSTREAM=1 ;;
    --template)
      shift
      [ "$#" -gt 0 ] || { echo "FATAL: --template needs a path"; exit 1; }
      TEMPLATE_ARG=$1 ;;
    --template=*) TEMPLATE_ARG=${1#--template=} ;;
    -h|--help)
      sed -n '/^# Usage:/,/^$/p' "$0" | sed 's/^# \{0,1\}//'
      exit 0 ;;
    *)
      echo "FATAL: unknown option [$1]. Run with --help."
      exit 1 ;;
  esac
  shift
done

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

MANIFEST='tests/bin/shared-invariants.manifest'
BIN_DIR='tests/bin'

# The five invariants D-028 names as shared. Written out here as well as in the
# manifest ON PURPOSE: the manifest is data and can be edited to say anything,
# so a manifest that quietly lost a line would otherwise pass its own check.
# This list is the independent expectation the manifest is compared against.
#
# no-unstable-deps is the fifth and it arrived after the other four, by the
# amendment to D-028 recorded 2026-08-24: D-028's original enumeration named
# four shared and five excluded, and this script was in neither list, so it had
# no verdict at all rather than a negative one. It passes vacuously today - the
# theme's composer.json has no require section - which is the argument FOR
# installing it now, not against: it becomes load-bearing the first time anyone
# adds a dependency, and non-negotiable rule 1 binds every package this project
# publishes, not only the template.
D028_SHARED='no-secrets
no-patches
no-blind-phpunit
identity-strings
no-unstable-deps'

# The source commits the manifest is allowed to name, each one deliberate and
# explained in the manifest's own header. This is a DECLARED SET, not a count:
# see the source-commit check near the end of this script for why the old
# "exactly one" rule was replaced rather than relaxed.
# 8c23a021 LEFT THIS LIST ON 2026-09-02 and 4154901 replaced it, because the only
# record naming the old commit - watch-gate - was re-synced onto the new one. The
# script reported BOTH halves of that on the same run: the new commit was not
# declared, and the old declaration no longer matched any record. Both were
# acted on rather than one, which is the point of checking in two directions.
#
# THE SHA WAS READ, NOT TYPED, which is the failure this list exists to catch:
# `git rev-parse HEAD` in agora_transparency after committing there, then
# `git -C ../agora cat-file -p 4154901:tests/bin/watch-gate | sha256sum` to
# confirm the file at that commit is byte-identical to the copy here.
#
# 3d11114 AND 36a0c1a LEFT THIS LIST ON 2026-09-19 and 1b4c1a4 replaced both, at T-1902. The
# two departing commits were the ONLY declarations for no-secrets-binary.py and no-secrets
# respectively, and T-1901 re-synced both copies onto one commit; no-blind-phpunit moved onto
# the same commit at T-1903, off 935c133, which three other records still name and which
# therefore stays. Five declarations became four while the record count stayed at eight - so
# this list is NOT a count of anything, which is the reason it is checked as a set.
#
# THE SHA WAS READ, NOT TYPED, twice and by two different commands: `git -C <template>
# rev-parse HEAD` at the moment of the copy, then `git show <sha>:tests/bin/<file> | sha256sum`
# and again `git cat-file -p <sha>:tests/bin/<file> | sha256sum`, both agreeing with the copy on
# disk here.
#
# ⚠️ THE SITE TEMPLATE'S HEAD MOVED WHILE T-1902 WAS RUNNING - 1b4c1a4 to 890a079, a second
# writer committing in that checkout - and the recorded sha is deliberately the OLDER one. This
# field means "the commit the copy was taken from", and 1b4c1a4 is the commit `git show` was
# actually run against. 890a079 touches only specs/003-demo-content/tasks.md, so the three
# blobs are byte-identical at both and either would verify; recording the one nobody read would
# have been inventing provenance to look current, which is the move this list exists to refuse.
#
# 4154901 LEFT THIS LIST ON 2026-09-20 and bc10c00 replaced it, which is the SECOND time
# watch-gate alone has moved this list and the second time it has been the only record naming
# the departing commit - the 2026-09-02 entry above describes the same event one commit
# earlier. The re-sync is the one the manifest's watch-gate block documents: the template's
# copy had grown a mirror section and stood 300 lines away from this one, `verbatim`
# throughout, and no check in this repository could see it.
#
# THE SHA WAS READ, NOT TYPED, and re-derived a second time rather than copied from the first:
# `git -C <template> rev-parse HEAD` printed bc10c00620dbbd0d984c8ffb0197b10b7559172e at the
# moment of the copy, then `git show bc10c00:tests/bin/watch-gate | sha256sum` and
# `git cat-file -p` on the same path both printed 9cbc978d..., agreeing with `sha256sum` of the
# copy on disk here. `git cat-file -t bc10c00` fails in THIS repository, which is what makes the
# commit agora_transparency's rather than something read back out of this working directory.
# 0966558 JOINED THIS LIST ON 2026-09-21 at T-0621 and NOTHING LEFT IT, which is the first time
# this list has grown without shrinking. Both `adapted` records - identity-strings and spellcheck
# - moved off 935c133 onto it; 935c133 stays because no-patches still names it. Five declarations
# across eight records now, and the reason the earlier entries keep saying so is that this list is
# a SET and never a count.
#
# THE SHA WAS READ, NOT TYPED, and the file hashes were re-derived THREE times by three commands,
# the third going through the blob object id rather than the path expression so that a bug in path
# resolution could not make them agree: `git -C <template> rev-parse HEAD` printed
# 096655810ac4713efd574832f3b059f1f982ea56, then `git show 0966558:tests/bin/<file> | sha256sum`,
# `git cat-file -p` on the same path, and `git cat-file blob <blob-id>` all printed the value
# recorded beside each record. `git cat-file -t 0966558` fails in THIS repository, which is what
# makes the commit agora_transparency's rather than something read back out of this tree - and it
# is now checked on every `--upstream` run rather than asserted here.
#
# ⚠️ THE TEMPLATE'S HEAD MOVED WHILE T-0621 WAS RUNNING, d7cd919 to 0966558, the second time this
# file records that happening. Unlike T-1902 the NEWER commit is recorded, and the rule did not
# change: record the commit `git show` was actually run against. Here that is 0966558.
#
# 976b44d REPLACED bc10c00 ON 2026-09-21 at T-0631, and it is a REPLACEMENT rather than a
# growth: watch-gate was the ONLY record naming bc10c00, so moving it moved the last user and the
# departing commit had to leave in the same edit. The detector said both halves itself, before
# either was done - `records name source commit [976b44d411fe5229e6efef5bc2af45c3df43310e], which is not in this script's
# DECLARED_COMMITS list` and `DECLARED_COMMITS names [bc10c00620dbbd0d984c8ffb0197b10b7559172e] but no manifest record uses it any
# more`. Six declarations across eight records, the count unchanged.
#
# WHAT THE RE-SYNC CARRIED: a third section in watch-gate, THE TAGS. Until it landed a pipeline
# whose ref is a TAG was read by nothing in either repository, because every ref both scripts
# derived was a BRANCH. The defect was measured in THIS repository - pipeline 969368, ref 1.2.0,
# sat RED FOR 23 HOURS while the identical commit was green on 1.x - and the manifest's watch-gate
# block carries the detail.
#
# THE SHA WAS READ, NOT TYPED, and re-derived rather than copied from the first reading:
# `git -C <template> rev-parse HEAD` printed 976b44d411fe5229e6efef5bc2af45c3df43310e, then
# `git show 976b44d:tests/bin/watch-gate | sha256sum` and `git cat-file -p` on the same path both
# printed 6b967fbd..., agreeing with `sha256sum` of the copy on disk here. `git cat-file -t 976b44d411fe5229e6efef5bc2af45c3df43310e`
# FAILS in THIS repository, which is what makes the commit agora_transparency's rather than
# something read back out of this working directory.
#
# ebae42d REPLACED 0966558 ON 2026-09-23 at T-0621, and it is a REPLACEMENT rather than a growth
# for the same structural reason 976b44d replaced bc10c00: 0966558 was named by exactly two
# records - identity-strings and spellcheck, the two `adapted` ones - and BOTH moved in this
# edit, so the departing commit had to leave in the same edit or the list would have gone stale
# in the direction nothing else can see. Six declarations across eight records, count unchanged.
#
# THE DETECTOR SAID BOTH HALVES ITSELF, BEFORE EITHER WAS DONE, which is the whole argument for
# this list existing rather than being inferred from the manifest at runtime. With the records
# re-synced and this declaration untouched, `shared-invariants` exited 1 with 2 findings:
# `records name source commit [ebae42d...], which is not in this script's DECLARED_COMMITS list`
# and `DECLARED_COMMITS names [0966558...] but no manifest record uses it any more`. A
# half-finished re-sync fails loudly; it does not pass quietly with a tidier file.
#
# THE SHA WAS READ, NOT TYPED: `git -C <template> rev-parse HEAD` printed
# ebae42ddc7efb1482355b4179f4b06024c49eae4, and every hash written at this re-sync was derived inside
# the same shell command that wrote it. `git cat-file -t ebae42ddc7efb1482355b4179f4b06024c49eae4` exits
# 128 in THIS repository, which is what makes the commit agora_transparency's rather than
# something read back out of this working directory - and unlike the earlier entries, that was
# not the only provenance check: the commit answers HTTP 200 on drupalcode's public commit page
# and API route, while a fabricated sha of the same length answers 404 on the same route. The
# 404 control is the half that matters, because upstream-drift refuses a commit nobody can open
# and a route that answered 200 to everything would read exactly like a verified provenance.
#
# 6dc1d06 JOINED THE LIST ON 2026-09-25 at T-0618, and it is a GROWTH rather than a replacement,
# for the reason the paragraphs above give in the other direction: ebae42d is still named by the
# spellcheck record, whose source has not moved, so only identity-strings left it. Removing
# ebae42d here would have been the half-finished edit this list exists to refuse. Seven
# declarations across eight records.
#
# THE DETECTOR SAID ITS ONE HALF ITSELF, BEFORE THIS LIST WAS TOUCHED: with the record moved and
# the declaration untouched, `shared-invariants` exited 1 with 1 finding - `records name source
# commit [6dc1d06...], which is not in this script's DECLARED_COMMITS list` - and printed
# `distinct source commits across 8 record(s): 7 (declared: 6)`. There was no second half,
# because no commit left the set.
#
# THE SHA WAS READ, NOT TYPED: `git -C <template> rev-parse 6dc1d06` printed
# 6dc1d06c80705a28afbc9d1d26d90333b1412484 - the named commit, not HEAD, although HEAD was the same commit at
# the time. `git cat-file -t` on it exits 128 in THIS repository, it answers HTTP 200 on
# drupalcode's public commit page and API route, and a fabricated sha of the same length answers
# 404 on that route. The manifest's identity-strings block carries the hashes.
#
# fc7b0ae REPLACED ebae42d ON 2026-09-25 at T-0717, and it is a REPLACEMENT rather than a growth:
# the paragraph above kept ebae42d for the spellcheck record alone, and that record has now moved,
# carrying the template's fix for a spell gate that counted lines it never read. The manifest's
# spellcheck block has the detail. The detector said both halves itself, before this list was
# touched: with the record moved and the declaration untouched, `shared-invariants` exited 1 with
# 2 findings - `records name source commit [fc7b0ae...], which is not in this script's
# DECLARED_COMMITS list` and `DECLARED_COMMITS names [ebae42d...] but no manifest record uses it
# any more`. Seven declarations across eight records, the count unchanged.
#
# THE SHA WAS READ, NOT TYPED: `git -C <template> rev-parse fc7b0ae` printed
# fc7b0aed461cdf05e51ef7f58a6043e7696d3f60. `git cat-file -t` on it exits 128 in THIS
# repository; it answers HTTP 200 on drupalcode's API commit route and on its raw file route,
# and a fabricated sha of the same length answers 404 on both.
#
# 2d651e6 REPLACED 6dc1d06 ON 2026-10-10, and it is a REPLACEMENT rather than a growth: 6dc1d06
# was named by the identity-strings record alone, and that record has now moved. The manifest's
# identity-strings block has the detail - 0 of 12 upstream lines taken, so only the record moved.
# The detector said both halves itself, before this list was touched: with the record moved and
# the declaration untouched, `shared-invariants` exited 1 with 2 findings - `records name source
# commit [2d651e6...], which is not in this script's DECLARED_COMMITS list` and `DECLARED_COMMITS
# names [6dc1d06...] but no manifest record uses it any more`. Seven declarations across eight
# records, the count unchanged.
#
# THE SHA WAS READ, NOT TYPED: `git -C <template> rev-parse 2d651e6` printed
# 2d651e659ced65286b5b4f5d569f82b276a2cc7e. `git cat-file -t` on it exits 128 in THIS
# repository; it answers HTTP 200 on drupalcode's public commit page and API commit route, and a
# fabricated sha of the same length answers 404 on that route.
DECLARED_COMMITS='935c133fbc2bfe6d10d8adff9d51b2c6a8fec35b
c8ce99f669e77533e62575216d1f810402f6bedb
1b4c1a44e0f77d09f6be7505a6e14cf8120af2c8
976b44d411fe5229e6efef5bc2af45c3df43310e
fc7b0aed461cdf05e51ef7f58a6043e7696d3f60
ac9b826971bb22d1ec131915108c115ee09a9eea
2d651e659ced65286b5b4f5d569f82b276a2cc7e'

# Files in tests/bin/ that were authored HERE and are correctly absent from the
# manifest. Adding a name here is a DECLARATION and is printed on every run.
#
# THE RULE THESE JUSTIFICATIONS FOLLOW, written down 2026-09-06 after one of
# them went false: an entry is justified by THIS repository's AUTHORSHIP - a
# commit anyone can open - and NEVER by an assertion about what the site
# template does or does not contain. Nothing here can read that repository, so
# such an assertion has no owner and rots without appearing in any diff. The
# executable-bit block below is the worked example of it going wrong.
#
# THREE ENTRIES STILL CARRY THE OLD SHAPE, AND ALL THREE ARE TRUE TODAY -
# measured against the site template's tests/bin/ on 2026-09-06, not assumed.
# contrast-check and no-colour-literals say there is no copy of them over
# there; that is CONTINGENT and can go false in one commit, exactly as
# executable-bit's did. component-metadata's is STRUCTURAL and does not rot:
# RequirementsTest allows the template zero *.info.yml, so it can never ship a
# component for a copy to check. The two contingent ones were left standing
# because they are true, and repairing a true sentence was not this change's
# job - they are a gap with an owner, named here rather than left to be found.
#
# contrast-check joined the list at T-608. It could not have taken a manifest
# RECORD instead: a record's six fields include source_sha256 and a 40-character
# source_commit, and there is no source - the script was written here, for a
# colour token file the site template does not have. Giving it a record would
# have meant inventing an agora_transparency commit for a file that repository
# has never held, which is the opposite of what the manifest is for. See the
# block about it in shared-invariants.manifest.
#
# no-colour-literals joined it at T-610, on the same argument: it was written
# here, it reads this theme's stylesheets against this theme's token file, and
# there is no copy of it in the site template to record a source commit for. It
# is DECLARED here and it is deliberately NOT yet executed by
# tests/bin/gate-a-theme.sh - that wiring is an edit to a file T-610 was told not
# to touch, and it is named in T-610's report rather than left implied. A
# declared invariant that nothing executes yet is a known gap with an owner; an
# undeclared file in this directory is the state D-028 replaced.
#
# component-metadata and its walker joined at T-1403, on the same argument a
# third time, and with the sharpest version of it: the site template ships no
# components at all - it cannot, RequirementsTest allows it zero *.info.yml -
# so there is not merely no copy of this script over there, there is nothing
# for a copy to check. Both files are listed because both are executable parts
# of the invariant; the walker is not a library the shell script happens to
# call, it is where every rule lives, and an undeclared file in this directory
# is the state D-028 replaced whether or not it has a shebang.
#
# preflight joined the list next, and it is the first entry here that is NOT an
# invariant at all: it runs the pipeline's LINT JOBS locally, against a rig, so
# it is closest in kind to spellcheck - which sits in the manifest as a
# `preflight` record because it was COPIED from the site template. This one was
# not: it was written HERE, in commit 43505f29. It reads this repository's own
# .gitlab-ci.yml, this repository's declared job list out of watch-gate, and a
# Drupal core installed in a local rig. Local-only, and declared here for the
# same reason as the four above it: an undeclared file in this directory has no
# drift detection at all.
#
# ITS JUSTIFICATION CARRIED THE SAME ROTTED CLAUSE as executable-bit's below -
# "there is nothing over there for it to be a copy of" - and that went false on
# 2026-09-06. Same repair and same reason, written out ONCE under executable-bit
# rather than twice here: a sentence kept in two places goes stale in one first.
#
# executable-bit joined with G10, and it is the only entry whose subject is this
# directory itself: it asserts that a tracked file beginning with a shebang is
# committed 100755, reading the mode from the git INDEX, because on the host
# this project is developed on that is the only place the bit is real.
#
# IT IS LOCAL-ONLY BECAUSE IT WAS WRITTEN HERE - commit 82201a6a - and not
# because of anything true of the site template. That distinction is this
# entry's whole history, and it is the reason the paragraph below exists.
#
# THE SENTENCE THAT STOOD HERE UNTIL 2026-09-06 SAID "the site template has no
# copy of it", AND IT WENT FALSE THE SAME DAY: agora_transparency's 434e289
# ported this script and preflight into its own tests/bin/. Nothing here could
# notice. This script's own summary line already disclaims that exact knowledge
# - it cannot see the other repository, by design, and D-028 assigns upstream
# drift to a review task in unit 006 - so the justification rested on a fact no
# mechanism here can read, inside the one file whose job is catching silent rot.
#
# THE CONCLUSION SURVIVED ITS OWN FALSE PREMISE, which is why this is a rewrite
# and not a reversal. "There is no source commit to record" follows from "it was
# written here" ALONE; the inventory clause was load-bearing for nothing and is
# deleted rather than corrected. The rule it leaves behind is the checkable one:
# a LOCAL_ONLY entry is justified by THIS repository's authorship, never by the
# other repository's contents. The first is a commit anyone can open; the second
# cannot be read from here and can go stale without ever showing up in a diff.
#
# AND THE COPY OVER THERE IS STILL NOT A MANIFEST CASE - ask that before
# reaching for a record. The manifest models ONE direction: source_commit is
# "the agora_transparency commit the copy was taken from". These two went the
# other way, authored here and ported there on 2026-09-06, so there is no commit
# this copy was taken from, and inventing one would be the move the manifest's
# header refuses outright ("a manifest that names a commit nobody opened").
# D-050 section 6 records that reversed direction as an OPEN D-028 question. A
# record here would not settle it; it would only make the manifest lie fluently.
#
# It is called by BOTH gate-a-theme.sh (as G10) and preflight, which is the
# point of it being a file at all: one rule, one implementation. Two copies
# would disagree eventually and would go on printing plausible numbers while
# they did.
#
# upstream-drift joined last, and it is the entry whose justification is the
# least contingent of all of them: it was written HERE, for THIS manifest, and
# its subject is the relationship between the two repositories rather than
# either one's contents. There is nothing over there for it to be a copy of
# because the site template has no manifest of copies taken from the theme -
# the manifest models ONE direction, and this script reads that direction over
# the drupalcode API.
#
# IT IS THE ONE FILE IN THIS DIRECTORY THAT NEEDS THE NETWORK, and that is why
# it is a separate file rather than a third mode of this one. Every invariant in
# both repositories is OFFLINE; that property is worth more than the check it
# would buy, because an invariant that needs the network can be defeated by
# unplugging something. So the network path is opt-in and CI is what opts in:
# gate-a-theme.sh runs it as G12, tolerating its NOT READ third state and
# failing on its findings. Nothing in THIS script reaches the network, and the
# `--upstream` mode below still answers the same question from a sibling
# checkout when one is present - two independent routes to one answer, which is
# how the API route was falsified on the day it was written: both printed the
# same 8 records, the same 8 verified and the same 0 behind.
LOCAL_ONLY='claims-match-readme
component-metadata
component-metadata.py
contrast-check
executable-bit
gate-a-theme.sh
no-colour-literals
preflight
shared-invariants
shared-invariants.manifest
upstream-drift'

FINDINGS=0

finding() { # <file> <field> <reason>
  FINDINGS=$((FINDINGS + 1))
  printf '  %s%s:%s%s  %s\n' "$C_BAD" "${1#./}" "$2" "$C_OFF" "$3"
}

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

fatal() { # <reason>
  printf '\n%sFATAL:%s %s\n' "$C_BAD" "$C_OFF" "$1"
  printf 'This run has NO result, which is not a clean result (I-029).\n'
  exit 1
}

printf '=========================================================================================================\n'
printf 'shared-invariants - agora_theme - D-028: the copies are what the manifest says they are (T-508)\n'
printf 'repo: %s\n' "$REPO_ROOT"
printf 'date: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '=========================================================================================================\n'

# ------------------------------------------------------- the sha256 hasher ---
# Three implementations, because the runners this must work on are not one
# machine: Git Bash on Windows, the drupalci Debian image, and whatever a
# contributor has. Resolved once, then PROVED against a known digest.
HASHER=''
if command -v sha256sum >/dev/null 2>&1; then
  HASHER='sha256sum'
elif command -v shasum >/dev/null 2>&1; then
  HASHER='shasum'
elif command -v openssl >/dev/null 2>&1; then
  HASHER='openssl'
else
  fatal 'no sha256 tool found (looked for sha256sum, shasum, openssl) - every assertion below is a hash comparison, so there is nothing to report.'
fi

sha256_of() { # <path> -> prints the lowercase hex digest, or nothing on failure
  case "$HASHER" in
    sha256sum) sha256sum "$1" 2>/dev/null | cut -d' ' -f1 ;;
    shasum)    shasum -a 256 "$1" 2>/dev/null | cut -d' ' -f1 ;;
    openssl)   openssl dgst -sha256 "$1" 2>/dev/null | sed 's/^.*= *//' ;;
  esac
}

# I-028 CANARY. A hasher that returns an empty string for everything would make
# every comparison below succeed against an empty manifest field and report a
# clean run over nothing at all - the degenerate case wearing the expected
# value's clothes. So the tool is made to hash a known input and produce a known
# answer BEFORE it is trusted with anything. 'agora' is five bytes and its
# digest is fixed for all time; this line can never need updating.
CANARY_FILE=$(mktemp -t shared-invariants.XXXXXX) || fatal 'mktemp -d failed - the hasher cannot be proved.'
trap 'rm -f "$CANARY_FILE"' EXIT INT TERM
printf 'agora' > "$CANARY_FILE" || fatal 'could not write the canary file.'
CANARY_EXPECT='f7070d57bbe5496e29249421e91572f46ac4c2b62953b7ea046fa3707b9e6b2a'
CANARY_GOT=$(sha256_of "$CANARY_FILE")
if [ "$CANARY_GOT" != "$CANARY_EXPECT" ]; then
  fatal "the sha256 tool ($HASHER) failed its canary: hashed the 5 bytes 'agora' and returned [$CANARY_GOT], expected [$CANARY_EXPECT]. A hasher that does not discriminate would report every comparison below as equal (I-028)."
fi
note "hasher: $HASHER (canary passed: sha256('agora') == ${CANARY_EXPECT%????????????????????????????????????????????????})"

# ------------------------------------------------------------ read manifest --
[ -r "$MANIFEST" ] || fatal "$MANIFEST is missing or unreadable - D-028's whole mechanism is this file plus this script; one without the other is decoration."

printf '\nmanifest\n'
note "path: $MANIFEST"
note "declared local-only (not expected in the manifest): $(printf '%s' "$LOCAL_ONLY" | tr '\n' ' ')"

# Records are whitespace-separated, six fields, `#` comments and blank lines
# skipped. Deliberately not a format needing a parser: the manifest must stay
# readable in a diff, because reviewing a regenerated manifest by eye is the
# control that D-028's option B leans on.
RECORDS=0
SEEN_SCRIPTS=''
SEEN_INVARIANTS=''
COMMITS=''
# The parsed records, kept so `--upstream` walks the SAME set this loop
# validated instead of re-parsing the manifest with a second reader. Two
# parsers of one file disagree eventually, and would go on printing plausible
# numbers while they did.
PARSED=''

printf '\nper-record verification\n'
printf '  %-20s %-10s %-9s %s\n' 'script' 'role' 'status' 'verdict'
printf '  %s\n' '-------------------------------------------------------------------------------------------------'

while IFS= read -r line || [ -n "$line" ]; do
  case "$line" in
    '#'* | '') continue ;;
  esac
  # shellcheck disable=SC2086
  set -- $line
  if [ "$#" -ne 6 ]; then
    finding "$MANIFEST" '<record>' "expected 6 whitespace-separated fields, found $# in: $line"
    continue
  fi
  R_SCRIPT=$1; R_ROLE=$2; R_STATUS=$3; R_LOCAL=$4; R_SOURCE=$5; R_COMMIT=$6
  RECORDS=$((RECORDS + 1))
  SEEN_SCRIPTS="$SEEN_SCRIPTS$R_SCRIPT
"
  if [ "$R_ROLE" = 'invariant' ]; then
    SEEN_INVARIANTS="$SEEN_INVARIANTS$R_SCRIPT
"
  fi
  COMMITS="$COMMITS$R_COMMIT
"
  PARSED="$PARSED$R_SCRIPT $R_STATUS $R_SOURCE $R_COMMIT
"

  R_VERDICT="${C_OK}ok${C_OFF}"
  R_BEFORE=$FINDINGS
  R_PATH="$BIN_DIR/$R_SCRIPT"

  # A declared file that is not on disk is FATAL, not a finding: the assertion
  # about it cannot be evaluated, and absent is not correct (the same rule
  # identity-strings applies to a declared identity file).
  [ -r "$R_PATH" ] || fatal "$MANIFEST declares $R_SCRIPT but $R_PATH is not readable - the drift check about it cannot be evaluated."

  # THE VOCABULARY GREW ON 2026-08-26, and the two edits are in one commit on purpose
  # (I-072: when a check keys on a set, adding a member IS a change to the check).
  # `helper` names a file copied from the site template that IS part of the gate but is
  # NOT one of D-028's five - it is a COMPONENT of a shared invariant rather than an
  # invariant itself. no-secrets-binary.py is the first: no-secrets calls it, so
  # `preflight` ("copied but NOT part of the gate") is false, and `invariant` is false
  # twice over because D-028 names exactly five and the count below enforces it.
  # Forcing either label would have been the honest-sounding move that makes a manifest
  # stop describing its files. Unknown roles still fail loudly.
  case "$R_ROLE" in
    invariant | preflight | helper) ;;
    *) finding "$MANIFEST" "$R_SCRIPT/role" "role is [$R_ROLE], expected invariant, preflight or helper" ;;
  esac

  case "$R_COMMIT" in
    ????????????????????????????????????????)
      case "$R_COMMIT" in
        *[!0-9a-f]*) finding "$MANIFEST" "$R_SCRIPT/source_commit" "[$R_COMMIT] is 40 characters but not lowercase hex - it is supposed to be an agora_transparency commit sha, read with git rev-parse, not typed" ;;
      esac
      ;;
    *)
      finding "$MANIFEST" "$R_SCRIPT/source_commit" "[$R_COMMIT] is not a 40-character sha - D-028 requires the source commit the copy was taken from, and an abbreviation stops being unambiguous the moment the other repository grows"
      ;;
  esac

  # ---- the drift check itself.
  ACTUAL=$(sha256_of "$R_PATH")
  if [ -z "$ACTUAL" ]; then
    fatal "$HASHER produced no digest for $R_PATH - a file that cannot be hashed is NOT a file that is unchanged."
  fi
  if [ "$ACTUAL" != "$R_LOCAL" ]; then
    finding "$R_PATH" '<sha256>' "LOCAL DRIFT: on disk [$ACTUAL], manifest says [$R_LOCAL]. Either this shared copy was edited here - which is what D-028 forbids and this check exists to catch - or the change is legitimate and the manifest was not regenerated with it. Never repair this by copying the new hash in without deciding which of the two it was."
  fi

  # ---- status must agree with the two hashes it is describing.
  case "$R_STATUS" in
    verbatim)
      if [ "$R_LOCAL" != "$R_SOURCE" ]; then
        finding "$MANIFEST" "$R_SCRIPT/status" "marked verbatim, but local_sha256 and source_sha256 differ - a verbatim copy is byte-identical to its source by definition, so one of the three fields is wrong"
      fi
      ;;
    adapted)
      if [ "$R_LOCAL" = "$R_SOURCE" ]; then
        finding "$MANIFEST" "$R_SCRIPT/status" "marked adapted, but local_sha256 and source_sha256 are equal - the adaptation has vanished, or was never recorded. An adapted script that is byte-identical to its source is a verbatim one wearing the wrong label, and the label is what tells a reviewer where to look."
      fi
      ;;
    *)
      finding "$MANIFEST" "$R_SCRIPT/status" "status is [$R_STATUS], expected verbatim or adapted"
      ;;
  esac

  [ "$FINDINGS" -ne "$R_BEFORE" ] && R_VERDICT="${C_BAD}FAIL${C_OFF}"
  printf '  %-20s %-10s %-9s %s\n' "$R_SCRIPT" "$R_ROLE" "$R_STATUS" "$R_VERDICT"
done < "$MANIFEST"

# Zero means FAIL here, so the `:-0` is the safe direction (I-031).
if [ "${RECORDS:-0}" -eq 0 ]; then
  fatal "$MANIFEST parsed to zero records - an empty manifest would pass every check below by construction (I-007)."
fi

# ------------------------------------------- the five D-028 names are present --
# EXPECT-PRESENT, not expect-zero (I-028): "the five shared invariants are
# recorded" has a degenerate value of `no`, never `yes`, so a manifest that lost
# a line cannot counterfeit a pass.
printf '\nD-028 named subset - all five recorded as invariants\n'
INVARIANT_COUNT=0
while IFS= read -r n; do
  [ -n "$n" ] || continue
  INVARIANT_COUNT=$((INVARIANT_COUNT + 1))
done <<EOF
$SEEN_INVARIANTS
EOF

while IFS= read -r want; do
  [ -n "$want" ] || continue
  if printf '%s\n' "$SEEN_INVARIANTS" | grep -qxF "$want"; then
    note "$want: recorded"
  else
    finding "$MANIFEST" "$want" 'named by D-028 as a shared invariant, but no record with role=invariant carries this name - either the copy was dropped or the manifest lost the line'
  fi
done <<EOF
$D028_SHARED
EOF

note "records with role=invariant: $INVARIANT_COUNT (D-028 names exactly 5)"
if [ "$INVARIANT_COUNT" -ne 5 ]; then
  finding "$MANIFEST" '<role=invariant>' "found $INVARIANT_COUNT records with role=invariant, expected exactly 5. D-028, as amended 2026-08-24, names five and forbids five others by name (sbom-check, no-code-in-template, no-boilerplate, no-ci-allow-dev, cited-tasks-exist) because they assert things that are FALSE about a theme. A SIXTH invariant appearing here needs a decision, not a manifest line."
fi

# ---------------------------------------------------------- the closed world --
printf '\nclosed world - every file in %s is declared\n' "$BIN_DIR"
SWEPT=0
while IFS= read -r p; do
  [ -n "$p" ] || continue
  b=${p##*/}
  SWEPT=$((SWEPT + 1))
  if printf '%s\n' "$SEEN_SCRIPTS" | grep -qxF "$b"; then
    continue
  fi
  if printf '%s\n' "$LOCAL_ONLY" | grep -qxF "$b"; then
    note "$b: declared local-only"
    continue
  fi
  finding "$p" '<undeclared>' "present in $BIN_DIR but recorded neither in $MANIFEST nor in this script's LOCAL_ONLY list. If it was copied from the site template it needs a manifest record with its source commit; if it was written here it needs declaring. An undeclared copy is a shared script with no drift detection at all, which is the state D-028 replaced."
done <<EOF
$(find "$BIN_DIR" -maxdepth 1 -type f 2>/dev/null | sed 's|^\./||' | sort)
EOF

if [ "${SWEPT:-0}" -eq 0 ]; then
  fatal "the sweep of $BIN_DIR listed 0 files - it contains at least this script, so the sweep itself has broken (I-028)."
fi

# --------------------------------------- the source commits are the declared --
# A manifest whose records name different source commits is not wrong in
# principle - copies can legitimately be taken at different times, and this one
# holds two because no-unstable-deps was copied after the other four and records
# the commit it was actually read at instead of inheriting theirs. What IS wrong
# is a set nobody declared: the usual cause of an unexpected commit appearing or
# disappearing is a re-sync that updated some copies and not others, which
# leaves the manifest telling the truth about each file and a lie about the set.
#
# So this is checked as a SET against DECLARED_COMMITS at the top of this script,
# not as a count. Replacing "exactly one" with "exactly two" would have been the
# lazy repair and it would have been strictly weaker: any two commits would then
# pass, including a half-finished re-sync that happens to land on two. Both
# directions are findings - an undeclared commit in the manifest, and a declared
# commit no record uses any more (which means the declaration went stale).
printf '\nsource commit\n'
DISTINCT=$(printf '%s\n' "$COMMITS" | grep -v '^$' | sort -u)
DISTINCT_N=$(printf '%s\n' "$DISTINCT" | wc -l | tr -d ' ')
DECLARED_N=0
while IFS= read -r c; do
  [ -n "$c" ] || continue
  DECLARED_N=$((DECLARED_N + 1))
done <<EOF
$DECLARED_COMMITS
EOF
note "distinct source commits across $RECORDS record(s): $DISTINCT_N (declared: $DECLARED_N)"

while IFS= read -r c; do
  [ -n "$c" ] || continue
  if printf '%s\n' "$DECLARED_COMMITS" | grep -qxF "$c"; then
    note "  $c: declared"
  else
    finding "$MANIFEST" '<source_commit>' "records name source commit [$c], which is not in this script's DECLARED_COMMITS list. Either a copy was re-synced without updating the declaration, or a commit sha was typed rather than read with git rev-parse. Do not add it to the list without establishing which."
  fi
done <<EOF
$DISTINCT
EOF

while IFS= read -r c; do
  [ -n "$c" ] || continue
  if printf '%s\n' "$DISTINCT" | grep -qxF "$c"; then
    continue
  fi
  finding "$MANIFEST" '<source_commit>' "DECLARED_COMMITS names [$c] but no manifest record uses it any more. The declaration has gone stale: either a re-sync moved every copy off that commit and the list was not trimmed, or a record was lost."
done <<EOF
$DECLARED_COMMITS
EOF

# ----------------------------------------------------------------- summary ---
# T-321(a): `wc -l` or a counting loop for a value that is printed or compared,
# never `grep -c`.
LOCAL_COUNT=0
while IFS= read -r n; do
  [ -n "$n" ] || continue
  LOCAL_COUNT=$((LOCAL_COUNT + 1))
done <<EOF
$LOCAL_ONLY
EOF

printf '\n=========================================================================================================\n'
printf 'scope:    %s (%s file(s) swept) + %s\n' "$BIN_DIR" "$SWEPT" "$MANIFEST"
printf 'scanned:  %s records - %s invariant, %s local-only declared\n' "$RECORDS" "$INVARIANT_COUNT" "$LOCAL_COUNT"
# THE HALF THIS RUN CANNOT SEE, NAMED ON EVERY RUN. Until T-0622 the disclaimer
# lived only on the clean path, in a parenthesis, and it pointed at "a dated
# review task" rather than at a command - so a reader who wanted to act on it
# had nowhere to go. It is now a summary field, so it survives a findings run
# too, and gate-a-theme.sh's G6 asserts it is here: this line is the only thing
# that tells anybody the other mode exists, and an undefended pointer is how a
# mode gets written once and never run again.
if [ "$UPSTREAM" -eq 1 ]; then
  printf 'upstream: CHECKED - see the section below\n'
else
  printf 'upstream: NOT CHECKED - run `tests/bin/shared-invariants --upstream` beside a site template checkout. This run compared the copies against the MANIFEST, never against agora_transparency, and those are different questions (D-063)\n'
fi
printf 'findings: %s\n' "$FINDINGS"
printf '=========================================================================================================\n'

if [ "$FINDINGS" -eq 0 ]; then
  printf '%s%s%s\n' "$C_OK" 'shared-invariants: clean - the copies are what the manifest says they are' "$C_OFF"
else
  printf '%s%s%s%s%s\n' "$C_BAD" 'shared-invariants: ' "$FINDINGS" ' finding(s)' "$C_OFF"
fi

if [ "$UPSTREAM" -eq 0 ]; then
  [ "$FINDINGS" -eq 0 ] && exit 0
  exit 1
fi

# ================================================================= upstream ===
# T-0622, D-063. Everything below runs ONLY under --upstream. The default
# exit path above is untouched on purpose: gate-a-theme.sh parses the summary
# block, and a mode that quietly changed what the gate reads would be a worse
# defect than the one this section fixes.
U_FINDINGS=0
u_finding() { # <script> <field> <reason>
  U_FINDINGS=$((U_FINDINGS + 1))
  printf '  %s%s:%s%s  %s\n' "$C_BAD" "$1" "$2" "$C_OFF" "$3"
}

not_read() { # <reason...> - the third state. Prints, sets the flag, returns.
  printf '\n  %sNOT READ%s - %s\n' "$C_BAD" "$C_OFF" "$1"
  shift
  while [ "$#" -gt 0 ]; do printf '             %s\n' "$1"; shift; done
  printf '             %sThat is not "the copies are up to date"; it is "nothing here\n' "$C_BAD"
  printf '             can see the site template". A third state, not a pass.%s\n' "$C_OFF"
}

printf '\n=========================================================================================================\n'
printf 'upstream drift - the copies against agora_transparency (T-0622, D-063)\n'
printf '=========================================================================================================\n'

# ---- locate the sibling, then PROVE it.
if ! command -v git >/dev/null 2>&1; then
  not_read 'git is not on PATH, so no commit in this manifest can be resolved.'
  exit 2
fi

# THIS repository's root AS GIT PRINTS IT, which is not what $REPO_ROOT holds.
# On the host this project is developed on, `pwd` gives /c/Users/... and
# `git rev-parse --show-toplevel` gives C:/Users/... for the same directory, so
# comparing the two strings never matches and the "a tree cannot be its own
# upstream" guard below would be DEAD CODE on the only machine that can run it.
# Measured, not assumed: `--template .` was watched falling through to the
# commit test instead of that branch. Both sides now come from the same command.
SELF_TOP=$(git rev-parse --show-toplevel 2>/dev/null)
[ -n "$SELF_TOP" ] || SELF_TOP="$REPO_ROOT"
PARENT=$(dirname "$REPO_ROOT")
CANDIDATES=''
[ -n "$TEMPLATE_ARG" ] && CANDIDATES="$TEMPLATE_ARG"
if [ -z "$CANDIDATES" ] && [ -n "${AGORA_TEMPLATE:-}" ]; then
  CANDIDATES="${AGORA_TEMPLATE}"
fi
if [ -z "$CANDIDATES" ]; then
  CANDIDATES="$PARENT/agora
$PARENT/agora_transparency"
fi
note "candidate(s) searched: $(printf '%s' "$CANDIDATES" | tr '\n' ' ')"

TEMPLATE=''
TRIED=0
REJECTED=''
while IFS= read -r candidate; do
  [ -n "$candidate" ] || continue
  TRIED=$((TRIED + 1))
  if [ ! -d "$candidate" ]; then
    REJECTED="$REJECTED
    $candidate: no such directory"
    continue
  fi
  CANDIDATE_TOP=$(git -C "$candidate" rev-parse --show-toplevel 2>/dev/null)
  if [ -z "$CANDIDATE_TOP" ]; then
    REJECTED="$REJECTED
    $candidate: not a git working tree"
    continue
  fi
  if [ "$CANDIDATE_TOP" = "$SELF_TOP" ]; then
    REJECTED="$REJECTED
    $candidate: resolves to THIS repository - a tree cannot be its own upstream"
    continue
  fi
  # THE PROOF, and it is the manifest's own: a commit sha is the identity.
  # `git cat-file -t <sha>` failing here is what makes the sha the site
  # template's rather than something read back out of this working directory.
  MISSING_C=''
  HAVE_C=0
  while IFS= read -r c; do
    [ -n "$c" ] || continue
    if [ "$(git -C "$candidate" cat-file -t "$c" 2>/dev/null)" = 'commit' ]; then
      HAVE_C=$((HAVE_C + 1))
    else
      MISSING_C="$MISSING_C $c"
    fi
  done <<EOF
$DECLARED_COMMITS
EOF
  if [ -n "$MISSING_C" ]; then
    REJECTED="$REJECTED
    $CANDIDATE_TOP: a git tree, but $HAVE_C of $DECLARED_N declared commit(s) resolve in it (missing:$MISSING_C)"
    continue
  fi
  TEMPLATE="$CANDIDATE_TOP"
  note "accepted: $TEMPLATE  ($HAVE_C of $DECLARED_N declared commits resolve there; 0 resolve here)"
  break
done <<EOF
$CANDIDATES
EOF

if [ -z "$TEMPLATE" ]; then
  not_read \
    "no site template checkout was found. $TRIED candidate(s) tried:$REJECTED" \
    '' \
    'Point at it with `--template <path>` or $AGORA_TEMPLATE, or clone it beside' \
    'this repository as ../agora. A candidate is accepted only when every commit' \
    'in DECLARED_COMMITS resolves in it, which is what distinguishes the site' \
    'template from any other git tree that happens to sit at that path.'
  exit 2
fi

T_HEAD=$(git -C "$TEMPLATE" rev-parse HEAD 2>/dev/null)
if [ -z "$T_HEAD" ]; then
  not_read "$TEMPLATE has no resolvable HEAD, so there is nothing to measure the recorded commits against."
  exit 2
fi
T_HEAD_DATE=$(git -C "$TEMPLATE" log -1 --format=%cI "$T_HEAD" 2>/dev/null)
T_DIRTY=$(git -C "$TEMPLATE" status --porcelain 2>/dev/null | head -1)
note "HEAD: $T_HEAD ($T_HEAD_DATE)"
if [ -n "$T_DIRTY" ]; then
  note 'NOTE: that working tree has uncommitted changes. Every delta below is measured against its HEAD COMMIT, never against its working tree - an uncommitted edit over there is invisible here, deliberately, because a commit is the only thing this manifest can record.'
fi

NOW_EPOCH=$(date -u '+%s' 2>/dev/null)

printf '\n  %-22s %-9s %-6s %-11s %s\n' 'script' 'recorded' 'age' 'provenance' 'delta recorded -> HEAD'
printf '  %s\n' '-------------------------------------------------------------------------------------------------'

U_READ=0
U_UNREAD=0
U_VERIFIED=0
U_BEHIND=0
U_LARGEST_ADD=0
U_UNREADABLE=''

while IFS= read -r rec; do
  [ -n "$rec" ] || continue
  # shellcheck disable=SC2086
  set -- $rec
  P_SCRIPT=$1; P_STATUS=$2; P_SOURCE=$3; P_COMMIT=$4
  P_PATH="$BIN_DIR/$P_SCRIPT"
  P_SHORT=$(printf '%s' "$P_COMMIT" | cut -c1-8)

  if [ "$(git -C "$TEMPLATE" cat-file -t "$P_COMMIT" 2>/dev/null)" != 'commit' ]; then
    U_UNREAD=$((U_UNREAD + 1))
    U_UNREADABLE="$U_UNREADABLE $P_SCRIPT"
    printf '  %-22s %-9s %-6s %-11s %s\n' "$P_SCRIPT" "$P_SHORT" '-' 'NOT READ' 'the recorded commit does not resolve over there'
    continue
  fi

  # Does the path exist at that commit? A record naming a path the source
  # never had is a provenance defect, not a delta.
  if [ "$(git -C "$TEMPLATE" cat-file -t "$P_COMMIT:$P_PATH" 2>/dev/null)" != 'blob' ]; then
    U_UNREAD=$((U_UNREAD + 1))
    U_UNREADABLE="$U_UNREADABLE $P_SCRIPT"
    printf '  %-22s %-9s %-6s %-11s %s\n' "$P_SCRIPT" "$P_SHORT" '-' 'NOT READ' "$P_PATH does not exist at that commit"
    u_finding "$MANIFEST" "$P_SCRIPT/source_commit" "$P_PATH is not a blob at [$P_COMMIT] in $TEMPLATE - the record names a commit at which this file did not exist, so source_sha256 cannot have been read from it."
    continue
  fi
  U_READ=$((U_READ + 1))

  # ---- 1. RE-DERIVE source_sha256 rather than trust the field.
  SRC_TMP=$(mktemp -t shared-invariants-src.XXXXXX) || fatal 'mktemp failed - source_sha256 cannot be re-derived, and an unverified provenance is not a verified one.'
  git -C "$TEMPLATE" cat-file -p "$P_COMMIT:$P_PATH" > "$SRC_TMP" 2>/dev/null
  SRC_HASH=$(sha256_of "$SRC_TMP")
  rm -f "$SRC_TMP"
  if [ -z "$SRC_HASH" ]; then
    PROV="${C_BAD}NO-DIGEST${C_OFF}"
    u_finding "$MANIFEST" "$P_SCRIPT/source_sha256" "$HASHER produced no digest for the blob at [$P_COMMIT] - a file that cannot be hashed is NOT a file that matches."
  elif [ "$SRC_HASH" = "$P_SOURCE" ]; then
    PROV="${C_OK}verified${C_OFF}"
    U_VERIFIED=$((U_VERIFIED + 1))
  else
    PROV="${C_BAD}MISMATCH${C_OFF}"
    u_finding "$MANIFEST" "$P_SCRIPT/source_sha256" "the blob at [$P_COMMIT] hashes to [$SRC_HASH], the manifest records [$P_SOURCE]. The recorded provenance is fiction: either the sha was typed rather than read, or the commit field was moved without re-deriving the hash beside it."
  fi

  # ---- 2. THE DELTA. Data, never a verdict.
  DELTA_RAW=$(git -C "$TEMPLATE" diff --numstat "$P_COMMIT" "$T_HEAD" -- "$P_PATH" 2>/dev/null)
  if [ -z "$DELTA_RAW" ]; then
    ADDED=0; DELETED=0
  else
    ADDED=$(printf '%s\n' "$DELTA_RAW" | awk 'NR==1{print $1}')
    DELETED=$(printf '%s\n' "$DELTA_RAW" | awk 'NR==1{print $2}')
  fi
  # A binary path reports `-` for both. Never silently read that as zero.
  case "$ADDED$DELETED" in
    *-*) ADDED='bin'; DELETED='bin' ;;
  esac

  COMMIT_COUNT=$(git -C "$TEMPLATE" rev-list --count "$P_COMMIT..$T_HEAD" -- "$P_PATH" 2>/dev/null)
  [ -n "$COMMIT_COUNT" ] || COMMIT_COUNT='?'

  C_EPOCH=$(git -C "$TEMPLATE" log -1 --format=%ct "$P_COMMIT" 2>/dev/null)
  if [ -n "$C_EPOCH" ] && [ -n "$NOW_EPOCH" ]; then
    AGE="$(( (NOW_EPOCH - C_EPOCH) / 86400 ))d"
  else
    AGE='?'
  fi

  if [ "$ADDED" = 'bin' ]; then
    DELTA='binary path - a line delta is not defined for it'
    U_BEHIND=$((U_BEHIND + 1))
  elif [ "${ADDED:-0}" -eq 0 ] && [ "${DELETED:-0}" -eq 0 ]; then
    DELTA="${C_OK}+0/-0${C_OFF}   unchanged at HEAD"
  else
    DELTA="+$ADDED/-$DELETED   across $COMMIT_COUNT commit(s) - a number to READ, not a failure"
    U_BEHIND=$((U_BEHIND + 1))
    [ "${ADDED:-0}" -gt "${U_LARGEST_ADD:-0}" ] 2>/dev/null && U_LARGEST_ADD=$ADDED
  fi

  printf '  %-22s %-9s %-6s %-11s %s\n' "$P_SCRIPT" "$P_SHORT" "$AGE" "$PROV" "$DELTA"
done <<EOF
$PARSED
EOF

# Zero means FAIL here - a walk that read nothing is not a walk that found
# nothing (I-007) - so the `:-0` is the safe direction.
if [ "${U_READ:-0}" -eq 0 ] && [ "${U_UNREAD:-0}" -eq 0 ]; then
  not_read 'the record walk considered 0 records, so nothing above was measured.'
  exit 2
fi

printf '\n---------------------------------------------------------------------------------------------------------\n'
printf 'upstream: %s at %s\n' "$T_HEAD" "$TEMPLATE"
printf 'read:     %s of %s record(s) (%s NOT READ%s)\n' \
  "$U_READ" "$RECORDS" "$U_UNREAD" "$(if [ -n "$U_UNREADABLE" ]; then printf ':%s' "$U_UNREADABLE"; fi)"
printf 'verified: %s source_sha256 re-derived from the recorded commit and matching\n' "$U_VERIFIED"
printf 'behind:   %s record(s) whose source file changed since the recorded commit (largest +%s)\n' \
  "$U_BEHIND" "$U_LARGEST_ADD"
printf 'findings: %s  (a DELTA is never a finding - only a provenance that could not be verified)\n' "$U_FINDINGS"
# `printf '---...'` is NOT the same as `printf '===...'`: bash's builtin parses a
# leading `--` as an option terminator and prints `printf: --: invalid option`
# to STDERR while the rest of the run looks perfectly healthy. Caught on this
# mode's FIRST run, which is the argument for running a new check before
# believing it. The rule is the format string never starts with a dash.
printf '%s\n' '---------------------------------------------------------------------------------------------------------'

if [ "$U_UNREAD" -ne 0 ]; then
  printf '%s%s%s\n' "$C_BAD" "upstream: $U_UNREAD record(s) NOT READ - this run has no answer for them, which is not a clean answer for them" "$C_OFF"
  exit 2
fi
if [ "$U_FINDINGS" -ne 0 ] || [ "$FINDINGS" -ne 0 ]; then
  exit 1
fi
if [ "$U_BEHIND" -ne 0 ]; then
  printf '%s%s%s\n' "$C_DIM" "upstream: every record read and verified; $U_BEHIND of $RECORDS sit behind HEAD. Read the deltas - a copy that SHOULD stay behind is the normal case, and the manifest's own block for each record is where that argument belongs." "$C_OFF"
  exit 0
fi
printf '%s%s%s\n' "$C_OK" 'upstream: every record read, every provenance verified, no source file changed since its recorded commit' "$C_OFF"
exit 0
