# -*- coding: utf-8 -*-
import json, os, subprocess, sys

B = "backend"; F = "frontend"

def c(chave, terr, titulo, paths, aceites, dep=None):
    return {"chave": chave, "territorio": terr, "titulo": titulo,
            "escopo_paths": paths, "aceites": aceites, "depende_de": dep or []}

def jest(f, nomes, extra=None):
    a = [f"cd backend && npx jest test/{f} --runInBand --ci"]
    a += [f"cd backend && npx jest test/{f} --runInBand --ci -t '{n}'" for n in nomes]
    return a + (extra or [])

def pw(f, nomes, extra=None):
    a = [f"cd frontend && npx playwright test e2e/{f} --reporter=line"]
    a += [f"cd frontend && npx playwright test e2e/{f} --reporter=line -g '{n}'" for n in nomes]
    return a + (extra or [])

# as tres provas de navegacao, corridas juntas — regressao obrigatoria da trava (11)
TRES = ("cd frontend && npx playwright test e2e/navegacao.spec.ts e2e/alcance.spec.ts "
        "e2e/botoes.spec.ts --reporter=line")

CARDS = []

# ══════════════════════════════════════════════════════════════════ BACKEND
CARDS.append(c("CS-121", B,
 "O ESQUEMA DO PONTO NASCE NUMA MIGRACAO SO', E O TRIMESTRE E' CIVIL EM `America/Bahia`. "
 "HOJE, MEDIDO em `backend/prisma/schema.prisma` (30/09): 13 models — Utilizador:34, Upa:96, "
 "Aula:136, BlocoAula:194, VinculoMedicoUpa:233, Parametro:277, Questao:297, QuestaoAlternativa:331, "
 "ParticipacaoAula:357, RespostaQuestao:387, Gravacao:431, MensagemChat:471, Nucleo:510 — e ⛔ NENHUM "
 "Ponto, Certificado, Obrigatoriedade, Contestacao ou PedidoDeTema. ⟹ Nascem AQUI, TODOS na MESMA "
 "migracao, e ⛔ EM MAIS CARD NENHUM: e' o unico jeito de os 12 cards seguintes correrem em PARALELO "
 "sem se pisarem no fim de `schema.prisma` (medido: «conflito ao integrar no epic» custou 6 voltas ao "
 "MSS-180). 🩸 `LancamentoDePonto` E' UMA LINHA POR FACTO, ⛔ NAO UM TOTAL POR MEDICO (decisao (a) da "
 "demanda): `valor Decimal(12,4)` CONGELADO no instante (precedente medido: `RespostaQuestao.correta` "
 "congela o acerto e a tabela ⛔ nao tem coluna de pontos, schema:387) · `origem OrigemDePonto` com os "
 "CINCO valores (SEGMENTO_AO_VIVO · SEGMENTO_GRAVACAO · RESPOSTA_A_QUESTAO · PRESENCA_CURSO_PRESENCIAL "
 "· CERTIFICADO_EXTERNO) · `ocorridoEm` (o instante DO FACTO, ⛔ nao do lancamento) · `trimestreAno` + "
 "`trimestreNum` (1..4) DERIVADOS de `ocorridoEm` e GRAVADOS · `anuladoEm` + `anuladoPorId` (a "
 "contestacao ⛔ NUNCA apaga a linha) · e A PROVA AO LADO por `factoTipo` + `factoId` em TEXTO, 🩸 ⛔ SEM "
 "relacao Prisma — precedente medido DUAS vezes neste schema: `RespostaQuestao.alternativaEscolhida` "
 "(TEXTO de proposito, «⛔ nao um apontador vivo») e `Parametro.atualizadoPorId` («⛔ nao fixo aqui um "
 "nome de modelo que ⛔ nao e' meu»). ⟹ E' o que faz este epic COMPILAR sem `segmento_validado` (epic "
 "CS-32) nem `inscricao` (epic CS-94) na base. 🩸 `@@unique([factoTipo, factoId])` DO BANCO: um facto "
 "da' UM lancamento e ⛔ NUNCA DOIS — e' isto que da' de graca «a gravada permanente pontua SO' NA "
 "PRIMEIRA VEZ» (resposta 90). `TipoCertificado` (nome + `pontos` + `arquivadoEm`) no molde MEDIDO do "
 "`Nucleo`:510, `Certificado` (medicoId · tipoId · ficheiro · PENDENTE/APROVADO/RECUSADO · quem "
 "decidiu e quando), `Obrigatoriedade` (ambito REDE|UPA · `upaId` nulo so' na REDE · horas e aulas por "
 "trimestre · quem pos e quando), `Contestacao` (lancamentoId · motivo · veredito · quem decidiu e "
 "quando), `PedidoDeTema` (medicoId · tema). E `src/modules/ponto/trimestre.ts`, FUNCAO PURA: "
 "🩸 `America/Bahia` e' o fuso MEDIDO deste produto (`app/ao-vivo/dados.ts:50` `FUSO_DA_AULA`, "
 "`componentes/chat/dados.ts:42`, `especialista/questoes/dados.ts:196`) e o BACKEND tem ZERO ocorrencias "
 "de fuso hoje — 31/12 23:00 em Salvador e' 01/01 02:00 UTC, e sem isto o limite do criterio (3) cai no "
 "trimestre errado. ⛔ NENHUMA rota, ⛔ nenhum servico de negocio e ⛔ nenhum numero de ponto neste card",
 ["backend/prisma/schema.prisma", "backend/prisma/migrations/",
  "backend/src/modules/ponto/trimestre.ts", "backend/src/modules/ponto/trimestre.spec.ts",
  "backend/test/esquema-do-ponto.e2e-spec.ts"],
 ["cd backend && npx jest src/modules/ponto/trimestre.spec.ts --runInBand --ci",
  "cd backend && npx jest src/modules/ponto/trimestre.spec.ts --runInBand --ci -t 'um instante de 31/12 as 23:00 em Salvador cai no 4.o trimestre e um de 01/01 as 00:30 cai no 1.o do ano seguinte'",
  "cd backend && npx jest src/modules/ponto/trimestre.spec.ts --runInBand --ci -t 'os quatro trimestres civis sao Jan-Mar, Abr-Jun, Jul-Set e Out-Dez, e a funcao devolve 1, 2, 3 e 4'",
  "cd backend && npx jest src/modules/ponto/trimestre.spec.ts --runInBand --ci -t 'a funcao e pura: o mesmo instante da sempre o mesmo par, e nao le relogio nem ambiente'",
  "cd backend && npx jest src/modules/ponto/trimestre.spec.ts --runInBand --ci -t 'o trimestre e o mesmo para a rede toda: nao aceita fuso nem UPA como argumento'"]
 + jest("esquema-do-ponto.e2e-spec.ts", [
  "os cinco models novos existem na base e contam-se 18 tabelas onde antes eram 13",
  "uma linha de ponto guarda origem, valor congelado, o instante do facto e o trimestre gravado",
  "o mesmo facto nao entra duas vezes: a segunda insercao com o mesmo factoTipo e factoId e recusada pelo banco",
  "a prova ao lado e TEXTO: apagar a linha do facto nao apaga o lancamento nem o deixa orfao",
  "anular nao apaga: a linha continua contavel na tabela com anuladoEm e anuladoPorId escritos",
  "a obrigatoriedade de ambito REDE nao leva upaId e a de ambito UPA nao passa sem ele",
  "o tipo de certificado arquivado continua na base, no mesmo gesto do nucleo arquivado",
  "as cinco origens de ponto sao exactamente cinco e o banco recusa uma sexta"],
  ["cd backend && grep -q 'model LancamentoDePonto' prisma/schema.prisma",
   "cd backend && grep -q 'model TipoCertificado' prisma/schema.prisma",
   "cd backend && grep -q 'model Certificado' prisma/schema.prisma",
   "cd backend && grep -q 'model Obrigatoriedade' prisma/schema.prisma",
   "cd backend && grep -q 'model Contestacao' prisma/schema.prisma",
   "cd backend && grep -q 'model PedidoDeTema' prisma/schema.prisma",
   "cd backend && grep -qE '@@unique\\(\\[factoTipo, *factoId\\]\\)' prisma/schema.prisma",
   "cd backend && grep -q 'America/Bahia' src/modules/ponto/trimestre.ts",
   "cd backend && test $(grep -c '^model ' prisma/schema.prisma) -eq 18",
   "cd backend && ! (grep -A40 'model LancamentoDePonto' prisma/schema.prisma | grep -qE 'segmento|SegmentoValidado|Inscricao|inscricao|total|saldo|media')",
   "cd backend && ! grep -rqE '@Controller|@Injectable|@Get|@Post|@Put' src/modules/ponto/",
   "cd backend && ! grep -rqE '[^A-Za-z_](5|9|16|24|20)[^0-9]' src/modules/ponto/trimestre.ts || true",
   "cd backend && npx jest test/participacao.e2e-spec.ts test/questao.e2e-spec.ts test/parametro.e2e-spec.ts --runInBand --ci"])))

CARDS.append(c("CS-122", B,
 "O SERVICO DE LANCAMENTO E O EXTRACTO DO PROPRIO MEDICO: `GET /pontos/eu`, LINHA A LINHA, com a PROVA "
 "ao lado. 🩸 UM SO' SITIO LANCA, e e' este: `ServicoLancamento.lancar({medicoId, origem, factoTipo, "
 "factoId, ocorridoEm, quantidade})` le' o PRECO do `parametro` NO INSTANTE, CONGELA-O na linha e deriva "
 "o trimestre pelo `trimestre.ts` do CS-121 — ⟹ os 4 cards que alimentam o ranking (CS-123, CS-124, "
 "CS-125, CS-127) ⛔ NAO repetem a conta, e a secretaria mudar a tabela ⛔ NAO reescreve o que ja' foi "
 "ganho (decisao (b)). 🩸 E E' IDEMPOTENTE POR CONSTRUCAO: o segundo `lancar` do MESMO facto devolve o "
 "lancamento que ja' existe e ⛔ NAO cria linha nova — apanhado pelo `@@unique([factoTipo, factoId])`, "
 "⛔ nao por um `findFirst` que perde a corrida. `anular(lancamentoId, quemAnulou)` escreve `anuladoEm` "
 "e ⛔ NUNCA apaga (e' o que a contestacao do CS-129 vai chamar). `GET /pontos/eu` devolve as linhas DO "
 "PROPRIO, mais novas primeiro, cada uma com origem · valor · `ocorridoEm` · trimestre · a prova · e se "
 "esta' ANULADA; 🩸 e traz O TOTAL AO LADO das linhas, ⛔ NUNCA EM VEZ (regra 84.3) — e o total e' a SOMA "
 "DAS LINHAS ⛔ NAO anuladas, conferida na propria resposta. ⚠️ `eu` E' A SESSAO E ⛔ NAO UM PARAMETRO: "
 "⛔ nao ha' `?medicoId=` nesta rota e ⛔ nao ha' caminho nenhum por onde um medico leia o extracto de "
 "outro. Os outros tres papeis levam 403 DO SERVIDOR (`@Papeis(Papel.MEDICO)`), e sem sessao e' 401 e "
 "⛔ nao 403, porque o guarda de sessao corre antes. ⛔ NENHUM numero de ponto em codigo: o preco vem "
 "SEMPRE de `parametro` (regra 53)",
 ["backend/src/modules/ponto/", "backend/src/app.module.ts", "backend/test/ponto.e2e-spec.ts"],
 jest("ponto.e2e-spec.ts", [
  "lancar grava uma linha com o preco do parametro congelado no instante, e o trimestre derivado do facto",
  "a secretaria muda pontos_extra_por_acertar de 5 para 9 sem reiniciar: o lancamento novo vale 9 e o antigo continua a valer 5",
  "lancar o mesmo facto duas vezes da uma linha so, e a segunda chamada devolve a que ja existia",
  "duas chamadas simultaneas ao mesmo facto dao uma linha e um conflito, nunca duas linhas",
  "anular escreve quem anulou e quando, e a linha continua na base e no extracto",
  "o total do extracto e a soma das linhas nao anuladas, conferido contra a soma feita a mao",
  "o total vem AO LADO das linhas e nao em vez delas: a resposta traz as duas coisas",
  "a linha anulada aparece no extracto marcada e nao entra no total",
  "o medico le o SEU extracto e nao ha parametro nenhum por onde pedir o de outro",
  "coordenador, secretaria e especialista levam 403 em GET /pontos/eu",
  "sem sessao da 401 e nao 403, porque o guarda de sessao corre antes",
  "ler o extracto nao escreve uma linha na base, e ler duas vezes da o mesmo corpo"],
  ["cd backend && grep -q 'ModuloPonto' src/app.module.ts",
   "cd backend && ! grep -rqE 'medicoId' src/modules/ponto/ponto.controlador.ts",
   "cd backend && ! grep -rqE '(pontos|preco|valor|peso)[A-Za-z]*\\s*[:=]\\s*-?[0-9]' src/modules/ponto/",
   "cd backend && ! grep -rqE 'prisma\\.lancamentoDePonto\\.(delete|deleteMany)' src/modules/",
   "cd backend && npx jest test/parametro.e2e-spec.ts test/identidade.e2e-spec.ts --runInBand --ci"]),
 ["CS-121"]))

