#!/usr/bin/env bash
#
# identity-strings - Agora - agora_theme's copy, taken from the site template
# under D-028 option B and ADAPTED. Its subject is THIS THEME's identity, not
# the site template's; tests/bin/shared-invariants.manifest records it as
# `adapted` precisely so nobody reads it as a verbatim copy.
#
# THE IDENTITY IS MEASURED, NOT ASSUMED. Every value in the source-of-truth
# block below was read from www.drupal.org/api-d7/node/3618791.json and is
# recorded in the site template's specs/000-project/DECISIONS.md, in the note
# closing D-014 rider (c): node 3618791 - title `Ágora Transparency Theme` -
# field_project_machine_name `agora_theme` - package `drupal/agora_theme`.
# The `Theme` suffix is NOT cosmetic: `Ágora Transparency` is already taken by
# the site template project and drupal.org refused it. Nobody may tidy it away.
#
# What it enforces: D-021, "a string whose job is to name the product to
# someone who has no other context carries the full name `Ágora Transparency`;
# a string that refers back to a product already named on the same surface may
# use `Ágora`."
#
# Why a guard and not a deny term (D-021, and this is the load-bearing part):
# `Ágora` is a LEGITIMATE substring of the correct value and of ~33 prose
# occurrences, so a deny term would report ~35 findings on a clean tree and the
# only route to green would be deleting the product's name from its own
# documentation. Decisively: a deny term is an EXPECT-ZERO assertion, and I-028
# says to prefer an expected value that a failure cannot counterfeit. "The
# identity strings are correct" is naturally EXPECT-PRESENT, whose degenerate
# value is `no` and never `yes`.
#
# SIX assertions. One to five mirror the site template's with the subject
# changed; six exists only here.
#
# ⚠️ THAT SECOND CLAUSE WAS TRUE WHEN IT WAS WRITTEN AND UPSTREAM DRIFT MADE IT
# MISLEADING - corrected at T-0621, 2026-09-21, and it is the only line of this
# file the 28-day re-sync changed. The site template's copy grew a sixth
# assertion of its own on 2026-08-26 - "recipe.yml keeps its structure" - and
# guarded it on the file existing precisely so that THIS copy would not be
# failed for having no recipe.yml. So there are now two sixths and they are
# DIFFERENT assertions. "Six exists only here" is still true of the one below:
# the template has no *.info.yml assertion and, allowing itself zero of them,
# cannot have one. But the sentence READS as "the template stops at five", and
# that stopped being true a month ago with nothing here able to notice.
#
# THE TEMPLATE'S SIXTH IS DELIBERATELY NOT TAKEN, and that is a ruling rather
# than an oversight: a theme has no recipe.yml ever, so the guarded branch is
# the only one that could run here, forever, and a check that cannot fire is
# not coverage. The hunk-by-hunk classification behind it - 125 lines absent
# from this copy, 0 of them belonging in it - is recorded beside this file's
# record in tests/bin/shared-invariants.manifest, so the next reader inherits
# the reasoning instead of re-deriving it. Re-measure any time with
# `tests/bin/shared-invariants --upstream`.
#   1. $INFO_FILE has exactly ONE `^name:` line, and its value is exactly
#      $FULL_NAME. On the Appearance page, and on the theme's Drupal.org
#      project page, that string IS the entire user interface for "what is
#      this". It is compared as bytes and never by eye, because D-021 exists
#      after this class of thing was got wrong once.
#   2. composer.json `.name` == $PACKAGE.
#   3. the two `description` fields - $INFO_FILE and composer.json - are
#      BYTE-IDENTICAL to each other. Keeping them in step is the only thing
#      stopping the two surfaces from describing two different products.
#   4. for each identity file, the FIRST mention of `Ágora` is followed by
#      ` Transparency`, i.e. it is never a bare `Ágora`. Note what this does
#      NOT assert, and why. It does not demand ` Transparency Theme`, because
#      several first mentions here legitimately name the SITE TEMPLATE -
#      "front-end theme for the Ágora Transparency site template" - and forcing
#      `Theme` onto those would make the prose claim that the theme IS the
#      template. $FAMILY_NAME, not $FULL_NAME, is the correct subject of this
#      rule; assertion 1 is where $FULL_NAME is pinned exactly.
#   5. CLOSED WORLD: every packaged file that names the product must be
#      declared, either as an identity file or as prose-only. An UNDECLARED
#      new packaged file naming the product is a FINDING. This is the half
#      that matters most: I-024 - "inherited boilerplate outlives the task
#      that was supposed to clean it up whenever no task owns the WHOLE file"
#      - made impossible rather than remembered.
#   6. THEME-ONLY, and the deliberate mirror image of the site template's
#      no-code-in-template: that invariant requires ZERO `*.info.yml` files,
#      which is TRUE of a recipe package and FALSE of a theme, which is exactly
#      why D-028 forbids copying it. What is true here is the positive form -
#      EXACTLY ONE `*.info.yml` at the repository root, named
#      `$MACHINE_NAME.info.yml`. Asserting that is not the forbidden
#      adjustment; it is the assertion the forbidden one would have had to be
#      mangled into, written from scratch instead. The machine name is
#      identity: it is the extension key in system.theme, the Composer package
#      suffix and the Drupal.org project name, and it is MEASURED
#      (field_project_machine_name, node 3618791), not chosen here.
#
# WHY ASSERTION 4 NORMALISES BEFORE MATCHING. A naive "the first line
# containing `Ágora` also contains `Ágora Transparency`" is wrong on this tree
# TODAY, and not because the tree is wrong: AGENTS.md's first mention is a
# Markdown blockquote whose full name WRAPS across the newline, so `Ágora` ends
# line 5 and `Transparency` begins line 6 behind a `> ` marker. A line-oriented
# check would report a finding against correct prose, and the tempting repair -
# a hand-placed line break - is the rot the rule exists to avoid. So each file
# is read as ONE stream with line-leading `>` and `#` markers dropped and all
# runs of whitespace collapsed to a single space; the assertion is then that
# the text FOLLOWING the first `Ágora` begins with ` Transparency`.
#
# An identity file containing NO accented mention at all is NOT a finding -
# composer.json is exactly that case, and it is covered by assertion 2 instead.
# But if NO identity file has one, the accent match itself has broken and every
# assertion 4 has silently stopped discriminating: that is FATAL (I-028).
#
# SCOPE, in two parts, mirroring no-code-in-template:
#   (1) AUTHORITATIVE: the packaged tree, literally `git archive HEAD`, which
#       is what Drupal.org ships.
#   (2) EARLY WARNING: untracked, non-ignored working-tree files that are not
#       under an `export-ignore`d root, so a new undeclared file naming the
#       product is caught BEFORE it is committed rather than after (I-030).
# Part 2 reads the RULE in .gitattributes rather than its effect, which is
# weaker than part 1 on purpose and is never the only witness; part 1 reads the
# effect. CONTENT is read from the working tree wherever the path exists there,
# so an uncommitted edit is seen (I-018).
#
# Contract (shared by every invariant in tests/bin/):
#   scope:    the exact set of files inspected
#   scanned:  N files, and N must be > 0 or this script fails
#   findings: M, each printed as  path:line  reason
#   exit 0 if and only if M == 0
#
# FATAL, not an ordinary finding, and printing NO summary line (I-029): a
# declared identity file missing from the working tree; `git archive HEAD`
# producing nothing; zero identity files declared; zero packaged files naming
# the product; zero identity files carrying an accented mention; any `grep`
# exiting >= 2. The closing metrics line is a claim about a scan that happened.
#
# 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`. The CANONICAL copy of the block,
# with the reasoning and the I-031 asymmetry spelled out, lives in the header of
# tests/bin/no-ci-allow-dev. This script was born under those rules rather than
# swept into them: there is no `grep -c` anywhere below.
#
# Usage: tests/bin/identity-strings   (run from anywhere; it cd's to the repo root)

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; }

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

