#!/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
• dois
• três |\n") b = s5(corpo) checar(len(b) == 1 and b[0]["item"] == "I-02", "célula com
: 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())