# -*- coding: utf-8 -*-
"""
a11y_audit.py — WCAG 2.1 AA sweep. Rebuilt 2026-08-23; scope made honest 2026-08-24.

WHAT THIS CHECKS, exactly:
  1.4.3   text contrast — every `color:` declaration in the site stylesheet and the
          pages' own <style>, resolved through the token graph, against both grounds;
          print colours against white, including verifying that the GENERATED print
          override block covers every colour-setting selector in the page CSS.
  1.4.11  non-text contrast — SVG fill/stroke ONLY (with alpha composited). No other
          non-text contrast is checked here; the browser-tooling gate covers it.
  1.4.10  reflow — unbreakable alphanumeric tokens (40+ chars) without a reachable
          word-break rule.
  1.3.1   heading-level skips; list-role stripping (list-style:none without
          role="list"); table captions and header scope; ambiguous prime-suffixed ids.
  2.4.3   the nav toggle precedes the menu it controls in DOM order.
  no-inline-colour rule — any `style=` attribute carrying a colour is a failure
          outright: inline colours are unreachable by the generated print overrides,
          which is exactly where the Phase 3 exit gate found 3.03:1 print text while
          this script printed zero.
  4.1.1/2 duplicate ids; dangling aria-labelledby/describedby.

WHAT THIS DOES NOT CHECK, so nobody mistakes a clean run for a full audit:
  backgrounds/borders/outlines against adjacent colours (needs layout), SVG
  fill/stroke inside @media print rules (the sweep reads `color:` in print blocks,
  not print-media paint — the browser gate covers it; proved by an adversarial probe
  at the Phase 3 exit gate), same-sheet token shadowing under last-wins cascade
  order (the token graph is first-wins), focus-order
  walks beyond the nav-toggle rule, focus-indicator contrast, accessible-name
  computation, ARIA semantics beyond resolution, zoom/text-spacing behaviour, actual
  print rendering (CSS is checked, a printer is not), and anything requiring a real
  browser. The Phase 3 exit gate runs a browser-tooling audit for those; this script
  is the fast regression tripwire, not the certification.

A CSS colour this script cannot resolve (rgb()/var chains it can't follow, etc.) is
REPORTED as unresolved — never silently skipped. An @import in any swept stylesheet
is reported as a failure, because it blinds the sweep.

History: the 2026-08-23 rebuild replaced a checker that printed ALL CHECKS PASS on a
tree with three real AA failures; round three then showed the rebuild itself missed
19 of 20 realistic regressions. This version claims less and proves what it claims
via the mutation self-test (--selftest), which asserts a clean baseline first.

The version this replaces was the check on the check, and it did not check. Round-two
review took it apart:

  * its contrast table "measures nothing that is text" — load_tokens() only matched
    literal-hex definitions, but every semantic token on this site is an alias
    (--text: var(--paper)), so all seven text tokens were SILENTLY DROPPED and it
    reported two navy values that are never used as text;
  * 13 of 14 page checks were substring searches. One asserted a contrast ratio as a
    hardcoded string. One passed on a CSS comment ABOUT the feature rather than the
    feature. "Marked up as lists" checked a class name, not the role, which is the
    actual failure mode;
  * N/A printed as PASS and counted toward ALL CHECKS PASS;
  * the sweep read `color:` inside <style> only — never SVG fill/stroke, never
    background/border/outline, never inline style=, and strip_print() deleted the print
    rules saying they are "judged against paper" and then never judged them anywhere,
    which is the direct reason a 1.12:1 white-on-white print failure shipped.

It printed ALL CHECKS PASS on a tree with three real AA failures, one of which the
accessibility pass itself had introduced.

This rebuild: resolves the whole token graph, composites alpha, sweeps SVG paint and
inline styles, judges print against paper, and treats N/A as a result to report rather
than a pass to bank.

Run:  python3 scripts/a11y_audit.py
"""
import os, re, sys
from html.parser import HTMLParser

# The bar. Named once so the self-test can pin it: it was four separate literals, and
# moving them let a real 4.15:1 failure sweep clean while every seeded defect still
# reported CAUGHT. WCAG 2.1 AA: 4.5:1 normal text, 3:1 large text and UI components.
AA_NORMAL = 4.5
AA_LARGE = 3.0

# ── colour ──────────────────────────────────────────────────────────────────

def _lin(c):
    c /= 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4


