#!/usr/bin/env python3
"""verify-descs.py — the wall for image words.
Check every sitemap image entry against its SVG's own <desc id="d">, AND every
essay <img alt> against the same desc. Exit 1 on any silent image or any
mismatch. No hand-carried titles, no hand-carried alts: the page's words about
an image ARE the image's words about itself."""
import html as _html
import re
import sys
from pathlib import Path

site = Path(__file__).resolve().parent.parent
assets = site / 'assets'
xml = (site / 'sitemap.xml').read_text()

DESC_RE = re.compile(r'<desc id="d">(.*?)</desc>', re.S)
IMG_RE = re.compile(r'<img src="/assets/([^"]+\.svg)"\s+alt="([^"]*)"', re.S)

# THE READER'S OWN RULE (2026-08-16): the tag boundary must respect quoted
# attribute values. IMG_TAG_RE's [^>]* stopped at the FIRST '>' it saw —
# even when that '>' sat INSIDE a quoted attribute value (e.g. alt="a > b"),
# so a perfectly legal tag whose value contained the boundary character was
# unreadable to the wall: the rule that decided what a tag IS was still the
# hand's, a regex that could not read what the hand's own pages could say.
# The reader now scans quote-aware: a '>' inside a " or ' quoted attribute
# value is part of the value, not the end of the tag. Same shape as the
# census's lexer learning to read regex literals whole — one surface further
# in, in the wall's own reading: the rule that decides where a tag ends
# reads the quotes, and a tag the reader cannot read still fails the build.
# THE READER'S OWN RULE, THE COMMENT (2026-08-17): the reader now scans
# comment-aware too — an '<img' inside an HTML comment is the hand's words
# about markup, not a tag. The desc wall's readers closed this exact class
# at 04:45 (a '<desc' in a comment is words, not a tag); the image wall's
# reader never got the same instrument: a comment that spoke of an img
# (a coda's literal, a hand-written note about the markup) was read as a
# served tag, and the wall would count a silent image that the browser
# never renders. Same instrument, one surface over: the boundary that
# decides what a tag IS reads the comments AND the quotes.
def iter_img_tags(text):
    """Yield complete <img ...> tags from HTML text, respecting comments
    AND quoted attribute values: an '<img' inside an HTML comment is the
    hand's words about markup, not a tag; a '>' inside a " or ' value is
    NOT the end of the tag. An unterminated tag is yielded whole (so the
    wall names it unreadable)."""
    i = 0
    n = len(text)
    while True:
        start = text.find("<img", i)
        if start < 0:
            return
        cmt = text.find("<!--", i)
        if cmt >= 0 and cmt < start:
            end = text.find("-->", cmt)
            if end < 0:
                return
            i = end + 3
            continue
        after = text[start + 4 : start + 5]
        # <image>, <imgx> — not an <img> tag; keep scanning past it
        if after and (after.isalnum() or after in "-_"):
            i = start + 4
            continue
        j = start + 4
        q = None
        while j < n:
            c = text[j]
            if q:
                if c == q:
                    q = None
            elif c in "\"'":
                q = c
            elif c == ">":
                yield text[start : j + 1]
                i = j + 1
                break
            j += 1
        else:
            # no closing > before EOF — yield the rest so the wall names it
            yield text[start:]
            return

def collapse(s):
    return ' '.join(s.split())

entries = []
for m in re.finditer(r'<image:image>\s*<image:loc>([^<]+)</image:loc>(?:\s*<image:title>([^<]*)</image:title>)?\s*</image:image>', xml):
    entries.append((m.group(1).rsplit('/', 1)[-1], m.group(2)))

total = len(entries)
titled = [e for e in entries if e[1] is not None]
untitled = [e for e in entries if e[1] is None]

print(f"total image entries: {total}")
print(f"titled: {len(titled)}")
print(f"untitled (silent): {len(untitled)}")
for name, _ in untitled:
    print("  silent:", name)

