Commit inicial: app de exportação da SD + deploy Docker/Coolify

Aplicação Flask (enviar SD.md -> corrigir pendências -> exportar JSON),
scripts de contrato (scripts/) e infraestrutura de deploy (Dockerfile,
docker-compose.yml) para VPS via Coolify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:07:38 -03:00
co-authored by Claude Sonnet 5
commit 0d4fdd2533
29 changed files with 4268 additions and 0 deletions
+529
View File
@@ -0,0 +1,529 @@
#!/usr/bin/env python3
"""
teste_fluxo.py — prova que o exportador cumpre o contrato da carga SEM mudar a
estrutura do JSON, e que a aplicação web e a CLI são a MESMA régua.
O que está fixado aqui, na ordem em que custa mais errar:
1. ESTRUTURA — o JSON novo tem exatamente as chaves, na mesma ordem, do JSON
que o importador já consome (scripts/backlog/sd/P7-SD31.json). A rotina
de importação depende disso; só VALORES podem mudar.
2. CONTRATO — cada SD que exporta cumpre CONTRATO-JSON-V2.md: linha de OS
preenchida, UST = time-box × semanas(datas), valor = UST × tarifa, janela
≤ 28 dias, vocabulário exato da §6, unicidades da §8.
3. PENDÊNCIAS — as SDs que NÃO exportam ficam pendentes pelos motivos certos,
campo a campo, e nenhuma outra.
4. MESMA RÉGUA — o caminho da app (ruamel) e o da CLI (safe_load) produzem
as mesmas pendências e, quando exportam, os mesmos bytes.
5. As peças reaproveitadas de regras_sd continuam valendo (secao, Seção 5,
tipos do ruamel, definir_secao).
Roda sem framework de teste:
python3 scripts/teste_fluxo.py
0 = tudo bate; 1 = alguma checagem falhou
"""
from __future__ import annotations
import json
import re
import sys
from datetime import date
from caminhos import PROJETOS, RAIZ, sds_reais
from exporta_sd import ExportacaoReprovada, analisar, montar, serializar
from regras_sd import (JANELA_MAXIMA_DIAS, PERFIS_DO_CADASTRO, STATUS_ENTREGAVEL, STATUS_SD,
carregar_canonico, definir_secao, enquadramento_tr, ler_sd_arquivo,
ler_sd_texto, secao, semanas_por_datas)
FALHAS: list[str] = []
# O que cada SD do acervo deve produzir HOJE. Chave = pasta da SD; valor = as
# pendências esperadas como (escopo, indice, campo) — lista vazia = exporta.
# Não é silenciamento: a suíte segue vermelha para qualquer pendência NOVA ou
# para uma esperada que sumir sem o SD.md ter mudado.
def _sem_linha(n: int) -> list[tuple]:
"""SD cujos n entregáveis ainda não declaram `linha_os` no SD.md."""
return [("entregavel", i, "linha_os") for i in range(n)]
ESPERADO = {
"SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"),
("entregavel", 7, "fim")],
"SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")] + _sem_linha(5),
"SD18-integracao-core-rnds-samu": _sem_linha(4),
"SD20-siscan-d1": [("sd", None, "po_responsavel")]
+ [("entregavel", i, c) for i in range(5) for c in ("inicio", "fim")],
"SD24-mvp-institucional": [("entregavel", 3, "semanas")],
"SD27-design-thinking-oficina-superintendencias": _sem_linha(3),
"SD31-fundacao-da-base-de-dispensacao": _sem_linha(4),
"SD32-extracao-automatizada-d-1-do-sigaf": _sem_linha(4),
"SD33-painel-institucional-de-dispensacao": _sem_linha(3),
"SD34-pacientes-ativos-e-alerta-de-inativacao": _sem_linha(3),
"SD35-programacao-estoque-e-faltas": _sem_linha(2),
"SD36-analise-de-processos-ceaf-com-ia-piloto": _sem_linha(3),
}
# O que a pessoa escreveria hoje em `linha_os`, copiado da tabela §5 do contrato
# (banco de 2026-09-03). NÃO é de-para do exportador — ele não tem nenhum, por
# decisão da gestão. Serve só para os testes de contrato e de estrutura rodarem
# sobre um payload completo, como se a SD já estivesse preenchida.
LINHA_DE_TESTE = {"I-02": "1090-L1", "I-03": "2587-L1", "I-04": "2587-L2", "I-05": "2587-L3"}
def com_linhas(sd: dict) -> dict:
"""A SD como ficaria depois de a pessoa preencher `linha_os` no formulário."""
for e in sd.get("entregaveis") or []:
if isinstance(e, dict) and not e.get("linha_os"):
e["linha_os"] = LINHA_DE_TESTE.get(e.get("item"), "1090-L1")
return sd
# Regras do sd-schema.json que NÃO são do contrato da carga — a decisão da
# gestão (2026-09-03) é deixar o importador rejeitar texto fora do teto, e a
# régua de 3..5 bullets de aderência é redação do repositório, não da carga.
# O schema continua sendo conferido aqui, por estrutura; estas ficam nomeadas.
SCHEMA_CONHECIDO = {
"SD32-extracao-automatizada-d-1-do-sigaf":
"o bloco I-02 da Seção 5 tem 2 bullets de aderência e o schema pede 3..5 — regra de "
"redação do repositório, fora do contrato da carga. O exportador não bloqueia por ela.",
}
def checar(condicao: bool, descricao: str) -> None:
print(f" {'ok ' if condicao else 'FALHA'} {descricao}")
if not condicao:
FALHAS.append(descricao)
# ---------------------------------------------------------------------------
# 1. Estrutura
# ---------------------------------------------------------------------------
def _esqueleto(o, pref=""):
"""Todas as listas de chaves do JSON, por caminho — ordem incluída."""
saida = []
if isinstance(o, dict):
saida.append((pref, list(o.keys())))
for k, v in o.items():
saida += _esqueleto(v, f"{pref}/{k}")
elif isinstance(o, list) and o:
saida += _esqueleto(o[0], pref + "[]")
return saida
def _achatar(o, p=""):
if isinstance(o, dict):
for k, v in o.items():
yield from _achatar(v, f"{p}/{k}")
elif isinstance(o, list):
for i, v in enumerate(o):
yield from _achatar(v, f"{p}[{i}]")
else:
yield p, o
# O que o contrato PEDIU que mudasse de valor. Qualquer outro campo diferente
# entre o JSON antigo e o novo é regressão na promessa "estrutura e valores
# iguais, só o que o contrato exige muda".
VALORES_QUE_O_CONTRATO_MUDOU = re.compile(
r"/entregaveis\[\d+\]/(ordem_servico/linha|alocacoes\[\d+\]/perfil"
r"|horas_semanais|ust|valor_unitario_ust|valor_entregavel|memoria_calculo)$"
r"|^/totais/(ust_total|ust_por_item(/.*)?|valor_total)$")
# Chaves cuja PRESENÇA já era condicional no exportador antigo, ou que dependem
# do conteúdo: comparar a lista delas contra um JSON antigo não diz nada sobre
# estrutura. `valor_total` só saía com preço em todos os itens; `ust_por_item`
# tem uma chave por item da SD — hoje sai {} porque a UST é do importador.
CAMINHOS_DE_CONTEUDO = ("/totais", "/totais/ust_por_item")
def estrutura_intacta(canon) -> None:
print("\n· ESTRUTURA: o JSON novo tem as mesmas chaves, na mesma ordem, do que o importador já lê")
pares = 0
for f in sds_reais():
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
ref_path = RAIZ / "scripts/backlog/sd" / (str(sd.get("sd", "")).replace("·", "-") + ".json")
if not ref_path.exists() or analisar(com_linhas(sd), corpo, canon)[1]:
continue # sem JSON antigo para comparar, ou pendente por outro motivo
pares += 1
referencia = json.loads(ref_path.read_text(encoding="utf-8"))
novo = json.loads(serializar(montar(sd, corpo, canon)))
ref, atual = dict(_esqueleto(referencia)), dict(_esqueleto(novo))
for c in CAMINHOS_DE_CONTEUDO:
ref.pop(c, None), atual.pop(c, None)
divergem = [c for c in sorted(set(ref) | set(atual)) if ref.get(c) != atual.get(c)]
checar(set(novo["totais"]) <= {"ust_total", "ust_por_item", "prazo_calendario_semanas", "moeda", "valor_total"},
f"{f.parent.name}: totais só com as chaves de sempre")
checar(not divergem, f"{f.parent.name}: mesmas chaves, na mesma ordem, em "
f"{len(ref)} caminhos" + (f" — divergem: {divergem}" if divergem else ""))
a, b = dict(_achatar(referencia)), dict(_achatar(novo))
mudados = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
fora = [k for k in mudados if not VALORES_QUE_O_CONTRATO_MUDOU.search(k)]
# Os JSONs do backlog são regravados pelo próprio uso da app; quando a
# referência já é uma exportação nova, `mudados` fica vazio e isso é o
# esperado. O que reprova é diferença FORA do que o contrato pediu.
checar(not fora,
f"{f.parent.name}: nenhum valor mudou fora de linha de OS e nome de perfil "
f"({len(mudados)} campos diferentes)" + (f" — FORA: {fora[:5]}" if fora else ""))
checar(pares >= 6, f"{pares} pares antigo × novo comparados (P7-SD31 e demais do backlog)")
# ---------------------------------------------------------------------------
# 2. Contrato
# ---------------------------------------------------------------------------
def cumpre_contrato(nome: str, payload: dict, canon) -> None:
sd = payload["sd"]
ents = payload["entregaveis"]
problemas = []
if sd["status"] not in STATUS_SD:
problemas.append(f"sd.status {sd['status']!r}")
for chave in ("numero_sequencial", "nome", "objetivo", "inicio", "fim"):
if sd.get(chave) in (None, ""):
problemas.append(f"sd.{chave} vazio")
if not sd["po_responsavel"]["nome"]:
problemas.append("po vazio")
if not re.match(r"^V\d+$", payload["_governanca"]["versao_documento"] or ""):
problemas.append("versao_documento")
if not ents:
problemas.append("sem entregáveis")
ns = [e["n"] for e in ents]
if len(ns) != len(set(ns)):
problemas.append("n repetido")
for e in ents:
a, b = date.fromisoformat(e["data_inicio"]), date.fromisoformat(e["data_prevista_termino"])
dias = (b - a).days
if dias < 0 or dias > JANELA_MAXIMA_DIAS:
problemas.append(f"n{e['n']} janela {dias}d")
sem = semanas_por_datas(a, b)
if e["numero_semanas"] != sem:
problemas.append(f"n{e['n']} semanas {e['numero_semanas']} ≠ {sem}")
# Caminho A: UST, tarifa e valor são do importador. Saem null, sempre —
# um número aqui seria o exportador voltando a copiar tabela alheia.
for chave in ("ust", "valor_unitario_ust", "valor_entregavel", "horas_semanais", "memoria_calculo"):
if chave in e and e[chave] is not None:
problemas.append(f"n{e['n']} {chave} deveria ser null")
if not re.match(r"^\d+-L\d+$", e["ordem_servico"]["linha"] or ""):
problemas.append(f"n{e['n']} linha {e['ordem_servico']['linha']!r}")
if e["status"] not in STATUS_ENTREGAVEL:
problemas.append(f"n{e['n']} status {e['status']!r}")
if e["item"] not in sd["itens_contratuais"]:
problemas.append(f"n{e['n']} item fora de itens_contratuais")
for al in e["alocacoes"]:
if al["perfil"] not in PERFIS_DO_CADASTRO:
problemas.append(f"n{e['n']} perfil {al['perfil']!r}")
for lista in ("backlog", "criterios_aceite"):
ordens = [x["ordem"] for x in e[lista]]
if ordens != list(range(1, len(ordens) + 1)) or any(not x["descricao"] for x in e[lista]):
problemas.append(f"n{e['n']} {lista}")
nomes = [d["nome"] for d in e["documentacao"]]
if len(nomes) != len(set(nomes)) or any(not x for x in nomes):
problemas.append(f"n{e['n']} documentacao")
checar(not problemas, f"{nome}: cumpre o contrato" + (f" — {problemas}" if problemas else ""))
# ---------------------------------------------------------------------------
# 3 + 4. Pendências esperadas, mesma régua nos dois caminhos
# ---------------------------------------------------------------------------
def acervo(canon) -> None:
print("\n· PENDÊNCIAS: cada SD do acervo reprova (ou não) pelos motivos esperados")
print("· MESMA RÉGUA: app (ruamel) × CLI (safe_load), pendências e bytes iguais")
vistos = set()
for f in sds_reais():
nome = f.parent.name
vistos.add(nome)
sd_cli, corpo_cli = ler_sd_arquivo(f)
sd_app, corpo_app = ler_sd_arquivo(f, preservar_comentarios=True)
p_cli, pend_cli = analisar(sd_cli, corpo_cli, canon)
p_app, pend_app = analisar(sd_app, corpo_app, canon)
assinatura = [(p.escopo, p.indice, p.campo) for p in pend_app]
esperado = ESPERADO.get(nome)
if esperado is None:
checar(False, f"{nome}: SD nova no acervo — declare o esperado em ESPERADO")
else:
checar(sorted(assinatura, key=str) == sorted(esperado, key=str),
f"{nome}: pendências {assinatura or 'nenhuma'}")
checar([str(p) for p in pend_cli] == [str(p) for p in pend_app],
f"{nome}: mesmas pendências nos dois caminhos")
if pend_app:
try:
montar(sd_app, corpo_app, canon)
checar(False, f"{nome}: montar() gerou payload com pendência aberta")
except ExportacaoReprovada as exc:
checar(len(exc.pendencias) == len(pend_app), f"{nome}: montar() levanta as mesmas pendências")
# Como a SD fica depois de a pessoa preencher `linha_os`: se só faltava
# isso, o payload completo tem de cumprir o contrato e sair igual nos
# dois caminhos.
com_linhas(sd_cli), com_linhas(sd_app)
if analisar(sd_app, corpo_app, canon)[1]:
continue # pendente por outro motivo — coberto acima
s_cli = serializar(montar(sd_cli, corpo_cli, canon))
s_app = serializar(montar(sd_app, corpo_app, canon))
checar(s_cli == s_app, f"{nome}: mesmos bytes (com linha_os preenchido)")
cumpre_contrato(nome, json.loads(s_app), canon)
for nome in ESPERADO:
if nome not in vistos:
checar(False, f"{nome}: consta em ESPERADO mas não existe mais no acervo")
# ---------------------------------------------------------------------------
# Regras do contrato que o acervo não exercita
# ---------------------------------------------------------------------------
_CORPO_OK = "# T\n\n## 1. Objetivo\n\nFazer algo útil.\n\n## 5. Enquadramento\n\n"
def _sd(entregaveis: str, topo: str = "") -> tuple[dict, str]:
fm = ("---\n"
'sd: "P9·SD99"\n'
'projeto: "P9 · Teste"\n'
'titulo: "SD sintética"\n'
'po_responsavel: "Alguém do Cadastro"\n'
'versao: "V1"\n'
"estado: rascunho\n"
+ topo
+ "entregaveis:\n" + entregaveis + "---\n")
return ler_sd_texto(fm + _CORPO_OK, preservar_comentarios=True)
def _pend(canon, entregaveis: str, topo: str = "") -> list[tuple]:
sd, corpo = _sd(entregaveis, topo)
return [(p.escopo, p.indice, p.campo) for p in analisar(sd, corpo, canon)[1]]
ENT_SEM_LINHA = (' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
" inicio: 2026-09-01\n fim: 2026-09-11\n")
ENT_OK = ENT_SEM_LINHA + ' linha_os: "1090-L1"\n'
def regras_sinteticas(canon) -> None:
print("\n· CONTRATO: regras que o acervo de hoje não tripa, em frontmatter sintético")
checar(_pend(canon, ENT_OK) == [], "o caso-base sintético exporta sem pendência")
def caso(desc, entregaveis, esperado, topo=""):
got = _pend(canon, entregaveis, topo)
checar(sorted(got, key=str) == sorted(esperado, key=str), f"{desc}: {got}")
caso("linha_os ausente", ENT_SEM_LINHA, [("entregavel", 0, "linha_os")])
caso("linha_os nula", ENT_SEM_LINHA + " linha_os: null\n", [("entregavel", 0, "linha_os")])
caso("linha_os fora do formato", ENT_SEM_LINHA + ' linha_os: "L1"\n', [("entregavel", 0, "linha_os")])
# Só formato: existência, item e status são do banco, na carga. Uma linha
# que o repositório não conhece PASSA aqui, de propósito.
checar(_pend(canon, ENT_SEM_LINHA + ' linha_os: "9999-L7"\n') == [],
"linha_os bem formada que o repositório não conhece passa — quem confere é o banco")
caso("I-01 (Licença) não entra pela carga", ENT_OK.replace("I-02", "I-01"), [("entregavel", 0, "item")])
caso("item desconhecido", ENT_OK.replace("I-02", "I-07"), [("entregavel", 0, "item")])
caso("tipo Manutenção para a carga", ENT_OK.replace("Descoberta", "Manutenção"), [("entregavel", 0, "tipo")])
caso("tipo fora do dicionário", ENT_OK.replace("Descoberta", "Discovery"), [("entregavel", 0, "tipo")])
caso("`n` repetido", ENT_OK + ENT_OK.replace('nome: "Um"', 'nome: "Dois"'), [("entregavel", 1, "n")])
caso("`n` zero", ENT_OK.replace("n: 1", "n: 0"), [("entregavel", 0, "n")])
caso("sem nome", ENT_OK.replace('nome: "Um"', 'nome: ""'), [("entregavel", 0, "nome")])
caso("UST declarada no .md (é do importador)", ENT_OK + " ust: 80\n", [("entregavel", 0, "ust")])
caso("fim antes do início", ENT_OK.replace("fim: 2026-09-11", "fim: 2026-08-30"), [("entregavel", 0, "fim")])
caso("dia inexistente", ENT_OK.replace("fim: 2026-09-11", 'fim: "2026-02-30"'), [("entregavel", 0, "fim")])
caso("janela de 29 dias", ENT_OK.replace("fim: 2026-09-11", "fim: 2026-09-30"), [("entregavel", 0, "fim")])
checar(_pend(canon, ENT_OK.replace("fim: 2026-09-11", "fim: 2026-09-29")) == [],
"janela de exatamente 28 dias passa (4 semanas)")
caso("documentação com nome repetido",
ENT_OK + ' artefatos:\n - nome: "Doc"\n - nome: "Doc"\n', [("entregavel", 0, "artefatos")])
caso("documentação sem nome", ENT_OK + ' artefatos:\n - nome: ""\n', [("entregavel", 0, "artefatos")])
caso("item vazio no backlog", ENT_OK + ' atividades:\n - "a"\n - ""\n', [("entregavel", 0, "atividades")])
caso("alocacoes_desvio com perfil fora do cadastro",
ENT_OK + ' alocacoes_desvio:\n - {perfil: "Mago", quantidade: 1, percentual: 50}\n',
[("entregavel", 0, "alocacoes_desvio")])
caso("alocacoes_desvio com percentual > 100",
ENT_OK + ' alocacoes_desvio:\n - {perfil: "Scrum Master", quantidade: 1, percentual: 150}\n',
[("entregavel", 0, "alocacoes_desvio")])
sd, corpo = _sd(ENT_OK)
sd["estado"] = "voando"
checar([(p.escopo, p.campo, bool(p.opcoes)) for p in analisar(sd, corpo, canon)[1]]
== [("sd", "estado", True)], "estado desconhecido: pendência com a lista de opções")
sd, corpo = _sd(ENT_OK)
sd["versao"] = "1.0"
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "versao")],
"versão fora de V{n}")
sd, corpo = _sd(ENT_OK)
sd["sd"] = "SD99"
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "sd")],
"identificador fora de P{n}·SD{n}")
sd, corpo = _sd(ENT_OK)
sd["dependencias"] = ["solta"]
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "dependencias")],
"dependencias em lista solta")
corpo_ruim = _CORPO_OK + "| **Item do TR** | I-09 — não existe |\n"
sd, _ = _sd(ENT_OK)
checar([(p.escopo, p.campo) for p in analisar(sd, corpo_ruim, canon)[1]] == [("corpo", "secao5")],
"Seção 5 com item fora do canônico")
# O caminho positivo do que é raro no acervo: linha declarada (1091-L1, a
# segunda OS de I-02) e alocacoes_desvio normalizada.
sd, corpo = _sd(ENT_SEM_LINHA + ' linha_os: "1091-L1"\n'
' alocacoes_desvio:\n'
' - {perfil: "Especialista de infraestrutura", quantidade: 2, percentual: 50}\n')
e = montar(sd, corpo, canon)["entregaveis"][0]
checar(e["ordem_servico"]["linha"] == "1091-L1", "linha_os declarada vai como está para o JSON")
checar(e["alocacoes"] == [{"perfil": "Especialista de Infraestrutura", "quantidade": 2,
"percentual_alocacao": 50}],
"alocacoes_desvio sai com o nome do cadastro e a chave percentual_alocacao")
checar(e["numero_semanas"] == 2 and e["ust"] is None and e["valor_unitario_ust"] is None
and e["valor_entregavel"] is None and e["horas_semanais"] is None,
"11 dias → 2 semanas; UST, tarifa e valor saem null — o importador deriva (§11 do contrato)")
checar(list(e) == ["n", "codigo", "titulo", "tipo_entrega", "item", "ordem_servico", "data_inicio",
"data_prevista_termino", "numero_semanas", "status", "horas_semanais", "ust",
"valor_unitario_ust", "valor_entregavel", "memoria_calculo", "alocacoes",
"backlog", "criterios_aceite", "documentacao"],
"as chaves do entregável ficam, na mesma ordem, mesmo com os valores em null")
for estado, sd_status, ent_status in (("emitida", "Em andamento", "Emitido"),
("validado", "Entregue", "Aprovado"),
("bloqueada", "Planejado", "Rascunho")):
sd, corpo = _sd(ENT_OK)
sd["estado"] = estado
p = montar(sd, corpo, canon)
checar(p["sd"]["status"] == sd_status and p["entregaveis"][0]["status"] == ent_status,
f"estado {estado} → sd.status {sd_status!r}, entregável {ent_status!r}")
# ---------------------------------------------------------------------------
# Schema — por estrutura
# ---------------------------------------------------------------------------
def schema_valida(canon) -> None:
"""Todo JSON que sai precisa validar contra o sd-schema.json.
O schema deixou de ser o portão do download (o contrato da carga é), mas
continua descrevendo a ESTRUTURA — additionalProperties: false na raiz e em
_governanca. Se um campo novo aparecer, ele acusa aqui.
"""
print("\n· JSON exportado valida contra sd-schema.json (estrutura)")
try:
import jsonschema
except ImportError:
checar(False, "jsonschema não instalado — pip install jsonschema")
return
esquema = json.loads((PROJETOS / "_template" / "sd-schema.json").read_text(encoding="utf-8"))
for f in sds_reais():
nome = f.parent.name
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
if analisar(com_linhas(sd), corpo, canon)[1]:
continue # não exporta nem com linha_os preenchido — não há JSON a validar
queixa = None
try:
jsonschema.validate(json.loads(serializar(montar(sd, corpo, canon))), esquema)
except jsonschema.ValidationError as exc:
caminho = "/".join(str(p) for p in exc.absolute_path) or "(raiz)"
queixa = f"{caminho} — {exc.message[:120]}"
conhecida = SCHEMA_CONHECIDO.get(nome)
if queixa and conhecida:
print(f" aviso {nome}: {queixa}")
print(f" └ conhecido: {conhecida}")
elif queixa:
checar(False, f"{nome}: {queixa}")
elif conhecida:
checar(False, f"{nome}: a divergência conhecida foi resolvida — tire de SCHEMA_CONHECIDO")
else:
checar(True, f"{nome}: valida")
# ---------------------------------------------------------------------------
# 5. Peças de regras_sd
# ---------------------------------------------------------------------------
def tipos_do_ruamel() -> None:
print("\n· Tipos que o ruamel devolve (as regras dependem deles)")
amostra = ("---\n"
"redigida: false\n"
"formalizacao: {sd_emitida: false, os_mae: {I-02: true}}\n"
"semanas: 3\n"
"data_abertura: 2026-08-11\n"
"---\ncorpo\n")
d, corpo = ler_sd_texto(amostra, preservar_comentarios=True)
checar(d["redigida"] is False, "`redigida: false` continua sendo o singleton False")
checar(d["formalizacao"]["os_mae"]["I-02"] is True, "`os_mae: true` é o singleton True")
checar(isinstance(d["semanas"], int), "`semanas` passa no isinstance(int)")
checar(isinstance(d["data_abertura"], date), "data crua vira datetime.date")
checar(isinstance(d, dict), "o mapa é subclasse de dict")
checar(corpo.strip() == "corpo", "o corpo sai separado do frontmatter")
checar("_corpo" not in d, "o frontmatter NÃO carrega `_corpo`")
def secoes() -> None:
print("\n· secao() e definir_secao() são a régua única para as seções do corpo")
corpo_torto = "# t\n\n## 5.Enquadramento no TR\n\ntexto\n"
checar(secao(corpo_torto, "5") is None, "`## 5.Enquadramento` (sem espaço) não é Seção 5")
corpo_ok = "# t\n\n## 5. Enquadramento no TR\n\ntexto\n"
checar(secao(corpo_ok, "5") == "texto", "`## 5. Enquadramento` é reconhecido")
# A P2·SD10 não tem Seção 1: o objetivo entra antes da 5, e as demais ficam.
sd10 = "# Título\n\n## 5. Enquadramento\n\ntabela\n\n## 6. Fora de escopo\n\nfora\n"
novo = definir_secao(sd10, "1", "Objetivo escrito na tela.")
checar(secao(novo, "1") == "Objetivo escrito na tela.", "Seção 1 criada quando não existia")
checar(novo.index("## 1.") < novo.index("## 5."), "criada ANTES da Seção 5, na ordem do template")
checar(secao(novo, "5") == "tabela" and secao(novo, "6") == "fora", "as outras seções ficam intactas")
# Existente: só o conteúdo muda; o cabeçalho e o resto ficam.
com1 = "# T\n\n## 1. Objetivo da SD\n\nvelho\ntexto\n\n## 2. Contexto\n\nctx\n"
novo = definir_secao(com1, "1", "novo")
checar(secao(novo, "1") == "novo" and "## 1. Objetivo da SD" in novo and secao(novo, "2") == "ctx",
"Seção 1 existente: conteúdo trocado, cabeçalho e Seção 2 preservados")
def enquadramento_da_secao5(canon) -> None:
print("\n· enquadramento_tr() lê a tabela da Seção 5 nas formas do acervo")
itens = canon.itens["itens"]
def s5(corpo):
return enquadramento_tr(secao(corpo, "5"), itens)
cabeca = "## 5. Enquadramento no Termo de Referência\n\n| | |\n|---|---|\n"
corpo = (cabeca
+ "| **Item do TR** | I-02 — Desenvolvimento e implantação |\n"
+ "| **Descrição do item** | Conforme TR 1.6.4.1 |\n"
+ "| **Aderência desta SD** | • um<br>• dois<br>• três |\n")
b = s5(corpo)
checar(len(b) == 1 and b[0]["item"] == "I-02", "célula com <br>: uma entrada, item I-02")
checar(b[0]["aderencia"] == ["um", "dois", "três"], "os '•' viram lista, sem marcador residual")
corpo = (cabeca
+ "| **Item do TR** | I-05 — \"Serviços Técnicos\" |\n"
+ "| **Aderência desta SD** | ver bullets abaixo |\n\n"
+ "- primeiro bullet que quebra\n em duas linhas\n"
+ "- **Avaliação Técnica** — com negrito\n- terceiro\n\nFecho.\n")
b = s5(corpo)
checar(b[0]["aderencia"] == ["primeiro bullet que quebra em duas linhas",
"**Avaliação Técnica** — com negrito", "terceiro"],
"indentação pendurada recolada, negrito intacto, fecho fora")
corpo = cabeca + "| **Item do TR** | I-09 — item que não existe |\n"
try:
s5(corpo)
checar(False, "item fora do canônico levanta ValueError")
except ValueError as exc:
checar("I-09" in str(exc), "o erro nomeia o item inválido")
def main() -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
canon = carregar_canonico()
estrutura_intacta(canon)
acervo(canon)
regras_sinteticas(canon)
schema_valida(canon)
tipos_do_ruamel()
secoes()
enquadramento_da_secao5(canon)
print()
if FALHAS:
print(f"{len(FALHAS)} falha(s):")
for f in FALHAS:
print(f" · {f}")
return 1
print("tudo bate.")
return 0
if __name__ == "__main__":
sys.exit(main())