def rgb(h):
    h = h.strip().lstrip("#")
    if len(h) == 3:
        h = "".join(c * 2 for c in h)
    return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))


def lum(h):
    r, g, b = rgb(h)
    return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)


def ratio(a, b):
    la, lb = lum(a), lum(b)
    hi, lo = max(la, lb), min(la, lb)
    return (hi + 0.05) / (lo + 0.05)


def composite(fg, bg, alpha):
    """What the eye actually sees when fg is drawn over bg at opacity alpha.
    The old sweep had no alpha handling at all, which is why 64 chart marks at
    opacity .55 were invisible to it."""
    f, b = rgb(fg), rgb(bg)
    return "#%02X%02X%02X" % tuple(round(f[i] * alpha + b[i] * (1 - alpha)) for i in range(3))


# ── token graph ─────────────────────────────────────────────────────────────

HEX = re.compile(r"#[0-9A-Fa-f]{3,8}\b")
VAR = re.compile(r"var\(\s*--([\w-]+)\s*(?:,\s*([^)]+))?\)")


def token_graph(css):
    """Every --token: value pair, including aliases. The old version dropped these."""
    g = {}
    for m in re.finditer(r"--([\w-]+)\s*:\s*([^;]+);", css):
        g.setdefault(m.group(1), m.group(2).strip())
    return g


RGB_FN = re.compile(r"rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+%?))?\s*\)")
NON_COLOR = {"inherit", "currentcolor", "transparent", "initial", "unset", "revert"}


def resolve(value, g, depth=0, ground="#0A1628"):
    """Follow var() chains, hex in all four lengths, and rgb()/rgba() (alpha
    composited over `ground`) to a literal colour. Returns the colour, the string
    "INHERIT" for keywords that defer to context, or None for a value this sweep
    CANNOT resolve — and callers must REPORT None, never skip it."""
    v = value.strip().replace("!important", "").strip()
    if depth > 12:
        return None
    if v.lower() in NON_COLOR:
        return "INHERIT"
    h = re.fullmatch(r"#([0-9A-Fa-f]{3,8})", v)
    if h:
        x = h.group(1)
        if len(x) in (3, 4):
            x = "".join(c * 2 for c in x)
        if len(x) == 8:                     # RRGGBBAA — composite over the ground
            return composite("#" + x[:6], ground, int(x[6:8], 16) / 255)
        if len(x) == 6:
            return ("#" + x).upper()
        return None
    if v.lower().startswith("var(") and v.endswith(")"):
        # paren-balanced parse — the old regex died on nested fallbacks like
        # var(--text-dim, var(--text-muted)), which the unresolved-reporting caught
        inner = v[4:-1]
        depth_p, cut = 0, None
        for i, ch in enumerate(inner):
            if ch == "(":
                depth_p += 1
            elif ch == ")":
                depth_p -= 1
            elif ch == "," and depth_p == 0:
                cut = i
                break
        name = inner[:cut].strip() if cut is not None else inner.strip()
        fallback = inner[cut + 1:].strip() if cut is not None else None
        name = name.lstrip("-")
        if name in g:
            r = resolve(g[name], g, depth + 1, ground)
            if r:
                return r
        return resolve(fallback, g, depth + 1, ground) if fallback else None
    fn = RGB_FN.fullmatch(v)
    if fn:
        r_, g_, b_ = (min(255, int(fn.group(i))) for i in (1, 2, 3))
        base = "#%02X%02X%02X" % (r_, g_, b_)
        a = fn.group(4)
        if a is None:
            return base
        alpha = float(a[:-1]) / 100 if a.endswith("%") else float(a)
        return composite(base, ground, max(0.0, min(1.0, alpha)))
    if HEX.match(v):                        # e.g. "1px solid #8A95A5"
        return HEX.search(v).group(0).upper()
    named = {"white": "#FFFFFF", "black": "#000000"}
    return named.get(v.lower())


# ── CSS blocks, at-rule aware ───────────────────────────────────────────────

def blocks(css):
    css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
    """Yield (selector, declarations, media) with correct nesting. The old regex could
    not parse nested at-rules and silently mis-attributed print rules."""
    out, i, stack = [], 0, []
    while i < len(css):
        b = css.find("{", i)
        if b < 0:
            break
        head = css[i:b].strip().split("}")[-1].strip()
        d, j = 1, b + 1
        while j < len(css) and d:
            d += (css[j] == "{") - (css[j] == "}")
            j += 1
        body = css[b + 1:j - 1]
        if head.startswith("@"):
            out.extend((s, dcl, (head + " " + m).strip()) for s, dcl, m in blocks(body))
        else:
            out.append((head, body, ""))
        i = j
    return out