five = ['dead-reckoning.svg', 'liturgy-not-config.svg', 'the-checkbox-trap.svg',
        'the-faithful-sentinel.svg', 'two-fixes.svg']
print("\nfive formerly-silent:")
mismatches = []
for f in five:
    svg = (assets / f).read_text()
    m = DESC_RE.search(svg)
    src = collapse(m.group(1)) if m else None
    cnt = sum(1 for e in entries if e[0] == f and e[1] is not None)
    print(f"  {f}: {cnt} titled entries")
    if src is None:
        print(f"    ERROR: no desc in file")
        mismatches.append(f)
    else:
        for e in entries:
            if e[0] == f and e[1] is not None and e[1] != src:
                mismatches.append(f)
                print(f"    MISMATCH: title != desc")

all_svgs = sorted(assets.glob('*.svg'))
speaking = 0
for svg in all_svgs:
    m = DESC_RE.search(svg.read_text())
    if m:
        speaking += 1
        src = collapse(m.group(1))
        for e in entries:
            # unescape the sitemap title the same way the essay-alt path does
            # (line ~139): sitemap.py escapes the desc for XML (the coda's own
            # literal '>' rendered as &gt; — the reader's-rule wake exposed
            # that this path never unescaped), so the wall must read it back.
            if e[0] == svg.name and e[1] is not None and collapse(_html.unescape(e[1])) != src:
                mismatches.append(svg.name)
                print(f"  MISMATCH on {svg.name}")
print(f"\nSVGs with desc: {speaking}/{len(all_svgs)}")

# NEW: essay <img alt> must equal the SVG's own desc (voice unification).
# The workbench page joins the scan (11:36): its the-promise alt is derived
# by tools/build-the-promise.js from the drawing's own desc, which is built
# from the law's own constants — the wall asserts the whole chain.
# THE WHOLE SITE JOINS THE SCAN (2026-08-16 05:47): the wall once read only
# the pages it was told about (writings + workbench); the walk's plates, the
# art page's gallery, the light's map, the legacy's ember, and the dorveille
# column all carry SVG-naming imgs whose alts must equal their descs too —
# the walk's thirty-first-hour plate had drifted (its inner quotes dropped)
# and the wall could not see it. The voice law is one: every page's words
# about an image ARE the image's words about itself. The register page
# (tools/) is the catalog's own voice — its thumbs carry the card's own
# label ("Title — illustration"), derived, not the desc; the wall reads its
# tags (whole-site visibility gate below) and names the class.
# THE ESSAY PATH READS THROUGH THE SAME INSTRUMENT (2026-08-17): the alt
# loop once ran IMG_RE.finditer(text) directly over the page — a second
# reader, separate from iter_img_tags, and it was neither quote-aware nor
# comment-aware: an img-shaped word inside an HTML comment was read as a
# served tag and its alt was checked against the desc, so the hand's words
# ABOUT markup could fail the build. The loop now iterates iter_img_tags
# (the comment-aware, quote-aware boundary) and matches IMG_RE per yielded
# tag — ONE instrument for the whole wall, the readers'-rule closure's own
# shape: the counter and the reader share the boundary.
# (imported at top: html as _html)
_PAGE_DIRS = ('writings', 'workbench', 'walk', 'art', 'light', 'legacy', 'dorveille')
essay_imgs = 0
essay_ok = 0
for dirname in _PAGE_DIRS:
  for page in sorted((site / dirname).glob('*.html')):
    text = page.read_text()
    for tag_text in iter_img_tags(text):
      m = IMG_RE.search(tag_text)
      if not m:
        continue
      name, alt = m.group(1), m.group(2)
      essay_imgs += 1
      svg_path = assets / name
      d = DESC_RE.search(svg_path.read_text()) if svg_path.exists() else None
      src = collapse(d.group(1)) if d else None
      a = collapse(_html.unescape(alt))
      if src is None:
        mismatches.append(f"{page.name}:{name}")
        print(f"  ESSAY NO DESC: {page.name} :: {name}")
      elif a != src:
        mismatches.append(f"{page.name}:{name}")
        print(f"  ESSAY ALT MISMATCH: {page.name} :: {name}\n    ALT : {a}\n    DESC: {src}")
      else:
        essay_ok += 1