# ------------------------------------------------- single source of truth ----
# Every assertion below reads these six and nothing else. Changing the
# product's name is one edit here plus the files it polices - never a hunt.
# FULL_NAME is the exact project title and the exact `name:` key. FAMILY_NAME
# is what a first mention must carry (assertion 4) and is a strict PREFIX of
# FULL_NAME - which is why the two are separate values and not one.
FULL_NAME='Ágora Transparency Theme'
FAMILY_NAME='Ágora Transparency'
SHORT_NAME='Ágora'
PACKAGE='drupal/agora_theme'
MACHINE_NAME='agora_theme'
INFO_FILE='agora_theme.info.yml'

# The ERE that decides whether a file NAMES the product, for the closed world.
# The bare form without the accent is included deliberately: a packaged file
# that spells the name wrong is still a file naming the product, and the closed
# world should surface it rather than let it pass for something unrelated.
PRODUCT_ERE='Ágora|Agora|agora_theme|agora_transparency'

# Identity files: read by someone with no other context on the same surface.
# THREE here, not the site template's five, and the difference is structural
# rather than an omission: `recipe.yml` and `recommended.yml` are recipe-package
# artefacts that a theme does not have, and `AGENTS.md` is scaffolded into an
# end user's site root by the recipe's drupal-scaffold.file-mapping, which a
# theme does not do either. `agora_theme.info.yml` takes recipe.yml's place: it
# is the file Drupal reads in order to put a name on the Appearance page.
IDENTITY_FILES='agora_theme.info.yml
composer.json
README.md'