# ── the sweep ───────────────────────────────────────────────────────────────

TEXTISH = ("color",)


def sweep_css(css, g, grounds, label, media_ground=None):
    """Every colour declaration, resolved and measured. An @import blinds the sweep
    and is a failure; an unresolvable value is reported, never skipped."""
    fails = []
    for imp in re.finditer(r"@import\b[^;]*;", css):
        fails.append((0.0, "sweep-blind @import", label, imp.group(0)[:40],
                      "imported CSS is not swept; inline it or audit it separately"))
    for sel, body, media in blocks(css):
        ground = grounds
        if "print" in media:
            if media_ground is None:
                continue
            ground = media_ground
        for m in re.finditer(r"(?<![-\w])(color)\s*:\s*([^;]+);", body):
            hexv = resolve(m.group(2), g, ground=ground[0])
            if hexv == "INHERIT":
                continue
            if not hexv:
                fails.append((0.0, "1.4.3 unresolved", f"{label}:{sel[:58]}",
                              m.group(2).strip()[:30], "manual check required"))
                continue
            low = min(ratio(hexv, b) for b in ground)
            if low < AA_NORMAL:
                fails.append((low, "1.4.3 text", f"{label}:{sel[:58]}", hexv,
                              f"vs {'/'.join(ground)}"))
    return fails


SVG_PAINT = re.compile(r'<(\w+)([^>]*?)(?:/>|>)')


def sweep_svg(svg, ground, label):
    """SVG fill/stroke with alpha compositing — never looked at before."""
    fails = []
    for m in SVG_PAINT.finditer(svg):
        tag, attrs = m.group(1), m.group(2)
        if tag in ("svg", "title", "desc", "g"):
            continue
        op = re.search(r'\b(?:fill-)?opacity="([\d.]+)"', attrs)
        alpha = float(op.group(1)) if op else 1.0
        for prop in ("fill", "stroke"):
            pm = re.search(rf'\b{prop}="(#[0-9A-Fa-f]{{3,6}})"', attrs)
            if not pm:
                continue
            if pm.group(1).upper() == ground.upper():
                continue          # hairline separator painted in the ground colour
            seen = composite(pm.group(1), ground, alpha)
            r = ratio(seen, ground)
            need = AA_NORMAL if tag == "text" else AA_LARGE
            crit = "1.4.3 SVG text" if tag == "text" else "1.4.11 graphical object"
            if r < need:
                fails.append((r, crit, f"{label}:<{tag}> {prop}",
                              f"{pm.group(1)}@{alpha:g}->{seen}", f"need {need}:1"))
    return fails


def check_reflow(html, label):
    """1.4.10 — an unbreakable token wider than a 320px viewport forces the whole page
    to scroll sideways. The bare SHA-256 digests did exactly this."""
    fails = []
    body = re.sub(r"<style>.*?</style>|<svg.*?</svg>|<!--.*?-->", "", html, flags=re.S)
    css = "\n".join(re.findall(r"<style>(.*?)</style>", html, re.S))
    # only an UNQUALIFIED selector guards everywhere; ".verify .hash" does not reach a
    # .hash outside .verify, which is exactly how the digests escaped
    breaks = set()
    for sel, decl, _ in blocks(css):
        if "word-break" in decl and "break-all" in decl:
            for one in sel.split(","):
                one = one.strip()
                if one.startswith(".") and " " not in one and ">" not in one:
                    breaks.add(one.lstrip("."))
    for m in re.finditer(r">([^<>]{40,})<", body):
        for tok in m.group(1).split():
            if len(tok) >= 40 and re.fullmatch(r"[A-Za-z0-9]+", tok):
                ctx = body[max(0, m.start() - 260):m.start()]
                cls = re.findall(r'class="([^"]+)"', ctx)
                guarded = any(c in breaks for grp in cls[-2:] for c in grp.split())
                if not guarded:
                    fails.append((0.0, "1.4.10 reflow", label,
                                  tok[:20] + "...", f"{len(tok)}-char unbreakable token, no word-break"))
    return fails


