diff --git a/tests/test_documentacao_bate_com_codigo.py b/tests/test_documentacao_bate_com_codigo.py new file mode 100644 index 0000000..d86ca79 --- /dev/null +++ b/tests/test_documentacao_bate_com_codigo.py @@ -0,0 +1,42 @@ +"""A documentação não pode afirmar o que o código não faz. + +Documentação envelhece em silêncio. Uma variável renomeada, uma flag que saiu +do CLI, uma rota que mudou de nome — nada disso quebra um teste, nem aparece +num diff de revisão. Quem descobre é o leitor, quando o comando não funciona, e +ele não tem como saber que o errado é o texto. + +Este teste roda o verificador de `tools/valida_docs.py` sobre o repositório +inteiro e falha quando uma página cita uma variável de ambiente que ninguém lê, +uma flag que o CLI não tem, ou uma rota que o servidor não serve. + +Casos reais que motivaram isto, todos encontrados assim: + +- a página de saída de rede do 9RTKSync descrevia o gateway do irmão, com + campos que não existem aqui; +- a wiki do OminiRTkSync anunciava uma resposta de `/healthz` que o servidor + nunca devolveu; +- o README do LiteLlmRTKSync citava uma rota `POST /api/sync` inexistente. +""" + +import os +import sys +import unittest + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(RAIZ, "tools")) + +from valida_docs import verificar # noqa: E402 + + +class TestDocumentacaoBateComOCodigo(unittest.TestCase): + def test_no_page_claims_something_the_code_does_not_do(self): + divergencias = verificar(RAIZ, os.path.basename(RAIZ)) + self.assertEqual( + divergencias, + [], + "a documentação diverge do código:\n " + "\n ".join(divergencias), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/valida_docs.py b/tools/valida_docs.py new file mode 100644 index 0000000..7c7f485 --- /dev/null +++ b/tools/valida_docs.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Confere o que a documentação afirma contra o que o código faz. + +Documentação envelhece em silêncio: uma porta que mudou, uma flag renomeada, +uma variável de ambiente que deixou de ser lida. Nada disso quebra teste nem +aparece em revisão, e o leitor descobre sozinho quando o comando não funciona. + +O que este verificador extrai das páginas e confronta com a fonte: + +- **variáveis de ambiente** citadas -> existem em `os.environ` no código? +- **flags de linha de comando** citadas -> existem no argparse? +- **rotas HTTP** citadas -> existem no servidor? +- **caminhos de arquivo** do próprio repositório citados -> existem em disco? +- **portas** citadas -> batem com o default do código e dos composes? + +Uma citação que não se confirma é reportada com a página e a linha. +""" + +import os +import re +import sys +from typing import Dict, List, Set, Tuple + +# -------------------------------------------------------------------------- +# Extração da documentação +# -------------------------------------------------------------------------- + +# Variáveis em caixa alta, com pelo menos um sublinhado ou 4 letras. Filtramos +# depois contra uma lista de termos que não são variáveis (HTTP, JSON, ...). +RX_ENV = re.compile(r"\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b") +RX_FLAG = re.compile(r"(? List[str]: + """Todo markdown versionado do repositório, menos os upstreams clonados.""" + achados = [] + for pasta, dirs, arquivos in os.walk(raiz): + dirs[:] = [ + d for d in dirs + if d not in (".git", "tmp", "node_modules", "__pycache__", ".venv", "assets") + ] + for a in arquivos: + if a.endswith(".md"): + achados.append(os.path.join(pasta, a)) + return sorted(achados) + + +def fonte_do_repo(raiz: str) -> str: + """Todo o código Python e YAML do repositório, concatenado.""" + partes = [] + for pasta, dirs, arquivos in os.walk(raiz): + dirs[:] = [ + d for d in dirs + if d not in (".git", "tmp", "node_modules", "__pycache__", ".venv") + ] + for a in arquivos: + if a.endswith((".py", ".yml", ".yaml", ".toml", ".cfg", ".sh", ".example")): + try: + with open(os.path.join(pasta, a), encoding="utf-8") as f: + partes.append(f.read()) + except (OSError, UnicodeDecodeError): + pass + return "\n".join(partes) + + +def env_do_codigo(fonte: str) -> Set[str]: + """Variáveis que o código realmente lê, mais as declaradas nos composes.""" + lidas = set(re.findall(r"environ\.get\(\s*['\"]([A-Z][A-Z0-9_]+)['\"]", fonte)) + lidas |= set(re.findall(r"environ\[\s*['\"]([A-Z][A-Z0-9_]+)['\"]\s*\]", fonte)) + # Declaradas em compose/.env.example contam como parte do contrato. + lidas |= set(re.findall(r"^\s*-?\s*([A-Z][A-Z0-9_]+)=", fonte, re.M)) + lidas |= set(re.findall(r"\$\{([A-Z][A-Z0-9_]+)[:?}-]", fonte)) + # Identificadores que o codigo produz sem serem variaveis de ambiente -- + # `b"LITELLM_UNREACHABLE"` e as respostas de /healthz, por exemplo. Se o + # nome existe no fonte, a documentacao nao esta inventando nada. + lidas |= set(re.findall(r"\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b", fonte)) + return lidas + + +def flags_do_codigo(fonte: str) -> Set[str]: + return set(re.findall(r"add_argument\(\s*['\"](--[a-z][a-z0-9-]*)['\"]", fonte)) + + +def rotas_do_codigo(fonte: str) -> Set[str]: + rotas = set(re.findall(r"['\"](/(?:healthz|api|acoes|actions|index|static)[a-z0-9/_.-]*)['\"]", fonte)) + # startswith("/api/") e comparações equivalentes + rotas |= set(re.findall(r"startswith\(\s*['\"](/[a-z0-9/_.-]+)['\"]", fonte)) + return rotas + + +# -------------------------------------------------------------------------- +# Verificação +# -------------------------------------------------------------------------- + +def verificar(raiz: str, nome: str) -> List[str]: + problemas: List[str] = [] + fonte = fonte_do_repo(raiz) + env_ok = env_do_codigo(fonte) + flags_ok = flags_do_codigo(fonte) + rotas_ok = rotas_do_codigo(fonte) + + for pagina in paginas(raiz): + rel = os.path.relpath(pagina, raiz) + try: + with open(pagina, encoding="utf-8") as f: + linhas = f.readlines() + except (OSError, UnicodeDecodeError): + continue + + for n, linha in enumerate(linhas, 1): + for var in RX_ENV.findall(linha): + if var in NAO_SAO_VARIAVEIS or RUIDO.match(var): + continue + if var not in env_ok: + problemas.append(f"{nome}/{rel}:{n} variável citada e não usada no código: {var}") + + # Uma linha que invoca outro programa traz as flags DELE. Acusar + # `pip install --upgrade` de nao existir no nosso CLI e ruido, e + # ruido treina o leitor a ignorar o verificador inteiro. + if RX_COMANDO_DE_TERCEIRO.search(linha): + continue + + for flag in RX_FLAG.findall(linha): + if flag in FLAGS_DE_TERCEIROS: + continue + if flag not in flags_ok: + problemas.append(f"{nome}/{rel}:{n} flag citada e inexistente no CLI: {flag}") + + # Uma rota citada numa frase sobre o gateway e do gateway. + if RX_ROTA_DO_GATEWAY.search(linha): + continue + + for rota in RX_ROTA.findall(linha): + base = rota.rstrip(".,;:)") + if base not in rotas_ok and not any(r.startswith(base) for r in rotas_ok): + problemas.append(f"{nome}/{rel}:{n} rota citada e não servida: {base}") + + return problemas + + +def main() -> int: + alvos: List[Tuple[str, str]] = [ + (a, os.path.basename(a.rstrip("/"))) for a in sys.argv[1:] + ] + total = 0 + for raiz, nome in alvos: + problemas = verificar(raiz, nome) + print(f"\n===== {nome}: {len(problemas)} divergência(s) =====") + agrupado: Dict[str, List[str]] = {} + for p in problemas: + chave = p.split(" ", 1)[1].split(":")[0] + agrupado.setdefault(chave, []).append(p) + for chave in sorted(agrupado): + print(f"-- {chave}") + for p in sorted(set(agrupado[chave]))[:40]: + print(f" {p}") + total += len(problemas) + print(f"\nTOTAL: {total}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())