# Prose-only: packaged files that legitimately mention the product below a
# surface that has already named it, or in a comment addressed to a maintainer
# rather than to an evaluator. Adding a file here is a DECLARATION, and it is
# printed on every run so it cannot be made quietly.
#
# Every tests/bin script is listed one by one rather than swept up by a
# `tests/bin/*` glob, and that IS the closed world doing its job: a glob would
# silently adopt the next script somebody copies in from the site template,
# which is precisely the drift tests/bin/shared-invariants exists to catch.
# `tests` is NOT export-ignored in this repository (see .gitattributes, which
# says why), so these files really are packaged and really are in scope.
#
# SIX FILES WERE ADDED TO THIS LIST BY T-1009 AND T-1010, and "prose-only" is a
# loose label for four of them, so it is worth saying what they are rather than
# letting the bucket name imply something untrue. The four
# `config/optional/block.block.agora_theme_*.yml` files are CONFIG, not prose.
# They name the product because a block placement's `theme:` key IS the machine
# name and its `id:` is built from it; there is no way to write them that does
# not. The bucket that already holds the fixture module's view config is the
# right one for them: a declared file whose product name is structural rather
# than editorial. `templates/pager.html.twig` is here because its header now
# cites one of those four by path - T-1010's heading rule depends on T-1009's
# <h1> and says so - which is the first time any template in this repository has
# named the product at all. `agora_theme_test.links.menu.yml` names it the way
# every other file in that module does, in its own module prefix.
# FIVE MORE FILES WERE ADDED BY THE VISUAL DESIGN PASS, and only one of them is
# prose. Four are the block placements that put a masthead, a footer statement
# and a footer menu and a colophon on the page; like the four before them they name the product
# because a block placement's `theme:` key IS the machine name and its `id:` is
# built from it, so there is no way to write them that does not. The fourth is
# `logo.svg`, and it is the first non-text file in this list: it names the
# product in its <title> element, which is the mark's ACCESSIBLE NAME - what a
# screen reader reads out where a sighted reader sees the portico. That is
# structural in the strongest sense available to an image, so it belongs in the
# same bucket as the config rather than in the identity list, which is for files
# an evaluator reads on their own.
# ONE MORE WAS ADDED WITH THE THEME'S FIRST PREPROCESS: `agora_theme.theme`.
# Its naming is structural twice over - a hook implementation MUST be prefixed
# with the machine name or Drupal never calls it, and its @file line names the
# theme the way every stylesheet header here does. Structural naming is this
# bucket's admission test, so it lands here rather than in the identity list.
# ONE MORE ARRIVED WITH THE BREADCRUMB TRAIL:
# `config/optional/block.block.agora_theme_breadcrumb.yml`, the ninth block
# placement. It is admitted on exactly the same ground as the eight before it -
# a block placement's `theme:` key IS the machine name and its `id:` is built
# from it - and it is the ONLY file of that change which needed declaring.
# `templates/breadcrumb.html.twig` does not, and the reason is worth stating so
# that nobody adds it "for symmetry": that template names no product anywhere.
# Its classes are `agora-breadcrumb`, hyphenated, which the product matcher does
# not match, and it deliberately cites the config file by DESCRIPTION rather
# than by path, since the path itself contains the machine name and quoting it
# would have pulled a template into this list for the sake of one sentence.
# FIVE MORE ARRIVED 2026-09-05 WITH THE THEME SETTINGS, and every one of them
# names the product because Drupal or this repository's own layout leaves it no
# choice. That is the admission test and all five meet it structurally, not
# editorially:
#   * `theme-settings.php` - the settings form is reached through
#     hook_form_FORM_ID_alter(), and a hook implementation MUST be prefixed with
#     the machine name or Drupal never calls it. Same ground as agora_theme.theme.
#   * `config/schema/agora_theme.schema.yml` and
#     `config/install/agora_theme.settings.yml` - a theme's settings object IS
#     `<machine name>.settings`, in the filename and in the top-level key.
#   * `logo-inverse.svg` - the second non-text file here, admitted on exactly the
#     ground logo.svg was: the product's name is the mark's ACCESSIBLE NAME, in
#     its <title> element.
#   * `tests/src/Functional/ThemeSettingsTest.php` - the namespace
#     `Drupal\Tests\agora_theme\Functional` is fixed by Drupal's test discovery.
# TWO FILES OF THAT SAME CHANGE ARE DELIBERATELY ABSENT, and the reason is the
# one the breadcrumb note above records: css/components.css and
# templates/block--system-branding-block.html.twig each named a PHP function by
# its full prefixed name in a comment. Neither has any structural need to name
# the product, so both mentions were REWORDED away - "the theme's block
# preprocess" - rather than declared. A stylesheet and a template do not belong
# in this bucket for the sake of one sentence apiece.
# ONE MORE ARRIVED WITH THE VIEWS EMPTY-REGION FIX:
# `tests/src/Functional/ViewsEmptyRegionTest.php`, admitted on exactly the
# ground ThemeSettingsTest.php was - the namespace
# `Drupal\Tests\agora_theme\Functional` is fixed by Drupal's test discovery and
# there is no way to write a test class that does not name the theme.
# ⚠️ IT WAS ADDED RATHER THAN FOLDED INTO ThemeSettingsTest, which is the
# cheaper option that file's own header describes, and the cost is named here
# because it is not free: declaring a file edits this script, this script is
# hash-pinned by tests/bin/shared-invariants.manifest, and the next run reports
# LOCAL DRIFT until that record is regenerated. That is the mechanism working.
# The reason it was paid: ThemeSettingsTest already covers two subjects and
# says so, its module list would have had to grow `views` and `node` for every
# test in it, and a class about a view's empty region living in a file named
# for the settings page is the kind of thing nobody finds twice.
# MEASURED BEFORE IT WAS DECLARED, not after: with the file present and this
# line absent, assertion 5 printed exactly `findings: 1` naming that path and
# the remedy.
# ONE MORE ARRIVED WITH THE EIGHTH SCANNED PAGE:
# `tests/modules/agora_theme_test/config/install/views.view.agora_theme_test_filtered.yml`,
# a second fixture view - a register carrying an exposed filter, so that the
# exposed form's action row and the message the theme substitutes when a filter
# emptied a register are on a page the accessibility gate can reach. It is
# admitted on exactly the ground the FIRST fixture view was: a view's config
# object is named `views.view.<id>`, in the filename and in the `id:` key, and
# the id has to carry the fixture module's prefix or the view does not belong
# to it. Structural naming is this bucket's admission test.
# ⚠️ THE CHEAPER OPTION WAS AVAILABLE AND WAS REFUSED, and it is the same shape
# as the refusal recorded three paragraphs up: exposing a filter on the view
# that is ALREADY declared -
# views.view.agora_theme_test_register.yml - would have cost nothing here. It
# was refused because that view is what /agora-theme-test/views-table measures,
# and an exposed form on it changes what seven existing pages are a measurement
# of. A declaration is cheaper than a fixture whose subject has quietly moved.
# ONE MORE ARRIVED WITH THE BAND GUARD: `tests/src/Nightwatch/Tests/bands.js`,
# the second Nightwatch file, which measures what the front page PAINTS rather
# than what it contains - T-1407. It is admitted on exactly the ground axe.js
# was, and it has no way to avoid it: the nightwatch job collects its files by
# `--tag=agora_theme`, so a file whose tags did not carry the machine name would
# never run, and its summary line opens with the machine name the way the axe
# gate's does. MEASURED BEFORE IT WAS DECLARED: with the file present and this
# line absent, assertion 5 printed exactly `findings: 1` naming that path and
# the remedy.
# THE THIRD NIGHTWATCH FILE, `tests/src/Nightwatch/Tests/logo.js` (T-0721),
# is admitted on the same ground as the other two and for the same reason:
# `'@tags': [THEME]` carries the machine name, or the nightwatch job never
# collects the file at all. MEASURED BEFORE IT WAS DECLARED: with the file
# present and this line absent, assertion 5 printed exactly `findings: 1`
# naming that path and the remedy.
# `tests/modules/agora_theme_test/agora_theme_test.module` (T-0740) is admitted
# on the ground the fixture module's .install was: a procedural hook is named
# `<module>_<hook>`, so its one function cannot avoid the module's prefix. It
# draws the links Gin Login puts under the account forms. MEASURED BEFORE IT
# WAS DECLARED: with the file present and this line absent, assertion 5
# printed exactly `findings: 1` naming that path and the remedy.
PROSE_ONLY='.gitattributes
.gitignore
agora_theme.libraries.yml
agora_theme.theme
config/install/agora_theme.settings.yml
config/optional/block.block.agora_theme_branding.yml
config/optional/block.block.agora_theme_breadcrumb.yml
config/optional/block.block.agora_theme_content.yml
config/optional/block.block.agora_theme_footer_branding.yml
config/optional/block.block.agora_theme_footer_menu.yml
config/optional/block.block.agora_theme_main_menu.yml
config/optional/block.block.agora_theme_messages.yml
config/optional/block.block.agora_theme_page_title.yml
config/optional/block.block.agora_theme_powered_by.yml
config/schema/agora_theme.schema.yml
css/tokens.css
css/typography.css
logo-inverse.svg
logo.svg
templates/pager.html.twig
theme-settings.php
tests/bin/claims-match-readme
tests/bin/contrast-check
tests/bin/gate-a-theme.sh
tests/bin/identity-strings
tests/bin/no-blind-phpunit
tests/bin/no-patches
tests/bin/no-secrets
tests/bin/no-secrets-binary.py
tests/bin/no-unstable-deps
tests/bin/shared-invariants
tests/bin/shared-invariants.manifest
tests/bin/spellcheck
tests/bin/upstream-drift
tests/bin/watch-gate
tests/modules/agora_theme_test/agora_theme_test.info.yml
tests/modules/agora_theme_test/agora_theme_test.install
tests/modules/agora_theme_test/agora_theme_test.links.menu.yml
tests/modules/agora_theme_test/agora_theme_test.module
tests/modules/agora_theme_test/agora_theme_test.routing.yml
tests/modules/agora_theme_test/config/install/views.view.agora_theme_test_filtered.yml
tests/modules/agora_theme_test/config/install/views.view.agora_theme_test_register.yml
tests/modules/agora_theme_test/src/Controller/AgoraThemeTestController.php
tests/src/Functional/ThemeSettingsTest.php
tests/src/Functional/ViewsEmptyRegionTest.php
tests/src/Nightwatch/Tests/axe.js
tests/src/Nightwatch/Tests/bands.js
tests/src/Nightwatch/Tests/logo.js
tests/src/Unit/ThemeHelpersTest.php
tests/src/TestSite/AgoraThemeTestSetup.php'