def check_structure(html, label):
    fails = []
    hs = [int(h) for h in re.findall(r"<h([1-6])[ >]", html)]
    for a, b in zip(hs, hs[1:]):
        if b > a + 1:
            fails.append((0.0, "1.3.1 heading order", label, f"h{a}->h{b}", "level skipped"))
    ids = re.findall(r'\sid="([^"]+)"', html)
    dupes = {i for i in ids if ids.count(i) > 1}
    if dupes:
        fails.append((0.0, "4.1.1 duplicate id", label, ", ".join(sorted(dupes))[:50], ""))
    for ref in {r for grp in re.findall(r'aria-(?:labelledby|describedby)="([^"]+)"', html)
                for r in grp.split()}:
        if ref not in ids:
            fails.append((0.0, "4.1.2 dangling aria", label, ref, "target does not exist"))
    # list semantics: list-style:none strips the role in Safari
    css = "\n".join(re.findall(r"<style>(.*?)</style>", html, re.S))
    for sel, body, _ in blocks(css):
        if "list-style: none" in body or "list-style:none" in body:
            cls = sel.strip().lstrip(".").split()[0]
            used_on_list = re.search(rf'<(?:ul|ol)[^>]*class="[^"]*\b{re.escape(cls)}\b[^>]*>', html)
            if used_on_list and 'role="list"' not in used_on_list.group(0):
                fails.append((0.0, "1.3.1 list semantics", label, sel.strip()[:40],
                              "list-style:none removes the list role in Safari; add role=\"list\""))
    # no-inline-colour rule: an inline colour style defeats the generated print
    # overrides (they select by selector; there is no selector for an attribute on
    # one element). Structural rule: colours live in classes, never in style=.
    for m in re.finditer(r'style="[^"]*(?<![-\w])(?:color|fill|stroke)\s*:', html, re.I):
        ctx = html[max(0, m.start() - 60):m.start()]
        if "<svg" not in ctx.split("<")[-1]:
            fails.append((0.0, "inline colour style", label,
                          re.sub(r"\s+", " ", html[m.start():m.start() + 44]),
                          "unreachable by the generated print overrides; use a class"))
    # 2.4.3: the nav toggle must precede the menu it controls in DOM order —
    # otherwise forward Tab skips the freshly opened menu (round-3 Level A finding)
    tog = html.find('class="nav-toggle"')
    menu = html.find('class="nav-links"')
    if tog != -1 and menu != -1 and tog > menu:
        fails.append((0.0, "2.4.3 focus order", label, "nav-toggle after nav-links",
                      "the opened menu is reachable only backwards; move the button first"))
    # table semantics: every data table needs a caption (or aria-label) and scoped headers
    for m in re.finditer(r"<table[^>]*>(.*?)</table>", html, re.S):
        t = m.group(0)
        if "<caption" not in t and "aria-label" not in t.split(">", 1)[0]:
            fails.append((0.0, "1.3.1 table caption", label,
                          re.sub(r"\s+", " ", t[:48]), "no caption or aria-label"))
        for th in re.findall(r"<th(?=[\s>])[^>]*>", t):
            if "scope=" not in th:
                fails.append((0.0, "1.3.1 th scope", label, th[:40], "header without scope"))
                break
    # screen-reader-distinguishable ids: primes are silent at default verbosity
    seen = re.findall(r'<span class="pcard-id">([^<]+)</span>', html)
    flat = [re.sub(r"[′″']", "", x) for x in seen]
    collide = {x for x in flat if flat.count(x) > 1}
    if collide:
        fails.append((0.0, "1.3.1 ambiguous label", label, ", ".join(sorted(collide)),
                      "prime suffixes are not announced; ids collide for screen-reader users"))
    return fails


def _selector_is_valid(sel):
    """Close-enough CSS selector validation: no token may start with a digit, parens
    must be balanced and only follow a pseudo-class name, and only selector
    characters may appear. Crude regexes failed in BOTH directions here — passing
    comment prose, then rejecting :not([open]) — so this is procedural."""
    sel = sel.strip()
    if not sel or sel[0].isdigit():
        return False
    if sel.count("(") != sel.count(")"):
        return False
    for m in re.finditer(r"\(", sel):
        if not re.search(r":[a-zA-Z-]+$", sel[:m.start()]):
            return False
    for tok in re.split(r"[\s>+~]+", sel):
        if tok and (tok[0].isdigit() or not re.match(r"^[-\w.#:*\[\]='\"()]+$", tok)):
            return False
    return True




