Como a documentação se mantém viva

Documentação não morre por falta de escrita inicial. Ela morre por drift: o código muda, a doc fica, e um dia alguém percebe que ela descreve um sistema que não existe mais. Desse ponto em diante ninguém confia em página nenhuma, inclusive nas que estão certas.

Esta página explica o mecanismo que existe para evitar isso. Leia-a se o CI reclamou de você — e leia-a antes de desligar qualquer parte dele.

O princípio: gerar > verificar > lembrar

Três níveis, em ordem decrescente de confiabilidade:

nível como funciona quando usar
Gerar a doc sai do código; drift é impossível a lista É o conteúdo
Verificar a doc é escrita à mão, o CI confere que está completa a prosa importa mais que a lista
Lembrar o CI avisa quem abriu o PR quando é preciso julgamento humano

O último nível é o mais fraco, e é por isso que ele é o último recurso. Um aviso que ninguém lê não protege nada.

Número em prosa é caso de verificar

Um número no meio de uma frase — "os 30 ADRs", "o próximo é 0031" — não é gerável: ele mora dentro do texto, e trocá-lo por um marcador deixaria a prosa da fonte ilegível. Mas ele é verificável, e é aí que deve morar.

Sem isso, ele envelhece calado. Foi o que aconteceu: o site publicado dizia "28 deles" e "as 29 decisões" quando já eram 30, e "o próximo é 0030" com o 0030 já feito. Nada quebrou, nenhuma checagem reclamou — só ficou errado.