# The two quote characters as variables, so no pattern below needs a backslash.
SQ="'"
DQ='"'

FINDINGS=0
WORKDIR=$(mktemp -d -t identity-strings.XXXXXX) || { echo "FATAL: mktemp -d failed"; exit 1; }
trap 'rm -rf "$WORKDIR"' EXIT INT TERM

finding() { # <file> <line> <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
}

grep_fatal() { # <rc> <pattern> <subject>
  fatal "grep exited $1 on $3 while matching: $2 - a file that could not be searched is NOT a file that is correct (I-027)."
}

printf '=========================================================================================================\n'
printf 'identity-strings - agora_theme - D-021: identity strings carry the full name\n'
printf 'repo: %s\n' "$REPO_ROOT"
printf '=========================================================================================================\n'
printf '\nsource of truth\n'
note "FULL_NAME:   $FULL_NAME  (drupal.org node 3618791 title, and the info file's name:)"
note "FAMILY_NAME: $FAMILY_NAME  (what a first mention must carry - assertion 4)"
note "SHORT_NAME:  $SHORT_NAME"
note "PACKAGE:     $PACKAGE"
note "MACHINE:     $MACHINE_NAME"

# ------------------------------------------------------------- preflight -----
command -v jq >/dev/null 2>&1 || fatal 'jq is not available - assertions 2 and 3 read composer.json with it and cannot be evaluated.'
command -v git >/dev/null 2>&1 || fatal 'git is not available - the packaged scope cannot be resolved.'