def check_print_coverage(html, label):
    """The pages generate their print overrides from their own CSS. Two things must
    hold, and the browser is the referee for both:
      (a) VALIDITY — one malformed selector in a rule's list makes a browser drop the
          ENTIRE rule, so every selector inside every print block must parse. Run 2
          of the exit gate found the whole generated block discarded over comment
          fragments while the old substring version of this check reported coverage.
      (b) COMPLETENESS — every selector that sets a colour outside @media print must
          reappear, as a parsed selector (set membership, not substring), inside one.
    """
    fails = []
    css = "\n".join(re.findall(r"<style>(.*?)</style>", html, re.S))
    outside, inside = set(), set()
    for sel, body, media in blocks(css):
        parts = [" ".join(one.split()) for one in sel.split(",") if one.strip()]
        if "print" in media:
            for one in parts:
                if not _selector_is_valid(one):
                    fails.append((0.0, "1.4.3 print rule voided", label, one[:48],
                                  "invalid selector — browsers drop the whole rule, "
                                  "taking every valid selector beside it down too"))
                inside.add(one)
        elif re.search(r"(?<![-\w])color\s*:", body):
            for one in parts:
                outside.add(one)
    for sel in sorted(outside):
        if sel and not sel.startswith("@") and sel not in inside:
            fails.append((0.0, "1.4.3 print coverage", label, sel[:48],
                          "colour-setting selector with no print override — generator gap"))
    return fails


def check_print(html, label, gsite=None):
    """The failure the old script's strip_print() guaranteed it could not see."""
    css = "\n".join(re.findall(r"<style>(.*?)</style>", html, re.S))
    g = dict(gsite or {}); g.update(token_graph(css))
    fails, forced = [], {}
    for sel, body, media in blocks(css):
        if media:
            continue
        for m in re.finditer(r"(?<![-\w])color\s*:\s*([^;]+);", body):
            if "!important" in m.group(1):
                forced[sel.strip()] = resolve(m.group(1), g)
    # what the page itself puts back inside @media print. An !important colour outside a
    # media block beats a NORMAL one inside @media print, so only an !important print
    # rule in the same stylesheet actually rescues it.
    rescued = {}
    for sel, body, media in blocks(css):
        if "print" not in media:
            continue
        for m in re.finditer(r"(?<![-\w])color\s*:\s*([^;]+);", body):
            if "!important" not in m.group(1):
                continue
            c = resolve(m.group(1), g)
            for one in sel.split(","):
                rescued[one.strip()] = c
    printed_bg = "#FFFFFF"
    for sel, hexv in forced.items():
        if not hexv:
            continue
        cover = [v for k, v in rescued.items() if k == sel or k in sel.split()]
        if cover and all(v and ratio(v, printed_bg) >= AA_NORMAL for v in cover):
            continue
        if ratio(hexv, printed_bg) < AA_NORMAL:
            fails.append((ratio(hexv, printed_bg), "1.4.3 print", f"{label}:{sel[:48]}",
                          hexv, "!important outside @media beats a normal print rule; "
                                "needs an !important print override"))
    return fails


PAGE_NAMES = ["2026-battleground-predictions.html", "the-headline-i-never-re-ran.html"]
SVG_NAMES = ["fig_step_not_slope.svg"]
STYLE_NAMES = ["styles_site.css", "styles.css"]


_HERE = os.path.dirname(os.path.abspath(__file__))


def _find(root, name):
    """Resolve a file whether we are in the repo (../web/) or in the published bundle,
    where everything sits flat beside this script."""
    for p in (os.path.join(root, "web", name), os.path.join(root, name),
              os.path.join(_HERE, name)):
        if os.path.exists(p):
            return p
    return None
BG, EL, PAPER = "#0A1628", "#112036", "#FFFFFF"


# Content links carry colour AND an underline, because gold-on-paper is 1.38:1 against
# the surrounding text and colour alone fails 1.4.1 at Level A. The stylesheet lists the
# contexts that get the underline BY ELEMENT -- p, li, td, dd, figcaption -- and `th` was
# not among them. Every one of the 36 digest links in the verification table sits in a
# <th scope="row">, so the largest single block of content links on the page was gold-only:
# the 1.4.1 failure, inside the fix for 1.4.1, on the page that publishes its own audit.
#
# Fixing the selector fixes today. This check is what makes it stay fixed: it finds every
# non-icon link in the article and asserts the stylesheet actually reaches it, so adding a
# link in a new element fails the build instead of shipping underlineless.
# Exempt by design, each for a reason, not for convenience:
#   gl         the '?' icon -- a focus ring and a shape, never colour alone
#   skip-link  hidden until focused, then a full-width banner: position and box are
#              the cue, and underlining it changes nothing a sighted user sees
UNDERLINE_EXEMPT = ("gl", "skip-link")