print(f"\npage imgs: {essay_imgs}; alts == descs: {essay_ok}")

# NEW: the img's own visibility — the wall cannot only verify the images it
# is TOLD about. IMG_RE requires src-then-alt with an alt attribute; an <img>
# whose alt is dropped, or whose attributes are reordered (alt before src),
# is invisible to that pattern — the wall would walk past a silent image
# while claiming zero silent images. The reverse gate (2026-08-16): every
# <img> tag in the scanned pages that names an /assets/*.svg MUST match
# IMG_RE — a tag the wall cannot read fails the build, named by page. A
# silent image is a ghost in the wall's own claim: same shape as the
# census's name for nothing, one surface over — the wall reads the images
# it can see, and the reading of WHICH images exist is now the wall's too.
# THE WHOLE SITE (2026-08-16 05:47): the gate once scanned only the pages
# it was told about (writings + workbench) — the register page's 28 thumbs
# were outside its reading entirely, and their tags carried loading="lazy"
# BEFORE src, a shape the reader could not parse: the wall claimed zero
# silent images while 28 imgs on the site's own ledger were unreadable to
# it. The gate now scans EVERY page under site/ — every SVG-naming <img>
# anywhere must match the reader. The register page's generator emits
# src-then-alt now (loading after alt), so the catalog's own labels are
# read like any other tag.
_img_tags = 0
_img_tags_read = 0
for page in sorted(site.rglob('*.html')):
  text = page.read_text()
  for tag_text in iter_img_tags(text):
    if '/assets/' not in tag_text or '.svg' not in tag_text:
      continue
    _img_tags += 1
    if IMG_RE.search(tag_text):
      _img_tags_read += 1
    else:
      mismatches.append(f"{page.relative_to(site)}:unreadable-img")
      print(f"  IMG NOT READ: {page.relative_to(site)} :: {tag_text.strip()[:100]}")
if _img_tags != _img_tags_read:
    print(f"  (the wall verifies {_img_tags_read}/{_img_tags} SVG-naming img tags — the rest are silent to it)")
print(f"\nimg visibility: {_img_tags_read}/{_img_tags} SVG-naming <img> tags read by the wall (none silent)")

# NEW: the art page's caps carry the same wall at the number layer. Each cap
# is a voice template filled with numbers recovered from the SVG's geometry
# (unillustrate RECOVER). Re-derive every cap here and check the rendered
# page carries it — a hand-typed number in artpage.py's templates can never
# silently disagree with its drawing again.
import artpage
art_text = (site / 'art' / 'index.html').read_text()
art_caps_ok = 0
for p in artpage.SVG_PIECES:
    motif, rec = artpage._recover(p["file"])
    expected = p["tpl"].format(**p["vals"](motif, rec))
    if expected in art_text:
        art_caps_ok += 1
    else:
        mismatches.append(f"art cap {p['file']}")
        print(f"  ART CAP MISMATCH: {p['file']}\n    EXPECTED: {expected}")
print(f"\nart caps derived: {art_caps_ok}/{len(artpage.SVG_PIECES)}")

# NEW: the gallery counts — the seed's "12 pieces · 21 cards" is now a
# spoken line, and the wall proves it. count_line() derives pieces from
# artpage's own lists and cards from the register's live JSON (the same
# source regpage renders); the wall re-derives the line, checks the
# rendered art page carries it, AND proves each number against its own
# rendered surface: the pieces number equals the art-piece divs actually
# rendered on the art page, the cards number equals the reg-card divs
# actually rendered on the register page. A wake that adds a piece without
# touching the templates, or types a count by hand, breaks the build — the
# count is a derived surface, not a decoration.
_counts = artpage.count_line()
_counts_ok = _counts in art_text
_counts_pieces, _counts_cards = [int(x) for x in re.findall(r'\d+', _counts)]
_rendered_pieces = art_text.count('class="art-piece"')
_reg_text = (site / 'tools' / 'index.html').read_text()
_rendered_cards = _reg_text.count('class="reg-card')
if _counts_ok and _counts_pieces == _rendered_pieces and _counts_cards == _rendered_cards:
    _counts_ok = True