CARDS.append(c("CS-123", B,
 "RESPONDER E ACERTAR PASSAM A LANCAR, NO CAMINHO QUE JA' EXISTE — X por responder, X+Y por acertar, e "
 "⛔ NADA por errar ALEM do X (regra 56, a letra: «Quem erra fica com X»). MEDIDO: `RespostaQuestao` "
 "nasce em `backend/src/modules/participacao/participacao.controlador.ts:34` "
 "(`questoes/:questaoId/resposta`) e guarda `correta` CONGELADO (`schema.prisma:387`) e ⛔ NENHUMA coluna "
 "de pontos — de proposito, porque X e Y sao parametro da secretaria. ⟹ O lancamento entra NA MESMA "
 "TRANSACCAO da resposta, com `factoTipo='resposta_questao'` e `factoId` = o id da resposta: origem "
 "`RESPOSTA_A_QUESTAO`, valor `pontos_por_responder` + (`correta` ? `pontos_extra_por_acertar` : 0), "
 "`ocorridoEm` = `respondidoEm`. 🩸 ⛔ NENHUM CASTIGO, E PROVA-SE: errar ⛔ NAO gera linha negativa, "
 "⛔ NAO anula nada e ⛔ NAO desce o total — quem errou fica com X e com os seus minutos (trava (9)). "
 "⚠️ A TRAVA DO `@@unique([questaoId, medicoId])` (schema:387) JA' IMPEDE a repeticao: duas chamadas "
 "simultaneas dao uma 201 e uma 409 ⟹ ⛔ NUNCA dois lancamentos pela mesma questao, e isso conta-se. "
 "⛔ NAO se toca no `schema.prisma` (o esquema e' do CS-121), ⛔ nao se cria chave de parametro nova (as "
 "duas ja' existem em `chaves.ts:7`) e ⛔ NAO se mexe nos minutos — os minutos sao do CS-124",
 ["backend/src/modules/participacao/", "backend/test/ponto-resposta.e2e-spec.ts"],
 jest("ponto-resposta.e2e-spec.ts", [
  "responder e errar lanca exactamente X, com X lido de pontos_por_responder e nada mais",
  "responder e acertar lanca X mais Y numa linha so, e o valor bate com a soma das duas chaves",
  "errar nao gera linha negativa, nao anula nada e o total do medico nao desce",
  "o lancamento nasce na mesma transaccao da resposta: se a resposta falhar nao fica lancamento nenhum",
  "a segunda resposta a mesma questao da 409 e continua a haver um so lancamento",
  "com pontos_extra_por_acertar em 5 o total e N, e depois de a secretaria por 9 o antigo continua a valer 5",
  "o lancamento aponta a resposta pelo factoId, e o extracto mostra a questao que o sustenta",
  "o instante do lancamento e o respondidoEm da resposta, e nao o instante em que a linha foi escrita",
  "a resposta a uma questao de aula de 31/12 e a de 01/01 caem em trimestres diferentes"],
  ["cd backend && ! grep -rqE '(pontos|preco|valor)[A-Za-z]*\\s*[:=]\\s*-?[0-9]' src/modules/participacao/",
   "cd backend && ! grep -rqE 'prisma\\.lancamentoDePonto\\.create' src/modules/participacao/",
   "cd backend && npx jest test/participacao.e2e-spec.ts test/questao.e2e-spec.ts --runInBand --ci",
   "cd backend && git diff --name-only main -- prisma/ | wc -l | grep -qx 0"]),
 ["CS-122"]))

CARDS.append(c("CS-124", B,
 "⚠️⚠️ ESTE CARD EXIGE `epic/CS-32` PUBLICADO EM `main` ANTES DE COMECAR — ⛔ NAO E' OPCAO DO DEV: MEDIDO "
 "30/09, `main` tem 13 models e ZERO ocorrencias de `segmento` em `schema.prisma`, e `model "
 "SegmentoValidado` + `enum CaminhoDeValidacao` vivem SO' em `epic/CS-32` (demanda 23, os 9 cards "
 "CS-33..CS-41 TODOS `aprovado`, demanda em `aprovada` e ⛔ NAO `publicada`). Sem esse merge este card "
 "⛔ nao compila e o dev devolve-o. ⟹ NASCE A CHAVE `pontos_por_minuto_validado` (inteiro >= 0, no molde "
 "MEDIDO de `chaves.ts:7` e com a omissao semeada SO' NA MIGRACAO — decisao (c), e e' exactamente o que "
 "`pontos_curso_presencial` fez no CS-97) E O SEGMENTO VALIDADO PASSA A LANCAR: valor = "
 "`(fimMin - inicioMin)` x `pontos_por_minuto_validado`, origem `SEGMENTO_AO_VIVO` quando "
 "`caminho=AO_VIVO` e `SEGMENTO_GRAVACAO` quando `GRAVACAO`, e 🩸 A GRAVADA VALE MENOS — x "
 "`multiplicador_gravada`, que JA' existe em `chaves.ts` com limite `> 0 && < 1` (regra 49). "
 "`factoTipo='segmento_validado'`, `factoId` = o id do segmento, `ocorridoEm` = `validadoEm`. "
 "⚠️ A UNIDADE E' O MINUTO ASSISTIDO E ⛔ NAO A AULA NEM A PERGUNTA (regra 48): quem entra a meio pontua "
 "o que assistiu, e um bloco ⛔ NAO validado ⛔ NAO conta os seus minutos mesmo tendo a pessoa la' estado. "
 "🩸 E O MULTIPLICADOR E' DO PRECO E ⛔ NAO DA CARGA HORARIA — a decisao 5 do quadro na demanda 23 esta' "
 "escrita no proprio `enum CaminhoDeValidacao`: «o MESMO valor em minutos que o AO_VIVO». ⟹ este card "
 "⛔ NAO toca em `minutosValidados` e ⛔ NAO corre migracao que recalcule nada (trava (10))",
 ["backend/src/modules/parametro/chaves.ts", "backend/prisma/migrations/",
  "backend/src/modules/participacao/", "backend/test/ponto-segmento.e2e-spec.ts"],
 jest("ponto-segmento.e2e-spec.ts", [
  "a chave pontos_por_minuto_validado existe, aceita inteiro maior ou igual a zero e recusa negativo com a frase que diz o que fazer",
  "a omissao da chave nova vem da migracao e nao de codigo: o valor de partida esta na base e nao no ficheiro",
  "um segmento de 20 minutos ao vivo lanca 20 vezes o valor da chave, conferido contra a conta feita a mao",
  "o mesmo segmento validado na gravacao vale menos: e o valor ao vivo vezes multiplicador_gravada",
  "quem entra a meio pontua so os minutos dos segmentos que validou, e o bloco nao validado nao conta nenhum",
  "o segmento nao muda minutosValidados nem uma linha, e a contagem de participacao fica igual antes e depois",
  "um segmento da uma linha so: revalidar nao duplica e o unico do banco apanha a corrida",
  "a secretaria muda pontos_por_minuto_validado e os segmentos antigos continuam a valer o que valiam"],
  ["cd backend && grep -q 'pontos_por_minuto_validado' src/modules/parametro/chaves.ts",
   "cd backend && grep -rq 'pontos_por_minuto_validado' prisma/migrations/",
   "cd backend && ! grep -rqE 'pontos_por_minuto_validado.*=\\s*[0-9]' src/",
   "cd backend && ! grep -rqE 'minutosValidados\\s*[:=]' src/modules/ponto/ src/modules/participacao/participacao.servico.ts || true",
   "cd backend && npx jest test/parametro.e2e-spec.ts test/participacao.e2e-spec.ts --runInBand --ci"]),
 ["CS-123"]))

CARDS.append(c("CS-125", B,
 "⚠️⚠️ ESTE CARD EXIGE `epic/CS-94` PUBLICADO EM `main` ANTES DE COMECAR: MEDIDO 30/09, `main` ⛔ NAO tem "
 "`backend/src/modules/curso/` nem `backend/src/modules/inscricao/` — o `CS-95` (o `model Curso`) esta' "
 "`em_curso` e o `CS-96` (a folha de presencas, com `compareceu` · `confirmadoPorId` · `confirmadoEm`) "
 "esta' `a_espera_do_pai`. ⟹ A PRESENCA CONFIRMADA UM A UM PASSA A LANCAR, e e' o fecho literal do que "
 "o CS-96 escreveu no seu proprio titulo: «⛔ NAO se grava ponto nenhum — o FACTO fica guardado para a "
 "demanda 29 o somar». O lancamento entra na MESMA transaccao do `POST "
 "/cursos/:cursoId/inscricoes/:id/compareceu`, com origem `PRESENCA_CURSO_PRESENCIAL`, valor lido de "
 "`pontos_curso_presencial` (a chave que o `CS-97` JA' criou, `aprovado`), "
 "`factoTipo='inscricao_curso'`, `factoId` = o id da inscricao e `ocorridoEm` = `confirmadoEm`. "
 "🩸 INSCREVER-SE ⛔ NAO DA' PONTO NENHUM e ⛔ NAO COMPARECER ⛔ NAO DA' PONTO NENHUM (regra 52): SO' a "
 "confirmacao positiva lanca, e isso conta-se nos tres estados. ⚠️ E `.../nao-compareceu` DEPOIS de "
 "`.../compareceu` ⛔ NAO pode deixar o ponto de pe' — ANULA o lancamento pelo `anular` do CS-122, "
 "⛔ NUNCA o apaga, porque a linha e' o que o medico contesta. ⛔ NENHUMA rota nova, ⛔ nenhum campo novo "
 "e ⛔ NADA da pergunta 94 (a fila de espera e' do epic CS-94, ⛔ nao deste)",
 ["backend/src/modules/inscricao/", "backend/test/ponto-curso.e2e-spec.ts"],
 jest("ponto-curso.e2e-spec.ts", [
  "confirmar que o medico compareceu lanca uma linha com o valor de pontos_curso_presencial congelado",
  "inscrever-se nao lanca nada: a linha de inscricao nasce e o extracto do medico continua vazio",
  "marcar nao compareceu nao lanca nada, e o total do medico fica igual",
  "marcar nao compareceu depois de ter marcado compareceu anula o lancamento e nao o apaga",
  "confirmar duas vezes o mesmo inscrito da um lancamento so",
  "o instante do lancamento e o confirmadoEm, e o trimestre deriva dele e nao do dia do curso",
  "so a secretaria confirma: coordenador, medico e especialista levam 403 e nenhum ponto e lancado",
  "a secretaria muda pontos_curso_presencial e as presencas ja confirmadas continuam a valer o que valiam"],
  ["cd backend && ! grep -rqE '(pontos|valor)[A-Za-z]*\\s*[:=]\\s*-?[0-9]' src/modules/inscricao/",
   "cd backend && ! grep -rqE 'fila|espera|promover' src/modules/inscricao/*.ts || true",
   "cd backend && npx jest test/presenca.e2e-spec.ts test/curso.e2e-spec.ts --runInBand --ci"]),
 ["CS-124"]))

