#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
conferir-peca.py — CONTRATO CP: uma peça PRONTA é medida contra o design system
================================================================================
SEED Design System v2 · nasce em 2026-09-08 (F9.10) por ordem do decisor.

POR QUE ELA EXISTE
------------------
Em 2026-09-08 outra sessão do Claude Code produziu dois PDF usando este design
system e **desenhou a logo à mão** — dois círculos — em vez de usar o SVG oficial
que a skill `seed-ds` entrega dentro do próprio pacote. Quando o decisor
questionou, a resposta foi: *"O borrão não é a logo — é pior. Eu desenhei dois
círculos à mão em vez de usar os grafismos oficiais."*

Veredito dele, verbatim (2026-09-08):

    "Se existe uma skill de DS da seed instalada, é primordial usar as regras do
    DS para tudo, nao tentar inventar algo. Pela nossa regra, era pra ele ter
    tentado usar o gerador, se nao existisse ele iria criar uma solicitação na
    skill pra criacao do que nao existe, e depois disso, geraria algo
    forçadamente o mais proximo do possivel com base no material do DS, mas nao
    foi isso que aconteceu. isso é inadimissivel."

E, logo depois, a pergunta que criou ESTE arquivo:

    "se as capas ficaram erradas, fico questionando o que mais ele deixou de
    aplicar na criação"

**Essa pergunta não tinha como ser respondida**, e é esse o defeito de fundo. A
skill já trazia a regra ("logo, símbolo e EEny (…) nunca redesenhados"), já
trazia os arquivos oficiais e já trazia o gerador do grafismo — e até uma seção
"Antes de entregar — a checagem", com caixas de marcar. **Caixa de marcar que o
próprio produtor marca de cabeça não é medição: é promessa.** A casa já sabia
disso e a regra estava escrita no CLAUDE.md global:

    "Canônico que afirma proteção inexistente é pior que canônico omisso — quem
    lê acha que está coberto. Toda afirmação de proteção aponta o TESTE que a
    prova; sem teste, a frase sai ou vira pendência."

Este arquivo é o teste que faltava. Ele não olha o código que gerou a peça: olha
**a peça pronta** — o PDF, o SVG ou o HTML que vai para o cliente — e mede.

O QUE ELE MEDE (contrato CP)
----------------------------
  CP-1  COR — toda cor pintada existe no `02-tokens/seed-tokens.json`
        (desde a CP-P30, 2026-09-29: a cor pintada DENTRO de um asset oficial
        que o CP-5 casou — sobreposição ≥ 0,90 — e que está na paleta do
        próprio SVG desse asset não reprova; sai listada como isenta. Motivo
        medido: 7 de 14 logos/símbolos oficiais pintam cor fora do token — o
        `#00ACAC` do logo e do símbolo principais, e a família vertical — e 6
        de 6 PDFs do repositório com o logo principal reprovavam por ele. A
        mesma cor FORA do asset, ou outra cor dentro dele, segue reprovando)
  CP-2  TIPOGRAFIA — toda fonte pertence às famílias do DS (Montserrat, JetBrains
        Mono, Caveat); fonte de sistema é vazamento de fallback
  CP-3  TAMANHO — em peça de tela, o menor degrau da escala (§4.7); em peça
        impressa, os pisos do §4.8 do marca-seed.md (8 pt absoluto, 10 pt no
        texto corrido, 12 pt em cláusula contratual por força do CDC)
  CP-4  CONTRASTE — todo texto atinge o piso da WCAG 2.2 AA medido contra o
        fundo REAL, amostrado no render (4,5:1; 3:1 para texto grande)

        ⚠ Em ENTRADA HTML, estes quatro itens (CP-1 a CP-4) são medidos na
        TELA (o navegador a 1440×900, a mesma janela do CP-13 e da régua RC
        `contraste-raster.mjs`), NUNCA no PDF impresso — corrigido na CP-P7
        (2026-09-15/16, ver o relatório da pendência e o cabeçalho de
        `confere_html`). Até então, HTML passava pelo MESMO `--print-to-pdf`
        de uma peça impressa, que reflui a página inteira para o tamanho de
        papel Letter (612×792 pt ≈ 816×1056 px a 96 dpi) — bem menor que os
        1440×900 em que a tela é desenhada — e o CP-4 chegava a medir METADE
        do contraste real (2,94 contra 5,79 na mesma tela, mesmo texto) porque
        a coordenada onde deveria estar o gradiente do rail cai fora dele, ou
        sobre fundo de página, no layout reflusído. Reprovava 5 de 11 telas de
        referência do shell por isso — nenhuma delas tinha texto realmente
        ilegível. Para PDF de verdade (e para CP-5 em diante, mesmo em HTML)
        o caminho continua sendo o PDF, que é o formato certo para medir.
  CP-5  MARCA — todo desenho de logo/símbolo casa com um asset oficial por
        sobreposição. É o item que pegaria os dois círculos. O piso é **0,90**,
        e é medido, não escolhido: a logo oficial passando por HTML → PDF →
        raster mede 0,946 contra o próprio SVG de origem; exigir 0,95 reprovaria
        o arquivo certo. Desde a leva 14 (CP-P35/CP-P36) o CP-5 também reprova a
        marca de FORMA ALTERADA — achatada ou esticada (casa com um asset numa
        grade quadrada, mas não tem a proporção dele: tolerância de 12 %) — e o
        painel com a cor e a proporção de `cor-azul`/`cor-cinza` sem marca oficial
        dentro (redesenho)
        Desde a leva 16 (2026-10-03): a peça HTML é impressa NO TAMANHO QUE DECLARA (`alvo-fisico` em mm, `alvo-tela`
        em px; sem declaração, papel padrão do Chrome — CP-P43); o "REDESENHO" exige também uma cor da paleta do asset,
        porque a forma compacta com vazados (sol, disco, placa ISO 7010) não é redesenho (CP-P44); o regime E1 da marca
        d'água (contorno turquesa-100, §12.2-b) sai da comparação e fica registrado, e a marca oficial de logo/símbolo
        CORTADA pela borda da própria peça, fora desse regime, reprova (CP-P46); abaixo do piso de 25 × 12 pt a marca
        entra só como evidência positiva
  CP-6  GRAFISMO — a cena de onda casa com a saída do `gen-detalhe.py` na largura
        da peça (proporção natural e geometria)
  CP-7  GRAFIA — a marca é escrita "SEED engenharia" (marca-seed.md §14.16)
  CP-8  DADO INSTITUCIONAL — telefone, 0800, CNPJ e domínio conferem com o
        `sobreaseed.md`
  CP-9  SEGREDO — nenhuma chave, token ou credencial (reprova). Em peça pronta
        (PDF/SVG/HTML), mede o TEXTO IMPRESSO; no modo `--alvo` (desde a
        CP-P10, 2026-09-20), mede também o CÓDIGO do repositório do app —
        arquivos rastreados e todo `.env*` presente no disco, rastreado ou
        não. Um JWT (o formato das chaves do Supabase) se classifica pelo
        `role` do payload, decodificado e NUNCA impresso: `service_role`
        reprova (chave de serviço, acesso total); `anon`/`authenticated`/
        `publishable` é CP-9b (identificador)
  CP-9b IDENTIFICADOR — número longo de plataforma de anúncio, ou (no `--alvo`)
        um JWT com `role` público (avisa: pode ser legítimo num relatório da
        própria conta ou num produto que o embute por desenho, indevido numa
        peça pública sem necessidade)
  CP-10 CAMADA DE MÁQUINA — o arquivo tem título próprio e texto real (não é
        imagem escaneada): um agente consegue ler e citar
        (desde a CP-P28, 2026-09-29: PEÇA DE TELA — `alvo-tela` preenchido e
        `alvo-fisico` vazio, a convenção do marca-seed §12.30/§12.32, ou a
        opção `--alvo-tela LxApx` para PDF/SVG — fica fora da régua de VOLUME
        de texto, que antes era escolhida pelo tamanho da página e reprovava
        slide 4:5 com 160 caracteres; o título e o que o DOM mede continuam)
        (desde a CP-P51, 2026-10-04: o <h1> único e real só é cobrado de quem o
        canon manda — página MK2, e-mail EL6, slide §12.32/§12.33, tela de
        aplicação SH10 —; peça FÍSICA (`alvo-fisico`) e IMAGEM de rede social
        (`peca` = capa-rede ou destaque-instagram) ficam fora desta parte, e a
        ficha diz "h1: não se aplica" com o motivo; sem declaração, exige-se)
        (desde a CP-P54, 2026-10-05: FRAGMENTO de e-mail declarado — comentário de abertura com `peca: fragmento-email` E
        sem a tag html — não tem título, `lang`, `<h1>` nem volume de texto cobrados; a `nav` com nome segue medida)
  CP-11 GEOMETRIA DE PÁGINA — num documento de várias páginas, o miolo tem a
        MESMA margem superior e não começa colado na borda. Entra em 2026-09-08:
        a ficha deu 10 de 10 numa peça cujo texto começava a 0,6 mm do topo da
        segunda página em diante, e quem viu foi o decisor, não a régua
  CP-12 FOLHA APROVEITADA — nenhuma folha de miolo sai quase em branco (piso de
        5 % de tinta, medido), e nenhum rótulo fica órfão no pé da folha com o
        conteúdo dele na seguinte. Entra em 2026-09-09: a ficha deu 11 de 11 num
        relatório com uma folha de 0,7 % de tinta e um subtítulo partido no meio
        da frase — `break-inside: avoid` num bloco mais alto que meia folha.
        Peça de TELA (a mesma declaração do CP-10, CP-P28) não tem folha: o
        item sai PASS "não se aplica", como o CP-13 faz com arquétipo declarado
  CP-13 SHELL (só HTML) — uma TELA DE APLICAÇÃO renderiza as regiões do shell
        (desde a SH-P14, 2026-09-15: rail de 80 px no estado PADRÃO ou 56 px
        quando o `.app` declara `data-rail="recolhido"`, SH19 — a régua lê o
        estado, nunca adivinha)
        (desde o SH21, F12.9, 2026-09-19: o MENU DE CONTA — o avatar é <button>
        com `aria-haspopup="menu"`, abre um painel `role="menu"` que existe de
        verdade, com os três estados de tema como `menuitemradio`, e vive na
        zona direita da topbar; em produto SEM topbar, na base do rail, acima do
        botão de recolher do SH19 — decisão dele no gate SH-P16, MANIFESTO
        §235.2)
        §46 do `seed-componentes.md`: rail (SH2/SH3), topbar (SH10) e conteúdo
        (SH1/SH7), medidos no DOM que o Chrome renderizou a 1440×900, nunca por
        regex no fonte. Entra em 2026-09-14 (F11.5, pendência LC-11): a sessão
        do ERP construiu a tela "Configurar funil" SEM rail e SEM topbar e o
        decisor reprovou duas vezes ("o quadro aprovado do ERP tem uma rail à
        esquerda") — a regra SH17 do §46.4 diz que no produto toda tela de
        aplicação vive dentro do shell, e nada mecânico media isso. Como a régua
        sabe se o HTML É tela de aplicação: pela RAIZ DE CONSULTA que o CP27 do
        `seed-composicao.md` já prescreve (o `container-name` mais externo):
        `app` = tela de aplicação (shell obrigatório) · `pagina` = página pública
        (não se aplica) · `tela` ou `conteudo` = arquétipo publicado SEM shell NA
        BANCADA (CP28) — aí a régua não adivinha: sai NÃO MEDI e pede
        `--arquetipo aplicacao|autenticacao|pagina|bancada|documento`. Desde 2026-09-15
        (SH18, §46.5, veredito V-LC11) a tela DECLARA o arquétipo no `<html>`
        (`data-arquetipo`, a mesma lista fechada) e a régua mede sem opção nenhuma;
        a opção continua como sobrescrita explícita de quem mede.
        (desde a CP-P9, 2026-09-20: DOIS REGIMES, porque a topbar NÃO é região
        obrigatória. O SH1 (§46.1) exige "conteúdo + ao menos uma navegação" e o
        SH21 (§46.6, v1.67) legisla para "produto SEM topbar"; até aqui a régua
        reprovava "topbar AUSENTE" em TODA tela de aplicação e contradizia o
        canon que ela existe para provar. Quem achou foi a cobaia real do aceite
        da skill (MANIFESTO §236.6/§236.7): o `--alvo` sobre o SEED Chat — que
        por desenho não tem topbar — saiu FAIL. Agora, COM topbar o contrato do
        SH10 vale inteiro; SEM topbar a régua cobra, no lugar dela, o avatar do
        menu de conta na BASE DO RAIL (SH21/V-SH16.2) e o rail como coluna de
        altura total à esquerda do conteúdo (SH3) — e a ficha diz qual regime
        mediu. Vale nos dois caminhos: no DOM e no CP-13 ESTÁTICO do `--alvo`,
        onde a ausência de topbar com rail presente virou AVISO com o que a
        leitura estática não alcança. No mesmo movimento entra a exigência que
        o SH10 já escrevia sem teste: UM `h1` visível dentro do `<main>` — "o
        título da tela vive no CONTEÚDO, não na topbar" — cobrada nos dois
        regimes; medido antes de escrever, 22 de 22 artefatos do acervo já a
        cumprem, então ela não reprova nada do que existe.)
        (desde a CP-P11, 2026-09-20: NÃO MEDI sobre a tela ANTERIOR À SESSÃO. Uma
        aplicação de página única atrás de login, medida com `--alvo --url` sem
        sessão aberta, é a tela de autenticação (A19), não o produto — e a régua
        reprovava "rail AUSENTE · conteúdo AUSENTE · menu de conta AUSENTE", três
        achados que não falam do app. Achado pela cobaia real da CP-P9 (MANIFESTO
        §237.7, relatório (f).2). Agora: sem rail E sem topbar no DOM, e o
        artefato não se autodeclarando `aplicacao` (SH18) ou com cara de tela de
        login (campo de senha, ou quase nenhum texto visível), a ficha diz "a
        origem servida não montou a aplicação (parece a tela anterior à sessão)
        — sirva uma rota autenticada ou rode sem --url", em vez de reprovar. Um
        app que DECLARA `aplicacao` de verdade e tem o shell genuinamente
        quebrado continua reprovando normalmente — ver `confere_shell`.
        Desde a CP-P22, 2026-09-27: essa frase é só da origem servida; sobre um
        arquivo HTML LOCAL, que não teve `--url`, o mesmo NÃO MEDI diz "este
        arquivo HTML não tem rail nem topbar …" e o que fazer com ele.)

  CP-14 MATERIAL (só --alvo, por enquanto) — a camada de vidro e metal do §93
        está LIGADA e COERENTE: `data-material="vidro-metal"` no <html> (MA1), o
        item `@seed/material` instalado e importado, o rail com
        `.seed-mat-metal-rail` (MA2) e NENHUM vidro/metal escrito à mão fora do
        módulo único (MA8, cujo anti-padrão nomeado é "CSS de material no
        molde"). A camada é OPCIONAL pelo registry — por isso não a ter é AVISO,
        e o que reprova é INCOERÊNCIA. Entra em 2026-09-19 (F12.2): em 2026-09-18
        o decisor pediu a adequação dos dois apps de chat da empresa e teve de
        dizer à IA, ele mesmo, que "o rail é metálico e com bordas arredondadas"
        — o §93 existia, a skill citava, e nada media
  CP-15 REGISTRY (só --alvo) — `@seed/tokens`, `@seed/fontes` e `@seed/theme`
        instalados e provados por MD5 contra o que o registry publica; nenhuma
        cópia TRANSCRITA (arquivo que redeclara `--seed-*` fora do arquivo
        instalado, ou apelido do shadcn com valor que não é um semântico do DS).
        Entra em 2026-09-19 (F12.2): no app da Clara, no Lovable, a IA
        transcreveu o tema num `seed-theme-map.css` em vez de instalar o item

O MODO `--alvo <pasta do app>` (2026-09-19, F12.2)
--------------------------------------------------
Os itens acima nasceram para medir uma peça PRONTA. O `--alvo` aponta o mesmo
contrato para um REPOSITÓRIO consumidor inteiro, por análise ESTÁTICA: o censo
vem do `git ls-files` do alvo (ou do `os.walk`, dizendo que o alvo não é git), e
sobre ele correm CP-1 (cor), CP-2 (tipografia), CP-9/CP-9b (segredo e
identificador — desde a CP-P10, 2026-09-20; ver `varre_segredo_alvo`), CP-13
estático (presença das regiões do §46), CP-14 e CP-15. A saída é a
**FICHA-ADEQUACAO.md** no diretório corrente, com o denominador no cabeçalho e
uma tabela `arquivo:linha · achado · classe · seção do DS que responde`. Com
`--url <origem servida>` o DOM daquela origem é reconstituído e medido pelo
caminho HTML de sempre; sem `--url`, os itens que dependem do navegador saem
**NÃO MEDI**.

A saída é a **FICHA DE CONFORMIDADE**: placar com denominador por item, e a lista
do que reprovou com página e trecho. Sem denominador não é placar (regra da casa).

USO
---
    python 06-validacao/ferramentas/conferir-peca.py <arquivo> [...]
    python 06-validacao/ferramentas/conferir-peca.py --arquetipo aplicacao tela.html
    python 06-validacao/ferramentas/conferir-peca.py --alvo <pasta do app> \
           [--url <origem servida>] [--arquetipo aplicacao]

Formatos: **PDF** (direto), **SVG** (lido pelo MuPDF; a família de fonte vem da
DECLARAÇÃO do arquivo, porque o MuPDF substitui fonte na leitura) e **HTML**
(duas medições pelo MESMO Chrome, desde a CP-P7: CP-1 a CP-4 na TELA — o
navegador renderiza a 1440×900 e a régua compara duas capturas, com e sem
texto, pela mesma técnica de máscara-por-diferença da RC — e CP-5 em diante no
PDF impresso, que é o caminho certo para geometria de página, marca e conteúdo.
Para o CP-13 e a parte do CP-10 que é do DOM, o MESMO Chrome a 1440×900,
sondado por script: título, `lang`, `h1`, `nav` nomeadas, raiz de consulta e a
geometria computada do rail, da topbar e do conteúdo).

O QUE A ENTRADA HTML NÃO MEDE (a régua diz o próprio alcance — ficha limpa não é
peça certa): estado de interação (hover, drawer SH12, paleta ⌘K, painel lateral) ·
o tema escuro (o DOM é sondado no tema em que a página abre, para CP-1 a CP-4 também) ·
texto com `background-clip: text` (tinta em gradiente — fora do alcance do CP-4 na
tela, declarado na sonda) · larguras estreitas
(o colapso do SH12 em 360 px é da `render-shell.mjs` e do gate) · a sidebar (SH1 a
declara opcional; a largura dela sai INFORMADA, não julgada — ver o comentário do
`confere_shell`) · o contraste sobre vidro/metal pixel a pixel (régua RC,
`guarda-material.py`) · comportamento de foco na troca de rota (SH7 é medido só
pelo `tabindex="-1"` do `main`, não pelo foco em si) · a marca do item ativo em
escala de cinza (DF4, gate visual).

DEPENDÊNCIA: PyMuPDF (`pip install --user pymupdf`) para ler e rasterizar o PDF.
Sem ela o script diz NÃO MEDI — nunca reprova o que não conseguiu medir.
"""
import argparse
import atexit
import collections
import io
import json
import os
import pathlib
import re
import sys
import unicodedata

sys.stdout.reconfigure(encoding='utf-8', errors='replace')

# PR-P189 (leva 46): contagem das leituras do DOM da peça HTML (o CP-10 só vale com o DOM lido); o `main()` imprime o fim dela
_DOM_LOG = {'chamadas': 0, 'sem_resposta': 0}

# ── onde estão o canon e os assets ──────────────────────────────────────────
# O MESMO script roda em dois lugares: dentro do repositório do DS e dentro do
# pacote da skill `seed-ds` instalado na máquina de quem produz. Os caminhos são
# diferentes, então são PROCURADOS, não presumidos — e o script diz qual achou.
def _primeiro(*candidatos):
    for c in candidatos:
        if c.exists():
            return c
    return None


_AQUI = pathlib.Path(__file__).resolve()
_REPO = _AQUI.parents[2]                       # …/seed-design-system
_SKILL = _AQUI.parents[2]                      # …/seed-ds  (scripts/06-validacao/ferramentas/…)

TOKENS = _primeiro(_REPO / '02-tokens' / 'seed-tokens.json',
                   _SKILL / 'references' / 'seed-tokens.json',
                   _AQUI.parents[3] / 'references' / 'seed-tokens.json')
EMPRESA = _primeiro(_REPO / '01-canonicos' / 'sobreaseed.md',
                    _SKILL / 'references' / 'empresa.md',
                    _AQUI.parents[3] / 'references' / 'empresa.md')
ASSETS = _primeiro(_REPO / '03-assets',
                   _SKILL / 'assets',
                   _AQUI.parents[3] / 'assets')
GERADOR = _primeiro(_REPO / '06-validacao' / 'geradores' / 'gen-detalhe.py',
                    _AQUI.parent.parent / 'geradores' / 'gen-detalhe.py')

# ── a escala do DS, lida do token, nunca digitada ───────────────────────────
PT_POR_PX = 0.75          # 1 px CSS = 0,75 pt
FAMILIAS = ('montserrat', 'jetbrainsmono', 'jetbrains mono', 'caveat')

# Pisos de peça IMPRESSA — marca-seed.md §4.8, criado em 2026-09-08 por veredito
# dele ("pode adotar a tabela acima", pendência CP-P1). Antes disso o canon só
# tinha escala de tela e este item avisava em vez de reprovar.
PISO_IMPRESSO_PT = 8.0    # piso absoluto: rótulo, legenda, nota, célula de tabela
PISO_CORRIDO_PT = 10.0    # texto corrido de documento (informativo, não reprova)

# Geometria do shell §46 — `seed-componentes.md` v1.56, §46.2 "Anatomia, tokens e
# fronteiras" e SH3/SH10 do §46.1. As três medidas NÃO existem como token no
# `seed-tokens.json` (conferido em 2026-09-14: o gêmeo só tem `rail.background`
# e os overlays de chrome); o canon as escreve como número MEDIDO ("80px dão
# 68px úteis = 9 caracteres", `medir-rail.mjs`). Por isso ficam aqui como
# constante COM a citação — se o §46.2 mudar, é esta linha que envelhece, e o
# `grep 'RAIL_PX'` acha a conta.
RAIL_PX = 80.0            # SH3: "largura 80px", altura total, à esquerda da topbar — o estado PADRÃO (`data-rail="expandido"`)
RAIL_RECOLHIDO_PX = 56.0  # SH19 (SH-P14, 2026-09-15, V-SH14.1 = b): estado RECOLHIDO só ícone = item sp-12 (48) + calha sp-1 (4)
RAIL_LARGO_PX = 160.0     # SH19 (SH-P46, 2026-10-08, Q2): a Expandida (`data-rail="largo"`) = sp-16 (64) + sp-24 (96); abaixo do
                          # limiar do Q3 (janela 1104 com sidebar, 1074 com escopo) ela CAI para os 80 do padrão — a 1440 nunca cai
                          # de cada lado; vale SÓ quando o `.app` declara `data-rail="recolhido"` — a régua lê o atributo
TOPBAR_PX = 60.0          # §46.2: "Topbar: altura 60px"
SIDEBAR_PX = 280.0        # §46.2: "Sidebar: largura 280px" — informativo no CP-13 (SH1: região opcional)
TOLERANCIA_PX = 1.0       # arredondamento de layout (metade de pixel em cada borda)
DOM_LARGURA, DOM_ALTURA = 1440, 900   # a janela em que a casa mede tela de aplicação (guarda-material, render-shell)
# CP-P19 (2026-09-26): quanto o conferidor espera o Chrome antes de encerrar a ÁRVORE dele e dizer NÃO MEDI.
# Os números são os que já existiam (180 s por chamada; 240 s no `--url`); o que mudou é que agora eles valem
# no Windows — ver `_roda_chrome`.
TIMEOUT_CHROME_S = 180
TIMEOUT_CHROME_URL_S = 240
# PR-P163 (2026-10-13): o Chrome headless segue o tema do SISTEMA (`prefers-color-scheme`): medido em 2026-10-13 numa
# cobaia com `matchMedia('(prefers-color-scheme: dark)')`, o Windows desta máquina em escuro dava `dark=true` nos quatro
# pontos que lançam o Chrome (DOM, contraste na tela, PDF e `--url`). A mesma peça mediria cores diferentes em máquinas
# diferentes. A flag abaixo fixa o tema CLARO na própria linha de comando (medida: `dark=false`, também com
# `--force-dark-mode`); vale em TODA linha `--headless=new` deste arquivo — a prova `prova-tema-claro-chrome.py` conta 4 de 4.
CHROME_TEMA_CLARO = '--blink-settings=preferredColorScheme=1'
# CP-P11 (2026-09-20): piso de "poucos nós de texto visível" para reconhecer a tela ANTERIOR à sessão
# (regra da casa: condição por FATO medido, não por adivinhação). Medido antes de escolher o número: o
# SEED Chat sem sessão (raiz do <div id="root"> nunca montada) tem 0; a tela-autenticacao.html do
# próprio acervo, com formulário renderizado, tem 22; a menor tela de aplicação de referência
# (tela-chat.html) tem 73. O piso fica bem abaixo do menor caso REAL (73) para nunca confundir uma tela
# de produto pequena com uma raiz vazia — por isso a régua também usa `campo_senha` (abaixo) como sinal
# independente, que cobre a tela de autenticação de verdade sem depender deste número.
POUCOS_TEXTOS_LIMITE = 3

# A raiz de consulta do CP27 (`seed-composicao.md` v2.3, lista fechada de
# contêineres nomeados) e o que ela diz sobre o shell, pelo SH17 (§46.4) e pelo
# CP28: a régua LÊ o fato do artefato e não adivinha o resto.
RAIZ_PARA_ARQUETIPO = {
    'app': 'aplicacao',       # o shell é a raiz — tela de aplicação
    'pagina': 'pagina',       # página pública/institucional (A5, §47) — fora do shell
    # 'tela' e 'conteudo' são raízes de arquétipo publicado SEM shell NA BANCADA
    # (CP28): no produto podem ser tela de aplicação (A18 configurações, A13
    # documento, A4 painel) ou não (A19 login). Ambíguo → NÃO MEDI sem --arquetipo.
}
ARQUETIPOS = ('aplicacao', 'autenticacao', 'pagina', 'bancada', 'documento')  # lista fechada do SH18 (§46.5)
NAO_SE_APLICA = {
    'autenticacao': 'A19 — antes da sessão não há app (SH17, §46.4)',
    'pagina': 'A5 — página pública/institucional não é aplicação (SH17; §47)',
    'documento': 'documento exportado — o PDF/impresso não tem chrome (SH17; C25 do §88)',
    'bancada': 'arquétipo medido SEM shell na bancada (CP28) — no produto ele vive no shell (SH17)',
}


# CP-P28 (2026-09-29): PEÇA DE TELA × PEÇA FÍSICA, lida da DECLARAÇÃO da peça, nunca do tamanho da página. A
# convenção é a do marca-seed §12.30 (capas de rede) e §12.32 (deck): "Peça de TELA: `alvo-fisico` vazio e
# `<meta name="alvo-tela" content="LxApx">`". As duas expressões são as MESMAS do `render-peca.mjs` e da
# `guarda-peca-fresca.mjs` (quem declara `alvo-fisico` em mm é peça física, e isso vence o `alvo-tela`).
# Antes, o CP-10 escolhia a régua de volume de texto por "> 500 × 700 pt = documento" e reprovava slide 4:5
# (1080 × 1350 px = 810 × 1012 pt, 160 caracteres), e o CP-12 lia o slide de conteúdo do deck como folha
# vazia (4,6 % de tinta) — régua que adivinha o tipo pela forma erra no primeiro caso misto (regra da casa).
_RE_ALVO_FISICO = re.compile(r'<meta name="alvo-fisico" content="([\d.]+)x([\d.]+)mm">')
_RE_ALVO_TELA = re.compile(r'<meta name="alvo-tela" content="(\d+)x(\d+)px">')
_RE_ALVO_TELA_CLI = re.compile(r'\d+x\d+px')


def alvo_declarado(texto_html, alvo_tela_cli=None):
    """{'tipo': 'tela'|'fisico'|None, 'valor': str|None, 'origem': str}. `alvo_tela_cli` (a opção --alvo-tela)
    é sobrescrita explícita de quem mede, como a --arquetipo; sem ela, vale o que o HTML declara."""
    if alvo_tela_cli:
        return {'tipo': 'tela', 'valor': alvo_tela_cli, 'origem': 'opção --alvo-tela'}
    texto_html = texto_html or ''
    mf = _RE_ALVO_FISICO.search(texto_html)
    if mf:
        return {'tipo': 'fisico', 'valor': '%sx%smm' % mf.groups(), 'origem': '<meta name="alvo-fisico">'}
    mt = _RE_ALVO_TELA.search(texto_html)
    if mt:
        return {'tipo': 'tela', 'valor': '%sx%spx' % mt.groups(),
                'origem': '<meta name="alvo-tela"> com alvo-fisico vazio'}
    return {'tipo': None, 'valor': None, 'origem': 'sem declaração'}


def _eh_tela(alvo):
    return bool(alvo) and alvo.get('tipo') == 'tela'


# CP-P51 (2026-10-04): o <h1> único e real é contrato de PÁGINA (MK2, seed-componentes.md §47), de E-MAIL (EL6, seed-email.md), de
# SLIDE (marca-seed §12.32/§12.33: um h1 por slide; deck e carrossel, um só, o da capa) e de TELA DE APLICAÇÃO (SH10: o título vive
# no conteúdo). O canon NÃO o pede de peça FÍSICA (o HTML é a fonte de um arquivo impresso: placa, cartão, frota, timbrado) nem da
# IMAGEM de rede social (capa, destaque: o HTML é a fonte do PNG exportado). Antes o CP-10 cobrava h1 de tudo e reprovava 163 de
# 189 peças aprovadas (medido em 2026-10-04): régua que reprova em bloco media outra coisa. A leitura é pela DECLARAÇÃO da peça
# (`alvo-fisico`, `peca`), nunca pela forma — a mesma convenção do CP-P28 —; sem declaração, o h1 continua exigido.
_RE_PECA = re.compile(r'<meta name="peca" content="([^"]*)">')
_PECAS_IMAGEM_DE_REDE = ('capa-rede', 'destaque-instagram')


def h1_exigido(alvo, texto_html):
    """(exigido: bool, motivo: str). `motivo` só quando NÃO se exige, para a ficha dizer por quê."""
    if alvo and alvo.get('tipo') == 'fisico':
        return False, 'peça física (%s): o canon pede <h1> de página, e-mail, slide e tela, não de peça impressa' % alvo['valor']
    if _eh_tela(alvo):
        m = _RE_PECA.search(texto_html or '')
        if m and m.group(1) in _PECAS_IMAGEM_DE_REDE:
            return False, 'imagem de rede social (peca=%s): o HTML é a fonte do arquivo de imagem, não uma página' % m.group(1)
    return True, ''


# CP-P54 (leva 20): FRAGMENTO de e-mail. A assinatura para colar (`assinatura-email-colar.html`) não tem as tags raiz de
# propósito (é colada DENTRO do compositor de mensagem), então título, `lang`, `<h1>` e volume de texto de DOCUMENTO não se
# aplicam a ela — a `suite-email.mjs` já a trata assim (fragmento = sem a tag html no texto, 18 PASS · 5 SKIP). Lido pela
# DECLARAÇÃO, nunca só pela forma (a convenção do CP-P28): o comentário de abertura do arquivo diz `peca: fragmento-email`
# E o texto não tem a tag html; só as duas juntas dispensam. Fragmento sem a declaração continua medido como documento.
# ALTERNATIVA (não recomendada por padrão): MOLDE de papel. `<meta name="molde" content="...">` numa peça FÍSICA diz que a
# folha é um molde (papel timbrado, envelope, pasta) cujo texto vem depois: o volume de texto de documento não se aplica.
_RE_MOLDE = re.compile(r'<meta name="molde" content="([^"]+)">')


def molde_declarado(alvo, texto_html):
    m = _RE_MOLDE.search(texto_html or '')
    if m and alvo and alvo.get('tipo') == 'fisico':
        return 'molde de papel declarado (molde=%s): o texto vem depois' % m.group(1)
    return ''


_RE_FRAGMENTO = re.compile(r'\bpeca:\s*fragmento-email\b')


def fragmento_declarado(texto_html):
    """Motivo (str) quando a peça se declara fragmento de e-mail; '' caso contrário."""
    t = texto_html or ''
    abertura = t.split('-->', 1)[0] if t.lstrip().startswith('<!--') else ''
    if abertura and _RE_FRAGMENTO.search(abertura) and '<html' not in t.lower():
        return 'fragmento de e-mail declarado (peca: fragmento-email): colado dentro do compositor, sem as tags raiz de um documento'
    return ''


# CP-P65 (leva 48, 2026-10-28): peça SEM TEXTO POR DESENHO (a capa só de logo da agenda). Desde a PR-P200 o conferidor sai 2 quando não há texto
# desenhado (CP-3 NÃO MEDI de quem devia medir), e a capa só de logo não tinha como dizer «não tem texto de propósito». A declaração mínima, no
# estilo do `alvo-fisico`/`molde` (CP-P51/CP-P54): `<meta name="sem-texto" content="MOTIVO">` no HTML, ou `--sem-texto "MOTIVO"` para PDF/SVG/HTML.
# O MOTIVO é obrigatório (declaração sem motivo não vale: vira a mesma peça sem declaração) e a declaração só dispensa o que NÃO TEM o que medir
# (CP-2, CP-3 e CP-4 sem texto); se a peça TEM texto, o texto é medido como sempre e a ficha traz um AVISO de que a declaração não confere.
_RE_SEM_TEXTO = re.compile(r'<meta name="sem-texto" content="([^"]*)">')

# CP-P66 item 3 (leva 49, 2026-10-30): a `<meta sem-texto>` só vale NO `<head>` REAL. Em 2026-10-30 o HEAD achava a meta com um `re.search` depois de apagar os comentários, e por isso
# valia a meta escrita dentro de `<style>`, `<script>`, `<textarea>`, `<title>`, `<![CDATA[ … ]]>` e depois de `<body>` — lugares onde o navegador a trata como TEXTO (ou onde já não é o
# head). Pior: a limpeza ingênua de comentário `<!--.*?-->` COMIA a meta válida quando um `<style>` continha `<!--` (no CSS não é comentário). Agora é uma varredura em ORDEM, como o
# tokenizador do navegador: um comentário `<!--…-->` (e os fechos curtos `<!-->` e `<!--->`), um CDATA, um elemento de TEXTO CRU (`style`, `script`, `textarea`, `title`, `noscript`: o
# conteúdo vai até o fecho dele; sem fecho, vai até o fim do arquivo) e o corpo (`<body` ou `</head>`: a varredura PARA, o head acabou) são PULADOS; a primeira `<meta sem-texto>` que
# sobra é a declaração. Na dúvida a varredura não declara (a peça sai 2, NÃO MEDI, e não 0): o CDATA em HTML é um comentário "falso" que o navegador fecha no primeiro `>`, e aqui ele é
# pulado até o `]]>` (mais largo, o lado seguro). Sem `<head>` explícito (fragmento só com metas) a meta vale: não há `<body` antes dela.
#
# CP-P68 (leva 50, 2026-10-31): fecha o que cabe SEM tokenizador de atributos (o que o A2 tentou e a sessão reverteu na leva 49 fica fora). (a) O nome da tag termina em espaço, `/` ou `>`
# (`(?=[\s/>])`), não em fronteira de palavra: `</title-x>` não fecha o `<title>` e `<style-x>` não é `<style>`. (b) `<template>` é inerte: um contador de profundidade
# ignora a meta (e os elementos de corpo) dentro dele; as guardas de `<title>` e `<style>` dentro do template seguem valendo. (c) Todo elemento que o parser de HTML NÃO aceita no
# head (qualquer nome fora de `_NOMES_DO_HEAD`: `div`, `p`, `svg`, `iframe`, `xmp`, `noembed`, elemento desconhecido…) encerra o head, como no navegador ("anything else" do modo
# "in head"); `<plaintext>` e `<body>` também. `</body>` e `</html>` idem. (d) `--!>` fecha o comentário (HTML5, erro de parse mas fecha). (e) O CDATA sem `]]>` é lembrado:
# a busca do `]]>` até o fim do arquivo era refeita a cada `<![CDATA[>` (quadrático: 500 KB = 4,3 s, 1 MB = 15 s); agora é uma busca só.
_RE_VARREDURA_HEAD = re.compile(r'<!--|<!\[CDATA\[|<(style|script|textarea|title|noscript|noframes)(?=[\s/>])|<(template)(?=[\s/>])|</(template)(?=[\s/>])|<body(?=[\s/>])|</head(?=[\s/>])'
                                r'|</(?:body|html)(?=[\s/>])|<meta(?=[\s/>])|<([a-z][^\s/>]*)', re.I)
_RE_FIM_COMENTARIO = re.compile(r'--!?>')
# o que o modo "in head" do HTML5 aceita sem encerrar o head (`<html>` e `<head>` são os do próprio documento)
_NOMES_DO_HEAD = frozenset(('html', 'head', 'meta', 'link', 'base', 'basefont', 'bgsound', 'title', 'style', 'script', 'noscript', 'noframes', 'template'))


def _meta_sem_texto_no_head(texto_html):
    """Motivo (str) da primeira `<meta name="sem-texto" content="…">` que está no head real; '' se não há.
    LIMITES CONHECIDOS (A3, leva 49, medidos em 2026-10-30; CP-P68 da leva 50 fechou o que cabia sem tokenizador de atributos, 2026-10-31; provas: `prova-conferidor-leva49.py` L1 a L6 e
    `prova-conferidor-leva50.py` S1 a S7). A varredura NÃO lê atributos. FALSOS POSITIVOS que SOBRAM (a declaração VALE, o navegador não a trata como meta do head): a meta escrita no VALOR de
    um atributo (`<link title='<meta name="sem-texto" content="x">'>`) e no atributo de uma tag de fecho (`</span title='<meta…>'>`). FECHADOS na leva 50: `</title-x>` / `</style-x>` (o nome da
    tag termina em espaço, `/` ou `>`), a meta dentro de `<template>` (contador de profundidade), `<iframe>`, `<plaintext>`, `<xmp>`, `<noembed>` e qualquer elemento fora de `_NOMES_DO_HEAD` antes
    da meta sem `<body>` (`<head><div>x</div><meta…>`), `</body>` e `</html>`. FALSOS NEGATIVOS que SOBRAM (a declaração NÃO vale, a peça sai 2): `<a title='<!--'>` e `<meta content="<style>">` antes
    da meta (a varredura abre o comentário/CSS que está dentro do valor), CDATA com `>` antes do `]]>` (pulado até o `]]>`, o navegador o fecha no primeiro `>`) e, NOVO na leva 50 e do lado
    seguro, um elemento de corpo escrito DENTRO do valor de um atributo antes da meta (`<link title='<div>'>`: o `<div>` do valor encerra o head). `--!>` agora FECHA o comentário (HTML5).
    FALSOS POSITIVOS que JÁ EXISTIAM na base (ed906a129) e que esta lista não trazia (a revisão do trabalho C os mediu em 2026-10-31, `revisao/contornos.txt` T1 a T4; base e novo dizem «vale» nos quatro;
    nenhum cala texto): (T1) texto que não é espaço escrito no head antes da meta (o HTML5 encerra o head num caractere de texto; aqui a meta depois dele ainda vale); (T2) `</br>` no head (o HTML5
    o trata como «anything else» e encerra o head); (T3) o comentário falso `</ ` (bogus comment do HTML5, vai até o primeiro `>`) que engole a meta, e que aqui não é visto; (T4) `</template>` escrito
    DENTRO do valor de um atributo, que o contador de profundidade conta como fecho (mesma classe «atributo» já declarada acima).
    DESEMPENHO: o CDATA sem `]]>` é lembrado (uma busca só); `<![CDATA[>` x 50 mil (500 KB) 4,3 s → ver a prova S7, 1 MB 15 s → idem (REVISAO-2 achado 3 da leva 49). `_RE_SEM_TEXTO` só aceita
    aspas duplas, minúsculas e `>` sem `/`. POR QUE É ACEITÁVEL: a declaração NUNCA cala texto (com texto na peça a ficha avisa e mede como sempre); ela só troca a saída 2 (NÃO MEDI)
    por «não se aplica» numa peça SEM texto. O tokenizador de atributos que fecharia os limites que sobram foi tentado pelo A2 e REVERTIDO pela sessão na leva 49 (decisão da sessão, exposta a
    veto): perdia guardas (`</HEAD>` em caixa alta, `<title>` dentro de `<template>`) e acrescentava um caso quadrático; a leva 50 NÃO o refez: só acrescentou fichas ao regex e ao laço."""
    t = texto_html or ''
    pos = 0
    profundidade = 0         # `<template>` aberto: o conteúdo é inerte (meta e elementos de corpo não contam)
    sem_cdata = False        # já se buscou o `]]>` até o fim do arquivo e não há: não buscar de novo (era o quadrático)
    while True:
        m = _RE_VARREDURA_HEAD.search(t, pos)
        if not m:
            return ''
        tok = m.group(0).lower()
        if tok == '<!--':
            if t.startswith('>', m.end()) or t.startswith('->', m.end()):   # `<!-->` e `<!--->` fecham na hora (o navegador)
                pos = t.index('>', m.end()) + 1
                continue
            fim = _RE_FIM_COMENTARIO.search(t, m.end())   # `-->` e `--!>` (HTML5)
            if not fim:
                return ''   # comentário sem fechamento vale até o fim do arquivo
            pos = fim.end()
        elif tok == '<![cdata[':
            fim = -1 if sem_cdata else t.find(']]>', m.end())
            if fim < 0:
                sem_cdata = True
                fim = t.find('>', m.end())     # sem `]]>`: o navegador fecha o "comentário" no primeiro `>`
                if fim < 0:
                    return ''
                pos = fim + 1
            else:
                pos = fim + 3
        elif m.group(1):
            fecho = re.compile(r'</%s(?=[\s/>])' % m.group(1), re.I).search(t, m.end())
            if not fecho:
                return ''   # elemento de texto cru sem fecho: o resto do arquivo é conteúdo dele
            fim = t.find('>', fecho.end())
            if fim < 0:
                return ''
            pos = fim + 1
        elif m.group(2):            # `<template`
            profundidade += 1
            pos = m.end()
        elif m.group(3):            # `</template`
            profundidade = max(0, profundidade - 1)
            pos = m.end()
        elif tok.startswith('<body') or tok.startswith('</head'):
            return ''   # o head acabou: meta daqui em diante é do corpo
        elif tok.startswith('</'):  # `</body` e `</html`: encerram o head (dentro de <template> são inertes)
            if not profundidade:
                return ''
            pos = m.end()
        elif m.group(4):            # `<nome` qualquer
            nome = m.group(4).lower()
            if nome == 'plaintext':
                return ''           # tudo depois é texto, em qualquer lugar
            if nome not in _NOMES_DO_HEAD and not profundidade:
                return ''           # elemento que o modo "in head" não aceita: o head acabou
            pos = m.end()
        else:           # `<meta`
            ms = _RE_SEM_TEXTO.match(t, m.start())
            if ms and not profundidade:
                return ms.group(1).strip()
            pos = m.end()


def sem_texto_declarado(texto_html, cli=None):
    """Motivo (str) quando a peça declara não ter texto por desenho; '' caso contrário. A opção `--sem-texto` (`cli`) vence o HTML.
    No HTML só vale a `<meta>` do head real (CP-P66 item 3; ver `_meta_sem_texto_no_head`); a de comentário já não valia desde a REVISAO B1 (achado 5, 2026-10-28)."""
    if cli is not None:
        return cli.strip()
    return _meta_sem_texto_no_head(texto_html)


def _dica_de_tela(alvo, html):
    """Linha de ajuda quando a régua de folha/volume reprova uma peça que NÃO declarou o alvo."""
    if alvo and alvo.get('tipo'):
        return []
    return ['se a peça é de TELA (slide, post, capa — §12.30/§12.32), declare-o: %s — a régua de folha e de '
            'volume de texto não se aplica a ela (CP-P28)'
            % ('<meta name="alvo-tela" content="LxApx"> com <meta name="alvo-fisico"> vazio' if html
               else 'opção --alvo-tela LxApx')]


def carrega_tokens():
    """Devolve {hex maiúsculo: [caminhos do token]} e a escala tipográfica em px."""
    d = json.load(io.open(TOKENS, encoding='utf-8'))
    cores, tamanhos = {}, {}

    def anda(no, cam):
        if not isinstance(no, dict):
            return
        v = no.get('$value')
        if isinstance(v, str):
            s = v.strip()
            if re.fullmatch(r'#[0-9A-Fa-f]{6}', s):
                cores.setdefault(s.upper(), []).append('.'.join(cam))
            elif cam[:3] == ['seed', 'font', 'size'] and s.endswith('px'):
                try:
                    tamanhos['.'.join(cam)] = float(s[:-2])
                except ValueError:
                    pass
        # CP-P57 (2026-10-11, relatado pela sessão SIGA): o valor de cada TEMA mora em $extensions["seed.mode"]
        # (light/dark); só o $value (o claro) entrava, e toda cor que só existe no escuro reprovava o CP-1 como
        # "não é token". Prova: 06-validacao/provas/conferidor/prova-cp1-escuro.py.
        modos = (no.get('$extensions') or {}).get('seed.mode') if isinstance(no.get('$extensions'), dict) else None
        if isinstance(modos, dict):
            for tema, mv in modos.items():
                if isinstance(mv, str) and re.fullmatch(r'#[0-9A-Fa-f]{6}', mv.strip()):
                    cores.setdefault(mv.strip().upper(), []).append('.'.join(cam) + '@' + tema)
        for k, sub in no.items():
            if not k.startswith('$'):
                anda(sub, cam + [k])

    anda(d, [])
    return cores, tamanhos


def luminancia(hx):
    hx = hx.lstrip('#')
    c = [int(hx[i:i + 2], 16) / 255 for i in (0, 2, 4)]
    c = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in c]
    return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]


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


# CP-P61 (leva 48, 2026-10-28): a RAZÃO SOCIAL COMPLETA e exata, escrita como o registro a escreve, sai da conta da grafia. O canon manda
# a exceção documental (sobreaseed.md §13: "a razão social em documentos jurídicos ('SEED Engenharia e Soluções Sustentáveis LTDA') segue a
# grafia do registro"), e a folha de rosto da ART (`07-pecas/art-diario-obra/`) reprovava só por ela. "Ltda" e "LTDA" valem (o canon escreve
# LTDA, o documento da peça escreve Ltda), com ou sem acento (o PDF pode perdê-lo) e com ponto final. As DUAS BORDAS são parte da regra (REVISAO B1,
# achado 4, 2026-10-28): `(?<!\w)` na frente barra "XSEED Engenharia e Soluções Sustentáveis Ltda" (sem ela o X ficava e a SEED sumia da conta);
# `(?![\w-])` depois barra "Ltdax" e "Ltda-ME" (a razão social de OUTRA empresa com a mesma raiz). O ponto final ("Ltda.") não é palavra: passa.
# "SEED Engenharia" solto, a razão social CORTADA (sem Ltda, sem Sustentáveis) e a razão social MAIS um erro solto continuam reprovando.
# CP-P66 item 2 (leva 49, 2026-10-30): a borda da FRENTE era `(?<!\w)` e deixava passar "A-SEED Engenharia e Soluções Sustentáveis Ltda" (medido: CP-7 PASS no HEAD) — o hífen
# colado é a mesma coisa que a letra colada (F1) ou o "-ME" depois (F2): outra palavra composta, não a razão social do registro. As duas bordas agora barram palavra E hífen
# (ASCII e os hífens Unicode U+2010, U+2011, U+2012, U+2212 sinal de menos, U+FE63 e U+FF0D; A2, REVISAO A achado 2: a revisão mediu "A−SEED …" com U+2212, U+FF0D, U+2012 e U+FE63 colados dando CP-7 PASS).
# O travessão de TEXTO CORRIDO (en U+2013, em U+2014) colado NÃO entra: é pontuação ('registro—SEED Engenharia … Ltda—CNPJ …'), não prefixo de palavra composta (decisão da sessão, exposta a veto). Hífen com ESPAÇO ao redor ("00 - SEED Engenharia … Ltda - registro", travessão de lista) continua passando: o caractere vizinho é o espaço.
# CP-P68 (leva 50, 2026-10-31): a classe de hífens era uma LISTA (ASCII, U+2010 a U+2012, U+2212, U+FE63, U+FF0D) e deixava passar os outros hífens da categoria Pd do Unicode
# (U+058A, U+05BE, U+1400, U+1806, U+2015, U+2E17, U+2E1A, U+2E3A, U+2E3B, U+2E40, U+2E5D, U+301C, U+3030, U+30A0, U+FE31, U+FE32, U+FE58, U+10EAD: 18 de 24, medido com o `unicodedata` do
# Python 3.13, Unicode 15.1, em 2026-10-31). Agora a classe é CALCULADA da categoria `Pd` (varredura de U+0000 a U+1FFFF, 0,01 s na importação) MAIS o sinal de menos U+2212 (categoria Sm,
# mas já estava na lista) MENOS o travessão de texto corrido en U+2013 e em U+2014 (decisão da sessão da leva 49, exposta a veto: pontuação, não prefixo de palavra composta). Nenhum
# caractere que a lista antiga barrava deixa de ser barrado (a lista antiga era subconjunto de Pd mais o U+2212: provado pela prova-conferidor-leva50 H1 e H2b). Limite que fica: os caracteres
# de formato e símbolo que NÃO são Pd (U+00AD hífen condicional, U+200B, U+2060, U+FEFF, U+207B, U+208B, U+2796) colados ainda passam (H3 da prova).
# [limite declarado] (revisão do trabalho C, 2026-10-31, decisão da sessão exposta a veto: DECLARAR em vez de isentar): três travessões de texto corrido, U+2015 (barra horizontal), U+2E3A (travessão
# duplo) e U+2E3B (travessão triplo), são Pd e por isso BARRAM a razão social colada, enquanto o en/em são isentos — incoerente, mas do lado seguro (mais FAIL, nunca menos). Na base (ed906a129) os três
# passavam. Não isentei para não ampliar a decisão da sessão da leva 49 (só en e em). Medido em 2026-10-31: a categoria Pd tem 26 caracteres no Python 3.13.15 / Unicode 15.1.0; a classe é calculada
# em tempo de importação, então DEPENDE do Unicode do Python que roda (outro Python pode barrar mais ou menos caracteres).
_HIFENS_PD = ''.join(re.escape(chr(c)) for c in range(0x20000) if unicodedata.category(chr(c)) == 'Pd' and c not in (0x2013, 0x2014))
_BORDA_RAZAO = r'\w' + _HIFENS_PD + '\u2212'
_RE_RAZAO_SOCIAL = re.compile(r'(?<![' + _BORDA_RAZAO + r'])SEED\s+Engenharia\s+e\s+Solu(?:ç|c)(?:õ|o)es\s+Sustent(?:á|a)veis\s+(?:Ltda|LTDA)(?![' + _BORDA_RAZAO + r'])\.?')


def piso_razao(r, casas=2):
    """Razão de contraste impressa em PISO (nunca acima da real). Cópia AUTOCONTIDA do `razao_impressa.piso_razao`
    (PR-P106, leva 24): este arquivo viaja sozinho no pacote da skill `seed-ds`, então não pode importar o módulo
    da casa. `'%.2f' % 2.996` arredonda para "3.00" ao lado de um FAIL contra o piso 3,00; aqui sai "2.99"."""
    import math
    if r is None or not math.isfinite(r):
        return 'NaN'
    f = 10 ** casas
    k = math.floor(r * f)
    if (k + 1) / f <= r:
        k += 1
    elif k / f > r:
        k -= 1
    return '%.*f' % (casas, k / f)


def texto_grande(pt, fonte):
    """WCAG: grande = 18pt normal, ou 14pt em negrito."""
    negrito = 'bold' in fonte.lower() or 'semibold' in fonte.lower() or 'black' in fonte.lower()
    return pt >= 18 or (negrito and pt >= 14)


# ── dado institucional canônico ─────────────────────────────────────────────
def dados_da_empresa():
    if not EMPRESA:
        return {'telefones': set(), 'oitocentos': set(), 'cnpj': set()}
    t = io.open(EMPRESA, encoding='utf-8').read()
    return {
        'telefones': set(re.findall(r'\(?\d{2}\)?\s?9?\d{4}-\d{4}', t)),
        'oitocentos': set(re.sub(r'\D', '', x) for x in re.findall(r'0800[\s\d-]{7,12}', t)),
        'cnpj': set(re.findall(r'\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}', t)),
    }


def codigo_de_saida(fail, sem_medida):
    """Código de saída da casa (PR-P200, leva 47): PASS 0 · FAIL 1 · NÃO MEDI 2 — o mesmo do `_relata` do `fechar.sh` (código 2 com
    `placar: … NÃO MEDI` = o instrumento não mediu). FAIL domina o NÃO MEDI (mediu e reprovou algo). O NÃO MEDI que só diz «não se aplica»
    («menos de 3 páginas», «nenhuma cena de linha larga») é rotina de ficha sadia e NÃO entra: medido em 2026-10-25, um PDF de 1 página
    com texto dá 3 desses; só o item marcado `sem_medida=True` (o instrumento devia medir e não mediu) faz a saída ser 2."""
    return 1 if fail else (2 if sem_medida else 0)


def pior_saida(a, b):
    """Combina dois códigos de saída sem o OU bit a bit (1 | 2 = 3): FAIL (1) domina NÃO MEDI (2), que domina PASS (0)."""
    return 1 if 1 in (a, b) else max(a, b)


class Ficha(object):
    """Quatro estados, e a distinção entre eles é a disciplina da casa:
    PASS mediu e passou · FAIL mediu e reprovou · AVISO mediu e a decisão é
    humana (o instrumento não tem como saber) · NÃO MEDI não teve instrumento.
    Reprovar o que não se mediu é afirmar defeito sem prova."""

    def __init__(self, alvo):
        self.alvo = alvo
        self.linhas = []
        self.pass_ = self.fail = self.aviso = self.nao_medi = 0
        self.sem_medida = 0    # NÃO MEDI de quem DEVIA medir e não mediu (PR-P200): é o que faz o código de saída ser 2

    def item(self, cod, estado, resumo, detalhes=None, sem_medida=False):
        if estado is True:
            estado, self.pass_ = 'PASS', self.pass_ + 1
        elif estado is False:
            estado, self.fail = 'FAIL', self.fail + 1
        elif estado == 'aviso':
            estado, self.aviso = 'AVISO', self.aviso + 1
        else:
            estado, self.nao_medi = 'NÃO MEDI', self.nao_medi + 1
            if sem_medida:
                self.sem_medida += 1
        self.linhas.append((estado, cod, resumo, detalhes or []))

    def imprime(self):
        print('\n' + '=' * 78)
        print('FICHA DE CONFORMIDADE — %s' % self.alvo)
        print('=' * 78)
        for estado, cod, resumo, det in self.linhas:
            print('%-8s %-6s %s' % (estado, cod, resumo))
            for d in det[:8]:
                print('           · %s' % d)
            if len(det) > 8:
                print('           · … mais %d' % (len(det) - 8))
        print('-' * 78)
        print('placar: %d PASS · %d FAIL · %d AVISO · %d NÃO MEDI'
              % (self.pass_, self.fail, self.aviso, self.nao_medi))
        if self.sem_medida:
            print('saída: %s — %d item(ns) NÃO MEDI de quem devia medir e não mediu (PR-P200)'
                  % ('1 (FAIL domina)' if self.fail else '2 (NÃO MEDI)', self.sem_medida))
        return codigo_de_saida(self.fail, self.sem_medida)


# CP-P50: placeholder declarado = trecho entre colchetes (o PDF quebra a linha dentro dele), sem colchete dentro (o `[ASSIM]` do canon; até 160 caracteres)
_RE_PLACEHOLDER = re.compile(r'\[[^\[\]]{1,160}\]')


def _texto_dict(pg):
    """O `get_text('dict')` SEM as imagens (CP-P49, 2026-10-03). Com o padrão do MuPDF o dict traz, além dos spans de texto, o
    CONTEÚDO BINÁRIO de cada imagem da página (`TEXT_PRESERVE_IMAGES`): na lateral de frota (15 m, áreas de foto a 150 DPI) isso
    custava 33 s por chamada, 3 chamadas por ficha = 99 s dos 135 s da peça (medido, perfil por função, 2026-10-03); o `get_text`
    de palavras e de texto, sem imagem, leva 0,0 s. Os três usos (CP-1, CP-2/3, CP-4) só leem `lines` e `spans` — nunca o bloco de
    imagem —, então o veredito é o mesmo por construção (spans idênticos, medidos: 18 de 18)."""
    import pymupdf
    return pg.get_text('dict', flags=pymupdf.TEXTFLAGS_DICT & ~pymupdf.TEXT_PRESERVE_IMAGES)


def _norm_texto_trace(txt):
    """Texto sem espaço e em minúsculas, para o casamento span × trace de `_span_invisivel` (CP-P66 item 1)."""
    return ''.join((txt or '').split()).lower()


def _texto_do_trace(tr):
    """Texto de um trace do `get_texttrace` (o primeiro elemento de cada char é o código Unicode, medido em 2026-10-30 no MuPDF 1.28.2)."""
    try:
        return _norm_texto_trace(''.join(c[0] if isinstance(c[0], str) else chr(c[0]) for c in tr.get('chars', ())))
    except Exception:
        return ''


def _span_invisivel(pg, s, cache):
    """REVISAO B1 (leva 48, achado 2): o span é texto INVISÍVEL DE FATO (não desenhado), e não só `alpha` 0?
    O `alpha` 0 do `get_text('dict')` mistura três coisas medidas em 2026-10-28 (MuPDF 1.28.2, Chrome do Windows, `get_texttrace`):
      · `render_mode=3` (camada de OCR de escaneado): trace `type` 3, opacidade 1 — INVISÍVEL; `render_mode=7` (só recorte): sem trace — INVISÍVEL;
      · `color:transparent` + `text-shadow:0 0 1px` (sombra borrada, 5 pt): trace `type` 0, opacidade 0 — o texto aparece NA SOMBRA, é VISÍVEL
        (no HEAD o CP-3 reprovava; o filtro `alpha==0` do B1 o deixou passar: regressão FAIL→PASS);
      · `color:transparent` puro: trace `type` 0, opacidade 0 — continua contando, como no HEAD (quem escreve texto transparente de propósito
        para fugir do piso é o contorno que a régua existe para pegar).
    Regra: `alpha` 0 E (algum trace da região é `type` 3, OU nenhum trace cobre o span — o modo 7 não gera trace). Span com `alpha` > 0 nunca sai.
    CP-P66 item 1 (leva 49, 2026-10-30): o limite da leva 48 («sombra borrada posta EXATAMENTE sobre uma camada `type` 3 passa») FOI FECHADO — contar, não declarar.
    Medido em 2026-10-30 (PyMuPDF/MuPDF 1.28.2, PDF sintético: `render_mode=3` e, na MESMA posição e com o MESMO texto, `fill_opacity=0` em `render_mode=0`): os dois
    spans saem com `alpha` 0 e `get_texttrace` devolve DOIS traces na mesma caixa (`type` 3 opacidade 1 e `type` 0 opacidade 0); `3 in cobrem` bastava para esconder o
    par inteiro. Regra nova: o span sai SÓ se nenhum outro trace (`type` ≠ 3, opacidade 0) cobrir a região COM O MESMO TEXTO — a sombra/transparência sobre o OCR é
    texto que alguém pode ler (como no HEAD da leva 47) e volta à conta. Texto de OUTRO conteúdo sobre a camada OCR (carimbo, número de página) não a revela: o
    texto tem de bater (um contém o outro, sem espaço nem caixa). Trace visível (opacidade 1) nunca revela o OCR: o span visível já conta por si (E9).
    O que continua fora da conta é só o `type` 3 puro (a camada de OCR); a ficha DIZ quantos trechos ficaram de fora (`confere_pdf`, CP-0 AVISO): nunca silêncio.
    `cache` = {n.º da página: [(bbox, type, opacidade, texto normalizado), ...]} (o `get_texttrace` só é lido nas páginas que têm algum span com `alpha` 0)."""
    if s.get('alpha', 255) != 0:
        return False
    chave = pg.number
    if chave not in cache:
        try:
            cache[chave] = [(t['bbox'], t.get('type'), t.get('opacity', 1), _texto_do_trace(t)) for t in pg.get_texttrace()]
        except Exception:
            cache[chave] = None
    traces = cache[chave]
    if traces is None:
        return False   # sem o trace não se distingue o modo: conta (nunca esconde texto por falta de instrumento)
    x0, y0, x1, y1 = s['bbox']
    cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
    cobrem_t = [(tp, op, tx) for (b, tp, op, tx) in traces if b[0] - 2 <= cx <= b[2] + 2 and b[1] - 2 <= cy <= b[3] + 2]
    cobrem = [tp for (tp, _op, _tx) in cobrem_t]
    meu = _norm_texto_trace(s['text'])
    # 2ª revisão independente (leva 48): só o trace `type` 3 (texto invisível de fato, OCR) sai da conta. `not cobrem` (render_mode 7, só recorte)
    # tirava da conta o texto PREENCHIDO por retângulo ou imagem, que é VISÍVEL (HEAD: FAIL, versão anterior: PASS); o Chrome não emite o modo 7, mas o
    # conferidor lê PDF de Cairo, Inkscape e LaTeX. Sem distinguir o preenchimento, o modo 7 conta como texto (nunca esconde por falta de instrumento).
    if 3 not in cobrem:
        return False
    # CP-P66 item 1: transparente (opacidade 0, `type` ≠ 3) com o MESMO texto sobre a região = legível por sombra; o par volta à conta
    for (tp, op, tx) in cobrem_t:
        if tp != 3 and not op and tx and meu and (meu in tx or tx in meu):
            return False
    return True


def _sem_o_que_medir(f, cod, rotulo, motivo, sem_texto=''):
    """CP-P65 (leva 48): um item que ficaria «0 de 0» (nenhuma cor, nenhum texto, nenhum trecho com fundo). Sem declaração é NÃO MEDI de quem DEVIA
    medir (`sem_medida`, saída 2: o PASS «0 de 0» era o falso). Com a peça declarando que não tem texto por desenho (`sem_texto` = o motivo
    declarado: `<meta name="sem-texto">` ou `--sem-texto`) o item continua NÃO MEDI na ficha, mas «não se aplica» e de rotina: não faz a saída ser 2."""
    if sem_texto:
        f.item(cod, None, '%s: não se aplica — a peça declara não ter texto por desenho (sem-texto: %s); %s' % (rotulo, sem_texto[:80], motivo))
    else:
        f.item(cod, None, '%s: NÃO MEDI — %s' % (rotulo, motivo), sem_medida=True)


def confere_pdf(caminho, cores_tok, escala_px, empresa, doc=None, rotulo=None,
                fontes_declaradas=None, titulo_declarado=None, maquina_extra=None,
                ficha=None, pular=None, alvo=None, html=False, escala_folga=1.0, nota_h1='', fragmento='', molde='', sem_texto=''):
    """`ficha` e `pular` nascem na CP-P7 (2026-09-15/16): quando a entrada é HTML,
    `confere_html()` já mediu CP-1/CP-2/CP-3/CP-4 na TELA (não no PDF impresso —
    ver `confere_tela_cp1234` e `mede_contraste_tela`) e passa a MESMA `Ficha`
    aqui para os itens seguintes (CP-5 em diante) entrarem nela, com `pular`
    listando os códigos que este caminho NÃO deve recalcular/reimprimir. Para
    PDF de verdade, `ficha=None` e `pular=None` — o caminho de sempre, intocado."""
    sem_texto = (sem_texto or '').strip()   # REVISAO B1 (achado 5): um só strip, aqui — a CLI, a meta e a chamada direta (`sem_texto='   '`) dão o mesmo motivo vazio = sem declaração
    pular = pular or ()
    try:
        import pymupdf
    except ImportError:
        f = ficha if ficha is not None else Ficha(caminho)
        f.item('CP-0', None, 'PyMuPDF não instalado — nenhuma medição feita (pip install --user pymupdf)', sem_medida=True)
        return f

    if doc is None:
        doc = pymupdf.open(caminho)
    rotulo_completo = '%s · %d página(s)%s' % (os.path.basename(caminho), len(doc), rotulo or '')
    if ficha is not None:
        f = ficha
        f.alvo = rotulo_completo   # a ficha nasceu só com o basename em confere_html(); completa aqui
    else:
        f = Ficha(rotulo_completo)

    # ── CP-1 cor ────────────────────────────────────────────────────────────
    # CP-P30 (2026-09-29): a marca é procurada ANTES do CP-1 (uma vez só; o CP-5 usa o mesmo achado, abaixo) para
    # isentar a cor que vem de DENTRO de um asset oficial casado — e só a que está na paleta do próprio asset.
    achado_marca = acha_marcas(doc, cores_tok, escala_folga)
    casadas = collections.defaultdict(list)          # página → [(retângulo do aglomerado, paleta, nomes)]
    for inst in (achado_marca or {}).get('casadas', []):
        pal = set()
        for svg in inst['assets']:
            pal |= _paleta_asset(svg)
        casadas[inst['pno']].append((inst['rect'], pal, sorted(s.name for s in inst['assets'])))
    usadas, isentas = collections.Counter(), collections.Counter()
    origem_isenta = collections.defaultdict(set)
    for pno, pg in enumerate(doc):
        for d in pg.get_drawings():
            for chave in ('fill', 'color'):
                c = d.get(chave)
                if c:
                    hx = '#%02X%02X%02X' % tuple(max(0, min(255, round(v * 255))) for v in c[:3])
                    usadas[hx] += 1
                    if hx not in cores_tok and hx != '#000000':
                        r = d['rect']
                        for g, pal, nomes in casadas.get(pno, ()):
                            if (hx in pal and r.x0 >= g.x0 - 0.5 and r.y0 >= g.y0 - 0.5
                                    and r.x1 <= g.x1 + 0.5 and r.y1 <= g.y1 + 0.5):
                                isentas[hx] += 1
                                origem_isenta[hx].update(nomes)
                                break
        for b in _texto_dict(pg)['blocks']:
            for l in b.get('lines', []):
                for s in l.get('spans', []):
                    usadas['#%06X' % s['color']] += 1
    # uma cor sai de "fora" só se TODA ocorrência dela veio de dentro de asset casado
    fora = [c for c in usadas if c not in cores_tok and c != '#000000' and usadas[c] > isentas[c]]
    so_asset = [c for c in usadas if c not in cores_tok and c != '#000000' and usadas[c] == isentas[c]]
    if 'CP-1' not in pular and not usadas:
        # CP-P65 (leva 48): «0 de 0 cores» era PASS (folha em branco: nenhum desenho nem texto). Sem cor pintada não há o que medir: NÃO MEDI de quem
        # devia medir (`sem_medida`, saída 2), a mesma regra do CP-3 desde a PR-P200. Não existe «peça sem cor de propósito»: sem declaração.
        f.item('CP-1', None, 'cor: NÃO MEDI — nenhuma cor pintada na peça (0 de 0: nenhum desenho nem texto no %s)' % ('PDF que o Chrome imprime' if html else 'PDF'),
               sem_medida=True)
    elif 'CP-1' not in pular:
        f.item('CP-1', not fora,
               'cor: %d de %d cores pintadas existem no seed-tokens.json (#000000 do estado inicial do PDF não conta)%s'
               % (len(usadas) - len(fora) - len(so_asset), len(usadas),
                  '; %d fora do token isenta(s): pintada(s) SÓ dentro de asset oficial casado no CP-5 (CP-P30)'
                  % len(so_asset) if so_asset else ''),
               ['%s (%dx) — não é token%s' % (c, usadas[c], ' (%dx dentro de asset oficial isentas; as outras %d não)'
                                              % (isentas[c], usadas[c] - isentas[c]) if isentas[c] else '')
                for c in sorted(fora, key=lambda x: -usadas[x])]
               + ['%s (%dx) — isenta: só dentro de %s, casado no CP-5, e está na paleta do asset (CP-P30)'
                  % (c, usadas[c], ' / '.join(sorted(origem_isenta[c]))) for c in sorted(so_asset, key=lambda x: -usadas[x])])

    # ── CP-2 tipografia e CP-3 tamanho ──────────────────────────────────────
    fontes = collections.Counter()
    pequenos = []
    spans = []
    invisiveis = {}   # cache por página de `_span_invisivel`
    n_invisiveis = 0  # CP-P66 item 1: trechos de texto invisível de fato (camada OCR) que ficaram FORA da conta; a ficha diz quantos
    for pno, pg in enumerate(doc):
        for b in _texto_dict(pg)['blocks']:
            for l in b.get('lines', []):
                for s in l.get('spans', []):
                    txt = s['text'].strip()
                    if not txt:
                        continue
                    # CP-P65 (leva 48): texto INVISÍVEL (`render_mode=3`, a camada de OCR de um escaneado; o MuPDF o devolve com `alpha` 0, medido em
                    # 2026-10-28 na versão 1.28.2) não é desenhado: não tem fonte nem tamanho que alguém veja, e contava no piso do CP-3. Segue na
                    # camada de máquina (CP-10) e na grafia/dado (CP-7, CP-8), que leem o TEXTO e não o desenho. Só o texto invisível DE FATO sai da
                    # conta (`_span_invisivel`): `alpha` 0 sozinho NÃO basta — `color:transparent` com `text-shadow` borrado é legível e o Chrome o
                    # imprime com `alpha` 0 (REVISAO B1, achado 2, 2026-10-28).
                    if _span_invisivel(pg, s, invisiveis):
                        n_invisiveis += 1
                        continue
                    fontes[s['font']] += len(txt)
                    spans.append((pno, s))
    def da_casa(nome):
        n = nome.lower().split('+')[-1].replace('-', '').replace(' ', '')
        return any(fam.replace(' ', '').replace('-', '') in n for fam in FAMILIAS)
    if fontes_declaradas is not None:
        # ⚠ Em SVG o MuPDF SUBSTITUI a fonte na leitura (Montserrat vira
        # Times-Roman, Arial vira Helvetica): medir por ele seria medir a
        # substituição, não a peça. Quando o formato declara a família no
        # próprio arquivo, é a declaração que vale.
        fontes = fontes_declaradas
    intrusas = [x for x in fontes if not da_casa(x)]
    if sem_texto and spans and not html:
        # CP-P65: a declaração `sem-texto` não cala o texto: a peça TEM texto, ele é medido normalmente e a ficha diz que a declaração não confere
        # (no HTML o aviso sai do `confere_html`, que lê o DOM)
        f.item('CP-0', 'aviso', 'declaração `sem-texto` (%s) não confere: a peça tem %d trecho(s) de texto desenhado — o texto é medido normalmente'
               % (sem_texto[:60], len(spans)))
    if n_invisiveis:
        # CP-P66 item 1 (leva 49): o que sai da conta se DIZ — «alpha 0 não foi medido» nunca é PASS silencioso. AVISO não muda o código de saída.
        f.item('CP-0', 'aviso', 'texto invisível (camada de OCR, render_mode 3): %d trecho(s) FORA da conta de CP-2, CP-3 e CP-4 — não são desenhados, '
               'então não têm fonte, tamanho nem contraste que alguém veja; seguem na grafia (CP-7) e no dado (CP-8)' % n_invisiveis)
    if 'CP-2' not in pular and not fontes:
        _sem_o_que_medir(f, 'CP-2', 'tipografia', 'nenhuma família de fonte (0 de 0: nenhum texto desenhado na peça)', sem_texto)
    elif 'CP-2' not in pular:
        f.item('CP-2', not intrusas,
               'tipografia: %d de %d famílias são do DS (Montserrat · JetBrains Mono · Caveat)%s'
               % (len(fontes) - len(intrusas), len(fontes),
                  ' — lidas da declaração do arquivo' if fontes_declaradas is not None else ''),
               ['%s (%s) — fonte de fora, provável fallback do sistema' % (x, fontes[x]) for x in intrusas])

    menor_px = min(escala_px.values()) if escala_px else 11.0
    piso_pt = menor_px * PT_POR_PX
    for pno, s in spans:
        if s['size'] + 0.05 < piso_pt:
            pequenos.append('pág %d · %.1fpt ≈ %.1fpx · "%s"'
                            % (pno + 1, s['size'], s['size'] / PT_POR_PX, s['text'].strip()[:40]))
    # ⚠ A escala do `seed-tokens.json` é de TELA, em px. Peça impressa (A4 e afins)
    # não tem piso publicado no canon — e o canon não se inventa (regra §14.1).
    # Por isso aqui é AVISO com o número na mesa, e a lacuna virou pendência.
    # CP-P33 (2026-09-29, leva 12): a mesma leitura da DECLARAÇÃO da CP-P28 — peça que se declara de TELA
    # (`--alvo-tela`, ou `alvo-tela` com `alvo-fisico` vazio) é medida pela escala de tela, qualquer que seja a
    # largura; peça física declarada, pela de impressão. Só sem declaração a largura decide (o comportamento de antes).
    # Antes, o deck 16:9 (1440 pt de largura) era medido com os pisos de impressão por ter mais de 500 pt.
    tipo_alvo = (alvo or {}).get('tipo')
    impressa = (tipo_alvo == 'fisico') or (tipo_alvo is None and any(pg.rect.width > 500 for pg in doc))
    if 'CP-3' not in pular:
        if not spans:
            # PR-P200 (leva 47): «0 de 0 trechos» era PASS. Sem texto desenhado não há o que medir (imagem, curvas, folha em branco): NÃO MEDI,
            # nunca PASS — mesma regra do CP-10 sem DOM (PR-P189). `sem_medida`: a saída do conferidor é 2 (PASS sem texto é o PASS falso).
            _sem_o_que_medir(f, 'CP-3', 'tamanho',
                             'nenhum texto desenhado na peça (0 trechos de texto no %s): não há o que medir contra o piso%s'
                             % ('PDF que o Chrome imprime' if html else 'PDF', ' de 8 pt (peça impressa, marca-seed.md §4.8)' if impressa else ' da escala do DS'),
                             sem_texto)
        elif impressa:
            abaixo_piso, abaixo_corrido = [], 0
            for pno, s in spans:
                if s['size'] + 0.05 < PISO_IMPRESSO_PT:
                    abaixo_piso.append('pág %d · %.1fpt (piso %.0fpt) · "%s"'
                                       % (pno + 1, s['size'], PISO_IMPRESSO_PT, s['text'].strip()[:40]))
                elif s['size'] + 0.05 < PISO_CORRIDO_PT:
                    abaixo_corrido += 1
            f.item('CP-3', not abaixo_piso,
                   'tamanho (peça impressa, marca-seed.md §4.8): %d de %d trechos no piso absoluto de %.0fpt; '
                   '%d entre %.0f e %.0fpt, que só valem para rótulo, legenda, nota e tabela — texto corrido pede %.0fpt%s'
                   % (len(spans) - len(abaixo_piso), len(spans), PISO_IMPRESSO_PT,
                      abaixo_corrido, PISO_IMPRESSO_PT, PISO_CORRIDO_PT, PISO_CORRIDO_PT,
                      ' · HTML medido no PDF que o Chrome imprime no tamanho declarado (texto desenhado = texto visível), não na tela (PR-P184)'
                      ' · a conta (PR-P200): 8 pt = 10,67 px a 96 dpi, então em A4 o texto de 10,67 a 11 px passa aqui e reprovaria na escala de'
                      ' tela de 11 px; o piso é o do §4.8, de impresso, e não muda'
                      if html else ''),
                   sorted(set(abaixo_piso)))
        else:
            f.item('CP-3', not pequenos,
                   'tamanho: %d de %d trechos no piso da escala do DS (menor degrau %gpx = %.2fpt)%s'
                   % (len(spans) - len(pequenos), len(spans), menor_px, piso_pt,
                      ' · peça de TELA declarada (%s, %s): escala de tela, não a régua de impressão (CP-P33)'
                      % (alvo['valor'], alvo['origem']) if tipo_alvo == 'tela' else ''),
                   sorted(set(pequenos)))

    # ── CP-4 contraste contra o fundo REAL, amostrado no render ─────────────
    # O fundo é amostrado em poucos pontos AO LADO do trecho (fora dos glifos),
    # não varrendo o retângulo inteiro: varrer custava minutos por documento e
    # media a mesma coisa.
    if 'CP-4' not in pular:
        ruins, medidos = [], 0
        invisiveis = {}   # cache por página de `_span_invisivel` (o mesmo do CP-2/CP-3, mas este laço pode rodar com o CP-2/CP-3 pulados)
        for pno, pg in enumerate(doc):
            pix = pg.get_pixmap(dpi=72)
            esc = pix.width / pg.rect.width
            # CP-P52 (2026-10-04): `pix.samples` COPIA o buffer inteiro a cada acesso (7200 × 2400 pt = ~52 MB por leitura, ~0,25 s;
            # 6 amostras por trecho × 3 canais = 18 cópias por trecho). Lê-se UMA vez por página; os valores amostrados são os mesmos.
            amostras = pix.samples

            def cor_em(x, y):
                xi, yi = int(x * esc), int(y * esc)
                if xi < 0 or yi < 0 or xi >= pix.width or yi >= pix.height:
                    return None
                p = yi * pix.stride + xi * pix.n
                return '#%02X%02X%02X' % (amostras[p], amostras[p + 1], amostras[p + 2])

            for b in _texto_dict(pg)['blocks']:
                for l in b.get('lines', []):
                    for s in l.get('spans', []):
                        if not s['text'].strip():
                            continue
                        if _span_invisivel(pg, s, invisiveis):   # CP-P65: texto invisível de fato (render_mode=3 ou 7) não tem contraste a medir
                            continue
                        frente = '#%06X' % s['color']
                        x0, y0, x1, y1 = s['bbox']
                        ym = (y0 + y1) / 2
                        pontos = [(x0 - 2.5, ym), (x1 + 2.5, ym), ((x0 + x1) / 2, y0 - 2.0),
                                  ((x0 + x1) / 2, y1 + 2.0), (x0 - 2.5, y0 - 2.0), (x1 + 2.5, y1 + 2.0)]
                        cont = collections.Counter()
                        for px, py in pontos:
                            c = cor_em(px, py)
                            if c and contraste(c, frente) >= 1.2:   # descarta amostra que caiu na tinta
                                cont[c] += 1
                        if not cont:
                            continue
                        fundo = cont.most_common(1)[0][0]
                        medidos += 1
                        piso = 3.0 if texto_grande(s['size'], s['font']) else 4.5
                        cr = contraste(frente, fundo)
                        if cr < piso:   # PR-P111 (leva 24): razao EXATA, sem folga de 0,01 (antes `cr + 0.01 < piso` aprovava 4,497 contra 4,5)
                            ruins.append('pág %d · %s sobre %s = %s:1 (piso %.1f) · %.1fpt · "%s"'
                                         % (pno + 1, frente, fundo, piso_razao(cr), piso, s['size'], s['text'].strip()[:36]))
        if not medidos:
            # CP-P65 (leva 48): «0 de 0 trechos» era PASS; sem trecho com fundo amostrado o contraste não foi medido
            _sem_o_que_medir(f, 'CP-4', 'contraste', 'nenhum trecho de texto com o fundo medido (0 de 0)', sem_texto)
        else:
            f.item('CP-4', not ruins,
                   'contraste: %d de %d trechos atingem o piso da WCAG 2.2 AA contra o fundo medido'
                   % (medidos - len(ruins), medidos),
                   sorted(set(ruins)))

    # ── CP-7 grafia da marca ────────────────────────────────────────────────
    texto = '\n'.join(pg.get_text() for pg in doc)
    meta = doc.metadata or {}
    texto_meta = ' '.join(str(v) for v in meta.values() if v)
    certa = len(re.findall(r'SEED engenharia', texto))
    # CP-P50 (2026-10-03): o valor entre colchetes é PLACEHOLDER declarado, não dado (marca-seed: "todo valor é placeholder em
    # maiúsculas entre colchetes"; o CNPJ-modelo `[00.000.000/0000-00]` está em 5 peças aprovadas). `ph` = o texto de cada colchete
    sem_ph = _RE_PLACEHOLDER.sub(' ', texto)
    # só o colchete TODO em caixa alta sai da conta da grafia: "[SEED Engenharia]" é a marca escrita errada num colchete
    sem_ph7 = _RE_PLACEHOLDER.sub(lambda m: ' ' if m.group(0) == m.group(0).upper() else m.group(0), texto)
    # CP-P61: a razão social completa sai da conta (ver `_RE_RAZAO_SOCIAL`); só ela — o resto do texto segue na conta
    sem_razao = _RE_RAZAO_SOCIAL.sub(' ', sem_ph7 + ' ' + texto_meta)
    erradas = collections.Counter(re.findall(r'SEED\s+(Engenharia|ENGENHARIA|engenheria)', sem_razao))
    # a grafia errada SÓ dentro de um colchete todo em caixa alta ("[DADOS BANCÁRIOS DA SEED ENGENHARIA]") é a convenção do
    # placeholder, não a marca escrita errada: AVISO (decisão humana: trocar o rótulo ou aceitar), nunca PASS calado nem FAIL
    so_ph = collections.Counter()
    for ph in _RE_PLACEHOLDER.findall(texto):
        if ph == ph.upper():
            so_ph.update(re.findall(r'SEED\s+(ENGENHARIA)', ph))
    if erradas:
        f.item('CP-7', False,
               'grafia: "SEED engenharia" %d ocorrência(s) certa(s), %d errada(s) (marca-seed.md §14.16)'
               % (certa, sum(erradas.values())),
               ['"SEED %s" — %dx' % (k, v) for k, v in erradas.items()])
    elif so_ph:
        f.item('CP-7', 'aviso',
               'grafia: "SEED engenharia" %d ocorrência(s) certa(s), 0 errada(s) fora de colchete; %d "SEED ENGENHARIA" em '
               'placeholder em caixa alta entre colchetes (convenção do canon; trocar o rótulo é decisão humana)'
               % (certa, sum(so_ph.values())), ['"SEED ENGENHARIA" dentro de [...] — %dx' % sum(so_ph.values())])
    else:
        f.item('CP-7', True,
               'grafia: "SEED engenharia" %d ocorrência(s) certa(s), 0 errada(s) (marca-seed.md §14.16)' % certa, [])

    # ── CP-8 dado institucional ─────────────────────────────────────────────
    achados, divergentes, sem_referencia = [], [], []
    # a borda antes do 0800 é obrigatória: sem ela, o "0800" DENTRO de um
    # identificador de 18 dígitos vira falso positivo (aconteceu na cobaia)
    # CP-P50: o que está entre colchetes é placeholder declarado e não se confere (sai do texto, como no CP-7)
    for m in re.findall(r'(?<![\d.,])0800[\s.-]?\d{3}[\s.-]?\d{4}(?![\d.,])', sem_ph):
        so = re.sub(r'\D', '', m)
        achados.append(so)
        if so not in empresa['oitocentos']:
            divergentes.append('0800 "%s" não confere com o sobreaseed.md' % m.strip())
    for m in re.findall(r'\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}', sem_ph):
        achados.append(m)
        if not empresa['cnpj']:
            # o `sobreaseed.md` não traz CNPJ (medido em 2026-10-03): sem referência, "não confere" seria afirmar o que não se mediu
            sem_referencia.append('CNPJ %s: o sobreaseed.md não traz CNPJ para conferir' % m)
        elif m not in empresa['cnpj']:
            divergentes.append('CNPJ %s não confere com o sobreaseed.md' % m)
    n_ph = len([x for x in _RE_PLACEHOLDER.findall(texto) if re.search(r'\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}|0800', x)])
    nota_ph = '; %d valor(es) institucional(is) entre colchetes tratado(s) como placeholder, não conferido(s) (CP-P50)' % n_ph if n_ph else ''
    f.item('CP-8', ('aviso' if (sem_referencia and not divergentes) else not divergentes),
           'dado institucional: %d de %d conferem com o sobreaseed.md%s%s'
           % (len(achados) - len(divergentes) - len(sem_referencia), len(achados),
              '; %d sem referência no canon' % len(sem_referencia) if sem_referencia else '', nota_ph),
           divergentes + sem_referencia)

    # ── CP-9 segredo (reprova) e CP-9b identificador (avisa) ────────────────
    # Chave e token são segredo: reprovam sempre. Identificador numérico de
    # plataforma pode ser legítimo num relatório da própria conta — o script
    # não tem como saber, então AVISA e a decisão é de quem publica.
    segredos = []
    # `act_<n>` (identificador de conta de anúncio da Meta) NÃO está nesta lista de propósito: até 2026-09-20
    # ele reprovava aqui, contra o veredito dele de 2026-09-08 (CP-P4, CLAUDE.md do projeto, "Conformidade"):
    # identificador NOMEIA e não dá acesso — é AVISO (CP-9b, abaixo), não reprovação. Achado pelo agente da
    # CP-P10 (Grupo 5); corrigido na consolidação, exposto a veto.
    for pat, rot in ((r'\bEAA[A-Za-z0-9]{20,}', 'token de acesso da Meta'),
                     (r'\bsk-[A-Za-z0-9]{20,}', 'chave de API'),
                     (r'\bAIza[A-Za-z0-9_\-]{20,}', 'chave do Google'),
                     (r'(?i)\b(senha|password|secret|api[_-]?key)\s*[:=]\s*\S+', 'credencial escrita no texto')):
        for m in set(re.findall(pat, texto)):
            segredos.append('%s: %s' % (rot, (m if isinstance(m, str) else m[0])[:28]))
    f.item('CP-9', not segredos,
           'segredo: %d credencial/chave encontrada (CLAUDE.md do projeto — nunca em conteúdo gerado)'
           % len(segredos), segredos)

    ids = collections.Counter(re.findall(r'(?<![\d.,\-/])\d{15,18}(?![\d.,\-/])', texto))
    ids_act = collections.Counter(re.findall(r'\bact_\d+\b', texto))   # conta de anúncio da Meta: identificador (CP-P4)
    f.item('CP-9b', 'aviso' if (ids or ids_act) else True,
           'identificador de plataforma: %d número(s) de 15 a 18 dígitos e %d `act_…` (conta de anúncio) no texto. '
           'Regra do CLAUDE.md do projeto (veredito de 2026-09-08): PODE em relatório para quem já tem acesso à '
           'conta; NÃO PODE em peça pública; NUNCA ao lado de token ou chave. O script acha o número e não sabe o '
           'destinatário — quem publica decide'
           % (sum(ids.values()), sum(ids_act.values())),
           ['%d dígitos, %dx, terminado em …%s' % (len(i), n, i[-4:]) for i, n in ids.most_common()]
           + ['conta de anúncio act_…%s, %dx' % (i[-4:], n) for i, n in ids_act.most_common()])

    # ── CP-10 camada de máquina ─────────────────────────────────────────────
    problemas = []
    # PR-P189 (leva 46): em HTML, `maquina_extra is None` = o DOM NÃO respondeu (o `confere_html` não leu h1, lang, nav nem título). Sem o
    # DOM o título do PDF não vale (o Chrome grava ali o NOME DO ARQUIVO quando a página não tem <title>: "sem título" nunca era acusado).
    dom_ausente = bool(html) and maquina_extra is None
    if titulo_declarado is None and not dom_ausente:
        titulo_declarado = (meta.get('title') or '').strip()
    if not titulo_declarado and not fragmento and not dom_ausente:
        problemas.append('sem título próprio — um agente não sabe o que é o arquivo')
    # ⚠ a régua de volume de texto só vale para DOCUMENTO. Uma placa, uma etiqueta
    # ou um post têm pouco texto por desenho, e reprová-los por isso seria medir
    # a categoria errada — o defeito que se quer pegar é o documento escaneado.
    # CP-P28 (2026-09-29): a peça que se DECLARA de tela sai desta régua — o tamanho da página só decide quando
    # não há declaração (o comportamento de antes, intocado para PDF sem --alvo-tela e HTML sem as metas).
    tela = _eh_tela(alvo)
    documento = not tela and not fragmento and not molde and (len(doc) > 1 or (doc[0].rect.width > 500 and doc[0].rect.height > 700))
    pouco_texto = False
    if documento and len(texto.strip()) < 200 * len(doc):
        pouco_texto = True
        problemas.append('pouco texto extraível (%d caracteres em %d páginas) — o documento pode ser imagem'
                         % (len(texto.strip()), len(doc)))
    # HTML: o que o DOM renderizado acrescenta (MK do §47: um h1, lang, nav nomeada)
    problemas.extend(maquina_extra or [])
    if pouco_texto:
        problemas.extend(_dica_de_tela(alvo, html))
    if dom_ausente and not problemas:
        # PR-P189: o que o PDF mede (volume de texto) passou, mas h1, lang, nav e título só o DOM sabe: sem ele é NÃO MEDI, nunca PASS
        f.item('CP-10', None,
               'camada de máquina: NÃO MEDI — o DOM não respondeu (a sonda do Chrome devolveu vazio ou erro): título próprio, h1, lang e nav '
               'com nome não foram lidos, e o PDF sozinho não os prova (PR-P189)%s'
               % (' · volume de texto medido no PDF: sem problema' if documento else ''), sem_medida=True)
    else:
        f.item('CP-10', not problemas,
               'camada de máquina: título próprio%s%s%s%s (achar · ler · citar)'
               % (' e texto real' if documento else (' · volume de texto não se aplica: %s' % molde if molde else ''),
                  (' · %s: título, texto, lang e h1 não se aplicam; nav com nome segue medida (CP-P54)' % fragmento
                   if fragmento and maquina_extra is not None else '')
                  or (' · no DOM: lang, toda nav com nome · h1: não se aplica — %s (CP-P51)' % nota_h1 if nota_h1
                   else ' · no DOM: um h1, lang, toda nav com nome') if maquina_extra is not None else '',
                  ' · peça de TELA (%s, %s): volume de texto não se aplica (CP-P28)' % (alvo['valor'], alvo['origem'])
                  if tela else '',
                  ' · DOM sem resposta: título, h1, lang e nav NÃO medidos (PR-P189)' if dom_ausente else ''), problemas)

    confere_margem(doc, f)
    confere_folha(doc, f, alvo=alvo, html=html)
    confere_marca(doc, f, achado=achado_marca)
    confere_grafismo(doc, f)
    return f


# ════════════════════════════════════════════════════════════════════════════
# CP-11 — GEOMETRIA DE PÁGINA: o miolo respira igual
# ════════════════════════════════════════════════════════════════════════════
def confere_margem(doc, f):
    """Nasce em 2026-09-08 porque a ficha deu 10 de 10 numa peça que tinha um
    defeito grosseiro: da segunda página em diante o texto começava a 0,6 mm do
    topo. O decisor viu no PDF o que dez itens de contrato não viam — cor, tipo,
    contraste e marca não dizem nada sobre geometria de página.

    O canon NÃO publica margem de documento, então a régua não pode exigir um
    valor. O que ela pode exigir é **coerência**: num documento de várias
    páginas, o miolo tem a MESMA margem superior. Página de sangria total (fundo
    colorido cobrindo a folha, como a capa) fica de fora, porque sangrar é o
    desenho dela."""
    import pymupdf
    if len(doc) < 3:
        f.item('CP-11', None, 'geometria: documento com menos de 3 páginas — sem miolo para comparar')
        return
    medidas = []
    for pno, pg in enumerate(doc):
        area = pg.rect.width * pg.rect.height
        sangra = any(d.get('fill') and (d['rect'].width * d['rect'].height) > area * 0.8
                     for d in pg.get_drawings())
        if sangra:
            continue
        tops = [b[1] for b in pg.get_text('blocks')]
        tops += [d['rect'].y0 for d in pg.get_drawings() if d['rect'].height > 1]
        if tops:
            medidas.append((pno + 1, min(tops)))
    if len(medidas) < 2:
        f.item('CP-11', None, 'geometria: menos de duas páginas de miolo — nada a comparar')
        return
    valores = [v for _, v in medidas]
    mediana = sorted(valores)[len(valores) // 2]
    fora = ['pág %d · topo a %.1f mm (as outras a %.1f mm)' % (n, v / 72 * 25.4, mediana / 72 * 25.4)
            for n, v in medidas if abs(v - mediana) > 6]        # 6 pt ≈ 2 mm
    colados = ['pág %d · topo a %.1f mm — praticamente colado na borda' % (n, v / 72 * 25.4)
               for n, v in medidas if v < 14]                   # < ~5 mm
    ruins = sorted(set(fora + colados))
    f.item('CP-11', not ruins,
           'geometria: %d de %d páginas de miolo com a mesma margem superior (%.1f mm; sangria total isenta)'
           % (len(medidas) - len(ruins), len(medidas), mediana / 72 * 25.4), ruins)


# ════════════════════════════════════════════════════════════════════════════
# CP-12 — FOLHA APROVEITADA: nenhuma folha quase em branco, nenhum rótulo órfão
# ════════════════════════════════════════════════════════════════════════════
def confere_folha(doc, f, alvo=None, html=False):
    """Nasce em 2026-09-09 pelo mesmo motivo do CP-11: a ficha deu 11 de 11 numa
    peça com defeito que qualquer pessoa vê em três segundos — uma folha com
    0,7 % de tinta (um cabeçalho de seção sozinho no alto, o resto branco) e um
    subtítulo partido no meio da frase entre duas folhas.

    A causa é sempre a mesma e vale escrever aqui, porque é o que o produtor
    precisa consertar: `break-inside: avoid` num bloco MAIOR que meia folha. Ele
    não cabe no resto da página, é empurrado inteiro para a seguinte e deixa o
    vazio atrás. O conserto é tirar o `avoid` do contêiner longo e pô-lo nos
    blocos pequenos (cabeçalho, rótulo, figura, item de lista), com
    `break-after: avoid` no cabeçalho para o título não ficar órfão no pé.

    Piso de tinta MEDIDO, não escolhido: nos dois relatórios consertados a folha
    de miolo mais leve tem 5,7 % e a mais pesada 33,6 %; a folha defeituosa
    tinha 0,7 %. Isenções declaradas com motivo, como no CP-11: a CAPA (sangria
    total, o desenho dela é ocupar a folha inteira) e a ÚLTIMA folha, que fecha
    legitimamente com pouco conteúdo.

    CP-P28 (2026-09-29): peça de TELA declarada (`alvo_declarado`) não tem folha — o slide de conteúdo do deck
    tem 4,6 % de tinta por desenho. Sai PASS "não se aplica", como o CP-13 faz com arquétipo declarado."""
    if _eh_tela(alvo):
        f.item('CP-12', True, 'folha: não se aplica — peça de TELA (%s, %s): slide não é folha impressa (CP-P28)'
               % (alvo['valor'], alvo['origem']))
        return
    if len(doc) < 3:
        # CP-P48 (2026-10-03): desde a CP-P43 a peça sai no tamanho declarado, e 175 das 181 peças HTML de `07-pecas/` cabem em UMA
        # folha — o NÃO MEDI de antes (79 peças) era correto: em Letter a régua lia 2 ou 3 fragmentos da peça cortada (32 FAIL e 47 PASS
        # sem objeto). O motivo agora diz isso, com o tamanho, em vez de parecer régua que deixou de funcionar
        decl = (' (%d folha(s) no tamanho declarado %s: a peça é de uma folha, não tem miolo)' % (len(doc), alvo['valor'])
                if alvo and alvo.get('tipo') == 'fisico' else '')
        f.item('CP-12', None, 'folha: documento com menos de 3 páginas — sem miolo para medir' + decl)
        return
    vazias, medidas = [], 0
    for pno in range(1, len(doc) - 1):                 # capa e última isentas
        pg = doc[pno]
        pix = pg.get_pixmap(dpi=72)
        n, s, largura, altura = pix.n, pix.samples, pix.width, pix.height
        total = largura * altura
        tinta = 0
        for k in range(0, len(s), n):
            if s[k] < 250 or s[k + 1] < 250 or s[k + 2] < 250:
                tinta += 1
        pct = 100.0 * tinta / total
        medidas += 1
        if pct < 5.0:
            vazias.append('pág %d · %.1f%% de tinta — folha praticamente vazia; '
                          'procure break-inside: avoid num bloco maior que meia folha'
                          % (pno + 1, pct))
    orfaos = []
    for pno in range(len(doc) - 1):                    # a última não tem seguinte
        linhas = [l.strip() for l in doc[pno].get_text().split('\n') if l.strip()]
        if not linhas:
            continue
        ultima = linhas[-1]
        # Rótulo = trecho CURTO, em caixa alta, feito de LETRAS: "O QUE PEDIMOS",
        # "GANHO ESPERADO". Sozinho no pé, o conteúdo dele foi para a seguinte.
        # O "sem dígito" e o "pelo menos três letras" não são enfeite: sem eles a
        # régua acusou `R$ 13,81` — a última célula de uma tabela — de ser rótulo
        # órfão, porque valor em dinheiro também não tem minúscula (achado em
        # 2026-09-09, no dia em que o item nasceu, medindo um relatório de outro
        # projeto). Prefere-se calar num rótulo com número ("PASSO 2") a acusar
        # um valor: instrumento não afirma defeito que não consegue distinguir.
        letras = sum(1 for c in ultima if c.isalpha())
        if (len(ultima) < 40 and ultima == ultima.upper()
                and letras >= 3 and not any(c.isdigit() for c in ultima)):
            orfaos.append('pág %d · rótulo %r sozinho no pé da folha — falta '
                          'break-after: avoid nele' % (pno + 1, ultima))
    ruins = vazias + orfaos + (_dica_de_tela(alvo, html) if vazias else [])
    # O resumo conta as DUAS coisas: a versão anterior só contava folha vazia e
    # dizia "e nenhum rótulo órfão" mesmo quando reprovava por um rótulo órfão —
    # linha de placar que contradiz o próprio veredito (achado em 2026-09-09).
    f.item('CP-12', not ruins,
           'folha: %d de %d páginas de miolo com tinta acima do piso de 5%%; '
           '%d rótulo(s) órfão(s) no pé em %d viradas de folha (capa e última folha isentas)'
           % (medidas - len(vazias), medidas, len(orfaos), max(len(doc) - 1, 0)), ruins)


# ════════════════════════════════════════════════════════════════════════════
# CP-5 — MARCA: todo desenho de marca casa com um asset oficial
# ════════════════════════════════════════════════════════════════════════════
def _fundo_do_pixmap(pix):
    """A cor do fundo do render = a cor mais comum numa grade de ~40 × 40 amostras; None se o render não tem pixel."""
    n, s, st, w, h = pix.n, pix.samples, pix.stride, pix.width, pix.height
    cont = collections.Counter()
    for y in range(0, h, max(1, h // 40)):
        b = y * st
        for x in range(0, w, max(1, w // 40)):
            p = b + x * n
            cont[(s[p], s[p + 1], s[p + 2])] += 1
    return cont.most_common(1)[0][0] if cont else None


def _mascara(pix, limiar=128):
    """Máscara binária da TINTA: qualquer pixel que não seja o fundo do render.
    CP-P39 (2026-09-29, leva 15): render SEM pixel (largura ou altura 0 — o recorte cai fora da página) não tem tinta:
    máscara vazia, e o `_bbox` devolve None. Antes: `IndexError` em `most_common(1)[0]` (`memorial-rosto.html`)."""
    n, s, st, w, h = pix.n, pix.samples, pix.stride, pix.width, pix.height
    fundo = _fundo_do_pixmap(pix)
    if fundo is None:
        return bytearray(), w, h
    m = bytearray(w * h)
    i = 0
    for y in range(h):
        b = y * st
        for x in range(w):
            p = b + x * n
            dist = abs(s[p] - fundo[0]) + abs(s[p + 1] - fundo[1]) + abs(s[p + 2] - fundo[2])
            m[i] = 1 if dist > limiar else 0
            i += 1
    return m, w, h


def _bbox(m, w, h):
    x0, y0, x1, y1 = w, h, -1, -1
    for y in range(h):
        linha = y * w
        for x in range(w):
            if m[linha + x]:
                if x < x0: x0 = x
                if x > x1: x1 = x
                if y < y0: y0 = y
                if y > y1: y1 = y
    return (x0, y0, x1, y1) if x1 >= 0 else None


GRADE = 220        # lado da grade comum de comparação


def _reamostra(m, w, h, W=GRADE, H=GRADE):
    """Leva a máscara para uma grade fixa por MÉDIA DE ÁREA (não por vizinho mais
    próximo): a média preserva traço fino, e traço fino é a maior parte de um
    logotipo. Com vizinho mais próximo a mesma logo oficial media 0,856 em vez
    de 0,97 — o erro era da régua, não da peça."""
    saida = bytearray(W * H)
    for Y in range(H):
        y0, y1 = Y * h // H, max(Y * h // H + 1, (Y + 1) * h // H)
        for X in range(W):
            x0, x1 = X * w // W, max(X * w // W + 1, (X + 1) * w // W)
            soma = tot = 0
            for y in range(y0, min(y1, h)):
                base = y * w
                for x in range(x0, min(x1, w)):
                    soma += m[base + x]
                    tot += 1
            if tot and soma * 2 >= tot:
                saida[Y * W + X] = 1
    return saida


def _iou(a, aw, ah, b, bw, bh):
    """Sobreposição de duas máscaras, ambas levadas à mesma grade fixa."""
    ra = _reamostra(a, aw, ah)
    rb = _reamostra(b, bw, bh)
    inter = uni = 0
    for i in range(GRADE * GRADE):
        va, vb = ra[i], rb[i]
        if va or vb:
            uni += 1
            if va and vb:
                inter += 1
    return inter / uni if uni else 0.0


def _assets_de_marca():
    """Os SVG oficiais que o CP-5 e a isenção do CP-1 comparam. CP-P58 (2026-10-11): `logos/` entra RECURSIVAMENTE — as
    assinaturas de produto (`logos/produtos/`, marca-seed §5.6, MR-P28) e qualquer subpasta que nascer (ex. empresas do
    grupo) são oficiais; antes só o nível de cima entrava e a assinatura SEED siga era comparada só com o logo SEED
    engenharia (0,905 a 160 px, 0,898 a 136 px: PASS num tamanho, FAIL "REDESENHO" no outro). `simbolos` e `mascote-eeny`
    seguem no nível de cima, como antes. Ordem estável: nível de cima primeiro, subpastas depois, cada grupo ordenado."""
    saida = []
    for pasta in ('logos', 'simbolos', 'mascote-eeny'):
        p = ASSETS / pasta
        if not p.is_dir():
            continue
        topo = sorted(p.glob('*.svg'))
        saida.extend(topo)
        if pasta == 'logos':
            saida.extend(sorted(x for x in p.rglob('*.svg') if x.parent != p))
    return saida


_CACHE_ASSET = {}
_CACHE_PAINEL = {}      # svg -> (cor do painel em hex, largura/altura da folha) — só os assets de painel cheio


def _painel_asset(svg):
    """(cor, proporção) do painel do asset de painel cheio (`cor-azul`, `cor-cinza`), ou None. CP-P36.

    MR-P29 (2026-10-10): os dois saíram de `03-assets/logos/` (exemplos do manual, não variações); nenhum asset oficial
    tem painel cheio hoje e este caminho fica inerte — mantido porque mede qualquer asset de painel que vier a entrar."""
    _mascara_asset(svg)
    return _CACHE_PAINEL.get(svg)


_CACHE_REAM = {}


def _ream_asset(svg, a, lado):
    """A máscara do asset levada à grade `lado` × `lado`, guardada (cálculo puro: só evita repetir)."""
    if (svg, lado) not in _CACHE_REAM:
        _CACHE_REAM[(svg, lado)] = _reamostra(a[0], a[1], a[2], lado, lado)
    return _CACHE_REAM[(svg, lado)]


def _iou_grades(ra, rb):
    inter = uni = 0
    for va, vb in zip(ra, rb):
        if va or vb:
            uni += 1
            if va and vb:
                inter += 1
    return inter / uni if uni else 0.0


def _deformado(rec, cw, ch, fora):
    """CP-P36: o recorte da peça, comparado com os assets cuja PROPORÇÃO ele não tem (fora da tolerância de 12 %).
    A sobreposição é medida numa grade quadrada — insensível à proporção — e por isso a proporção é checada à parte:
    uma marca que casa com o asset a 0,90 ou mais numa grade quadrada e não tem a proporção dele foi ACHATADA ou
    ESTICADA. Devolve (sobreposição, asset) do melhor, ou (0.0, None). Um passe grosso (32 × 32) poupa a grade cheia."""
    if not fora:
        return 0.0, None
    grosso = _reamostra(rec, cw, ch, 32, 32)
    fino = None
    melhor, quem = 0.0, None
    for svg, a in fora:
        if _iou_grades(grosso, _ream_asset(svg, a, 32)) < 0.6:
            continue
        if fino is None:
            fino = _reamostra(rec, cw, ch)
        v = _iou_grades(fino, _ream_asset(svg, a, GRADE))
        if v > melhor:
            melhor, quem = v, svg
    return melhor, quem


def _mascara_asset(svg, lado=520):
    """A máscara da TINTA do asset oficial, recortada na caixa de tinta.

    CP-P35 (2026-09-29, leva 14): o asset é aberto DEPOIS de `_svg_estilo_resolvido` (a mesma troca de classe por
    `style` inline que a CP-P32 faz na peça), porque o MuPDF não aplica o `<style>` de classe: `cor-azul` e `cor-cinza`
    saíam TODOS pretos, caixa de tinta 520×1 (o preto contra o preto), e nunca eram candidatos. E o asset de fundo
    transparente (a tinta é a parte opaca) sai pelo canal alfa, não pela distância ao fundo branco: `mono-negativo` e
    `fundo-turquesa` têm tinta BRANCA — com a troca de classe seriam branco sobre branco e sumiriam. O asset de painel
    cheio (`cor-azul`, `cor-cinza`: o retângulo da folha é o primeiro caminho) segue o caminho de antes — o fundo é
    a cor mais comum e a tinta é o resto."""
    if svg in _CACHE_ASSET:
        return _CACHE_ASSET[svg]
    try:
        import pymupdf
        resolvido, _tocados, _nao = _svg_estilo_resolvido(io.open(str(svg), encoding='utf-8', errors='replace').read())
        d = pymupdf.open(stream=resolvido.encode('utf-8'), filetype='svg')
        r = d[0].rect
        esc = lado / max(r.width, r.height)
        pix = d[0].get_pixmap(matrix=pymupdf.Matrix(esc, esc), alpha=True)
        alfa = pix.samples[3::4]
        if sum(1 for v in alfa if v >= 128) < 0.98 * pix.width * pix.height:
            # limiar 43 = o `limiar=128` do `_mascara` (soma de três canais) num traço preto sobre branco: cobertura ≥ 1/6.
            # Medido (rascunho/diag-limiar-alfa.py): com 43 a máscara dos 7 assets que já funcionavam sai IDÊNTICA à
            # de antes (sobreposição 1,000); com 128 ela ficava 0,907–0,947 e a peça, medida pelo mesmo `limiar`, engordava.
            m, w, h = bytearray(1 if v >= 43 else 0 for v in alfa), pix.width, pix.height
        else:
            m, w, h = _mascara(pix)
            # a borda de pixels de `cor-azul`/`cor-cinza` sai semitransparente (o painel cobre 595 pt, o pixmap
            # arredonda para cima; alfa 101 medido): sem isto ela vira "tinta" e a caixa de tinta cobre a folha inteira
            # (520×368 em vez de 520×247, medido em rascunho/diag-cor-azul.py). Num painel opaco a tinta é sempre
            # alfa 255: o que tem menos é aresta do painel
            for i, v in enumerate(alfa):
                if v < 250:
                    m[i] = 0
            # e a tinta de um asset de painel é ~80 % da folha (412 de 520 px): re-renderiza para a TINTA ter o lado
            # de `lado`, como o recorte da peça (`esc = 520 / max(g.width, g.height)`, abaixo) — senão o traço fino
            # do lettering branco sobre azul (contraste menor, limiar de 50 % de cobertura) some no asset e não na
            # peça: sobreposição 0,900 (abaixo do corte) com o próprio asset, medido em rascunho/diag-a01.py
            bb0 = _bbox(m, w, h)
            _CACHE_PAINEL[svg] = ('#%02X%02X%02X' % tuple(pix.samples[0:3]), r.width / r.height)
            if bb0:
                esc2 = esc * lado / max(bb0[2] - bb0[0] + 1, bb0[3] - bb0[1] + 1)
                pix = d[0].get_pixmap(matrix=pymupdf.Matrix(esc2, esc2), alpha=True)
                m, w, h = _mascara(pix)
                for i, v in enumerate(pix.samples[3::4]):
                    if v < 250:
                        m[i] = 0
        bb = _bbox(m, w, h)
        if not bb:
            _CACHE_ASSET[svg] = None
            return None
        x0, y0, x1, y1 = bb
        cw, ch = x1 - x0 + 1, y1 - y0 + 1
        rec = bytearray(cw * ch)
        for y in range(ch):
            for x in range(cw):
                rec[y * cw + x] = m[(y + y0) * w + (x + x0)]
        _CACHE_ASSET[svg] = (rec, cw, ch, cw / ch)
    except Exception:
        _CACHE_ASSET[svg] = None
    return _CACHE_ASSET[svg]


LIMIAR_INVISIVEL = 64      # CP-P41: forma do asset a esta distância (soma dos 3 canais) ou menos do fundo NÃO aparece na peça
_CACHE_SOBRE = {}


def _mascara_asset_sobre(svg, fundo, lado=520):
    """CP-P41 (2026-09-29, leva 15): a máscara do asset COMO ELE APARECE sobre o `fundo` da peça. `logo-vertical-
    fundo-turquesa` traz uma forma `.b` de #10B4AF (16 % da tinta opaca, medido em rascunho/diag-p41.py); sobre o painel
    #00ACAC ela está a distância 27 do fundo, abaixo do `limiar` de 128 do `_mascara` da peça, e some — a máscara do
    asset (canal alfa) a tem, a da peça não, e a identidade própria caía para o `-2` (sem essa forma). A forma que já é
    invisível sobre o fundo não pode ser exigida da peça: a máscara sai do alfa MENOS o pixel cuja cor composta sobre o
    fundo fica a `LIMIAR_INVISIVEL` (64, metade do limiar da peça) ou menos dele. Só os assets de tinta sobre transparência
    (os de painel cheio, `cor-azul` e `cor-cinza`, voltam a máscara base) e só quando a paleta do SVG TEM cor tão perto
    do fundo; do contrário — e se nenhum pixel sair — devolve a máscara base, a MESMA de antes (nenhuma cor de fundo
    nova muda o que já media). Mesma geometria de `_mascara_asset` (mesmo `lado`, mesmo limiar de alfa 43)."""
    base = _mascara_asset(svg, lado)
    if base is None or fundo is None or svg in _CACHE_PAINEL:
        return base
    perto = False
    for hx in _paleta_asset(svg):
        c = (int(hx[1:3], 16), int(hx[3:5], 16), int(hx[5:7], 16))
        if abs(c[0] - fundo[0]) + abs(c[1] - fundo[1]) + abs(c[2] - fundo[2]) <= LIMIAR_INVISIVEL:
            perto = True
            break
    if not perto:
        return base
    chave = (svg, tuple(fundo), lado)
    if chave in _CACHE_SOBRE:
        return _CACHE_SOBRE[chave]
    saida = base
    try:
        import pymupdf
        resolvido, _tocados, _nao = _svg_estilo_resolvido(io.open(str(svg), encoding='utf-8', errors='replace').read())
        d = pymupdf.open(stream=resolvido.encode('utf-8'), filetype='svg')
        r = d[0].rect
        esc = lado / max(r.width, r.height)
        pix = d[0].get_pixmap(matrix=pymupdf.Matrix(esc, esc), alpha=True)
        s, w, h = pix.samples, pix.width, pix.height
        fr, fg, fb = fundo
        m = bytearray(w * h)
        removidos = 0
        for i in range(w * h):
            a = s[4 * i + 3]
            if a < 43:
                continue
            # o pixmap é PRÉ-MULTIPLICADO (medido: (6, 72, 70, 102) num pixel de #10B4AF): cor composta = c + fundo·(1 − a/255)
            k = 255 - a
            dist = (abs(s[4 * i] + fr * k // 255 - fr) + abs(s[4 * i + 1] + fg * k // 255 - fg)
                    + abs(s[4 * i + 2] + fb * k // 255 - fb))
            if dist > LIMIAR_INVISIVEL:
                m[i] = 1
            else:
                removidos += 1
        # guarda medida na regressão de 189 peças: `logo-vertical-fundo-turquesa-2` (tinta BRANCA) sobre fundo BRANCO perdia ~80 % da
        # tinta e o que sobrava (o símbolo amarelo) casava a 0,973 com o símbolo de um logo horizontal — identidade falsa. Só a
        # forma MENOR (`.b` = 16 %, medido) some; se mais de 30 % da tinta ficaria invisível, o asset não está em uso sobre este
        # fundo e vale a máscara base
        bb = _bbox(m, w, h) if 0 < removidos <= 0.30 * (removidos + sum(m)) else None
        if bb:
            x0, y0, x1, y1 = bb
            cw, ch = x1 - x0 + 1, y1 - y0 + 1
            rec = bytearray(cw * ch)
            for y in range(ch):
                for x in range(cw):
                    rec[y * cw + x] = m[(y + y0) * w + (x + x0)]
            saida = (rec, cw, ch, cw / ch)
    except Exception:
        saida = base
    _CACHE_SOBRE[chave] = saida
    return saida


def _aglomera(desenhos, folga=8.0):
    """Agrupa desenhos vetoriais próximos — um logotipo é dezenas de caminhos."""
    import pymupdf
    caixas = [pymupdf.Rect(d['rect']) for d in desenhos]
    grupos = []
    for r in caixas:
        if r.width <= 0 or r.height <= 0:
            continue
        exp = pymupdf.Rect(r.x0 - folga, r.y0 - folga, r.x1 + folga, r.y1 + folga)
        alvo = None
        for k, g in enumerate(grupos):
            if g.intersects(exp):
                alvo = k
                break
        if alvo is None:
            grupos.append(pymupdf.Rect(r))
        else:
            # atribuição por ÍNDICE: `g |= r` só rebindaria o nome local e a
            # lista ficaria com o retângulo antigo — o aglomerado nunca crescia
            grupos[alvo] = grupos[alvo] | r
    # segunda passada: funde grupos que se tocam
    mudou = True
    while mudou:
        mudou = False
        for i in range(len(grupos)):
            for j in range(i + 1, len(grupos)):
                gi, gj = grupos[i], grupos[j]
                if pymupdf.Rect(gi.x0 - folga, gi.y0 - folga, gi.x1 + folga, gi.y1 + folga).intersects(gj):
                    grupos[i] = gi | gj
                    del grupos[j]
                    mudou = True
                    break
            if mudou:
                break
    return grupos


# CP-P30 (2026-09-29): a paleta de um asset oficial sai do TEXTO do SVG — `fill`, `stroke` e `stop-color` em hex,
# no atributo ou no `<style>` de classe. NÃO pelo MuPDF: ele não aplica o `<style>` de classe dos SVG oficiais
# (`.a{fill:#00acac}`) e lê os 14 logos/símbolos todos pretos (medido em 2026-09-29, rodada a do censo da CP-P30).
_RE_COR_SVG = re.compile(r'(?:fill|stroke|stop-color)\s*[:=]\s*"?\s*(#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3})(?![0-9A-Fa-f])')
_CACHE_PALETA = {}


def _paleta_asset(svg):
    if svg not in _CACHE_PALETA:
        pal = set()
        try:
            for h in _RE_COR_SVG.findall(io.open(svg, encoding='utf-8', errors='replace').read()):
                h = h[1:].upper()
                pal.add('#' + (''.join(c * 2 for c in h) if len(h) == 3 else h))
        except OSError:
            pass
        _CACHE_PALETA[svg] = pal
    return _CACHE_PALETA[svg]


def _tem_cor_fora(g, desenhos, cores_tok):
    """Algum desenho DENTRO do aglomerado `g` pinta cor que não é token (nem o #000000 do estado inicial)?"""
    for d in desenhos:
        r = d['rect']
        if r.x0 >= g.x0 - 0.5 and r.y0 >= g.y0 - 0.5 and r.x1 <= g.x1 + 0.5 and r.y1 <= g.y1 + 0.5:
            for chave in ('fill', 'color'):
                c = d.get(chave)
                if c:
                    hx = '#%02X%02X%02X' % tuple(max(0, min(255, round(v * 255))) for v in c[:3])
                    if hx not in cores_tok and hx != '#000000':
                        return True
    return False


def _eh_traco_e1(d):
    """O traço do regime E1 (marca-seed §12.2-b, "marca d'água explodida"): contorno SEM preenchimento na cor
    `turquesa-100` `#CDF3EC` (o canon: "o contorno é transformação mecânica do asset — cada `fill` reescrito para
    `fill:none` + stroke"; "Cor `turquesa-100` (#CDF3EC)"), tolerância de 2 por canal. CP-P46 (2026-10-03)."""
    c = d.get('color')
    if d.get('fill') is not None or not c:
        return False
    return all(abs(round(v * 255) - ref) <= 2 for v, ref in zip(c[:3], (0xCD, 0xF3, 0xEC)))


def _tinta_rel(d):
    """CP-P47 (2026-10-03): quanto da caixa do desenho é TINTA, de 0 a 1. Desenho só de contorno (sem preenchimento: o anel
    tracejado, a moldura, o traço de cota) tem tinta fina por definição: 0. Desenho preenchido: a área dos sub-caminhos pela
    fórmula do cadarço sobre os pontos de partida e de chegada de cada segmento (a corda do arco, que subestima o disco em
    poucos por cento e não muda a ordem de grandeza que importa aqui: o anel tracejado desenhado como contorno preenchido
    mede ~0,01 e o disco, a forma da marca e a letra medem ≥ 0,3)."""
    r = d['rect']
    caixa = max(r.width * r.height, 1e-6)
    if d.get('fill') is None:
        return 0.0
    area, pts, ult = 0.0, [], None
    for it in d.get('items', ()):
        k = it[0]
        if k == 're':
            q = it[1]
            area += abs(q.width * q.height)
            continue
        if k == 'qu':
            q = it[1]
            pp = [q.ul, q.ur, q.lr, q.ll]
            area += abs(sum(pp[i].x * pp[(i + 1) % 4].y - pp[(i + 1) % 4].x * pp[i].y for i in range(4))) / 2
            continue
        a, b = it[1], it[-1]
        if ult is None or abs(a.x - ult.x) > 0.01 or abs(a.y - ult.y) > 0.01:
            if len(pts) > 2:
                area += abs(sum(pts[i][0] * pts[(i + 1) % len(pts)][1] - pts[(i + 1) % len(pts)][0] * pts[i][1]
                                for i in range(len(pts)))) / 2
            pts = [(a.x, a.y)]
        pts.append((b.x, b.y))
        ult = b
    if len(pts) > 2:
        area += abs(sum(pts[i][0] * pts[(i + 1) % len(pts)][1] - pts[(i + 1) % len(pts)][0] * pts[i][1]
                        for i in range(len(pts)))) / 2
    return min(1.0, area / caixa)


def _molduras(des):
    """CP-P47 (2026-10-03): as MOLDURAS entre os desenhos — o anel tracejado de recorte do avatar, o contorno de um painel
    transparente, o traço que cerca a marca. É o desenho de TINTA FINA (`_tinta_rel` < 0,15) que CONTÉM a caixa de outros
    ≥ 2 desenhos. Na aglomeração a caixa da moldura engole a da marca (o anel de 206 pt do especime de avatar era o aglomerado) e
    a máscara da tinta, recortada na caixa inteira, herdava o anel: a marca oficial dentro dele lia 0,5 de sobreposição.
    Mesmo princípio do painel do CP-P33 (que só pega o retângulo preenchido): o que cerca a marca não é a marca."""
    cand = [d for d in des if len(d.get('items', ())) >= 4 and d['rect'].width >= 20 and d['rect'].height >= 20
            and _tinta_rel(d) < 0.15]
    out = []
    for c in cand:
        r = c['rect']
        n = 0
        for e in des:
            if e is c:
                continue
            q = e['rect']
            if r.x0 + 0.5 <= q.x0 and r.y0 + 0.5 <= q.y0 and r.x1 - 0.5 >= q.x1 and r.y1 - 0.5 >= q.y1                     and (q.x1 - q.x0) * (q.y1 - q.y0) < 0.9 * r.width * r.height:
                n += 1
                if n >= 2:
                    out.append(c)
                    break
    return out


def _hex_de(c):
    return '#%02X%02X%02X' % tuple(max(0, min(255, round(v * 255))) for v in c[:3])


_MAX_DESENHOS_DO_ANEL = 8   # CP-P63: teto de desenhos no aglomerado para o TRAÇO contar como cor (anel de rosca com 1 a 8 segmentos medido; EEny só de traço: 125 a 176)


def _fills_do_aglomerado(g, desenhos):
    """As cores de PREENCHIMENTO dos desenhos que cabem dentro do aglomerado `g`. CP-P63 (leva 50, 2026-10-31): o desenho que NÃO preenche (só traço: o anel de um gráfico de rosca, um
    ícone de linha) entra pela cor do TRAÇO. Antes ele não dava cor nenhuma, o aglomerado ficava «sem cor de preenchimento medida» e `_paleta_confere` NÃO isentava: um anel espesso turquesa
    de painel (traço 24 pt, raio 40 pt) lia «parece eeny-pose-07 … 0,629 — REDESENHO» (falso positivo medido; o EEny não tem cor declarada, a paleta dele é o preto). O traço PRETO continua
    lido como o EEny (limite declarado, E3 da prova-conferidor-leva50). O traço só conta no aglomerado de até `_MAX_DESENHOS_DO_ANEL` (8) desenhos: o EEny redesenhado SÓ com traço (125 a 176
    desenhos, poses 01 e 03) em cor fora da paleta, que a primeira versão do conserto deixava passar de FAIL a AVISO (X1, X2, X6, X8 da revisão), volta a ser REDESENHO.
    [limite declarado] (a) um gráfico de rosca com MAIS de 8 segmentos de traço volta ao falso positivo; (b) um redesenho de QUALQUER asset com até 8 DESENHOS só de traço fora da paleta dele sai AVISO em vez de FAIL (um caminho composto, um `<path>` só, conta 1 desenho por mais traços que tenha: é o que um vetorizador entrega; vale também para o traço preto quando a paleta do asset não tem preto, p.ex. o símbolo); mesmo limite do CP-P44 (preenchido fora da paleta já saía AVISO na base); AVISO não é PASS. Medido na 2.ª volta da revisão (2026-10-31): EEny pose 01 ou 03 só com traço, em 1 ou 8 desenhos, FAIL na base → AVISO; com 9 desenhos, FAIL."""
    dentro = [d for d in desenhos
              if d['rect'].x0 >= g.x0 - 0.5 and d['rect'].y0 >= g.y0 - 0.5 and d['rect'].x1 <= g.x1 + 0.5 and d['rect'].y1 <= g.y1 + 0.5]
    # o traço só vale como cor do aglomerado de POUCOS desenhos (o anel de rosca: 1 a 8 medidos); o EEny redesenhado só com traço tem 125 a 176 desenhos no aglomerado (medido, poses 01 e 03)
    # e, com cor fora da paleta, virava AVISO (falso negativo achado na revisão do trabalho C, 2026-10-31): nele o traço continua sem cor medida, isto é, REDESENHO
    traco_vale = len(dentro) <= _MAX_DESENHOS_DO_ANEL
    cores = set()
    for d in dentro:
        if d.get('fill'):
            cores.add(_hex_de(d['fill']))
        elif d.get('color') and traco_vale:
            cores.add(_hex_de(d['color']))
    return cores


def _paleta_confere(g, desenhos, svgs):
    """CP-P44 (2026-10-03): a marca REDESENHADA mantém as cores do asset que ela refaz; uma forma compacta com vazados
    (o sol, o disco com o símbolo vazado, a placa ISO 7010) tem a mesma sombra de tinta, mas outras cores. O "REDESENHO"
    exige também que o aglomerado pinte alguma cor da paleta do asset (±3 por canal; asset sem cor declarada pinta
    preto). Sem cor de preenchimento medida no aglomerado, não isenta: continua REDESENHO (conservador)."""
    # `svgs` = o asset de melhor sobreposição E os empatados com ele: as variantes da mesma geometria (principal, mono, cor)
    # empatam na máscara e o desempate seria adivinhar (CP-P30), então a paleta é a de todas elas
    pal = set()
    for s in svgs:
        pal |= (_paleta_asset(s) or {'#000000'})
    cores = _fills_do_aglomerado(g, desenhos)
    if not cores:
        return True
    ref = [tuple(int(p[i:i + 2], 16) for i in (1, 3, 5)) for p in pal]
    return any(all(abs(int(c[i:i + 2], 16) - r[k]) <= 3 for k, i in enumerate((1, 3, 5))) for c in cores for r in ref)


def _fora_da_pagina(g, pagina):
    """O aglomerado passa da borda da página (CP-P46): o retângulo dos desenhos não é recortado pelo `overflow`."""
    vis = g & pagina
    if g.width <= 0 or g.height <= 0 or vis.is_empty:
        return False
    sai = g.x0 < pagina.x0 - 1 or g.y0 < pagina.y0 - 1 or g.x1 > pagina.x1 + 1 or g.y1 > pagina.y1 + 1
    return sai and (vis.width * vis.height) >= 0.2 * g.width * g.height


def _casa_cortada(pg, g, esc, svg, fundo_peca):
    """CP-P46: sobreposição da PARTE VISÍVEL do aglomerado `g` (cortado pela borda da página) com a MESMA parte do asset
    `svg` — o asset, esticado na caixa inteira do aglomerado, é recortado pela página como a peça foi. Devolve 0.0 se não
    houver o que comparar. A comparação inteira (`_iou`) não serve: ela recorta as duas máscaras na caixa da tinta visível
    e a parte do asset que a página cortou deixa de existir só de um lado."""
    import pymupdf
    a = _mascara_asset_sobre(svg, fundo_peca) or _mascara_asset(svg)
    if not a:
        return 0.0
    vis = g & pg.rect
    pix = pg.get_pixmap(clip=vis, matrix=pymupdf.Matrix(esc, 0, 0, esc, -vis.x0 * esc, -vis.y0 * esc), alpha=False)
    mp, w, h = _mascara(pix)
    if not mp:
        return 0.0
    ma, aw, ah = a[0], a[1], a[2]
    fx0, fx1 = (vis.x0 - g.x0) / g.width, (vis.x1 - g.x0) / g.width
    fy0, fy1 = (vis.y0 - g.y0) / g.height, (vis.y1 - g.y0) / g.height
    cx0, cx1 = int(round(fx0 * aw)), max(int(round(fx0 * aw)) + 1, int(round(fx1 * aw)))
    cy0, cy1 = int(round(fy0 * ah)), max(int(round(fy0 * ah)) + 1, int(round(fy1 * ah)))
    cw, ch = min(cx1, aw) - cx0, min(cy1, ah) - cy0
    if cw <= 0 or ch <= 0:
        return 0.0
    sub = bytearray(cw * ch)
    for y in range(ch):
        for x in range(cw):
            sub[y * cw + x] = ma[(cy0 + y) * aw + (cx0 + x)]
    return _iou_grades(_reamostra(mp, w, h), _reamostra(sub, cw, ch))


def acha_marcas(doc, cores_tok=None, escala_folga=1.0):
    """O reconhecimento do CP-5, separado do veredito (CP-P30): devolve None sem assets, ou
    {'oficiais', 'suspeitos', 'avaliados', 'casadas'} — `casadas` é a lista das instâncias com sobreposição
    ≥ 0,90, cada uma com a página, o retângulo do aglomerado e TODOS os assets que casam nele (não só o de
    maior sobreposição: variantes da mesma geometria — principal, mono, cor — empatam na máscara, e escolher
    uma delas pelo desempate seria adivinhar). O CP-1 isenta por esse achado; o CP-5 julga por ele.

    Com `cores_tok`, o aglomerado ABAIXO do piso de tamanho do CP-5 (25 × 12 pt, de 2026-09-08) que pinta cor
    fora do token também é comparado — só para `casadas`, nunca para o veredito do CP-5 (não entra em
    oficiais, suspeitos, avaliados nem no "melhor da página"). Motivo medido: no PDF do deck, 9 dos 17
    `#00ACAC` são o símbolo principal do rodapé, 23 × 22 pt, que o piso deixava de fora (sobreposição 0,992
    com `simbolo-principal.svg` quando comparado). Sem número novo: o critério é o mesmo 0,90."""
    import pymupdf
    assets = _assets_de_marca()
    if not assets:
        return None
    oficiais, suspeitos, avaliados, casadas = [], [], 0, []
    regimes, cortadas = [], []
    # A distância entre o símbolo e a palavra "SEED" é maior que a distância
    # entre as letras: com UMA folga só, ou o aglomerado quebra a logo em duas
    # partes (e a parte é comparada com o asset errado), ou funde tudo com o
    # texto ao lado. Por isso a régua aglomera em TRÊS folgas e fica com a
    # melhor sobreposição — não adivinha qual é a certa.
    # CP-P43: com a peça impressa no tamanho declarado, a logo de uma peça GRANDE (rollup, frota: zoom 3 a 7,6) tem o intervalo
    # entre o símbolo e a palavra maior que 28 pt e se partia em duas; a folga cresce com o zoom. Peça PEQUENA (zoom < 1, como o
    # envelope a 0,32) NÃO encolhe a folga: medido no envelope-saco-a4-frente, a logo casa com a folga a 0,64 e 1,28 do valor
    # antigo e deixa de casar a 0,32 (o intervalo real, 9–18 pt, é maior que 28 × 0,32) — por isso o `max(1.0, …)`
    FOLGAS = tuple(f * max(1.0, escala_folga) for f in (6.0, 14.0, 28.0))
    for pno, pg in enumerate(doc):
        # ⚠ NÃO filtrar desenho pequeno antes de aglomerar: as letras de
        # "engenharia" têm ~7 pt e sumiam do aglomerado, deixando a logo
        # invisível para a régua (defeito do instrumento medido na cobaia).
        # e também não pode entrar no aglomerado o que é FUNDO: a chapa da
        # página e as faixas de sangria engolem todos os desenhos num aglomerado
        # só, do tamanho da página, que a régua descarta — e a logo some junto
        # (segundo defeito do instrumento medido na cobaia).
        area_pg = pg.rect.width * pg.rect.height
        todos_des = pg.get_drawings()
        # CP-P46 (2026-10-03): o regime E1 — contorno turquesa-100 sem preenchimento (marca-seed §12.2-b) — é a composição
        # "explodida" que o canon manda sangrar pelas quatro bordas, recortada pelo `overflow:hidden` da peça. Não é marca a
        # comparar nem defeito: sai da comparação e fica registrado.
        e1 = [d for d in todos_des if _eh_traco_e1(d)]
        if len(e1) >= 3:
            regimes.append('pág %d · regime E1 (marca-seed §12.2-b): %d traço(s) de contorno turquesa-100, sem preenchimento'
                           % (pno + 1, len(e1)))
            ids_e1 = set(id(d) for d in e1)
        else:
            ids_e1 = set()
        des = []
        for d in todos_des:
            if id(d) in ids_e1:
                continue
            r = d['rect']
            if r.width <= 0.5 or r.height <= 0.5:
                continue
            if r.width * r.height > area_pg * 0.20:
                continue
            if r.width > pg.rect.width * 0.85:
                continue
            des.append(d)
        # CP-P33 (2026-09-29, leva 12): o PAINEL pequeno (menor que o filtro de 20 % acima) também é fundo — o
        # retângulo, reto ou de cantos arredondados, que CONTÉM outro desenho. Antes ele entrava no aglomerado, a
        # proporção do aglomerado virava a do painel e o logo sobre ele não era nem comparado (a cobaia da prova
        # prova-classe-e-painel.py: AVISO "nenhuma ocorrência"). "Retangular" = só retângulo/quadrilátero, ou até 4
        # retas e até 4 curvas (o cartão de cantos arredondados que o Chrome imprime). Medido antes nos 22 assets
        # oficiais (rascunho/sonda-painel.py do agente C): a regra só pega o retângulo-prancha dos logos cor-azul e
        # cor-cinza, nenhuma parte de logo, símbolo ou mascote. Sem número novo: é a regra da chapa, na escala do painel.
        def _retangular(d):
            ks = [it[0] for it in d.get('items', ())]
            return bool(ks) and set(ks) <= {'l', 'c', 're', 'qu'} and ks.count('c') <= 4 and ks.count('l') <= 4

        def _contem(a, b):
            return a.x0 + 0.5 <= b.x0 and a.y0 + 0.5 <= b.y0 and a.x1 - 0.5 >= b.x1 and a.y1 - 0.5 >= b.y1

        # CP-P47 (2026-10-03): a MOLDURA (anel, contorno) que cerca a marca sai do aglomerado, como o painel do CP-P33
        molduras = _molduras(des)
        if molduras:
            regimes.append('pág %d · %d moldura(s) de tinta fina cercando outros desenhos (anel/contorno), fora do aglomerado '
                           'da marca (CP-P47)' % (pno + 1, len(molduras)))
            des = [d for d in des if not any(d is m for m in molduras)]
        paineis = [d for d in des if d.get('fill') is not None and _retangular(d)
                   and any(e is not d and _contem(d['rect'], e['rect']) for e in des)]
        # CP-P36: o painel que TEM a cor e a proporção da folha de um asset de painel cheio (`cor-azul`, `cor-cinza`)
        # e não traz dentro NENHUMA marca oficial é a marca REDESENHADA — antes só sumia do aglomerado (CP-P33) e a
        # régua dava AVISO "nenhuma ocorrência". Só entra o painel que contém desenho (o que o CP-P33 já chama de
        # painel). Dentro basta QUALQUER asset casar, não o próprio: o lettering de `cor-azul` tem a forma de
        # `mono-negativo` e a 120 pt a sobreposição com o próprio asset cai a 0,896 (corte 0,90; medido em
        # rascunho/diag-a01.py) — exigir o próprio reprovaria o logo certo em escala pequena, que passava.
        paineis_de_asset = []
        for p in paineis:
            hx = [round(v * 255) for v in p['fill'][:3]]
            for s in assets:
                pa = _painel_asset(s)
                if pa and p['rect'].width >= 25 and p['rect'].height >= 12                         and max(abs(int(pa[0][i:i + 2], 16) - hx[k]) for k, i in enumerate((1, 3, 5))) <= 2                         and abs(p['rect'].width / p['rect'].height - pa[1]) / pa[1] < 0.12:
                    paineis_de_asset.append((pymupdf.Rect(p['rect']), s))
        if paineis:
            des = [d for d in des if not any(d is p for p in paineis)]
        caixas, vistas = [], set()
        for folga in FOLGAS:
            for g in _aglomera(des, folga):
                chave = (round(g.x0), round(g.y0), round(g.x1), round(g.y1))
                if chave not in vistas:
                    vistas.add(chave)
                    caixas.append(g)
        melhor_da_pagina = 0.0
        pendentes = []
        for g in caixas:
            if g.width > pg.rect.width * 0.95:
                continue
            # abaixo do piso: só para a isenção do CP-1, e só se pinta cor fora do token (ver a docstring)
            pequeno = g.width < 25 or g.height < 12
            # CP-P43 (2026-10-03): o piso de 25 × 12 pt é FÍSICO e foi escolhido com a peça impressa em Letter, onde uma peça de
            # 300 DPI aparecia 3,1× maior. No tamanho declarado, o símbolo de 7 mm de um cartão ou de um crachá (16–22 pt) cai
            # abaixo do piso. Abaixo do piso (e acima de 10 × 10 pt, que deixa os pingos e as letras de fora) a marca entra
            # só como EVIDÊNCIA POSITIVA: casando com um asset (≥ 0,90) é "oficial"; nunca vira suspeita nem aglomerado avaliado.
            minimo = g.width >= 10 and g.height >= 10
            if pequeno and not minimo and not (cores_tok is not None and _tem_cor_fora(g, des, cores_tok)):
                continue
            asp = g.width / g.height
            todos = [(s, a) for s in assets for a in [_mascara_asset(s)] if a]
            cand = [(s, a) for s, a in todos if abs(asp - a[3]) / a[3] < 0.12]
            fora = [(s, a) for s, a in todos if abs(asp - a[3]) / a[3] >= 0.12]
            if pequeno and not cand:
                continue
            if cand and not pequeno:
                avaliados += 1
            # CP-P43: no tamanho declarado, o aglomerado de uma peça grande (a lateral de um veículo tem 4.663 pt) passou a render a
            # 3× = 14.000 × 6.700 px e o laço em Python levava 148 a 282 s por peça (medido). A grade de comparação é 220 × 220:
            # o lado maior do render fica em 1040 px no máximo (o piso de 3× continua valendo para o aglomerado de até 347 pt)
            esc = max(min(3.0, 1040.0 / max(g.width, g.height)), 520.0 / max(g.width, g.height))
            # CP-P40 (2026-09-29, leva 15): a translação `-g.x0·esc` põe a tinta na FASE zero do pixel. Sem ela o recorte
            # herda a fração de pixel da posição da peça na página e a sobreposição de `cor-azul` com o PRÓPRIO asset
            # oscilava 0,886–0,969 só por mudar 0,1 pt de lugar (medido em rascunho/diag-p40.py; corte 0,90); com a
            # fase zero fica em 0,944 em toda largura e todo deslocamento
            pix = pg.get_pixmap(clip=g, matrix=pymupdf.Matrix(esc, 0, 0, esc, -g.x0 * esc, -g.y0 * esc), alpha=False)
            fundo_peca = _fundo_do_pixmap(pix)
            m, w, h = _mascara(pix)
            bb = _bbox(m, w, h)
            if not bb:
                continue
            x0, y0, x1, y1 = bb
            cw, ch = x1 - x0 + 1, y1 - y0 + 1
            rec = bytearray(cw * ch)
            for y in range(ch):
                for x in range(cw):
                    rec[y * cw + x] = m[(y + y0) * w + (x + x0)]
            melhor, nome, asp_nome, svg_melhor = 0.0, '', 1.0, None
            casam, notas_v = [], []
            for svg, a in cand:
                a = _mascara_asset_sobre(svg, fundo_peca) or a     # CP-P41: o asset como aparece sobre o fundo desta marca
                v = _iou(rec, cw, ch, a[0], a[1], a[2])
                if v >= 0.90:
                    casam.append(svg)
                notas_v.append((svg, v))
                if v > melhor:
                    melhor, nome, asp_nome, svg_melhor = v, svg.name, a[3], svg
            if pequeno:
                if casam:
                    casadas.append({'pno': pno, 'rect': pymupdf.Rect(g), 'assets': casam})
                    if minimo:
                        oficiais.append('pág %d · %s (sobreposição %.3f, abaixo do piso de 25×12 pt: só evidência positiva)'
                                        % (pno + 1, nome, melhor))
                continue
            # CP-P46: marca de logo/símbolo OFICIAL cortada pela borda da própria peça, fora do regime E1 (que já saiu dos
            # desenhos acima). A parte visível casa com a MESMA parte do asset: não é redesenho, é a marca partida.
            if melhor < 0.90 and _fora_da_pagina(g, pg.rect):
                for svg_c, a_c in cand:
                    # CP-P58: pela pasta de topo em `03-assets/`, não pela pasta-pai — `logos/produtos/` e outras subpastas de logos contam
                    try:
                        topo_c = svg_c.relative_to(ASSETS).parts[0]
                    except ValueError:
                        topo_c = svg_c.parent.name
                    if topo_c not in ('logos', 'simbolos'):
                        continue
                    vc = _casa_cortada(pg, g, esc, svg_c, fundo_peca)
                    if vc >= 0.85:
                        cortadas.append('pág %d · %s cortado pela borda da peça (a parte visível casa %.3f com a mesma parte do '
                                        'asset) — a marca oficial não se corta; só a E1 sangra (contorno turquesa-100, §12.2-b)'
                                        % (pno + 1, svg_c.name, vc))
                        break
            if melhor < 0.90:
                dv, quem = _deformado(rec, cw, ch, fora)
                if dv >= 0.90:
                    pendentes.append('pág %d · parece %s (sobreposição %.3f numa grade quadrada) mas a proporção é %.2f e a '
                                     'do asset é %.2f — FORMA ALTERADA (achatada ou esticada), use o arquivo oficial'
                                     % (pno + 1, quem.name, dv, asp, _mascara_asset(quem)[3]))
            melhor_da_pagina = max(melhor_da_pagina, melhor)
            if melhor >= 0.90:
                oficiais.append('pág %d · %s (sobreposição %.3f)' % (pno + 1, nome, melhor))
                casadas.append({'pno': pno, 'rect': pymupdf.Rect(g), 'assets': casam})
            # CP-P42 (2026-09-29, leva 15): "parece X" (REDESENHO) exige também a PROPORÇÃO DA TINTA de X (12 %, a mesma tolerância
            # do filtro de candidatos). A sobreposição é medida numa grade QUADRADA e, sem isto, um pedaço de marca cortado
            # pela borda do papel ou da própria peça (`envelope-*-verso`, `portfolio-capa`) ou uma letra "E" grande da
            # agenda (0,456 com `eeny-pose-04`, fora da proporção dele) lia REDESENHO. Sem a proporção não é o mesmo desenho
            # refeito: fica AVISO (nunca PASS), e a forma esticada ou achatada segue no `_deformado`, acima
            elif melhor >= 0.45 and abs(cw / ch - asp_nome) / asp_nome < 0.12 and _paleta_confere(g, des, [s for s, v in notas_v if v >= melhor - 0.03]):
                pendentes.append('pág %d · parece %s mas a sobreposição é %.3f — REDESENHO, use o arquivo oficial'
                                 % (pno + 1, nome, melhor))
        # parte de uma logo que casou inteira não é redesenho: é a parte dela
        if melhor_da_pagina < 0.90:
            suspeitos.extend(pendentes)
        # CP-P36: painel com a cor e a proporção de um asset de painel cheio, sem o desenho do asset dentro
        for rp, s in paineis_de_asset:
            if not any(c['pno'] == pno and rp.contains(c['rect']) for c in casadas):
                suspeitos.append('pág %d · painel com a cor e a proporção de %s, mas sem nenhuma marca oficial dentro — '
                                 'REDESENHO, use o arquivo oficial' % (pno + 1, s.name))
    return {'oficiais': oficiais, 'suspeitos': suspeitos, 'avaliados': avaliados, 'casadas': casadas,
            'regimes': regimes, 'cortadas': cortadas}


def confere_marca(doc, f, achado=False):
    """`achado`: o resultado de `acha_marcas(doc)` já calculado (o `confere_pdf` o calcula antes do CP-1);
    sem ele, calcula aqui — o uso de sempre."""
    if achado is False:
        achado = acha_marcas(doc)
    if achado is None:
        f.item('CP-5', None, 'marca: nenhum asset oficial encontrado em 03-assets/ — sem referência para comparar', sem_medida=True)   # REVISAO c: sem referência a marca não foi medida
        return
    oficiais, suspeitos, avaliados = achado['oficiais'], achado['suspeitos'], achado['avaliados']
    regimes, cortadas = achado.get('regimes', []), achado.get('cortadas', [])
    if cortadas:
        f.item('CP-5', False,
               'marca: %d marca(s) oficial(is) CORTADA(S) pela borda da peça, fora do regime E1 (%d oficiais encontrados '
               'em %d aglomerados avaliados)' % (len(cortadas), len(oficiais), avaliados),
               cortadas + suspeitos + oficiais + regimes)
    elif suspeitos:
        f.item('CP-5', False,
               'marca: %d desenho(s) parecido(s) com a marca que NÃO são o asset oficial (%d oficiais encontrados '
               'em %d aglomerados avaliados)' % (len(suspeitos), len(oficiais), avaliados),
               suspeitos + oficiais + regimes)
    elif oficiais:
        f.item('CP-5', True,
               'marca: %d ocorrência(s) da marca, todas casando com o asset oficial (sobreposição ≥ 0,90)'
               % len(oficiais), oficiais + regimes)
    else:
        f.item('CP-5', 'aviso',
               'marca: nenhuma ocorrência da marca reconhecida nos %d aglomerados avaliados — confira se a peça '
               'deveria trazer o logotipo' % avaliados, regimes)


# ════════════════════════════════════════════════════════════════════════════
# CP-6 — GRAFISMO: a serra casa com a saída do gerador
# ════════════════════════════════════════════════════════════════════════════
def _paths_do_gerador(span):
    import importlib.util
    if not GERADOR:
        raise RuntimeError('gen-detalhe.py não encontrado nem no repositório nem no pacote da skill')
    # ⚠ sem bytecode: importar o gerador criava __pycache__/ DENTRO da pasta da
    # skill instalada, e a guarda SK-6 (que compara a cópia instalada com a
    # gerada, arquivo a arquivo) reprovava. Régua não suja o que ela mede.
    anterior = sys.dont_write_bytecode
    sys.dont_write_bytecode = True
    try:
        spec = importlib.util.spec_from_file_location('gen_detalhe', str(GERADOR))
        mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(mod)
    finally:
        sys.dont_write_bytecode = anterior
    svg, meta = mod.svg_divisor(span, tema='escuro')
    pontos = []
    for m in re.finditer(r'<path ([^>]*?)d="([^"]+)"([^>]*)/>', svg):
        if 'stroke=' not in (m.group(1) + m.group(3)):
            continue
        nums = [float(x) for x in re.findall(r'-?\d+\.?\d*', m.group(2))]
        pontos.append([(nums[i], nums[i + 1]) for i in range(0, len(nums) - 1, 2)])
    return pontos, meta


def _assinatura(pts, n=32):
    """Altura da curva em n posições X igualmente espaçadas, normalizada em 0..1
    nos dois eixos. Reamostrar por X (e não por índice de ponto) é o que torna a
    assinatura comparável entre escalas e entre representações diferentes da
    MESMA curva — o PDF converte a quadrática do SVG em cúbica e o número de
    pontos muda, mas a curva é a mesma."""
    if len(pts) < 3:
        return None
    pts = sorted(pts, key=lambda p: p[0])
    xs = [p[0] for p in pts]
    ys = [p[1] for p in pts]
    x0, x1 = xs[0], xs[-1]
    y0, y1 = min(ys), max(ys)
    if x1 - x0 <= 0 or y1 - y0 <= 0:
        return None
    saida, j = [], 0
    for i in range(n):
        alvo = x0 + (x1 - x0) * i / (n - 1.0)
        while j < len(xs) - 2 and xs[j + 1] < alvo:
            j += 1
        xa, xb = xs[j], xs[j + 1]
        ya, yb = ys[j], ys[j + 1]
        t = 0.0 if xb == xa else (alvo - xa) / (xb - xa)
        saida.append(round(((ya + (yb - ya) * t) - y0) / (y1 - y0), 3))
    return tuple(saida)


def _distancia(a, b):
    return sum(abs(x - y) for x, y in zip(a, b)) / len(a)


# CP-P48 (2026-10-03, leva 17): "cena larga" = o traço de serra com ≥ metade da página — critério calibrado em Letter (612 pt,
# onde a metade são 306 pt). Desde a CP-P43 a peça de tela sai no tamanho declarado (a `capa-youtube` em 1920 pt) e a metade da
# página passou a ser 960 pt: a serra dela, que mede 703 pt e é a MESMA em CSS px, deixou de ser vista (3 peças: `capa-youtube`,
# `capa-youtube-guia`, `carrossel-exemplo-sequencia` passaram de PASS para NÃO MEDI). Baixar o corte para 306 pt reprovou a
# `frota-regra-do-formato` (um contorno de pílula de 357 × 19 pt é traço largo e fino, e não é serra): por isso a cena é de dois
# níveis. FORTE (≥ metade da página): como sempre, casa ou reprova. FRACA (de 306 pt até a metade da página, só em página maior
# que Letter): é EVIDÊNCIA POSITIVA — entra se casar com a serra do gerador, e o que não casa é ignorado (nunca reprova), como o
# símbolo pequeno do CP-5 (CP-P43). Página pequena não tem cena fraca (a metade dela já é ≤ 306 pt).
_CENA_LARGA_PT = 306.0


def confere_grafismo(doc, f):
    import pymupdf
    cenas = []
    for pno, pg in enumerate(doc):
        for d in pg.get_drawings():
            if d['type'] != 's' or not d.get('color'):
                continue
            r = d['rect']
            forte = r.width >= pg.rect.width * 0.5
            if (not forte and r.width < _CENA_LARGA_PT) or r.height <= 0 or r.width / r.height < 5:
                continue
            pts = []
            for it in d['items']:
                for v in it[1:]:
                    if hasattr(v, 'x'):
                        pts.append((v.x, v.y))
            a = _assinatura(pts)
            if a:
                cenas.append((pno + 1, r, a, forte))
    if not cenas:
        f.item('CP-6', None, 'grafismo: nenhuma cena de linha larga encontrada — nada a comparar')
        return
    largura = max(c[1].width for c in ([c for c in cenas if c[3]] or cenas))   # o span do gerador vem da cena FORTE, se houver
    try:
        ref, meta = _paths_do_gerador(round(largura))
    except Exception as e:
        f.item('CP-6', None, 'grafismo: não foi possível rodar o gen-detalhe.py (%s)' % e, sem_medida=True)
        return
    refs = [x for x in (_assinatura(p) for p in ref) if x]
    casam, fora, validas = [], [], []
    for pno, r, a, forte in cenas:
        d = min((_distancia(a, x) for x in refs), default=9)
        if not forte and d > 0.06:
            continue                      # CP-P48: a cena FRACA que não casa não é evidência de nada (nunca reprova)
        validas.append((pno, r, a))
        (casam if d <= 0.06 else fora).append('pág %d · %.0f×%.0f pt · distância %.3f' % (pno, r.width, r.height, d))
    cenas = validas
    if not cenas:
        f.item('CP-6', None, 'grafismo: nenhuma cena de linha larga encontrada — nada a comparar')
        return
    # a proporção se mede na faixa VISÍVEL (a cena é recortada pela página; os
    # arcos nascem e morrem fora dela), nunca no retângulo do traçado
    pag = doc[cenas[0][0] - 1].rect
    visivel = None
    for _, r, _a in cenas:
        c = r & pag
        visivel = c if visivel is None else (visivel | c)
    razao = (pag.width / visivel.height) if visivel and visivel.height else 0
    nota = 'faixa visível %.0f×%.0f pt, proporção %.1f (natural do canon: 12,7)' % (pag.width, visivel.height, razao)
    if fora:
        f.item('CP-6', False,
               'grafismo: %d de %d traços da serra casam com o gen-detalhe.py; %s'
               % (len(casam), len(cenas), nota), fora + casam)
    else:
        f.item('CP-6', True,
               'grafismo: %d de %d traços da serra casam com a saída do gen-detalhe.py; %s'
               % (len(casam), len(cenas), nota), casam)


# ════════════════════════════════════════════════════════════════════════════
# ENTRADAS: PDF (direto), SVG (MuPDF) e HTML (pelo navegador, como em produção)
# ════════════════════════════════════════════════════════════════════════════
def _familias_do_svg(caminho):
    """Famílias DECLARADAS no arquivo — atributo, style e @font-face."""
    s = io.open(caminho, encoding='utf-8', errors='replace').read()
    fam = collections.Counter()
    for m in re.finditer(r'font-family\s*[:=]\s*["\']?([^"\';>}]+)', s, re.I):
        primeira = m.group(1).split(',')[0].strip().strip('\'"')
        if primeira:
            fam[primeira] += 1
    return {k: '%d declaração(ões)' % v for k, v in fam.items()}


# CP-P32 (2026-09-29, leva 12): o MuPDF (1.28.2, medido) NÃO aplica o `<style>` de um SVG — `.a{fill:#123456}` e
# `rect{fill:…}` são lidos em PRETO; só o atributo `fill` e o `style` inline valem (rascunho/repro-cp-p32.py do agente
# C: 5 de 5 formas). Uma peça SVG que pinta por classe era medida em preto no CP-1, no CP-4 e no CP-5. Antes de abrir
# o SVG, as regras SIMPLES do `<style>` viram `style` inline, na ordem da cascata do navegador: a regra de folha vence
# o atributo de apresentação (`fill="…"`), a mais específica vence (id > classe > elemento) e, no empate, a última;
# o `style` inline que o elemento já tinha vence todas. Herança pelo `<g>` fica com o MuPDF (medido: ele herda o
# `style` do pai). O que não é seletor simples (`g .a`, `a > b`, `:hover`, `[attr]`, `@media`…) NÃO é aplicado e
# volta DECLARADO (lição 32 de reguas-e-instrumentos: régua que pula um tipo de nó diz isso no placar).
_RE_STYLE_BLOCO = re.compile(r'<style\b[^>]*>(.*?)</style>', re.S | re.I)
_RE_SEL_SIMPLES = re.compile(r'^(?:(?P<tag>[A-Za-z][\w-]*)?(?P<cls>(?:\.[\w-]+)*)|#(?P<id>[\w-]+))$')
_RE_TAG_ABRE = re.compile(r'<(?P<nome>[A-Za-z][\w:.-]*)(?P<attrs>(?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|\'[^\']*\'))*)\s*(?P<fim>/?)>')


def _svg_estilo_resolvido(texto):
    """(texto com as regras simples do <style> como style inline, nº de elementos tocados, [regras não aplicadas])."""
    regras, nao = [], []
    for bloco in _RE_STYLE_BLOCO.findall(texto):
        css = re.sub(r'/\*.*?\*/', '', bloco, flags=re.S).replace('<![CDATA[', '').replace(']]>', '')
        # regra @ com bloco (inclusive aninhado, como @media { a { } }) sai inteira e é declarada; @font-face não pinta
        while True:
            m = re.search(r'@[\w-]+[^{;]*\{', css)
            if not m:
                break
            i, prof = m.end(), 1
            while i < len(css) and prof:
                prof += {'{': 1, '}': -1}.get(css[i], 0)
                i += 1
            if not m.group(0).lower().startswith('@font-face'):
                nao.append(re.sub(r'\s+', ' ', m.group(0).rstrip('{')).strip())
            css = css[:m.start()] + css[i:]
        for sels, decl in re.findall(r'([^{}]+)\{([^{}]*)\}', css):
            decls = [(p.strip().lower(), v.strip()) for p, _, v in
                     (d.partition(':') for d in decl.split(';')) if p.strip() and v.strip()]
            for sel in (s.strip() for s in sels.split(',')):
                m = _RE_SEL_SIMPLES.match(sel)
                if not sel or not m or not (m.group('tag') or m.group('cls') or m.group('id')):
                    nao.append(sel)
                    continue
                cls = [c for c in (m.group('cls') or '').split('.') if c]
                espec = (1 if m.group('id') else 0, len(cls), 1 if m.group('tag') else 0)
                regras.append((espec, len(regras), m.group('tag'), set(cls), m.group('id'), decls))
    if not regras:
        return texto, 0, nao
    tocados = 0
    trechos, pos = [], 0
    for bm in _RE_STYLE_BLOCO.finditer(texto):          # não reescreve o que está dentro do próprio <style>
        trechos.append((pos, bm.start(), True))
        trechos.append((bm.start(), bm.end(), False))
        pos = bm.end()
    trechos.append((pos, len(texto), True))

    def reescreve(m):
        nonlocal tocados
        nome, attrs = m.group('nome').split(':')[-1], m.group('attrs')
        av = dict((k.lower(), v) for k, v in re.findall(r'([^\s=/>]+)\s*=\s*"([^"]*)"', attrs))
        av.update((k.lower(), v) for k, v in re.findall(r"([^\s=/>]+)\s*=\s*'([^']*)'", attrs))
        classes = set((av.get('class') or '').split())
        casam = sorted(r for r in regras
                       if (r[2] is None or r[2] == nome) and r[3] <= classes and (r[4] is None or r[4] == av.get('id'))
                       and (r[2] or r[3] or r[4]))
        if not casam:
            return m.group(0)
        tocados += 1
        final = collections.OrderedDict()
        for r in casam:
            for p, v in r[5]:
                final[p] = v
        # o style inline que o elemento já tinha vence a regra (no navegador); no MuPDF a PRIMEIRA declaração repetida
        # vence e o ATRIBUTO vence o style (medido: rascunho/repro-cp-p32-precedencia.py) — por isso sai UM style sem
        # repetição e sai o atributo de apresentação da mesma propriedade, que no navegador perde para os dois
        for p, _, v in (d.partition(':') for d in (av.get('style') or '').split(';')):
            if p.strip() and v.strip():
                final[p.strip().lower()] = v.strip()
        attrs = re.sub(r'\sstyle\s*=\s*("[^"]*"|\'[^\']*\')', '', attrs, count=1)
        for p in final:
            attrs = re.sub(r'\s%s\s*=\s*("[^"]*"|\'[^\']*\')' % re.escape(p), '', attrs, count=1)
        estilo = '; '.join('%s: %s' % (p, v.replace('"', "'")) for p, v in final.items())
        return '<%s%s style="%s"%s>' % (m.group('nome'), attrs, estilo, m.group('fim'))

    saida = []
    for a, b, livre in trechos:
        saida.append(_RE_TAG_ABRE.sub(reescreve, texto[a:b]) if livre else texto[a:b])
    return ''.join(saida), tocados, nao


def _chrome():
    """O mesmo Chrome que a casa usa para renderizar — %CHROME_BIN% e depois os
    caminhos padrão do Windows (mesma ordem do 06-validacao/ferramentas/ambiente.mjs)."""
    cands = [os.environ.get('CHROME_BIN'),
             r'C:\Program Files\Google\Chrome\Application\chrome.exe',
             r'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
             '/usr/bin/google-chrome', '/usr/bin/chromium']
    for c in cands:
        if c and pathlib.Path(c).exists():
            return c
    return None


def _roda_chrome(cmd, timeout=None, temp=None):
    """Roda o Chrome de linha de comando e devolve `(código, stdout em bytes)`,
    ou `None` se ele não terminou em `timeout` segundos — o chamador diz NÃO MEDI.

    `temp` (CP-P24, 2026-09-27): a pasta temporária DA CHAMADA. Quando vem, o
    Chrome recebe TEMP/TMP apontando para ela, e o que ele mesmo larga no
    temporário (medido na base `750a81ca`, duas rodadas da
    `prova-temp-sem-sobra.py`: 15 a 30 entradas por ficha de HTML —
    `<GUID>.tmp`, `scoped_dir*` e `chrome_url_fetcher_*`) nasce ali dentro e
    sai junto com a pasta, no `finally` de quem a criou. Sem `temp`, herda o
    ambiente, como antes.

    Por que não `subprocess.run(cmd, capture_output=True, timeout=...)` (CP-P19,
    2026-09-26): no Windows o `run` estourado mata SÓ o processo que lançou
    (`TerminateProcess` no chrome.exe navegador) e depois chama `communicate()`
    SEM prazo, para recolher a saída; o renderer, a GPU e o crashpad (netos)
    herdaram a ponta de escrita do pipe e continuam vivos, então a leitura não
    termina. A sessão do SIGA mediu mais de 9 min preso, duas vezes (REV-19); a
    `reproduz-trava.py` da CP-P19 mediu, com o prazo trocado para 20 s, 150 s
    sem voltar no `--url` e 97 s no `mede_dom`, com chrome.exe órfão.

    Aqui: (1) a saída vai para um arquivo temporário, não para um pipe — neto
    vivo não segura mais a leitura; (2) no Windows o Chrome nasce SUSPENSO,
    entra num Job Object com `KILL_ON_JOB_CLOSE` e só então é retomado: todo
    descendente dele nasce dentro do job, e no prazo UMA chamada
    (`TerminateJobObject`) encerra a árvore inteira — inclusive o neto cujo
    pai já saiu, que o `taskkill /T` não acha mais; e, se o próprio conferidor
    morrer, o Windows fecha o job e leva o Chrome junto; ao terminar normal, o
    fechamento do job encerra o que tiver sobrado; (3) sem job (outro sistema,
    ou a API recusou), cai no `_encerra_arvore`: grupo próprio, árvore pelo PID
    que ESTE processo lançou (`taskkill /T /F` ou `killpg`) e varredura pelo
    `--user-data-dir` desta chamada. Nunca por nome.
    Medido na CP-P19 (prazo de 20 s, máquina com ~1 GB livre): o caminho do
    `taskkill` + varredura voltou em 40 a 47 s; o do job, ver o MANIFESTO."""
    import subprocess
    import tempfile
    if timeout is None:
        timeout = TIMEOUT_CHROME_S
    extra = {}
    if temp is not None:
        extra['env'] = dict(os.environ, TEMP=str(temp), TMP=str(temp))
    job = None
    if os.name == 'nt':
        job = _job_windows()
        extra['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP | (0x00000004 if job else 0)  # CREATE_SUSPENDED
    else:
        extra['start_new_session'] = True
    with tempfile.TemporaryFile() as saida:
        try:
            p = subprocess.Popen(cmd, stdout=saida, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, **extra)
        except OSError:
            if job:
                job.fecha()
            return None
        if job:
            no_job, retomado = job.adota_e_retoma(p.pid)
            if not retomado:                # ficou suspenso: não mede, e não deixa resto
                if no_job:
                    job.encerra()
                job.fecha()
                _encerra_arvore(p, cmd)
                return None
            if not no_job:                  # rodando, mas fora do job: segue pelo caminho do `taskkill /T`
                job.fecha()
                job = None
        try:
            codigo = p.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            if job:
                job.encerra()
                try:
                    p.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    pass
                job.fecha()
            else:
                _encerra_arvore(p, cmd)
            return None
        if job:
            job.fecha()                     # KILL_ON_JOB_CLOSE: descendente que sobrou ao navegador sai junto
        saida.seek(0)
        return codigo, saida.read()


class _JobWindows(object):
    """Um Job Object do Windows (kernel32 por ctypes, sem dependência nova) com
    `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Só `_roda_chrome` usa."""

    def __init__(self, k32, nt, alca):
        self.k32, self.nt, self.alca = k32, nt, alca

    def adota_e_retoma(self, pid):
        """Põe o processo SUSPENSO no job e o retoma. Devolve (entrou_no_job, foi_retomado)."""
        acesso = 0x0001 | 0x0100 | 0x0800 | 0x1000   # TERMINATE | SET_QUOTA | SUSPEND_RESUME | QUERY_LIMITED
        h = self.k32.OpenProcess(acesso, False, pid)
        if not h:
            return False, False
        try:
            no_job = bool(self.k32.AssignProcessToJobObject(self.alca, h))
            return no_job, self.nt.NtResumeProcess(h) == 0
        finally:
            self.k32.CloseHandle(h)

    def encerra(self):
        self.k32.TerminateJobObject(self.alca, 1)

    def fecha(self):
        if self.alca:
            self.k32.CloseHandle(self.alca)
            self.alca = None


def _job_windows():
    """Cria o job do `_roda_chrome`, ou devolve None (o chamador cai no `taskkill /T`)."""
    try:
        import ctypes
        from ctypes import wintypes
        k32 = ctypes.WinDLL('kernel32', use_last_error=True)
        nt = ctypes.WinDLL('ntdll')
        k32.CreateJobObjectW.restype = wintypes.HANDLE
        k32.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
        k32.SetInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD)
        k32.OpenProcess.restype = wintypes.HANDLE
        k32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
        k32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
        k32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
        k32.CloseHandle.argtypes = (wintypes.HANDLE,)
        nt.NtResumeProcess.argtypes = (wintypes.HANDLE,)
        nt.NtResumeProcess.restype = ctypes.c_long

        class _Basico(ctypes.Structure):
            _fields_ = [('PerProcessUserTimeLimit', ctypes.c_int64), ('PerJobUserTimeLimit', ctypes.c_int64),
                        ('LimitFlags', wintypes.DWORD), ('MinimumWorkingSetSize', ctypes.c_size_t),
                        ('MaximumWorkingSetSize', ctypes.c_size_t), ('ActiveProcessLimit', wintypes.DWORD),
                        ('Affinity', ctypes.c_size_t), ('PriorityClass', wintypes.DWORD),
                        ('SchedulingClass', wintypes.DWORD)]

        class _Estendido(ctypes.Structure):
            _fields_ = [('BasicLimitInformation', _Basico), ('IoInfo', ctypes.c_uint64 * 6),
                        ('ProcessMemoryLimit', ctypes.c_size_t), ('JobMemoryLimit', ctypes.c_size_t),
                        ('PeakProcessMemoryUsed', ctypes.c_size_t), ('PeakJobMemoryUsed', ctypes.c_size_t)]

        alca = k32.CreateJobObjectW(None, None)
        if not alca:
            return None
        info = _Estendido()
        info.BasicLimitInformation.LimitFlags = 0x00002000   # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
        if not k32.SetInformationJobObject(alca, 9, ctypes.byref(info), ctypes.sizeof(info)):  # ExtendedLimit
            k32.CloseHandle(alca)
            return None
        return _JobWindows(k32, nt, alca)
    except Exception:
        return None


def _encerra_arvore(p, cmd):
    """Encerra o Chrome lançado por `_roda_chrome` e TODOS os descendentes — só
    processos desta chamada: o PID que ela lançou e, no Windows, os chrome.exe
    cuja linha de comando traz o `--user-data-dir` dela (pasta única). É o
    caminho SEM job (ver `_roda_chrome`)."""
    import subprocess
    if os.name == 'nt':
        try:
            subprocess.run(['taskkill', '/T', '/F', '/PID', str(p.pid)], capture_output=True, timeout=60)
        except Exception:
            pass
    else:
        import signal
        try:
            os.killpg(p.pid, signal.SIGKILL)
        except Exception:
            pass
    try:
        p.kill()
    except Exception:
        pass
    try:
        # prazo curto de propósito: sob carga o Windows leva MINUTOS para desmontar um chrome.exe já encerrado
        # (medido na CP-P19: o `taskkill` responde "não há ocorrência da tarefa em execução" e o processo some
        # sozinho ~2 min depois) — esperar por ele é voltar a travar
        p.wait(timeout=5)
    except Exception:
        pass
    perfil = next((a.split('=', 1)[1] for a in cmd if a.startswith('--user-data-dir=')), None)
    if os.name != 'nt' or not perfil:
        return
    # neto cujo pai já saiu não está mais na árvore do `/T`: acha-se pela pasta do perfil, que é desta chamada
    marca = perfil.replace("'", "''")
    ps = ("Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" | Where-Object { $_.CommandLine -and "
          "$_.CommandLine.Contains('%s') } | ForEach-Object { $_.ProcessId }" % marca)
    try:
        r = subprocess.run(['powershell', '-NoProfile', '-Command', ps], capture_output=True, text=True, timeout=60)
        pids = [x.strip() for x in r.stdout.split() if x.strip().isdigit()]
        if pids:   # UMA chamada: cada `taskkill` custa ~1 s sob carga (medido na CP-P19, 14 chamadas = 15 s)
            subprocess.run(['taskkill', '/F'] + [a for x in pids for a in ('/PID', x)], capture_output=True,
                           timeout=60)
    except Exception:
        pass


def confere_svg(caminho, cores_tok, escala_px, empresa, alvo_tela=None, sem_texto=None):
    import pymupdf
    fonte_svg = io.open(caminho, encoding='utf-8', errors='replace').read()
    # CP-P32: o MuPDF não aplica o <style>; as regras simples entram como style inline antes de abrir (ver
    # _svg_estilo_resolvido). Sem <style>, o texto é o mesmo e o caminho é o de antes.
    resolvido, tocados, nao_aplicadas = _svg_estilo_resolvido(fonte_svg)
    doc = (pymupdf.open(caminho) if resolvido == fonte_svg
           else pymupdf.open(stream=resolvido.encode('utf-8'), filetype='svg'))
    m = re.search(r'<title[^>]*>(.*?)</title>', fonte_svg, re.S | re.I)
    f = confere_pdf(caminho, cores_tok, escala_px, empresa, doc=doc,
                    rotulo=' · SVG lido pelo MuPDF%s' % (
                        ' · <style>: %d elemento(s) com as regras aplicadas como style inline (CP-P32)' % tocados
                        if tocados else ''),
                    fontes_declaradas=_familias_do_svg(caminho),
                    titulo_declarado=(m.group(1).strip() if m else ''),
                    alvo=alvo_declarado('', alvo_tela), sem_texto=sem_texto_declarado(fonte_svg, sem_texto))
    if nao_aplicadas:
        # a cor que essas regras pintam não foi medida: o CP-1 não pode sair PASS calado (vira AVISO, com a lista)
        for i, (est, cod, resumo, det) in enumerate(f.linhas):
            if cod == 'CP-1':
                if est == 'PASS':
                    est = 'AVISO'
                    f.pass_, f.aviso = f.pass_ - 1, f.aviso + 1
                f.linhas[i] = (est, cod, resumo + '; %d regra(s) do <style> NÃO aplicada(s) — seletor fora do que o '
                               'conferidor resolve (só elemento, classe, elemento.classe e #id; CP-P32): a cor que '
                               'elas pintam não foi medida' % len(nao_aplicadas),
                               det + ['regra do <style> não aplicada: %s' % s for s in nao_aplicadas])
    return f


# ════════════════════════════════════════════════════════════════════════════
# CP-13 — SHELL: a tela de aplicação renderiza as regiões do §46 (DOM real)
# ════════════════════════════════════════════════════════════════════════════
# A sonda roda DENTRO do Chrome headless: uma página-embrulho gravada em pasta
# temporária (nunca na pasta da peça — régua não suja o que mede) abre a peça
# num <iframe> de DOM_LARGURA×DOM_ALTURA, espera o `load`, lê o DOM renderizado
# e escreve um JSON num <pre>; o `--dump-dom` devolve a página-embrulho com o
# JSON dentro. Só Chrome: sem Node, sem Playwright — este script também roda
# de dentro do pacote da skill, onde não existe o `ambiente.mjs` da casa.
# `--allow-file-access-from-files` é o que deixa o embrulho ler o documento do
# iframe (file:// é origem única por arquivo no Chrome).
_SONDA_DOM_JS = r'''
(function () {
  var out = { sonda: 'cp-13/v1' };
  try {
    var fr = document.getElementById('alvo');
    var w = fr.contentWindow, d = fr.contentDocument;
    var cs = function (e) { return w.getComputedStyle(e); };
    var box = function (e) { var r = e.getBoundingClientRect();
      return { x: r.left, y: r.top, w: r.width, h: r.height, r: r.right, b: r.bottom }; };
    var visivel = function (e) { var r = e.getBoundingClientRect(); var s = cs(e);
      return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
    var texto = function (e) { return (e.textContent || '').split(/\s+/).join(' ').trim(); };
    var prof = function (e) { var k = 0; while ((e = e.parentElement)) k++; return k; };
    var desc = function (e) { return { tag: e.tagName.toLowerCase(),
      cls: (typeof e.className === 'string') ? e.className : '',
      label: e.getAttribute('aria-label'), role: e.getAttribute('role') }; };

    // raiz de consulta (CP27): o elemento MAIS EXTERNO com container-name declarado
    var raizes = [];
    var todos = d.querySelectorAll('*');
    for (var i = 0; i < todos.length; i++) {
      var cn = cs(todos[i]).containerName;
      if (cn && cn !== 'none') { var x = desc(todos[i]); x.nome = cn; x.prof = prof(todos[i]); raizes.push(x); }
    }
    raizes.sort(function (a, b) { return a.prof - b.prof; });
    out.raiz = raizes.length ? raizes[0] : null;
    out.raizes_total = raizes.length;

    // camada de máquina (CP-10 no DOM)
    out.title = d.title || '';
    out.lang = d.documentElement.getAttribute('lang') || '';
    // SH18 (§46.5): o arquétipo declarado no próprio artefato (lista fechada)
    out.arquetipo = d.documentElement.getAttribute('data-arquetipo') || '';
    // CP-P11 (2026-09-20): fato que ajuda o CP-13 a reconhecer a tela ANTERIOR à
    // sessão (formulário de login) quando alguém força `--arquetipo aplicacao`
    // sobre ela — nunca usado para DECIDIR o arquétipo, só para não reprovar a
    // tela errada como se fosse o produto. Ver `confere_shell`.
    out.campo_senha = !!d.querySelector('input[type="password"]');
    // h1: conta os VISÍVEIS — uma tela com vistas alternadas (login: entrar /
    // recuperar / redefinir) tem um h1 por vista, e só uma vista está montada.
    // `h1_no_conteudo` conta quantos desses visíveis vivem DENTRO do <main>: é o
    // que o SH10 diz ("o título da tela vive no CONTEÚDO, não na topbar") e é o
    // que o CP-13 passa a cobrar na tela de aplicação SEM topbar (CP-P9,
    // 2026-09-20) — sem a topbar, o h1 do conteúdo é a única identificação da tela.
    var h1s = d.querySelectorAll('h1'); out.h1_total = h1s.length; out.h1 = 0; out.h1_no_conteudo = 0;
    for (var p = 0; p < h1s.length; p++) if (visivel(h1s[p])) {
      out.h1++;
      if (h1s[p].closest('main')) out.h1_no_conteudo++;
    }
    out.navs = [];
    var navs = d.querySelectorAll('nav');
    for (var j = 0; j < navs.length; j++) {
      var n = desc(navs[j]); n.box = box(navs[j]); n.vis = visivel(navs[j]);
      n.ativos = navs[j].querySelectorAll('[aria-current="page"]').length;
      out.navs.push(n);
    }

    // rail — pela ordem em que o §46 o define: landmark nomeado (SH2) primeiro
    var rail = d.querySelector('nav[aria-label="Áreas"]'), como = 'nav[aria-label="Áreas"]';
    if (!rail) { rail = d.querySelector('nav.rail'); como = rail ? 'nav.rail' : ''; }
    if (!rail) { rail = d.querySelector('.rail'); como = rail ? '.rail' : ''; }
    out.rail = null;
    if (rail) {
      // destinos: o vocabulário da casa primeiro (`.rail-item`, o mesmo da suite-shell
      // e do shell do SIGA — que mostra área não construída como <span aria-disabled>,
      // SH6/MN6, e ela É um destino do rail); depois <li>; por fim os interativos.
      var it = rail.querySelectorAll('.rail-item'), contado = '.rail-item';
      if (!it.length) { it = rail.querySelectorAll('li'); contado = 'li'; }
      if (!it.length) { it = rail.querySelectorAll('a, button, [role="link"], [role="button"]'); contado = 'a/button'; }
      var itens = [];
      for (var k = 0; k < it.length; k++) if (visivel(it[k]))
        itens.push({ txt: texto(it[k]).slice(0, 24),
          atual: it[k].getAttribute('aria-current') || (it[k].querySelector('[aria-current="page"]') ? 'page' : null) });
      var r = desc(rail); r.como = como; r.box = box(rail); r.vis = visivel(rail);
      r.width = r.box.w; r.width_css = parseFloat(cs(rail).width); r.itens = itens; r.contado = contado;
      // SH19: o estado do rail é declarado no .app (`data-rail`): expandido (padrão) ou recolhido (56 px)
      var appEl = rail.closest('.app') || d.querySelector('.app');
      r.estado = appEl ? (appEl.getAttribute('data-rail') || '') : '';
      r.alterna = rail.querySelectorAll('.alterna-rail').length;
      out.rail = r;
    }

    // topbar — header.topbar; senão o primeiro <header> fora do <main>
    var top = d.querySelector('header.topbar');
    if (!top) { var hs = d.querySelectorAll('header'); for (var m = 0; m < hs.length; m++) if (!hs[m].closest('main')) { top = hs[m]; break; } }
    out.topbar = null;
    if (top) { var t = desc(top); t.box = box(top); t.vis = visivel(top); t.height = t.box.h; out.topbar = t; }

    // conteúdo — <main> (SH1), com tabindex=-1 (SH7)
    var mains = d.querySelectorAll('main');
    out.mains = mains.length;
    out.main = null;
    if (mains.length) { var mn = desc(mains[0]); mn.id = mains[0].id || ''; mn.tabindex = mains[0].getAttribute('tabindex');
      mn.box = box(mains[0]); mn.vis = visivel(mains[0]); out.main = mn; }

    // sidebar (opcional) — informada, não julgada
    var sb = d.querySelector('nav[aria-label="Seções"]') || d.querySelector('nav.sidebar');
    out.sidebar = sb ? { width: box(sb).w, label: sb.getAttribute('aria-label') } : null;

    // skip link (SH8): o primeiro focável da ordem do documento
    var foc = d.querySelectorAll('a[href], button, input, select, textarea, [tabindex]');
    out.primeiro_focavel = null;
    for (var q = 0; q < foc.length; q++) {
      var e = foc[q];
      if (e.tabIndex >= 0 && !e.disabled && cs(e).display !== 'none') {
        out.primeiro_focavel = { tag: e.tagName.toLowerCase(), href: e.getAttribute('href') || '', txt: texto(e).slice(0, 40) };
        break;
      }
    }
    // SH21 (F12.9, 2026-09-19) — MENU DE CONTA: o avatar (o gatilho) e o painel que ele abre.
    // A ordem de busca vai do mais canônico ao mais tolerante, como a do rail: o id `b-conta` é o
    // que o módulo `shell_bloco.py` emite; depois qualquer controle que se declare gatilho de menu
    // dentro da topbar ou do rail; por fim um `.avatar` qualquer — que é justamente o caso que o
    // SH21 veio proibir (um <div> decorativo fazendo as vezes de conta) e que a régua precisa VER
    // para poder reprovar, em vez de dizer "não achei".
    var conta = d.getElementById('b-conta');
    if (!conta) conta = d.querySelector('header [aria-haspopup="menu"].avatar, nav.rail [aria-haspopup="menu"].avatar');
    if (!conta) conta = d.querySelector('.avatar');
    out.conta = null;
    // quantos AVATARES declaram abrir um menu. Contar todos os `role="menu"` da tela seria contar
    // errado: a `tela-detalhe` tem um menu de ações legítimo (o de envio de comentário, §35) além do
    // menu de conta — medido em 2026-09-19, e foi essa medição que corrigiu a régua. O que o SH21 diz
    // que é ÚNICO é o menu DE CONTA, não todo menu da tela.
    out.avatares_menu = d.querySelectorAll('.avatar[aria-haspopup="menu"], #b-conta').length;
    out.menus = d.querySelectorAll('[role="menu"]').length;
    if (conta) {
      var alvoId = conta.getAttribute('aria-controls') || '';
      var painel = alvoId ? d.getElementById(alvoId) : null;
      var noRail = !!(conta.closest && conta.closest('nav.rail, nav[aria-label="\u00c1reas"]'));
      var naTop = !!(conta.closest && conta.closest('header'));
      var c = { tag: conta.tagName.toLowerCase(), id: conta.id || '',
                haspopup: conta.getAttribute('aria-haspopup'), expanded: conta.getAttribute('aria-expanded'),
                controls: alvoId, declarado: conta.getAttribute('data-conta') || '',
                nome: (conta.getAttribute('aria-label') || texto(conta)).slice(0, 60),
                onde: noRail ? 'rail' : (naTop ? 'topbar' : 'outro'), vis: visivel(conta), painel: null };
      // no rail (produto sem topbar, V-SH16.2) o avatar vem ANTES do botão de recolher do SH19,
      // que continua sendo o último item focável do rail
      if (noRail) {
        var r2 = conta.closest('nav.rail, nav[aria-label="\u00c1reas"]');
        var fo = r2.querySelectorAll('a[href], button');
        c.penultimo_do_rail = fo.length >= 2 && fo[fo.length - 2] === conta &&
          (fo[fo.length - 1].className || '').indexOf('alterna-rail') >= 0;
      }
      if (painel) {
        var mi = painel.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]');
        var rot = [];
        for (var z = 0; z < mi.length; z++) rot.push(texto(mi[z]).slice(0, 24));
        var temas = painel.querySelectorAll('[role="menuitemradio"][data-tema]');
        var vt = [];
        for (var y = 0; y < temas.length; y++) vt.push(temas[y].getAttribute('data-tema'));
        c.painel = { role: painel.getAttribute('role'), itens: mi.length, rotulos: rot, temas: vt,
                     acoes: painel.querySelectorAll('[role="menuitem"]').length,
                     grupos: painel.querySelectorAll('[role="group"]').length,
                     submenu: painel.querySelectorAll('[role="menu"]').length,
                     dentro_do_app: !!(painel.closest && painel.closest('.app')) };
      }
      out.conta = c;
    }
    out.viewport = { w: w.innerWidth, h: w.innerHeight };

    // ── CP-1/CP-2/CP-3/CP-4 — cores, tipografia, tamanho e texto da TELA (CP-P7,
    // 2026-09-15/16): o `--print-to-pdf` reflui a página inteira para o tamanho
    // de papel Letter (612×792 pt ≈ 816×1056 px a 96 dpi), bem menor que os
    // 1440×900 em que a tela é desenhada e medida em produção (RC, CP-13) — o
    // fundo do rail, que devia ser o gradiente turquesa, sai #FFFFFF na amostra
    // do PDF em coordenadas onde o rail deveria estar (medido na tela-shell.html,
    // registrado no relatório da CP-P7). Por isso estes quatro itens, quando a
    // entrada é HTML, usam o que o NAVEGADOR pintou de verdade — o pixel do CP-4
    // sai de `mede_contraste_tela()` (duas capturas do arquivo, com e sem texto,
    // pela mesma técnica de máscara-por-diferença da régua RC), e cor/família/
    // tamanho saem daqui, do DOM computado. Fora do alcance, declarado: texto com
    // `background-clip: text` (tinta em gradiente) e texto coberto por outro
    // elemento no ponto de teste — os dois saem de fora, nunca "medidos errado".
    out.docW = d.documentElement.scrollWidth;
    var ePeca = d.querySelector('.peca') || d.querySelector('.card') || d.body.firstElementChild;
    out.pecaW = ePeca ? ePeca.getBoundingClientRect().width : null;   // CP-P43: o fator de zoom da impressão
    out.docH = d.documentElement.scrollHeight;
    var W = w.innerWidth, H = w.innerHeight;
    var corHex = function (s) {
      var m = String(s).match(/rgba?\(([^)]+)\)/);
      if (!m) return null;
      var p = m[1].split(/[ ,\/]+/).filter(Boolean).map(Number);
      if (p.length < 3 || isNaN(p[0])) return null;
      var a = p.length > 3 ? p[3] : 1;
      var hx = '#' + [p[0], p[1], p[2]].map(function (v) {
        var h = Math.max(0, Math.min(255, Math.round(v))).toString(16).toUpperCase();
        return h.length < 2 ? '0' + h : h;
      }).join('');
      return { hex: hx, a: a };
    };
    var mapaCores = {};
    var addCor = function (hx) { if (hx) mapaCores[hx] = 1; };
    out.textos = [];
    var todosEl = d.querySelectorAll('body *');
    for (var ti = 0; ti < todosEl.length; ti++) {
      var elT = todosEl[ti];
      if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].indexOf(elT.tagName) >= 0) continue;
      if (elT.closest('svg')) continue;
      if (elT.closest('[hidden]')) continue;
      var sT = cs(elT);
      if (sT.display === 'none' || sT.visibility === 'hidden' || Number(sT.opacity) === 0) continue;
      var bgT = corHex(sT.backgroundColor);
      if (bgT && bgT.a > 0.01) addCor(bgT.hex);
      ['Top', 'Right', 'Bottom', 'Left'].forEach(function (lado) {
        if (parseFloat(sT['border' + lado + 'Width']) > 0 && sT['border' + lado + 'Style'] !== 'none') {
          var bcT = corHex(sT['border' + lado + 'Color']);
          if (bcT && bcT.a > 0.01) addCor(bcT.hex);
        }
      });
      if (sT.backgroundImage && sT.backgroundImage !== 'none') {
        var stopsT = sT.backgroundImage.match(/rgba?\([^)]*\)/g) || [];
        stopsT.forEach(function (c) { var hT = corHex(c); if (hT && hT.a > 0.01) addCor(hT.hex); });
      }
      var nosT = [].slice.call(elT.childNodes).filter(function (n) { return n.nodeType === 3 && n.textContent.trim().length > 0; });
      if (!nosT.length) continue;
      if (elT.closest(':disabled, [aria-disabled="true"], [disabled]')) continue;   // inativo: isento (SC 1.4.3)
      var clipTextoT = (sT.webkitBackgroundClip === 'text' || sT.backgroundClip === 'text');
      if (clipTextoT) continue;   // tinta em gradiente: declarado fora do alcance
      var corT = corHex(sT.color);
      if (!corT || corT.a === 0) continue;
      addCor(corT.hex);
      var tamT = parseFloat(sT.fontSize) || 16;
      var pesoT = parseInt(sT.fontWeight, 10) || 400;
      var familiaT = (sT.fontFamily || '').split(',')[0].replace(/["']/g, '').trim();
      var selT = elT.tagName.toLowerCase() + (typeof elT.className === 'string' && elT.className.trim() ? '.' + elT.className.trim().split(/\s+/)[0] : '');
      for (var ni = 0; ni < nosT.length; ni++) {
        var noT = nosT[ni];
        var rgT = d.createRange(); rgT.selectNodeContents(noT);
        var rects = [].slice.call(rgT.getClientRects())
          .map(function (r) { return { x: r.left, y: r.top, w: r.width, h: r.height }; })
          .filter(function (r) { return r.w >= 2 && r.h >= 2; })
          .map(function (r) {
            var x0 = Math.max(0, r.x), y0 = Math.max(0, r.y);
            var x1 = Math.min(W, r.x + r.w), y1 = Math.min(H, r.y + r.h);
            return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
          }).filter(function (r) { return r.w >= 2 && r.h >= 2; });
        if (!rects.length) continue;
        var c0T = rects[0];
        var topoT = d.elementFromPoint(c0T.x + c0T.w / 2, c0T.y + c0T.h / 2);
        if (!topoT || !(topoT === elT || elT.contains(topoT))) continue;   // coberto: fora do alcance
        out.textos.push({ sel: selT, texto: noT.textContent.trim().replace(/\s+/g, ' ').slice(0, 40),
          cor: corT.hex, alfa: corT.a, tam: tamT, peso: pesoT, familia: familiaT, caixas: rects });
      }
    }
    out.cores = Object.keys(mapaCores);
  } catch (err) { out.erro = String(err); }
  document.getElementById('saida').textContent = JSON.stringify(out);
})();
'''


# PR-P199 (leva 50, 2026-10-31): teto, em ms de TEMPO VIRTUAL do Chrome, da espera pelo carregamento do iframe da peca. Fica abaixo do
# `--virtual-time-budget=8000` do dump (6000 + os 300 ms da sonda < 8000) para que, se o iframe nao terminar, a pagina ESCREVA o motivo na saida
# (`erro` -> NAO MEDI com o motivo) em vez de deixar o `<pre>` vazio e o chamador sem saber por que o DOM nao respondeu.
# LIMITE (medido na revisao, 2026-10-31 07:2x UTC): o teto e de tempo VIRTUAL. Iframe preso em REDE (`<img>`/`<iframe>` para um soquete que aceita
# e nunca responde) PARA o tempo virtual do Chrome, o teto nao dispara e `mede_dom` devolve `None` so no prazo de parede `TIMEOUT_CHROME_S` (180 s;
# 29,2 s e 28,6 s com o prazo reduzido a 25 s). Nao vira PASS (None -> CP-1/2/3 `sem_medida=True` = NAO MEDI), mas o motivo escrito so aparece
# quando o travamento e do documento, nao de rede. Prova: `provas/conferidor/prova-mede-dom-espera.py` (6 casos, com 2 mutantes).
DOM_ESPERA_MS = 6000


def mede_dom(caminho, chrome, largura=DOM_LARGURA, altura=DOM_ALTURA):
    """Renderiza a peça no Chrome e devolve o JSON da sonda (dict) ou None se o
    navegador não produziu nada — o chamador diz NÃO MEDI, nunca FAIL.

    CP-P22 (2026-09-27): a pasta `cp-dom-*` desta chamada (o embrulho e o perfil
    inteiro do Chrome) sai no `finally`, pelo caminho EXATO que o `mkdtemp`
    devolveu — nunca por padrão de nome, que casaria pastas de outras sessões.
    Até aqui ela ficava no temporário para sempre (533 contadas no `%TEMP%`
    em 2026-09-27, relatório SUITES da leva 4, achado A4). Prova:
    `06-validacao/provas/conferidor/prova-cp13-sem-url.py`, caso D."""
    import tempfile
    pasta = pathlib.Path(tempfile.mkdtemp(prefix='cp-dom-'))
    try:
        return _mede_dom_na_pasta(pasta, caminho, chrome, largura, altura)
    finally:
        _apaga_pasta_da_chamada(pasta)


def _apaga_pasta_da_chamada(pasta, tentativas=10, pausa_s=0.3):
    """Apaga a pasta temporária que UMA chamada criou, pelo caminho exato (CP-P22;
    desde a CP-P24 também as `cp-tela-*`, `cp-*` e `cp-url-*`).
    No Windows o perfil do Chrome pode ficar preso alguns décimos de segundo depois
    de o processo sair (o fechamento do Job Object encerra os netos de forma
    assíncrona); por isso tenta de novo, com teto (10 × 0,3 s = 3 s). Se ainda
    assim sobrar, deixa: limpeza nunca derruba nem atrasa sem teto a medição.
    Devolve True quando a pasta não existe mais.

    CP-P24 (2026-09-27): o que sobrar entra em `_PASTAS_PENDENTES` e ganha uma
    SEGUNDA chance na saída do processo (`_apaga_pendentes_na_saida`, pelo
    `atexit`), quando nenhum Chrome desta execução está mais vivo. Medido com a
    máquina carregada (ficha de HTML em 287 s): uma `cp-tela-*` ficou VAZIA —
    os arquivos já apagados, mas presos em "exclusão pendente" por um processo
    do Chrome que ainda morria — e a pasta não saiu nos 3 s."""
    import shutil
    import time
    for i in range(tentativas):
        shutil.rmtree(str(pasta), ignore_errors=True)
        if not os.path.exists(str(pasta)):
            return True
        if i + 1 < tentativas:
            time.sleep(pausa_s)
    if str(pasta) not in _PASTAS_PENDENTES:
        _PASTAS_PENDENTES.append(str(pasta))
    return False


_PASTAS_PENDENTES = []   # CP-P24: pastas desta execução que não saíram no `finally` da chamada


def _apaga_pendentes_na_saida(teto_s=15.0, pausa_s=0.5):
    """Segunda chance (CP-P24), registrada no `atexit`: tenta de novo, pelo caminho
    exato, cada pasta que esta execução criou e não conseguiu apagar na hora, com
    teto TOTAL de `teto_s`. O que ainda sobrar é dito no stderr, pelo nome — nunca
    em silêncio, e nunca por padrão de nome."""
    import shutil
    import time
    fim = time.monotonic() + teto_s
    while _PASTAS_PENDENTES:
        for p in list(_PASTAS_PENDENTES):
            shutil.rmtree(p, ignore_errors=True)
            if not os.path.exists(p):
                _PASTAS_PENDENTES.remove(p)
        if not _PASTAS_PENDENTES or time.monotonic() >= fim:
            break
        time.sleep(pausa_s)
    for p in _PASTAS_PENDENTES:
        sys.stderr.write('aviso: a pasta temporária %s não saiu (arquivo preso por outro processo); '
                         'pode ser apagada à mão, pelo nome\n' % p)


atexit.register(_apaga_pendentes_na_saida)


def _mede_dom_na_pasta(pasta, caminho, chrome, largura, altura):
    import html as modulo_html
    embrulho = pasta / 'sonda.html'
    alvo_uri = pathlib.Path(caminho).resolve().as_uri()
    pagina = ('<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0}'
              'iframe{border:0;display:block;width:%dpx;height:%dpx}</style></head><body>'
              '<iframe id="alvo" src="%s"></iframe><pre id="saida"></pre>'
              '<script>(function () { var f = document.getElementById("alvo"), rodou = false;'
              ' function roda() { if (rodou) return; rodou = true; setTimeout(function () { %s }, 300); }'
              ' f.addEventListener("load", roda);'
              ' (function espera(t) { if (rodou) return; var d = null; try { d = f.contentDocument; } catch (e) {}'
              ' if (d && d.readyState === "complete" && d.location.href !== "about:blank") { roda(); return; }'
              ' if (t >= %d) { rodou = true; document.getElementById("saida").textContent = JSON.stringify({erro:'
              ' "o iframe da peca nao terminou de carregar em %d ms (tempo virtual do Chrome)"}); return; }'
              ' setTimeout(function () { espera(t + 100); }, 100); })(0); })();</script></body></html>'
              % (largura, altura, alvo_uri, _SONDA_DOM_JS, DOM_ESPERA_MS, DOM_ESPERA_MS))
    io.open(embrulho, 'w', encoding='utf-8', newline='').write(pagina)
    cmd = [chrome, '--headless=new', CHROME_TEMA_CLARO, '--disable-gpu', '--hide-scrollbars',
           '--allow-file-access-from-files', '--user-data-dir=%s' % (pasta / 'perfil'),
           '--window-size=%d,%d' % (largura, altura + 200), '--virtual-time-budget=8000',
           '--dump-dom', embrulho.as_uri()]
    r = _roda_chrome(cmd, temp=pasta)   # CP-P19: prazo que vale no Windows; None = o Chrome não terminou
    if r is None:                       # CP-P24: o temporário do Chrome nasce na pasta da chamada
        return None
    dom = r[1].decode('utf-8', 'replace')
    m = re.search(r'<pre id="saida">(.*?)</pre>', dom, re.S)
    if not m or not m.group(1).strip():
        return None
    try:
        return json.loads(modulo_html.unescape(m.group(1)))
    except ValueError:
        return None


def texto_grande_px(tam_px, peso):
    """WCAG 2.2 SC 1.4.3, em px de TELA (a mesma régua da RC, `contraste-raster.mjs`):
    grande = 24px normal (≈18pt), ou 18,66px em negrito (≈14pt). `texto_grande()`
    (acima) é a mesma regra em PONTOS, para o caminho PDF — este aqui é o
    equivalente em CSS px, porque a sonda do navegador lê `fontSize` em px."""
    return tam_px >= 24 or (peso >= 700 and tam_px >= 18.66)


def compor_alpha(fg, a, bg):
    return tuple(round(fg[i] * a + bg[i] * (1 - a)) for i in range(3))


def confere_tela_cp1234(f, dom, cores_tok, escala_px, cp3_na_tela=True, sem_texto=''):
    """CP-1 (cor), CP-2 (tipografia) e CP-3 (tamanho) medidos no DOM computado
    pelo navegador — nunca no PDF impresso. Nasce na CP-P7 (2026-09-15/16):
    ver o comentário no cabeçalho da sonda (`_SONDA_DOM_JS`) para a causa.
    O CP-4 (contraste) não entra aqui — precisa de PIXEL real, e sai de
    `mede_contraste_tela()`, chamado à parte por precisar de duas capturas.
    `cp3_na_tela=False` (PR-P184, leva 46): em peça HTML FÍSICA (`alvo-fisico`) o CP-3 é medido no PDF impresso contra o piso de 8 pt
    (`confere_pdf`); a leitura da tela contra o piso de 11 px da escala de aplicação não diz nada de uma peça impressa e dava PASS falso."""
    if dom is None or dom.get('erro') or 'textos' not in dom:
        motivo = ('a sonda do navegador não devolveu dados de texto (%s)'
                  % (dom.get('erro', 'sem detalhe')[:120] if dom else 'Chrome não respondeu'))
        for cod in ('CP-1', 'CP-2') + (('CP-3',) if cp3_na_tela else ()):
            f.item(cod, None, '%s: NÃO MEDI — %s' % (cod, motivo), sem_medida=True)
        return

    # ── CP-1 cor (medida na TELA: fundo, borda, gradiente e tinta de todo elemento visível) ──
    # #000000 fica FORA da checagem pelo mesmo motivo do caminho PDF (a exceção já existia
    # em `confere_pdf`), mas por uma razão diferente e MEDIDA nesta sonda (CP-P7): o canon
    # usa preto semitransparente como base de sombra/veu — `--seed-shadow-raised: …
    # rgba(0,0,0,.40)`, `--seed-mat-metal-edge`/`-action` (§93, material) — e o CSS resolve
    # o `calc()` da opacidade em `getComputedStyle` para um número real, então o preto de
    # baixo do alpha aparece aqui como cor "usada". Não é fill plano fora de token: é o
    # idioma de sombra/overlay do canon, que não tem hex próprio (é sempre preto ou branco
    # + alfa). Achado na `tela-shell.html` ao rodar esta sonda pela primeira vez.
    usadas = collections.Counter(dom.get('cores') or [])
    fora = [c for c in usadas if c not in cores_tok and c != '#000000']
    if not usadas:
        # CP-P65 (leva 48): «0 de 0 cores computadas» era PASS; sem cor não há o que medir (NÃO MEDI de quem devia medir, saída 2)
        f.item('CP-1', None, 'cor: NÃO MEDI — nenhuma cor computada na tela (0 de 0)', sem_medida=True)
    else:
        f.item('CP-1', not fora,
               'cor (medida na TELA, não no PDF): %d de %d cores computadas existem no seed-tokens.json '
               '(#000000 semitransparente de sombra/overlay do material não conta — não é fill plano)'
               % (len(usadas) - len(fora), len(usadas)),
               ['%s — não é token' % c for c in sorted(fora)])

    # ── CP-2 tipografia e CP-3 tamanho (medidos na TELA: fontFamily/fontSize computados) ──
    textos = dom.get('textos') or []
    fontes = collections.Counter()
    for t in textos:
        fontes[t['familia']] += len(t['texto'])

    def da_casa(nome):
        n = nome.lower().split('+')[-1].replace('-', '').replace(' ', '')
        return any(fam.replace(' ', '').replace('-', '') in n for fam in FAMILIAS)
    intrusas = [x for x in fontes if not da_casa(x)]
    if not fontes:
        _sem_o_que_medir(f, 'CP-2', 'tipografia', 'nenhuma família computada na tela (0 de 0: nenhum texto visível)', sem_texto)
    else:
        f.item('CP-2', not intrusas,
               'tipografia (medida na TELA): %d de %d famílias computadas são do DS (Montserrat · JetBrains Mono · Caveat)'
               % (len(fontes) - len(intrusas), len(fontes)),
               ['%s (%s caractere(s)) — fonte de fora, provável fallback do sistema' % (x, fontes[x]) for x in intrusas])

    if not cp3_na_tela:
        return
    menor_px = min(escala_px.values()) if escala_px else 11.0
    pequenos = ['%s · %gpx · "%s"' % (t['sel'], t['tam'], t['texto'])
               for t in textos if t['tam'] + 0.05 < menor_px]
    if not textos:
        # CP-P65 (leva 48): «0 de 0 nós de texto» era PASS no CP-3 da tela (o da peça impressa já era NÃO MEDI desde a PR-P200)
        _sem_o_que_medir(f, 'CP-3', 'tamanho', 'nenhum nó de texto na tela (0 de 0): não há o que medir contra a escala de aplicação', sem_texto)
        return
    f.item('CP-3', not pequenos,
           'tamanho (medido na TELA, escala de aplicação do DS, §4.7): %d de %d nós de texto no piso da '
           'escala (menor degrau %gpx)' % (len(textos) - len(pequenos), len(textos), menor_px),
           sorted(set(pequenos)))


def mede_contraste_tela(caminho, chrome, textos, largura, altura):
    """CP-4 na TELA — o coração da CP-P7. Tira DUAS capturas do arquivo (cópias
    TEMPORÁRIAS em pasta própria; o arquivo original nunca é tocado): uma
    normal e uma com todo texto apagado por CSS (`color: transparent`, como a
    régua RC `contraste-raster.mjs` já faz por Playwright) — e mede o PIOR
    PIXEL atrás de cada nó de texto que a sonda do DOM (`mede_dom`) já leu,
    pela MESMA técnica de máscara-por-diferença da RC: o pixel que MUDOU entre
    as duas capturas tinha tinta; o que não mudou é fundo puro.

    Por que duas cópias TEMPORÁRIAS e não o arquivo original + uma folha
    injetada por CDP: este script também roda dentro do pacote da skill
    instalado na máquina de quem produz, sem Playwright — só o Chrome CLI
    (`--screenshot`), que não aceita injetar CSS a meio do carregamento. As
    telas de referência da casa são autocontidas (sem `src`/`href` externos —
    conferido na CP-P7 em `tela-shell.html`), então uma cópia com um `<style>`
    extra antes de `</head>` não quebra fonte nem imagem nenhuma.

    Devolve (relatorio, None) ou (None, motivo) — motivo vira NÃO MEDI, nunca FAIL
    (regra da casa: instrumento que não conseguiu medir diz isso, não afirma defeito).

    CP-P24 (2026-09-27): a pasta `cp-tela-*` desta chamada (as duas cópias, as
    duas capturas e os dois perfis do Chrome) sai no `finally`, pelo caminho
    EXATO que o `mkdtemp` devolveu, como a do `mede_dom` desde a CP-P22. Até
    aqui ficava para sempre (311 no `%TEMP%` às 18:45Z de 2026-09-27, 3,4 GB).
    Prova: `06-validacao/provas/conferidor/prova-temp-sem-sobra.py`."""
    import tempfile
    if not textos:
        return {'medidos': 0, 'ruins': [], 'janela': (largura, altura)}, None

    pasta = pathlib.Path(tempfile.mkdtemp(prefix='cp-tela-'))
    try:
        return _mede_contraste_tela_na_pasta(pasta, caminho, chrome, textos, largura, altura)
    finally:
        _apaga_pasta_da_chamada(pasta)


def _mede_contraste_tela_na_pasta(pasta, caminho, chrome, textos, largura, altura):
    import pymupdf
    fonte = io.open(caminho, encoding='utf-8', errors='replace').read()
    APAGA = ('*,*::before,*::after{color:transparent!important;-webkit-text-fill-color:transparent!important;'
             'text-shadow:none!important;text-decoration-color:transparent!important;'
             'caret-color:transparent!important;animation:none!important;transition:none!important}'
             '::placeholder{color:transparent!important}')
    CONGELA = ('*,*::before,*::after{animation:none!important;transition:none!important;'
               'animation-play-state:paused!important}')

    def com_estilo(css):
        estilo = '<style>%s</style>' % css
        if '</head>' in fonte:
            return fonte.replace('</head>', estilo + '</head>', 1)
        return estilo + fonte

    antes_html = pasta / 'antes.html'
    depois_html = pasta / 'depois.html'
    io.open(antes_html, 'w', encoding='utf-8', newline='').write(com_estilo(CONGELA))
    io.open(depois_html, 'w', encoding='utf-8', newline='').write(com_estilo(CONGELA + APAGA))

    def tira_print(arq, saida_png):
        cmd = [chrome, '--headless=new', CHROME_TEMA_CLARO, '--disable-gpu', '--hide-scrollbars', '--force-color-profile=srgb',
               '--disable-lcd-text', '--font-render-hinting=none',
               '--user-data-dir=%s' % (pasta / ('perfil-%s' % saida_png.stem)),
               '--window-size=%d,%d' % (largura, altura), '--virtual-time-budget=6000',
               '--screenshot=%s' % saida_png, pathlib.Path(arq).resolve().as_uri()]
        if _roda_chrome(cmd, temp=pasta) is None:   # CP-P19 · CP-P24: temporário do Chrome na pasta da chamada
            return False
        return saida_png.exists()

    png_antes = pasta / 'antes.png'
    png_depois = pasta / 'depois.png'
    if not (tira_print(antes_html, png_antes) and tira_print(depois_html, png_depois)):
        return None, 'o Chrome não produziu as capturas de antes/depois do texto'

    pa = pymupdf.Pixmap(str(png_antes))
    pd_ = pymupdf.Pixmap(str(png_depois))
    if pa.alpha:
        pa = pymupdf.Pixmap(pa, 0)
    if pd_.alpha:
        pd_ = pymupdf.Pixmap(pd_, 0)
    if pa.width != pd_.width or pa.height != pd_.height:
        return None, ('MEDIDOR QUEBRADO — capturas de tamanhos diferentes (%dx%d × %dx%d)'
                      % (pa.width, pa.height, pd_.width, pd_.height))

    W, H = pa.width, pa.height

    # ⚠ a primeira versão desta função varria pixel a pixel EM PYTHON PURO (um
    # `for` duplo por caixa de linha) — na cobaia (`tela-shell.html`, 71 nós de
    # texto) consumiu mais de 240 s de CPU sem terminar em 120 s (medido na
    # CP-P7, matando o processo para investigar). É a mesma conta de pixels que
    # a régua RC faz em ~2,5 s em Node/V8 — o gargalo é o interpretador, não o
    # método. O CONSERTO é vetorizar com numpy (já uma dependência do ambiente
    # local, `python -c "import numpy"` conferido antes de usar): a mesma
    # máscara-por-diferença, mas em arranjos NumPy em vez de um laço por pixel.
    import numpy as np

    def _array_rgb(pix):
        h, w, n, stride = pix.height, pix.width, pix.n, pix.stride
        buf = np.frombuffer(pix.samples, dtype=np.uint8)
        return buf.reshape(h, stride)[:, :w * n].reshape(h, w, n)[:, :, :3]

    arr_antes = _array_rgb(pa)
    arr_depois = _array_rgb(pd_)

    def _lum(rgb):
        c = rgb / 255.0
        c = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
        return 0.2126 * c[..., 0] + 0.7152 * c[..., 1] + 0.0722 * c[..., 2]

    ruins, medidos = [], 0
    for t in textos:
        tinta_hex = t['cor']
        tr = np.array([int(tinta_hex[i:i + 2], 16) for i in (1, 3, 5)], dtype=np.float64)
        alfa = t.get('alfa', 1.0)
        grande = texto_grande_px(t['tam'], t['peso'])
        piso = 3.0 if grande else 4.5
        pior_geral, pior_fundo_geral = None, None
        n_px_geral = 0
        for c in t['caixas']:
            x0, y0 = max(0, int(round(c['x'] + 0.5))), max(0, int(round(c['y'] + 0.5)))
            x1, y1 = min(W - 1, int(c['x'] + c['w']) - 1), min(H - 1, int(c['y'] + c['h']) - 1)
            if x1 < x0 or y1 < y0:
                continue
            A = arr_antes[y0:y1 + 1, x0:x1 + 1].astype(np.float64)
            D = arr_depois[y0:y1 + 1, x0:x1 + 1].astype(np.float64)
            mudou = np.any(A != D, axis=-1)       # máscara por diferença — mesma técnica da RC
            if not mudou.any():
                continue
            chan_dif = np.abs(tr.reshape(1, 1, 3) - D)
            ch_idx = np.argmax(chan_dif, axis=-1)                          # (h,w) — canal em que tinta e fundo mais diferem
            dmax = np.take_along_axis(chan_dif, ch_idx[..., None], axis=-1)[..., 0]
            A_ch = np.take_along_axis(A, ch_idx[..., None], axis=-1)[..., 0]
            D_ch = np.take_along_axis(D, ch_idx[..., None], axis=-1)[..., 0]
            tr_ch = tr[ch_idx]
            denom = tr_ch - D_ch
            denom_seguro = np.where(denom == 0, 1.0, denom)
            cov = (A_ch - D_ch) / denom_seguro                            # cobertura estimada do glifo neste pixel
            mascara = mudou & (dmax > 0) & (cov >= 0.5)                   # < 0,5 é franja de antialiasing — não conta
            if not mascara.any():
                continue
            fundo = D if alfa >= 1 else D  # o fundo medido é sempre D (a captura sem texto)
            frente = tr.reshape(1, 1, 3) if alfa >= 1 else tr.reshape(1, 1, 3) * alfa + D * (1 - alfa)
            hi = np.maximum(_lum(frente), _lum(fundo))
            lo = np.minimum(_lum(frente), _lum(fundo))
            razao = (hi + 0.05) / (lo + 0.05)
            razao_mascarada = np.where(mascara, razao, np.inf)
            idx = np.unravel_index(np.argmin(razao_mascarada), razao_mascarada.shape)
            pior_caixa = float(razao_mascarada[idx])
            if not np.isfinite(pior_caixa):
                continue
            n_px_geral += int(mascara.sum())
            if pior_geral is None or pior_caixa < pior_geral:
                pior_geral = pior_caixa
                pior_fundo_geral = tuple(int(v) for v in D[idx])
        if n_px_geral == 0:
            continue  # nada a medir (recortado por overflow, cor igual ao fundo, ou só franja)
        medidos += 1
        if pior_geral < piso:   # PR-P111 (leva 24): razao EXATA, sem folga de 0,01 (antes `+ 0.01 < piso`)
            ruins.append('%s · %s:1 (piso %.1f) · %gpx/%d · "%s" · tinta %s sobre fundo #%02X%02X%02X'
                         % (t['sel'], piso_razao(pior_geral), piso, t['tam'], t['peso'], t['texto'], tinta_hex, *pior_fundo_geral))
    return {'medidos': medidos, 'ruins': ruins, 'janela': (W, H)}, None


def _perto(medido, esperado, tol=TOLERANCIA_PX):
    return medido is not None and abs(medido - esperado) <= tol


def confere_shell(f, dom, arquetipo_cli=None, origem_servida=False, nao_aplicacao=''):
    """CP-13. Decide o ARQUÉTIPO por um fato do artefato (a raiz de consulta do
    CP27) ou pela opção --arquetipo; se nenhum dos dois decide, NÃO MEDI — régua
    que adivinha o tipo erra no primeiro caso misto (regra da casa). Só para
    `aplicacao` mede as regiões; para os demais arquétipos o item não se aplica
    e sai PASS com o motivo do SH17.

    `origem_servida` (CP-P22, 2026-09-27): True só quando o DOM veio do `--url`
    (a página que um servidor devolveu); o arquivo HTML local é o padrão. Muda
    só o TEXTO do NÃO MEDI "sem identidade de shell" — a origem servida ouve
    "sirva uma rota autenticada ou rode sem --url"; o arquivo local, que não
    teve `--url`, ouve o que é verdade para ele.

    `nao_aplicacao` (CP-P64, leva 48, 2026-10-28): o motivo declarado pela PEÇA de que ela não é tela de aplicação (peça física, imagem de rede,
    fragmento de e-mail, molde); '' quando não declarou nada. Veredito dele (folha 15, Q5 = a, 2026-10-27): «os casos do CP-13 sem medir e o
    `--alvo` sem `--url` também saem 2, como o CP-10». A regra da casa separa as duas coisas: NÃO MEDI de quem DEVIA medir e não mediu (sem
    arquétipo decidido numa peça que não se declara, tela anterior à sessão) sai 2 (`sem_medida`); «não se aplica» DECLARADO sai 0."""
    if dom is None:
        f.item('CP-13', None, 'shell: o Chrome não devolveu o DOM renderizado — nada medido', sem_medida=True)
        return
    if dom.get('erro'):
        f.item('CP-13', None, 'shell: a sonda falhou dentro do navegador (%s) — nada medido' % dom['erro'][:120], sem_medida=True)
        return
    raiz = dom.get('raiz') or {}
    nome_raiz = raiz.get('nome')
    declarado = (dom.get('arquetipo') or '').strip()   # SH18: data-arquetipo no <html>
    origem = None
    if declarado and declarado not in ARQUETIPOS:
        # declaração fora da lista fechada é defeito do artefato, não falta de instrumento
        f.item('CP-13', False, 'shell: `data-arquetipo="%s"` fora da lista fechada do SH18 (%s)'
               % (declarado[:40], ' · '.join(ARQUETIPOS)))
        return
    if arquetipo_cli:
        arq, origem = arquetipo_cli, 'declarado por --arquetipo'
        if declarado and declarado != arquetipo_cli:
            origem += ' — sobrescreve o `data-arquetipo="%s"` do artefato' % declarado
    elif declarado:
        arq, origem = declarado, 'declarado no artefato: `data-arquetipo="%s"` (SH18, §46.5)' % declarado
    elif nome_raiz in RAIZ_PARA_ARQUETIPO:
        arq, origem = RAIZ_PARA_ARQUETIPO[nome_raiz], 'raiz de consulta `%s` no <%s%s> (CP27)' % (
            nome_raiz, raiz.get('tag'), ('.' + raiz['cls'].split(' ')[0]) if raiz.get('cls') else '')
    else:
        if nome_raiz:
            motivo = ('raiz de consulta `%s` = arquétipo publicado SEM shell na bancada (CP28); no produto pode ser '
                      'tela de aplicação (A18, A13, A4) ou não (A19)' % nome_raiz)
        else:
            motivo = 'nenhum container-name na página (CP27: contêiner anônimo é drift) — a régua não sabe o arquétipo'
        if nao_aplicacao and not origem_servida:
            # CP-P64: a peça se declara não-aplicação, então não há arquétipo a decidir: não se aplica (rotina, saída 0), com o motivo dito na ficha
            f.item('CP-13', None, 'shell: não se aplica — %s não é tela de aplicação (SH17); %s' % (nao_aplicacao, motivo))
        else:
            f.item('CP-13', None,
                   'shell: %s — declare `data-arquetipo` no <html> (SH18) ou passe --arquetipo %s' % (motivo, '|'.join(ARQUETIPOS)), sem_medida=True)
        return
    if arq != 'aplicacao':
        f.item('CP-13', True,
               'shell: não se aplica — %s (%s)' % (NAO_SE_APLICA.get(arq, arq), origem))
        return

    falhas, notas = [], []
    rail, top, main = dom.get('rail'), dom.get('topbar'), dom.get('main')

    # CP-P11 (2026-09-20, origem: CP-P9, relatório (f).2): sobre uma aplicação de
    # página única atrás de LOGIN, a origem servida SEM SESSÃO é a tela ANTERIOR
    # ao produto (A19, SH17 — fora do shell), não o produto. Até aqui, quando
    # `--arquetipo aplicacao` (ou a raiz de consulta, CP27) decidia o arquétipo, a
    # régua media essa tela como se já fosse o app e reprovava "rail AUSENTE",
    # "conteúdo AUSENTE", "menu de conta AUSENTE": três achados que não falam do
    # produto — falam de o instrumento não ter alcançado o estado onde o produto
    # existiria (regra da casa: instrumento que não mede diz NÃO MEDI, nunca
    # FALHOU — e nunca "não existe"). Medido: o SEED Chat servido sem sessão
    # (CP-P9) nunca monta nem o `<main>`; a `tela-autenticacao.html` do próprio
    # acervo, forçada com `--arquetipo aplicacao` para reproduzir o mesmo engano,
    # TEM `<main>` (é o "palco" do formulário) mas não tem rail nem topbar.
    #
    # A condição é por FATOS do artefato, nunca por adivinhação de tipo (regra da
    # casa: "régua que adivinha o tipo erra no primeiro caso misto"):
    #   (A) nem RAIL nem TOPBAR existem no DOM — as duas regiões que dão a
    #       IDENTIDADE de shell (SH2/SH3 e SH10); `<main>` fica de fora desta
    #       condição de propósito, porque uma tela de autenticação pode ter um
    #       `<main>` de conteúdo (é o caso da `tela-autenticacao.html`) sem que
    #       isso a torne o produto — exigir os três ausentes deixaria essa forma
    #       de fora e o achado ficaria sem cobaia real.
    #   (B) e, além disso, pelo menos um destes dois fatos aponta para "tela
    #       ANTERIOR à sessão", nunca para "produto com o shell quebrado":
    #       (B1) o PRÓPRIO artefato não declara `data-arquetipo="aplicacao"`
    #            (SH18) — o `aplicacao` em uso só veio de `--arquetipo` ou da
    #            raiz de consulta, nunca do artefato se autodeclarar assim; ou
    #       (B2) a tela tem a FORMA de autenticação: um campo de senha
    #            (`input[type="password"]`, medido pela sonda) ou pouquíssimo
    #            texto visível no documento inteiro (piso em POUCOS_TEXTOS_LIMITE,
    #            medido: SEED Chat sem sessão = 0; ver a constante).
    # O que isto NÃO cobre, com todas as letras: um app que DECLARA
    # `data-arquetipo="aplicacao"` de verdade (SH18 cumprido) E tem rail e topbar
    # genuinamente ausentes E não tem cara de tela de login (sem campo de senha,
    # com texto de sobra) — aí (B1) e (B2) são as duas falsas, a condição não
    # dispara, e a régua reprova abaixo, região por região: é o shell realmente
    # quebrado, não a tela errada.
    sem_identidade_de_shell = (not rail) and (not top)
    if sem_identidade_de_shell:
        n_textos = len(dom.get('textos') or [])
        parece_pre_sessao = bool(dom.get('campo_senha')) or n_textos <= POUCOS_TEXTOS_LIMITE
        arquetipo_proprio_diverge = (not declarado) or (declarado != 'aplicacao')
        if (arquetipo_proprio_diverge and origem_servida) or parece_pre_sessao:
            fatos = ['nem rail (SH2/SH3) nem topbar (SH10) existem no DOM']
            fatos.append('o artefato %s' % (('declara `data-arquetipo="%s"`' % declarado) if declarado
                         else 'NÃO declara `data-arquetipo` (SH18)'))
            fatos.append('%d nó(s) de texto visível(is) no documento inteiro (piso: %d)%s'
                         % (n_textos, POUCOS_TEXTOS_LIMITE,
                            ' · há campo de senha (`input[type=\"password\"]`)' if dom.get('campo_senha') else ''))
            if main:
                fatos.append('há `<main>` (%s), mas isso sozinho não faz desta tela o produto — a tela de '
                             'autenticação também pode ter conteúdo' % (('id="%s"' % main['id']) if main.get('id') else 'sem id'))
            if origem_servida:
                f.item('CP-13', None,
                       'shell: a origem servida não montou a aplicação (parece a tela anterior à sessão) — sirva '
                       'uma rota autenticada ou rode sem --url. Medido: %s' % '; '.join(fatos), sem_medida=True)   # REVISAO c: com --url o app NÃO foi medido (o do arquivo local é decisão dele)
            elif parece_pre_sessao:
                # CP-P22: arquivo local, sem `--url` — não há origem a servir nem opção a tirar
                f.item('CP-13', None,
                       'shell: este arquivo HTML não tem rail nem topbar e parece a tela anterior à sessão (A19), '
                       'não o produto — meça o HTML da tela do produto, depois do login. Medido: %s' % '; '.join(fatos), sem_medida=True)   # CP-P64
            else:
                f.item('CP-13', None,
                       'shell: este arquivo HTML não tem rail nem topbar e o próprio artefato não se declara '
                       '`aplicacao` (SH18) — a régua não o mede região por região; se ele É a tela do produto, '
                       'declare `data-arquetipo="aplicacao"` no <html> (SH18, §46.5) e meça de novo. Medido: %s'
                       % '; '.join(fatos), sem_medida=True)   # CP-P64
            return

    # ── rail (SH2 nome · SH3 largura, altura total, 3–12 destinos · SH5 um ativo)
    if not rail:
        falhas.append('rail AUSENTE — nenhuma nav[aria-label="Áreas"], nav.rail nem .rail (SH1/SH3)')
    else:
        if rail.get('role') == 'img' or 'ilustrativo' in (rail.get('label') or '').lower():
            falhas.append('rail ILUSTRATIVO (<%s role="%s" aria-label="%s">) — é cenário de bancada, não landmark de '
                          'navegação; no produto o rail é <nav aria-label="Áreas"> (SH2)'
                          % (rail['tag'], rail.get('role'), rail.get('label')))
        else:
            if rail['como'] != 'nav[aria-label="Áreas"]':
                falhas.append('rail achado por %s, mas o nome acessível canônico é "Áreas" (SH2) — lido: tag <%s>, aria-label=%r'
                              % (rail['como'], rail['tag'], rail.get('label')))
            if not rail.get('vis'):
                falhas.append('rail existe no DOM mas não é visível a %d×%d' % (DOM_LARGURA, DOM_ALTURA))
            # SH19 (SH-P14): a largura esperada depende do ESTADO declarado no .app — 80 no padrão, 56 no recolhido;
            # 58 px no recolhido reprova como 64 no padrão (cobaia `shell-rail-recolhido-58px-cobaia.html`; 57 cai na tolerância de 1 px)
            estado = (rail.get('estado') or '').strip()
            if estado == 'recolhido':
                esperado, qual = RAIL_RECOLHIDO_PX, 'SH19 recolhido'
            elif estado == 'largo':   # SH-P46: a DOM_LARGURA (1440) está acima dos dois limiares do Q3 — a Expandida não cai
                esperado, qual = RAIL_LARGO_PX, 'SH19 largo (Expandida)'
            else:
                esperado, qual = RAIL_PX, 'SH3 padrão'
            if not _perto(rail.get('width'), esperado):
                falhas.append('rail mede %.1f px de largura; o canon pede %.0f px (%s, §46.2%s)'
                              % (rail.get('width') or 0, esperado, qual,
                                 '; data-rail="%s"' % estado if estado else '; sem data-rail no .app — lido como padrão'))
            n = len(rail.get('itens') or [])
            if not (3 <= n <= 12):
                falhas.append('rail com %d destino(s) visíveis; o contrato é de 3 a 12 (SH3)' % n)
            ativos = [i for i in rail.get('itens') or [] if i.get('atual') == 'page']
            if len(ativos) != 1:
                falhas.append('%d destino(s) do rail com aria-current="page"; deve ser exatamente um (SH3/SH5)' % len(ativos))
            sem_rotulo = [i for i in rail.get('itens') or [] if not i.get('txt')]
            if sem_rotulo:
                falhas.append('%d destino(s) do rail sem rótulo textual visível (SH3: ícone + rótulo sempre visível)' % len(sem_rotulo))
            notas.append('rail: <%s aria-label="%s"> · %.0f px · estado %s · %d destinos (contados por %s) · ativo: %s · botão de alternar (SH19): %s'
                         % (rail['tag'], rail.get('label'), rail.get('width') or 0,
                            ('`data-rail="%s"`' % estado) if estado else 'não declarado (padrão)', n, rail.get('contado'),
                            ativos[0]['txt'] if ativos else '—',
                            {0: 'ausente', 1: 'presente'}.get(rail.get('alterna'), '%d (!)' % (rail.get('alterna') or 0))))

    # ── topbar (SH10 · §46.2 altura 60px) — e o REGIME SEM TOPBAR (SH1 + SH21)
    #
    # Até 2026-09-20 esta régua reprovava "topbar AUSENTE — nenhum <header> fora
    # do <main> (SH10)" em TODA tela de aplicação. Isso contradizia o canon que
    # ela existe para provar, em dois pontos:
    #   · o **SH1** (§46.1) declara obrigatórias DUAS regiões — **conteúdo** e
    #     **ao menos uma navegação** — e a topbar não está entre elas ("as demais
    #     o produto liga conforme a tarefa", princípio-mãe "contrato por região,
    #     composição livre");
    #   · o **SH21** (§46.6, `seed-componentes.md` v1.67, decisão dele de
    #     2026-09-19 no gate SH-P16) legisla explicitamente para o caso: "**em
    #     produto sem topbar, o avatar vive na base do rail**".
    # Quem achou foi a cobaia real do aceite da skill (MANIFESTO §236.6): o
    # `--alvo` rodado sobre o SEED Chat — um produto que, por desenho, não tem
    # topbar — reprovou por ausência de uma região que o canon não exige. A
    # cobaia escreveu, certa: "é divergência entre a RÉGUA e o CANON, não do
    # app". Pendência `CP-P9`, consertada aqui.
    #
    # Desde então são DOIS regimes, e a ficha diz qual mediu:
    #   COM topbar — o contrato do SH10 vale inteiro (é <header>, é visível, mede
    #     60 px) e o avatar vive nela (conferido em `_confere_conta`).
    #   SEM topbar — **nada some do contrato**: o que a topbar carregava passa a
    #     ser cobrado onde o canon o pôs. (a) a navegação obrigatória do SH1 é o
    #     rail, já cobrado acima; (b) a conta e o tema vão para a BASE DO RAIL
    #     (SH21/V-SH16.2, cobrado em `_confere_conta`, que já sabia distinguir
    #     "no rail" de "na topbar"); (c) o título da tela vive no conteúdo (SH10)
    #     e, sem topbar, é a ÚNICA identificação da tela — cobrado logo abaixo.
    #
    # O que este regime NÃO afrouxa: tela sem rail E sem topbar continua
    # reprovando, pelo "rail AUSENTE" do bloco acima — é o SH1 sendo cobrado.
    # E o rail continua OBRIGATÓRIO mesmo havendo topbar, o que o SH1 sozinho não
    # exigiria: essa é a decisão LC-11 (F11.5, 2026-09-14), nascida de o decisor
    # reprovar duas vezes uma tela do ERP sem rail — "o quadro aprovado do ERP tem
    # uma rail à esquerda" —, e o SH17 (§46.4) a escreveu. A assimetria é
    # deliberada e tem fonte: para "produto sem topbar" o canon TEM regra (SH21);
    # para "tela de aplicação sem rail", não tem.
    sem_topbar = not top
    if sem_topbar:
        notas.append('topbar: AUSENTE — medida no regime "produto SEM topbar" (SH1: obrigatórias são conteúdo '
                     '+ ao menos uma navegação; o SH21, §46.6 v1.67, legisla este caso). O que a topbar '
                     'carregaria é cobrado onde o canon o põe: conta e tema na BASE DO RAIL (SH21) e o título '
                     'da tela no conteúdo (SH10)')
    else:
        if top['tag'] != 'header':
            falhas.append('topbar é <%s>, não <header> (landmark banner; SH10)' % top['tag'])
        if not top.get('vis'):
            falhas.append('topbar existe no DOM mas não é visível a %d×%d' % (DOM_LARGURA, DOM_ALTURA))
        if not _perto(top.get('height'), TOPBAR_PX):
            falhas.append('topbar mede %.1f px de altura; o canon pede %.0f px (§46.2)' % (top.get('height') or 0, TOPBAR_PX))
        notas.append('topbar: <%s class="%s"> · %.0f px' % (top['tag'], top.get('cls'), top.get('height') or 0))

    # ── conteúdo (SH1 <main> · SH7 tabindex=-1)
    if not main:
        falhas.append('região de conteúdo AUSENTE — nenhum <main> (SH1)')
    else:
        if dom.get('mains', 1) != 1:
            falhas.append('%d elementos <main>; a região de conteúdo é uma (SH1)' % dom.get('mains'))
        if main.get('tabindex') != '-1':
            falhas.append('<main> sem tabindex="-1" — a troca de rota não tem para onde mover o foco (SH7, §46.2)')
        notas.append('conteúdo: <main id="%s" tabindex=%s>' % (main.get('id') or '', main.get('tabindex')))

    # ── título da tela NO CONTEÚDO (SH10) — entra em 2026-09-20 pela CP-P9
    # O SH10 escreve, na própria decisão: "**O título da tela vive no CONTEÚDO,
    # não na topbar**", e a coluna "Descartado" dele diz por quê — título na
    # topbar "duplica o `h1` do conteúdo e cria duas fontes de verdade sobre onde
    # o usuário está". A régua cobrava isso só de lado, no CP-10 (camada de
    # máquina), que conta os `h1` da PÁGINA e não olha ONDE eles vivem.
    # Por que entra agora e por que vale para os dois regimes: no regime SEM
    # topbar o `h1` do conteúdo é a única identificação da tela, então ele
    # substitui parte do que a topbar carregava; e no regime COM topbar a frase
    # do SH10 já valia — só não tinha teste. Medido antes de escrever a regra
    # (2026-09-20, sonda do CP-13 a 1440×900): **22 de 22** artefatos do acervo
    # (as 16 telas de referência e as 6 cobaias do CP-13) já têm exatamente UM
    # `h1` visível dentro do `<main>` — a exigência não reprova nada do que
    # existe, fecha um buraco. (Alternativa descartada: cobrar o `h1` só no
    # regime sem topbar; custaria a mesma linha de código, deixaria a frase do
    # SH10 sem teste onde há topbar e criaria dois contratos de conteúdo para a
    # mesma seção.)
    # Só se mede quando o `<main>` existe: sem ele, a linha "região de conteúdo
    # AUSENTE" acima já é a causa, e duas linhas para a mesma causa é ruído.
    if main:
        n_h1 = dom.get('h1_no_conteudo')
        if n_h1 is None:
            notas.append('título da tela: NÃO MEDI — esta execução da sonda não devolveu `h1_no_conteudo` '
                         '(sonda anterior à CP-P9)')
        elif n_h1 != 1:
            falhas.append('%d <h1> visível(is) dentro do <main>; o título da tela vive no CONTEÚDO e é UM '
                          '(SH10%s) — na página inteira a sonda viu %d <h1> visível(is) de %d no DOM'
                          % (n_h1, ' — e, sem topbar, ele é a ÚNICA identificação da tela' if sem_topbar else '',
                             dom.get('h1', 0), dom.get('h1_total', 0)))
        else:
            notas.append('título da tela: 1 <h1> visível dentro do <main> (SH10 — o título vive no conteúdo)')

    # ── menu de conta (SH21, F12.9): existe, é botão, abre um menu de verdade, e vive onde a
    #    decisão dele manda — na topbar quando ela existe, na base do rail quando não existe
    _confere_conta(dom, falhas, notas)

    # ── geometria entre regiões (SH3, com a posição da SH-P24): a topbar vai de ponta a ponta, ACIMA do rail; o
    #    rail é uma coluna à esquerda do conteúdo, da base da topbar até a base do conteúdo. SH-P24 (2026-09-25,
    #    veredito dele "1 arranjo: sim", MANIFESTO §252) supersede o "altura total, à esquerda da topbar" de agosto:
    #    até então esta régua reprovava rail que começasse abaixo do topo da topbar — agora reprova o contrário.
    if rail and top and main and rail.get('role') != 'img' and rail.get('vis') and top.get('vis'):
        rb, tb, mb = rail['box'], top['box'], main['box']
        if tb['x'] > rb['x'] + TOLERANCIA_PX:
            falhas.append('topbar não cobre o rail (topbar começa em x=%.0f, rail em x=%.0f) — a topbar vai de ponta a '
                          'ponta, acima do rail (SH3, SH-P24)' % (tb['x'], rb['x']))
        if rb['y'] + TOLERANCIA_PX < tb['b']:
            falhas.append('rail começa acima da base da topbar (y=%.0f × y=%.0f) — o rail vem ABAIXO da topbar (SH3, '
                          'SH-P24)' % (rb['y'], tb['b']))
        if rb['r'] > mb['x'] + TOLERANCIA_PX:
            falhas.append('rail não está à esquerda do conteúdo (rail termina em x=%.0f, conteúdo começa em x=%.0f) (SH3)'
                          % (rb['r'], mb['x']))
        if rb['b'] + TOLERANCIA_PX < mb['b']:
            falhas.append('rail termina antes da base do conteúdo (y=%.0f × y=%.0f) — o rail vai até a base (SH3)' % (rb['b'], mb['b']))
    elif sem_topbar and rail and main and rail.get('role') != 'img' and rail.get('vis'):
        # SEM topbar (CP-P9, 2026-09-20): o SH3 escreve "altura total, **à esquerda da topbar**", e sem ela a
        # referência passa a ser a única região que sobra à direita — o CONTEÚDO. A régua mede a MESMA
        # propriedade (o rail é uma coluna à esquerda, de cima a baixo), com a âncora que existe na tela.
        rb, mb = rail['box'], main['box']
        if rb['r'] > mb['x'] + TOLERANCIA_PX:
            falhas.append('rail não está à esquerda do conteúdo (rail termina em x=%.0f, conteúdo começa em '
                          'x=%.0f) — SH3, medido contra o conteúdo porque não há topbar' % (rb['r'], mb['x']))
        if rb['b'] + TOLERANCIA_PX < mb['b']:
            falhas.append('rail termina antes da base do conteúdo (y=%.0f × y=%.0f) — altura total (SH3)'
                          % (rb['b'], mb['b']))

    # ── skip link (SH8): primeiro focável leva ao conteúdo
    pf = dom.get('primeiro_focavel')
    if main:
        # sem id no <main> não existe alvo para o skip link — a cobaia 11 do
        # LEIA-ME (casca antiga do SIGA) passou aqui na primeira versão porque a
        # condição curto-circuitava no id vazio; conferido e consertado em 2026-09-14
        eh_skip = (pf and pf.get('tag') == 'a' and (pf.get('href') or '').startswith('#')
                   and (not main.get('id') or pf.get('href') == '#' + main['id']))
        if not main.get('id'):
            falhas.append('<main> sem id — o skip link não tem para onde apontar (SH8)')
        elif not eh_skip:
            falhas.append('o primeiro elemento focável não é o skip link para o conteúdo (SH8) — achado: %s'
                          % ('<%s href="%s"> "%s"' % (pf['tag'], pf['href'], pf['txt']) if pf else 'nenhum'))

    # ── nomes das navegações (SH2): todas nomeadas, únicas, sem "navegação"
    navs = dom.get('navs') or []
    sem_nome = [n for n in navs if not (n.get('label') or '').strip()]
    nomes = [(n.get('label') or '').strip() for n in navs if (n.get('label') or '').strip()]
    repetidos = sorted(set(x for x in nomes if nomes.count(x) > 1))
    com_navega = [x for x in nomes if 'navega' in x.lower()]
    if sem_nome:
        falhas.append('%d <nav> sem aria-label (SH2: todo landmark de navegação tem nome único)' % len(sem_nome))
    if repetidos:
        falhas.append('nome de nav repetido: %s (SH2)' % ', '.join('"%s"' % x for x in repetidos))
    if com_navega:
        falhas.append('nome de nav contém "navegação": %s (SH2 — o leitor de tela já anuncia o papel)' % ', '.join('"%s"' % x for x in com_navega))

    # ── sidebar: informada, não julgada. O §46.2 escreve 280 px, mas a `tela-quadro`
    # aprovada em gate mede 250 px e o shell do ERP também (medido em 2026-09-14):
    # canon e referência divergem, e a régua não escolhe entre eles — registra.
    sb = dom.get('sidebar')
    if sb:
        notas.append('sidebar: <nav aria-label="%s"> · %.0f px (§46.2 escreve %.0f px; SH1 a declara opcional — informado, não julgado)'
                     % (sb.get('label'), sb.get('width') or 0, SIDEBAR_PX))
    else:
        notas.append('sidebar: não ligada (SH1 permite: conteúdo + ao menos uma navegação)')

    # A ficha diz QUAL REGIME mediu (CP-P9): ler "0 achados" sem saber se a régua
    # cobrou a topbar ou o par conta-no-rail + título-no-conteúdo é ler meio placar.
    regime = ('regime SEM topbar (SH1 + SH21): rail, conteúdo, conta na base do rail e título no conteúdo'
              if sem_topbar else 'regime COM topbar (SH10): rail, topbar e conteúdo')
    f.item('CP-13', not falhas,
           'shell §46: tela de aplicação (%s) — %s, medidos no DOM a %d×%d: %d achado(s)'
           % (origem, regime, DOM_LARGURA, DOM_ALTURA, len(falhas)),
           falhas + notas)


def _confere_conta(dom, falhas, notas):
    """CP-13 · SH21 (F12.9, 2026-09-19) — o MENU DE CONTA da tela de aplicação.

    O SH21 é a decisão do Rafael no gate SH-P16 (MANIFESTO §235.2, 2026-09-19): toda tela de
    produto tem UM menu de conta, aberto pelo avatar (`aria-haspopup="menu"`), com os itens
    Perfil · Tema (claro · escuro · seguir o sistema) · Sair. Onde existe topbar (SH10) o avatar
    vive na zona direita dela; **em produto sem topbar, na base do rail**, imediatamente acima do
    botão de recolher do SH19, que continua o último item focável do rail (V-SH16.2, verbatim
    dele). O alternador de tema NUNCA é controle solto: é item deste menu (V-SH16.1 = b).

    O que esta função NÃO julga, e a omissão é decisão: quais itens além de Perfil/Tema/Sair o
    produto põe no menu (o §46 é "contrato por região, composição livre" — SH1), e o
    comportamento de teclado, que é da camada de render (`render-shell.mjs` R10-R14) e não se
    mede num retrato do DOM em repouso.
    """
    c = dom.get('conta')
    if not c:
        falhas.append('menu de conta AUSENTE — nenhum avatar na tela (SH21: toda tela de produto tem UM, '
                      'com Perfil · Tema · Sair)')
        return
    if c.get('tag') != 'button':
        falhas.append('o avatar é <%s>, não <button> (SH21) — um <div> não recebe foco nem é anunciado como '
                      'controle (SC 4.1.2); foi o estado de dez das onze telas do DS até 2026-09-19'
                      % c.get('tag'))
    if c.get('haspopup') != 'menu':
        falhas.append('o avatar não declara `aria-haspopup="menu"` (SH21/§35 MN2) — lido: %r' % c.get('haspopup'))
    if c.get('expanded') is None:
        falhas.append('o avatar não declara `aria-expanded` (SH21/§35 MN2): quem não vê o painel não sabe se ele abriu')
    if not c.get('controls'):
        falhas.append('o avatar não aponta para o painel por `aria-controls` (SH21)')
    if not (c.get('nome') or '').strip():
        falhas.append('o avatar não tem nome acessível (SH21: "Conta de <pessoa>", com o estado de presença junto)')
    p = c.get('painel')
    if not p:
        falhas.append('o avatar existe mas o PAINEL do menu não — `aria-controls="%s"` não resolve para nenhum '
                      'elemento (SH21). Era exatamente este o estado do shell entre agosto e 2026-09-19: o '
                      '`aria-haspopup="menu"` estava escrito e não abria nada' % (c.get('controls') or ''))
    else:
        if p.get('role') != 'menu':
            falhas.append('o painel do menu de conta tem role=%r; o §35 MN1 pede `role="menu"` para itens que '
                          'executam AÇÃO' % p.get('role'))
        if p.get('submenu'):
            falhas.append('%d submenu(s) dentro do menu de conta — o MN5 proíbe submenu (um nível só)' % p['submenu'])
        # SH-P46 (2026-10-08, Q7 aprovada): o teto ~7 do MN5 é de itens de AÇÃO (`menuitem`); rádios e checkbox são OPÇÕES
        # (MN4: ajustam sem fechar). O menu de conta passou a 9 itens = 2 ações (Perfil, Sair) + 7 opções.
        if p.get('acoes', p.get('itens', 0)) > 7:
            falhas.append('%d itens de AÇÃO no menu de conta; o teto do MN5 é ~7' % p.get('acoes', p.get('itens', 0)))
        temas = p.get('temas') or []
        if sorted(temas) != ['dark', 'light', 'sistema']:
            falhas.append('o menu de conta não traz os TRÊS estados de tema como `menuitemradio` '
                          '(claro · escuro · seguir o sistema) — lido: %s (V-SH16.1 = b · V-SH16.3 = a)'
                          % (', '.join(temas) or 'nenhum'))
        if p.get('dentro_do_app'):
            notas.append('menu de conta: o painel vive DENTRO do `.app` — funciona, mas o módulo da casa o emite '
                         'FORA, para escapar do `overflow` do rail e do bloco contendo que o `backdrop-filter` '
                         'do material (§93) cria em ancestral')
    # onde o avatar vive (V-SH16.2): com topbar, nela; sem topbar, na base do rail
    tem_topbar = bool(dom.get('topbar'))
    onde = c.get('onde')
    if tem_topbar and onde != 'topbar':
        falhas.append('a tela TEM topbar e o avatar está %s; o SH21 o põe na zona direita da topbar (a base do '
                      'rail é o caso de produto SEM topbar)' % ('no rail' if onde == 'rail' else 'fora da topbar'))
    if not tem_topbar and onde != 'rail':
        # o valor cru da sonda (`topbar`/`rail`/`outro`) não é frase: quem lê a ficha é gente, e
        # "o avatar está outro" foi o que a cobaia da CP-P9 imprimiu antes desta tradução
        ONDE_EM_PORTUGUES = {'topbar': 'na topbar', 'rail': 'no rail',
                             'outro': 'fora do rail e fora da topbar'}
        falhas.append('a tela NÃO tem topbar e o avatar está %s; o SH21 (V-SH16.2) o põe na BASE DO RAIL'
                      % ONDE_EM_PORTUGUES.get(onde, 'em lugar nenhum identificável'))
    if onde == 'rail' and c.get('penultimo_do_rail') is False:
        falhas.append('o avatar no rail não é o PENÚLTIMO item focável — o botão de recolher do SH19 tem de '
                      'continuar sendo o último (SH19 + SH21)')
    # o que o SH21 diz que é ÚNICO é o menu DE CONTA, não todo menu da tela: a `tela-detalhe` tem,
    # legitimamente, um segundo `role="menu"` — o de envio de comentário, §35. A primeira versão desta
    # régua contava todos os `role="menu"` e reprovou aquela tela em 2026-09-19; é a lição da casa
    # ("régua que ADIVINHA o tipo do objeto medido erra no primeiro caso misto"), medida no mesmo dia
    # em que a régua nasceu.
    if dom.get('avatares_menu', 0) > 1:
        falhas.append('%d avatares declarando abrir um menu; o menu de CONTA é UM por tela (SH21) — outros '
                      'menus de ação são legítimos e não entram nesta conta' % dom['avatares_menu'])
    elif dom.get('menus', 0) > 1:
        notas.append('menus na tela: %d com `role="menu"` — um é o de conta; os demais são menus de ação do §35, '
                     'legítimos (informado, não julgado)' % dom['menus'])
    notas.append('menu de conta (SH21): <%s id="%s"> %s · nome "%s" · painel: %s'
                 % (c.get('tag'), c.get('id'), 'na topbar' if onde == 'topbar' else ('na base do rail' if onde == 'rail' else 'em posição não identificada'),
                    c.get('nome'),
                    ('%d itens (%s) · tema: %s' % (p.get('itens'), ', '.join(p.get('rotulos') or []), ', '.join(p.get('temas') or []) or '—')) if p else 'AUSENTE'))


# CP-P43 (2026-10-03, leva 16): o PDF de uma peça HTML sai NO TAMANHO QUE ELA DECLARA. Até aqui o `--print-to-pdf` imprimia
# toda peça em papel Letter (612×792 pt) e ignorava `<meta name="alvo-fisico">` e `<meta name="alvo-tela">`: o `.peca` nasce
# em px de 300 DPI (3,125× o tamanho CSS em mm — medido nos 122 `alvo-fisico` de `07-pecas/`: 82 a 3,125, 12 a 0,132, 11 a 0,529,
# o resto entre 1,06 e 2,65), cabia em 2 ou 3 páginas Letter cortadas, e toda medida do PDF (CP-5, CP-10, CP-11, CP-12) lia
# um pedaço da peça num papel que ninguém imprime. A cura é imprimir uma CÓPIA TEMPORÁRIA do HTML (o arquivo medido nunca é
# tocado) com `@page{size:LxA;margin:0}`, margem zero e `zoom` no documento que leva o `.peca` (ou `.card`, ou o primeiro
# elemento do corpo) exatamente à largura da página. O fator 0,998 não é gosto: com 1,000 o arredondamento do Chrome deixa uma
# linha de pixel a mais e a peça de 50 mm vira 2 páginas (medido em 4 de 4 casos; 0,998 dá 1 página em 4 de 4). Peça que traz o
# PRÓPRIO `@page` com `size` (o deck) já manda no tamanho: fica como está. Sem declaração (alvo None), nada muda.
_FATOR_CABE_CP43 = 0.998
_RE_PAGE_SIZE = re.compile(r'@page[^{]*\{[^}]*\bsize\s*:', re.I)


def _fator_do_alvo(alvo, peca_w):
    """O `zoom` que a impressão do CP-P43 aplica (o mesmo cálculo do script injetado), ou 1.0 se não houver alvo
    declarado ou largura medida. É também a escala das folgas do CP-5: o intervalo entre o símbolo e a palavra da
    logo cresce com o mesmo fator que a peça (CP-P43: 6/14/28 pt eram px de CSS do papel Letter)."""
    if not alvo or alvo.get('tipo') not in ('fisico', 'tela') or not peca_w:
        return 1.0
    m = re.match(r'([\d.]+)x([\d.]+)(mm|px)', alvo['valor'])
    if not m:
        return 1.0
    larg = float(m.group(1)) * (96 / 25.4 if m.group(3) == 'mm' else 1.0)
    return _FATOR_CABE_CP43 * larg / peca_w


def _html_no_tamanho_declarado(caminho, alvo, pasta):
    """(arquivo a imprimir, texto do que foi feito). Sem declaração, ou com `@page size` próprio, devolve o próprio
    `caminho` — a peça é impressa como é. Com declaração, grava em `pasta` uma cópia byte a byte do HTML com o bloco
    do CP-P43 antes de `</head>` e devolve a cópia."""
    if not alvo or alvo.get('tipo') not in ('fisico', 'tela'):
        return caminho, 'sem alvo declarado: impresso como a peça é (papel padrão do Chrome)'
    cru = pathlib.Path(caminho).read_bytes()
    if _RE_PAGE_SIZE.search(cru.decode('utf-8', errors='replace')):
        return caminho, 'a peça traz @page com size próprio: impresso como ela manda'
    m = re.match(r'([\d.]+)x([\d.]+)(mm|px)', alvo['valor'])
    if not m:
        return caminho, 'alvo declarado ilegível (%s): impresso como a peça é' % alvo['valor']
    larg, alt, un = m.groups()
    alvo_px = ('%s*96/25.4' % larg) if un == 'mm' else larg
    bloco = (
        '<base href="%s"><style id="cp-p43">@page{size:%s%s %s%s;margin:0}html,body{margin:0 !important}</style>'
        '<script id="cp-p43-js">document.addEventListener("DOMContentLoaded",function(){'
        'var e=document.querySelector(".peca")||document.querySelector(".card")||document.body.firstElementChild;'
        'if(e){var w=e.getBoundingClientRect().width;if(w>0){document.documentElement.style.zoom=%s*(%s)/w;}}});</script>'
        % (pathlib.Path(caminho).resolve().parent.as_uri() + '/', larg, un, alt, un, _FATOR_CABE_CP43, alvo_px)
    ).encode('utf-8')
    achado = list(re.finditer(rb'</head>', cru, re.I))
    if achado:
        i = achado[-1].start()
        novo = cru[:i] + bloco + cru[i:]
    else:
        novo = bloco + cru
    destino = pathlib.Path(pasta) / 'peca-no-alvo.html'
    destino.write_bytes(novo)
    return destino, 'impresso em %sx%s %s (o alvo declarado em %s), não em Letter' % (larg, alt, un, alvo['origem'])


def confere_html(caminho, cores_tok, escala_px, empresa, arquetipo=None, origem_servida=False, alvo_tela=None, sem_texto=None):
    """HTML se mede pelo NAVEGADOR, não por um interpretador próprio — mas
    **não pelo mesmo caminho para tudo**. Duas superfícies do MESMO Chrome:

      1. A TELA renderizada a 1440×900 (DOM_LARGURA×DOM_ALTURA, a mesma janela
         do CP-13 e da régua RC `contraste-raster.mjs`): é dela que saem CP-1
         (cor), CP-2 (tipografia), CP-3 (tamanho) e CP-4 (contraste) — os
         quatro itens que dependem do que o navegador PINTOU — e o CP-13
         (shell) e a parte do CP-10 que só o DOM sabe (h1, lang, nav nomeada).
      2. O PDF impresso (`--print-to-pdf`): CP-5 em diante (marca, grafismo,
         grafia, dado institucional, segredo, geometria de página, folha
         aproveitada) — itens sobre o CONTEÚDO e a geometria de PÁGINA
         impressa, que ainda fazem sentido medir por esse caminho.

    A separação nasce na CP-P7 (2026-09-15/16, MANIFESTO — ver o relatório da
    pendência): até aqui, TUDO passava pelo PDF, inclusive HTML de tela de
    aplicação. O `--print-to-pdf` reflui a página inteira para o tamanho de
    papel Letter (612×792 pt ≈ 816×1056 px a 96 dpi) — bem menor que os
    1440×900 em que a tela é desenhada — e a medição saía de um layout que
    NINGUÉM vê: o rail, medido no `tela-shell.html`, tinha o fundo (deveria
    ser o gradiente turquesa `--seed-rail-background`) amostrado como
    `#FFFFFF` pelo CP-4 antigo, porque a coordenada onde o rail estaria a
    1440 px cai fora dele (ou sobre fundo de página) no layout reflusído a
    ~816 px. Medido nas onze telas de referência do shell: o CP-4 pelo PDF
    reprovava 5 de 11 ANTES deste conserto (`tela-atividade`, `tela-chat`,
    `tela-gantt`, `tela-quadro`, `tela-tabela`) — não porque o texto real
    tivesse contraste ruim, mas porque a régua estava medindo outra página.
    Sem Chrome, NÃO MEDI (nunca FAIL — regra da casa: instrumento sem
    ferramenta não afirma defeito)."""
    import tempfile
    chrome = _chrome()
    if not chrome:
        f = Ficha(os.path.basename(caminho))
        f.item('CP-0', None, 'HTML precisa do Chrome para ser medido como em produção — defina %CHROME_BIN%', sem_medida=True)
        return f

    # 1) a TELA — sonda do DOM a 1440×900 (mede_dom já faz isso para o CP-13;
    # desde a CP-P7 ela também devolve cores/textos computados, usados abaixo).
    dom = mede_dom(caminho, chrome)
    # PR-P189 (leva 46): o registro do DOM que não respondeu (`main()` imprime «DOM: N chamada(s) a mede_dom, M sem resposta»)
    dom_respondeu = bool(dom) and not dom.get('erro')
    _DOM_LOG['chamadas'] += 1
    if not dom_respondeu:
        _DOM_LOG['sem_resposta'] += 1
    maquina, titulo = None, None
    # As DECLARAÇÕES da peça (fragmento, molde, h1 exigido) vêm do TEXTO do arquivo, não do DOM: com o DOM sem resposta o fragmento de
    # e-mail deixava de ser lido como fragmento e reprovava como documento (F1 do rito de 2026-10-13).
    try:
        fonte_h1 = io.open(caminho, encoding='utf-8', errors='replace').read()
    except OSError:
        fonte_h1 = ''
    exige_h1, motivo_h1 = h1_exigido(alvo_declarado(fonte_h1, alvo_tela), fonte_h1)
    sem_texto = sem_texto_declarado(fonte_h1, sem_texto)   # CP-P65: «sem texto por desenho» declarado (meta ou --sem-texto); '' = não declarou
    fragmento = fragmento_declarado(fonte_h1)
    molde = molde_declarado(alvo_declarado(fonte_h1, alvo_tela), fonte_h1)
    if fragmento:
        exige_h1 = False
    if dom_respondeu:
        titulo = dom.get('title') or ''
        maquina = []
        if exige_h1 and dom.get('h1') != 1:
            maquina.append('%d <h1> visível(is) no DOM (%d no total) — a página tem um h1 único e real (MK do §47)'
                           % (dom.get('h1'), dom.get('h1_total', dom.get('h1'))))
        lang = (dom.get('lang') or '').strip()
        if fragmento:
            pass
        elif not lang:
            maquina.append('<html> sem lang (MK: lang="pt-BR")')
        elif not lang.lower().startswith('pt'):
            maquina.append('<html lang="%s"> — a página é em português; o MK do §47 pede lang="pt-BR"' % lang)
        anon = [n for n in (dom.get('navs') or []) if not (n.get('label') or '').strip()]
        if anon:
            maquina.append('%d <nav> sem nome acessível — um agente não distingue as navegações (MK; SH2)' % len(anon))

    f = Ficha(os.path.basename(caminho))
    # PR-P184 (leva 46): peça HTML FÍSICA (`alvo-fisico`) tem o CP-3 medido no PDF impresso (piso de 8 pt, marca-seed.md §4.8), não na tela
    alvo_fis = alvo_declarado(fonte_h1, alvo_tela)
    fisica = alvo_fis.get('tipo') == 'fisico'
    confere_tela_cp1234(f, dom, cores_tok, escala_px, cp3_na_tela=not fisica, sem_texto=sem_texto)   # CP-1, CP-2 (e CP-3 fora da peça física) (DOM computado)
    if sem_texto and dom_respondeu and (dom.get('textos') or []):
        f.item('CP-0', 'aviso', 'declaração `sem-texto` (%s) não confere: a tela tem %d nó(s) de texto — o texto é medido normalmente'
               % (sem_texto[:60], len(dom['textos'])))
    if dom and not dom.get('erro') and dom.get('textos') is not None:
        rel, motivo = mede_contraste_tela(caminho, chrome, dom['textos'], DOM_LARGURA,
                                          max(DOM_ALTURA, min(dom.get('docH') or DOM_ALTURA, 12000)))
        if rel is None:
            f.item('CP-4', None, 'contraste (tela): %s' % motivo, sem_medida=True)
        elif not rel['medidos']:
            # CP-P65 (leva 48): «0 de 0 nós de texto» era PASS no CP-4 da tela
            _sem_o_que_medir(f, 'CP-4', 'contraste (tela)', 'nenhum nó de texto com o fundo medido (0 de 0)', sem_texto)
        else:
            f.item('CP-4', not rel['ruins'],
                   'contraste (medido na TELA a %dx%d, não no PDF — CP-P7): %d de %d nós de texto atingem o '
                   'piso da WCAG 2.2 AA contra o fundo medido' % (rel['janela'][0], rel['janela'][1],
                      rel['medidos'] - len(rel['ruins']), rel['medidos']),
                   sorted(set(rel['ruins'])))
    else:
        f.item('CP-4', None, 'contraste (tela): a sonda do navegador não devolveu dados de texto', sem_medida=True)

    # 2) o PDF impresso — CP-5 em diante, na MESMA ficha (CP-1..CP-4 pulados: já entraram acima)
    # CP-P24 (2026-09-27): a pasta `cp-*` desta chamada (o PDF e o perfil do Chrome) sai no `finally`, pelo
    # caminho EXATO do `mkdtemp`, depois de o documento ser FECHADO (no Windows o PyMuPDF segura o arquivo
    # aberto e a pasta não sairia). Até aqui ficava para sempre: 493 no `%TEMP%` às 18:45Z de 2026-09-27.
    pasta_pdf = pathlib.Path(tempfile.mkdtemp(prefix='cp-'))
    doc = None
    try:
        saida = pasta_pdf / 'peca.pdf'
        # CP-P43: o alvo declarado é lido ANTES de imprimir — o PDF sai no tamanho dele, não em Letter
        try:
            fonte_alvo = io.open(caminho, encoding='utf-8', errors='replace').read()
        except OSError:
            fonte_alvo = ''
        alvo_imp = alvo_declarado(fonte_alvo, alvo_tela)
        a_imprimir, nota_impressao = _html_no_tamanho_declarado(caminho, alvo_imp, pasta_pdf)
        fator_imp = 1.0 if a_imprimir == caminho else _fator_do_alvo(alvo_imp, (dom or {}).get('pecaW'))
        # CP-P19: prazo que vale no Windows e perfil PRÓPRIO — sem `--user-data-dir`, o chrome.exe entrega a linha
        # de comando ao Chrome aberto de quem está na máquina (lição 27 de navegador-e-playwright.md)
        _roda_chrome([chrome, '--headless=new', CHROME_TEMA_CLARO, '--disable-gpu', '--no-pdf-header-footer',
                      '--user-data-dir=%s' % (saida.parent / 'perfil'),
                      '--print-to-pdf=%s' % saida, pathlib.Path(a_imprimir).resolve().as_uri()], temp=pasta_pdf)
        if not saida.exists():
            f.item('CP-0', None, 'o Chrome não produziu PDF a partir do HTML — CP-5 em diante não medidos '
                                 '(CP-1 a CP-4 já foram medidos na tela, acima%s)' % ('; o CP-3 da peça física é do PDF: NÃO MEDI' if fisica else ''), sem_medida=True)
            if fisica:
                f.item('CP-3', None, 'tamanho (peça impressa): NÃO MEDI — o Chrome não produziu o PDF em que o piso de 8 pt é medido (PR-P184)', sem_medida=True)
        else:
            import pymupdf
            doc = pymupdf.open(str(saida))
            # CP-P28: o alvo (tela × físico) vem da DECLARAÇÃO no fonte do HTML, pelas mesmas expressões do
            # render-peca.mjs; a --alvo-tela sobrescreve
            try:
                fonte_html = io.open(caminho, encoding='utf-8', errors='replace').read()
            except OSError:
                fonte_html = ''
            # REV-G1 (achado 2): o zoom do CP-P43 só entra se a sonda achou o elemento de escala (`.peca`, `.card` ou o 1.º filho do body) com
            # largura > 0; sem ele (e sem `@page` próprio) a peça de 300 DPI sai 3 a 4 vezes maior no PDF e o piso de 8 pt "passava". Com o DOM
            # lido e sem esse elemento o CP-3 não tem com que medir: NÃO MEDI. Sem o DOM (pecaW desconhecido) nada muda: o script roda no Chrome.
            sem_escala = bool(fisica and dom_respondeu and a_imprimir != caminho and not ((dom.get('pecaW') or 0) > 0))
            if sem_escala:
                f.item('CP-3', None, 'tamanho (peça impressa): NÃO MEDI — a peça não tem elemento de escala (.peca, .card ou um 1.º filho do body com largura), '
                                     'o zoom do tamanho declarado não foi aplicado e o texto do PDF não está em pt reais (PR-P184)', sem_medida=True)
            confere_pdf(caminho, cores_tok, escala_px, empresa, doc=doc,
                       rotulo=' · CP-1 a CP-4 na TELA (1440×900) · CP-5 em diante no PDF impresso pelo Chrome (%s)' % nota_impressao,
                       titulo_declarado=titulo, maquina_extra=maquina, nota_h1=motivo_h1, fragmento=fragmento, molde=molde,
                       ficha=f, pular=('CP-1', 'CP-2', 'CP-4') + (() if fisica and not sem_escala else ('CP-3',)),
                       alvo=alvo_declarado(fonte_html, alvo_tela), html=True, escala_folga=fator_imp, sem_texto=sem_texto)
    finally:
        if doc is not None:
            doc.close()
        _apaga_pasta_da_chamada(pasta_pdf)
    # CP-P64 (leva 48): a peça que SE DECLARA não-aplicação (física, imagem de rede, fragmento de e-mail, molde) não precisa de arquétipo: o CP-13 não se aplica
    # a ela e o NÃO MEDI do CP-13 é de rotina (saída 0); sem essa declaração o NÃO MEDI é de quem devia medir (saída 2)
    if fisica:
        nao_aplicacao = 'peça física declarada (alvo-fisico %s)' % alvo_fis.get('valor')
    elif fragmento:
        nao_aplicacao = 'fragmento de e-mail declarado (peca: fragmento-email)'
    elif _eh_tela(alvo_fis) and _RE_PECA.search(fonte_h1):
        nao_aplicacao = 'peça de tela declarada (peca=%s)' % _RE_PECA.search(fonte_h1).group(1)
    else:
        nao_aplicacao = ''
    confere_shell(f, dom, arquetipo, origem_servida=origem_servida, nao_aplicacao=nao_aplicacao)   # CP-P22: só o --url é origem servida
    return f


# ════════════════════════════════════════════════════════════════════════════
# --alvo — ADEQUAÇÃO: o conferidor apontado para um REPOSITÓRIO consumidor
# ════════════════════════════════════════════════════════════════════════════
# Nasce em 2026-09-19 no brick F12.2 (MANIFESTO §234.6, contrato 3). Por quê:
# em 2026-09-18 o decisor pediu, nos dois apps de chat da empresa, "adeque este
# material ao novo DS" — e precisou corrigir a IA seis vezes (rail sem o metal
# do §93, bordas quadradas, cores não revisadas). O reconhecimento mediu a causa
# (§234.4): a IA leu a fatia certa e não conferiu NADA; `conferir-peca.py` teve
# **0 execuções** nas duas superfícies. O conferidor existia, media peça PRONTA
# (um PDF, um SVG, um HTML) e não sabia olhar um REPOSITÓRIO. Este modo é o que
# faltava: o primeiro passo de uma adequação é a ficha do estado inicial, e a
# ficha do estado inicial É o inventário (defeito D-4 do relatório C).
#
# O QUE ELE MEDE (análise ESTÁTICA do código do app; nada é renderizado sem --url)
#   CP-1  COR      cor literal fora do token: hex, rgb()/hsl()/oklch() com número,
#                  classe de cor da paleta padrão do Tailwind (`bg-blue-500`) e
#                  `var(--x)` que não é do DS nem apelido do registry
#   CP-2  FONTE    família declarada fora de Montserrat · JetBrains Mono · Caveat
#   CP-9  SEGREDO  (novo, CP-P10, 2026-09-20) chave/token/senha no código ou em
#                  QUALQUER `.env*` presente no disco (rastreado ou não) — a
#                  cobaia da fase 2 da F12 mostrou um `.env` publicável passar
#                  em silêncio porque o `--alvo` não tinha varredura nenhuma
#   CP-9b IDENTIFICADOR (novo, CP-P10) número de plataforma, ou um JWT com
#                  `role` público (anon/authenticated/publishable) — avisa,
#                  não reprova (CLAUDE.md: identificador não é credencial)
#   CP-13 SHELL    (estático) o app declara `data-arquetipo` (SH18) e, sendo
#                  `aplicacao`, tem as três regiões do §46 no código
#   CP-14 MATERIAL (novo) a camada §93: `data-material="vidro-metal"` ligado, o
#                  item `@seed/material` instalado e importado, o rail com
#                  `.seed-mat-metal-rail` (MA2) e NENHUM vidro/metal escrito à
#                  mão fora do módulo (MA8)
#   CP-15 REGISTRY (novo) `@seed/tokens`, `@seed/fontes` e `@seed/theme`
#                  instalados, provados por MD5 contra o publicado, e nenhuma
#                  cópia transcrita de token ou de apelido
#
# COM `--url <origem servida>` o modo acrescenta o que só o navegador sabe: o
# DOM renderizado é medido pelo MESMO caminho do HTML (sonda a 1440×900), e daí
# saem CP-1/2/3 computados, CP-4 no pixel e CP-13 de verdade. SEM `--url` os
# itens de DOM saem **NÃO MEDI**, nunca FAIL — instrumento que não mediu não
# afirma defeito (regra da casa).
#
# O DENOMINADOR é obrigatório e sai no cabeçalho da ficha: quantos arquivos o
# censo achou, quantos foram analisados por tipo, quantos ficaram de fora e por
# quê. Placar sem denominador se lê como sucesso.

# Tipos que a régua sabe ler, com o nome em português do que cada um é. Arquivo
# de tipo fora desta tabela NÃO é reprovado: é contado em "ignorados por forma",
# com o motivo — a cobertura do instrumento tem denominador como qualquer outro
# placar (regra da casa, seed-ads + site SEED 2026-09-10).
ALVO_TIPOS = {
    '.tsx': 'componente React', '.jsx': 'componente React',
    '.ts': 'código TypeScript', '.js': 'código JavaScript',
    '.mjs': 'código JavaScript', '.cjs': 'código JavaScript',
    '.vue': 'componente Vue', '.svelte': 'componente Svelte',
    '.css': 'folha de estilo', '.scss': 'folha de estilo', '.sass': 'folha de estilo',
    '.less': 'folha de estilo',
    '.html': 'documento HTML', '.htm': 'documento HTML',
    '.json': 'configuração',
}
# Pastas que nunca entram no censo quando o alvo não é um repositório git (com
# git, quem decide é o `git ls-files`, que já respeita o .gitignore).
ALVO_PASTAS_FORA = ('node_modules', '.git', 'dist', 'build', 'out', '.next', '.output',
                    '.nuxt', 'coverage', '.venv', 'venv', '__pycache__', '.turbo',
                    '.cache', '.vercel', '.svelte-kit', 'target', 'vendor')
# Arquivos de trava de dependência: são JSON enormes, gerados, sem cor nem fonte.
ALVO_ARQUIVOS_FORA = ('package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'deno.lock',
                      'bun.lockb', 'composer.lock')
ALVO_LIMITE_BYTES = 2_000_000   # arquivo maior que isto sai como ignorado, com o motivo

# A paleta padrão do Tailwind (as 22 famílias de cor que vêm de fábrica) e os
# prefixos de utilitário que pintam. Uma classe `bg-blue-500` num produto SEED é
# cor de fora do DS por construção: o registry `@seed/theme` mapeia os apelidos
# do shadcn (`bg-primary`, `text-muted-foreground`…) para os semânticos do DS.
ALVO_PALETA_TAILWIND = ('slate', 'gray', 'zinc', 'neutral', 'stone', 'red', 'orange',
                        'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan',
                        'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose')
ALVO_PREFIXO_TAILWIND = ('bg', 'text', 'border', 'ring', 'from', 'to', 'via', 'fill',
                         'stroke', 'shadow', 'outline', 'decoration', 'divide', 'accent',
                         'caret', 'placeholder')
RE_TW_COR = re.compile(r'(?<![\w-])(?:[a-z]+:)*(?:' + '|'.join(ALVO_PREFIXO_TAILWIND) + r')-(?:'
                       + '|'.join(ALVO_PALETA_TAILWIND) + r')-(?:50|[1-9]00|950)(?![\w-])')
RE_HEX6 = re.compile(r'#([0-9a-fA-F]{6})(?:[0-9a-fA-F]{2})?(?![0-9a-fA-F])')
RE_HEX3 = re.compile(r'#([0-9a-fA-F]{3})(?![0-9a-fA-F])')
# Só conta quando o primeiro argumento é NÚMERO: `hsl(var(--primary))` consome o
# apelido do registry e é legítimo; `hsl(210 40% 96%)` é cor escrita no código.
RE_FUNCAO_COR = re.compile(r'(?<![\w-])(rgba?|hsla?|oklch|oklab|lab|lch)\(\s*(?=[-.\d])', re.I)
RE_VAR = re.compile(r'var\(\s*(--[A-Za-z0-9_-]+)')
RE_DECLARA_VAR = re.compile(r'(?m)^[^\S\n]*(--[A-Za-z0-9_-]+)\s*:\s*([^;\n]*)')
RE_FONT_FAMILY = re.compile(r'font-family\s*:\s*([^;}\n]+)', re.I)
RE_FONT_FAMILY_JS = re.compile(r'fontFamily\s*:\s*[\'"`]([^\'"`]+)')
# `font-serif` é a única família de fábrica do Tailwind que o DS não publica.
# `display` e `body` saíram desta lista depois da peça de fora (2026-09-19):
# `font-display: swap` é DESCRITOR de `@font-face`, não classe — a régua acusou
# 7 vezes a `tela-chat.html` do próprio DS por isso. O `(?!\s*:)` é o que separa
# a classe do descritor.
RE_TW_FONTE = re.compile(r'(?<![\w-])(?:[a-z]+:)*font-(serif)(?![\w-])(?!\s*:)')
# Quando a LINHA declara um `--seed-*`, ela é o gêmeo do DS embutido e os
# literais dela SÃO os tokens — mesma razão pela qual o caminho PDF já isenta o
# `#000000`. Medido na peça de fora: as 54 "cores fora do token" da
# `tela-chat.html` eram todas `rgba()` dentro de `--seed-mat-*` (o idioma de
# sombra e véu do canon, que é sempre preto ou branco + alfa).
RE_DECLARA_SEED = re.compile(r'(?<![\w-])--seed-[A-Za-z0-9_-]+\s*:')
RE_PRETO_BRANCO = re.compile(r'(?i)rgba?\(\s*(0\s*,\s*0\s*,\s*0|255\s*,\s*255\s*,\s*255)\b')
# A camada de material EMITIDA pelo módulo único se reconhece pela saída dele —
# o token derivado `--seed-mat-glass-filter` e as classes `.seed-mat-*`. Um
# arquivo que carrega essa saída É o módulo (instalado pelo registry ou embutido
# por gerador num artefato autocontido); o `backdrop-filter` dele não é "à mão".
RE_SAIDA_DO_MODULO = re.compile(r'--seed-mat-glass-filter\s*:|\.seed-mat-(?:casca|vidro|metal-rail)\s*[,{.:]')
# Vidro e metal escritos à mão — a forma, não o nome: é isto que a MA8 proíbe
# fora do módulo `material_bloco.py` ("receita nova nasce no módulo; nunca CSS
# copiado no molde"; anti-padrão declarado: "CSS de material no molde").
RE_MATERIAL_A_MAO = (
    (re.compile(r'(?<![\w-])-?(?:webkit-)?backdrop-filter\s*:'), 'backdrop-filter escrito à mão'),
    (re.compile(r'(?<![\w-])(?:[a-z]+:)*backdrop-(?:blur|saturate|brightness)-'), 'utilitário backdrop-* do Tailwind'),
    (re.compile(r'(?i)(?<![\w-])(linear|radial|conic)-gradient\([^)]*(?:metal|sheen|especular|brilho)'), 'gradiente de metal à mão'),
)

# Onde cada classe de achado se responde no DS. Mapa FIXO no código, com o
# motivo escrito — é o que transforma um achado em conserto: quem lê a ficha
# sabe qual seção abrir. A seção vem do `01-canonicos/seed-consulta.json` (a
# frase "quando consultar" de cada uma).
ALVO_SECAO_POR_CLASSE = {
    'cor-fora-do-token': ('seed-tokens.md §3 (paleta e semânticos) · marca-seed.md §3 (política de cor)',
                          'cor só existe no DS como token semântico; hex no código é valor que ninguém atualiza'),
    'cor-transcrita': ('seed-tokens.md §3 · seed-componentes.md §44 (Tailwind/shadcn)',
                       'o valor confere com um token, mas está COPIADO — constante copiada diverge no primeiro supersede'),
    'variavel-desconhecida': ('seed-componentes.md §44 (o código consome só semânticos)',
                              'variável que não é `--seed-*` nem apelido do registry: a régua não sabe se deriva de token'),
    'classe-de-paleta': ('seed-componentes.md §44 · registry `@seed/theme`',
                         'a paleta de fábrica do Tailwind não é a paleta da SEED; o tema do registry dá os apelidos certos'),
    'fonte-de-fora': ('marca-seed.md §4 (tipografia) · registry `@seed/fontes`',
                      'as famílias do DS são Montserrat, JetBrains Mono e Caveat'),
    'material-a-mao': ('seed-componentes.md §93 MA8 (o módulo único emite a camada)',
                       'vidro e metal só existem pelo item `@seed/material`; CSS copiado é duas verdades'),
    'material-ausente': ('seed-componentes.md §93 MA1/MA2 (metal no rail)',
                         'a camada é opcional pelo registry, mas o MA2 põe metal no rail — decisão de quem publica'),
    'registry-ausente': ('registry do DS, `10-publicacao/r/LEIA-ME.md`',
                         'sem o item instalado o produto não herda token nenhum'),
    'registry-divergente': ('registry do DS, `10-publicacao/r/LEIA-ME.md` (passo 4, a prova por MD5)',
                            'arquivo instalado que não bate byte a byte com o publicado foi editado à mão ou envelheceu'),
    'transcricao-de-token': ('seed-componentes.md §44 · registry `@seed/tokens`',
                             'redeclarar `--seed-*` fora do arquivo instalado cria uma segunda fonte para a mesma medida'),
    'shell-ausente': ('seed-componentes.md §46 (SH1, SH3, SH10, SH17)',
                      'no produto toda tela de aplicação vive dentro do shell'),
    'topbar-ausente-sem-prova': ('seed-componentes.md §46.1 (SH1) · §46.6 (SH21) · SH10',
                                 'produto sem topbar é caso PREVISTO pelo canon (SH21: o avatar vai para a '
                                 'base do rail); o que falta é a prova de DOM de que ele foi mesmo para lá'),
    'arquetipo-ausente': ('seed-componentes.md §46.5 (SH18)',
                          'sem `data-arquetipo` no <html> nenhuma régua sabe se o shell é obrigatório ali'),
    'segredo': ('CLAUDE.md do projeto, "Conformidade" · CLAUDE.md global, "Segurança de nascimento do código"',
               'chave, token, senha ou credencial de acesso total nunca entra em repositório — só em cofre '
               '(secrets do CI, vault); descoberto, o valor se REVOGA e se troca, nunca só se remove do texto'),
    'identificador-alvo': ('CLAUDE.md do projeto, "Conformidade" (veredito de 2026-09-08, CP-P4/CP-9b)',
                          'número ou token que só NOMEIA (não dá acesso) pode ficar registrado — o risco é '
                          'circulação, não sigilo; quem publica decide se cabe onde vai'),
    'env-rastreado': ('práticas gerais de versionamento (fora do canon do DS)',
                      'arquivo de ambiente (.env) rastreado no git expõe valor que deveria existir só na '
                      'máquina/cofre de quem roda — publicável ou não, .env não se versiona'),
}

# Vocabulário → seção do DS, para a coluna "seção que responde" do inventário de
# PEÇAS (não de achados). O casamento é pelo caminho do arquivo em minúsculas.
ALVO_SECAO_POR_PECA = (
    (r'(^|/)(chat|conversa|mensage|message)', '§55 Chat (CH1–CH14, gabarito A23)'),
    (r'(^|/)(shell|rail|topbar|sidebar)', '§46 Shell de aplicação (SH1–SH19)'),
    (r'(^|/)(lista|list|conversationslist)', '§28 Lista · §92 Lista longa'),
    (r'(^|/)(tabela|table|datagrid)', '§40 Tabela de dados (DT1–…)'),
    (r'(^|/)(botao|button)', '§1 Botão'),
    (r'(^|/)(form|campo|input|field)', '§2 Form-field · §3 Input de texto'),
    (r'(^|/)(dialog|modal)', '§30 Modal'),
    (r'(^|/)(drawer|gaveta)', '§31 Drawer'),
    (r'(^|/)(toast|sonner)', '§17 Toast'),
    (r'(^|/)(avatar)', '§32 Avatar'),
    (r'(^|/)(badge|chip|tag)', '§21 Badge · §22 Chip'),
    (r'(^|/)(card|cartao)', '§26 Card (CD1–CD5)'),
    (r'(^|/)(tab|aba)', '§34 Tabs'),
    (r'(^|/)(menu|dropdown)', '§35 Menu/dropdown'),
    (r'(^|/)(tooltip)', '§23 Tooltip'),
    (r'(^|/)(chart|grafico|report|relatorio)', 'seed-dataviz.md (rampa e tipos de gráfico)'),
    (r'(^|/)(auth|login|entrar|senha)', '§83 Tela A19 autenticação · §66 Credenciais'),
    (r'(^|/)(config|settings|preferenc)', '§86 Tela A18 configurações'),
    (r'(^|/)(icon|icone)', '§45 Iconografia (IC/NM)'),
    (r'(^|/)(empty|vazio)', '§25 Estado vazio'),
    (r'(^|/)(skeleton|loading|spinner)', '§18 Spinner · §19 Skeleton'),
    (r'\.css$', 'seed-tokens.md §3 · §44 Integração Tailwind/shadcn'),
)


# ════════════════════════════════════════════════════════════════════════════
# CP-9/CP-9b no ALVO — SEGREDO (reprova) e IDENTIFICADOR (avisa) no repositório
# do app, não só no artefato pronto. Nasce em 2026-09-20 (`CP-P10`, MANIFESTO
# §236.7): o CP-9/CP-9b de `confere_pdf` mede o texto IMPRESSO de uma peça
# pronta (PDF/SVG/HTML impresso) — nunca o CÓDIGO de um repositório. O
# `--alvo` (F12.2) nasceu SEM nenhuma varredura de segredo: na cobaia da fase
# 2 do aceite da F12 (o SEED Chat, commit `911d70d`), um `.env` com chaves do
# Supabase ficou no disco do app e a ficha não disse UMA PALAVRA — nem "achei
# um .env, conferi, são publicáveis". Dois motivos, os dois medidos lendo o
# código: (1) o censo do `--alvo` vem do `git ls-files` (linha 2380 e
# seguintes) — um `.env` normalmente vive no `.gitignore`, então é INVISÍVEL
# para o censo; (2) mesmo que fosse rastreado, `confere_alvo()` nunca chama
# `f.item('CP-9', …)` nem `'CP-9b'` — os dois só existem dentro de
# `confere_pdf()`, que o `--alvo` (sem `--url`) nunca executa.
#
# Os padrões AQUI são PRÓPRIOS deste bloco — não reaproveitam a lista de
# `confere_pdf` (linha ~574). Até 2026-09-20 aquela lista botava `act_\d+`
# (identificador de conta de anúncio, que o CLAUDE.md do projeto chama
# explicitamente de "não é credencial") dentro do que REPROVA (CP-9), quando
# pelo próprio veredito de 2026-09-08 deveria avisar (CP-9b) — inconsistência
# PRÉ-EXISTENTE achada pelo agente da CP-P10 e corrigida na consolidação do
# Grupo 5 (o `act_` passou para o CP-9b do artefato; cobaia: HTML com um
# `act_…` dá CP-9 PASS e CP-9b AVISO).
#
# A ficha NUNCA imprime o valor do segredo — só o NOME da variável (linha
# `NOME=valor`), o arquivo, a linha e a CLASSE. Para um JWT (o formato das
# chaves do Supabase, que é a maior parte do risco real medido na cobaia), a
# distinção FAIL × AVISO não é o padrão do texto — é o `role` do PAYLOAD,
# decodificado em base64 e NUNCA impresso nem devolvido: `service_role` é
# credencial de acesso total ao banco (ignora RLS, a regra do banco que
# decide quais linhas cada usuário enxerga) — FAIL; `anon`/`authenticated`/
# `publishable` é identificador — a segurança mora na política do banco, não
# em esconder a chave (CLAUDE.md do projeto, "Conformidade", veredito de
# 2026-09-08: "identificador não é credencial") — AVISO.
RE_ALVO_ENV_ARQUIVO = re.compile(r'^\.env(\.[A-Za-z0-9_.-]+)?$')
RE_ALVO_ENV_LINHA = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$')
# JWT = três blocos base64url separados por ponto (cabeçalho.payload.assinatura).
# O DS não valida assinatura nenhuma — só lê o `role` do meio, que é público
# por desenho (é a mesma informação que o navegador do cliente já recebe).
RE_ALVO_JWT = re.compile(r'\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b')
# Credencial pelo FORMATO — a forma reconhecível de uma chave de terceiro, ou
# senha embutida numa URL (usuário:senha@host). Cada uma reprova sozinha,
# sem precisar do nome da variável ao lado.
RE_ALVO_CRED = (
    (re.compile(r'\bEAA[A-Za-z0-9]{20,}'), 'token de acesso da Meta (EAA…)'),
    (re.compile(r'\bsk-[A-Za-z0-9_-]{20,}'), 'chave de API estilo OpenAI (sk-…)'),
    (re.compile(r'\bAIza[A-Za-z0-9_\-]{20,}'), 'chave do Google (AIza…)'),
    (re.compile(r'\bAKIA[0-9A-Z]{16}\b'), 'chave de acesso da AWS (AKIA…)'),
    (re.compile(r'-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED |)PRIVATE KEY-----'), 'bloco de chave privada'),
    (re.compile(r'[A-Za-z][A-Za-z0-9+.\-]*://[^/\s:@]+:[^@\s/]+@'), 'senha embutida numa URL (protocolo://usuário:senha@host)'),
)
# Credencial pelo NOME da variável — só reprova quando o VALOR não está vazio
# e não é um marcador óbvio de exemplo (o `.env.example` da casa versiona
# placeholder, e placeholder não é segredo).
RE_ALVO_NOME_SENSIVEL = re.compile(
    r'(?i)\b(SERVICE_ROLE(_KEY)?|SECRET(_KEY)?|CLIENT_SECRET|PRIVATE_KEY|DB_PASSWORD|DATABASE_PASSWORD|'
    r'SMTP_PASS(WORD)?|ACCESS_TOKEN|API_SECRET|ENCRYPTION_KEY)\b')
ALVO_VALOR_PLACEHOLDER = ('', 'changeme', 'change-me', 'xxx', 'your-key-here', 'troque-me',
                          'exemplo', 'example', 'placeholder', 'preencher', 'todo', '<preencher>')
# A MESMA régua do CP-9b do artefato (linha ~557): número de 15 a 18 dígitos é
# identificador de plataforma (conta, pixel), não credencial — avisa.
RE_ALVO_ID_LONGO = re.compile(r'(?<![\d.,\-/])\d{15,18}(?![\d.,\-/])')
# Extensões que a régua já sabe que são binárias: entram na varredura de
# segredo só se o NOME for `.env*` (um `.env` nunca é binário; a extensão
# aqui é sobre o que normalmente NÃO é texto).
ALVO_EXT_BINARIA = {
    '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.bmp', '.tiff',
    '.woff', '.woff2', '.ttf', '.otf', '.eot', '.pdf', '.zip', '.gz', '.tar',
    '.7z', '.rar', '.mp3', '.mp4', '.mov', '.avi', '.webm', '.wasm', '.node',
    '.exe', '.dll', '.so', '.dylib', '.class', '.jar', '.db', '.sqlite', '.map',
}


def _papel_jwt(token):
    """Decodifica só o PAYLOAD (o bloco do meio) de um JWT em base64url e
    devolve o `role` dele — NUNCA o token inteiro, nunca impresso. Sem
    padding (o formato do JWT omite o `=` final) e tolerante a erro: um
    token que não decodifica como JSON devolve None, e o chamador trata
    como "não deu para confirmar", nunca como PASS silencioso."""
    import base64
    try:
        meio = token.split('.')[1]
        meio += '=' * (-len(meio) % 4)
        payload = json.loads(base64.urlsafe_b64decode(meio.encode('ascii')))
        return payload.get('role')
    except Exception:
        return None


def _parece_binario(caminho):
    """Bytes de controle fora do texto (0x00 em especial) nos primeiros 4 KB —
    o mesmo sinal que qualquer editor de texto usa para recusar abrir um
    binário como texto. Extensão desconhecida (sem ser `.env`) que falha
    aqui é ignorada da varredura de segredo, nunca lida errado."""
    try:
        with open(str(caminho), 'rb') as fp:
            pedaco = fp.read(4096)
        return b'\x00' in pedaco
    except Exception:
        return True


def _envs_no_disco(raiz):
    """Todo arquivo `.env`, `.env.local`, `.env.production` etc. PRESENTE NO
    DISCO da pasta do alvo — rastreado ou não. É exatamente o caso que a
    cobaia da fase 2 (SEED Chat) mostrou passar em silêncio: um `.env` fora
    do `git ls-files` é invisível para o censo de `caminhos`, mas está lá, e
    alguém pode publicá-lo por engano (cópia de pasta, deploy errado, zip do
    projeto inteiro). Poda pelas MESMAS pastas que o censo por `os.walk` já
    poda (`ALVO_PASTAS_FORA` — regra da casa: fronteira por FORMA, nunca
    lista de pastas escrita à mão para o caso novo)."""
    achados = []
    raiz = pathlib.Path(raiz)
    for pasta, subpastas, arquivos in os.walk(str(raiz)):
        subpastas[:] = [s for s in subpastas if s not in ALVO_PASTAS_FORA and s != '.git']
        for a in arquivos:
            if RE_ALVO_ENV_ARQUIVO.match(a):
                rel = str((pathlib.Path(pasta) / a).relative_to(raiz)).replace('\\', '/')
                achados.append(rel)
    return sorted(achados)


def _acha_segredos_no_texto(rel, texto, falhas, avisos):
    """Varre UM arquivo já lido como texto, linha a linha, e ACRESCENTA a
    `falhas`/`avisos` (listas passadas por referência — o chamador soma o
    total). Devolve quantos FAIL este arquivo sozinho gerou, para o chamador
    decidir se um `.env` "só tem identificador" (nenhum FAIL) merece a nota
    informativa. Cada linha para no primeiro achado forte (JWT ou padrão de
    credencial) para não contar a MESMA coisa duas vezes por heurísticas
    diferentes (o token e, ao lado, o nome sensível da variável que o guarda)."""
    antes = len(falhas)
    for n, linha in enumerate(texto.split('\n'), 1):
        achou_forte = False
        for m in RE_ALVO_JWT.finditer(linha):
            achou_forte = True
            token = m.group(0)
            papel = _papel_jwt(token)
            if papel == 'service_role':
                falhas.append(Achado(rel, n, 'token JWT com `role=service_role` — chave de SERVIÇO (acesso total ao '
                                     'banco, ignora RLS): NUNCA em repositório nem em produto cliente', 'segredo'))
            elif papel in ('anon', 'authenticated', 'publishable'):
                avisos.append(Achado(rel, n, 'token JWT com `role=%s` — identificador público (a segurança mora na '
                                     'política do banco/RLS, não em esconder a chave; CLAUDE.md, veredito de '
                                     '2026-09-08)' % papel, 'identificador-alvo'))
            else:
                avisos.append(Achado(rel, n, 'token JWT achado; `role` %s — não deu para confirmar se é '
                                     'identificador ou credencial (decodifique o payload e confira antes de '
                                     'publicar)' % (('= %r' % papel) if papel else 'ausente/ilegível'),
                                     'identificador-alvo'))
        sem_jwt = RE_ALVO_JWT.sub(' ', linha) if achou_forte else linha
        for pat, rot in RE_ALVO_CRED:
            if pat.search(sem_jwt):
                achou_forte = True
                falhas.append(Achado(rel, n, 'credencial: %s' % rot, 'segredo'))
        if not achou_forte:
            m = RE_ALVO_ENV_LINHA.match(linha)
            if m:
                nome, valor = m.group(1), m.group(2).strip().strip('"\'')
                if (RE_ALVO_NOME_SENSIVEL.search(nome) and valor
                        and valor.lower() not in ALVO_VALOR_PLACEHOLDER and len(valor) >= 6
                        and '${' not in valor):
                    achou_forte = True
                    falhas.append(Achado(rel, n, 'variável `%s` — o NOME sugere credencial e o valor não está '
                                         'vazio nem é placeholder (nunca versione; use um cofre/secret do CI)'
                                         % nome, 'segredo'))
        if not achou_forte:
            for m in RE_ALVO_ID_LONGO.finditer(linha):
                avisos.append(Achado(rel, n, 'número de %d dígitos — identificador de plataforma (conta/pixel; a '
                                     'MESMA régua do CP-9b do artefato, CLAUDE.md, veredito de 2026-09-08): pode '
                                     'ficar registrado, evite peça pública' % len(m.group(0)), 'identificador-alvo'))
    return len(falhas) - antes


def varre_segredo_alvo(raiz, caminhos, eh_git):
    """CP-9 (segredo, reprova) e CP-9b (identificador, avisa) sobre o
    REPOSITÓRIO do alvo. Duas fontes de arquivo: (a) o censo (`caminhos`),
    filtrado para texto e para o teto de tamanho de `ALVO_LIMITE_BYTES`; (b)
    todo `.env*` PRESENTE NO DISCO, dentro ou fora de (a) — a fonte (b) é o
    que fecha o buraco medido na cobaia: um `.env` normalmente vive fora de
    (a), no `.gitignore`.

    `eh_git` diz se `caminhos` veio de `git ls-files` (`censo_do_alvo()`: a
    origem começa com "git ls-files"). Isto importa para UMA distinção: o
    achado "`.env` RASTREADO NO GIT" só faz sentido quando existe um git de
    verdade por trás — sem ele, `caminhos` vem do `os.walk` e TUDO que está
    no disco aparece lá por definição; rotular isso de "rastreado no git"
    seria uma afirmação falsa (medido na 1.ª rodada desta função contra a
    cobaia sem `.git`: o `.env` de mentira saiu como "rastreado" quando não
    havia repositório git nenhum ali — a régua estava medindo outra coisa).

    Devolve (falhas, avisos, notas, denominador) — o denominador é a frase
    que vai na ficha e no CP-9/CP-9b: "placar sem denominador não é placar"."""
    env_no_disco = _envs_no_disco(raiz)
    rastreados = set(caminhos)
    candidatos, ignorados_forma, ignorados_grande = [], 0, 0
    for rel in caminhos:
        base = os.path.basename(rel)
        ext = os.path.splitext(rel)[1].lower()
        eh_env = bool(RE_ALVO_ENV_ARQUIVO.match(base))
        if base in ALVO_ARQUIVOS_FORA:
            continue
        if ext in ALVO_EXT_BINARIA and not eh_env:
            ignorados_forma += 1
            continue
        completo = raiz / rel
        if not completo.is_file():
            continue
        if completo.stat().st_size > ALVO_LIMITE_BYTES:
            ignorados_grande += 1
            continue
        if _parece_binario(completo):
            ignorados_forma += 1
            continue
        candidatos.append((rel, eh_env))
    extras_env = [r for r in env_no_disco if r not in rastreados]
    for rel in extras_env:
        candidatos.append((rel, True))

    falhas, avisos, notas = [], [], []
    for rel, eh_env in candidatos:
        try:
            texto = _le(raiz / rel)
        except Exception:
            continue
        n_fail = _acha_segredos_no_texto(rel, texto, falhas, avisos)
        rastreado_no_git = eh_git and rel in rastreados
        # `.env.example`/`.sample`/`.template` é o MOLDE — versionado por
        # desenho, com placeholder no lugar do valor (é o próprio
        # `.env.example` da casa: `06-validacao` não tem um, mas o app pode
        # ter). Só o `.env`/`.env.local`/`.env.production` — o de VALOR REAL
        # — é que não deveria estar rastreado.
        eh_molde = bool(re.search(r'\.(example|sample|template|dist)$', os.path.basename(rel), re.I))
        if eh_env and rastreado_no_git and not eh_molde:
            avisos.append(Achado(rel, 0, 'arquivo de ambiente (.env) RASTREADO no git — .env não deveria estar '
                                 'versionado, tenha ele segredo ou não (some do repositório e some do histórico '
                                 'se algum valor real já foi commitado); `.env.example` é o molde e pode ficar',
                                 'env-rastreado'))
        elif eh_env and not n_fail:
            if rastreado_no_git:
                situacao = 'rastreado no git (molde/exemplo)' if eh_molde else 'rastreado no git'
            elif eh_git:
                situacao = 'local, fora do git'
            else:
                situacao = 'no disco (alvo sem .git)'
            notas.append('.env %s: `%s` — só identificador(es) publicável(is) encontrado(s), nenhuma credencial '
                         '(informativo)' % (situacao, rel))
    varridos_censo = len([r for r, _e in candidatos if r in rastreados])
    if eh_git:
        rotulo_censo, rotulo_extra = 'rastreado(s) no git', 'fora do git'
    else:
        rotulo_censo, rotulo_extra = 'do censo (os.walk — alvo sem .git)', 'fora do censo do os.walk'
    denom = ('%d de %d arquivo(s) %s varridos como texto (%d fora por forma binária/tamanho) + '
            '%d arquivo(s) `.env` no disco (%d já contado(s) por estarem no censo acima, %d %s)'
            % (varridos_censo, len(caminhos), rotulo_censo, ignorados_forma + ignorados_grande,
               len(env_no_disco), len(env_no_disco) - len(extras_env), len(extras_env), rotulo_extra))
    return falhas, avisos, notas, denom


def _md5_do_arquivo(caminho):
    import hashlib
    h = hashlib.md5()
    fp = open(str(caminho), 'rb')
    try:
        for bloco in iter(lambda: fp.read(1 << 16), b''):
            h.update(bloco)
    finally:
        fp.close()
    return h.hexdigest()


def _celulas(linha):
    """Células de uma linha de tabela Markdown, sem crase e sem espaço nas bordas."""
    if not linha.lstrip().startswith('|'):
        return []
    partes = linha.strip().strip('|').split('|')
    return [p.strip().strip('`').strip() for p in partes]


_CACHE_REGISTRY = {}


def registry_publicado():
    """O contrato do registry, LIDO da fonte — nunca digitado aqui.

    Duas fontes, nesta ordem: (1) os arquivos publicados em `10-publicacao/r/`,
    de onde o MD5 sai calculado na hora; (2) o `LEIA-ME.md` do registry (que o
    pacote da skill entrega como `references/registry.md` — MD5 igual, conferido
    em 2026-09-19), de onde saem a tabela de MD5, a tabela dos apelidos do
    shadcn e a tabela das classes `.seed-mat-*`. Constante copiada diverge no
    primeiro supersede (regra da casa, DS 2026-09-08): por isso nada aqui é
    transcrito do documento."""
    if _CACHE_REGISTRY:
        return _CACHE_REGISTRY
    pasta = _primeiro(_REPO / '10-publicacao' / 'r', _SKILL / 'r')
    leiame = _primeiro(_REPO / '10-publicacao' / 'r' / 'LEIA-ME.md',
                       _SKILL / 'references' / 'registry.md',
                       _AQUI.parents[3] / 'references' / 'registry.md')
    dados = {'itens': {}, 'apelidos': {}, 'apelidos_literais': {}, 'classes_material': {},
             'pasta': pasta, 'leiame': leiame, 'origem_md5': None}
    if leiame and leiame.exists():
        texto = io.open(leiame, encoding='utf-8', errors='replace').read()
        for linha in texto.splitlines():
            c = _celulas(linha)
            if len(c) == 4 and c[0].startswith('@seed/'):
                arquivo = None
                for m in re.finditer(r'([A-Za-z0-9_-]+\.css)', c[2]):
                    arquivo = m.group(1)
                md5 = c[3] if re.fullmatch(r'[0-9a-f]{32}', c[3]) else None
                dados['itens'][c[0]] = {'arquivo': arquivo, 'md5': md5, 'tipo': c[1]}
            if len(c) == 2 and c[0].startswith('--') and c[0] != '---':
                # O apelido só "aponta para o DS" quando a célula INTEIRA é um
                # `var(--seed-…)`. A linha do `--radius` do LEIA-ME é um
                # contraexemplo medido: ela vale `0.625rem` (literal, por
                # desenho) e só CITA `var(--seed-card-radius)` ao falar do
                # `--radius-xl`. Ler por `search` mapeava o `--radius` para um
                # token que ele não usa e a régua reprovava o registry certo —
                # defeito do instrumento, achado na 1.ª rodada da cobaia.
                m = re.fullmatch(r'var\(\s*(--seed-[A-Za-z0-9_-]+)\s*\)', c[1])
                if m:
                    dados['apelidos'][c[0]] = m.group(1)
                else:
                    dados['apelidos_literais'][c[0]] = c[1]
            if len(c) == 3 and c[0].startswith('.seed-mat-'):
                for nome in re.findall(r'\.seed-mat-[A-Za-z0-9_-]+', c[0]):
                    dados['classes_material'][nome] = c[1]
        dados['origem_md5'] = 'tabela do %s' % leiame.name
    if pasta and pasta.is_dir():
        for item in dados['itens'].values():
            if item['arquivo'] and (pasta / item['arquivo']).exists():
                item['md5'] = _md5_do_arquivo(pasta / item['arquivo'])
        dados['origem_md5'] = 'MD5 calculado nos arquivos de %s' % pasta
    _CACHE_REGISTRY.update(dados)
    return dados


def tira_comentarios(texto, ext):
    """Devolve o texto com os COMENTÁRIOS trocados por espaço, preservando cada
    quebra de linha e cada coluna (para o `arquivo:linha` continuar verdadeiro).

    Por que isto existe, medido na cobaia: das 4 ocorrências de hexadecimal no
    `index.html` do SEED Chat, 2 estão dentro de um comentário HTML que EXPLICA
    de onde veio o valor; no `index.css` de 2026-09-19 as 2 ocorrências estão as
    duas dentro de comentário. Acusar comentário é acusar quem documentou.

    `CP-P16` (2026-09-24, origem: achado fora do escopo da `CP-P15`, agente E do
    Grupo 8) — dentro de um template literal (crase), um `${…}` REABRE código
    JavaScript de verdade (pode ter comentário, string nova, ou outra crase
    aninhada); a versão anterior tratava tudo entre crases como string opaca e
    um `` `${ /* nota antiga */ x }` `` sobrevivia inteiro, comentário incluído
    — medido na cobaia `06-validacao/provas/tela-html/cp16-leitura-crua/
    gabarito-comentario/`. Agora a função empilha CONTEXTO (`codigo`, `gabarito`
    = texto literal do template fora de `${…}`, e a profundidade de chaves
    dentro de um `${…}`) para tratar cada um com a gramática certa: comentário
    só é comentário em `codigo`/`${…}`, nunca no texto literal do gabarito (a
    cobaia `gabarito-texto-real/` prova que `` `// isto não é comentário` ``
    continua literal, sem mudança).

    Alcance declarado: a varredura entende aspas simples, duplas e crase (então
    `"https://…"` não vira comentário), os pares `/* */`, `//` e `<!-- -->`, e
    agora `${…}` dentro de crase (com chaves e crases aninhadas contadas por
    pilha). NÃO entende literal de expressão regular em JavaScript — uma barra
    que abre regex pode ser lida como comentário. Nenhuma ocorrência disso foi
    medida nas cobaias; se aparecer, o efeito é a régua deixar de ver um
    trecho, nunca inventar um achado."""
    if ext in ('.html', '.htm'):
        saida = []
        i, n = 0, len(texto)
        while i < n:
            if texto.startswith('<!--', i):
                fim = texto.find('-->', i + 4)
                fim = n if fim < 0 else fim + 3
                saida.append(''.join('\n' if c == '\n' else ' ' for c in texto[i:fim]))
                i = fim
            else:
                saida.append(texto[i])
                i += 1
        limpo = ''.join(saida)
        # E DENTRO de <style> e <script> vale o comentário da linguagem de lá —
        # sem isto, um `/* … #E8FBF7 … */` dentro do <style> de uma tela
        # autocontida vira achado (medido na peça de fora, 2026-09-19: 3 dos 6
        # achados da `tela-chat.html` do próprio DS estavam em comentário de CSS).
        partes, pos = [], 0
        for m in re.finditer(r'(?is)<(style|script)\b[^>]*>(.*?)</\1\s*>', limpo):
            partes.append(limpo[pos:m.start(2)])
            partes.append(tira_comentarios(m.group(2), '.css' if m.group(1).lower() == 'style' else '.js'))
            pos = m.end(2)
        partes.append(limpo[pos:])
        return ''.join(partes)
    if ext == '.json':
        return texto
    saida = []
    i, n = 0, len(texto)
    aspas = None        # aspas simples/duplas correntes (nunca cruzam um `${…}`)
    # Pilha de CONTEXTO — o que muda com `CP-P16`. `'codigo'` é a gramática de
    # sempre; `'gabarito'` é o texto literal de um template entre crases, fora
    # de `${…}` (comentário ali NÃO é comentário, é conteúdo); um inteiro é a
    # profundidade de chaves dentro de um `${…}` (código de novo — ao fechar a
    # chave que abriu a expressão, volta para o `'gabarito'` que a empilhou).
    pilha = ['codigo']
    linha_dupla = ext in ('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.scss', '.sass',
                          '.less', '.vue', '.svelte')
    while i < n:
        contexto = pilha[-1]
        c = texto[i]
        if contexto == 'gabarito':
            if c == '\\' and i + 1 < n:
                saida.append(c); saida.append(texto[i + 1])
                i += 2
                continue
            if texto.startswith('${', i):
                saida.append('$'); saida.append('{')
                pilha.append(1)
                i += 2
                continue
            if c == '`':
                saida.append(c)
                pilha.pop()
                i += 1
                continue
            saida.append(c)
            i += 1
            continue
        # contexto == 'codigo', ou um inteiro (dentro de `${…}`) — mesma
        # gramática de código nos dois: comentário É comentário aqui.
        if aspas:
            saida.append(c)
            if c == '\\' and i + 1 < n:
                saida.append(texto[i + 1])
                i += 2
                continue
            if c == aspas:
                aspas = None
            i += 1
            continue
        if c in ('"', "'"):
            aspas = c
            saida.append(c)
            i += 1
            continue
        if c == '`':
            saida.append(c)
            pilha.append('gabarito')
            i += 1
            continue
        if texto.startswith('/*', i):
            fim = texto.find('*/', i + 2)
            fim = n if fim < 0 else fim + 2
            saida.append(''.join('\n' if x == '\n' else ' ' for x in texto[i:fim]))
            i = fim
            continue
        if linha_dupla and texto.startswith('//', i):
            fim = texto.find('\n', i)
            fim = n if fim < 0 else fim
            saida.append(' ' * (fim - i))
            i = fim
            continue
        if isinstance(contexto, int):
            if c == '{':
                pilha[-1] = contexto + 1
                saida.append(c)
                i += 1
                continue
            if c == '}':
                novo = contexto - 1
                pilha[-1] = novo
                saida.append(c)
                i += 1
                if novo == 0:
                    pilha.pop()  # a expressão `${…}` fechou: volta ao `gabarito`
                continue
        saida.append(c)
        i += 1
    return ''.join(saida)


# ── ALCANCE: o que o produto de fato CARREGA ────────────────────────────────
# A primeira rodada desta régua na cobaia (SEED Chat em `911d70d`, 2026-09-19)
# devolveu 624 achados de cor — e **617 deles estavam em `docs/gates/*.html`**,
# folhas de gate de setembro, que são EVIDÊNCIA de decisão e não interface de
# produto. Três achados reais (o CSS de exemplo que o Vite deixa em `App.css`)
# ficavam afogados. Régua nova que reprova em bloco está medindo outra coisa.
#
# A saída NÃO é uma lista de pastas a excluir — lista à mão envelhece no dia em
# que nasce uma pasta (regra da casa). É uma regra de FORMA: a superfície do
# produto é o que se ALCANÇA a partir do ponto de entrada, seguindo `import`,
# `require`, `@import` e as referências do HTML de entrada. O que não se alcança
# (documentação, evidência, ferramenta, função de servidor) é medido à parte e
# **não reprova** — mas aparece no censo com a contagem, porque cobertura sem
# denominador se lê como "não existe".
RE_IMPORT_JS = re.compile(
    r'''(?:^|[^\w.$])(?:import|export)\s*(?:[\w{}*,\s$]*?\bfrom\s*)?['"]([^'"\n]+)['"]'''
    r'''|(?:^|[^\w.$])(?:require|import)\s*\(\s*['"]([^'"\n]+)['"]\s*\)''', re.M)
RE_IMPORT_CSS = re.compile(r'''@import\s+(?:url\(\s*)?['"]([^'"\n]+)['"]''')
RE_REF_HTML = re.compile(r'''<(?:script|link)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"'\n]+)["']''', re.I)
EXT_RESOLUCAO = ('', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte',
                 '.css', '.scss', '.sass', '.less', '.json',
                 '/index.ts', '/index.tsx', '/index.js', '/index.jsx')


def _apelidos_de_caminho(raiz):
    """O mapa `@/*` → `src/*` e afins, lido do `tsconfig*.json` do alvo (o campo
    `compilerOptions.paths`). Comentário de linha é removido antes do JSON, que
    é o formato que a ferramenta grava (JSONC)."""
    mapa = {}
    for nome in ('tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'):
        arq = raiz / nome
        if not arq.exists():
            continue
        try:
            bruto = tira_comentarios(_le(arq), '.ts')
            dados = json.loads(bruto)
        except Exception:
            continue
        caminhos = (dados.get('compilerOptions') or {}).get('paths') or {}
        for chave, destinos in caminhos.items():
            if not destinos:
                continue
            mapa[chave.rstrip('*')] = str(destinos[0]).rstrip('*').lstrip('./')
    return mapa


def _resolve(raiz, conjunto, base_rel, especificador, apelidos):
    """Devolve o caminho relativo do censo para onde um `import` aponta, ou None.
    Pacote de `node_modules` (nome nu) não resolve e não é erro: ele não está no
    censo por desenho."""
    esp = especificador.split('?')[0].split('#')[0].strip()
    if not esp or esp.startswith(('http:', 'https:', 'data:', 'mailto:')):
        return None
    candidatos = []
    if esp.startswith('/'):
        candidatos.append(esp.lstrip('/'))
    elif esp.startswith('.'):
        base = os.path.dirname(base_rel)
        candidatos.append(os.path.normpath(os.path.join(base, esp)).replace('\\', '/'))
    else:
        for apelido, destino in apelidos.items():
            if apelido and esp.startswith(apelido):
                candidatos.append((destino + esp[len(apelido):]).replace('\\', '/'))
    for c in candidatos:
        for ext in EXT_RESOLUCAO:
            tentativa = (c + ext).replace('\\', '/').lstrip('./')
            if tentativa in conjunto:
                return tentativa
    return None


def alcance_do_alvo(raiz, caminhos):
    """Conjunto dos arquivos do censo alcançáveis a partir do ponto de entrada.
    Devolve (alcancaveis, entradas, motivo). Sem ponto de entrada reconhecível, o
    alcance é TODO o censo e o motivo diz isso — a régua não inventa fronteira."""
    conjunto = set(caminhos)
    apelidos = _apelidos_de_caminho(raiz)
    entradas = [c for c in caminhos
                if c.lower().endswith(('.html', '.htm')) and '/' not in c]
    if not entradas:
        for provavel in ('src/main.tsx', 'src/main.ts', 'src/main.jsx', 'src/main.js',
                         'src/index.tsx', 'src/index.ts', 'app/layout.tsx', 'app/page.tsx'):
            if provavel in conjunto:
                entradas.append(provavel)
    if not entradas:
        return set(caminhos), [], ('nenhum ponto de entrada reconhecível (HTML na raiz, `src/main.*` ou '
                                   '`app/layout.tsx`) — o alcance é TODO o censo')
    vistos, fila = set(entradas), list(entradas)
    while fila:
        atual = fila.pop()
        ext = os.path.splitext(atual)[1].lower()
        try:
            texto = _le(raiz / atual)
        except Exception:
            continue
        refs = []
        if ext in ('.html', '.htm'):
            refs = [m.group(1) for m in RE_REF_HTML.finditer(texto)]
        elif ext in ('.css', '.scss', '.sass', '.less'):
            refs = [m.group(1) for m in RE_IMPORT_CSS.finditer(tira_comentarios(texto, ext))]
        elif ext in ('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte'):
            limpo = tira_comentarios(texto, ext)
            refs = [m.group(1) or m.group(2) for m in RE_IMPORT_JS.finditer(limpo)]
            refs += [m.group(1) for m in RE_IMPORT_CSS.finditer(limpo)]
        for r in refs:
            destino = _resolve(raiz, conjunto, atual, r, apelidos)
            if destino and destino not in vistos:
                vistos.add(destino)
                fila.append(destino)
    motivo = ('grafo de importação a partir de %s (%d arquivo(s) alcançado(s) de %d do censo)'
              % (', '.join(entradas[:3]), len(vistos), len(caminhos)))
    return vistos, entradas, motivo


def censo_do_alvo(raiz):
    """O censo vem do `git ls-files` do ALVO — nunca de uma lista de pastas
    escrita à mão, que envelhece no dia em que nasce uma pasta (regra da casa).
    Quando o alvo não é repositório git, cai no `os.walk` e **imprime o motivo**:
    aí o denominador é o que sobrou de um filtro por forma, e isso se declara."""
    import subprocess
    caminhos, origem = [], None
    try:
        r = subprocess.run(['git', '-C', str(raiz), 'ls-files'], capture_output=True, timeout=180)
        if r.returncode == 0:
            saida = r.stdout.decode('utf-8', 'replace')
            caminhos = [l.strip() for l in saida.splitlines() if l.strip()]
            origem = 'git ls-files (%d caminhos rastreados)' % len(caminhos)
    except Exception:
        caminhos = []
    if not caminhos:
        origem = ('os.walk — o alvo NÃO é um repositório git (ou o git não respondeu); '
                  'pastas fora do censo por forma: %s' % ', '.join(ALVO_PASTAS_FORA))
        for pasta, subpastas, arquivos in os.walk(str(raiz)):
            subpastas[:] = [s for s in subpastas if s not in ALVO_PASTAS_FORA]
            for a in arquivos:
                completo = pathlib.Path(pasta) / a
                caminhos.append(str(completo.relative_to(raiz)).replace('\\', '/'))
    return sorted(set(caminhos)), origem


# CP-P62 (leva 50, 2026-10-31): a VARIANTE LOCAL do `@seed/fontes`. O item publicado (`seed-fontes.css`) serve as 7 fontes por `https://ds.seed.eng.br/03-assets/fontes/…`; um produto com
# CSP `font-src 'self'` (o Painel da Lívia, MANIFESTO §323.2 item 3) copia os woff2 para o próprio site e troca SÓ o prefixo das `url()` (o arquivo publicado já diz: «copie os arquivos e
# troque as URLs»). O MD5 muda e o CP-15 reprovava como «registry-divergente». Agora o arquivo é aceito como o item do registry quando, e só quando: (1) o nome do arquivo é o do item; (2) trocando
# cada `url(…)` pelo NOME do arquivo woff2, o texto fica BYTE A BYTE igual ao publicado (nenhuma outra mudança: nem `font-display`, nem um `@font-face` a mais ou a menos, nem comentário);
# (3) cada caminho é LOCAL: relativo (`../fontes/x.woff2`, `./x.woff2`, `x.woff2`) ou da raiz do site (`/static/fontes/x.woff2`), sem protocolo (`https:`, `data:`), sem host (`//cdn/…`),
# sem `?` nem `#`. Fonte servida de OUTRO host continua «divergente» (é fonte de fora, o CP-1/CP-15 a querem ver). Decisão da sessão, exposta a veto: aceitar a troca de prefixo NÃO muda a política de
# fontes dele (a variante servida de `ds.seed.eng.br` segue sendo a publicada); a alternativa descartada foi publicar uma segunda variante do item (muda o registry, o MD5 publicado e a skill).
_RE_URL_FONTE = re.compile(r"url\(\s*(['\"]?)([^'\")\s]*)\1\s*\)")
_RE_CAMINHO_LOCAL = re.compile(r'^(?!//)(?![A-Za-z][A-Za-z0-9+.\-]*:)[\w./~@\-]+$')
_VARIANTES_LOCAIS = {}      # rel -> (item, arquivo) dos arquivos aceitos como variante local nesta classificação


def _fontes_sem_prefixo(texto):
    """(texto com cada `url(…)` reduzido ao nome do arquivo, lista dos caminhos)."""
    caminhos = []

    def troca(m):
        caminhos.append(m.group(2))
        return 'url(@%s)' % m.group(2).rsplit('/', 1)[-1]
    return _RE_URL_FONTE.sub(troca, texto), caminhos


def _variante_local_do_registry(completo, reg):
    """(item, arquivo) quando `completo` é a variante local do `@seed/fontes` (ver acima); None caso contrário."""
    dados = reg['itens'].get('@seed/fontes') or {}
    arquivo = dados.get('arquivo')
    pasta = reg.get('pasta')
    if not arquivo or not pasta or os.path.basename(str(completo)) != arquivo or not (pathlib.Path(pasta) / arquivo).is_file():
        return None
    try:
        publicado = io.open(str(pathlib.Path(pasta) / arquivo), encoding='utf-8', newline='').read()
        candidato = io.open(str(completo), encoding='utf-8', newline='').read()
    except (OSError, UnicodeDecodeError):
        return None
    norm_pub, _cam_pub = _fontes_sem_prefixo(publicado)
    norm_can, caminhos = _fontes_sem_prefixo(candidato)
    if not caminhos or norm_pub != norm_can or not all(_RE_CAMINHO_LOCAL.match(c) for c in caminhos):
        return None
    return ('@seed/fontes', arquivo)


def classifica_censo(raiz, caminhos):
    """Separa o censo em ANALISADOS (por tipo) e IGNORADOS (por forma, com o
    motivo). Devolve também os arquivos que SÃO itens do registry, provados por
    MD5 contra o publicado: eles ficam de fora do CP-1 e do CP-2 porque são o
    próprio DS — os 537 hexadecimais do `seed-tokens.css` são os tokens."""
    reg = registry_publicado()
    md5_oficiais = {}
    for nome, item in reg['itens'].items():
        if item.get('md5') and item.get('arquivo'):
            md5_oficiais[item['md5']] = (nome, item['arquivo'])
    analisados, ignorados, do_registry = [], collections.Counter(), {}
    detalhe_ignorado = collections.defaultdict(list)
    _VARIANTES_LOCAIS.clear()
    for rel in caminhos:
        completo = raiz / rel
        if not completo.is_file():
            ignorados['não existe no disco (rastreado mas ausente)'] += 1
            detalhe_ignorado['não existe no disco (rastreado mas ausente)'].append(rel)
            continue
        base = os.path.basename(rel)
        ext = os.path.splitext(rel)[1].lower()
        if base in ALVO_ARQUIVOS_FORA:
            ignorados['trava de dependência (gerada, sem cor nem fonte)'] += 1
            continue
        if ext not in ALVO_TIPOS:
            ignorados['tipo fora da tabela da régua (%s)' % (ext or 'sem extensão')] += 1
            continue
        tam = completo.stat().st_size
        if tam > ALVO_LIMITE_BYTES:
            ignorados['maior que %d bytes' % ALVO_LIMITE_BYTES] += 1
            detalhe_ignorado['maior que %d bytes' % ALVO_LIMITE_BYTES].append(rel)
            continue
        assinatura = _md5_do_arquivo(completo)
        if assinatura in md5_oficiais:
            do_registry[rel] = md5_oficiais[assinatura]
            continue
        local = _variante_local_do_registry(completo, reg) if ext == '.css' else None
        if local:
            do_registry[rel] = local
            _VARIANTES_LOCAIS[rel] = local
            continue
        analisados.append(rel)
    return analisados, ignorados, detalhe_ignorado, do_registry


def secao_da_peca(rel):
    baixo = rel.lower()
    for padrao, secao in ALVO_SECAO_POR_PECA:
        if re.search(padrao, baixo):
            return secao
    return '—'


class Achado(object):
    """Uma linha do inventário: onde, o quê, de que classe, e qual seção do DS
    responde. É esta tupla que transforma a ficha em roteiro de conserto."""

    def __init__(self, arquivo, linha, achado, classe, trecho=''):
        self.arquivo, self.linha, self.achado, self.classe = arquivo, linha, achado, classe
        self.trecho = trecho

    def secao(self):
        return ALVO_SECAO_POR_CLASSE.get(self.classe, ('—', ''))[0]

    def __str__(self):
        onde = '%s:%d' % (self.arquivo, self.linha) if self.linha else self.arquivo
        return '%s · %s · %s · %s' % (onde, self.achado, self.classe, self.secao())


def _le(caminho):
    return io.open(caminho, encoding='utf-8', errors='replace').read()


def varre_cor_e_fonte(raiz, analisados, cores_tok):
    """CP-1 e CP-2 sobre o censo. Devolve (achados_cor, avisos_cor, achados_fonte,
    medidos) — `medidos` é o denominador: quantas ocorrências de cor a régua
    olhou, não quantos arquivos existem."""
    reg = registry_publicado()
    apelidos = set(reg['apelidos']) | set(reg['apelidos_literais'])
    fora, avisos, fontes, ocorrencias = [], [], [], 0
    valores_token = set(cores_tok)
    proprias = {}       # --x declarada no alvo -> (arquivo, linha, valor)
    for rel in analisados:
        texto = tira_comentarios(_le(raiz / rel), os.path.splitext(rel)[1].lower())
        for m in RE_DECLARA_VAR.finditer(texto):
            proprias.setdefault(m.group(1), (rel, texto.count('\n', 0, m.start()) + 1, m.group(2).strip()))
    for rel in analisados:
        ext = os.path.splitext(rel)[1].lower()
        bruto = _le(raiz / rel)
        texto = tira_comentarios(bruto, ext)
        linhas = texto.split('\n')
        eh_css = ext in ('.css', '.scss', '.sass', '.less')
        for n, linha in enumerate(linhas, 1):
            # a linha que DECLARA um `--seed-*` é o gêmeo do DS: os literais dela
            # são os tokens, não cor escrita no produto (ver RE_DECLARA_SEED)
            if RE_DECLARA_SEED.search(linha):
                continue
            for m in RE_HEX6.finditer(linha):
                ocorrencias += 1
                valor = '#' + m.group(1).upper()
                if valor in valores_token:
                    avisos.append(Achado(rel, n, 'hexadecimal %s — o valor CONFERE com %s, mas está escrito literal'
                                         % (valor, cores_tok[valor][0]), 'cor-transcrita', linha.strip()[:90]))
                else:
                    fora.append(Achado(rel, n, 'hexadecimal %s fora do seed-tokens.json' % valor,
                                       'cor-fora-do-token', linha.strip()[:90]))
            if eh_css and ':' in linha:
                for m in RE_HEX3.finditer(linha):
                    if linha[:m.start()].count(':') == 0:
                        continue
                    ocorrencias += 1
                    d = m.group(1)
                    valor = ('#' + d[0] * 2 + d[1] * 2 + d[2] * 2).upper()
                    if valor in valores_token:
                        avisos.append(Achado(rel, n, 'hexadecimal curto #%s (= %s) escrito literal' % (d, valor),
                                             'cor-transcrita', linha.strip()[:90]))
                    else:
                        fora.append(Achado(rel, n, 'hexadecimal curto #%s (= %s) fora do token' % (d, valor),
                                           'cor-fora-do-token', linha.strip()[:90]))
            for m in RE_FUNCAO_COR.finditer(linha):
                ocorrencias += 1
                # preto e branco + alfa são o idioma de SOMBRA e VÉU do canon
                # (não têm hex próprio): a mesma isenção que o caminho PDF já faz
                # para o `#000000`, agora medida também aqui
                if RE_PRETO_BRANCO.match(linha, m.start()):
                    continue
                fora.append(Achado(rel, n, '%s(...) com valor numérico — cor calculada no código'
                                   % m.group(1).lower(), 'cor-fora-do-token', linha.strip()[:90]))
            for m in RE_TW_COR.finditer(linha):
                ocorrencias += 1
                fora.append(Achado(rel, n, 'classe `%s` da paleta de fábrica do Tailwind' % m.group(0),
                                   'classe-de-paleta', linha.strip()[:90]))
            for m in RE_VAR.finditer(linha):
                nome = m.group(1)
                ocorrencias += 1
                if nome.startswith('--seed-') or nome in apelidos or nome.startswith('--tw-'):
                    continue
                # Segue a CADEIA de declarações do próprio alvo, até 4 saltos: o
                # Tailwind v4 gera `--color-border: var(--border)` a partir do
                # `@theme inline`, e `--border` é apelido do registry, que aponta
                # para um `--seed-*`. Parar no 1.º salto acusava `var(--color-border)`
                # como desconhecida — falso positivo medido na cobaia.
                dono = proprias.get(nome)
                if _deriva_do_ds(nome, proprias, apelidos):
                    continue
                onde = (' (declarada em %s:%d como `%s`)' % (dono[0], dono[1], dono[2][:40])) if dono else \
                       ' (não declarada em nenhum arquivo do censo)'
                avisos.append(Achado(rel, n, 'var(%s)%s' % (nome, onde), 'variavel-desconhecida',
                                     linha.strip()[:90]))
            for m in RE_FONT_FAMILY.finditer(linha):
                primeira = m.group(1).split(',')[0].strip().strip('\'"')
                if primeira.startswith('var(') or not primeira:
                    continue
                if not _familia_da_casa(primeira):
                    fontes.append(Achado(rel, n, 'font-family: %s' % primeira, 'fonte-de-fora', linha.strip()[:90]))
            for m in RE_FONT_FAMILY_JS.finditer(linha):
                primeira = m.group(1).split(',')[0].strip()
                if not _familia_da_casa(primeira):
                    fontes.append(Achado(rel, n, 'fontFamily: %s' % primeira, 'fonte-de-fora', linha.strip()[:90]))
            for m in RE_TW_FONTE.finditer(linha):
                if m.group(1) in ('serif', 'display', 'body'):
                    fontes.append(Achado(rel, n, 'classe `%s` — o DS não publica essa família' % m.group(0),
                                         'fonte-de-fora', linha.strip()[:90]))
    return fora, avisos, fontes, ocorrencias


def _deriva_do_ds(nome, proprias, apelidos, saltos=4):
    """A variável `nome` acaba num `--seed-*` ou num apelido do registry?
    Percorre as declarações que o PRÓPRIO alvo faz, no máximo `saltos` níveis."""
    visto = set()
    fila = [nome]
    while fila and saltos > 0:
        saltos -= 1
        seguinte = []
        for atual in fila:
            if atual in visto:
                continue
            visto.add(atual)
            if atual.startswith('--seed-') or atual in apelidos or atual.startswith('--tw-'):
                return True
            dono = proprias.get(atual)
            if not dono:
                continue
            for m in RE_VAR.finditer(dono[2]):
                seguinte.append(m.group(1))
        fila = seguinte
    return False


def _familia_da_casa(nome):
    n = nome.lower().split('+')[-1].replace('-', '').replace(' ', '').replace('"', '').replace("'", '')
    if n in ('inherit', 'initial', 'unset', 'revert', 'sans-serif', 'sansserif', 'monospace',
             'system-ui', 'systemui', 'ui-sans-serif', 'uisansserif', 'ui-monospace', 'uimonospace'):
        return True
    return any(fam.replace(' ', '').replace('-', '') in n for fam in FAMILIAS)


def raizes_de_fonte(raiz, entradas):
    """As pastas que o projeto declara como CÓDIGO-FONTE: o destino de cada
    apelido de caminho do `tsconfig` (`@/*` → `src/*`) mais a pasta de cada ponto
    de entrada que não seja HTML da raiz. Serve para separar duas coisas que o
    "fora do alcance" mistura: um arquivo de FONTE que ninguém importa (código
    morto, ou import que a régua não seguiu — vale um aviso) de um arquivo de
    documentação, evidência ou ferramenta (não é interface, e pronto)."""
    raizes = set()
    for destino in _apelidos_de_caminho(raiz).values():
        primeira = destino.strip('/').split('/')[0]
        if primeira:
            raizes.add(primeira)
    for e in entradas:
        if '/' in e:
            raizes.add(e.split('/')[0])
    return raizes


def varre_registry(raiz, analisados, do_registry, censo_todo=None):
    """CP-15. Três perguntas, cada uma com o denominador: os itens `registry:file`
    estão instalados e batem por MD5? o item `registry:theme` escreveu os apelidos
    apontando para semânticos do DS? existe cópia TRANSCRITA em algum lugar?"""
    reg = registry_publicado()
    linhas, falhas, avisos = [], [], []
    instalados = {}
    for rel, (item, arquivo) in do_registry.items():
        instalados[item] = rel
    # O gêmeo de tokens pode vir EMBUTIDO (artefato autocontido emitido por
    # gerador, como as telas de referência do DS). Nesse caso, "não instalou o
    # item do registry" é o desenho, não defeito: os itens ausentes saem como
    # AVISO, com a diferença escrita. Num produto, é transcrição e quem publica
    # decide. Medido na peça de fora (2026-09-19).
    gemeo_embutido = None
    for rel in analisados:
        if os.path.splitext(rel)[1].lower() not in ('.html', '.htm'):
            continue
        if len(set(re.findall(r'(--seed-[A-Za-z0-9_-]+)\s*:', tira_comentarios(_le(raiz / rel), '.html')))) >= 20:
            gemeo_embutido = rel
            break
    # 1) os itens de arquivo
    for item in sorted(reg['itens']):
        dados = reg['itens'][item]
        arquivo = dados.get('arquivo')
        if not arquivo:
            continue
        opcional = item == '@seed/material'
        if item in instalados:
            if instalados[item] in _VARIANTES_LOCAIS:
                linhas.append('%s: instalado em %s como variante local — só o prefixo do caminho das url() difere do publicado (MD5 do publicado %s não se aplica; CP-P62)'
                              % (item, instalados[item], dados['md5']))
                continue
            linhas.append('%s: instalado em %s — MD5 %s igual ao publicado' % (item, instalados[item], dados['md5']))
            continue
        # o arquivo existe com o nome certo mas com outro conteúdo?
        candidatos = [r for r in analisados if os.path.basename(r) == arquivo]
        if candidatos:
            for c in candidatos:
                achado = Achado(c, 0, '%s presente mas com MD5 %s ≠ %s do publicado'
                                % (item, _md5_do_arquivo(raiz / c), dados['md5']), 'registry-divergente')
                falhas.append(achado)
        elif gemeo_embutido:
            avisos.append(Achado(gemeo_embutido, 0, '%s não está instalado, mas o gêmeo do DS está EMBUTIDO neste '
                                 'arquivo — é o desenho de um artefato autocontido gerado; num produto, instale o '
                                 'item' % item, 'transcricao-de-token'))
        else:
            achado = Achado(arquivo, 0, '%s NÃO está instalado (nenhum %s no censo)' % (item, arquivo),
                            'registry-ausente' if not opcional else 'material-ausente')
            (avisos if opcional else falhas).append(achado)
    # 2) o tema (registry:theme) — o CLI escreve os apelidos no CSS global
    apelidos = reg['apelidos']
    achados_tema, errados = {}, []
    for rel in analisados:
        if os.path.splitext(rel)[1].lower() not in ('.css', '.scss', '.less'):
            continue
        texto = tira_comentarios(_le(raiz / rel), os.path.splitext(rel)[1].lower())
        for m in RE_DECLARA_VAR.finditer(texto):
            nome, valor = m.group(1), m.group(2).strip()
            n = texto.count('\n', 0, m.start()) + 1
            if nome in apelidos:
                if '--seed-' in valor:
                    achados_tema.setdefault(nome, (rel, n, valor))
                else:
                    errados.append(Achado(rel, n, 'apelido `%s` do shadcn com valor `%s` — o @seed/theme o aponta para `var(%s)`'
                                          % (nome, valor[:40], apelidos[nome]), 'transcricao-de-token'))
            elif nome in reg['apelidos_literais']:
                pass   # o registry publica valor LITERAL para este apelido (ex.: `--radius: 0.625rem`)
            elif nome.startswith('--seed-'):
                errados.append(Achado(rel, n, 'redeclara `%s` fora do arquivo instalado do registry' % nome,
                                      'transcricao-de-token'))
    falhas.extend(errados)
    # Gêmeo de tokens EMBUTIDO num HTML (`<style>` com `--seed-*`): num artefato
    # autocontido gerado é o desenho (as telas de referência do DS fazem assim);
    # num produto é transcrição, que diverge no primeiro supersede. A régua não
    # sabe qual é o caso — AVISA e diz por quê (o quarto estado da casa).
    for rel in analisados:
        if os.path.splitext(rel)[1].lower() not in ('.html', '.htm'):
            continue
        texto = tira_comentarios(_le(raiz / rel), '.html')
        quantos = len(set(re.findall(r'(--seed-[A-Za-z0-9_-]+)\s*:', texto)))
        if quantos:
            avisos.append(Achado(rel, 0, 'gêmeo de tokens EMBUTIDO: %d variável(is) `--seed-*` declaradas dentro '
                                 'do HTML. Legítimo em artefato autocontido gerado; num produto, é transcrição — '
                                 'instale `@seed/tokens`' % quantos, 'transcricao-de-token'))
    if apelidos:
        if not achados_tema:
            achado = Achado('(CSS global)', 0, '@seed/theme NÃO está instalado: 0 de %d apelidos do shadcn '
                            'apontam para um semântico do DS%s'
                            % (len(apelidos), ' (o alvo não usa shadcn: o gêmeo está embutido em %s)'
                               % gemeo_embutido if gemeo_embutido else ''), 'registry-ausente')
            (avisos if gemeo_embutido else falhas).append(achado)
        elif len(achados_tema) < len(apelidos):
            faltando = sorted(set(apelidos) - set(achados_tema))
            falhas.append(Achado('(CSS global)', 0, '@seed/theme parcial: %d de %d apelidos apontam para o DS; '
                                 'faltam %s' % (len(achados_tema), len(apelidos), ', '.join(faltando[:8])),
                                 'registry-ausente'))
        else:
            linhas.append('@seed/theme: %d de %d apelidos do shadcn apontam para um semântico do DS'
                          % (len(achados_tema), len(apelidos)))
    # 3) o namespace no components.json — arquivo de CONFIGURAÇÃO, que ninguém
    # importa e por isso nunca cai na superfície alcançável: procura-se no censo
    # inteiro (a 1.ª rodada da cobaia disse "não existe" com ele no disco —
    # defeito do instrumento, consertado aqui).
    cj = [r for r in (censo_todo or analisados) if os.path.basename(r) == 'components.json']
    if cj:
        txt = _le(raiz / cj[0])
        if 'ds.seed.eng.br/r/' in txt:
            linhas.append('components.json: namespace @seed apontando para o registry publicado')
        else:
            avisos.append(Achado(cj[0], 0, 'components.json sem o namespace @seed → https://ds.seed.eng.br/r/{name}.json',
                                 'registry-ausente'))
    else:
        linhas.append('components.json: não existe no censo — o produto pode não usar o CLI do shadcn (informativo)')
    return linhas, falhas, avisos


def varre_material(raiz, analisados, do_registry, html_raiz):
    """CP-14. A camada §93 é OPCIONAL pelo registry ("sem o atributo, nada muda um
    pixel", MA1) — então a régua não reprova quem não a ligou: AVISA, com o MA2 na
    mesa (metal no rail). O que ela reprova é INCOERÊNCIA: ligou e o rail não usa
    a classe; ou vidro/metal escrito à mão fora do módulo, que é o anti-padrão
    nomeado da MA8 ("CSS de material no molde")."""
    reg = registry_publicado()
    falhas, avisos, notas = [], [], []
    _cache_limpo = {}

    def _sem_comentarios(rel):
        """`CP-P16` (2026-09-24, origem: achado fora do escopo da `CP-P15`, agente E
        do Grupo 8) — as duas leituras CRUAS que sobraram nesta função (o padrão de
        classe/CSS do módulo, `RE_SAIDA_DO_MODULO`, e a classe `.seed-mat-metal-rail`
        do rail) tinham o MESMO comentário-vira-presença que a `CP-P15` já fechou
        para `data-material`/`data-arquetipo`/o import do CSS: um comentário só
        CITANDO o padrão contava como presença real. Mesmo conserto
        (`tira_comentarios`), memoizado por arquivo para não ler/limpar o mesmo
        `rel` duas vezes nesta função. Fora de `ALVO_TIPOS` (imagem, fonte,
        lockfile) devolve string vazia — a régua não lê binário à procura de CSS.
        Cobaias: `06-validacao/provas/tela-html/cp16-leitura-crua/`."""
        if rel not in _cache_limpo:
            ext = os.path.splitext(rel)[1].lower()
            _cache_limpo[rel] = tira_comentarios(_le(raiz / rel), ext) if ext in ALVO_TIPOS else ''
        return _cache_limpo[rel]

    ligado = None
    for rel, conteudo in html_raiz:
        m = re.search(r'data-material\s*=\s*["\']([^"\']*)["\']', conteudo)
        if m:
            ligado = (rel, m.group(1))
            break
    if ligado is None:
        # `CP-P15` (2026-09-20, origem: achado fora do escopo da `CP-P13`/agente E do Grupo 7) —
        # o mesmo comentário-vira-presença que a `CP-P13` fechou para o `<nav class="rail">` do
        # HTML existia aqui, do lado do FONTE: um `// TODO: pôr data-material="vidro-metal" no
        # <html>` contava como camada LIGADA, porque a busca era `'data-material' in texto` no
        # texto CRU. `tira_comentarios(texto, ext)` troca `//`, `/* */` e o conteúdo de string por
        # espaço preservando toda quebra de linha e cada coluna — o `arquivo:linha` calculado
        # depois continua verdadeiro porque o texto limpo tem o MESMO tamanho e as MESMAS posições
        # de `\n` do original (ver o contrato no docstring de `tira_comentarios`). Cobaia na pasta
        # `06-validacao/provas/tela-html/cp15-comentario-vira-presenca/`.
        for rel in analisados:
            ext = os.path.splitext(rel)[1].lower()
            if ext not in ('.ts', '.tsx', '.js', '.jsx'):
                continue
            texto = tira_comentarios(_le(raiz / rel), ext)
            if 'data-material' in texto:
                n = texto[:texto.index('data-material')].count('\n') + 1
                alvo = 'vidro-metal' if 'vidro-metal' in texto else '(valor não literal)'
                ligado = ('%s:%d' % (rel, n), alvo)
                break
    instalado = '@seed/material' in {i for i, _a in do_registry.values()}
    arquivo_modulo = None
    for rel, (item, _a) in do_registry.items():
        if item == '@seed/material':
            arquivo_modulo = rel
    if ligado and ligado[1] == 'vidro-metal':
        notas.append('camada ligada: `data-material="vidro-metal"` em %s (MA1)' % ligado[0])
    elif ligado:
        falhas.append(Achado(ligado[0], 0, '`data-material="%s"` — o valor do §93 é `vidro-metal`' % ligado[1],
                             'material-a-mao'))
    else:
        avisos.append(Achado('(HTML da raiz)', 0, 'a camada §93 não está ligada: nenhum `data-material="vidro-metal"`. '
                             'Ela é OPCIONAL pelo registry, mas a MA2 põe metal no rail e na ação primária',
                             'material-ausente'))
    # Arquivos que CARREGAM a saída do módulo único — o item do registry
    # instalado, ou o bloco embutido por um gerador num artefato autocontido
    # (é o caso das telas de referência do próprio DS). O `backdrop-filter`
    # deles não é "à mão": é a camada. Sem esta distinção a régua reprovava 7
    # linhas da `tela-chat.html` do DS, medido na peça de fora (2026-09-19).
    com_modulo = set(arquivo_modulo and [arquivo_modulo] or [])
    for rel in analisados:
        if RE_SAIDA_DO_MODULO.search(_sem_comentarios(rel)):  # `CP-P16`
            com_modulo.add(rel)
    embutido = sorted(com_modulo - {arquivo_modulo})
    if instalado:
        notas.append('item `@seed/material` instalado em %s (MD5 igual ao publicado)' % arquivo_modulo)
        # `CP-P15`: a TERCEIRA ocorrência da mesma forma, achada ao medir o enunciado — um
        # comentário citando `seed-material.css` (`// falta importar seed-material.css`) contava
        # como import de verdade, porque a busca era `'seed-material.css' in texto` no texto CRU.
        # Mesmo conserto: texto sem comentário antes de procurar.
        importado = []
        for rel in analisados:
            ext = os.path.splitext(rel)[1].lower()
            if ext not in ('.css', '.scss', '.less', '.ts', '.tsx', '.js', '.jsx'):
                continue
            if 'seed-material.css' in tira_comentarios(_le(raiz / rel), ext):
                importado.append(rel)
        if importado:
            notas.append('importado em %s' % ', '.join(importado[:3]))
        else:
            falhas.append(Achado(arquivo_modulo, 0, 'seed-material.css está no repositório mas NÃO é importado '
                                 'em nenhum arquivo do censo', 'material-a-mao'))
    elif embutido:
        avisos.append(Achado(embutido[0], 0, 'a camada §93 está EMBUTIDA (a saída do módulo único aparece no '
                             'próprio arquivo) em vez de vir do item `@seed/material` do registry. Num artefato '
                             'autocontido gerado é o desenho; num produto, instale o item — quem publica decide',
                             'material-ausente'))
    elif ligado:
        falhas.append(Achado('(styles)', 0, 'o `data-material` está ligado mas o item `@seed/material` não está '
                             'instalado nem há saída do módulo embutida — a camada não existe', 'registry-ausente'))
    # vidro e metal à mão (MA8) — fora de todo arquivo que carrega a saída do módulo
    a_mao = []
    for rel in analisados:
        ext = os.path.splitext(rel)[1].lower()
        if ext not in ALVO_TIPOS or rel in com_modulo:
            continue
        texto = tira_comentarios(_le(raiz / rel), ext)
        for n, linha in enumerate(texto.split('\n'), 1):
            for padrao, rotulo in RE_MATERIAL_A_MAO:
                if padrao.search(linha):
                    a_mao.append(Achado(rel, n, '%s — a camada é emitida pelo módulo único (MA8)' % rotulo,
                                        'material-a-mao', linha.strip()[:90]))
    falhas.extend(a_mao)
    # o rail em metal (MA2)
    classes_rail = [r for r in analisados
                    if re.search(r'(^|/)rail', os.path.basename(r).lower())
                    or 'seed-mat-metal-rail' in _sem_comentarios(r)]  # `CP-P16`
    tem_classe = [r for r in classes_rail if 'seed-mat-metal-rail' in _sem_comentarios(r)]
    if not classes_rail:
        notas.append('rail: nenhum arquivo do censo parece ser o rail — MA2 NÃO MEDIDO neste alvo')
    elif tem_classe:
        notas.append('rail: `.seed-mat-metal-rail` em %s (MA2)' % ', '.join(tem_classe[:3]))
    elif ligado:
        falhas.append(Achado(classes_rail[0], 0, 'o rail existe e a camada está ligada, mas ele não usa '
                             '`.seed-mat-metal-rail` (MA2, registry `@seed/material`)', 'material-a-mao'))
    else:
        avisos.append(Achado(classes_rail[0], 0, 'o rail existe e não usa `.seed-mat-metal-rail` (MA2) — '
                             'a camada nem está ligada neste app', 'material-ausente'))
    return falhas, avisos, notas


def _sem_comentarios_para_regioes(texto):
    """`CP-P13` (2026-09-20, origem: achado fora do escopo da `CP-P12`) — tira os comentários
    `<!-- ... -->` do texto ANTES de o CP-13 ESTÁTICO procurar as três regiões do shell nele. Sem
    isto, um comentário que só EXPLICA uma mutação (a nota de proveniência que `deriva-cobaias.py`
    grava na linha 2 de cada cobaia) conta como código de verdade: medido,
    `06-validacao/provas/tela-html/shell-sem-rail-cobaia.html` tem o `<nav class="rail">` removido
    do corpo e mesmo assim o CP-13 estático dava PASS, "região `rail` presente no código" — porque
    o comentário de proveniência cita literalmente `<nav class="rail">` para dizer o que a mutação
    tirou. Presença aceita por engano é o MESMO defeito que a `CP-P12` corrigiu para o sinal do
    `aria-label`, agora pela via do comentário em vez da via do nome do landmark compartilhado.

    Substitui cada comentário por espaços (preservando toda quebra de linha e cada coluna, para o
    `arquivo:linha` de outros achados continuar verdadeiro) e devolve `(texto_limpo, quantos)`.

    NÃO toca em `<script>`/`<style>`: ali `<!--` pode ser conteúdo de verdade (o truque antigo de
    esconder script de navegador sem suporte, ou só uma substring dentro de uma string/comentário
    de CSS ou JS) — e nenhuma das três regiões do shell (`<nav>`, `<header>`, `<main>`) vive dentro
    de um `<script>` ou `<style>`, então proteger esses blocos não perde achado nenhum, só evita
    corromper conteúdo que não é HTML. A implementação primeiro marca os intervalos de
    `<script>…</script>` e `<style>…</style>` (não-gulosos, sem diferenciar caixa) e só remove um
    `<!--…-->` cujo início caia FORA de todos eles."""
    protegidos = [(m.start(), m.end()) for m in
                  re.finditer(r'(?is)<(script|style)\b[^>]*>.*?</\1\s*>', texto)]

    def _protegido(pos):
        return any(ini <= pos < fim for ini, fim in protegidos)

    saida, i, n, removidos = [], 0, len(texto), 0
    while i < n:
        if texto.startswith('<!--', i) and not _protegido(i):
            fim = texto.find('-->', i + 4)
            fim = n if fim < 0 else fim + 3
            saida.append(''.join('\n' if c == '\n' else ' ' for c in texto[i:fim]))
            removidos += 1
            i = fim
        else:
            saida.append(texto[i])
            i += 1
    return ''.join(saida), removidos


def _regiao_rail_presente(texto):
    """Evidência da região `rail` (SH2/SH3, §46) no código-fonte de UM arquivo, para o CP-13
    ESTÁTICO. Três sinais fecham sozinhos, sem ambiguidade: a classe carrega a palavra `rail`
    como TOKEN inteiro (`class="rail"`, `className="... bg-seed-rail ..."` — nunca dentro de
    outra palavra, como em "trail"), o componente `<Rail` (nome próprio, PascalCase) ou um
    import `/rail` (arquivo `Rail.tsx`/`rail.ts`).

    O quarto sinal do enunciado original, `aria-label="Áreas"` sozinho, deixa de fechar sozinho —
    achado da CP-P12 (2026-09-20), medido na cópia real do SEED Chat. **Errata ao enunciado da
    pendência, medida:** não é o `\\brail\\b` que casava com a barra de abas do celular
    (`MobileTabBar.tsx`) — medido: zero ocorrências da palavra "rail", maiúscula ou minúscula, no
    arquivo inteiro. Quem casava era esta quarta alternativa: o `<nav>` do rail (SH2) e o `<nav>`
    da barra de abas do celular (V-CHAT-18) usam o MESMO nome de landmark, "Áreas", porque as duas
    são a mesma área de navegação em duas larguras — o SH2 não distingue a forma, só nomeia o
    destino. Por isso o aria-label só conta como rail quando o MESMO `<nav ...>` não carrega,
    junto, a forma textual da barra de abas que o SH12 manda para tela estreita: fixa na base do
    viewport e só abaixo do ponto de corte — `bottom-0` combinado com `fixed` ou `md:hidden`.
    O que isto NÃO cobre: um rail de verdade que por outro motivo usasse essas mesmas classes
    (nenhum caso no acervo nem no SEED Chat, medido) sairia como AUSÊNCIA falsa — o oposto do
    defeito original (presença aceita por engano), e mais seguro para o SH1 (rail é obrigatório;
    ausência falsa reprova, presença falsa passava batido)."""
    if re.search(r'(?i)(class(Name)?\s*=\s*["\'][^"\']*\brail\b|<Rail\b|/rail)', texto):
        return True
    for m in re.finditer(r'(?i)<nav\b[^>]*aria-label\s*=\s*["\']Áreas["\'][^>]*>', texto):
        tag = m.group(0)
        eh_barra_de_abas = re.search(r'\bbottom-0\b', tag) and re.search(r'\b(?:fixed|md:hidden)\b', tag)
        if not eh_barra_de_abas:
            return True
    return False


def varre_shell_estatico(raiz, analisados, html_raiz, arquetipo_cli):
    """CP-13 ESTÁTICO. A régua NÃO adivinha o arquétipo (regra da casa: régua que
    adivinha o tipo erra no primeiro caso misto). Ela procura o `data-arquetipo`
    do SH18 — no HTML da raiz ou escrito por código — e, só quando ele diz
    `aplicacao` (ou `--arquetipo aplicacao` o declara), cobra as três regiões do
    §46 no CÓDIGO. Presença no código não é geometria: a largura de 80/56 px, a
    altura de 60 px e a ordem das regiões só o DOM responde, e para isso é o
    `--url`. Aqui o que se mede é ausência, que é o defeito que o SIGA teve.

    Desde 2026-09-20 (pendência `CP-P9`) a **topbar não reprova por ausência**,
    aqui como no caminho do DOM: o SH1 (§46.1) só exige conteúdo + ao menos uma
    navegação, e o SH21 (§46.6, v1.67) legisla para o produto SEM topbar. Foi
    exatamente este ponto que reprovou a cobaia real do SEED Chat (MANIFESTO
    §236.6): a ficha dela saiu FAIL no CP-13 com UM achado, "região `topbar`
    AUSENTE no código", num app que por desenho não tem topbar. Agora:
      · topbar ausente COM rail presente → **AVISO**, dizendo o que a leitura
        estática não alcança (se o avatar está mesmo na base do rail, SH21, e se
        há um `h1` no conteúdo, SH10 — as duas coisas são DOM: `--url`);
      · topbar ausente E rail ausente → **FAIL**, porque aí não sobra navegação
        nenhuma e o SH1 é violado;
      · conteúdo ausente → FAIL, como sempre (SH1).
    Devolve (achados, avisos, notas, arquétipo efetivo)."""
    achados, avisos, notas = [], [], []
    declarado, onde = None, None
    for rel, conteudo in html_raiz:
        m = re.search(r'data-arquetipo\s*=\s*["\']([^"\']*)["\']', conteudo)
        if m:
            declarado, onde = m.group(1), rel
            break
    if declarado is None:
        # `CP-P15` (2026-09-20) — o mesmo comentário-vira-presença da CP-P13 (nav do shell) e da
        # CP-14 (data-material) acima: um JSDoc citando `data-arquetipo="aplicacao"` contava como
        # arquétipo DECLARADO, porque a busca era no texto CRU. `tira_comentarios(texto, ext)`
        # preserva quebra de linha e coluna (mesmo contrato do conserto do CP-14), então o
        # `arquivo:linha` de `onde` continua verdadeiro. Cobaia em
        # `06-validacao/provas/tela-html/cp15-comentario-vira-presenca/`.
        for rel in analisados:
            ext = os.path.splitext(rel)[1].lower()
            if ext not in ('.ts', '.tsx', '.js', '.jsx'):
                continue
            texto = tira_comentarios(_le(raiz / rel), ext)
            if 'data-arquetipo' not in texto:
                continue
            n = texto[:texto.index('data-arquetipo')].count('\n') + 1
            valores = sorted(set(re.findall(r'["\'](aplicacao|autenticacao|pagina|bancada|documento)["\']', texto)))
            if valores:
                declarado, onde = '/'.join(valores), '%s:%d (escrito por código)' % (rel, n)
                break
    if arquetipo_cli:
        notas.append('arquétipo por --arquetipo: %s%s' % (arquetipo_cli,
                     (' (o alvo declara %s em %s)' % (declarado, onde)) if declarado else ''))
        efetivo = arquetipo_cli
    elif declarado:
        notas.append('arquétipo declarado pelo alvo: `%s` em %s (SH18, §46.5)' % (declarado, onde))
        efetivo = declarado
    else:
        achados.append(Achado('(HTML da raiz)', 0, 'nenhum `data-arquetipo` no alvo — o SH18 manda toda tela '
                              'declarar o que ela é', 'arquetipo-ausente'))
        efetivo = None
    if efetivo is None or 'aplicacao' not in efetivo:
        notas.append('regiões do §46 NÃO cobradas: o arquétipo não é (ou não se sabe se é) `aplicacao` (SH17)')
        return achados, avisos, notas, efetivo
    regioes = {
        # CP-P12 (2026-09-20): o sinal do rail deixou de ser um regex só — `aria-label="Áreas"`
        # sozinho casa também com a barra de abas do celular (a mesma landmark, duas larguras).
        # `_regiao_rail_presente` decide por FORMA (ver a docstring dela); `padrao` aqui é
        # chamável, não uma string, e o laço abaixo trata os dois casos.
        'rail': (_regiao_rail_presente, 'SH2/SH3 — a coluna de navegação à esquerda'),
        'topbar': (r'(?i)(class(Name)?\s*=\s*["\'][^"\']*\btopbar\b|<Topbar\b|<TopBar\b|/topbar)',
                   'SH10 — a barra superior de 60 px'),
        'conteudo': (r'(?i)(<main\b|<Main\b|role\s*=\s*["\']main["\'])',
                     'SH1/SH7 — a região de conteúdo'),
    }
    # CP-P13 (2026-09-20): lê cada arquivo UMA vez e tira os comentários ANTES de qualquer região
    # procurar nele — um comentário de proveniência que cita `<nav class="rail">` para dizer o que
    # foi removido não pode contar como o `<nav>` estar presente (ver `_sem_comentarios_para_regioes`).
    textos_limpos, comentarios_removidos = [], 0
    for rel in analisados:
        ext = os.path.splitext(rel)[1].lower()
        if ext not in ('.tsx', '.jsx', '.ts', '.js', '.html', '.htm', '.vue', '.svelte'):
            continue
        limpo, n = _sem_comentarios_para_regioes(_le(raiz / rel))
        textos_limpos.append((rel, limpo))
        comentarios_removidos += n
    # Duas passadas de propósito: a decisão sobre a topbar depende de o RAIL existir
    # ou não, e depender da ordem de iteração de um dicionário é a constante que
    # envelhece — mede-se tudo primeiro, decide-se depois.
    onde_por_regiao = {}
    for nome, (padrao, motivo) in regioes.items():
        onde_achou = []
        for rel, texto in textos_limpos:
            casou = padrao(texto) if callable(padrao) else re.search(padrao, texto)
            if casou:
                onde_achou.append(rel)
        onde_por_regiao[nome] = onde_achou
    if comentarios_removidos:
        notas.append('%d comentário(s) HTML removido(s) antes de procurar as regiões do shell '
                     '(CP-P13) — um comentário citando a classe de uma região não conta mais como '
                     'presença' % comentarios_removidos)
    for nome, (padrao, motivo) in regioes.items():
        onde_achou = onde_por_regiao[nome]
        if onde_achou:
            notas.append('região `%s` presente no código: %s (%s)' % (nome, ', '.join(onde_achou[:3]), motivo))
        elif nome == 'topbar':
            # CP-P9 (2026-09-20): ausência de topbar não é defeito — ver a docstring.
            if onde_por_regiao['rail']:
                avisos.append(Achado('(código do alvo)', 0,
                                     'região `topbar` não aparece no código — e isso NÃO reprova: o SH1 exige '
                                     'conteúdo + ao menos uma navegação (a topbar não está entre as '
                                     'obrigatórias) e o SH21, §46.6, legisla o produto SEM topbar. O que a '
                                     'leitura estática não alcança, e por isso é AVISO e não PASS: se o avatar '
                                     'do menu de conta está mesmo na BASE DO RAIL (SH21/V-SH16.2) e se há UM '
                                     '`h1` dentro do `<main>` (SH10) — as duas são do DOM: sirva o app e repita '
                                     'com `--url <origem>`', 'topbar-ausente-sem-prova'))
            else:
                notas.append('região `topbar` também não aparece no código — sem rail e sem topbar não sobra '
                             'navegação nenhuma, e a linha do `rail` acima já é a reprovação (SH1)')
        else:
            achados.append(Achado('(código do alvo)', 0, 'região `%s` AUSENTE no código (%s)' % (nome, motivo),
                                  'shell-ausente'))
    return achados, avisos, notas, efetivo


def mede_url(url, chrome):
    """Reconstitui, num arquivo temporário, o DOM que o navegador montou na
    origem servida, com uma `<base href>` para as referências relativas
    continuarem resolvendo contra ela — e devolve o caminho desse arquivo, que
    passa pelo MESMO `confere_html` de sempre.

    Por que não medir a origem direto: a sonda do CP-13 lê a peça dentro de um
    `<iframe>` e uma página `file://` não consegue ler o documento de um iframe
    `http(s)://` (origem diferente — o navegador bloqueia, e a régua sairia NÃO
    MEDI sem saber por quê). E o CP-4 precisa de DUAS capturas, uma com o texto
    apagado por CSS, que o Chrome de linha de comando não injeta numa URL.
    Devolve (caminho, None) ou (None, motivo) — motivo vira NÃO MEDI.

    CP-P24 (2026-09-27): a pasta `cp-url-*` desta chamada não fica mais no
    temporário (22 no `%TEMP%` às 18:45Z de 2026-09-27). Quando a medição
    falha, ela sai aqui mesmo, no `finally`; quando dá certo, o arquivo
    devolvido VIVE nela, então quem o recebe apaga a pasta
    (`pathlib.Path(caminho).parent`, o caminho exato do `mkdtemp`) depois de
    medir — é o que o `confere_alvo` faz."""
    import tempfile
    pasta = pathlib.Path(tempfile.mkdtemp(prefix='cp-url-'))
    entregue = False
    try:
        caminho, motivo = _mede_url_na_pasta(pasta, url, chrome)
        entregue = caminho is not None
        return caminho, motivo
    finally:
        if not entregue:
            _apaga_pasta_da_chamada(pasta)


def _mede_url_na_pasta(pasta, url, chrome):
    cmd = [chrome, '--headless=new', CHROME_TEMA_CLARO, '--disable-gpu', '--hide-scrollbars',
           '--user-data-dir=%s' % (pasta / 'perfil'),
           '--window-size=%d,%d' % (DOM_LARGURA, DOM_ALTURA),
           '--virtual-time-budget=10000', '--dump-dom', url]
    r = _roda_chrome(cmd, TIMEOUT_CHROME_URL_S, temp=pasta)   # CP-P19: prazo que vale no Windows · CP-P24
    if r is None:
        return None, ('o Chrome não terminou de carregar %s em %d s (recurso que nunca termina — um websocket de '
                      'servidor de desenvolvimento, por exemplo, segura o `--dump-dom`); a árvore do processo foi '
                      'encerrada' % (url, TIMEOUT_CHROME_URL_S))
    dom = r[1].decode('utf-8', 'replace')
    if '<html' not in dom.lower():
        return None, 'a origem %s não devolveu HTML (o Chrome imprimiu %d bytes)' % (url, len(dom))
    base = '<base href="%s">' % url
    baixo = dom.lower()
    if '<head>' in baixo:
        i = baixo.index('<head>') + len('<head>')
        dom = dom[:i] + base + dom[i:]
    else:
        dom = base + dom
    destino = pasta / 'origem.html'
    io.open(destino, 'w', encoding='utf-8', newline='').write(dom)
    return str(destino), None


def confere_alvo(raiz, cores_tok, escala_px, empresa, url=None, arquetipo=None):
    """O modo `--alvo`. Grava `FICHA-ADEQUACAO.md` no diretório corrente (contrato
    3 do §234.6) e devolve a Ficha para o placar sair no stdout como sempre."""
    raiz = pathlib.Path(raiz).resolve()
    f = Ficha('%s (repositório consumidor)' % raiz.name)
    caminhos, origem = censo_do_alvo(raiz)
    analisados, ignorados, detalhe_ignorado, do_registry = classifica_censo(raiz, caminhos)
    alcancaveis, entradas, motivo_alcance = alcance_do_alvo(raiz, caminhos)
    superficie = [r for r in analisados if r in alcancaveis]
    acervo = [r for r in analisados if r not in alcancaveis]
    reg = registry_publicado()

    html_raiz = []
    for rel in superficie:
        if os.path.splitext(rel)[1].lower() in ('.html', '.htm'):
            html_raiz.append((rel, _le(raiz / rel)))
    html_raiz.sort(key=lambda x: (x[0].count('/'), len(x[0])))

    # ── CP-1 e CP-2 ─────────────────────────────────────────────────────────
    raizes_fonte = raizes_de_fonte(raiz, entradas)
    orfaos = [r for r in acervo if r.split('/')[0] in raizes_fonte]
    externos = [r for r in acervo if r.split('/')[0] not in raizes_fonte]
    fora, avisos_cor, fontes, ocorrencias = varre_cor_e_fonte(raiz, superficie, cores_tok)
    fora_orfao, _av_orf, fontes_orfao, ocorr_orfao = varre_cor_e_fonte(raiz, orfaos, cores_tok)
    fora_ext, _av_ext, fontes_ext, ocorr_ext = varre_cor_e_fonte(raiz, externos, cores_tok)
    fora_acervo = fora_orfao + fora_ext
    ocorr_acervo = ocorr_orfao + ocorr_ext
    fontes_acervo = fontes_orfao + fontes_ext
    # Órfão dentro da pasta de FONTE é achado: ou é código morto (o `App.css` que
    # o Vite deixa no molde, medido na cobaia — ninguém o importa e ele carrega
    # três cores de fora do DS), ou é um import que a régua não seguiu. Vira
    # AVISO com o nome do arquivo; nunca FAIL, porque o instrumento não sabe qual
    # dos dois é — a decisão é de quem publica (o quarto estado da casa).
    for a in fora_orfao:
        avisos_cor.append(Achado(a.arquivo, a.linha, a.achado + ' — em arquivo da pasta de FONTE que NENHUM '
                                 'import alcança (código morto? import que a régua não seguiu?)',
                                 'cor-fora-do-token', a.trecho))
    if fora:
        estado_cor = False
    elif avisos_cor:
        estado_cor = 'aviso'
    else:
        estado_cor = True
    linha_acervo = ('fora do alcance do ponto de entrada: %d arquivo(s) de FONTE que ninguém importa '
                    '(%d ocorrência(s), %d fora do token — listados acima como aviso) e %d arquivo(s) de '
                    'documentação, evidência, ferramenta ou servidor (%d ocorrência(s), %d fora do token) '
                    '— estes últimos MEDIDOS e NÃO reprovam: folha de gate é evidência, e evidência não se '
                    'reescreve' % (len(orfaos), ocorr_orfao, len(fora_orfao),
                                   len(externos), ocorr_ext, len(fora_ext)))
    f.item('CP-1', estado_cor,
           'cor (estática, no código): %d ocorrência(s) de cor lidas nos %d arquivo(s) da SUPERFÍCIE do '
           'produto (os que o ponto de entrada alcança); %d fora do seed-tokens.json, %d transcrita(s)/'
           'desconhecida(s)' % (ocorrencias, len(superficie), len(fora), len(avisos_cor)),
           [str(a) for a in fora] + [str(a) for a in avisos_cor] + [linha_acervo])
    f.item('CP-2', not fontes,
           'tipografia (estática): %d declaração(ões) de família fora de Montserrat · JetBrains Mono · Caveat '
           'na superfície do produto (as famílias chegam pelo item `@seed/fontes` e pelo `@theme` do '
           '`@seed/theme`); %d no acervo fora do alcance, que não reprova' % (len(fontes), len(fontes_acervo)),
           [str(a) for a in fontes])

    # ── CP-13 estático ──────────────────────────────────────────────────────
    achados_shell, avisos_shell, notas_shell, arq_efetivo = varre_shell_estatico(
        raiz, superficie, html_raiz, arquetipo)
    estado_shell = False if achados_shell else ('aviso' if avisos_shell else True)
    f.item('CP-13', estado_shell,
           'shell §46 (ESTÁTICO — presença no código, nunca geometria): %d achado(s) e %d aviso(s). '
           'A geometria (rail 80/56 px, topbar 60 px, ordem das regiões) só o DOM responde: use --url'
           % (len(achados_shell), len(avisos_shell)),
           [str(a) for a in achados_shell] + [str(a) for a in avisos_shell] + notas_shell)

    # ── CP-14 material ──────────────────────────────────────────────────────
    falhas_mat, avisos_mat, notas_mat = varre_material(raiz, superficie, do_registry, html_raiz)
    estado_mat = False if falhas_mat else ('aviso' if avisos_mat else True)
    f.item('CP-14', estado_mat,
           'material §93 (vidro e metal): %d achado(s) e %d aviso(s). A camada é OPCIONAL pelo registry '
           '(MA1: sem o atributo nada muda um pixel); o que reprova é incoerência — camada ligada sem a classe '
           'no rail (MA2) ou vidro/metal escrito à mão fora do módulo (MA8)' % (len(falhas_mat), len(avisos_mat)),
           [str(a) for a in falhas_mat] + [str(a) for a in avisos_mat] + notas_mat)

    # ── CP-15 registry ──────────────────────────────────────────────────────
    notas_reg, falhas_reg, avisos_reg = varre_registry(raiz, superficie, do_registry, analisados)
    estado_reg = False if falhas_reg else ('aviso' if avisos_reg else True)
    f.item('CP-15', estado_reg,
           'registry do DS: %d de %d item(ns) `registry:file` instalado(s) e provado(s) por MD5%s; '
           '%d achado(s), %d aviso(s). Origem do MD5: %s'
           % (len(do_registry), len([i for i in reg['itens'].values() if i.get('arquivo')]),
              (' (%d como variante local: só o prefixo das url() difere)' % len(_VARIANTES_LOCAIS)) if _VARIANTES_LOCAIS else '',
              len(falhas_reg), len(avisos_reg), reg['origem_md5'] or 'NÃO ENCONTRADA'),
           [str(a) for a in falhas_reg] + [str(a) for a in avisos_reg] + notas_reg)

    # ── CP-9/CP-9b segredo e identificador no repositório (CP-P10, 2026-09-20) ─
    # Nasce aqui porque a cobaia da fase 2 do aceite da F12 mostrou o `--alvo`
    # calado diante de um `.env` publicável — ver o comentário de
    # `varre_segredo_alvo` para a origem completa e o porquê dos padrões.
    falhas_seg, avisos_seg, notas_seg, denom_seg = varre_segredo_alvo(
        raiz, caminhos, eh_git=(origem or '').startswith('git ls-files'))
    f.item('CP-9', not falhas_seg,
           'segredo no repositório do app: %d credencial/chave encontrada. Varredura: %s'
           % (len(falhas_seg), denom_seg), [str(a) for a in falhas_seg])
    f.item('CP-9b', 'aviso' if avisos_seg else True,
           'identificador de plataforma/registro no repositório do app: %d achado(s). Regra do CLAUDE.md do '
           'projeto (veredito de 2026-09-08): identificador NOMEIA e não dá acesso — pode ficar registrado; '
           'credencial DÁ ACESSO e nunca entra em repositório. O script acha o valor e não sabe o destino '
           '(publicado? só local?) — quem publica decide' % len(avisos_seg),
           [str(a) for a in avisos_seg] + notas_seg)

    # ── o que só o navegador sabe ───────────────────────────────────────────
    ficha_url = None
    if url:
        chrome = _chrome()
        if not chrome:
            f.item('CP-4', None, 'contraste/DOM: --url pedido mas o Chrome não foi encontrado — defina %CHROME_BIN%', sem_medida=True)
        else:
            arquivo, motivo = mede_url(url, chrome)
            if motivo:
                f.item('CP-4', None, 'contraste/DOM: %s' % motivo, sem_medida=True)
            else:
                try:
                    ficha_url = confere_html(arquivo, cores_tok, escala_px, empresa, arquetipo=arquetipo,
                                             origem_servida=True)
                    ficha_url.alvo = '%s (origem servida, DOM reconstituído com <base>)' % url
                finally:   # CP-P24: a pasta `cp-url-*` que ESTA chamada ao `mede_url` criou, pelo caminho exato
                    _apaga_pasta_da_chamada(pathlib.Path(arquivo).parent)
    else:
        for cod, o_que in (('CP-3', 'tamanho de texto'), ('CP-4', 'contraste no pixel'),
                           ('CP-5', 'marca por sobreposição'), ('CP-10', 'camada de máquina do DOM')):
            # CP-P64 (leva 48): o `--alvo` sem `--url` mede só o código; estes quatro itens o instrumento DEVIA medir e não mediu: saída 2
            f.item(cod, None, '%s: NÃO MEDI — depende do que o navegador pintou. Sirva o app e repita com '
                              '`--url <origem>`' % o_que, sem_medida=True)

    alcance = {'superficie': superficie, 'acervo': acervo, 'entradas': entradas,
               'motivo': motivo_alcance, 'fora_acervo': fora_acervo, 'ocorr_acervo': ocorr_acervo,
               'orfaos': orfaos, 'externos': externos, 'raizes_fonte': sorted(raizes_fonte),
               'fora_orfao': fora_orfao, 'fora_ext': fora_ext,
               'ocorr_orfao': ocorr_orfao, 'ocorr_ext': ocorr_ext}
    relatorio = escreve_ficha_adequacao(raiz, f, ficha_url, url, caminhos, analisados, ignorados,
                                        detalhe_ignorado, do_registry, origem,
                                        fora + avisos_cor + fontes + achados_shell + falhas_mat
                                        + avisos_mat + falhas_reg + avisos_reg + falhas_seg + avisos_seg,
                                        notas_shell + notas_mat + notas_reg + notas_seg, arq_efetivo, ocorrencias,
                                        alcance)
    print('ficha gravada em: %s' % relatorio)
    return f, ficha_url


def escreve_ficha_adequacao(raiz, f, ficha_url, url, caminhos, analisados, ignorados,
                            detalhe_ignorado, do_registry, origem, achados, notas,
                            arquetipo, ocorrencias, alcance):
    """A FICHA-ADEQUACAO.md, no diretório corrente (contrato 3 do §234.6).
    Cabeçalho com denominador · tabela por item · inventário `arquivo:linha ·
    achado · classe · seção do DS que responde` · e a última linha dizendo o que
    a ficha NÃO mede. Placar sem alcance se lê como "está tudo certo" (regra da
    casa, DS 2026-09-08)."""
    import datetime
    agora = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
    p = []
    p.append('# FICHA DE ADEQUAÇÃO — ' + raiz.name)
    p.append('')
    p.append('> Medida por `conferir-peca.py --alvo` em ' + agora + '. **Alvo:** `' + str(raiz) + '`.')
    p.append('> Esta ficha responde a UMA pergunta: *o que, neste app, ainda não segue o SEED Design System?*')
    p.append('> Ela é o PRIMEIRO passo de uma adequação — a ficha do estado inicial é o inventário — e o')
    p.append('> último, como prova do conserto. Siglas: **DS** = design system da SEED; **token** = valor de')
    p.append('> design nomeado (cor, espaço, raio); **registry** = o pacote instalável que leva os tokens ao')
    p.append('> produto; **§NN** = seção do canônico `01-canonicos/seed-componentes.md`.')
    p.append('')
    p.append('## Censo — o denominador')
    p.append('')
    p.append('- **' + str(len(caminhos)) + ' arquivos** no censo. Origem: ' + (origem or '—') + '.')
    p.append('- **' + str(len(analisados)) + ' analisados** por tipo (' + ', '.join(sorted(set(ALVO_TIPOS.values()))) + ').')
    p.append('- **' + str(len(do_registry)) + ' são itens do registry do DS**, provados por MD5 contra o publicado, e')
    p.append('  por isso ficam FORA do CP-1 e do CP-2: eles são o próprio DS (o `seed-tokens.css` tem centenas de')
    p.append('  hexadecimais — são os tokens).')
    for rel in sorted(do_registry):
        p.append('  - `' + rel + '` → ' + do_registry[rel][0])
    total_ignorado = sum(ignorados.values())
    p.append('- **' + str(total_ignorado) + ' ignorados por forma**, com o motivo:')
    for motivo, n in ignorados.most_common():
        p.append('  - ' + str(n) + ' × ' + motivo)
    p.append('')
    p.append('### Superfície do produto × acervo do repositório')
    p.append('')
    p.append('A régua só REPROVA o que o produto carrega. A fronteira não é uma lista de pastas (lista à mão')
    p.append('envelhece): é o **alcance a partir do ponto de entrada** — ' + alcance['motivo'] + '.')
    p.append('')
    p.append('- **' + str(len(alcance['superficie'])) + ' arquivos na superfície do produto** — é sobre eles que')
    p.append('  CP-1, CP-2, CP-13, CP-14 e CP-15 reprovam. **CP-9 e CP-9b (segredo/identificador, CP-P10) medem')
    p.append('  todo o censo rastreado mais todo `.env*` no disco — não só a superfície alcançada**, porque um')
    p.append('  segredo que ninguém importa ainda pode ser publicado por engano (cópia de pasta, deploy, zip).')
    p.append('- **' + str(len(alcance['orfaos'])) + ' arquivos dentro da pasta de FONTE ('
             + ', '.join('`' + r + '`' for r in alcance['raizes_fonte']) + ') que NENHUM import alcança**:')
    p.append('  ' + str(alcance['ocorr_orfao']) + ' ocorrências de cor, **' + str(len(alcance['fora_orfao']))
             + ' fora do token**. Saem como AVISO na tabela: ou é código morto, ou é um import que a régua')
    p.append('  não seguiu — o instrumento não sabe qual, e quem publica decide.')
    for r in sorted(alcance['orfaos'])[:20]:
        p.append('  - `' + r + '`')
    if len(alcance['orfaos']) > 20:
        p.append('  - … mais ' + str(len(alcance['orfaos']) - 20))
    p.append('- **' + str(len(alcance['externos'])) + ' arquivos fora da pasta de fonte** (documentação, folha')
    p.append('  de gate, ferramenta, função de servidor): ' + str(alcance['ocorr_ext']) + ' ocorrências de cor,')
    p.append('  **' + str(len(alcance['fora_ext'])) + ' fora do token**. Medido e declarado; **não reprova**,')
    p.append('  porque folha de gate é evidência de uma decisão e evidência não se reescreve.')
    p.append('- Ponto(s) de entrada: ' + (', '.join('`' + e + '`' for e in alcance['entradas']) or '—') + '.')
    p.append('')
    p.append('- **' + str(ocorrencias) + ' ocorrências de cor** lidas na superfície (hexadecimal, função de cor,')
    p.append('  classe de paleta e `var()`), já descontados os comentários — comentário que EXPLICA um valor')
    p.append('  não é o valor.')
    p.append('')
    p.append('## Placar por item')
    p.append('')
    p.append('| estado | item | o que foi medido |')
    p.append('|---|---|---|')
    for estado, cod, resumo, _det in f.linhas:
        p.append('| **' + estado + '** | ' + cod + ' | ' + resumo.replace('|', '\\|') + ' |')
    p.append('')
    p.append('**Placar: ' + str(f.pass_) + ' PASS · ' + str(f.fail) + ' FAIL · ' + str(f.aviso)
             + ' AVISO · ' + str(f.nao_medi) + ' NÃO MEDI.**')
    p.append('')
    if ficha_url is not None:
        p.append('## Origem servida — ' + str(url))
        p.append('')
        p.append('O DOM que o navegador montou nessa origem foi reconstituído num arquivo temporário (com')
        p.append('`<base href>` para as referências relativas continuarem resolvendo) e medido pelo MESMO caminho')
        p.append('de uma peça HTML: CP-1 a CP-4 na tela a 1440×900 e CP-5 em diante no PDF impresso.')
        p.append('')
        p.append('| estado | item | o que foi medido |')
        p.append('|---|---|---|')
        for estado, cod, resumo, _det in ficha_url.linhas:
            p.append('| **' + estado + '** | ' + cod + ' | ' + resumo.replace('|', '\\|') + ' |')
        p.append('')
        p.append('**Placar da origem servida: ' + str(ficha_url.pass_) + ' PASS · ' + str(ficha_url.fail)
                 + ' FAIL · ' + str(ficha_url.aviso) + ' AVISO · ' + str(ficha_url.nao_medi) + ' NÃO MEDI.**')
        p.append('')
    p.append('## Inventário — o que consertar, onde, e qual seção responde')
    p.append('')
    if not achados:
        p.append('Nenhum achado. (Leia a última seção antes de concluir que o app está adequado.)')
    else:
        p.append('| arquivo:linha | achado | classe | seção do DS que responde | por que essa seção |')
        p.append('|---|---|---|---|---|')
        por_classe = collections.defaultdict(list)
        for a in achados:
            por_classe[a.classe].append(a)
        for classe in sorted(por_classe, key=lambda c: -len(por_classe[c])):
            secao, motivo = ALVO_SECAO_POR_CLASSE.get(classe, ('—', ''))
            for a in por_classe[classe]:
                onde = a.arquivo + (':' + str(a.linha) if a.linha else '')
                p.append('| `' + onde + '` | ' + a.achado.replace('|', '\\|') + ' | ' + classe
                         + ' | ' + secao + ' | ' + motivo + ' |')
        p.append('')
        p.append('Contagem por classe: ' + ' · '.join(
            c + ' ' + str(len(por_classe[c])) for c in sorted(por_classe, key=lambda c: -len(por_classe[c]))) + '.')
    p.append('')
    if notas:
        p.append('## Notas da medição (o que a régua LEU e considerou certo)')
        p.append('')
        for n in notas:
            p.append('- ' + str(n))
        p.append('')
    if arquetipo:
        p.append('Arquétipo efetivo deste alvo (SH18, §46.5): **' + str(arquetipo) + '**.')
        p.append('')
    p.append('## Peças do app e a seção que as governa')
    p.append('')
    p.append('| pasta do app | arquivos | seção do DS |')
    p.append('|---|---|---|')
    grupos = collections.Counter()
    secoes = {}
    for rel in alcance['superficie']:
        pasta = os.path.dirname(rel) or '(raiz)'
        grupos[pasta] += 1
        secoes[pasta] = secao_da_peca(rel if secao_da_peca(rel) != '—' else pasta)
    for pasta, n in grupos.most_common(30):
        p.append('| `' + pasta + '` | ' + str(n) + ' | ' + secoes.get(pasta, '—') + ' |')
    if len(grupos) > 30:
        p.append('| … mais ' + str(len(grupos) - 30) + ' pastas | | |')
    p.append('')
    p.append('## O que esta ficha NÃO mede')
    p.append('')
    p.append('Ficha limpa não é app certo — a régua diz o próprio alcance (regra da casa).')
    p.append('')
    p.append('- **Hierarquia e composição:** se a tela usa o gabarito certo, se a informação está na zona certa,')
    p.append('  se a densidade é a do §26/§28. Isso é o gate visual e a `suite-composicao`.')
    p.append('- **Texto e microcópia** (§89): rótulo de botão, mensagem de erro, vazio, confirmação.')
    p.append('- **Comportamento:** foco, teclado, estados de interação (hover, pressionado), rota, offline (§90).')
    p.append('- **Geometria**, quando não há `--url`: largura do rail, altura da topbar, ordem das regiões,')
    p.append('  contraste no pixel, tamanho de texto renderizado. Presença no código não é geometria.')
    p.append('- **Tema escuro:** o DOM é sondado no tema em que a página abre.')
    p.append('- **Conteúdo do canon que o app precisa e o DS não cobre.** A régua não sabe o que falta no DS;')
    p.append('  quem acha uma peça sem seção abre o **pedido de cobertura** (`scripts/enviar-pedido.py`).')
    p.append('- **Arquivo de tipo fora da tabela da régua** (ver o censo acima, com a contagem e o motivo).')
    p.append('- **O acervo fora do alcance do ponto de entrada** é medido mas não reprova, e o alcance é um')
    p.append('  grafo de `import`/`@import`: importação DINÂMICA por variável, plugin de build e alias que não')
    p.append('  esteja no `tsconfig.paths` não são seguidos — um arquivo de produto pode cair no acervo por isso.')
    p.append('- **Literal de expressão regular em JavaScript** pode confundir o removedor de comentários; o')
    p.append('  efeito possível é a régua deixar de ver um trecho, nunca inventar um achado.')
    p.append('')
    destino = pathlib.Path(os.getcwd()) / 'FICHA-ADEQUACAO.md'
    io.open(destino, 'w', encoding='utf-8', newline='\n').write('\n'.join(p) + '\n')
    return destino


def main():
    ap = argparse.ArgumentParser(description='Mede uma peça pronta contra o SEED Design System (contrato CP).')
    ap.add_argument('arquivos', nargs='*')
    ap.add_argument('--alvo', default=None,
                    help='pasta de um REPOSITÓRIO consumidor (o app). Mede o código por análise estática '
                         '(CP-1 cor · CP-2 tipografia · CP-9/CP-9b segredo e identificador (rastreado + todo '
                         '.env* no disco) · CP-13 shell no código · CP-14 material §93 · CP-15 registry) e '
                         'grava FICHA-ADEQUACAO.md no diretório corrente.')
    ap.add_argument('--url', default=None,
                    help='(com --alvo) a origem em que o app está servido, por exemplo http://127.0.0.1:5173. '
                         'O DOM daquela origem é medido pelo mesmo caminho de uma peça HTML — é o que dá '
                         'CP-4 (contraste no pixel) e o CP-13 de verdade (geometria). Sem ela, esses itens '
                         'saem NÃO MEDI.')
    ap.add_argument('--arquetipo', choices=ARQUETIPOS, default=None,
                    help='(HTML) sobrescrita explícita do arquétipo: `aplicacao` exige o shell §46 (CP-13); '
                         '`autenticacao`, `pagina`, `bancada` e `documento` dispensam (SH17). Normalmente não é '
                         'preciso: a tela declara `data-arquetipo` no <html> (SH18, §46.5) e a régua lê; sem '
                         'declaração e sem raiz de consulta decisiva, sai NÃO MEDI.')
    ap.add_argument('--alvo-tela', default=None, metavar='LxApx',
                    help='(PDF, SVG ou HTML) declara a peça de TELA com o tamanho fixo em px (ex.: 1920x1080px), '
                         'como o <meta name="alvo-tela"> com alvo-fisico vazio (marca-seed §12.30/§12.32): o '
                         'CP-10 não aplica a régua de volume de texto e o CP-12 (folha) não se aplica (CP-P28). '
                         'É o caminho do PDF de entrega do deck, que não carrega as metas. Normalmente o HTML '
                         'declara e a régua lê; a opção sobrescreve.')
    ap.add_argument('--sem-texto', default=None, metavar='MOTIVO',
                    help='(PDF, SVG ou HTML) declara que a peça NÃO TEM texto por desenho (a capa só de logo): CP-2, CP-3 e CP-4 sem o que medir saem '
                         '«não se aplica» (saída 0) em vez de NÃO MEDI (saída 2). O MOTIVO é obrigatório. No HTML a peça declara com '
                         '<meta name="sem-texto" content="MOTIVO">; a opção vence a meta. Se a peça TEM texto a declaração não vale e a ficha avisa (CP-P65).')
    args = ap.parse_args()

    if args.sem_texto is not None and not args.sem_texto.strip():
        # saída 2 (a do argparse) = NÃO MEDI: nada foi medido porque o pedido não é válido; é o MESMO código da `<meta name="sem-texto" content="">` vazia
        # (a meta vazia não vale e a peça sem texto sai 2 pelo CP-3 sem declaração). Decisão da leva 48 (B3), exposta a veto.
        ap.error('--sem-texto: motivo obrigatório (por exemplo: --sem-texto "capa só de logo"). Declaração sem motivo não vale — nada foi medido, saída 2 (NÃO MEDI), '
                 'a mesma saída de uma peça sem texto que não declara o motivo')
    if args.alvo_tela and not _RE_ALVO_TELA_CLI.fullmatch(args.alvo_tela):
        ap.error('--alvo-tela espera LxApx em px inteiros, por exemplo 1920x1080px (recebido: %r)' % args.alvo_tela)
    if args.url and not args.alvo:
        ap.error('--url só faz sentido junto com --alvo <pasta do app>')
    if not args.alvo and not args.arquivos:
        ap.error('informe ao menos um arquivo (PDF, SVG ou HTML) ou --alvo <pasta do app>')

    if not TOKENS or not ASSETS:
        print('não achei o canon. Rode de dentro do repositório do DS ou de dentro da pasta da skill seed-ds.')
        sys.exit(2)
    cores, escala = carrega_tokens()
    empresa = dados_da_empresa()
    print('canon lido de: %s' % TOKENS)
    print('assets de marca: %s · gerador do grafismo: %s' % (ASSETS, GERADOR or 'NÃO ENCONTRADO'))
    print('%d cores no token · escala tipográfica com %d degraus (menor %gpx)'
          % (len(cores), len(escala), min(escala.values()) if escala else 0))

    saida = 0
    if args.alvo:
        f, f_url = confere_alvo(args.alvo, cores, escala, empresa, url=args.url, arquetipo=args.arquetipo)
        saida = pior_saida(saida, f.imprime())
        if f_url is not None:
            saida = pior_saida(saida, f_url.imprime())
    for a in args.arquivos:
        ext = a.lower().rsplit('.', 1)[-1]
        if ext == 'pdf':
            f = confere_pdf(a, cores, escala, empresa, alvo=alvo_declarado('', args.alvo_tela), sem_texto=args.sem_texto)
        elif ext == 'svg':
            f = confere_svg(a, cores, escala, empresa, alvo_tela=args.alvo_tela, sem_texto=args.sem_texto)
        elif ext in ('html', 'htm'):
            f = confere_html(a, cores, escala, empresa, arquetipo=args.arquetipo, alvo_tela=args.alvo_tela, sem_texto=args.sem_texto)
        else:
            f = Ficha(a)
            f.item('CP-0', None, 'formato não suportado: %s (aceita PDF, SVG e HTML)' % ext, sem_medida=True)
        saida = pior_saida(saida, f.imprime())
    if _DOM_LOG['chamadas']:
        print('DOM: %d chamada(s) a mede_dom, %d sem resposta' % (_DOM_LOG['chamadas'], _DOM_LOG['sem_resposta']))
    sys.exit(saida)


if __name__ == '__main__':
    main()