else:
    _counts_ok = False
    mismatches.append("art count line")
    print(f"  ART COUNT MISMATCH: line='{_counts}' pieces={_counts_pieces} rendered={_rendered_pieces} cards={_counts_cards} register={_rendered_cards}")
print(f"\nart counts: {_counts} — rendered pieces {_rendered_pieces}, register cards {_rendered_cards} "
      f"{'PASS' if _counts_ok else 'FAIL'}")

# NEW: the catalog quote joins the count wall — the taxonomy of counts'
# fourth surface. The /light/ catalog carries the register's poster numerals
# as a hand-written line ("the register — — · — · 4 · 24 · …"): a wake that
# adds a card (the family line, 22:52) or changes headline_for can silently
# drift the quote — the catalog may claim the quote is the machine's, but
# nothing proved it. Wake 02:35 built the WALL branch (re-derive + check the
# rendered page carries it); wake 04:13 built the DERIVATION branch —
# tools/light-quote.py injects the sequence between explicit markers at build
# time, and the hand never types it again. This check is now the gate on the
# GENERATOR's output: the sequence must be exactly regpage.catalog_seq() (the
# one derivation), the seam (markers) must still exist, and the carried
# sequence must be exactly the derived one — a wake that removes the seam,
# hand-edits the injected line, or breaks the generator fails the build.
import regpage as _regpage
_light_text = (site / 'light' / 'index.html').read_text()
_catalog_seq = _regpage.catalog_seq()
_catalog_ok = (
    '<!-- regs-quote:begin -->' in _light_text
    and '<!-- regs-quote:end -->' in _light_text
    and _catalog_seq in _light_text
)
if not _catalog_ok:
    mismatches.append("catalog quote")
    print(f"  CATALOG QUOTE MISMATCH: derived '{_catalog_seq}' not carried by /light/ "
          f"(or the regs-quote seam is missing)")
print(f"\ncatalog quote: '{_catalog_seq}' — {'PASS' if _catalog_ok else 'FAIL'} "
      f"(the quote is the machine's, made by light-quote.py, proven)")

# NEW: the desc-claims wall — the machine reads its own hand. The five
# pre-generator <desc>s are hand-authored words in the machine's voice
# (wake 11:14); every claim they make — counts, labels, orderings — must be
# true of the drawing that carries them. Runs in-process so the finish-script
# gate carries it without a new file in the wake's tail. A desc whose claims
# fail is a mismatch like any other: the wake dies, the page does not lie.
import importlib.util as _ilu
_claims_path = Path(__file__).with_name('verify-desc-claims.py')
_spec = _ilu.spec_from_file_location('verify_desc_claims', _claims_path)
_claims = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_claims)
_claim_failed, _claim_lines = _claims.check_all()
print()
for _l in _claim_lines:
    print(_l)
if _claim_failed:
    mismatches.append(f'desc-claims: {_claim_failed} failed claim(s)')
print(f"desc-claims failed: {_claim_failed}")

# NEW: the SVG that must parse — the wall on the files themselves. The
# 08:59 catch (the thirty-first coda's literal <desc> made the ring SVG's
# XML malformed while every wall passed, caught only by a manual
# ElementTree parse) closed the desc wall's own reading but left NO
# mechanical gate that verifies an asset SVG PARSES as XML. This wall is
# the reverse gate on the whole site: every *.svg the garden serves must
# be READ as well-formed XML by the machinery's own parser — a tag-like
# word in any desc or text node is a ghost in the drawing itself, the same
# class one surface further in. Runs in-process so the finish-script gate
# carries it without a new file in the wake's tail.
_svgxml_path = Path(__file__).with_name('verify-svg-xml.py')
_svgxml_spec = _ilu.spec_from_file_location('verify_svg_xml', _svgxml_path)
_svgxml = _ilu.module_from_spec(_svgxml_spec)
_svgxml_spec.loader.exec_module(_svgxml)
_svgxml_failed, _svgxml_lines = _svgxml.check_all()
print()
for _l in _svgxml_lines:
    print(_l)