CARDS.append(c("CS-126", B,
 "O TIPO DE CERTIFICADO E QUANTO ELE VALE SAO DA SECRETARIA, SEM DEPLOY (regra 53, a letra: «cursos, "
 "congressos, artigo publicado, parametrizavel tanto o tipo quanto o quantos pontos aquele tipo vale "
 "pela secretaria»). 🩸 E POR ISSO ⛔ NAO E' CHAVE DE `parametro`: a lista ⛔ NAO E' FECHADA, e "
 "`chaves.ts:7` e' um `as const` de literais que o codigo valida — nele a secretaria ⛔ nao pode CRIAR "
 "um tipo. ⟹ E' o `model TipoCertificado` do CS-121, no molde MEDIDO do `Nucleo` (`schema.prisma:510`: "
 "nome + marca + `arquivadoEm`), com `POST`/`GET`/`PATCH /tipos-de-certificado` e ARQUIVAR em vez de "
 "apagar. OS TRES TIPOS QUE O DONO NOMEOU — CURSO, CONGRESSO e ARTIGO PUBLICADO — entram pela MIGRACAO "
 "e sao «o conteudo inicial, ⛔ nao a lista fechada»; o VALOR de cada um tambem, e ⛔ NENHUM desses "
 "numeros aparece em codigo. 🩸 A SEPARACAO DE PODERES E' TRAVA E CONTA-SE: SO' a SECRETARIA escreve "
 "(`@Papeis(Papel.SECRETARIA)`) e o COORDENADOR leva 403 aqui — ele diz SE E' VERDADE (CS-127), "
 "⛔ NUNCA quanto vale (regra 53, «⛔ nao e' o coordenador que arbitra caso a caso»). LER e' de qualquer "
 "sessao ATIVA (o medico precisa de saber que tipos pode submeter), e o PENDENTE le' `200 []` e ⛔ nao "
 "403, no mesmo gesto MEDIDO de `aula.controlador.ts:80-92` (regra 46). ⚠️ ARQUIVAR UM TIPO ⛔ NAO "
 "MEXE NUM UNICO PONTO JA' GANHO: o valor esta' congelado na linha (decisao (b)), e isso prova-se com "
 "um certificado ja' aprovado antes do arquivo",
 ["backend/src/modules/certificado/", "backend/src/app.module.ts",
  "backend/test/certificado-tipo.e2e-spec.ts"],
 jest("certificado-tipo.e2e-spec.ts", [
  "os tres tipos que o dono nomeou vem da migracao com o seu valor, e nenhum desses numeros esta em codigo",
  "a secretaria cria um quarto tipo e ele fica disponivel sem reiniciar o servidor",
  "a secretaria muda quantos pontos um tipo vale e a mudanca vale para os certificados novos",
  "arquivar um tipo nao o apaga da base e nao mexe num unico ponto ja ganho",
  "o tipo arquivado deixa de poder ser escolhido num certificado novo, e a frase diz o que fazer",
  "so a secretaria escreve: coordenador, medico e especialista levam 403 a criar, mudar e arquivar",
  "qualquer sessao activa le a lista de tipos, e o medico pendente le 200 com lista vazia e nao 403",
  "sem sessao da 401 e nao 403, porque o guarda de sessao corre antes",
  "um valor negativo e recusado com a frase que diz o que aconteceu e o que fazer, e nada fica gravado"],
  ["cd backend && grep -q 'ModuloCertificado' src/app.module.ts",
   "cd backend && grep -rq 'tipos-de-certificado' src/modules/certificado/",
   "cd backend && grep -rq 'TipoCertificado' prisma/migrations/",
   "cd backend && ! grep -rqE '(pontos|valor)[A-Za-z]*\\s*[:=]\\s*[0-9]' src/modules/certificado/",
   "cd backend && ! grep -rqE 'prisma\\.tipoCertificado\\.(delete|deleteMany)' src/",
   "cd backend && ! grep -q 'tipo_certificado' src/modules/parametro/chaves.ts",
   "cd backend && npx jest test/parametro.e2e-spec.ts test/nucleo.e2e-spec.ts --runInBand --ci"]),
 ["CS-121"]))

CARDS.append(c("CS-127", B,
 "O CERTIFICADO EXTERNO PONTA A PONTA NO SERVIDOR: o MEDICO SOBE, entra PENDENTE, e o COORDENADOR "
 "APROVA — e SO' o aprovar LANCA, com o valor do tipo CONGELADO (regra 43(2), que o dono escolheu "
 "sabendo que custa trabalho humano: «um ranking fraudavel que decide escala e' pior do que ⛔ nao haver "
 "ranking»). 🩸 O ARMAZEM E' PROPRIO E ⛔ NAO E' O DA MARCA: MEDIDO, `marca.tipos.ts:11` tem "
 "`TECTO_BYTES = 524288` e `FORMATOS = ['svg','png','webp']` — 🩸 UM CERTIFICADO DIGITALIZADO E' PDF OU "
 "JPEG E ⛔ NAO CABE NEM NO TECTO NEM NA LISTA, e foi por isso que o CS-67 fez `modules/documento` para "
 "o protocolo («um PDF de protocolo ⛔ NAO cabe nos 512 KB da marca do CS-51»). ⟹ armazem proprio do "
 "certificado, com TECTO e FORMATOS proprios (PDF · JPEG · PNG · WEBP), o formato lido do CONTEUDO e "
 "⛔ NAO da extensao, os cabecalhos que o impedem de EXECUTAR e ⛔ NENHUM ficheiro orfao quando recusa — "
 "o MOLDE e' o de `modules/marca` (que esta' em `main`) e ⛔ NAO `modules/documento` (que vive no epic "
 "CS-65, ⛔ nao publicado: reusa-lo punha uma TERCEIRA pre-condicao de publicacao neste epic). "
 "🩸 PENDENTE ⛔ NAO E' PONTO: enquanto PENDENTE ⛔ nao ha' uma linha de `LancamentoDePonto` — conta-se. "
 "APROVAR lanca com `factoTipo='certificado'`, `factoId` = o id do certificado, `ocorridoEm` = o "
 "instante da APROVACAO; RECUSAR ⛔ nao lanca nada e a frase do coordenador fica escrita. 🩸 O "
 "COORDENADOR ⛔ NAO MEXE NO VALOR E O SERVIDOR RECUSA-LHO: ⛔ nao existe campo de pontos no corpo de "
 "aprovar, e mandar um e' 400 — a separacao de poderes da regra 53 e' do BANCO e do CONTRATO, ⛔ nao "
 "do ecra. E ⛔ NENHUM certificado EMITIDO pela plataforma (trava (10)): ⛔ zero brasao, ⛔ zero "
 "assinatura, ⛔ zero geracao de PDF",
 ["backend/src/modules/certificado/", "backend/test/certificado.e2e-spec.ts"],
 jest("certificado.e2e-spec.ts", [
  "o medico sobe um PDF de 2 MB e o certificado entra PENDENTE, sem uma unica linha de ponto",
  "o medico sobe um JPEG e ele e aceito: o formato le-se do conteudo e nao da extensao",
  "um ficheiro que se diz PDF na extensao e nao o e por dentro e recusado, e nenhum ficheiro fica no armazem",
  "acima do tecto e recusado com a frase que diz o que aconteceu e o que fazer, e nada fica gravado",
  "o coordenador aprova e nasce uma linha de ponto com o valor do tipo congelado no instante",
  "o coordenador recusa e nao nasce linha nenhuma, e o motivo fica escrito com quem decidiu e quando",
  "o coordenador nao mexe no valor: mandar pontos no corpo de aprovar da 400 e nada muda",
  "aprovar duas vezes o mesmo certificado da uma linha de ponto so",
  "so o coordenador decide: secretaria, medico e especialista levam 403 a aprovar e a recusar",
  "o medico ve os SEUS certificados e nao ha caminho por onde veja os de outro",
  "a secretaria muda o valor do tipo depois da aprovacao e o ponto ja ganho nao se reescreve",
  "o ficheiro serve-se com os cabecalhos que o impedem de executar, e nunca como pagina do mesmo dominio"],
  ["cd backend && ! grep -rq 'TECTO_BYTES' src/modules/certificado/ || ! grep -rq \"from '../marca\" src/modules/certificado/",
   "cd backend && ! grep -rq 'modules/documento' src/modules/certificado/",
   "cd backend && ! grep -rqiE 'brasao|assinatura|emitir|pdfkit|puppeteer' src/modules/certificado/",
   "cd backend && ! grep -rqE '(pontos|valor)[A-Za-z]*\\s*[:=]\\s*[0-9]' src/modules/certificado/",
   "cd backend && npx jest test/marca.e2e-spec.ts test/certificado-tipo.e2e-spec.ts --runInBand --ci"]),
 ["CS-126", "CS-122"]))

CARDS.append(c("CS-128", B,
 "A OBRIGATORIEDADE EM DOIS NIVEIS, E A LEITURA RESOLVIDA DO MEDICO (regra 114, a letra: «Os dois "
 "niveis»). HOJE, MEDIDO: `obrigatorio`, `obrigatoriedade` e `carga horaria` dao ZERO ocorrencias em "
 "`backend/src` — o conceito ⛔ nao existe. ⟹ A SECRETARIA poe para A REDE (ambito REDE, `upaId` nulo) "
 "e O COORDENADOR poe para A SUA UPA e SO' PARA A SUA (ambito UPA) — pedir a UPA de outro e' 403 DO "
 "SERVIDOR, no mesmo gesto MEDIDO de `upa.controlador.ts` e do `vinculo`. Cada nivel leva HORAS POR "
 "TRIMESTRE e AULAS POR TRIMESTRE. ⚠️ AS DUAS SAO NUMERO E ⛔ NAO LISTA DE AULAS MARCADAS: o «2 de 6» do "
 "criterio (6) le-se como CONTAGEM de aulas no trimestre, e ⛔ nao como um campo `obrigatoria` na "
 "`Aula` — e' o caminho REVERSIVEL (de uma contagem chega-se sempre a uma lista; de uma lista marcada "
 "⛔ nao se volta atras sem tocar em toda a `Aula`), e fica NOMEADO para o dono corrigir no aceite. "
 "🩸 A LEITURA DO MEDICO DEVOLVE UMA EXIGENCIA POR UPA DELE, ⛔ NAO UMA RESOLVIDA EM SILENCIO: um medico "
 "da UPA X com 24 h e um de outra com 16 h leem numeros diferentes, e um medico em DUAS UPAs com "
 "exigencias diferentes le' AS DUAS, cada uma COM O NOME DA UNIDADE (regra 83.a: o nome da unidade "
 "esta' SEMPRE a uma linha do avatar). Onde a UPA ⛔ nao pos nada, vale a da REDE — e a resposta DIZ de "
 "que nivel veio, porque isso e' o que o medico vai perguntar. E AS DUAS MUDAM SEM DEPLOY: muda-se por "
 "rota, ⛔ nao por migracao. ⛔ NENHUM ecra, ⛔ nenhuma migracao nova, ⛔ nenhuma coluna na `Aula`",
 ["backend/src/modules/obrigatoriedade/", "backend/src/app.module.ts",
  "backend/test/obrigatoriedade.e2e-spec.ts"],
 jest("obrigatoriedade.e2e-spec.ts", [
  "a secretaria poe 16 h por trimestre para a rede e o valor esta na base sem migracao nenhuma",
  "o coordenador da UPA X poe 24 h e um medico da UPA X le 24 h enquanto um de outra UPA le 16 h",
  "um medico em duas UPAs com exigencias diferentes le as DUAS, cada uma com o nome da unidade",
  "onde a UPA nao pos nada vale a da rede, e a resposta diz de que nivel veio o numero",
  "as duas mudam sem deploy: muda-se por rota e a leitura seguinte ja traz o numero novo",
  "o coordenador nao poe exigencia na UPA de outro: leva 403 do servidor e nada fica gravado",
  "so a secretaria poe a da rede: coordenador, medico e especialista levam 403",
  "o medico nao poe exigencia nenhuma, nem para si: leva 403 nas duas rotas",
  "a exigencia leva horas por trimestre E aulas por trimestre, e nenhuma delas e uma lista de aulas",
  "sem sessao da 401 e nao 403, porque o guarda de sessao corre antes",
  "mudar a exigencia nao reescreve um unico lancamento de ponto ja gravado"],
  ["cd backend && grep -q 'ModuloObrigatoriedade' src/app.module.ts",
   "cd backend && git diff --name-only main -- prisma/ | wc -l | grep -qx 0",
   "cd backend && ! grep -rqiE 'obrigatoria' prisma/schema.prisma",
   "cd backend && ! grep -rqE '(horas|aulas)PorTrimestre\\s*[:=]\\s*[0-9]' src/modules/obrigatoriedade/",
   "cd backend && npx jest test/upa.e2e-spec.ts test/vinculo.e2e-spec.ts --runInBand --ci"]),
 ["CS-121"]))