VOID = {"br", "hr", "img", "input", "meta", "link", "source", "col", "wbr", "area"}


class _Ancestors(HTMLParser):
    """Ancestor element stack at each <a>, from a real parser.

    A hand-rolled tag-stack got this wrong: one mismatched close anywhere earlier in the
    document corrupts the stack for everything after it, and it reported links as
    un-underlined that plainly sit inside <p>. A checker that cries wolf gets ignored, and
    this project has already paid for that lesson once (privacy_gate's first draft flagged
    730 names). html.parser is in the standard library, so this costs the reader nothing.
    """

    def __init__(self):
        HTMLParser.__init__(self, convert_charrefs=True)
        self.stack, self.hits = [], []

    def handle_starttag(self, tag, attrs):
        if tag == "a":
            self.hits.append((dict(attrs), list(self.stack)))
        if tag not in VOID:
            self.stack.append(tag)

    def handle_startendtag(self, tag, attrs):
        if tag == "a":
            self.hits.append((dict(attrs), list(self.stack)))

    def handle_endtag(self, tag):
        if tag in self.stack:                 # pop down to it; tolerate stray closes
            while self.stack.pop() != tag:
                pass



def check_link_underline(html, label):
    """1.4.1 — every content link must be reached by the underline rule."""
    css = "\n".join(re.findall(r"<style>(.*?)</style>", html, re.S))
    # Comments first: a /* ... */ block immediately before a rule gets swallowed into the
    # selector capture, and every underline selector on this page carries one -- so the
    # first version of this check saw no selectors at all and reported five clean links
    # as failures. Strip them before parsing.
    css = re.sub(r"/\*.*?\*/", " ", css, flags=re.S)
    # Which elements does the underline rule name inside .pred-wrap?
    covered = set()
    for blk in re.findall(r"([^{}]+)\{([^}]*)\}", css):
        sel, body = blk
        if "text-decoration: underline" not in body and "text-decoration:underline" not in body:
            continue
        for one in sel.split(","):
            one = one.strip()
            mm = re.match(r"\.pred-wrap\s+(\w+)\s+a$", one)
            if mm:
                covered.add(mm.group(1))
            elif re.match(r"\.[\w-]+\s+a$", one):
                # e.g. ".pcard-note a" -- reached through a CLASS on an ancestor, which
                # the element-name test cannot see, so record the bare class name too.
                covered.add(one.split()[0].lstrip("."))
    body = re.sub(r"<style.*?</style>", "", html, flags=re.S)
    body = re.sub(r"<head.*?</head>", "", body, flags=re.S)
    par = _Ancestors()
    par.feed(body)
    fails = []
    for attrs, stack in par.hits:
        if "href" not in attrs:
            continue                          # an anchor target, not a link
        cls = (attrs.get("class") or "").split()
        if any(c in UNDERLINE_EXEMPT for c in cls):
            continue
        if {"nav", "header", "footer"} & set(stack):
            continue          # chrome, not content: styled by the site stylesheet
        if not any(e in covered for e in stack) and not (set(cls) & covered):
            fails.append((0.0, "1.4.1 link not underlined", label,
                          "<%s> a" % (stack[-1] if stack else "?"),
                          "colour is the only cue: " + (attrs["href"])[:60]))
    return fails