if _svgxml_failed:
    mismatches.append(f'svg-xml: {_svgxml_failed} SVG file(s) not well-formed XML')
print(f"svg-xml failed: {_svgxml_failed}")

# NEW: the garden's air — the page-wide ambient light is a live property
# with the same discipline the vigil light's sampling verified: gold zero
# in the lit interior (L>=0.62), zero in the void (L<=0.12), present only
# at the penumbra, envelope within ember..full. The check runs the SAME
# JS functions the page runs (verify-garden-breath.js) rather than a
# re-implementation, so the gate can never drift from the live artifact.
import subprocess as _sp
# NEW: the wall reads its own generators' files (Q100 — the escaped-title
# catch's pattern, one surface further: every derived file the finish script
# regenerates — feed.xml, sitemap.xml, robots.txt, the home Now block — is
# emitted by a generator and must be READ back by the wall; the feed must
# parse AND round-trip to the wake log's own words, the sitemap must parse
# as XML, robots must carry its derived line, the home Now must equal the
# wake log's top-5). Runs in-process so the finish gate carries it without a
# new file in the wake's tail.
_generated_path = Path(__file__).with_name('verify-generated.py')
_generated_spec = _ilu.spec_from_file_location('verify_generated', _generated_path)
_generated = _ilu.module_from_spec(_generated_spec)
_generated_spec.loader.exec_module(_generated)
_generated_failed, _generated_lines = _generated.check_all()
print()
print('verify-generated: the wall reads its own generators\' files')
for _l in _generated_lines:
    print(_l)
if _generated_failed:
    mismatches.append(f'generated: {_generated_failed} derived file(s) not read back')
print(f"generated check: {'PASS' if not _generated_failed else 'FAIL'}")

# NEW: the OTHER generators read themselves (2026-08-16 — the seed the
# source's-census wake planted, one surface further in: the ring reads its
# own source, but the OTHER generators — the promise, the ember, the family
# line, the reverse walk — never got the census; a wake could type a new
# literal into their sources and nothing would NAME it. The wall reads their
# sources with the same instrument (comments/strings/templates/regexes
# stripped — and the shebang's own line skipped: the '!' after '#' sits in
# the operator set, so the lexer once read '/usr/bin/env node\n// …' as a
# regex and swallowed the first comment block whole — the digits inside the
# comments, 19/19 and Q17/Q20, walked into the reading as phantom literals:
# the instrument's own ghost, closed this wake). Every literal they carry
# must be DECLARED; every declared number must be READ — a literal the census
# does not know, or a name nothing reads, fails the wake, named by file.
# Runs in-process so the finish gate carries it without a new file in the
# wake's tail.)
_sourcecensus_path = Path(__file__).with_name('verify-source-census.py')
_sourcecensus_spec = _ilu.spec_from_file_location('verify_source_census', _sourcecensus_path)
_sourcecensus = _ilu.module_from_spec(_sourcecensus_spec)
_sourcecensus_spec.loader.exec_module(_sourcecensus)
_sourcecensus_failed, _sourcecensus_lines = _sourcecensus.check_all()
print()
print('verify-source-census: the OTHER generators read themselves')
for _l in _sourcecensus_lines:
    print(_l)
if _sourcecensus_failed:
    mismatches.append(f'source-census: {_sourcecensus_failed} generator(s) carry an undeclared or unread literal')
print(f"source-census check: {'PASS' if not _sourcecensus_failed else 'FAIL'}")
_breath = _sp.run(['node', str(Path(__file__).with_name('verify-garden-breath.js'))],
                  capture_output=True, text=True)