CARDS.append(c("CS-129", B,
 "A CONTESTACAO FECHA O TRIPE: O MEDICO APONTA UMA LINHA, A SECRETARIA DECIDE (regra 118 §3, e "
 "⛔ NAO O COORDENADOR — «e' a secretaria que PARAMETRIZA quanto vale cada coisa, logo e' ela que sabe "
 "se o valor lancado esta' certo»). O medico aponta UM `LancamentoDePonto` DELE e diz porque discorda; "
 "apontar o de outro e' 403 DO SERVIDOR, e apontar uma linha que ⛔ nao existe e' 404. A SECRETARIA "
 "decide: ACEITA ⟹ chama o `anular` do CS-122; RECUSA ⟹ ⛔ nao mexe no lancamento. 🩸 AS TRES TRAVAS "
 "QUE O DONO ESCREVEU, cada uma contada: (1) A LINHA CONTESTADA ⛔ NUNCA DESAPARECE — N linhas antes e N "
 "depois, na base, aceite OU recusada; (2) O VEREDITO FICA ESCRITO COM QUEM DECIDIU E QUANDO — e "
 "⛔ nao ha' rota que o apague; (3) O TOTAL SO' MUDA DEPOIS DA DECISAO — contestar ⛔ NAO baixa o total "
 "nem um ponto, e isso conta-se ANTES e DEPOIS. ⚠️ O COORDENADOR LEVA 403 A DECIDIR, e isso ⛔ nao e' "
 "zelo: ele aprova CERTIFICADO (CS-127) e ⛔ nunca uma contestacao — sao poderes diferentes e o servidor "
 "e' que os separa. Uma linha ja' ANULADA ⛔ nao se contesta outra vez, e a mesma linha ⛔ nao aceita duas "
 "contestacoes abertas (unico do BANCO, ⛔ nao um `findFirst` que perde a corrida). ⛔ NENHUM ecra e "
 "⛔ nenhuma migracao nova: o `model Contestacao` ja' nasceu no CS-121",
 ["backend/src/modules/contestacao/", "backend/src/app.module.ts",
  "backend/test/contestacao.e2e-spec.ts"],
 jest("contestacao.e2e-spec.ts", [
  "o medico aponta uma linha do seu extracto e diz porque discorda, e a contestacao nasce pendente",
  "contestar nao baixa o total nem um ponto: o total e o mesmo antes e depois, contado nos dois sitios",
  "a secretaria aceita e o lancamento fica anulado, e so entao o total desce",
  "a secretaria recusa e o lancamento continua a contar, com o veredito escrito ao lado",
  "a linha contestada nunca desaparece: N linhas antes e N depois, aceite ou recusada",
  "o veredito guarda quem decidiu e quando, e nao ha rota que o apague",
  "o coordenador leva 403 a decidir uma contestacao, e a linha fica intacta",
  "o medico nao contesta a linha de outro: leva 403 do servidor",
  "apontar uma linha que nao existe da 404 e nao 500",
  "a mesma linha nao aceita duas contestacoes abertas, e a trava e do banco",
  "uma linha ja anulada nao se contesta outra vez, e a frase diz o que fazer",
  "sem sessao da 401 e nao 403, porque o guarda de sessao corre antes"],
  ["cd backend && grep -q 'ModuloContestacao' src/app.module.ts",
   "cd backend && git diff --name-only main -- prisma/ | wc -l | grep -qx 0",
   "cd backend && ! grep -rqE 'prisma\\.(lancamentoDePonto|contestacao)\\.(delete|deleteMany)' src/modules/contestacao/",
   "cd backend && ! grep -rq 'COORDENADOR' src/modules/contestacao/contestacao.controlador.ts",
   "cd backend && npx jest test/ponto.e2e-spec.ts --runInBand --ci"]),
 ["CS-122"]))

CARDS.append(c("CS-130", B,
 "PEDIR UM TEMA DE AULA VAI DIRECTO PARA A SECRETARIA, E ⛔ NAO PARA O COORDENADOR — e esta e' UMA DAS "
 "DUAS CORRECCOES AO TEXTO DA DEMANDA: a regra 114 dizia coordenador, a regra 118 §4 corrigiu-a («e' "
 "ela quem cadastra a aula e indica o especialista responsavel, regra 57, logo e' ela quem decide o "
 "tema») e MANDA A POSTERIOR. MEDIDO: hoje SO' o coordenador tem «Pedidos» (`navegacao.ts:139-145`, "
 "`rotaGuardia: 'GET /vinculos'`) e essa porta E' PARA ACEITAR MEDICO (regra 46) — ⛔ nao para temas. "
 "⟹ O MEDICO cria (`POST /pedidos-de-tema`, so' `@Papeis(Papel.MEDICO)`) e A SECRETARIA le' a lista "
 "(`GET /pedidos-de-tema`, so' `@Papeis(Papel.SECRETARIA)`) com o tema, quem pediu, o CRM e quando. "
 "🩸 O COORDENADOR LEVA 403 NAS DUAS ROTAS, e e' o que prova a correccao — ⛔ nao ha' caminho no servidor "
 "por onde um pedido de tema lhe chegue. Um tema vazio ou so' de espacos e' recusado com a frase que diz "
 "O QUE ACONTECEU E O QUE FAZER (⛔ nunca «dados invalidos») e ⛔ NADA fica gravado. O medico PENDENTE "
 "⛔ nao pede (regra 46: pendente ⛔ nao pontua e ⛔ nao aparece no ranking; pedir tema e' acto de quem "
 "esta' dentro). ⛔ NENHUM estado de decisao, ⛔ nenhum aceitar/recusar e ⛔ NENHUM aviso nem email "
 "(regra 133: o email existe SO' para a promocao da fila do curso) — o dono decidiu «chega a' "
 "secretaria», e ⛔ nao um fluxo de aprovacao que ele ⛔ nunca pediu",
 ["backend/src/modules/pedido-de-tema/", "backend/src/app.module.ts",
  "backend/test/pedido-de-tema.e2e-spec.ts"],
 jest("pedido-de-tema.e2e-spec.ts", [
  "o medico pede um tema e o pedido aparece na lista da secretaria com o tema, o nome, o CRM e a data",
  "o coordenador leva 403 a criar e 403 a ler: nao ha caminho por onde o pedido lhe chegue",
  "a secretaria le a lista, mais novos primeiro, e a data vai em tabular",
  "um tema vazio ou so de espacos e recusado com a frase que diz o que fazer, e nada fica gravado",
  "o medico pendente nao pede: leva 403 e nada fica gravado",
  "o especialista leva 403 nas duas rotas",
  "o medico nao le a lista dos pedidos dos outros: leva 403",
  "sem sessao da 401 e nao 403, porque o guarda de sessao corre antes",
  "pedir nao lanca ponto nenhum: o extracto do medico continua igual"],
  ["cd backend && grep -q 'ModuloPedidoDeTema' src/app.module.ts",
   "cd backend && git diff --name-only main -- prisma/ | wc -l | grep -qx 0",
   "cd backend && ! grep -rq 'COORDENADOR' src/modules/pedido-de-tema/",
   "cd backend && ! grep -rqiE 'aceitar|recusar|aprovar|email|nodemailer|aviso' src/modules/pedido-de-tema/",
   "cd backend && npx jest test/vinculo.e2e-spec.ts --runInBand --ci"]),
 ["CS-121"]))

CARDS.append(c("CS-131", B,
 "O RANKING NOMINAL DE QUEM ESCALA: DUAS COLUNAS, UM RANKING DE PROFISSIONAL, LENTE POR UPA — E O "
 "MEDICO LEVA 403 DO SERVIDOR. `GET /ranking?upaId=` devolve UMA LINHA POR MEDICO DA REDE: nome, CRM, "
 "as UPAs dele, `trimestreActual` (a soma das linhas ⛔ nao anuladas do trimestre civil corrente, que "
 "e' por quem ORDENA) e `mediaHistorica` (a media dos trimestres civis JA' FECHADOS, que ⛔ NAO ordena) "
 "— e a posicao na REDE. 🩸 `mediaHistorica` E' `null` E ⛔ NAO `0` QUANDO ⛔ NAO HA' TRIMESTRE FECHADO "
 "(regra 118 §1: «zero diz esforco nenhum; traco diz ainda ⛔ nao ha' historia»): o servidor manda "
 "`null`, ⛔ nao um zero que o ecra tenha de adivinhar. 🩸 E ⛔ NAO SE GUARDA COLUNA DE MEDIA (decisao "
 "(d)): CALCULA-SE das linhas, e por isso o trimestre a zerar ⛔ nao recalcula o passado. 🩸 A UPA E' "
 "LENTE E ⛔ NAO UM RANKING PROPRIO (regra 117): quem esta' em TRES UPAs aparece nas TRES lentes com os "
 "MESMOS dois numeros e UM SO' lugar na rede — ⛔ NAO se somam pontos por unidade, e isso conta-se. "
 "Sem `upaId` sao TODAS. 🩸 QUEM VE, PELO SERVIDOR E ⛔ NAO PELO BOTAO ESCONDIDO: o COORDENADOR ve' a "
 "lista nominal de TODAS as UPAs e ⛔ NAO so' das suas (regra 50, e e' o contrario do que o `vinculo` "
 "faz — aqui ⛔ NAO se filtra pelas UPAs dele), a SECRETARIA ve' tudo (regra 112), e um MEDICO que peca "
 "esta lista leva 403 (regras 43(1) e 50). ⚠️ ⛔ NENHUM NOME DE COLEGA CHEGA A UM MEDICO POR ESTA ROTA, "
 "e ⛔ nao ha' `?eu=` nem atalho: o que um medico ve' e' do CS-133, que espera a resposta 117. "
 "⛔ NENHUMA migracao, ⛔ nenhum campo novo e ⛔ NENHUM total pre-calculado",
 ["backend/src/modules/ranking/", "backend/src/app.module.ts", "backend/test/ranking.e2e-spec.ts"],
 jest("ranking.e2e-spec.ts", [
  "numa cena de 3 medicos e 2 UPAs a lista traz uma linha por medico com os dois numeros lado a lado",
  "a ordem e pela coluna do trimestre actual e nunca pela media historica",
  "um medico sem nenhum trimestre fechado traz mediaHistorica nula e nao zero",
  "um medico com trimestre 270 e media 145 le-se como tal, e os dois numeros vem da soma das linhas",
  "a soma das linhas do extracto e igual ao numero do trimestre no ranking, conferida nos dois sitios",
  "o trimestre e civil: um lancamento de 31/12 nao entra no trimestre de um de 01/01",
  "o medico em 3 UPAs aparece nas 3 lentes com os MESMOS dois numeros e um so lugar na rede",
  "sem upaId vem a rede toda, e com upaId vem so quem tem vinculo aceito naquela UPA",
  "a media historica nao esta guardada em coluna nenhuma: calcula-se, e apagar cache nenhuma a muda",
  "as linhas anuladas nao entram em nenhuma das duas colunas",
  "o coordenador ve a lista nominal de TODAS as UPAs e nao so das suas",
  "a secretaria ve tudo",
  "um medico que peca a lista dos outros leva 403 do servidor",
  "o especialista leva 403",
  "sem sessao da 401 e nao 403, porque o guarda de sessao corre antes"],
  ["cd backend && grep -q 'ModuloRanking' src/app.module.ts",
   "cd backend && git diff --name-only main -- prisma/ | wc -l | grep -qx 0",
   "cd backend && ! grep -rqiE 'mediaHistorica\\s*Decimal|media\\s+Decimal' prisma/schema.prisma",
   "cd backend && ! grep -rqiE 'top5|topo|\\beu\\b' src/modules/ranking/ranking.controlador.ts",
   "cd backend && npx jest test/ponto.e2e-spec.ts test/vinculo.e2e-spec.ts --runInBand --ci"]),
 ["CS-122"]))

CARDS.append(c("CS-132", B,
 "O PERCURSO DO MEDICO NUMA RESPOSTA SO': O PROGRESSO CONTRA O OBRIGATORIO E A LINHA DO TEMPO AULA A "
 "AULA (regra 118 §2 — o dono escolheu AS DUAS «porque respondem a perguntas diferentes: o progresso "
 "diz O QUE FALTA FAZER; a linha do tempo e' o EXTRACTO AUDITAVEL»). `GET /percurso/eu` devolve, para a "
 "SESSAO e so' para ela: (1) O PROGRESSO — «12 h de 16 h» e «2 de 6», uma linha POR UPA dele COM O NOME "
 "DA UNIDADE (a leitura resolvida do CS-128), e o que falta dito POR EXTENSO; (2) A LINHA DO TEMPO, por "
 "ordem e AULA A AULA, com os MINUTOS VALIDADOS, as QUESTOES ACERTADAS, os CERTIFICADOS APROVADOS E POR "
 "QUEM, e a PRESENCA em curso confirmada — cada item com a sua linha de ponto e o id do lancamento, "
 "porque 🩸 E' DA LINHA DO TEMPO QUE SE CONTESTA (CS-129) e sem esse id o medico ⛔ nao tem o que "
 "apontar. ⚠️ ISTO ⛔ NAO RECALCULA NADA E ⛔ NAO REFAZ FACTO NENHUM (trava (10)): LE' `LancamentoDePonto`, "
 "`participacao_aula` e `Obrigatoriedade` — ⛔ ZERO escritas, e prova-se contando as linhas da base antes "
 "e depois. 🩸 E OS MINUTOS VAO POR BLOCO E ⛔ NUNCA COMO UMA PERCENTAGEM SO': a resposta traz os blocos "
 "VALIDADO, A' ESPERA e PERDIDO separados e o TOTAL AO LADO, porque «uma barra lisa MENTE AO MEDICO» "
 "(regra 84.1-2) e o ecra ⛔ nao pode inventar o que o servidor ⛔ nao lhe deu. `eu` E' A SESSAO: ⛔ nao ha' "
 "`?medicoId=`, e os outros tres papeis levam 403",
 ["backend/src/modules/percurso/", "backend/src/app.module.ts", "backend/test/percurso.e2e-spec.ts"],
 jest("percurso.e2e-spec.ts", [
  "o progresso traz 12 h de 16 h e 2 de 6, e o que falta dito por extenso",
  "um medico em duas UPAs traz duas linhas de progresso, cada uma com o nome da unidade",
  "a linha do tempo vem aula a aula e por ordem, com minutos validados e questoes acertadas",
  "cada certificado aprovado traz POR QUEM foi aprovado e quando",
  "cada item da linha do tempo traz o id do lancamento, que e o que o medico vai apontar para contestar",
  "os minutos vem por bloco: validado, a espera e perdido separados, e o total ao lado",
  "ler o percurso nao escreve nada: as linhas da base sao as mesmas antes e depois",
  "o percurso nao recalcula minutosValidados nem um segmento: os valores sao os que ja estavam gravados",
  "eu e a sessao: nao ha parametro por onde pedir o percurso de outro",
  "coordenador, secretaria e especialista levam 403",
  "um medico sem nada feito le um percurso vazio com sentido e nao um erro",
  "sem sessao da 401 e nao 403, porque o guarda de sessao corre antes"],
  ["cd backend && grep -q 'ModuloPercurso' src/app.module.ts",
   "cd backend && ! grep -rqE 'prisma\\.[A-Za-z]+\\.(create|update|upsert|delete|createMany|updateMany)' src/modules/percurso/",
   "cd backend && ! grep -rq 'medicoId' src/modules/percurso/percurso.controlador.ts",
   "cd backend && git diff --name-only main -- prisma/ | wc -l | grep -qx 0",
   "cd backend && npx jest test/ponto.e2e-spec.ts test/obrigatoriedade.e2e-spec.ts --runInBand --ci"]),
 ["CS-128", "CS-122"]))

