Files
wanderandClaude Fable 5.1 a4b1374fad Linha de OS: tabela linhas-os.yaml como fallback quando o SD.md não declara
Decisão de 2026-09-04, revendo a de 2026-09-03 (sem de-para): o .md continua
mandando, e a tabela contrato/linhas-os.yaml (OS Mãe × linha × item) só entra
quando o entregável não declara `linha_os`. Linha declarada vai como está e não
é conferida contra a tabela; existência/item/status seguem sendo do banco.

- caminhos.LINHAS_OS; regras_sd: Canonico(itens, linhas_os), carregar_linhas_os
  (arquivo ausente = fallback desligado), preparar_linhas_os (valida: formato,
  linha repetida, dois `padrao`, `padrao` não booleano) e linha_da_tabela.
- exporta_sd: ausente → padrão do item; 2+ candidatas sem padrão → pendência
  nomeando-as; item fora da tabela → pendência. CLI informa quantas linhas
  vieram da tabela.
- app: sessao.adotar_linhas_da_tabela grava a linha no frontmatter da sessão
  com comentário de origem, para o Salvar SD.md deixar a SD autodeclarada;
  a tela marca `tabela` e mostra a data da tabela.
- testes: SD18, SD27, SD31–36 passam a exportar de saída; SD10 só PO+objetivo;
  casos sintéticos para tabela ausente, ambígua, sem o item e malformada.
- deploy: linhas-os.yaml vive em clientes/ (gitignored) — vai por scp ao volume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:22:15 -03:00