A lista de números verificados cresceu depois que um terceiro caso provou o mesmo ponto. Uma revisão externa achou o README anunciando "as 158 RNs" quando havia 331 escritas — errado por mais do dobro, na tabela que apresenta o repositório. Foi corrigido à mão (PR #412), o que só zera o relógio: a próxima RN o deixaria desatualizado de novo. Então a checagem deixou de cobrir só contagens de ADR e passou a cobrir três famílias, cada uma lida do ARTEFATO e nunca de outra prosa:

número em prosa fonte de verdade
contagem de ADRs, número do próximo ADR os arquivos em docs/adr/
contagem de RNs os cabeçalhos ### RN-NNN em business-rules.md
contagem de providers de LLM os literais capabilities que descobrirProviders() encontra — a mesma fonte da tabela gerada em llm-providers.md

A de providers é escrita por extenso ("nove providers"), e a checagem compara com a palavra em vez de forçar a prosa a virar dígito: a checagem existe para proteger o texto, não para remodelá-lo.

Desde a AT-399 (2026-10-03) esses números também são reescritos. PRs paralelos que adicionavam cada um uma RN ou um ADR escreviam os números compartilhados à mão, e cada merge deixava os outros desatualizados: o guardião de docs falhou 8 de 30 execuções em três horas só por isso. Então pnpm docs:generate (modo de escrita) agora corrige no lugar o que a fonte determina — as contagens de ADR e RN acima, o número do próximo ADR (descartando uma linha duplicada "o próximo é" deixada por um merge), o recuo das entradas de docs/adr/temas.yml escritas na coluna 0 e a linha de uma referência com símbolo (abaixo). As funções puras moram em scripts/docs/reescrita.mjs, refs-com-simbolo.mjs (novaLinha, reancorar) e temas-de-adr.mjs (corrigirRecuo). --check mantém a mesma severidade: arquivo commitado desatualizado continua reprovando o CI, e a correção é pnpm docs:generate mais um commit, como nos arquivos gerados. O que ainda precisa de um humano: uma frase cujo padrão parou de casar (CEGO), um ADR sem tema, uma variável sem descrição e uma referência cujo símbolo saiu do arquivo, andou mais de 40 linhas ou empata entre duas ocorrências mais próximas.

O caso mais caro foi a versão anunciada em prosa: o README anunciou v0.1.0 da Fase 5 até a v2.1.0 — sete releases atrás da realidade, na primeira coisa que um recém-chegado lê. A checagem compara a prosa com o primeiro ## vX.Y.Z do CHANGELOG, que é escrito pelo workflow de release e volta por PR; uma tag lida do git não funcionaria, porque o checkout raso do CI pode não ter tag nenhuma. O badge, por outro lado, saiu da checagem e virou geração: ele lê a release do GitHub direto (shields.io/github/v/release) e se atualiza sozinho — só se verifica o que não dá para gerar.

E aconteceu uma vez por porta de entrada. docs/intro.md — a primeira página do site publicado, à qual nem todo mundo chega pelo README — continuou dizendo "Fases 1 a 5 concluídas, v0.1.0" enquanto o produto estava na Fase 26. Conferir só o README nunca protegeu a segunda porta, então verificarVersaoAnunciada passou a receber os dois arquivos numa lista, e scripts/ci/readme-version.ts passou a escrever nos dois no mesmo commit do CHANGELOG. As duas metades andam juntas de propósito: uma checagem que cobrasse o que o gerador não escreve faria todo release nascer vermelho num PR que o bot abre e ninguém consegue consertar.

O padrão do intro.md exige a frase inteira — "Phases 1 through NN complete, version vX.Y.Z" — e não só a versão: as duas metades envelhecem juntas, e casar só metade deixaria a contagem de fases mentindo ao lado de uma versão correta. A contagem de fases não é gerável daqui: o release sabe a versão, não a fase.

generate.mjs agora confere essas afirmações contra a realidade do diretório. E padrão que para de casar também reprova: uma checagem cuja regex parou de achar a frase é pior que checagem nenhuma, porque fica verde para sempre afirmando ter conferido algo para o qual nunca olhou. Quando a frase muda, o CI diz CEGO e pede que o padrão seja ajustado.

Acrescentar uma afirmação nova à checagem é uma entrada numa das listas afericoes — verificarContagensDeAdr (arquivo, padrão, valor esperado) quando a fonte de verdade é o diretório de ADRs, verificarVersaoAnunciada quando é a última release do CHANGELOG — ou uma função própria ao lado delas, quando não é nenhum dos dois.

Contagens derivadas do código (AT-123). Uma varredura em 2026-09-25 de docs/, README.md, CONTRIBUTING.md, THIRD_PARTY_NOTICES.md e CLAUDE.md achou cinco números já errados — "nove schemas" (onze), "doze operações" do contrato de git (quinze), "dez abas" (doze), "três imagens" construídas pelo CI (cinco), "Thirteen types" de proposed_action (vinte e um) — nenhum com checagem por trás. As contagens cuja fonte é um lugar do código agora moram em scripts/docs/contagens-do-codigo.mjs, chamado por verificarContagensDerivadasDoCodigo: uma linha de tabela por FRASE (a mesma contagem dita em dois arquivos são duas frases que envelhecem separadas), cada uma apontando para um extrator que conta o artefato — os blocos overrides dos dois pnpm-workspace.yaml, os CASOS e ARQUIVOS_CURADOS do golden-set e o floor.json dele, o total que scripts/ci/imagens-pinadas.ts imprime, ALVOS, o laço non-root do smoke, os métodos de GitProviderContract e de DockerPort, o REGISTRO de abas, os serviços do compose de dev sem profiles: (que precisam TODOS ter healthcheck, senão o valor esperado vira uma frase que a prosa não tem), ACTION_TYPES, o enum action_status e as chamadas pgTable(. A comparação ignora maiúsculas e segue a forma da frase (palavras em português no masculino/feminino, palavras em inglês ou dígitos). Como os extratores são muitos, cada um é provado por MUTAÇÃO em contagens-do-codigo.spec.ts: mude o artefato e não a prosa, e ele tem de reprovar DESATUAL; remova a frase ou a fonte, e ele tem de reprovar CEGO.

O que foi datado em vez de derivado, e por quê: as contagens de arquivos por app em architecture.md e as "seis rotas" do broker mudam com quase todo PR ou são lidas de um switch, então uma checagem só acrescentaria ruído — elas levam "medido em 2026-09-25". Números que descrevem um invariante de desenho ("duas camadas", "três estados") ou uma medição passada já datada no lugar (THIRD_PARTY_NOTICES.md, as transcrições de restauração do runbook) ficaram como estavam.

Referências de linha com símbolo

Uma RN cita código por caminho:linha, e um número de linha envelhece toda vez que alguém edita o arquivo. A RN-547 citava fechar_a_instalacao em install.sh:966 e post_interno em :682. Em 2026-09-17 eles estavam em :1107 e :809, e em 2026-09-18 em :1463 e :1165. Um número de linha não pode ser gerado e, em geral, também não pode ser verificado, porque apps/api/src/foo.ts:40 sozinho não diz o que deveria estar lá. Quando a RN também nomeia o símbolo ao lado da linha, porém, a linha pode ser verificada. Esse é o único caso que generate.mjs confere (verificarRefsComSimbolo, com a lógica em scripts/docs/refs-com-simbolo.mjs, testada).

O padrão é estreito de propósito. Uma checagem barulhenta é desligada no primeiro mês. Ela lê os três arquivos de RN (business-rules.md, business-rules/custo.md, business-rules/autenticacao.md) e pega só estas duas formas:

`<path>:<N>` (`<symbol>`     explicit reference
`:<N>` (`<symbol>`           continuation — inherits the path
  • O símbolo tem de vir logo depois da referência, abrindo um parêntese e entre crases. Só espaço em branco pode ficar entre eles, inclusive uma quebra de linha.
  • O símbolo tem de ser um identificador: fechar_a_instalacao, join/3, git_dir?, Engine.Runners.RunnerReadiness, decide(). MARCADOR_SCHEMA=3, uma frase ou um caminho não é símbolo, e é pulado.
  • Pares ambíguos são pulados: :640/:647 (a/b), ou duas referências antes de um símbolo. A checagem não tem como saber qual símbolo vai com qual linha.
  • Uma continuação herda o último caminho explícito no mesmo item de lista. Uma linha em branco ou um novo - encerra o item.
  • O caminho é resolvido a partir da raiz do repositório. Se não estiver lá, a checagem usa o único arquivo rastreado cujo caminho termina com ele. Se nenhum arquivo casar, ou mais de um casar, a referência é contada à parte como não resolvida.

Uma referência bate quando o símbolo, ou o último segmento dele após ., aparece como palavra inteira a até ±3 linhas de N. A janela admite uma citação que aponta para o docblock em vez da assinatura. Ela fica pequena porque o drift anda dezenas ou centenas de linhas. Quando uma referência não bate, a saída nomeia a linha mais próxima onde o símbolo aparece. Desde a AT-399, pnpm docs:generate reescreve N para essa linha quando a ocorrência mais próxima é única (sem empate na mesma distância) e está a no máximo 40 linhas (DISTANCIA_MAXIMA_DE_REANCORA); quando o símbolo sumiu do arquivo, andou mais longe ou duas ocorrências empatam, a referência fica como está e tem de ser relida pelo símbolo, à mão. A ocorrência mais próxima pode ser uma chamada e não a definição — esse é o preço aceito por não editar números à mão através de PRs paralelos, e o diff do arquivo regenerado mostra cada movimento para revisão.

Medido em 2026-09-18, sobre todo caminho:N dos três arquivos:

contagem
referências …:N no total 592
casam com o padrão 187
corretas (dentro de ±3) 116
erradas 71
caminho não resolvido 0

Uma conferência por amostragem das 71 não achou falso positivo: cada referência que ela cobriu era drift real, uma renomeação ou um símbolo que mudou de arquivo. Alargar a janela para ±5 limparia só três delas. Nove estão entre 4 e 10 linhas fora, e as outras 62 mais longe. O item de código da RN-547 foi relido pelo símbolo e corrigido no mesmo PR, o que deixa 63. São muitas para corrigir uma a uma dentro desta mudança, e a ocorrência mais próxima nem sempre é a definição. A lista é o que pnpm docs:check imprime.

Severidade ao nascer: warn. Ela relatava e não reprovava, porque um block teria parado todo PR que tocasse um arquivo de RN por uma dívida que outra pessoa deixou. Havia uma exceção, a regra da casa: se a checagem extrai zero referências, isso é CEGO e reprova. Zero significa que a sintaxe de RN mudou ou o extrator quebrou, e uma checagem cega fica verde para sempre.

O critério original para block (PR #593): quando a lista estiver vazia, e depois de quatro semanas seguidas de docs:check na dev sem nenhuma referência errada nova nas RNs tocadas nessas semanas. A partir daí, toda referência errada nova é culpa do PR corrente, e o PR pode corrigi-la. Promover antes cobraria de cada PR a dívida de outra pessoa. Se o padrão mostrar um falso positivo real antes disso, estreite o padrão. Não alargue a janela.

A lista zerou em 2026-09-26 (AT-122). Nos oito dias depois de a checagem entrar, as 63 referências sabidamente erradas tinham crescido para 75. Esse crescimento é o drift que a checagem existe para mostrar. As 75 foram relidas pelo símbolo contra o código daquele dia, e nenhuma era falso positivo do padrão. A maioria tinha andado dezenas de linhas. Uma era renomeação: modo_de_execucao/1 virou exigencias_do_projeto/1 na RN-516. Uma tinha mudado de arquivo: escreverEnv foi de preflight.mjs para env-file.mjs. Duas apontavam para o teste certo, mas na linha do it(, enquanto o símbolo (types) só aparece algumas linhas abaixo, no corpo. Essas duas agora apontam para a linha do corpo. A janela ficou em ±3. Os itens com o pior drift (RN-566, RN-567, RN-570 e RN-514) tiveram as referências vizinhas não conferidas relidas na mesma passada. Depois disso, 273 referências casam com o padrão, 273 estão corretas e 0 estão erradas. As quatro semanas começariam a contar quando essa mudança chegasse à dev.

Promovida a block em 2026-10-01 (AT-122), por decisão do mantenedor, sem esperar as quatro semanas. Cinco dias depois de a lista zerar, a dev tinha 140 referências erradas de novo, de 896 que casam com o padrão: o warn não segurou o drift, porque um aviso sobre o qual ninguém tem de agir é um aviso que ninguém lê. As 140 foram relidas pelo símbolo na mesma mudança que promoveu a checagem, nunca somando um delta, e a janela ficou em ±3. Nenhuma era falso positivo do padrão, e nenhum símbolo faltava no arquivo dele. O que a passada achou além do drift de linha:

  • Sobras de merge. Três itens tinham sido duplicados por resolução de conflito, uma cópia com números mais antigos ao lado de uma com números mais novos (o Where da RN-120, o Código da RN-547 e a linha de SessionPage.tsx da RN-626). A cópia desatualizada foi removida, e é por isso que o total foi de 896 para 883.
  • Uma referência cujo assunto mudou (drift de estado, não de linha). A RN-587 citava o teste de uma mensagem do infra; desde a RN-617 o Infra Lead conversa, e o teste agora usa qa como o agente do roster sem conversa. A referência agora diz isso. A regra da RN não mudou.
  • Vizinhas não conferidas nos itens com o pior drift (RN-547, RN-567, RN-587, RN-609, RN-663, RN-566) foram relidas na mesma passada onde o teste ou a cláusula que elas nomeiam pôde ser identificado.

Depois disso, 883 referências casam com o padrão, 883 estão corretas e 0 estão erradas.

Severidade agora: block. Uma referência que não bate, ou cujo caminho não resolve mais a um único arquivo rastreado, reprova o docs:check. As duas são o mesmo drift: a RN aponta para onde o código não está. Zero referências continua CEGO e continua reprovando. O veredito é veredito em refs-com-simbolo.mjs, provado por mutação no spec ao lado: uma referência movida para fora da janela reprova, um símbolo que saiu do arquivo reprova, um caminho que não resolve mais reprova, e texto reescrito até a extração dar zero é CEGO, nunca ok. Quando reprovar, corrija a referência pelo símbolo (a saída nomeia a linha mais próxima onde ele aparece, o que é uma pista, não uma correção). Não alargue a janela, e não estreite o padrão para fazer uma referência real sumir; estreite-o só por um falso positivo real, com o caso no spec.

As traduções pt-BR dos três arquivos de RN sob docs/i18n/ não são conferidas. Elas têm poucas referências (23 casam com o padrão, 15 delas erradas em 2026-10-01), e a tradução está atrás da fonte em inglês de todo modo; conferir uma cópia que ninguém mantém em dia reprovaria todo PR pelo atraso da tradução.

No irmão. Desde o ADR 0275, cada irmão confere as próprias refs com símbolo no CI, pelo reutilizável refs-com-simbolo.yml do brabo-ci, sem layout e sem o gerador (a conferência usa o mesmo núcleo, refs-com-simbolo.ts, com a mesma janela e o mesmo CEGO). Uma ref para código de outro irmão só é listada no summary: quem a confere é a subida da trava (ADR 0274), que nomeia o repo dono. Quando a ref do próprio irmão reprova, o job sobe o patch do reancoramento como artefato refs-patch, sem comentar na PR. O check fica fora dos obrigatórios: quem escapar cai na trava.

Todo cabeçalho de RN carrega sua âncora

A âncora {#rn-NNN} num cabeçalho de RN é o contrato para links de fora: ela sobrevive a uma RN com título novo e a uma RN movida para outro arquivo. Em 2026-09-26 (AT-230) a dev tinha 451 cabeçalhos ### RN- e 449 âncoras. A RN-305 e a RN-306 tinham sido escritas sem uma, em business-rules.md e na tradução pt-BR dele, e nada reprovou. O build do site não pega isso: sem âncora explícita o o gerador deriva o id do texto do cabeçalho e a página compila. O que quebra é um link #rn-305 que alguém escreve depois. A contagem de RNs também não pega, porque conta cabeçalhos, não âncoras.

generate.mjs agora confere isso (verificarAncorasDeRn, com a lógica em scripts/docs/ancoras-de-rn.mjs, provada por mutação em ancoras-de-rn.spec.ts). Todo cabeçalho, de qualquer nível, que começa com RN-<dígitos> tem de terminar em {#rn-<mesmos dígitos>}, zeros à esquerda incluídos (RN-001 → rn-001). Âncora ausente e âncora com outro número ou nome reprovam as duas, uma linha por cabeçalho, com arquivo e linha. Zero cabeçalhos encontrados é CEGO e também reprova.

Severidade: block, diferente das referências de linha acima: não há dívida herdada (as duas âncoras ausentes foram acrescentadas na mesma mudança), e cada cabeçalho novo é escrito pelo PR que o acrescenta.

As traduções também são cobertas. Os arquivos são achados por glob: docs/business-rules.md, docs/business-rules/*.md (a fonte pt-BR, ADR 0216), e os mesmos dois caminhos sob docs/i18n/<locale>/ para cada locale traduzido em LOCALES_TRADUZIDOS (en, ru, zh-Hans). O site de um locale serve business-rules#rn-305 a partir do arquivo traduzido, e uma tradução anterior tinha copiado o cabeçalho sem a âncora. A regra é por cabeçalho, então uma tradução parcial não é problema: uma RN que não foi traduzida cai na fonte pt-BR, que é conferida de qualquer modo.

A tabela de procedimentos do runbook

O runbook termina com uma tabela dos seus procedimentos de operação — procedure | anchor | verification | schedule — dizendo o que prova cada um e o que roda essa prova (Procedures and how each is verified). Ela existe por causa do critério de fechamento do EP-015: nenhuma linha com verificação vazia ou que diga "ver abaixo", e as provas que existem rodam por agenda, não de memória. Medido em 2026-09-25 (AT-129), a tabela de que o critério falava não existia no repositório: uma cópia de sete linhas morava nas notas do mantenedor, contra 28 procedimentos de operação aqui, e uma linha apontava para outra nota. Uma tabela de nomes de arquivo escrita à mão envelhece do mesmo jeito que números em prosa — um spec é renomeado, um workflow perde o schedule:, e a célula continua afirmando.

generate.mjs a confere (verificarProcedimentosDoRunbook, lógica em scripts/docs/procedimentos-do-runbook.mjs, provada por mutação em procedimentos-do-runbook.spec.ts). Por linha, ela reprova quando:

  • a célula anchor não tem link (#id), ou o id não é um {#id} explícito num cabeçalho do runbook — o mesmo contrato das âncoras de RN: uma seção com título novo não pode quebrar o link;
  • a célula verification está vazia, diz "see below/above", nomeia um arquivo que não é rastreado pelo git ou um alvo make que o Makefile não tem, ou não nomeia nada conferível e não começa com **None** — a declaração explícita de que não existe prova;
  • um segmento de schedule (eles são separados por ;) não começa com sua classe de gatilho — weekly/daily/monthly, every PR, every tag ou manual — ou um workflow que ele cita não existe ou não tem esse gatilho no on: lido (schedule:, pull_request, push: tags). Um segmento manual não pode citar workflow.

A coluna se chama schedule e ainda aceita every PR e every tag de propósito. A pergunta do critério é "roda sem alguém lembrar?", e um spec que o ci.yml roda em todo pull request responde que sim; chamá-lo de manual seria errar no sentido oposto. O que a checagem garante é que a classe escrita é a que o workflow tem: weekly apontando para um workflow sem schedule: reprova, que é o caso que a AT-193 nomeou.

Seção ausente, tabela ausente, cabeçalhos de coluna mudados ou zero linhas é CEGO e reprova.

Severidade: block, pelo mesmo motivo das âncoras de RN: a tabela nasceu com toda linha conferida, então não há dívida herdada, e o que a quebrar depois (um spec renomeado, um workflow que perdeu a agenda) é culpa do PR que a quebrou, que é também o PR que pode consertá-la.

Só o runbook da fonte (docs/runbook.md) tem a tabela. A cópia pt-BR antiga dele estava atrás (AT-209) e saiu com o ADR 0216; uma tradução futura sob docs/i18n/<locale>/ cai na regra traducao do docmap (warn) e não teria os dez ids explícitos que esta tabela acrescentou. Uma segunda cópia da tabela ali, sem conferência, seria exatamente a cópia que a tabela existe para substituir.

O índice de ADR é agrupado por tema

O índice de ADR era agrupado por fase. Quando as fases acabaram, a última seção virou o índice: medido em 2026-10-01, 148 das 191 linhas moravam sob ## Phase 12. O ADR 0202 o agrupa por tema, e põe o tema fora do ADR — um ADR aceito nunca é editado, nem para ganhar uma chave tema: no frontmatter.

O tema mora em docs/adr/temas.yml, ao lado do índice: temas: é a lista fechada e ordenada (id, titulo, descricao) e adrs: mapeia cada número de ADR para UM tema. O índice é conferido, não gerado — as descrições de uma linha são curadas e nenhum script as escreve, o mesmo motivo que verificarIndiceAdr dá — e cada tema é uma seção ## <titulo> {#tema-<id>}, na ordem da lista, com as linhas em ordem numérica.

generate.mjs o confere (verificarTemasDeAdr, logo depois de verificarIndiceAdr; lógica em scripts/docs/temas-de-adr.mjs, provada por mutação em temas-de-adr.spec.ts). Ele reprova um arquivo de ADR sem tema, um tema no mapa que não está na lista, uma entrada do mapa para um ADR que não existe, um tema listado sem ADR, um id ou título de tema repetido ou malformado e, no índice, uma seção de tema ausente, repetida, com nome errado, fora de ordem ou desconhecida, ou uma linha de ADR sob a seção de outro tema, fora de qualquer seção de tema, repetida ou fora de ordem numérica. YAML ilegível, lista ou mapa vazio, ou um índice sem seção de tema nenhuma (o antigo índice por fase) é CEGO e reprova.

Severidade: block, como a tabela do runbook: nasceu com todo ADR classificado, então o que a quebrar depois é um ADR novo cujo PR esqueceu a linha dele em temas.yml — o PR que pode consertá-la. As traduções do índice moram sob docs/i18n/<locale>/ e não são conferidas; a regra traducao avisa, como sempre.

As peças

flowchart TD
  A[docs/.docmap.yml<br/>mapa código → doc] --> B[docmap.mjs<br/>valida o mapa]
  A --> C[drift.mjs<br/>cobra no PR]
  D[código] --> E[generate.mjs<br/>gera e verifica]
  E --> F[docs/]
  C --> G[docs-check.yml<br/>guardião do PR]
  B --> G
  E --> G
  F --> H[docs-build<br/>link quebrado reprova]
  H --> G
  A --> I[audit.mjs<br/>mensal: docs velhas]

docs/.docmap.yml — o mapa

Liga caminhos de código aos documentos que dependem deles. É a única fonte que diz "quem mexe nisto precisa revisar aquilo".

Duas severidades: block reprova o PR, warn só comenta. E um atributo generated: true, que marca os documentos que saem do gerador.

Uma regra que cobra o que não faz sentido ensina as pessoas a recorrer à saída de emergência por reflexo. É por isso que politica-de-branches vigia uma lista de permitidos em scripts/ci/ — os scripts que são a política de branches e os specs deles — em vez de scripts/ci/** menos uma lista de exclusões mantida à mão, na qual todo spec novo de alcance do repositório inteiro naquela pasta tinha de entrar (onze até a AT-206). A lista de permitidos não abre mão de falhar fechada: scripts/docs/politica-de-branches.spec.ts deriva o conjunto do repositório — todo arquivo de scripts/ci/ cujo docblock de abertura cita branching-policy.md, mais o spec dele — e reprova se a regra divergir. Um script novo de política declara sua fonte no cabeçalho, como os outros, e o spec faz o resto.

docmap.mjs — valida o mapa

Roda antes de todo o resto, porque um mapa quebrado faz o resto mentir. Reprova se:

  • um glob não casa com arquivo nenhum — uma regra morta. Ela nunca dispara, e dá a impressão de uma cobertura que não existe. É o defeito mais silencioso que um docmap pode ter, e foi encontrado em 8 globs quando o validador entrou.
  • um documento para o qual o mapa aponta não existe
  • há id duplicado ou severity inválida

generate.mjs — gera e verifica

Dois modos de saída:

Arquivo inteiro — docs/reference/scripts.md. Não há prosa a preservar: a lista de comandos é o conteúdo. Ela vem do package.json de cada pacote e dos alvos anotados do Makefile. A lista de pacotes é declarada em gerarScripts, incluindo os dois pacotes fora do workspace (website/, e2e/, rodados com --dir), e um package.json ausente ou que não parseia reprova a geração nomeando-o — antes ele descartava o pacote em silêncio (AT-224).

Bloco marcado — o trecho entre <!-- BEGIN:GENERATED:<id> --> e <!-- END:GENERATED:<id> --> dentro de um arquivo escrito à mão. É o caso de configuration.md e events.md: ali, a prosa ("o que quebra quando esta variável está errada") vale muito mais que a lista, mas a lista precisa estar completa. O bloco é o inventário; o texto em volta é a explicação.

O inventário marca com ⚠️ o que aparece no código e não tem descrição na prosa. Foi assim que tool.result e agent.response — dois tipos de evento reais — apareceram depois de terem ficado de fora do primeiro rascunho.

O que o inventário de ambiente pega, medido (AT-124, 2026-09-25). As fontes e seus globs moram em scripts/docs/fontes-de-env.mjs, provadas contra a árvore real por fontes-de-env.spec.ts. Uma mutação por fonte — um process.env.X novo num arquivo direto sob e2e/, aninhado em e2e/suporte/, direto sob apps/api/scripts/ e aninhado um nível abaixo dele, cada um com git add — fez o --check reprovar com DESATUAL em configuration.md nos quatro. Um buraco foi achado e fechado: o filtro .spec., certo para fontes onde um spec é teste unitário ao lado do código, também descartava e2e/testes/*.spec.ts — os próprios testes Playwright, onde uma variável nova de e2e/ tem mais chance de nascer — e essa mutação passava verde. Um limite fica declarado: o inventário lê só arquivos versionados (git ls-files), então um arquivo não rastreado é invisível até o git add.

deploy/k8s/*.sh virou fonte (AT-212, 2026-09-28). O bootstrap e as provas de propriedade leem vinte e poucas variáveis do ambiente — nome do cluster, namespace, a conta do smoke, os tetos de cada prova — e nenhuma estava no inventário. Elas são fonte ferramenta, casadas pela expansão com default do shell (${X:-…}, ${X:=…}, ${X:?…}) e só por ela: um ${X} puro é quase sempre variável do próprio script. Mutação: um ${X:-x} novo em deploy/k8s/validate.sh, no stage, fez o --check reprovar com DESATUAL e uma linha SEM DESC. nomeando a variável e o arquivo.

O ⚠️ de ambiente é portão (AT-211, 2026-09-27). Até então o que reprovava era só o bloco desatualizado: assim que o docs:generate o reescrevia, a variável ficava ali com ⚠️ e o --check passava — HUGGINGFACE_API_TOKEN e HUGGINGFACE_HUB_URL ficaram assim por meses. Por decisão do mantenedor, o --check agora reprova qualquer ⚠️ no inventário de ambiente, para as duas espécies de fonte (produto e ferramenta), mesmo com o bloco em dia. A falha nomeia a variável, o arquivo que a lê e a seção de configuration.md onde a descrição vai (linhas SEM DESC.). A regra mora em scripts/docs/inventario-de-env.mjs e é provada por mutação em inventario-de-env.spec.ts: uma variável nova sem descrição reprova, a mesma com descrição passa.

Um **TODO(humano):** na linha que cita a variável conta como descrição e não reprova: a regra de docs é "nunca inventar; sem informação, TODO(humano)", e um portão que reprovasse o TODO empurraria quem não sabe o que a variável faz a inventar uma frase para ficar verde. O TODO é uma lacuna declarada, com a pergunta escrita onde a resposta vai; o ⚠️ é uma lacuna silenciosa. O --check ainda lista as variáveis descritas só por um TODO, numa linha TODO, sem reprovar. O inventário de eventos em events.md mantém seu ⚠️ como lacuna visível — a decisão cobriu só o inventário de ambiente.

--check não escreve nada e reprova se qualquer coisa fosse ficar diferente. É o modo do CI.

fontes.yml — onde mora o código que a doc lê

scripts/docs/fontes.yml é o mapa nome-lógico → raiz (api, engine, web, runner, broker, contracts, docs, infra), preparação para a separação em repositórios. No monorepo ele é IDENTIDADE: nenhum caminho resolvido muda, e os caminhos escritos no código e nas RNs (apps/api/...) seguem relativos à raiz do monorepo. Todo leitor de código em scripts/docs passa pelo resolvedor de fontes.mjs (ler, arquivos, versionados, caminhoReal), que escolhe a fonte pelo prefixo mais longo (infra: . é o resto). Depois da separação, BRABO_FONTES=/caminho/outro-fontes.yml aponta os mesmos nomes para clones irmãos (raiz relativa ao monorepo ou absoluta). Fonte ausente do mapa ativo, raiz inexistente ou arquivo que não abre REPROVA nomeando a fonte — nunca cai no caminho antigo nem devolve vazio (fontes.spec.ts, por mutação).

drift.mjs — cobra no PR

Cruza git diff --name-only <base>...HEAD com o mapa. Para toda regra disparada cujo documento não foi tocado: block reprova, warn comenta.

Num PR do Dependabot cujo diff é só troca de pin de action, um passo logo antes do drift (scripts/ci/dependabot-justifica-pin.ts) escreve a linha docs-not-needed: no corpo, no mesmo job, para escrever e ler numa execução só. O workflow escuta edited, e @dependabot rebase emite synchronize e edited juntos; o concurrency cancela um, então a execução que sobrevive pode ser a do edited. Por isso o passo também avalia no edited quando quem editou é o bot (AT-100), e nunca quando é um humano (a remoção da linha por ele vence). Não tem como entrar em laço: edições de corpo feitas com o token do workflow não disparam edited. A falha fica fechada: se a escrita falha, o drift lê o corpo do evento e o PR continua bloqueado.

audit.mjs — a auditoria mensal

A checagem de drift pega uma doc que ficou errada num PR. A auditoria pega uma doc que ficou velha sem ninguém mexer nela — o que é mais difícil de notar. Ela relata:

  • páginas sem alteração há meses cujo código correspondente mudou desde então
  • itens TODO(human) pendentes
  • referências arquivo:linha que não resolvem mais
  • ADRs em status proposed há mais de 60 dias

Sempre na mesma issue, atualizada no lugar. Uma issue nova todo mês vira spam, e spam é desligado. O workflow cria o próprio rótulo docs (gh label create --force, idempotente) antes de abrir a issue, e scripts/ci/rotulos-dos-workflows.spec.ts reprova todo --label sem esse create no mesmo arquivo.

O build do site

onBrokenLinks, onBrokenAnchors e onBrokenMarkdownLinks estão em throw. Mover um arquivo sem corrigir quem aponta para ele quebra o CI em vez de virar um 404 em produção. É o mecanismo mais barato do conjunto inteiro.

Página nova precisa entrar à mão em navegacao.mjs

O conteúdo Markdown mora em docs/, mas o roteamento mora em scripts/docs/site/navegacao.mjs (o porte do antigo sidebars.ts) — e as seções dele enumeram os itens um a um, em vez de varrer o diretório. Criar um arquivo em docs/ sem acrescentá-lo lá produz uma página que existe, é servida por URL direta e não aparece na navegação.

O build não reprova por isso: uma página órfã não é um link quebrado. É uma conferência visual — depois do pnpm docs:build, abra a barra lateral e confirme que a página está lá.

A exceção são os ADRs: o grupo "Decisões (ADR)" é derivado a cada build de docs/adr/temas.yml e dos arquivos em docs/adr/ — uma subcategoria por tema, na ordem da lista, com os ADRs em ordem numérica (AT-560). ADR novo entra no grupo do tema dele sem tocar na navegação. Se o parser não achar tema nenhum, ADR mapeado nenhum ou um ADR sem tema (que o docs:check já reprova), o build quebra com erro nomeado em vez de desenhar uma lateral errada. A Explicação é enumerada à mão em cinco grupos por assunto, e página nova ali ainda entra pelo caminho acima.

As categorias da barra lateral são recolhíveis (AT-767): cada uma é um <details>/<summary> nativo, sem JavaScript e sem persistência. Nasce aberta quando colapsada é falso ou quando contém a página atual; do contrário nasce fechada (os temas de ADR e quatro dos cinco grupos da Explicação). O link de "Decisões (ADR)" fica fora do <summary>, como primeiro item da lista.

api-render-check.mjs — build verde não é página que renderiza

Esta peça existe por causa de uma lição cara: as 117 páginas de operação da referência da API foram ao ar mortas nas releases v1.0.0 e v1.0.1, e nenhuma checagem pegou.

A configuração do Docusaurus não declarava docItemComponent: '@theme/ApiItem', e o ApiItem é o único lugar do plugin de OpenAPI que montava o <Provider> do redux. Sem o invólucro, o contexto era nulo — e o error boundary trocava a página inteira por "This page crashed."

O modo de falha é o que importa aqui, porque ele derrota todas as outras peças deste mecanismo:

etapa resultado
MDX compila ✅ os componentes do tema existem e resolvem
SSR renderiza ✅ o HTML servido tem o conteúdo da rota
pnpm docs:build ✅ verde
hidratação no navegador ❌ a página é apagada

Em outras palavras: "o build passou" nunca foi prova de que a página funciona.

Desde o ADR 0206 esse plugin saiu e, desde o ADR 0253, a referência é uma página Redoc 3 por tag, gerada por SSR no build (scripts/docs/site/openapi.mjs, disableTelemetry: true, sem hidratação no navegador): reference/api.html é o índice das tags e cada tag fica em reference/api/<tag>.html#<operationId>. A lição fica, então a guarda fica também: api-render-check.mjs roda depois do docs:build e afirma, nos quatro locales, que as páginas existem, carregam o info.title do spec e somam uma seção renderizada por operação de docs/reference/openapi.json (263 hoje, contadas por página).

Ela pega a página sumindo, renderizando vazia ou perdendo operações.

O mesmo episódio produziu a regra site-e-publicacao do mapa: website/** não era coberto por regra nenhuma, e mudar a configuração do site não cobrava documentação (hoje a regra cobre também scripts/docs/site/**).

Quatro locales, com pt-BR como fonte (ADR 0216)

docs/ é a árvore pt-BR, a fonte e o locale padrão (defaultLocale: 'pt-BR', servido na raiz de cada ambiente). Os locales traduzidos são en, ru e zh-Hans, sob docs/i18n/<locale>/ (o gerador NÃO trata essa pasta como página da fonte pt-BR), com os rótulos da barra lateral em docs/i18n/<locale>/current.json e os textos da moldura no próprio gerador (scripts/docs/site/layout.mjs). Uma página sem tradução num locale é servida a partir da fonte pt-BR no mesmo slug. A busca é por substring, com um índice por idioma carregado só quando a pessoa busca. Páginas cuja cópia pt-BR antiga estava desatualizada ficam em docs/ no idioma em que estão até o lote delas.

O site em inglês saiu da raiz para /brabo/<environment>/en/…, e links antigos para /brabo/<environment>/pt-BR/… são reescritos pelo 404.html da raiz (scripts/docs/landing.mjs) para /brabo/<environment>/…, no mesmo molde de /main/ → /prd/ (ADR 0073).

A regra traducao do docmap (era traducao-pt-br) vigia a fonte e aponta para docs/i18n/. Ela é warn e nunca block: exigir tradução em todo PR de doc custaria três traduções por mudança.

A paridade de tradução é gerada, e a página diz quando está atrás (AT-550)

O que segura o atraso é medição, não o docmap. scripts/docs/paridade-de-traducao.mjs (provado por mutação no spec ao lado) roda dentro do pnpm docs:generate e escreve dois arquivos: docs/reference/paridade-de-traducao.md (generated: true) e docs/i18n/traducao.json. Por locale ele conta páginas e caracteres traduzidos sobre a fonte (a docs/ do próprio brabo, reference/ incluído desde o ADR 0219, menos o próprio relatório, o openapi.json e i18n/; a doc dos irmãos fica fora, ADR 0273), e divide as páginas traduzidas em em dia, desatualizadas e sem proveniência. A proveniência é o traducao_de: <SHA> no frontmatter da página traduzida, o último commit da fonte que ela traduz. Uma página está desatualizada quando a fonte tem um commit com COMMIT DATE posterior a esse SHA, quando a fonte tem alterações não commitadas, ou quando o SHA não existe. Ele nunca usa o relógio, então o --check dá a mesma resposta antes e depois do commit que toca a fonte. Um caminho é do brabo quando está em HEAD ou não consta do manifesto .git/layout-irmaos.lst; por isso um merge de doc num irmão não muda mais a paridade. A interface não é medida aqui: o portão de chaves e de plurais é o src/lib/i18n-paridade.test.ts do brabo-web, e os textos da API só avisam no log do docs:generate. O --check reprova quando qualquer um dos dois arquivos está desatualizado.

A faixa é desenhada pelo layout.mjs. Fora do pt-BR, uma página servida do fallback pt-BR diz isso, e uma página traduzida que o traducao.json marca como desatualizada mostra a data da tradução e diz que o original mudou. scripts/docs/faixa-de-traducao-check.mjs roda depois do pnpm docs:build e confere o HTML estático: uma página de fallback tem a faixa (a amostra vem do primeiro locale, en, ru ou zh-Hans, que ainda tenha página sem tradução; com todos completos não há fallback a conferir), enquanto a mesma página na raiz e uma página traduzida para en que não está desatualizada não têm. As regras para quem traduz estão em Localização.

A doc de todos os repos é em pt-BR por padrão (AT-766, ADR 0266, que substitui o 0260). Os irmãos podem ter <raiz>/docs/i18n/, medido no repositório dono (a paridade do brabo não os conta, ADR 0273). A página de irmão que ainda está em inglês (estado misto, até a retradução) continua aceita: com idioma_original: en ela não entra na paridade (ehFonteTraduzivel), é servida sem faixa no /en/ e com a faixa "esta página só existe em inglês" nos outros locales (paginas.mjs marca somenteIngles; faixa-de-traducao-check.mjs confere o HTML). O índice de RN de cada locale usa a tradução quando existe e a fonte quando não existe (rnsDoLocale), o bloco gerado numa fonte em inglês sai com os textos em inglês (textosDaFonte), e as frases ancoradas na prosa de irmão (contagens-do-codigo.mjs, o runbook) aceitam pt-BR ou inglês (padraoEn). Os catálogos openapi-textos.json moram no brabo, em docs/i18n/<locale>/.

A página da API sai traduzida por catálogo (ADR 0220)

A fonte das descrições da página Redoc continua sendo o texto dos decorators da api, quase todo em inglês. Cada locale tem um catálogo docs/i18n/<locale>/openapi-textos.json, chaveado pelo sha1 (12 hex) do texto da fonte, com fonte e traducao. O gerador do site aplica o catálogo com scripts/docs/openapi-por-locale.mjs (função pura, provada por mutação no spec ao lado) e grava a spec do locale em reference/openapi.json do build. Texto sem tradução fica o da fonte; o en só traduz os textos que estão em pt-BR. Com o Redoc 3 (ADR 0253) a interface do Redoc fica toda em inglês: o 3.x removeu a opção labels. Só o texto do spec é traduzido. O docs:check reprova chave órfã (o texto saiu da api) e entrada inválida (fonte que não bate com o hash, ou número de trechos `…` diferente); faltando só avisa, e a paridade conta numa seção própria. pnpm docs:openapi-textos <locale> acrescenta ao catálogo as chaves que faltam, com traducao: "".

Era o hook markdown.hooks.onBrokenMarkdownLinks do Docusaurus que reescrevia links quebrados por lacuna de tradução para pathname://, a saída de emergência que nunca mais é conferida. Com o gerador próprio (ADR 0253) essa reescrita desapareceu: cada locale é montado com o conjunto inteiro de páginas (tradução ou fallback no mesmo slug), e o checador de links do docs:build confere todo href e toda âncora. A guarda de locale duplicado fica, como histórico da lição:

Uma reprovou, para o site pt-BR inteiro (AT-221). A reescrita devolvia pathname:///pt-BR/<slug>, mas pathname:// não é um caminho absoluto no domínio — o <Link> o passa por useBaseUrl, e o baseUrl do build pt-BR já é /brabo/prd/pt-BR/. Todo link reescrito saía como /brabo/prd/pt-BR/pt-BR/...: 441 hrefs em 56 páginas, todos 404, com o docs:build verde.

A reescrita agora mora em scripts/docs/links-do-locale.mjs como função pura com spec, e devolve o slug puro — o locale vem do baseUrl de qualquer locale que esteja compilando, e a página de destino existe lá (traduzida, ou servida por fallback no mesmo slug). O mesmo módulo, rodado depois do docs:build no docs-check.yml, varre o HTML gerado e reprova qualquer href com prefixo de locale duplicado. Um locale novo entra também em LOCALES_COM_PREFIXO, senão a duplicação dele passa calada. Desde o ADR 0216 essa lista é en, ru e zh-Hans — o pt-BR é o padrão e não tem prefixo.

Com traduções parciais, compilar en quebra um link de uma página não traduzida (servida da fonte pt-BR) para uma traduzida em qualquer pasta, não só nas três zonas. Então um link também é tratado como lacuna quando o destino existe na fonte docs/ (paginaExisteNaFonte) — essa é a página que o locale serve, traduzida ou por fallback. Um destino que não existe em lugar nenhum continua reprovando o build.

A isenção de adr/ também engolia um nome de arquivo de ADR digitado errado: ela olhava só a forma do nome (NNNN-*.md), então 0055-politica-de-terminal.md — um ADR que nunca existiu com esse nome — era reescrito para um slug que nenhum locale tem. Vinte e três links assim estavam nas docs, 404 (AT-227). Desde então um destino com cara de ADR tem de existir em docs/adr/ antes de qualquer isenção de lacuna valer, inclusive de uma fonte dentro de adr/, reference/ ou explanation/; um slug errado reprova o docs:build em todo locale.

O que a guarda ainda não pega: um link que não é de ADR, de dentro de uma das zonas de lacuna, cujo destino não existe no repositório.

Publicação, um site por degrau

Cada branch permanente publica no seu próprio lugar do mesmo GitHub Pages:

branch URL indexado por buscadores
— (índice) https://daneiel.github.io/brabo/ ❌
main https://daneiel.github.io/brabo/prd/ ✅
dev https://daneiel.github.io/brabo/dev/ ❌

Os dois são simétricos (a qa foi aposentada, ADR 0235) desde o ADR 0071; a raiz é uma página gerada que lista os três com a versão carimbada de cada um, e todo site tem um seletor no topo para trocar de degrau. Antes disso, a main publicava na raiz, e esse caso especial obrigava o workflow a preservar /dev/ e /qa/ num caminho que só rodava um terço das vezes.

O caminho não é o nome da branch. O ADR 0073 separou os dois: o endereço nomeia o ambiente para quem lê, e main é vocabulário de quem commita. É por isso que a main publica em /prd/; qa e dev coincidem por acaso, não por regra. O mapa branch→caminho existe num ponto por processo — o passo "qual degrau, e onde publica" do workflow, a tabela DEGRAUS em scripts/docs/site/layout.mjs e a de landing.mjs — e todo o resto deriva dele.

Duas consequências que vale lembrar antes de mexer nisso:

  • /brabo/main/ era publicado, e não é mais. Como a árvore é montada e empurrada com keep_files: false, o diretório some. O que preserva os links salvos é o 404.html da raiz, que reescreve o prefixo (/brabo/main/<algo> → /brabo/prd/<algo>) como caso próprio, separado do redirecionamento genérico — que produziria /brabo/prd/main/<algo>. A guarda contra laço só cobre caminhos que existem (prd|qa|dev).
  • /prd/ foi semeado a partir de gh-pages:main na transição, pelo mesmo motivo por que o ADR 0071 semeou /main/ a partir da raiz antiga: sem isso, /brabo/prd/ responderia 404 entre o primeiro push da dev e a próxima promoção para a main. A semente é reescrita de /brabo/main/ para /brabo/prd/, porque o 404.html não salva sub-recursos — CSS e JS pedidos no endereço antigo receberiam HTML de volta com status 404, e o site serviria texto cru.

Isso fecha uma lacuna da esteira: entre um merge na dev e a promoção final, ler a documentação daquele estado exigia clonar o repositório. O docs-check constrói o site em todo PR mas descarta o build — o veredito dele é "constrói sem links quebrados", nunca "mora em algum lugar que eu possa abrir". E foi exatamente essa lacuna entre escrever e olhar que deixou a referência da API ir ao ar quebrada por duas releases.

Três detalhes que não são óbvios e já custaram um bug cada:

  • Os assets são RELATIVOS (ADR 0253): o gerador não precisa do baseUrl do degrau, e o mesmo build serve em /brabo/dev/, em /brabo/prd/ ou aberto do disco. No Docusaurus um baseUrl errado (DOCS_BASE_URL) deixava a página quebrada sem erro; o docs:build agora checa todo href/src.
  • O degrau é declarado em DOCS_BRANCH, não deduzido do baseUrl. Antes era BASE_URL === '/brabo/', e funcionava enquanto a main era a única na raiz. Com os três sob um subdiretório, essa comparação vira falsa também para a main, e o efeito seria noIndex na documentação REAL — fora do Google, calado, com o CI verde, porque nada no build reprova por subindexação. O ADR 0073 é exatamente o cenário para o qual essa separação foi construída: o caminho mudou para /prd/ e E_PRODUCAO não precisou saber.
  • noindex fora da main vai no <meta name="robots"> de cada página e fala com buscadores externos; a busca do site é o índice local por idioma e não o lê, então as duas salvaguardas não se anulam.
  • A main monta a árvore antes de publicar, trazendo /dev/ e /qa/ de volta. keep_files: true seria mais simples e estaria errado: nunca removeria nada, e uma página apagada do repositório ficaria publicada para sempre.

Publicar são DOIS workflows, e só um é nosso

Isto engana quem procura o deploy na aba Actions:

ordem workflow onde mora o que faz
1 Documentação (docs-deploy.yml) .github/workflows/ constrói o site e commita no gh-pages
2 pages build and deployment dynamic/pages/ — gerado pelo GitHub lê o gh-pages e o serve

O segundo não está no repositório e não aparece na lista de workflows versionados; o GitHub o cria sozinho quando a fonte do Pages é uma branch. O nosso workflow termina no commit — ele não publica no Pages, embora o job dele ainda se chame "Publish to GitHub Pages".

Consequência prática: o site pode estar velho mesmo com Documentação verde. Se o build_type do Pages não for legacy/gh-pages, o commit acontece e ninguém o serve — e nada no CI fica vermelho. A conferência é gh api repos/daneiel/brabo/pages, registrada em Rulesets.

O desenho inteiro, com as alternativas descartadas, está no ADR 0034.

As cores do site vêm do produto

O scripts/docs/site/assets/site.css usa só var(--token) de design/tokens.css — a mesma fonte de cor do apps/web (ADR 0181): escuro preto, claro branco, acento terracota, realce de código pelos --syntax-*. Nenhum hex é copiado. scripts/docs/contraste-do-site.spec.ts resolve os tokens de texto até hex nos dois temas e reprova abaixo de 4,5:1 contra o fundo (e reprova como CEGO se nada resolver).

A exceção é o índice da raiz (/brabo/, escrito por scripts/docs/landing.mjs): ele é HTML avulso no Pages, não alcança o tokens.css e por isso COPIA os valores, nos dois temas pela preferência do sistema. A cópia é amarrada por scripts/docs/landing.spec.ts, que resolve os tokens e reprova o valor que divergir — ela já ficou petróleo uma vez depois do ADR 0181.

O gerador do site (ADR 0253)

O ADR 0253 tirou o site do Docusaurus. pnpm docs:build roda docs:site (escreve build/docs-site/, ignorado pelo git, com as mesmas URLs e âncoras do site anterior) e docs:site:check (checador de links e âncoras, locale duplicado e faixa de tradução). O núcleo mora em scripts/docs/site/: paginas.mjs descobre as páginas de cada idioma (a tradução onde existe, a fonte pt-BR no resto, então o link nunca sai do idioma), render.mjs converte o Markdown (âncora {#id}, admonições, Mermaid, realce com shiki, link .md para URL relativa), navegacao.mjs porta a barra lateral, com os ADRs agrupados por docs/adr/temas.yml, layout.mjs desenha a moldura e openapi.mjs gera a referência da API. O caminho das traduções é uma constante só (CAMINHO_DAS_TRADUCOES, docs/i18n/<locale>/). website/ guarda só a árvore isolada do Redoc.

Rodando na sua máquina

website/ guarda só a árvore do Redoc e tem pnpm-lock.yaml próprio desde o ADR 0117 — o pnpm install da raiz não o alcança, então a primeira vez (ou depois que o lockfile dele muda) precisa de um passo a mais:

cd website && pnpm install && cd ..  # only the first time, or when website/pnpm-lock.yaml changes

pnpm docs:check      # validates the map + checks generated content is up to date
pnpm docs:generate   # regenerates
pnpm docs:drift      # simulates the PR check (origin/dev...HEAD)
pnpm docs:build      # the build CI runs — generates build/docs-site and checks links
pnpm docs:start      # builds and serves on http://localhost:3000

# does the API reference render? needs the build above, and isn't part
# of docs:check because that one doesn't build the site
node scripts/docs/api-render-check.mjs
node scripts/docs/links-do-locale.mjs   # no href with a doubled locale prefix (AT-221)

Ou, se você está no Claude Code, /sync-docs roda o ciclo inteiro e entrega um relatório do que mudou, do que virou TODO(human) e do que foi deliberadamente deixado sem mudar.

Conjunção por padrão, disjunção quando a doc foi partida

A lista docs: de uma regra é uma conjunção: todo documento listado tem de mudar, senão a regra dispara. É o padrão certo — é o que torna cobrável "mudou o gate, atualize a doc do gate E as regras".

Ele ficou errado exatamente uma vez, e a correção é docs_alternativos:, uma disjunção: qualquer um dos documentos listados satisfaz a regra. Ela existe porque business-rules.md foi partido por tamanho (Custo e Autenticação sozinhos eram metade de uma página de 640 KB), então uma regra de negócio agora mora em um de três arquivos. Sob conjunção, mudar uma regra de auth cobraria também o índice — um arquivo que não contém mais aquela regra — e a saída de quem fosse cobrado seria a saída de emergência docs-not-needed. Uma regra que ensina as pessoas a usar a saída de emergência é pior que regra nenhuma: ela as treina a ignorar a checagem.

docmap.mjs valida as duas listas do mesmo jeito: um caminho que não existe é o mesmo erro silencioso de um glob morto — uma alternativa com erro de digitação nunca poderia satisfazer a regra, e ninguém veria.

Quando a checagem reclama injustamente

Ela vai reclamar injustamente às vezes. Uma refatoração que renomeia variáveis internas dispara dominio-e-regras sem mudar regra de negócio nenhuma. Isso é esperado: o mapa funciona por caminho de arquivo, não por semântica.

Há duas saídas, e as duas exigem alguém explicando por quê:

PR label:      docs-not-needed
or in the body: docs-not-needed: internal refactor, no RN changed

Use sem culpa quando couber. A saída de emergência existe de propósito: sem uma saída legítima, o hábito que se forma é trapacear — um commit cosmético na doc só para a checagem passar. Aí o mecanismo começa a mentir, o que é pior que não existir.

Uma classe de PR recebe a linha do corpo de um bot, e só uma: um PR do Dependabot cujo diff não é nada além de trocas de pin de action (o SHA do uses: e o comentário de versão dele). Um passo logo antes do drift escreve a linha, marcada como do bot, e o drift continua lendo só a linha — ver A pin bump justifies itself. Como editar o corpo com o GITHUB_TOKEN não dispara evento edited, e uma reexecução reusa o payload original, o drift lê o corpo de PR_BODY_FILE quando esse passo o reescreveu, e do evento caso contrário.

O que não está certo é usar a saída de emergência por pressa. Se você a usou três vezes na mesma semana para a mesma regra, a regra é o problema: ajuste o glob em docs/.docmap.yml, ou baixe a severidade de block para warn.

Quando o próprio mecanismo está errado

sintoma correção
a regra dispara numa mudança irrelevante glob largo demais — estreite o watch
a regra nunca dispara glob morto — o pnpm docs:check o aponta
o conteúdo gerado está sempre desatualizado no CI alguém o editou à mão; corrija o gerador ou a fonte
o build quebra num { ou num HTML solto .md é CommonMark; se precisar de componente React, renomeie para .mdx
a auditoria relata a si mesma acrescente o arquivo à lista META em audit.mjs

O que este mecanismo não faz

Ele não confere se o texto está correto. Ele confere se o texto foi revisado quando o código mudou, e se as listas estão completas. Uma frase factualmente errada que ninguém tocou passa em toda checagem — é para isso que servem a leitura humana e o /sync-docs.

Ele não escreve documentação. Ele gera um inventário e cobra revisão. O que uma variável faz quando está errada, por que um teto existe, o que investigar durante um incidente — isso continua sendo trabalho que tem de ser escrito.

Ele não cobra o README.md, nem nada que regra nenhuma vigia. O docmap é piso, não teto: em 2026-07-29 nenhuma regra vigiava o coração da observabilidade (tracing.ts, infrastructure/observability/**, telemetry/**, lib/logger.ts), e o README.md mal é exigido por alguma regra. Entregar só o que o CI cobra é como as duas frases sobre OTEL_EXPORTER_OTLP_ENDPOINT ficaram erradas por meses. Quando você mudar código, varra docs/ procurando o que a mudança tornou falso — inclusive o que ninguém pediu para você olhar.

Raízes de doc por área

Desde o ADR 0254 a doc de cada área mora em <raiz>/docs/ (a lista está em scripts/docs/raizes-de-doc.mjs, o mapa nome→pasta em scripts/docs/fontes.yml ou no arquivo de BRABO_FONTES), e docs/ segue como agregador. O site é a árvore de união: o arquivo mantém o caminho relativo que tinha, a URL pública não muda e os links entre páginas usam as coordenadas do site, não as do disco.

flowchart LR
  A["apps/api/docs"] --> U
  W["apps/web/docs"] --> U
  E["apps/engine/docs"] --> U
  R["apps/runner/docs · broker · packages"] --> U
  I["deploy/docs · scripts/ci/docs"] --> U
  P["docs/ (agregador)"] --> U
  U(("união")) --> S["site: mesmas URLs"]
  • RN nova vai em <raiz>/docs/business-rules/<raiz>.md, na raiz que hospeda a primeira ref caminho:N. A numeração é global e a âncora {#rn-NNN} não muda; docs/business-rules.md é índice gerado e custo.md/autenticacao.md são stubs congelados.
  • Os irmãos entram pela trava. irmaos.lock (um SHA por irmão) é o que o montar-layout.sh usa no docs-check, no docs-audit e no degrau dev do deploy; BRABO_REF vence a trava (o degrau main usa BRABO_REF=main). Só o regenera-gerados.yml sobe a trava, junto com os gerados (ADR 0274). Assim, scripts.md, o índice de RN, as contagens e as refs com símbolo só mudam na subida, e a PR comum não fica vermelha por merge num irmão.
  • docs:check soma todas as raízes e reprova RN duplicada entre elas. Com --raiz <nome> roda por raiz o que só depende dela (âncoras, refs internas, glob do docmap, drift) e deixa para o agregador o que cruza raízes (contagens, índice de RN, paridade, links entre idiomas). Hoje o --raiz só restringe as refs com símbolo; os demais geradores rodam inteiros. Por isso o irmão não o usa: as refs dele são conferidas no próprio CI (ADR 0275).
  • Traduções ficam em <raiz>/docs/i18n/<locale>/, com um só traducao.json. Mover arquivos exige dois commits: a mudança e depois o recarimbo de traducao_de.
  • Preço: link entre raízes dá 404 na visualização do GitHub; no site funciona.