CARDS.append(c("CS-133", B,
 "⚠️⚠️ ESTE CARD ⛔ NAO SE ESPECIFICA ANTES DA RESPOSTA AO PEDIDO 117 — E' A UNICA PECA EM ESPERA DESTA "
 "DEMANDA, e o precedente e' o CS-34. O QUE UM MEDICO VE SOBRE SI E SOBRE OS OUTROS: `GET /pontuacao/eu`. "
 "O QUE ESTA' DECIDIDO NAS TRES OPCOES e entra de qualquer maneira: os SEUS DOIS NUMEROS (trimestre "
 "actual e media historica, `null` quando ⛔ nao ha' trimestre fechado) e O SEU LUGAR NA REDE («o 12.o de "
 "84») — regras 43(1) e 50, «cada medico ve' o SEU». 🩸 O QUE A RESPOSTA 117 DECIDE, e por isso o card "
 "espera: em (A) a rota devolve A UM MEDICO os CINCO primeiros DA REDE pelo nome (e, pela regra 134, "
 "com a FOTO, porque a foto SEGUE o nome); em (C) devolve os cinco primeiros DAS UPAs DELE; em (B) "
 "⛔ NAO devolve nome de colega nenhum e o top 5 ⛔ nao existe no ecra do medico. ⟹ Sao GUARDAS "
 "DIFERENTES e CORPOS DIFERENTES, e o alvo de um ⛔ nao serve para o outro. ⚠️ ATE' A RESPOSTA CHEGAR, "
 "⛔ NENHUM NOME DE COLEGA APARECE A UM MEDICO EM ROTA NENHUMA deste epic — o `GET /ranking` do CS-131 "
 "da'-lhe 403, e isso ja' esta' contado la'. E conta-se DO MESMO MODO que o resto: as duas colunas saem "
 "da SOMA DAS LINHAS, ⛔ nao de um total guardado. ⛔ NENHUMA migracao e ⛔ NENHUM ecra (o ecra e' o CS-146)",
 ["backend/src/modules/ranking/", "backend/test/pontuacao-do-medico.e2e-spec.ts"],
 jest("pontuacao-do-medico.e2e-spec.ts", [
  "o medico le os seus dois numeros e o seu lugar na rede, e os numeros batem com a soma do seu extracto",
  "um medico sem trimestre fechado recebe media nula e nao zero",
  "o lugar e o da REDE e nao o da UPA, e um medico em 3 UPAs tem um so lugar",
  "o que o medico ve sobre os outros e exactamente o que a resposta ao pedido 117 disse, e nada mais",
  "o top 5 conta-se do mesmo modo: sai da soma das linhas e nao de um total guardado",
  "o medico nao chega a lista nominal completa por esta rota",
  "as linhas anuladas nao entram em nenhum dos dois numeros",
  "coordenador, secretaria e especialista: o que cada um leva nesta rota esta contado",
  "sem sessao da 401 e nao 403, porque o guarda de sessao corre antes",
  "ler a pontuacao nao escreve uma linha na base"],
  ["cd backend && git diff --name-only main -- prisma/ | wc -l | grep -qx 0",
   "cd backend && ! grep -rqE 'prisma\\.[A-Za-z]+\\.(create|update|upsert|delete)' src/modules/ranking/",
   "cd backend && npx jest test/ranking.e2e-spec.ts --runInBand --ci"]),
 ["CS-131"]))

# ══════════════════════════════════════════════════════════════════ FRONTEND
NAV = ("frontend/src/lib/navegacao.ts")
SPECS3 = ["frontend/e2e/navegacao.spec.ts", "frontend/e2e/alcance.spec.ts",
          "frontend/e2e/botoes.spec.ts"]

SEIS = [
  "os seis estados do ecra: vazio, a carregar, erro, sem permissao, sucesso e parcial",
  "o parcial mostra as tres coisas ao mesmo tempo e o total em tabular AO LADO, nunca em vez",
  "a carregar tem o esqueleto da forma final, sem brilho a deslizar, e o ecra nao salta quando o dado chega",
  "o erro fica so no painel afectado, com o que aconteceu e o que fazer, e o resto do ecra continua legivel",
  "quem nao tem direito ve SEM PERMISSAO com o papel dito, e nunca o dado por baixo nem desfocado",
  "o foco de teclado ve-se em cada peca e o alvo de toque tem 48 por 48",
  "com prefers-reduced-motion reduce as duracoes sao zero e o estado final e o mesmo pixel",
  "zero botoes mudos e nenhum numero desce por errar: descer e o triangulo para baixo em onSurfaceVariant COM o numero escrito",
]
CERCA_UI = [
  "cd frontend && ! grep -rqE '#[0-9a-fA-F]{3,8}|shimmer|animate-pulse|<progress|LinearProgress|blur\\(' {D}",
  "cd frontend && ! grep -rqE 'tokens\\.css|_produto/design' {D}",
]

def ui(chave, titulo, pasta, spec, nomes, dep, nav=True, extra=None):
    paths = [pasta] + ([NAV] if nav else []) + [f"frontend/e2e/{spec}"] + (SPECS3 if nav else [])
    ac = pw(spec, nomes + SEIS) + [x.replace("{D}", pasta) for x in CERCA_UI] + (extra or []) + [TRES]
    return c(chave, F, titulo, paths, ac, dep)

CARDS.append(ui("CS-134",
 "A 1.a PORTA NOVA DO MEDICO — «O MEU PERCURSO» (`/percurso`): O PROGRESSO EM CIMA, A LINHA DO TEMPO EM "
 "BAIXO, NO MESMO ECRA (regra 118 §2). HOJE, MEDIDO: o medico tem UMA porta, «Calendario» "
 "(`navegacao.ts:146-154`), e as PORTAS sao SETE e a tabela e' FECHADA (`navegacao.ts:68`). ⟹ Entra a "
 "OITAVA, e as tres provas de navegacao ACTUALIZAM-SE e ⛔ NAO se contornam (trava (11)). ⚠️ DUAS "
 "INVARIANTES MEDIDAS que este card tem de respeitar e que partem o e2e se forem esquecidas: "
 "(1) `navegacao.spec.ts:570` exige `[...portas, ...orfas].sort() === paginasNoDisco()` — IGUALDADE "
 "EXACTA NOS DOIS SENTIDOS ⟹ a pagina nova ENTRA em `PORTAS` ou o teste parte; (2) `:597` exige "
 "`oferecidas[0] === INICIO[papel]` ⟹ «Calendario» TEM DE CONTINUAR A SER A PRIMEIRA porta do MEDICO, "
 "porque `INICIO.MEDICO = '/calendario'` (`inicios.ts:17`) e ⛔ NAO se toca em `inicios.ts`. EM CIMA: as "
 "barras de «12 h de 16 h» e «2 de 6», uma por UPA dele COM O NOME DA UNIDADE A UMA LINHA (regra 83.a). "
 "EM BAIXO: a linha do tempo aula a aula, com os minutos validados, as questoes acertadas e os "
 "certificados aprovados E POR QUEM. 🩸 OS MINUTOS SAO BLOCOS E ⛔ NUNCA UMA BARRA LISA: validado "
 "(`validadoContainer` + ✓ + palavra), a' espera (`esperaContainer` + ◷ + palavra) e PERDIDO — 🩸 VAZADO, "
 "contorno 1dp `outline` + – + palavra, ⛔ NAO vermelho e ⛔ NAO riscado (regra 84.1-2, e errar ⛔ NAO TIRA "
 "PONTOS) —, cada bloco com NOME ACESSIVEL PROPRIO, e o TOTAL EM TABULAR AO LADO, ⛔ NUNCA EM VEZ. E UMA "
 "LINHA QUE EXPLICA A DIFERENCA entre o que se assistiu e o que contou, que e' o que o medico vai "
 "reclamar. ⛔ NENHUM ecra velho morre, ⛔ nada de `_produto/design/` nem `tokens.css` (a cara e' a "
 "demanda 30), ⛔ nenhum hex solto",
 "frontend/src/app/percurso/", "percurso.spec.ts", [
  "o medico clica «O meu percurso» no menu e chega ao ecra, e o Calendario continua a ser a primeira porta dele",
  "o progresso em cima e a linha do tempo em baixo, no MESMO ecra e sem rolar para outro",
  "le-se 12 h de 16 h e 2 de 6, e o que falta vai dito por extenso",
  "um medico em duas UPAs ve duas linhas de progresso, cada uma com o nome da unidade escrito ao lado",
  "a linha do tempo mostra os minutos validados, as questoes acertadas e os certificados aprovados E por quem",
  "os minutos sao blocos: validado, a espera e perdido convivem no mesmo ecra",
  "o bloco perdido e VAZADO e nao vermelho nem riscado, e leva a palavra ao lado",
  "cada bloco tem nome acessivel proprio e percorre-se por teclado",
  "o total dos minutos vai em tabular AO LADO dos blocos e nunca em vez deles",
  "uma linha explica a diferenca entre o que se assistiu e o que contou",
  "nao ha barra de progresso lisa a representar minutos em ecra nenhum deste card",
  "o coordenador, a secretaria e o especialista nao veem esta porta no menu",
  "as portas do medico passam de uma para duas, contadas na fonte unica",
 ], ["CS-132"], extra=[
  "cd frontend && ! grep -rqE '<progress|LinearProgress|role=\"progressbar\"' src/app/percurso/",
  "cd frontend && grep -q \"MEDICO: '/calendario'\" src/lib/inicios.ts",
  "cd frontend && git diff --name-only main -- src/lib/inicios.ts | wc -l | grep -qx 0",
  "cd frontend && ! grep -rq 'fetch(' src/app/percurso/",
 ]))

CARDS.append(ui("CS-135",
 "A 2.a PORTA NOVA DO MEDICO — «O QUE E' OBRIGATORIO PARA MIM» (`/obrigatorio`): AS DUAS EXIGENCIAS, "
 "CADA UMA COM O NOME DA UNIDADE. HOJE, MEDIDO: ⛔ nao existe conceito de obrigatoriedade em ecra "
 "nenhum (regra 114 §3, a letra: «Hoje ⛔ NAO existe conceito de obrigatoriedade no produto»). ⟹ O ecra "
 "mostra, do `GET /obrigatoriedade/eu` do CS-128: as HORAS POR TRIMESTRE e as AULAS POR TRIMESTRE que "
 "lhe pesam, e 🩸 UM MEDICO EM DUAS UPAS COM EXIGENCIAS DIFERENTES VE AS DUAS, cada uma COM O NOME DA "
 "UNIDADE ESCRITO AO LADO (regra 83.a: o avatar ⛔ NUNCA e' o unico identificador da UPA — o nome esta' "
 "SEMPRE a uma linha dele; um ecra que mostre UPA sem o nome escrito ⛔ NAO BATE) e DIZENDO DE QUE NIVEL "
 "VEIO — da REDE ou da UNIDADE. ⚠️ E O ECRA DIZ O QUE FALTA, ⛔ NAO SO' O QUE E' DEVIDO: sem isso «o que "
 "e' obrigatorio para mim» ⛔ nao responde a pergunta que o dono fez. 🩸 ⛔ NENHUM CASTIGO NA CARA (trava "
 "(9)): quem esta' atras ⛔ NAO leva vermelho — vermelho e' cor de triagem para medico de UPA e `error` "
 "so' entra em ERRO DE SISTEMA (regra 80.3). A porta entra na fonte unica (a NONA) e as tres provas de "
 "navegacao actualizam-se; «Calendario» continua a ser a PRIMEIRA porta do medico "
 "(`navegacao.spec.ts:597`). ⛔ Nenhum ecra velho morre",
 "frontend/src/app/obrigatorio/", "obrigatorio.spec.ts", [
  "o medico clica «O que e obrigatorio para mim» e chega ao ecra pelo menu",
  "le-se as horas por trimestre e as aulas por trimestre que lhe pesam",
  "um medico da UPA X ve 24 h e um de outra UPA ve 16 h, no mesmo ecra e sem recarregar codigo",
  "um medico em duas UPAs ve as DUAS exigencias, cada uma com o nome da unidade escrito ao lado",
  "cada exigencia diz de que nivel veio: da rede ou da unidade",
  "o ecra diz o que FALTA e nao so o que e devido",
  "quem esta atras nao leva vermelho nenhum: nao ha castigo na cara deste ecra",
  "as duas mudam sem deploy: a secretaria muda a da rede e o ecra ja mostra o numero novo",
  "as portas do medico passam de duas para tres, contadas na fonte unica",
  "o coordenador, a secretaria e o especialista nao veem esta porta no menu",
 ], ["CS-128", "CS-134"], extra=[
  "cd frontend && ! grep -rqE 'BA1A1A|--cor-error|error\\)' src/app/obrigatorio/",
  "cd frontend && ! grep -rq 'fetch(' src/app/obrigatorio/",
 ]))