575 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 (declarada no .md ou, ausente, o padrão do item em
contrato/linhas-os.yaml), UST/tarifa/valor null para o importador derivar,
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,
Canonico, carregar_canonico, definir_secao, enquadramento_tr,
ler_sd_arquivo, ler_sd_texto, linha_da_tabela, preparar_linhas_os,
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.
#
# Desde 2026-09-04 a falta de `linha_os` no .md deixou de ser pendência para os
# itens que contrato/linhas-os.yaml conhece: SD18, SD27 e SD31–SD36 (que só
# tinham isso) passaram a exportar, e SD10 ficou só com PO e objetivo. O que a
# tabela preencheu é conferido em acervo(), linha a linha.
ESPERADO = {
"SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"),
("entregavel", 7, "fim")],
"SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")],
"SD18-integracao-core-rnds-samu": [],
"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": [],
"SD31-fundacao-da-base-de-dispensacao": [],
"SD32-extracao-automatizada-d-1-do-sigaf": [],
"SD33-painel-institucional-de-dispensacao": [],
"SD34-pacientes-ativos-e-alerta-de-inativacao": [],
"SD35-programacao-estoque-e-faltas": [],
"SD36-analise-de-processos-ceaf-com-ia-piloto": [],
}
# 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(sd, corpo, canon)[1]:
continue # sem JSON antigo para comparar, ou pendente
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")
if pend_app:
continue # pendente — 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")
payload = json.loads(s_app)
cumpre_contrato(nome, payload, canon)
# O que o .md não declara veio da tabela — e tem de ser o padrão do
# item, não qualquer linha. O que o .md declara vai como está.
por_n = {e["n"]: e["ordem_servico"]["linha"] for e in payload["entregaveis"]}
erradas = []
for e in sd_app["entregaveis"]:
esperada = (str(e["linha_os"]).strip() if e.get("linha_os")
else linha_da_tabela(canon.linhas_os, e.get("item"))[0])
if por_n.get(e["n"]) != esperada:
erradas.append((e["n"], por_n.get(e["n"]), esperada))
da_tabela = sum(1 for e in sd_app["entregaveis"] if not e.get("linha_os"))
checar(not erradas, f"{nome}: linha de OS = declarada no .md ou padrão da tabela "
f"({da_tabela} da tabela)" + (f" — erradas: {erradas}" if erradas else ""))
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}")
# Linha de OS. Declarada manda; ausente, contrato/linhas-os.yaml dá o
# padrão do item (fallback de 2026-09-04); fora do formato é pendência.
itens_importaveis = sorted(i for i in canon.itens["itens"] if i != "I-01")
checar(canon.linhas_os is not None
and all(linha_da_tabela(canon.linhas_os, i)[0] for i in itens_importaveis),
f"a tabela real tem linha padrão para todo item importável ({', '.join(itens_importaveis)})")
e = montar(*_sd(ENT_SEM_LINHA), canon)["entregaveis"][0]
checar(e["ordem_servico"]["linha"] == linha_da_tabela(canon.linhas_os, "I-02")[0] == "1090-L1",
"linha_os ausente: sai o padrão de I-02 na tabela (1090-L1), sem pendência")
checar(_pend(canon, ENT_SEM_LINHA + " linha_os: null\n") == [],
"linha_os nula: idem — nulo é ausência")
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 — e a tabela não
# é régua do que foi declarado.
checar(_pend(canon, ENT_SEM_LINHA + ' linha_os: "9999-L7"\n') == [],
"linha_os bem formada que a tabela não conhece passa — quem confere é o banco")
e = montar(*_sd(ENT_SEM_LINHA + ' linha_os: "1091-L1"\n'), canon)["entregaveis"][0]
checar(e["ordem_servico"]["linha"] == "1091-L1",
"linha declarada NÃO é trocada pelo padrão da tabela — o .md manda")
# A tabela em outras formas: ausente, ambígua, sem o item, com uma linha só.
sem_tabela = Canonico(canon.itens, None)
caso_t = lambda c, ent: [(p.escopo, p.indice, p.campo) for p in analisar(*_sd(ent), c)[1]]
checar(caso_t(sem_tabela, ENT_SEM_LINHA) == [("entregavel", 0, "linha_os")],
"sem o arquivo linhas-os.yaml, `linha_os` ausente volta a ser pendência (como antes)")
checar(caso_t(sem_tabela, ENT_OK) == [], "sem o arquivo, a linha declarada segue passando")
ambigua = Canonico(canon.itens, preparar_linhas_os(
{"linhas": [{"linha": "1090-L1", "item": "I-02"}, {"linha": "1091-L1", "item": "I-02"}]}))
p = analisar(*_sd(ENT_SEM_LINHA), ambigua)[1]
checar([(x.campo, x.opcoes) for x in p] == [("linha_os", ["1090-L1", "1091-L1"])]
and "sem `padrao`" in p[0].mensagem,
"duas linhas para o item e nenhuma `padrao`: pendência nomeando as candidatas")
so_i05 = Canonico(canon.itens, preparar_linhas_os({"linhas": [{"linha": "2587-L3", "item": "I-05"}]}))
p = analisar(*_sd(ENT_SEM_LINHA), so_i05)[1]
checar([(x.escopo, x.indice, x.campo) for x in p] == [("entregavel", 0, "linha_os")]
and "não tem linha para I-02" in p[0].mensagem,
"item fora da tabela: pendência que diz que a tabela não o tem")
checar(caso_t(so_i05, ENT_SEM_LINHA.replace("I-02", "I-05")) == [],
"item com UMA linha só na tabela: ela é o padrão, sem precisar da marca")
for desc, bruto in (
("dois `padrao: true` no mesmo item",
{"linhas": [{"linha": "1090-L1", "item": "I-02", "padrao": True},
{"linha": "1091-L1", "item": "I-02", "padrao": True}]}),
("linha repetida", {"linhas": [{"linha": "1090-L1", "item": "I-02"}, {"linha": "1090-L1", "item": "I-05"}]}),
("linha fora do formato", {"linhas": [{"linha": "L1", "item": "I-02"}]}),
("`padrao` que não é booleano", {"linhas": [{"linha": "1090-L1", "item": "I-02", "padrao": "sim"}]}),
("entrada sem item", {"linhas": [{"linha": "1090-L1"}]}),
("sem a lista `linhas`", {"atualizado_em": "2026-09-04"}),
):
try:
preparar_linhas_os(bruto)
checar(False, f"tabela malformada ({desc}) reprova na leitura")
except ValueError as exc:
checar(True, f"tabela malformada ({desc}) reprova na leitura — {exc}")
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(sd, corpo, canon)[1]:
continue # não exporta — 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())