IDENTITY_COUNT=0
MISSING_IDENTITY=''
while IFS= read -r f; do
  [ -n "$f" ] || continue
  IDENTITY_COUNT=$((IDENTITY_COUNT + 1))
  [ -r "$f" ] || MISSING_IDENTITY="$MISSING_IDENTITY $f"
done <<EOF
$IDENTITY_FILES
EOF

# T-321(b): defaulted because zero means FAIL here - the safe direction of
# I-031. Do not copy the `:-0` to a guard where zero means PASS.
if [ "${IDENTITY_COUNT:-0}" -eq 0 ]; then
  fatal 'zero identity files declared - the scope collapsed to nothing (I-007).'
fi
if [ -n "$MISSING_IDENTITY" ]; then
  fatal "declared identity file(s) not readable in the working tree:$MISSING_IDENTITY - the assertions about them cannot be evaluated, and absent is not correct."
fi

PROSE_COUNT=0
while IFS= read -r f; do
  [ -n "$f" ] || continue
  PROSE_COUNT=$((PROSE_COUNT + 1))
done <<EOF
$PROSE_ONLY
EOF

printf '\ndeclared scope\n'
note "identity files ($IDENTITY_COUNT): $(printf '%s' "$IDENTITY_FILES" | tr '\n' ' ')"
note "prose-only ($PROSE_COUNT): $(printf '%s' "$PROSE_ONLY" | tr '\n' ' ')"

# ----------------------------------------------------- YAML scalar reader ----
# recipe.yml is read with sed, never with a YAML parser: gate A deliberately
# owns no YAML dependency (T-320(c)). The reader therefore handles exactly the
# three forms a scalar can take on one line - bare, single-quoted,
# double-quoted - and REFUSES anything else loudly instead of guessing.
yaml_scalar() { # <key> <file>  -> prints the value
  _key=$1; _file=$2
  _matched=$(grep "^$_key:" "$_file" 2>/dev/null)
  _rc=$?
  [ "$_rc" -ge 2 ] && grep_fatal "$_rc" "^$_key:" "$_file"
  [ "$_rc" -eq 0 ] || return 1
  _v=$(printf '%s\n' "$_matched" | head -1 | sed "s/^$_key:[[:space:]]*//")
  case "$_v" in
    '|'* | '>'*)
      fatal "$_file: $_key: is a YAML block scalar, which this reader does not parse. Assertions 1 and 3 would compare a marker instead of a value; fix the reader or the file, never let it pass."
      ;;
  esac
  case "$_v" in
    "$SQ"*"$SQ")
      _v=${_v#"$SQ"}
      _v=${_v%"$SQ"}
      _v=$(printf '%s' "$_v" | sed "s/$SQ$SQ/$SQ/g")
      ;;
    "$DQ"*"$DQ")
      _v=${_v#"$DQ"}
      _v=${_v%"$DQ"}
      ;;
  esac
  printf '%s' "$_v"
}

# ------------------------------------------ 1. recipe.yml name: exactly one --
printf '\n1. %s name: - exactly one line, value is the full name\n' "$INFO_FILE"
NAME_MATCHED=$(grep '^name:' "$INFO_FILE" 2>/dev/null)
NAME_RC=$?
[ "$NAME_RC" -ge 2 ] && grep_fatal "$NAME_RC" '^name:' "$INFO_FILE"
if [ "$NAME_RC" -eq 0 ]; then
  NAME_LINES=$(printf '%s\n' "$NAME_MATCHED" | wc -l | tr -d ' ')
else
  NAME_LINES=0