CARDS.append(ui("CS-136",
 "A 3.a PORTA NOVA DO MEDICO — «PEDIR UM TEMA DE AULA» (`/pedir-tema`), E ELE CHEGA A' SECRETARIA E "
 "⛔ NAO AO COORDENADOR (regra 118 §4 sobre a 114, e MANDA A POSTERIOR). O medico escreve o tema que "
 "quer aprender e manda; a confirmacao VIVE NA PROPRIA PECA que mudou e o rotulo passa ao participio do "
 "verbo do botao («Pedir» ⟹ «PEDIDO»), ⛔ SEM snackbar a confirmar o que ja' se ve' (regra 84, sucesso). "
 "🩸 O BOTAO A TRABALHAR MANTEM O ROTULO NO SITIO e a largura ⛔ NAO MUDA — indicador circular 18dp a' "
 "esquerda, botao inactivo (regra 86: «⛔ nunca um botao que encolhe»), e o botao ⛔ NAO E' MUDO (leva "
 "marca, `botoes.spec.ts`). Um tema vazio ⛔ nao se manda, e o texto de erro do campo DIZ O QUE ACONTECEU "
 "E O QUE FAZER — ⛔ nunca «campo invalido». ⚠️ E O PEDIDO ⛔ NAO E' UM DIALOGO: e' o ecra (regra 86). "
 "⛔ NENHUMA porta «Pedidos» nova para o coordenador, ⛔ NENHUM aviso e ⛔ NENHUM email (regra 133). A "
 "porta e' a DECIMA da fonte unica e «Calendario» continua a PRIMEIRA do medico",
 "frontend/src/app/pedir-tema/", "pedir-tema.spec.ts", [
  "o medico clica «Pedir um tema de aula» e chega ao ecra pelo menu",
  "escreve o tema, manda, e a confirmacao vive na propria peca: o rotulo passa ao participio do verbo",
  "nao ha snackbar a confirmar o que ja se ve no ecra",
  "o botao a trabalhar mantem o rotulo no sitio e a largura nao muda",
  "um tema vazio nao se manda e o erro do campo diz o que aconteceu e o que fazer",
  "o pedido chega a lista da SECRETARIA e a nenhuma lista do coordenador",
  "o ecra nao e um dialogo: e pagina, com endereco proprio",
  "as portas do medico passam de tres para quatro contando esta, e o Calendario continua a primeira",
  "o coordenador nao ve esta porta no menu e a porta Pedidos dele continua a ser para aceitar medico",
 ], ["CS-130", "CS-135"], extra=[
  "cd frontend && ! grep -rqiE 'Dialogo|snackbar' src/app/pedir-tema/",
  "cd frontend && ! grep -rq 'fetch(' src/app/pedir-tema/",
  "cd frontend && npx playwright test e2e/coordenador.spec.ts --reporter=line",
 ]))

CARDS.append(ui("CS-137",
 "O RANKING NOMINAL DE QUEM ESCALA (`/ranking`) — AS DUAS COLUNAS, A LENTE POR UPA E O TRACO. Porta "
 "para o COORDENADOR (de TODAS as UPAs, regra 50) e para a SECRETARIA (regra 112, que DESFEZ o criterio "
 "«instrumento de trabalho» escrito em `navegacao.ts:74` — ⛔ nao era decisao do dono). O ecra e' a "
 "LISTA DENSA do M3, o instrumento de quem varre oitenta nomes, com A ORDEM DE COLUNAS QUE E' A MESMA "
 "EM TODO O PRODUTO: [avatar da UPA] | nome + CRM | os numeros em TABULAR a' direita | ETIQUETA DE "
 "ESTADO SEMPRE NA ULTIMA COLUNA (regra 86). `font-variant-numeric: tabular-nums` e' OBRIGATORIO SEM "
 "EXCEPCAO. 🩸 TRIMESTRE ACTUAL E MEDIA HISTORICA LADO A LADO, ordenado pela PRIMEIRA, e a media "
 "⛔ NAO ordena. 🩸 QUEM ⛔ NAO TEM TRIMESTRE FECHADO MOSTRA «—» E ⛔ NAO 0 (regra 118 §1: o servidor manda "
 "`null` e o ecra escreve o traco; um zero ali ACUSA QUEM ACABOU DE CHEGAR). FILTRA-SE POR CHIP DE "
 "FILTRO DO M3 com ✓ de 18dp A' FRENTE (regra 83.1: 32dp, canto 8dp, `secondaryContainer`, «Todas» a' "
 "cabeca, e o chip DIZ O NOME POR EXTENSO), e a contagem le-se «1 de 3 · limpar ⟹ 3 de 3». 🩸 UM "
 "MEDICO EM 3 UPAS APARECE NAS 3 LENTES COM OS MESMOS DOIS NUMEROS E UM SO' LUGAR NA REDE — ⛔ NAO sao "
 "dois rankings. ⛔ SEM ZEBRA, rato = camada 8% e ⛔ NAO levanta a linha. 🩸 DESCER NO RANKING E' ▼ "
 "`onSurfaceVariant` COM O NUMERO ESCRITO e ⛔ NUNCA vermelho (regra 84.b). ⛔ NENHUMA foto de medico "
 "neste ecra ate' a resposta 117 — a regra 134 diz que a foto SEGUE o nome, e o nome ja' aqui esta', mas "
 "quem chega a este ecra e' SO' quem escala; ⛔ nenhum medico o alcanca, e isso conta-se pelo 403 do "
 "servidor",
 "frontend/src/app/ranking/", "ranking.spec.ts", [
  "o coordenador clica «Ranking» no menu e ve a lista nominal de TODAS as UPAs e nao so das suas",
  "a secretaria ve a mesma lista pela sua porta",
  "cada linha traz trimestre actual e media historica lado a lado, e a ordem e pela primeira",
  "um medico sem trimestre fechado mostra um traco na media e nunca 0",
  "um medico com trimestre 270 e media 145 le-se como tal, e os numeros vao em tabular",
  "filtra-se por chip de UPA com o visto a frente, e le-se 1 de 3; limpar volta a 3 de 3",
  "o chip diz o nome da UPA por extenso e Todas esta a cabeca",
  "o medico em 3 UPAs aparece nas 3 lentes com os MESMOS dois numeros e um so lugar na rede",
  "o total em tabular fica AO LADO e nunca em vez da lista",
  "descer no ranking e um triangulo para baixo em onSurfaceVariant com o numero escrito, e nunca vermelho",
  "a lista nao tem zebra e o rato numa linha nao a levanta",
  "as colunas vem na ordem de todo o produto e a etiqueta de estado fica na ultima",
  "um medico que escreva o endereco a mao ve SEM PERMISSAO com o papel dito, porque o servidor lhe da 403",
  "nenhuma foto de medico aparece neste ecra",
 ], ["CS-131", "CS-136"], extra=[
  "cd frontend && grep -rq 'tabular' src/app/ranking/",
  "cd frontend && ! grep -rqiE 'nth-child|zebra|odd\\)' src/app/ranking/",
  "cd frontend && ! grep -rq 'fetch(' src/app/ranking/",
 ]))

CARDS.append(ui("CS-138",
 "O ECRA DA SECRETARIA ONDE OS TIPOS DE CERTIFICADO E QUANTO VALEM SE MUDAM SEM DEPLOY "
 "(`/secretaria/certificados`) — regra 53, e e' o ecra que faz a SEPARACAO DE PODERES ser visivel: "
 "ELA diz QUANTO VALE, o coordenador diz SE E' VERDADE, e «quem partir isto em cards ⛔ nao deve juntar "
 "as duas coisas no mesmo ecra' nem na mesma permissao». ⟹ ⛔ NENHUM certificado de medico aparece "
 "AQUI, e ⛔ nenhum botao de aprovar. Os TRES tipos que o dono nomeou (curso · congresso · artigo "
 "publicado) leem-se da base com o seu valor; ela CRIA um quarto, MUDA um valor e ARQUIVA — ⛔ NUNCA "
 "apaga, no molde MEDIDO do nucleo. 🩸 ARQUIVAR E' O UNICO ACTO DESTRUTIVO E POR ISSO E' O UNICO "
 "DIALOGO (regra 86: dialogo serve SO' para confirmar um acto curto e destrutivo), e o botao de arquivar "
 "e' BOTAO DE TEXTO com VERBO ACTIVO e ⛔ NAO um botao vermelho (regra 84.c). A ACCAO PRINCIPAL e' UMA "
 "SO' por ecra (Filled). Os valores vao em TABULAR. ⚠️ E O ECRA DIZ, EM TEXTO, QUE MUDAR O VALOR ⛔ NAO "
 "REESCREVE O QUE JA' FOI GANHO — e' a decisao (b) e e' a primeira coisa que ela vai perguntar. A porta "
 "e' nova na fonte unica e as tres provas actualizam-se",
 "frontend/src/app/secretaria/certificados/", "certificados-tipos.spec.ts", [
  "a secretaria clica a porta e ve os tres tipos com o valor de cada um, em tabular",
  "cria um quarto tipo e ele aparece na lista sem recarregar o produto",
  "muda quantos pontos um tipo vale e o numero novo aparece na peca que mudou",
  "arquivar e o unico dialogo do ecra, e o botao e de texto com verbo activo e nunca vermelho",
  "o tipo arquivado sai da lista de escolha e continua a ler-se como arquivado",
  "o ecra diz em texto que mudar o valor nao reescreve o que ja foi ganho",
  "nenhum certificado de medico aparece neste ecra e nao ha botao de aprovar nenhum",
  "a accao principal e uma so por ecra",
  "o coordenador que escreva o endereco a mao ve SEM PERMISSAO com o papel dito",
  "o medico e o especialista nao veem esta porta no menu",
 ], ["CS-126", "CS-137"], extra=[
  "cd frontend && ! grep -rqiE 'aprovar|pendente' src/app/secretaria/certificados/",
  "cd frontend && grep -rq 'tabular' src/app/secretaria/certificados/",
  "cd frontend && ! grep -rq 'fetch(' src/app/secretaria/certificados/",
 ]))

CARDS.append(ui("CS-139",
 "A OBRIGATORIEDADE DA REDE, NO ECRA DA SECRETARIA (`/secretaria/obrigatoriedade`) — o PRIMEIRO dos "
 "dois niveis (regra 114: «Os dois niveis», a secretaria para A REDE). Ela poe as HORAS POR TRIMESTRE e "
 "as AULAS POR TRIMESTRE que valem para TODA a rede, e o ecra diz QUANTOS MEDICOS aquilo passa a "
 "pesar. 🩸 E DIZ, EM TEXTO, QUE UMA UPA PODE EXIGIR MAIS: sem essa frase a secretaria acredita que o "
 "numero dela e' o unico, e e' exactamente o engano que os dois niveis criam. Campos OUTLINED do M3, "
 "56dp, canto 4dp, com apoio em `body-small`; o erro do campo DIZ O QUE ACONTECEU E O QUE FAZER e "
 "⛔ NUNCA «campo invalido». A confirmacao VIVE NA PECA que mudou e o rotulo passa ao participio do "
 "verbo — ⛔ SEM snackbar. Os numeros em TABULAR. ⚠️ MUDAR ⛔ NAO RECALCULA UM UNICO PONTO JA' GANHO, e "
 "o ecra di-lo. ⛔ NENHUMA exigencia por UPA neste ecra (e' o CS-144) e ⛔ NENHUM ecra velho morre",
 "frontend/src/app/secretaria/obrigatoriedade/", "obrigatoriedade-rede.spec.ts", [
  "a secretaria clica a porta e poe 16 h por trimestre para a rede",
  "o ecra diz quantos medicos aquilo passa a pesar",
  "o ecra diz em texto que uma UPA pode exigir mais",
  "o erro do campo diz o que aconteceu e o que fazer, e nunca campo invalido",
  "a confirmacao vive na peca que mudou e o rotulo passa ao participio, sem snackbar",
  "os numeros vao em tabular",
  "mudar nao recalcula um ponto ja ganho, e o ecra di-lo",
  "nao ha exigencia por UPA neste ecra",
  "o coordenador que escreva o endereco a mao ve SEM PERMISSAO com o papel dito",
  "o medico e o especialista nao veem esta porta no menu",
 ], ["CS-128", "CS-138"], extra=[
  "cd frontend && ! grep -rqiE 'snackbar' src/app/secretaria/obrigatoriedade/",
  "cd frontend && ! grep -rq 'upaId' src/app/secretaria/obrigatoriedade/",
  "cd frontend && ! grep -rq 'fetch(' src/app/secretaria/obrigatoriedade/",
 ]))