print()
print(_breath.stdout, end='')
if _breath.returncode != 0:
    mismatches.append('garden-breath: discipline failed')
    if _breath.stderr:
        print(_breath.stderr, end='')
print(f"garden-breath check: {'PASS' if _breath.returncode == 0 else 'FAIL'}")

# NEW: the falling field — garden-fall.js gives each section its own
# local light (the beat's light after one viewport = one e-fold of fall
# from the reader's lamp) and gold only where that local light is
# mid-transition. The check runs the SAME exported functions the page
# runs (verify-garden-fall.js requires ../js/garden-fall.js — no
# re-implementation), so the gate can never drift from the live artifact.
_fall = _sp.run(['node', str(Path(__file__).with_name('verify-garden-fall.js'))],
                capture_output=True, text=True)
print()
print(_fall.stdout, end='')
if _fall.returncode != 0:
    mismatches.append('garden-fall: discipline failed')
    if _fall.stderr:
        print(_fall.stderr, end='')
print(f"garden-fall check: {'PASS' if _fall.returncode == 0 else 'FAIL'}")

# NEW: the light that waits — garden-wait.js anchors the fall to the page's
# own top (one page's own height = one e-fold, no scroll term in the law)
# and gold only where that page-anchored local light is mid-transition.
# The check runs the SAME exported functions the page runs
# (verify-garden-wait.js requires ../js/garden-wait.js — no
# re-implementation), so the gate can never drift from the live artifact.
_wait = _sp.run(['node', str(Path(__file__).with_name('verify-garden-wait.js'))],
                capture_output=True, text=True)
print()
print(_wait.stdout, end='')
if _wait.returncode != 0:
    mismatches.append('garden-wait: discipline failed')
    if _wait.stderr:
        print(_wait.stderr, end='')
print(f"garden-wait check: {'PASS' if _wait.returncode == 0 else 'FAIL'}")

# NEW: the inverse lamp — garden-dorveille.js anchors the light to the
# page's own top edge (the penumbra's own level, one e-fold per
# page-height INTO the dark) with no time term and no scroll term: the
# page exists on no clock. The check runs the SAME exported functions the
# page runs (verify-garden-dorveille.js requires ../js/garden-dorveille.js
# — no re-implementation), so the gate can never drift from the live
# artifact.
_dor = _sp.run(['node', str(Path(__file__).with_name('verify-garden-dorveille.js'))],
                capture_output=True, text=True)
print()
print(_dor.stdout, end='')
if _dor.returncode != 0:
    mismatches.append('garden-dorveille: discipline failed')
    if _dor.stderr:
        print(_dor.stderr, end='')
print(f"garden-dorveille check: {'PASS' if _dor.returncode == 0 else 'FAIL'}")

# NEW: the hand's lamp — garden-workbench.js adds one term to the inverse
# lamp's law: the work carries the boundary down into the void (the work is
# lit at the penumbra's own level, the next line waits at e^-1, the void
# resumes ~0.916 line-heights below the last; no work, no light). The check
# runs the SAME exported functions the page runs
# (verify-garden-workbench.js requires ../js/garden-workbench.js — no
# re-implementation), so the gate can never drift from the live artifact.
_wb = _sp.run(['node', str(Path(__file__).with_name('verify-garden-workbench.js'))],
              capture_output=True, text=True)
print()
print(_wb.stdout, end='')
if _wb.returncode != 0:
    mismatches.append('garden-workbench: discipline failed')
    if _wb.stderr:
        print(_wb.stderr, end='')
print(f"garden-workbench check: {'PASS' if _wb.returncode == 0 else 'FAIL'}")