def run(root="."):
    allf = []
    sp = next((q for q in (_find(root, n) for n in STYLE_NAMES) if q), None)
    if sp is None:
        raise SystemExit("a11y_audit: no stylesheet found under " + root)
    site = open(sp, encoding="utf-8").read()
    gsite = token_graph(site)
    allf += sweep_css(site, gsite, [BG, EL], "styles.css", media_ground=[PAPER])
    for name in PAGE_NAMES:
        p = _find(root, name)
        if p is None:
            print(f"  (page not present here: {name})")
            continue
        h = open(p, encoding="utf-8").read()
        css = "\n".join(re.findall(r"<style>(.*?)</style>", h, re.S))
        g = dict(gsite); g.update(token_graph(css))
        lbl = os.path.basename(p)
        allf += sweep_css(css, g, [BG, EL], lbl, media_ground=[PAPER])
        allf += check_reflow(h, lbl)
        allf += check_structure(h, lbl)
        allf += check_print(h, lbl, gsite)
        allf += check_print_coverage(h, lbl)
        allf += check_link_underline(h, lbl)
        for m in re.finditer(r"<svg.*?</svg>", h, re.S):
            allf += sweep_svg(m.group(0), BG, lbl + " (embedded svg)")
    for name in SVG_NAMES:
        q = _find(root, name)
        if q:
            allf += sweep_svg(open(q, encoding="utf-8").read(), BG, name)
    seen, out = set(), []
    for f in allf:
        k = (f[1], f[2], f[3])
        if k not in seen:
            seen.add(k); out.append(f)
    out.sort(key=lambda f: (f[1], f[0]))
    print(f"WCAG 2.1 AA sweep — {len(out)} failures\n")
    for r, crit, where, val, note in out:
        rr = f"{r:5.2f}:1" if r else "   —  "
        print(f"  {rr}  {crit:26s} {val:26s} {where}")
        if note:
            print(f"          {note}")
    if not out:
        print("  none. Every colour resolved through the token graph, alpha composited,")
        print("  print judged against paper, structure and reflow checked.")
    return len(out)





# ─────────────────────────────────────────────────────────────────────────────
#  Mutation test — does the audit actually catch anything?
#
#  The version this replaces reported ALL CHECKS PASS on a tree with three real AA
#  failures. A clean run means nothing unless the checker can be shown to fail when
#  a known defect is put back. Each mutation below reintroduces one of the failures
#  round-two review found, and the test asserts the audit reports it.
#
#  Run: python3 scripts/a11y_audit.py --selftest
# ─────────────────────────────────────────────────────────────────────────────

MUTATIONS = [
    ("chart dot opacity back to .55 (was 2.67:1)",
     "fig_step_not_slope.svg", 'opacity=".75"', 'opacity=".55"', "1.4.11 graphical object"),
    ("strip the GENERATED print overrides (the 1.12:1 class)",
     "2026-battleground-predictions.html",
     "/* GENERATED print overrides — one per colour-setting selector above */", 
     "*/ /*", "1.4.3 print"),
    ("remove role=list (Safari drops the list role)",
     "2026-battleground-predictions.html", '<ul class="pred-list" role="list"',
     '<ul class="pred-list"', "1.3.1 list semantics"),
    ("accent back to the brand red on text (2.76:1)",
     "styles_site.css", "--accent-text: #E06B6B;", "--accent-text: #B83232;", "1.4.3 text"),
    ("un-break the published hashes (342px of reflow at 320px)",
     "2026-battleground-predictions.html", "word-break: break-all", "word-break: normal",
     "1.4.10 reflow"),
    # The defect that actually shipped: `th` missing from the underline selector, leaving
    # every digest link in the verification table gold-only against muted paper (1.38:1).
    # Found by an independent reviewer, not by this audit -- which is why the check and
    # this probe were both added afterwards.
    ("drop th from the 1.4.1 underline rule (36 gold-only digest links)",
     "2026-battleground-predictions.html",
     ".pred-wrap td a, .pred-wrap th a, .pred-wrap dd a",
     ".pred-wrap td a, .pred-wrap dd a", "1.4.1 link not underlined"),
    ("skip a heading level",
     "2026-battleground-predictions.html", "<h2>The finding, after it was checked</h2>",
     "<h4>The finding, after it was checked</h4>", "1.3.1 heading order"),
    ("put the nav toggle back after the menu (2.4.3)",
     "the-headline-i-never-re-ran.html",
     ['<button class="nav-toggle"', '<ul class="nav-links" id="navLinks" role="list">'],
     ['<button class="was-toggle"',
      '<ul class="nav-links" id="navLinks" role="list"><li><button class="nav-toggle" aria-label="Menu"><span></span></button></li>'],
     "2.4.3 focus order"),
    ("strip a table caption on the story page",
     "the-headline-i-never-re-ran.html", "<caption class=\"vh\">", "<x-caption>",
     "1.3.1 table caption"),
    ("smuggle an unresolvable colour into the page CSS",
     "the-headline-i-never-re-ran.html",
     ".pred-wrap p { color: var(--text-muted);", ".pred-wrap p { color: color-mix(in srgb, red, blue);",
     "1.4.3 unresolved"),
    ("poison the generated print block with one bogus selector",
     "2026-battleground-predictions.html",
     "/* GENERATED print overrides — one per colour-setting selector above */\n    @media print {\n      ",
     "/* GENERATED print overrides — one per colour-setting selector above */\n    @media print {\n      3.03:1 (gate,\n      ",
     "1.4.3 print rule voided"),
    ("uppercase inline colour style (case evasion)",
     "the-headline-i-never-re-ran.html", '<p class="pred-meta">',
     '<p class="pred-meta" style="COLOR:#1B2D49">', "inline colour style"),
    ("reintroduce an inline colour style (the print blind spot)",
     "2026-battleground-predictions.html", '<span class="file-desc">',
     '<span style="color:var(--text-dim)">', "inline colour style"),
    ("blind the sweep with an @import",
     "2026-battleground-predictions.html", "<style>", "<style>@import url(x.css);",
     "sweep-blind @import"),
]