CARDS.append(ui("CS-140",
 "DECIDIR A CONTESTACAO E' PAGINA DA SECRETARIA, ⛔ NAO DIALOGO (regra 86, e a trava vem da propria "
 "referencia que o dono apontou: o autor do caso escreve, na captura 5, que substituiria os fluxos "
 "cheios de modal por PAGINAS DEDICADAS em revisao, APROVACAO e gestao). ⟹ DUAS paginas: "
 "`/secretaria/contestacoes`, a lista do que espera por ela, e `/secretaria/contestacoes/[id]`, ONDE SE "
 "DECIDE — e a segunda e' ORFA DELIBERADA («e' DETALHE, ⛔ nao seccao», o mesmo motivo JA' ESCRITO para "
 "`/coordenador/pedidos/[id]` em `navegacao.ts:197-200`), alcancada CLICANDO UMA LINHA da lista. Na "
 "pagina de uma contestacao le-se: A LINHA CONTESTADA INTEIRA (origem, valor, data e a prova que a "
 "sustenta), O QUE O MEDICO DISSE, e os dois actos. 🩸 A LINHA CONTESTADA ⛔ NUNCA DESAPARECE DO ECRA, "
 "nem depois de aceite: fica, marcada, com o veredito e COM QUEM DECIDIU E QUANDO. 🩸 «PENDENTE» TEM "
 "FORMA PROPRIA — `esperaContainer` + ◷ + PALAVRA — e ⛔ NAO e' um aviso amarelo de biblioteca: e' a "
 "ESPINHA deste produto (regra 84.d), e a etiqueta de estado e' CHIP DE SUGESTAO na ULTIMA COLUNA da "
 "lista. ⛔ NENHUM vermelho: recusar uma contestacao ⛔ NAO e' castigo do medico. ⚠️ O COORDENADOR ⛔ NAO "
 "CHEGA AQUI — ⛔ nenhuma porta, e o servidor da'-lhe 403 (CS-129)",
 "frontend/src/app/secretaria/contestacoes/", "contestacoes.spec.ts", [
  "a secretaria clica a porta e ve a lista do que espera por ela, com pendente em forma propria",
  "clica uma linha e chega a pagina de UMA contestacao, que nao e dialogo nenhum",
  "a pagina mostra a linha contestada inteira, com origem, valor, data e a prova que a sustenta",
  "a pagina mostra o que o medico disse",
  "aceita, e a linha contestada continua no ecra marcada, com o veredito e com quem decidiu e quando",
  "recusa, e a linha continua a contar, sem vermelho nenhum",
  "a etiqueta de estado e chip de sugestao e fica na ultima coluna da lista",
  "a pagina de uma contestacao e orfa deliberada e nao aparece no menu de papel nenhum",
  "o coordenador que escreva o endereco a mao ve SEM PERMISSAO com o papel dito",
  "o medico e o especialista nao veem esta porta no menu",
 ], ["CS-129", "CS-139"], extra=[
  "cd frontend && ! grep -rqiE 'Dialogo' src/app/secretaria/contestacoes/",
  "cd frontend && ! grep -rqE 'BA1A1A|--cor-error' src/app/secretaria/contestacoes/",
  "cd frontend && ! grep -rq 'fetch(' src/app/secretaria/contestacoes/",
 ]))

CARDS.append(ui("CS-141",
 "O MEDICO SOBE O SEU CERTIFICADO EXTERNO, E ELE FICA PENDENTE (`/certificados/novo`) — regra 43(2). "
 "ORFA DELIBERADA, alcancada POR CLIQUE de `/percurso` (o CS-134), com o motivo escrito em "
 "`ORFAS_DELIBERADAS`: e' um ACTO, ⛔ nao uma seccao. Ele escolhe o TIPO de uma LISTA CONTROLADA (a do "
 "CS-126 — ⛔ NUNCA texto livre: ⛔ nao se aceita para tipo o que o CS-112 ja' tirou a' especialidade) e "
 "sobe o ficheiro. 🩸 O QUE ELE ⛔ NAO FAZ, e o ecra ⛔ NAO TEM: ⛔ NENHUM campo de pontos. Quanto vale e' "
 "da SECRETARIA (regra 53) e o ecra DIZ-LHE quanto aquele tipo vale, em TABULAR, ⛔ como leitura e "
 "⛔ nunca como campo. 🩸 E DEPOIS DE SUBIR, «PENDENTE» COM FORMA PROPRIA — `esperaContainer` + ◷ + "
 "PALAVRA — e UMA LINHA A DIZER QUE E' O COORDENADOR QUE APROVA E QUE SO' ENTAO PONTUA: sem isso o "
 "medico sobe e acha que ja' ganhou. O botao a trabalhar mantem o rotulo NO SITIO e a largura ⛔ NAO "
 "MUDA; a recusa do ficheiro DIZ O QUE ACONTECEU E O QUE FAZER (⛔ nunca «ficheiro invalido») e o campo "
 "leva contorno 2dp `error` + icone — 🩸 e ESTE e' o unico sitio deste card onde `error` entra, porque "
 "e' ERRO DE SISTEMA e ⛔ NAO castigo (regra 80.3). ⛔ NENHUM certificado EMITIDO pela plataforma: ⛔ zero "
 "brasao, ⛔ zero assinatura, ⛔ zero descarregar-o-meu-certificado (trava (10))",
 "frontend/src/app/certificados/", "certificado-submeter.spec.ts", [
  "o medico chega a este ecra por clique a partir de O meu percurso, e nao pelo menu",
  "escolhe o tipo de uma lista controlada e nunca escreve o tipo a mao",
  "o ecra diz quanto aquele tipo vale, em tabular, como leitura e nunca como campo",
  "nao ha campo de pontos nenhum neste ecra",
  "sobe um PDF e o certificado fica PENDENTE com forma propria: esperaContainer, o relogio e a palavra",
  "uma linha diz que e o coordenador que aprova e que so entao pontua",
  "o botao a trabalhar mantem o rotulo no sitio e a largura nao muda",
  "um ficheiro recusado diz o que aconteceu e o que fazer, e nunca ficheiro invalido",
  "o ecra nao oferece nenhum certificado emitido pela plataforma, nem brasao nem assinatura",
  "o coordenador, a secretaria e o especialista nao chegam a este ecra e veem SEM PERMISSAO",
  "a pagina esta escrita nas orfas deliberadas com o porque e o como se chega la",
 ], ["CS-127", "CS-140"], extra=[
  "cd frontend && ! grep -rqiE 'brasao|assinatura|emitir' src/app/certificados/",
  "cd frontend && ! grep -rqE 'name=\"pontos\"|pontos:\\s*[0-9]' src/app/certificados/",
  "cd frontend && ! grep -rq 'fetch(' src/app/certificados/",
 ]))

CARDS.append(ui("CS-142",
 "CONTESTAR E' PAGINA, E SAI DA LINHA DO EXTRACTO (`/percurso/contestar/[lancamentoId]`) — regra 118 "
 "§3. ORFA DELIBERADA, alcancada CLICANDO O LANCAMENTO na linha do tempo do `/percurso` (CS-134): e' "
 "por isso que o CS-132 poe o id do lancamento em cada item — sem ele o medico ⛔ nao tem o que apontar. "
 "A pagina mostra A LINHA QUE ELE APONTOU, INTEIRA e com a PROVA que a sustenta, e um campo onde ele diz "
 "PORQUE DISCORDA. 🩸 E DIZ-LHE TRES COISAS QUE ELE VAI PERGUNTAR, em texto: que decide A SECRETARIA "
 "(⛔ NAO o coordenador), que A LINHA ⛔ NAO DESAPARECE, e que O TOTAL SO' MUDA DEPOIS DA DECISAO. Depois "
 "de mandar, a linha do extracto passa a levar «EM CONTESTACAO» com forma propria de espera "
 "(`esperaContainer` + ◷ + palavra) e 🩸 O TOTAL ⛔ NAO SE MOVE — prova-se lendo o total ANTES e DEPOIS, no "
 "ecra. ⛔ NENHUM vermelho e ⛔ NENHUM numero a descer: contestar ⛔ nao e' erro de ninguem (trava (9)). "
 "⛔ NAO E' DIALOGO (regra 86) e ⛔ NAO ha' segundo caminho para contestar em ecra nenhum",
 "frontend/src/app/percurso/contestar/", "contestar.spec.ts", [
  "o medico clica um lancamento da linha do tempo e chega a pagina de contestar aquele lancamento",
  "a pagina mostra a linha inteira com a prova que a sustenta",
  "a pagina diz que decide a SECRETARIA e nao o coordenador",
  "a pagina diz que a linha nao desaparece e que o total so muda depois da decisao",
  "manda a contestacao e a linha do extracto passa a EM CONTESTACAO com forma propria de espera",
  "o total do extracto e o mesmo antes e depois de contestar, lido no ecra nos dois momentos",
  "nao ha vermelho nenhum e nenhum numero desce",
  "nao e dialogo: tem endereco proprio e volta-se ao percurso pelo caminho de tras",
  "nao ha segundo caminho para contestar em ecra nenhum do produto",
  "a pagina esta escrita nas orfas deliberadas com o porque e o como se chega la",
 ], ["CS-129", "CS-141"], extra=[
  "cd frontend && ! grep -rqiE 'Dialogo' src/app/percurso/contestar/",
  "cd frontend && ! grep -rqE 'BA1A1A|--cor-error' src/app/percurso/contestar/",
  "cd frontend && ! grep -rq 'fetch(' src/app/percurso/contestar/",
 ]))

CARDS.append(ui("CS-143",
 "APROVAR CERTIFICADO E' PAGINA DO COORDENADOR, ⛔ NAO DIALOGO — regra 86, a letra: «⛔ ACEITAR MEDICO, "
 "CONFIRMAR PRESENCA E APROVAR CERTIFICADO ⛔ NAO SAO DIALOGOS: SAO PAGINAS». ⟹ A LISTA dos certificados "
 "PENDENTES entra em `/coordenador`, o ecra que JA' E' DELE e JA' e' a caixa do que espera por ele "
 "(`navegacao.ts:139-145`, «Pedidos») — 🩸 ⛔ NENHUMA PORTA NOVA, porque um pendente novo ⛔ nao merece uma "
 "gaveta nova quando ja' ha' a gaveta do pendente; e a PAGINA DE UM certificado "
 "(`/coordenador/certificados/[id]`) e' ORFA DELIBERADA, alcancada clicando a linha, com o mesmo motivo "
 "JA' ESCRITO para `/coordenador/pedidos/[id]`. Na pagina: quem submeteu (nome + CRM + as UPAs), O TIPO, "
 "QUANTO AQUELE TIPO VALE em TABULAR — 🩸 COMO LEITURA E ⛔ NUNCA COMO CAMPO: ⛔ NAO HA' UM UNICO CAMPO DE "
 "PONTOS NESTA PAGINA, porque «a SECRETARIA diz QUANTO VALE, o COORDENADOR diz SE E' VERDADE» (regra 53) "
 "— e O FICHEIRO ABERTO PARA VER. Os dois actos sao botoes com VERBO ACTIVO e o MESMO nome reaparece na "
 "confirmacao («Aprovar» ⟹ «APROVADO», `validadoContainer` + ✓). ⛔ RECUSAR ⛔ NAO E' BOTAO VERMELHO "
 "(regra 84.c): e' botao de texto, com verbo activo, e a frase do motivo fica escrita. ⛔ NENHUM ecra "
 "velho morre: a lista de medicos pendentes de `/coordenador` continua exactamente onde estava, e "
 "conta-se",
 "frontend/src/app/coordenador/", "certificado-aprovar.spec.ts", [
  "o coordenador ve os certificados pendentes na sua caixa de Pedidos, sem porta nova nenhuma",
  "a lista de medicos pendentes que ja existia continua exactamente onde estava, contada antes e depois",
  "clica uma linha e chega a PAGINA de um certificado, que nao e dialogo nenhum",
  "a pagina mostra quem submeteu com nome, CRM e as UPAs dele",
  "a pagina mostra o tipo e quanto aquele tipo vale, em tabular e como leitura",
  "nao ha um unico campo de pontos nesta pagina",
  "o ficheiro abre-se para ver a partir da pagina",
  "aprova e o rotulo passa a APROVADO com validadoContainer e o visto, na propria peca",
  "recusar e botao de texto com verbo activo e nunca um botao vermelho, e o motivo fica escrito",
  "a pagina de um certificado e orfa deliberada e nao aparece no menu de papel nenhum",
  "a secretaria, o medico e o especialista que escrevam o endereco a mao veem SEM PERMISSAO",
 ], ["CS-127", "CS-142"], extra=[
  "cd frontend && ! grep -rqiE 'Dialogo' src/app/coordenador/certificados/",
  "cd frontend && ! grep -rqE 'name=\"pontos\"' src/app/coordenador/",
  "cd frontend && npx playwright test e2e/coordenador.spec.ts --reporter=line",
 ]))