fi
note "^name: lines: $NAME_LINES"
if [ "${NAME_LINES:-0}" -ne 1 ]; then
  finding "$INFO_FILE" '<name>' "expected exactly one ^name: line, found $NAME_LINES - Appearance shows one of them and nobody can say which"
else
  INFO_NAME=$(yaml_scalar name "$INFO_FILE")
  note "value: $INFO_NAME"
  if [ "$INFO_NAME" != "$FULL_NAME" ]; then
    finding "$INFO_FILE" '<name>' "is [$INFO_NAME], expected [$FULL_NAME] - it must be BYTE-identical to the drupal.org project title (node 3618791), and the Theme suffix exists because Ágora Transparency was already taken, so it may not be tidied away"
  fi
fi

# ------------------------------------------------- 2. composer.json .name ----
printf '\n2. composer.json .name - the package identifier\n'
COMPOSER_NAME=$(jq -r '.name // empty' composer.json 2>/dev/null | tr -d '\r')
JQ_RC=$?
if [ "$JQ_RC" -ne 0 ]; then
  fatal 'composer.json could not be parsed by jq - assertions 2 and 3 have no value to compare.'
fi
note "value: $COMPOSER_NAME"
if [ "$COMPOSER_NAME" != "$PACKAGE" ]; then
  finding composer.json '<name>' "is [$COMPOSER_NAME], expected [$PACKAGE]"
fi

# ------------------------------------- 3. the two descriptions, byte-equal ---
printf '\n3. description - %s and composer.json byte-identical\n' "$INFO_FILE"
INFO_DESC=$(yaml_scalar description "$INFO_FILE") || INFO_DESC=''
COMPOSER_DESC=$(jq -r '.description // empty' composer.json 2>/dev/null | tr -d '\r')
note "$INFO_FILE: [$INFO_DESC]"
note "composer.json:         [$COMPOSER_DESC]"
if [ -z "$INFO_DESC" ]; then
  finding "$INFO_FILE" '<description>' 'empty or absent - two surfaces cannot be kept in step when one of them says nothing'
elif [ -z "$COMPOSER_DESC" ]; then
  finding composer.json '<description>' 'empty or absent - two surfaces cannot be kept in step when one of them says nothing'
elif [ "$INFO_DESC" != "$COMPOSER_DESC" ]; then
  finding "$INFO_FILE" '<description>' 'differs from composer.json .description - D-021 keeps the two byte-identical, or the theme describes two different products depending on whether you are reading Appearance or Packagist'
fi

# -------------------------------- 4. first mention names the product in full --
printf '\n4. first mention of %s in each identity file carries [%s]\n' "$SHORT_NAME" "$FAMILY_NAME"
WITH_MENTION=0
while IFS= read -r f; do
  [ -n "$f" ] || continue
  NORM=$(sed -e 's/^[[:space:]]*[>#]*[[:space:]]*//' "$f" 2>/dev/null | tr '\n\t' '  ' | tr -s ' ')
  case "$NORM" in
    *"$SHORT_NAME"*) ;;
    *) note "$f: no accented mention (covered by assertion 2 where it applies)"; continue ;;
  esac
  WITH_MENTION=$((WITH_MENTION + 1))
  AFTER=${NORM#*"$SHORT_NAME"}
  case "$AFTER" in
    ' Transparency'*)
      note "$f: first mention is the full name"
      ;;
    *)
      finding "$f" '<first mention>' "the first $SHORT_NAME is not followed by Transparency (next 40 characters: [$(printf '%s' "$AFTER" | cut -c1-40)]) - D-021: the first mention on a surface carries $FAMILY_NAME, never a bare $SHORT_NAME"
      ;;
  esac
done <<EOF
$IDENTITY_FILES
EOF
note "identity files carrying an accented mention: $WITH_MENTION of $IDENTITY_COUNT"
# I-028 canary. If NO identity file matched, the accent match itself has broken
# - a locale, an encoding or a mangled checkout - and every assertion above
# stopped discriminating while still reporting zero findings. That degenerate
# run must not be reported as clean. Zero means FAIL here, so the `:-0` is the
# safe direction (I-031).
if [ "${WITH_MENTION:-0}" -eq 0 ]; then
  fatal "no identity file contains $SHORT_NAME at all - the accent match has broken and assertion 4 discriminated nothing (I-028)."
fi

# ------------------------------------------------- 5. the closed world -------
printf '\n5. closed world - every packaged file naming the product is declared\n'
# The export-ignore roots are read FIRST, because BOTH halves below need them.
# `git check-attr` is NOT usable here, verified 2026-08-23 - it reports nothing
# for a file inside a directory whose DIRECTORY entry carries the attribute,
# which is exactly how the site template's roots are written. In this
# repository the two roots happen to be single files rather than directories,
# and the same rule reader handles both.
IGNORED_ROOTS=''
if [ -r .gitattributes ]; then
  IGNORED_ROOTS=$(grep -E '^/[^[:space:]]+[[:space:]]+export-ignore' .gitattributes 2>/dev/null | sed 's|^/||' | sed 's|[[:space:]].*$||')
  IGN_RC=$?
  [ "$IGN_RC" -ge 2 ] && grep_fatal "$IGN_RC" 'export-ignore roots' .gitattributes