def selftest(root):
    import io, contextlib, shutil, tempfile
    tmp = tempfile.mkdtemp()
    src = os.path.join(root, "web")
    if not os.path.isdir(src):
        print("  the mutation test needs the repo layout (web/); skipping in the flat bundle")
        return 0
    shutil.copytree(src, os.path.join(tmp, "web"))
    ok = True
    import io, contextlib
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        base = run(tmp)
    if base != 0:
        print("BASELINE NOT CLEAN — the mutation test means nothing on a failing tree:")
        print(buf.getvalue()[-800:])
        shutil.rmtree(tmp)
        return 1
    print("baseline: clean (asserted, not assumed)")
    # The AA threshold is the bar every one of these mutations is measured against. It
    # was itself mutable: moving 4.5 to 2.80 let a real 4.15:1 failure sweep clean while
    # every seeded defect still reported CAUGHT, because the seeds sit far below any
    # plausible weakened bar. Pin it.
    if AA_NORMAL != 4.5 or AA_LARGE != 3.0:
        print("SELF-TEST FAILED — the AA thresholds have been moved (%s / %s). Every "
              "verdict this audit prints is measured against them." % (AA_NORMAL, AA_LARGE))
        shutil.rmtree(tmp)
        return 1
    print("MUTATION TEST — each line reintroduces one known defect\n")
    for name, rel, find, repl, expect in MUTATIONS:
        path = _find(tmp, os.path.basename(rel))
        if path is None:
            print(f"  SKIP   {name}\n         (file not present: {rel})")
            ok = False
            continue
        orig = open(path, encoding="utf-8").read()
        pairs = list(zip(find, repl)) if isinstance(find, list) else [(find, repl)]
        missing = [f for f, _ in pairs if f not in orig]
        if missing:
            print(f"  SKIP   {name}\n         (anchor not present: {missing[0][:40]!r})")
            ok = False
            continue
        mutated = orig
        for f, r in pairs:
            mutated = mutated.replace(f, r)
        open(path, "w", encoding="utf-8").write(mutated)
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            run(tmp)
        # Exact match on the CRITERION COLUMN, not a substring of the whole report.
        # "1.4.3 print" used to be satisfied by "1.4.3 print coverage" from an unrelated
        # check, so the check the mutation targets could be deleted outright and the
        # self-test still said CAUGHT.
        # An expectation names EITHER a criterion (matched exactly, on the criterion
        # column) OR a phrase from the explanatory note beneath it. The old test was a
        # substring search over the whole report, so the criterion "1.4.3 print" was
        # satisfied by the unrelated "1.4.3 print coverage" of a different check -- and
        # `check_print` could be replaced with `return []` while this still said CAUGHT.
        report = buf.getvalue()
        crits, notes = set(), []
        for line in report.split("\n"):
            m = re.match(r"^\s{2}(?:\s*[\d.]+:1|\s*—)\s+(\S.*?)\s{2,}", line)
            if m:
                crits.add(m.group(1).strip())
            elif line.startswith("          "):
                notes.append(line.strip())
        caught = expect in crits or any(expect in n for n in notes)
        print(f"  {'CAUGHT' if caught else 'MISSED'} {name}")
        ok &= caught
        open(path, "w", encoding="utf-8").write(orig)
    shutil.rmtree(tmp)
    print(f"\n{'the audit detects every seeded defect' if ok else 'THE AUDIT MISSED A SEEDED DEFECT'}")
    return 0 if ok else 1


if __name__ == "__main__":
    ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
    if "--selftest" in sys.argv:
        sys.exit(selftest(ROOT))
    sys.exit(1 if run(ROOT) else 0)