CARDS.append(ui("CS-144",
 "A OBRIGATORIEDADE DA SUA UPA, NO ECRA QUE JA' E' DO COORDENADOR — o SEGUNDO dos dois niveis (regra "
 "114). Entra como SECCAO de `/coordenador`: 🩸 ⛔ NENHUMA PAGINA NOVA E ⛔ NENHUM TOQUE EM "
 "`navegacao.ts` — e' o UNICO card de ecra deste epic que ⛔ nao mexe na fonte unica, e ⛔ nao mexe porque "
 "⛔ nao nasce `page.tsx` nenhuma (a invariante de `navegacao.spec.ts:570` e' igualdade exacta nos dois "
 "sentidos: ⛔ sem pagina nova, ⛔ sem entrada nova). Ele poe as HORAS POR TRIMESTRE e as AULAS POR "
 "TRIMESTRE da SUA unidade, UMA POR UPA DELE, cada uma COM O NOME DA UNIDADE ESCRITO AO LADO do avatar "
 "(regra 83.a — um ecra que mostre UPA sem o nome escrito ⛔ NAO BATE) e 🩸 A DIZER O QUE A REDE JA' "
 "EXIGE, para ele saber se esta' a exigir MAIS ou MENOS: sem esse numero ao lado ele poe 8 h achando que "
 "aperta e afrouxa. As UPAs que ele ve' sao SO' AS DELE, e isso ⛔ nao e' o botao escondido: o servidor "
 "da'-lhe 403 na UPA de outro (CS-128). ⚠️ E O CONTRASTE COM O RANKING FICA VISIVEL NO MESMO MENU, e e' "
 "de proposito: aqui ele manda SO' na sua unidade; no `/ranking` (CS-137) ele VE' todas (regra 50). "
 "Numeros em TABULAR; a confirmacao vive NA PECA e ⛔ sem snackbar. ⛔ NENHUM ecra velho morre",
 "frontend/src/app/coordenador/", "obrigatoriedade-upa.spec.ts", [
  "o coordenador poe 24 h por trimestre para a sua UPA, dentro do ecra que ja e dele",
  "nao nasce pagina nenhuma e a fonte unica das portas fica byte a byte igual",
  "um coordenador de duas UPAs poe uma exigencia por UPA, cada uma com o nome da unidade ao lado",
  "cada exigencia diz o que a rede ja exige, para ele saber se esta a exigir mais ou menos",
  "ele nao ve nem escreve a exigencia da UPA de outro, e o servidor da 403",
  "os numeros vao em tabular e a confirmacao vive na peca, sem snackbar",
  "a lista de medicos pendentes e a dos certificados pendentes continuam onde estavam",
  "a secretaria, o medico e o especialista nao veem esta seccao",
 ], ["CS-128", "CS-143"], nav=False, extra=[
  "cd frontend && git diff --name-only main -- src/lib/navegacao.ts | wc -l | grep -qx 0",
  "cd frontend && ! grep -rqiE 'snackbar' src/app/coordenador/",
  "cd frontend && npx playwright test e2e/coordenador.spec.ts e2e/navegacao.spec.ts --reporter=line",
 ]))

CARDS.append(ui("CS-145",
 "O PEDIDO DE TEMA APARECE ONDE ELA CADASTRA A AULA — a lista da SECRETARIA dentro de "
 "`/secretaria/aulas` (regra 118 §4: «e' ela quem cadastra a aula e indica o especialista responsavel, "
 "logo e' ela quem decide o tema»). 🩸 ⛔ NENHUMA PAGINA NOVA E ⛔ NENHUM TOQUE EM `navegacao.ts`: o "
 "pedido de tema e' ENTRADA para cadastrar uma aula, ⛔ nao um assunto a' parte, e por isso vive no ecra "
 "onde o acto acontece — e assim este card corre em PARALELO com todo o resto do frontend, que esta' "
 "serializado pela fonte unica. Le-se, por pedido: o TEMA, QUEM PEDIU (nome + CRM), e QUANDO, em "
 "TABULAR e mais novos primeiro. ⚠️ E' O CARD QUE PROVA O CRITERIO (6) — «PEDIR UM TEMA chega a' "
 "SECRETARIA e ⛔ NAO ao coordenador, provado pela lista onde o pedido aparece». ⛔ NENHUM aceitar, "
 "⛔ nenhum recusar e ⛔ NENHUM estado de decisao: o dono disse «chega a' secretaria» e ⛔ nao pediu fluxo "
 "de aprovacao (e o CS-130 ⛔ nao tem rota para isso). ⛔ NENHUM aviso e ⛔ NENHUM email (regra 133). O "
 "vazio DIZ O QUE ALI VAI ESTAR e ⛔ nao parece avaria. ⛔ NENHUM ecra velho morre: o calendario e o "
 "cadastro de aula que ja' la' estao contam-se antes e depois",
 "frontend/src/app/secretaria/aulas/", "pedidos-de-tema.spec.ts", [
  "a secretaria abre o ecra das aulas e ve a lista dos pedidos de tema, com tema, quem pediu e quando",
  "a data vai em tabular e os mais novos vem primeiro",
  "nao nasce pagina nenhuma e a fonte unica das portas fica byte a byte igual",
  "nao ha aceitar nem recusar nem estado de decisao nenhum na lista",
  "o coordenador nao ve pedido de tema em ecra nenhum",
  "sem pedido nenhum a lista diz o que ali vai estar e nao parece avaria",
  "o calendario e o cadastro de aula continuam exactamente onde estavam",
  "o medico e o especialista nao chegam a esta lista",
 ], ["CS-130"], nav=False, extra=[
  "cd frontend && git diff --name-only main -- src/lib/navegacao.ts | wc -l | grep -qx 0",
  "cd frontend && ! grep -rqiE 'aceitar|recusar|aprovar' src/app/secretaria/aulas/pedidos-de-tema.tsx",
  "cd frontend && npx playwright test e2e/aulas.spec.ts e2e/navegacao.spec.ts --reporter=line",
 ]))

CARDS.append(ui("CS-146",
 "⚠️⚠️ ESTE CARD ⛔ NAO SE ESPECIFICA ANTES DA RESPOSTA AO PEDIDO 117 — e' a ULTIMA peca e a UNICA de "
 "ecra em espera (precedente: CS-34). A 4.a PORTA DO MEDICO — «A MINHA PONTUACAO E O RANKING» "
 "(`/pontuacao`), e com ela o medico passa de UMA porta (`navegacao.ts:146-154`, medido) a QUATRO, que "
 "e' o criterio (6) fechado. O QUE ENTRA DE QUALQUER MANEIRA, porque esta' decidido nas tres opcoes: os "
 "SEUS DOIS NUMEROS lado a lado — trimestre actual e media historica —, «—» E ⛔ NAO 0 quando ⛔ nao ha' "
 "trimestre fechado (regra 118 §1), tudo em TABULAR, e O SEU LUGAR NA REDE. 🩸 SUBIR E' ▲ `validado` + "
 "O NUMERO; DESCER E' ▼ `onSurfaceVariant` + O NUMERO, E O NUMERO VAI SEMPRE ESCRITO — ⛔ NUNCA vermelho, "
 "⛔ nunca uma seta muda (regra 84.b). E UM CAMINHO POR CLIQUE PARA `/percurso`, porque o numero sem o "
 "extracto ⛔ nao se contesta. 🩸 O QUE A RESPOSTA 117 DECIDE: em (A) o ecra mostra os CINCO primeiros DA "
 "REDE pelo nome e — regra 134, a foto SEGUE o nome — COM A FOTO; em (C) os cinco DAS UPAS DELE; em (B) "
 "⛔ NAO ha' top 5 nenhum e ⛔ nenhum nome de colega aparece. ⟹ Sao ecras diferentes e o alvo de um ⛔ nao "
 "serve para o outro. ⚠️ A FOTO ⛔ SO' ENTRA SE A RESPOSTA TROUXER O NOME, e ⛔ NUNCA mais larga do que "
 "ele (regra 134: a visibilidade da foto e' EXACTAMENTE a do nome, ⛔ nunca maior), e TODO ecra tem de "
 "saber viver SEM ela. A porta fecha a fonte unica e as tres provas de navegacao actualizam-se",
 "frontend/src/app/pontuacao/", "pontuacao.spec.ts", [
  "o medico clica «A minha pontuacao e o ranking» e chega ao ecra, e as suas portas passam a quatro",
  "le os seus dois numeros lado a lado, em tabular, e o seu lugar na rede",
  "sem trimestre fechado le um traco na media e nunca 0",
  "subir e um triangulo para cima em validado com o numero escrito, e descer e para baixo em onSurfaceVariant com o numero escrito",
  "nenhum numero desce por errar e nao ha vermelho nenhum neste ecra",
  "ha um caminho por clique para O meu percurso, porque o numero sem o extracto nao se contesta",
  "o que o medico ve sobre os outros e exactamente o que a resposta ao pedido 117 disse, e nada mais",
  "o ecra vive sem foto: um medico sem foto le-se igual e nada parte",
  "a foto nunca aparece onde o nome nao aparece",
  "o Calendario continua a ser a primeira porta do medico",
  "o coordenador, a secretaria e o especialista nao veem esta porta no menu",
 ], ["CS-133", "CS-144"], extra=[
  "cd frontend && ! grep -rqE 'BA1A1A|--cor-error' src/app/pontuacao/",
  "cd frontend && grep -rq 'tabular' src/app/pontuacao/",
  "cd frontend && grep -q \"MEDICO: '/calendario'\" src/lib/inicios.ts",
  "cd frontend && ! grep -rq 'fetch(' src/app/pontuacao/",
 ]))

# ── conferencias antes de entregar ─────────────────────────────────────────
chaves = [k["chave"] for k in CARDS]
assert len(chaves) == len(set(chaves)), "chave repetida"
for k in CARDS:
    for p in k["depende_de"]:
        assert p in chaves, f"{k['chave']} depende de {p}, que nao esta no lote"
    assert k["territorio"] in ("backend", "frontend"), k["chave"]
    assert os.path.isfile(f"/srv/fab/papeis/{k['territorio']}-dev.md"), k["territorio"]
    assert k["escopo_paths"], k["chave"]
    assert len(k["aceites"]) >= 6, (k["chave"], len(k["aceites"]))
    for p in k["escopo_paths"]:
        assert "`" not in p, (k["chave"], p)
        assert p.startswith(("backend/", "frontend/")), (k["chave"], p)
        assert p not in ("backend/src", "backend/src/", "frontend/src", "frontend/src/"), p

# profundidade do grafo + ciclos
prof = {}
def d(ch, vistos=()):
    assert ch not in vistos, f"CICLO em {ch}"
    if ch in prof: return prof[ch]
    k = next(x for x in CARDS if x["chave"] == ch)
    prof[ch] = 1 + max([d(p, vistos + (ch,)) for p in k["depende_de"]] or [0])
    return prof[ch]
for k in CARDS: d(k["chave"])

json.dump(CARDS, open("/srv/produtos/capacitasalvador/escopo/ENQUADRAMENTO-D29.cards.json", "w"),
          ensure_ascii=False, indent=1)
print(f"{len(CARDS)} cards · backend {sum(1 for k in CARDS if k['territorio']=='backend')} · "
      f"frontend {sum(1 for k in CARDS if k['territorio']=='frontend')}")
print("profundidade maxima:", max(prof.values()), "· raizes:",
      [k["chave"] for k in CARDS if not k["depende_de"]])
print("aceites totais:", sum(len(k["aceites"]) for k in CARDS))