fi

# AUTHORITATIVE half: `git archive HEAD`, which is literally what Drupal.org
# packages. Used WHENEVER a commit exists.
#
# THE PRE-FIRST-COMMIT PROJECTION, and why it is not a hole in the gate. This
# repository is created by T-502, T-504 and T-508 and pushed by T-506, so
# between those tasks it has a branch and no commit and `git archive HEAD` has
# nothing to archive. Refusing to run would mean this invariant could not be
# exercised until after the very push it exists to guard - and T-508 lands
# BEFORE that push on purpose. So when, and only when, HEAD does not exist, the
# packaged scope is PROJECTED from the index plus untracked non-ignored files,
# minus the export-ignore roots above, and the projection is announced on its
# own line rather than passing for the real thing.
#
# It is not a fallback anybody can choose: the condition is the existence of a
# commit, so the authoritative path is taken again the instant one exists, and
# this branch retires itself at T-506 instead of having to be remembered. A
# projection over zero files is still FATAL, exactly like an empty archive.
if git rev-parse --verify HEAD >/dev/null 2>&1; then
  HAVE_HEAD=yes
  SCOPE_SOURCE='git archive HEAD - authoritative, this is what Drupal.org packages'
  ARCHIVE_LIST=$(git archive --format=tar HEAD 2>/dev/null | tar -tf - 2>/dev/null)
  ARCHIVE_RC=$?
  if [ "$ARCHIVE_RC" -ne 0 ] || [ -z "$ARCHIVE_LIST" ]; then
    fatal "git archive HEAD produced nothing (exit $ARCHIVE_RC) - an empty archive would pass every closed-world check through the back door."
  fi
  PACKAGED_MATCHED=$(printf '%s\n' "$ARCHIVE_LIST" | grep -v '/$' 2>/dev/null)
  PKG_RC=$?
  [ "$PKG_RC" -ge 2 ] && grep_fatal "$PKG_RC" 'directory entries' 'the archive listing'
  printf '%s\n' "$PACKAGED_MATCHED" | grep -v '^$' > "$WORKDIR/scope" 2>/dev/null