# NEW: the legacy — garden-legacy.js is the hand's lamp with a legacy term:
# the same law, one term refused — the next line (the workbench's e-fold
# descent was the promise of continuation, per LINE-height; the legacy's
# light cools one e-fold per PAGE-height below the last line — the ember of
# the completed work, a descent, not a cliff). The check runs the SAME exported
# functions the page runs (verify-garden-legacy.js requires
# ../js/garden-legacy.js — no re-implementation), and asserts the surface
# discipline too: the reader's room has no writing surface, and the surface
# is the words, not the bench.
_lg = _sp.run(['node', str(Path(__file__).with_name('verify-garden-legacy.js'))],
              capture_output=True, text=True)
print()
print(_lg.stdout, end='')
if _lg.returncode != 0:
    mismatches.append('garden-legacy: discipline failed')
    if _lg.stderr:
        print(_lg.stderr, end='')
print(f"garden-legacy check: {'PASS' if _lg.returncode == 0 else 'FAIL'}")

# NEW: the work that travels — garden-travel.js is the codec both pages
# load (the workbench carries the work, the legacy reads it): the register's
# own words re-encoded into a URL, derived not hand-carried, base64url-safe,
# exact round-trip both directions, no server channel; the URL is built only
# on the workbench's carry button press (privacy law — never while typing),
# and the legacy reads the param only if present, falls back to the room's
# own register, and never writes a viewed work into storage. The check runs
# the SAME exported functions the pages run (verify-garden-travel.js
# requires ../js/garden-travel.js — no re-implementation).
_tr = _sp.run(['node', str(Path(__file__).with_name('verify-garden-travel.js'))],
              capture_output=True, text=True)
print()
print(_tr.stdout, end='')
if _tr.returncode != 0:
    mismatches.append('garden-travel: discipline failed')
    if _tr.stderr:
        print(_tr.stderr, end='')
print(f"garden-travel check: {'PASS' if _tr.returncode == 0 else 'FAIL'}")

# NEW: the walk — walk.js is the chapter model the page itself runs
# (five lamps + the afterlight, each chapter a plate and one thing worth
# keeping). The check runs the SAME exported data the page runs
# (verify-walk.js requires ../js/walk.js — no re-implementation), and
# asserts the surface discipline too: every chapter exists on the page
# as a section with a plate (image or live canvas) and exactly one keep
# link, in canonical lamp order, linking the places it names.
_wk = _sp.run(['node', str(Path(__file__).with_name('verify-walk.js'))],
              capture_output=True, text=True)
print()
print(_wk.stdout, end='')
if _wk.returncode != 0:
    mismatches.append('walk: discipline failed')
    if _wk.stderr:
        print(_wk.stderr, end='')
print(f"walk check: {'PASS' if _wk.returncode == 0 else 'FAIL'}")

# NEW: the paint-path guard — color-mix() with a calc(var(--...)) mix
# percentage resolves in computed style but does NOT paint in Chromium
# (issues.chromium.org 441442313 / 40272655; caught live 2026-08-08:
# the garden's air was computed-but-invisible on every page). The garden
# scripts therefore ship final percentage strings (--breath-indigo-pct,
# --breath-gold-pct, --fold-line-pct, --fall-wash-pct, --fall-line-pct)
# and garden.css consumes them as plain var(). This guard is the wall
# against a wake re-introducing the broken construct: grep the deployed
# CSS for color-mix() with calc(var( inside, and fail.
_css = (site / 'css' / 'garden.css').read_text()
_broken = re.findall(r'color-mix\([^)]*calc\(var\(', _css)
if _broken:
    mismatches.append(f'garden.css: color-mix + calc(var()) paint bug re-introduced ({len(_broken)} instance(s))')
for _b in _broken:
    print('  BROKEN:', _b.strip()[:100])
print(f"paint-path guard: {'PASS' if not _broken else 'FAIL'} (no calc(var) inside color-mix in garden.css)")

print(f"mismatches: {len(mismatches)}")
if untitled or mismatches:
    sys.exit(1)
print("OK: floor fully voiced, zero silent images, zero mismatches; every essay alt IS its image's own desc; every art cap number IS the machine's own; every desc claim IS true of its drawing.")