else
  HAVE_HEAD=no
  SCOPE_SOURCE='PROJECTED from index + untracked, minus export-ignore - no commit exists yet (pre-T-506)'
  : > "$WORKDIR/scope"
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    _skip=no
    while IFS= read -r r; do
      [ -n "$r" ] || continue
      case "$f" in "$r" | "$r"/*) _skip=yes ;; esac
    done <<EOF
$IGNORED_ROOTS
EOF
    [ "$_skip" = yes ] && continue
    printf '%s\n' "$f" >> "$WORKDIR/scope"
  done <<EOF
$(git ls-files --cached --others --exclude-standard 2>/dev/null)
EOF
fi
note "scope source: $SCOPE_SOURCE"

# EARLY-WARNING half: untracked, non-ignored files that are not under an
# export-ignored root, so a new undeclared file naming the product is caught
# BEFORE it is committed rather than after (I-030). Skipped in projection mode,
# where those files are already in the scope and counting them twice would make
# the note below lie about where the scope came from.
UNTRACKED_ADDED=0
if [ "$HAVE_HEAD" = yes ]; then
while IFS= read -r f; do
  [ -n "$f" ] || continue
  _skip=no
  while IFS= read -r r; do
    [ -n "$r" ] || continue
    case "$f" in "$r" | "$r"/*) _skip=yes ;; esac
  done <<EOF
$IGNORED_ROOTS
EOF
  [ "$_skip" = yes ] && continue
  printf '%s\n' "$f" >> "$WORKDIR/scope"
  UNTRACKED_ADDED=$((UNTRACKED_ADDED + 1))
done <<EOF
$(git ls-files --others --exclude-standard 2>/dev/null)
EOF
fi

sort -u "$WORKDIR/scope" > "$WORKDIR/sorted" && mv "$WORKDIR/sorted" "$WORKDIR/scope"
SCOPE_COUNT=$(wc -l < "$WORKDIR/scope" | tr -d ' ')
note "packaged scope plus $UNTRACKED_ADDED untracked candidate(s) = $SCOPE_COUNT file(s) in scope"
if [ "${SCOPE_COUNT:-0}" -eq 0 ]; then
  fatal 'the packaged scope listed 0 files - the result proves nothing (I-007).'
fi

NAMING=0
UNREADABLE=0
while IFS= read -r f; do
  [ -n "$f" ] || continue
  if [ -r "$f" ]; then
    _src=$f
  else
    _src="$WORKDIR/blob"
    git show "HEAD:$f" > "$_src" 2>/dev/null || { UNREADABLE=$((UNREADABLE + 1)); continue; }
  fi
  LC_ALL=C grep -qIE "$PRODUCT_ERE" "$_src" 2>/dev/null
  _rc=$?
  [ "$_rc" -ge 2 ] && grep_fatal "$_rc" "$PRODUCT_ERE" "$f"
  [ "$_rc" -eq 0 ] || continue
  NAMING=$((NAMING + 1))
  _declared=no
  while IFS= read -r d; do
    [ -n "$d" ] || continue
    [ "$f" = "$d" ] && _declared=identity
  done <<EOF
$IDENTITY_FILES
EOF
  if [ "$_declared" = no ]; then
    while IFS= read -r d; do
      [ -n "$d" ] || continue
      [ "$f" = "$d" ] && _declared=prose
    done <<EOF
$PROSE_ONLY
EOF
  fi
  case "$_declared" in
    identity) note "$f: names the product - declared IDENTITY" ;;
    prose)    note "$f: names the product - declared PROSE-ONLY" ;;
    *)        finding "$f" '<undeclared>' 'packaged file names the product but is declared neither as an identity file nor as prose-only - declare it in tests/bin/identity-strings, or stop naming the product in it (I-024)' ;;
  esac
done < "$WORKDIR/scope"

[ "$UNREADABLE" -gt 0 ] && note "$UNREADABLE packaged path(s) readable from neither the working tree nor HEAD"

# I-028 canary, the other direction: if NOTHING in the packaged tree names the
# product, the matcher has broken and the closed world enclosed nothing while
# still reporting zero findings. Zero means FAIL, so `:-0` is the safe
# direction here too (I-031).
if [ "${NAMING:-0}" -eq 0 ]; then
  fatal 'no packaged file names the product at all - the matcher has broken and the closed world enclosed nothing (I-028).'
fi

# A declared identity file that is not packaged is a real regression, but it is
# a finding rather than a FATAL: the scan itself still discriminated.
while IFS= read -r d; do
  [ -n "$d" ] || continue
  if ! grep -qxF "$d" "$WORKDIR/scope" 2>/dev/null; then
    finding "$d" '<not packaged>' 'declared as an identity file but absent from the packaged scope - either it stopped shipping, or the declaration has rotted'
  fi
done <<EOF
$IDENTITY_FILES
EOF

# ------------------------------------- 6. exactly one info file, correctly named --
# THEME-ONLY. The site template's no-code-in-template asserts ZERO *.info.yml
# files and D-028 forbids copying it here, because that assertion is false about
# a theme and the only way to make it pass would be to weaken it. This is the
# positive assertion written from scratch in its place.
#
# Scope is the REPOSITORY ROOT only, `find -maxdepth 1`, not a recursive sweep:
# a theme may legitimately ship sub-extensions later, and a recursive count
# would then have to be relaxed - which is the failure mode this whole file is
# arguing against. Root is where Drupal looks for the extension that this
# package IS.
printf '\n6. exactly one root *.info.yml, and it is named for the machine name\n'
INFO_LIST=$(find . -maxdepth 1 -type f -name '*.info.yml' 2>/dev/null | sed 's|^\./||' | sort)
FIND_RC=$?
if [ "$FIND_RC" -ne 0 ]; then
  fatal "find exited $FIND_RC while listing root *.info.yml files - a directory that could not be listed is NOT a directory that is correct (I-027)."
fi
if [ -z "$INFO_LIST" ]; then
  INFO_COUNT=0
else
  INFO_COUNT=$(printf '%s\n' "$INFO_LIST" | wc -l | tr -d ' ')
fi
note "root *.info.yml files: $INFO_COUNT [$(printf '%s' "$INFO_LIST" | tr '\n' ' ')]"
if [ "${INFO_COUNT:-0}" -ne 1 ]; then
  finding '.' '<root>' "expected exactly one root *.info.yml, found $INFO_COUNT - a theme package declares one extension; zero means Drupal sees no theme at all, and more than one means nobody can say which extension this package is"
elif [ "$INFO_LIST" != "$MACHINE_NAME.info.yml" ]; then
  finding "$INFO_LIST" '<filename>' "the root info file is [$INFO_LIST], expected [$MACHINE_NAME.info.yml] - the filename IS the machine name, and the machine name is measured (field_project_machine_name on drupal.org node 3618791), not chosen here"
fi

# ----------------------------------------------------------------- summary ---
printf '\n=========================================================================================================\n'
printf 'identity files checked: %s · prose-only declared: %s · packaged files naming the product: %s · root info files: %s · findings: %s\n' \
  "$IDENTITY_COUNT" "$PROSE_COUNT" "$NAMING" "$INFO_COUNT" "$FINDINGS"
printf '=========================================================================================================\n'

if [ "$FINDINGS" -eq 0 ]; then
  # The colour token is passed as its own conversion, never glued to the word
  # that follows it: a format specifier followed immediately by lowercase
  # letters reads to a spell checker as ONE token, which is how `snone` and
  # `smention` ended up as entries in .cspell-project-words.txt. Keeping the
  # literal in its own argument costs nothing and adds no dictionary entry.
  printf '%s%s%s\n' "$C_OK" 'identity-strings: clean' "$C_OFF"
  exit 0
fi
printf '%s%s%s%s%s\n' "$C_BAD" 'identity-strings: ' "$FINDINGS" ' finding(s)' "$C_OFF"
exit 1
