Pular para conteúdo

0047 — Correção no web sem forma de provar que está certa

Status CONCLUÍDA
Branch spec/0047-suite-unitaria-do-web
Criada em 2026-08-26
Apps afetados web
Revisão do problema aprovada na rodada 8
Revisão da experiência aprovada na rodada 4
Revisão do detalhamento fechada na rodada 9 por decisão do usuário — ver a seção 16
Revisão da implementação fechada na rodada 1 por decisão do usuário — ver a seção 17

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

A spec tem três partes, escritas em ordem, e quatro portões — cada um revisado por um subagente de contexto limpo, em até 3 rodadas. A máquina de revisão é do plugin superpowers; a régua é a rubrica correspondente em .claude/rubrics/. Achados vêm classificados: qualquer Critical reprova, Important em aberto reprova salvo decisão registrada do usuário, e Minor vira pendência aceita.

  1. A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
  2. A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
  3. A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
  4. A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.

Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.


Parte I — O problema

Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje. Se você já sabe qual é a tela, guarde para a Parte II.

1. Dor

Não há como deixar provado que uma correção de comportamento no apps/web está certa. Conferir, dá — abrindo a tela. O que não há é como transformar essa conferência em algo que fique, que rode de novo e que fale quando a regra sumir. São dois fatos — o primeiro se confere com um comando, o segundo é o que esse comando não entrega:

  1. Não existe ferramenta de teste no apps/web. O package.json tem quatro scripts — dev, build, start, refine — e nenhuma dependência de teste. Não é uma suíte fraca ou desatualizada: é a ausência dela.
  2. O que existe de barato responde uma pergunta que não é a mesma. make web-dev (Makefile:88) sobe o Vite com hot reload, e em segundos dá para olhar o campo repintar. Olhar responde "está assim agora?". Não responde "continua assim depois da próxima mudança?" — não se reverte a correção e re-roda, não fica nada de regressão, e a conferência depende de alguém lembrar de olhar e saber o que procurar.

Uma afirmação repetível sobre comportamento existe e se chama teste de e2e: fica no repositório, roda sozinha, e a 0046 tem três deles sobre comportamento de tela. O que ela custa é um rebuild do ambiente antes de cada execução. O que não existe é uma que seja repetível e barata ao mesmo tempo — e é essa que falta, não "uma forma de ver a tela".

O que isso já custou, uma vez, a quem estava usando o sistema — com a ressalva de que um dos mecanismos candidatos não teria sido pego por suíte nenhuma. A Parte I da spec 0046 registra, como o ponto de quebra "que foi reportado depois": o erro do telefone existe e não aparece — telefone com formato errado, e nenhum aviso na tela. A investigação levantou quatro mecanismos capazes de produzir aquilo e não isolou qual foi; o que está observado é o sintoma. O contorno registrado na mesma seção — para a dor inteira daquela spec, não só para o telefone — é: "a pessoa relê o formulário campo a campo até achar; não achando, telefona ou manda mensagem para a garantidora".

A ressalva, e ela é contra o argumento desta spec. Dos quatro mecanismos candidatos, três seriam alcançáveis por teste — precedência de texto no componente, máscara descartando dígito, estado de erro anulado. O quarto é ausência de regra: o campo não tinha conferência nenhuma, e não há teste que fique vermelho por uma regra que ninguém escreveu. Se a causa foi essa, uma suíte existindo antes não teria mudado nada naquele defeito — o que ela muda é o custo de descobrir os outros três, e o de provar a regra depois de escrita.

A 0046 não nomeia o ambiente. Como a produção não tem clientes desde que existe (spec 0028), quem esbarrou foi o time — o alcance de hoje é ele.

E o que quase custou. Três defeitos da implementação da 0046, cada um encontrado por um revisor lendo o diff, nenhum por teste:

  • Clicar em Salvar e a tela não fazer nada. Digitando São Paulo no campo Estado do cadastro de imobiliária, o sistema recusava, e a recusa não aparecia em lugar nenhum — nem no campo, nem numa faixa, nem em movimento de rolagem.
  • O erro aparecer sem dizer o que fazer. Dois campos de telefone — em Minha Garantidora e no onboarding — ficavam sem conferência na tela, então a recusa vinha do servidor e a faixa exibia "Verifique os detalhes abaixo", sem nada abaixo.
  • A tela levar ao campo errado. O botão que todo mundo usa não executava a rolagem até o primeiro erro; sobrava o comportamento padrão da biblioteca de formulários, que escolhe pela ordem em que os campos foram registrados, e não pela ordem em que aparecem na tela.

Os dois primeiros nasceram da correção de um defeito anterior, e nenhum dos três foi descoberto por uma execução: os três saíram de leitura humana, em rodadas diferentes.

O custo, no que está registrado. A implementação da 0046 passou por dez rodadas de revisão de branch, além de uma de fase (apps/api) — cada uma um revisor de contexto limpo lendo o diff inteiro, cerca de dez minutos e ~270 mil tokens.

Este número custou uma rodada desta spec para ficar certo, e vale o registro. A tabela da seção 17 da 0046 listava nove linhas de branch enquanto a narrativa dela tinha dez seções — faltava a linha de uma rodada, e as de baixo estavam um número adiantadas. Derivar da tabela dava nove; o Registro da mesma spec dizia dez. A tabela foi corrigida.

E a conferência que transforma "escrevi um teste" em "tenho uma prova" — reverter a regra e ver o teste ficar vermelho — está registrada onze vezes na seção 17 daquela spec: nove testes da API na tabela, e dois dos três de e2e listados abaixo dela (o terceiro não foi morto de propósito, porque existe para medir uma corrida). A branch teve pelo menos mais uma conferência fora desse registro. Das onze, duas eram de comportamento de tela, e custaram dois rebuilds do dev-remote cada uma, porque não há outro caminho.

2. Quem sente

Quem implementa no apps/web, e é onde está toda a medida deste documento. Toda mudança ali atravessa um ciclo em que nenhum dos juízes de comportamento disponíveis é repetível e barato ao mesmo tempo. Olhar a tela no dev server é barato e não fica: na próxima mudança alguém precisa lembrar de olhar. A suíte e2e é repetível e fica no repositório, e custa um rebuild a cada mudança de código — reexecutá-la sobre a mesma imagem, não. O revisor não exige rebuild e também não é repetível — ele lê uma vez, e a 0046 registra achados saindo em rodadas diferentes sobre o mesmo código. E é caminho quase sempre obrigatório: 41 das 45 specs anteriores a esta declaram impacto no apps/web — a conta exclui a própria 0047, que estaria contando a si mesma como prova. Três das quatro exceções são de ferramental e de infraestrutura; a quarta é feature de produto que não chega à tela (templates de e-mail).

Quem carrega o risco: quem preenche formulário. O produto tem mais formulários que estes — login, usuários, perfis, convite, recuperação de senha, o parecer e a emissão dentro da análise. Os seis abaixo são os que a spec 0046 tocou, e por isso os que esta cobre; cada um tem dono, conferido em apps/api/app/db/rbac_seed.py:

Tela Quem preenche
Análise de crédito OPERATOR, ADMIN_TENANT, AGENCY_USER — e o ANALYST só na edição, porque tem credit_analyses:update e não tem create
Cadastro de inquilino OPERATOR, ADMIN_TENANT, AGENCY_USER — e o ANALYST na edição, por renters:update_sensitive, que o router aceita como alternativa ao update
Cadastro de imóvel OPERATOR, ADMIN_TENANT, AGENCY_USER
Cadastro de imobiliária ADMIN_TENANT
Dados da própria garantidora ADMIN_TENANT
Onboarding quem abre uma garantidora nova — exige sessão, e nenhuma permissão do seed

O ADMIN_GLOBAL recebe no seed todas as permissões menos duas — as do dossiê de garantia, subtraídas pela spec 0040 — e portanto alcança os cinco que exigem permissão; ele fica fora da tabela porque é papel de plataforma, não de negócio — a fronteira que o CLAUDE.md da raiz estabelece. A 0046 registra, como defeito alheio a corrigir em trabalho próprio, que ele hoje lista inquilino de todas as garantidoras.

O defeito da seção 1 mordeu alguém preenchendo algum destes, e a 0046 não registra qual — e o conjunto de candidatas depende de qual mecanismo foi. Dos quatro que a 0046 levantou, um ela descarta por escrito ("nenhum chamador faz isso… armadilha armada, não causa provada"); dois vivem em módulo compartilhado e só alcançam tela com campo mascarado — que na época eram três das seis: cadastro de inquilino, onboarding e dados da garantidora (conferido em 40c49d2~1). O quarto é ausência de regra, e esse alcança qualquer tela com campo de telefone, mascarado ou não — inclusive a análise de crédito, que tinha o campo e nenhuma conferência. O que está registrado é que aconteceu. Os três defeitos da outra lista, não: a revisão os pegou antes.

Não há volume de operação a medir: a produção não tem clientes. O alcance se sustenta pelo desenho: os seis formulários são o caminho por onde todo dado de negócio entra no sistema — ainda que dois deles (inquilino e imóvel) também se preencham dentro da análise de crédito, e que os dados da garantidora sejam edição posterior ao onboarding. E pelo que foi medido em desenvolvimento, na seção 3.

3. A jornada de hoje

O gatilho: alguém corrige uma linha no apps/web — a precedência de um texto de erro, qual elemento recebe o foco, a decisão entre marcar o campo e mostrar a faixa.

O caminho, ponta a ponta:

  1. tsc --noEmit — segundos, e responde uma pergunta só: compila. Não diz se o texto certo aparece, se o foco vai ao campo certo, se a mensagem não foi trocada por outra.
  2. Abrir a tela no dev server (make web-dev) — segundos, com hot reload. Você vê. O que você viu não fica: na próxima mudança alguém precisa lembrar de olhar de novo, e saber o que procurar. É o fato 2 da seção 1.
  3. Reconstruir o dev-remote — é o que os passos 4 e 5 exigem, porque as imagens embutem o código. Só a implementação da 0046 pagou 30 rebuilds, cada um com cerca de 65 segundos de passos de build, mais recreate e espera do container.
  4. Rodar o e2e — a 0046 registra "os ~15 minutos de execução" da suíte inteira; um arquivo só leva cerca de um minuto — medido, como os outros dois da nota abaixo. O tamanho se pergunta com npx playwright test --list, que hoje responde 369 casos (47 arquivos de spec, mais o de autenticação). Um teste de e2e exercita a tela por fora: diz que o conjunto funciona, não qual das dez linhas que você mexeu está errada.
  5. Conferir no navegador — depois do rebuild, um script de screenshot nos dois viewports custa ~30s e pega o que o type check não pega: título truncado, campo estourando, rolagem horizontal. A 0046 usou ("verificado no navegador, em 375px"). Responde sobre enquadramento, não sobre qual mensagem apareceu.
  6. Ou despachar um revisor — dez minutos, ~270 mil tokens, e ele julga lendo.

De onde vêm os números. Rodadas, tokens e os ~15 minutos da suíte estão registrados na seção 17 e no Registro da 0046. Os 30 rebuilds, os ~65 segundos de build e o ~1 minuto de um arquivo de e2e foram medidos durante aquela implementação e não estão registrados em lugar nenhum — ficam aqui com a origem à vista.

Onde quebra, exatamente: no passo 2. Toda pergunta de comportamento, que é a mais barata e a mais frequente, cai no degrau de alguns minutos (o rebuild, e depois um arquivo de e2e) ou no de dez (o revisor).

E quebra de um jeito específico, que é o que torna a dor conferível. Na spec 0046 um teste do web foi escrito, ficou verde, e não podia falhar: afirmava que o foco ia ao primeiro campo registrado, que naquele formulário coincide com o primeiro visual — apagar a implementação inteira não mudava o resultado. Foi descoberto na quarta rodada de revisão, por leitura humana.

Isso não é exclusividade do web, e a distinção importa. Teste que não pode falhar apareceu também na API naquela mesma branch, e também foi um revisor que o pegou. A diferença não é o defeito nascer — é a pergunta "ele fica vermelho se eu reverter?" ter resposta em um comando de um lado e em dois rebuilds do outro. Uma conferência que custa minutos é uma conferência que se adia.

O que já se tentou.

A disciplina escrita. O CLAUDE.md da raiz prescreve, para exatamente este caso: "o substituto é enumerar os estados que a correção toca e percorrer um a um por escrito antes de chamar o portão". É o contorno em uso, e a medida do quanto ele sustenta são as dez rodadas de branch da 0046 — que revisavam os três apps, e cujos achados também eram dos três; o que a seção 1 mostra é que os do web foram os que precisaram de mais voltas para convergir.

A auditoria de red-green, e ela é a evidência mais forte desta seção. Antes da quinta rodada da 0046, cada teste novo foi percorrido um a um, revertendo a regra e vendo-o ficar vermelho. Funcionou — a rodada seguinte procurou o mesmo padrão e não achou caso novo. E o registro dela declara o próprio limite, textualmente: "O que essa auditoria não alcança é o apps/web". O método que resolve o problema existe, é conhecido, e para na fronteira do web por custo.

4. Resultado esperado

Conferir uma correção de comportamento no apps/web passa a custar segundos, na máquina de quem a escreveu, sem reconstruir ambiente nenhum.

Como se sabe que resolveu — tudo verificável, nada instrumentado:

  • Existe um comando que responde sobre comportamento e cabe entre os dois primeiros passos da seção 3. Hoje, entre "compila" e "abrir a tela", não há nada que fique no repositório e que se possa rodar de novo. Depois, há: ele roda, e fica vermelho quando deve.
  • Os defeitos de decisão da seção 1 ficam vermelhos com a correção revertida, e a prova é reverter e ver — sem rebuild. São os que se resolvem escolhendo o que exibir e onde: qual texto vence no campo, qual elemento é o alvo do foco, o que vai para a faixa e o que fica inline.

O que continua no e2e, e é decisão desta seção: o quanto a tela rola e o que o teclado virtual cobre dependem de layout medido, e a 0046 registra um dos seus três casos de e2e como existindo "para medir uma corrida" entre o requestAnimationFrame e o repintar do React. Prometer esses fora do navegador seria prometer o que não se entrega. A própria recomendação da 0046 é estreita neste ponto — "qual elemento é alvo da rolagem", e não quão longe ela rola. - A conferência de que um teste novo morre passa a custar segundos. A medida do "antes" está na seção 1: das onze conferências de red-green da 0046, duas eram de comportamento de tela e custaram dois rebuilds cada. Aquelas duas continuam custando o mesmo — são casos de e2e, e nada sai do e2e nesta spec. O que muda é o preço de conferir daqui em diante. - Defeito de comportamento com cenário reproduzível passa a ter onde ser escrito antes da revisão. Não é promessa sobre o que o portão vai achar — isso não se verifica; é a constatação de que hoje não há arquivo onde pôr esse caso, e depois há.

5. Fora de escopo

  • A suíte e2e não perde nada. Nenhum caso sai dela nesta spec. Tirar cobertura antes de a substituta ter provado que pega os mesmos defeitos é trocar cobertura por promessa — decisão registrada com o usuário em 2026-08-26.
  • A suíte da API não muda. O problema descrito aqui é do apps/web.
  • O escopo são dez arquivos, nomeados, e não "o que a 0046 tocou" — ela tocou 22, e doze deles ficam de fora (o item seguinte diz quais e por quê). Os dez: os seis formulários da tabela da seção 2 (agencies/form.tsx, credit-analyses/CreditAnalysisForm.tsx, properties/form.tsx, renters/form.tsx, my-garantidora/index.tsx, onboarding/StepTenant.tsx), o credit-analyses/components/SelectionBlock.tsx — que é peça de um deles e carrega o erro de bloco —, e os três módulos compartilhados onde o comportamento dos formulários vive: shared/utils/foco-no-erro.ts (criado pela 0046), shared/utils/masks.ts e shared/components/MaskedField.tsx (que já existiam e ela estendeu).

Escopo decidido pelo usuário em 2026-08-26. Uma versão anterior desta spec descreveu o recorte como "dois formulários", o que era falso; e confirmá-lo com "22 arquivos" seria confirmar outra coisa, porque metade daqueles arquivos está excluída pelo item seguinte. - O resto do apps/web fica de fora, inclusive os doze arquivos que a 0046 tocou e não estão na lista acima. Onze são telas de lista, de detalhe, garantias e o painel do SaaS. O décimo segundo é o components/shared/CepField.tsx, e ele merece a distinção: é componente de campo, da mesma família do MaskedField.tsx, usado dentro de cinco dos seis formulários em escopo — todos menos o da análise de crédito. Fica de fora porque a 0046 não mudou comportamento nenhum nele: o diff daquele arquivo é, no líquido, um comentário dizendo que o CEP não ganhou máscara ali e que a seção 5 daquela spec o exclui por escrito. Cobrir comportamento que nenhuma spec definiu seria escrever o teste antes da regra. - Teste de aparência fica fora. Cor, espaçamento e alinhamento não entram — quem julga isso é o olho nos dois viewports, e a spec 0026 já tem o caminho para percorrer processo inteiro. - CI fica fora. Não há CI neste repositório, e criá-lo é decisão de outra ordem. - Sem tela, e sem superfície de segurança — mas com interface. Não há página, não há 375px, não há PWA, e não há endpoint, permissão, filtro de tenant ou escopo de imobiliária: os critérios de segurança dos portões seguintes saem da conta, e é aqui que isso fica dito. O que existe é a linha de comando e o que ela imprime, e isso é interface — a seção 7 a descreve, porque é ela que decide se a suíte é usada ou ignorada. Quem usa o resultado é quem desenvolve. - Os três defeitos alheios que apareceram durante a 0046 — a rota da parametrização de documentos, o ADMIN_GLOBAL que lista inquilino de todas as garantidoras, e os dois testes intermitentes por desempate de UUID — estão registrados no Registro dela, e cada um é trabalho próprio.

6. Revisão do problema

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/problema.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 1 2 3 REPROVADA
2 — 4 2 REPROVADA
3 3 4 4 REPROVADA
4 — 5 5 REPROVADA
5 — 5 8 REPROVADA
6 2 2 8 REPROVADA
7 — 2 8 REPROVADA
8 — 3 1 REPROVADA

Resultado: fechada na rodada 8, sem rodada confirmatória. O revisor da 8 escreveu que os três achados eram "corrigíveis dentro da própria frase" e que, feito isso, a Parte I aprova; as três correções foram aplicadas e conferidas item a item, e o usuário decidiu seguir para a Parte II em vez de gastar a nona rodada. A aprovação é condicional e ninguém a confirmou — está dito aqui porque "aprovada" e "aprovada se as três correções estiverem certas" são coisas diferentes.

O que estas oito rodadas ensinaram, e não é sobre esta spec

Sete das oito acharam o mesmo tipo de defeito: uma frase afirmando mais do que a fonte dava, sempre no sentido de agravar a dor. Não foram descuidos espalhados — foram três formas, e elas se repetiram:

  1. Negar algo que existe. "Não há passo 2" (existe make web-dev); "o único juiz repetível é um revisor" (a suíte e2e é repetível); "os dois exigem reconstruir o ambiente" (a oração seguinte dizia que o revisor não exige); "uma afirmação repetível não tem por onde ser escrita" (um spec de e2e é exatamente isso).
  2. Absoluto que é quase-verdade. "Todas as permissões" — eram todas menos duas. "Os formulários obrigatórios são seis" — há mais. "Fluxo anônimo" — exige sessão.
  3. Número que confirma outra coisa, ou que não propagou. Citar 22 arquivos para sustentar um escopo de dez; corrigir "nove rodadas" para "dez" numa seção e deixar "nove" noutra.

Duas descobertas vieram de conferir, não de escrever. A rodada 6 encontrou um erro na spec 0046, não nesta: a tabela de rodadas dela tinha nove linhas para dez seções, e o número errado desta spec veio de lá. E a rodada 8 obrigou a escrever, na Parte I, a ressalva que enfraquece esta spec — um dos quatro mecanismos candidatos do defeito é ausência de regra, e teste nenhum fica vermelho por regra que ninguém escreveu.

A lição de método está na última linha da rodada 6: "vale varrer os números uma vez contra a fonte em vez de um por rodada". Nas rodadas 1 a 5 remendou-se o que cada uma achava; a partir da 6 passou-se a extrair toda afirmação verificável do documento e conferir cada uma com um comando antes de despachar. As duas últimas acharam três achados cada, todos de uma frase — contra sete e cinco das anteriores.

Pendências aceitas: nenhuma. Os achados das oito rodadas foram corrigidos; o que restou de limitação está no corpo da Parte I, não aqui — a ressalva sobre o mecanismo que suíte nenhuma pegaria, e a origem declarada dos três números que não estão registrados em lugar nenhum.


Parte II — A experiência

Não preencher antes da revisão da seção 6 estar aprovada.

7. Jornada pretendida

A interface é a linha de comando e o que ela imprime. Alguém digita e alguém lê o resultado, e é isso que decide se a suíte vai ser usada ou ignorada — uma cuja falha não diz o que quebrou é uma que se roda uma vez. Não há tela; a seção 7.2 trata disso.

São duas jornadas, e a que decide o destino da feature é a primeira.

A jornada de escrever o caso

É a cara, e é a que mata a suíte se for cara demais. O comportamento que esta spec cobre vive em formulários que rodam dentro de Refine, tema MUI, roteador e react-hook-form; pôr um desses de pé para observar uma linha é o custo dominante.

  1. A pessoa acabou de descobrir um defeito — próprio ou apontado numa revisão — e quer deixá-lo provado.
  2. Ela copia o caso mais parecido que já existe. É assim que a forma se propaga, e é a resposta para "de onde vem o jeito de escrever": não de documentação, que ninguém lê no meio de uma correção, mas do vizinho. Por isso cada um dos dez arquivos em escopo nasce com pelo menos um caso, e por isso eles se parecem entre si — o primeiro que alguém abrir precisa servir de molde.
  3. Ela troca o que muda: o que colocar na tela, o que digitar ou clicar, e qual texto esperar. Se precisar mexer em mais do que isso — configurar tema, roteador, provedor — o molde falhou, e é sinal de que o preparo comum não estava no lugar certo.
  4. Ela roda o arquivo que está editando, não a suíte. O resultado vem em segundos.
  5. Quando ela erra ao escrever — e o erro mais comum é procurar um texto que não está lá —, a saída precisa mostrar o que a tela de fato tinha. A diferença entre não encontrei "Informe o telefone" e não encontrei "Informe o telefone"; a tela tinha "Somente ligações" é a diferença entre dez minutos e dez segundos.
  6. Ela reverte a correção e roda o mesmo arquivo de novo, esperando vermelho. É o passo que transforma o teste em prova, e é o que hoje custa dois rebuilds.

Custo de uso: conhecer o código, que ela já conhece, e ter um vizinho de quem copiar. Não há credencial, ambiente ou banco.

A jornada de rodar

As invocações, e a pessoa está sempre na raiz do monorepo — é de lá que ela já roda make test, make lint e make e2e, e o make help lista o alvo novo ao lado deles.

O que ela digita Responde Quando
make test-web "o apps/web está inteiro?" antes de commitar
make test-web com um alvo — um arquivo, ou um trecho do nome do caso "o que eu acabei de escrever passa?" no meio da correção, e no red-green
make test-web-watch o mesmo, sem digitar de novo enquanto se trabalha num arquivo

(A forma exata do argumento — variável do make, flag repassada — é da Parte III; o que a experiência exige é que exista uma e que ela apareça no make help.)

Os passos, no caso comum: ela entra no modo contínuo ao começar a mexer num arquivo → salva, e vê o resultado sem tirar a mão do teclado → repete até ficar verde → sai → roda make test-web inteiro antes de commitar. Entre o segundo e o terceiro passo cabe o red-green: reverter a correção, salvar, ver vermelho, restaurar.

O que o modo contínuo precisa deixar legível: o que está observando, ao entrar; quais arquivos a última mudança alcançou; e como forçar a suíte inteira e como sair, sem consultar documentação. Ao encerrar, a última execução permanece na tela — apagá-la obriga a rodar de novo só para reler o que já tinha sido dito.

Na execução com alvo, a contagem precisa dizer quantos ficaram de fora. "3 casos, 3 passaram" é indistinguível de uma suíte de três casos, e é assim que alguém commita achando que rodou tudo.

No make test combinado, o web roda primeiro (decisão do usuário, 2026-08-26). Falhando, o comando para ali: o resumo do web é a última coisa na tela, e os quatro a cinco minutos do pytest não são gastos. Passando, o pytest roda e a saída dele fecha o buffer, como hoje.

Não há passo anterior: o alvo garante as dependências sozinho (decisão do usuário, 2026-08-26).

7.1 O terminal

A largura de referência é 80 colunas — terminal do editor com painel lateral, que é onde isto vai ser lido na maior parte das vezes. Não é a largura máxima; é a que não pode quebrar.

Numa falha, o que precisa caber na tela sem rolar: o nome do caso, o arquivo:linha, e o esperado × recebido. É o conjunto mínimo para decidir o que fazer sem abrir mais nada.

Esperado × recebido empilhados, com rótulo em cada um. Duas mensagens em português não cabem lado a lado em 80 colunas — Informe um telefone válido contra Somente ligações daria duas colunas de 38 caracteres, quebrando as duas no meio. Esta é a única decisão de largura desta interface, e é o análogo do que a tabela de cinco colunas sofre em 375px.

O recebido é truncado quando for grande, e o que ele preserva é o texto — não a marcação. Ao procurar um texto numa tela, o "recebido" cru é a árvore inteira do formulário. Despejá-la empurra o nome do caso e o arquivo:linha para fora do buffer; mas truncá-la por tamanho, a partir do topo, é pior: o que sobra são div, label e fieldset, e a pessoa fica com o não encontrei "Informe o telefone" que o passo 5 da jornada de escrever descreve como o caso ruim.

Então o que se preserva são os textos que estavam na tela, que é o que a asserção procurava — a tela tinha: "Somente ligações", "Telefone", "Salvar". Sendo muitos, trunca-se a lista, dizendo quantos ficaram de fora. A árvore de marcação, quando for útil, é coisa que se pede, e não o que aparece por omissão: ela responde "como está montado", e a pergunta aqui é "o que estava escrito".

Uma falha ocupa poucas linhas. O alvo é caber, com a contagem, numa tela de terminal sem rolagem — porque o gesto seguinte é voltar ao editor, e não rolar o terminal para reconstituir o que aconteceu.

Quando o make test para no web, o que fica visível é a saída da suíte do web inteira: ela foi a última a rodar e o pytest não chegou a imprimir nada.

7.2 Mobile (375px)

Não se aplica. A interface desta feature é a saída de um comando de terminal: não há tabela que vire cartão, filtro que colapse ou alvo de toque, e ninguém roda teste unitário do celular. A decisão de largura que existe aqui está em 7.1 — 80 colunas, e o que acontece quando esperado e recebido não cabem lado a lado.

O que é de 375px nesta spec são os casos testados, não a ferramenta: parte do comportamento que a 0046 corrigiu depende de largura e de teclado virtual. A Parte I já declarou que essa classe continua no e2e, e a Parte III dirá quais casos ficam de cada lado — isso é escopo de teste, não experiência de uso.

PWA: sem impacto. Nenhum asset novo, nada servido ao navegador.

7.3 Estados

Toda a Parte II é exigência sobre a saída, não observação. A ferramenta ainda não foi escolhida — é decisão da Parte III. O que este documento fixa é o que a pessoa precisa poder ler; a Parte III diz o que a escolha entrega de fábrica e o que precisa ser configurado. Uma exigência que o executor escolhido não atender e não deixar configurar volta para cá como decisão, não some.

Estado O que precisa ser legível Por quê
Instalando, na primeira vez que está instalando, e que isso é só desta vez é o estado mais lento da feature e a primeira impressão dela; um minuto sem nada na tela é indistinguível de travado
Instalação falhou que a instalação falhou — não a suíte —, o erro, e o comando a rodar à mão rede caída no meio de uma viagem é o caso comum; sem a distinção, a pessoa vai procurar defeito no código dela
Em execução que está vivo, e o progresso idem, na escala de segundos
Tudo passa uma linha: quantos casos, em quanto tempo saída longa em caso de sucesso faz parar de ler a saída — e quem não lê não percebe quando ela muda
Algo falha o caso pelo nome, o arquivo:linha, e esperado × recebido é o que separa "falhou" de "o campo mostrou o texto de ajuda em vez do erro"
Falha em massa as primeiras falhas em detalhe, o resto resumido, e a contagem no fim ver abaixo
Execução filtrada quantos rodaram e quantos ficaram de fora "3 de 3" e "3 de 200" são a mesma frase para quem só lê o verde
Nenhum teste encontrado dito, com o padrão de busca, e falha ver abaixo
Modo contínuo, ocioso que está esperando, e o que observa silêncio é indistinguível de morto
Modo contínuo, mudança sem teste que nada foi alcançado é a informação de que não há cobertura ali
Caso pendurado limite de tempo, e qual caso estourou promessa não resolvida é a falha realista do modo contínuo, e sem limite ele fica parado para sempre
Erro de sintaxe o erro, com arquivo e linha é o que impede a suíte de rodar
Arquivo que não carrega qual arquivo, e o que ele não achou renomear um módulo quebra o import do teste sem erro de parse; sem esta linha, o desfecho seria "nenhum teste encontrado", que é o estado mais perigoso da tabela

O progresso some quando o resultado chega, nos dois desfechos. As duas exigências — "está vivo" durante e "uma linha" no fim — só convivem assim: enquanto roda, o progresso ocupa a tela e é a mesma região sendo reescrita, não saída acumulada. Sem isso, duzentos casos verdes viram duzentas linhas e a regra do "quase nada" morre.

Terminando verde, o progresso dá lugar ao resumo de uma linha. Terminando vermelho, ele dá lugar às falhas — que passam a ser o conteúdo da tela, com a contagem fechando. Em nenhum dos dois ele fica por cima do que interessa. No modo contínuo vale o mesmo: o que permanece ao encerrar é o resultado da última execução, verde ou vermelho, nunca a régua de progresso.

Na falha em massa, a execução vai até o fim (decisão do usuário, 2026-08-26). Mexer num módulo compartilhado — masks.ts serve cinco dos seis formulários — derruba dezenas de casos, e o tamanho do estrago é diagnóstico: "quarenta quebraram" e "uma quebrou" pedem coisas diferentes de quem lê. Parar na primeira esconderia isso.

Para o começo não rolar para fora do buffer, as primeiras falhas saem em detalhe e o resto resumido, uma linha cada, com a contagem do que não coube — e a contagem total fecha a saída.

"Nenhum teste encontrado" precisa falhar, e é o mais perigoso da tabela. Uma suíte que não acha teste nenhum responde "sem falhas" a quem perguntou "está tudo certo?". É o defeito dos testes que não podiam falhar — o mesmo que a Parte I descreve — um nível acima: o portão morre em silêncio e continua dando verde.

Erro de tipo não está na tabela, e isso é decisão. Executores deste ecossistema removem os tipos sem conferi-los: um string onde se espera number não quebra a execução. Quem confere tipo é o tsc, que é o passo 1 da jornada de hoje e continua sendo.

Sem permissão: não se aplica. Não há autenticação, papel ou escopo em ponto nenhum desta interface — quem roda é quem tem o repositório na máquina.

Sobre "em segundos". É o critério da Parte I, e ele tem fronteira. A execução completa sempre diz quanto demorou — uma linha. E só quando passa do orçamento ela mostra também os casos mais lentos, sem ninguém pedir (decisão do usuário, 2026-08-26): abaixo disso a regra do "quase nada" no verde continua valendo, e acima dela a suíte avisa que deixou de valer, em vez de engordar em silêncio até alguém parar de rodá-la. Qual é o orçamento em segundos é decisão da Parte III.

8. Revisão da experiência

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/experiencia.md.

Rodada Critical Important Minor Veredicto
1 1 7 3 REPROVADA
2 1 5 3 REPROVADA
3 2 4 2 REPROVADA
4 — 1 5 REPROVADA

Resultado: fechada na rodada 4, sem rodada confirmatória — mesma situação da Parte I, e registrada pelo mesmo motivo. A rodada 4 veio sem Critical, com um Important de uma frase e cinco Minor de detalhe; todos foram corrigidos, e o usuário decidiu seguir para a Parte III em vez de gastar a quinta rodada.

Os três defeitos que se repetiram, e o que cada um ensinou

Descrever a jornada de usar e esquecer a de produzir. A rodada 1 apontou que só a jornada de rodar um teste estava escrita — e a que decide o destino da feature é a de escrever, que é a cara. Num app com Refine, MUI e react-hook-form, pôr um formulário de pé para observar uma linha é o custo dominante; se cada caso pagar esse custo, ninguém escreve o segundo. A pergunta que faltava responder era simples e não estava em lugar nenhum: de onde vem a forma do primeiro caso? A resposta que ficou é "do vizinho", e é ela que obriga cada um dos dez arquivos em escopo a nascer com pelo menos um caso.

Enunciar em vez de descrever, e o defeito mudar de endereço. A rodada 2 achou uma frase que dizia "precisa ter: como se entra, como se sai, como se força a suíte inteira" — a lista da tarefa ocupando o lugar do conteúdo, e passando despercebida porque parece específica. Corrigido ali, o mesmo defeito reapareceu na rodada 3 noutra seção: o preâmbulo dos estados declarava que descrever ferramenta ainda não escolhida seria descrever desejo, e duas seções acima o texto fazia exatamente isso no presente do indicativo. A correção final foi de escopo, não de frase: toda a Parte II é exigência sobre a saída, e a Parte III diz o que a escolha entrega de fábrica e o que precisa ser configurado.

Editar por posição e apagar uma seção inteira. A rodada 3 encontrou a 7.1 ausente — uma reescrita cortou do meio de uma seção até o início da seguinte e levou junto a subseção do terminal, que a 7.2 referenciava. Estrutura de arquivo é tão verificável quanto conteúdo, e não estava sendo verificada depois das edições grandes.

Pendências aceitas: os cinco Minor da rodada 4 foram corrigidos, não aceitos. O que fica como limite declarado, no corpo: o orçamento em segundos da execução completa é decisão da Parte III, e as exigências sobre a saída podem esbarrar no que o executor escolhido não configura — e nesse caso voltam como decisão, não somem.

Parte III — Detalhamento

Não preencher antes das revisões das seções 6 e 8 estarem aprovadas.

9. Regras de negócio

"Negócio" aqui é o comportamento da ferramenta. Numeradas para poderem ser citadas nos critérios e nos testes — inclusive nos testes que a própria suíte vai ter sobre si mesma.

O executor e o ambiente

  • RN1. O executor é o Vitest, na linha 3.x, compatível com o Vite 6 do apps/web. Ele lê o apps/web/vite.config.ts que já existe — a mesma resolução de módulos, o mesmo tratamento de TypeScript e JSX —, e por isso não há segunda cadeia de build a manter em sincronia, que é o custo que faria a suíte divergir do app e mentir.

O mecanismo tem um detalhe que não é opcional: hoje o arquivo faz import { defineConfig, loadEnv } from "vite" — no apps/web/vite.config.ts —, e a seção test não existe no tipo que o Vite exporta. Ela entra trocando esse defineConfig pelo de vitest/config, que é o mesmo com o campo a mais. Não é preferência de estilo: sem a troca, a seção nova é erro de tipo.

E o erro não apareceria no make lint: apps/web/tsconfig.json inclui só src, e o tsconfig.node.json, que inclui vite.config.ts, não é referenciado por nada — o arquivo de configuração não é conferido por tsc nenhum hoje. Quem vê o erro é o editor, ou o próprio Vitest ao subir.

Efeito colateral que precisa estar escrito: com a troca, o vite.config.ts passa a importar de vitest/config também quando o build de produção roda. Funciona hoje porque os Dockerfiles instalam tudo (infra/prd/web.Dockerfile não usa NODE_ENV=production nem --prod), mas deixa de funcionar no dia em que alguém instalar só as dependências de produção — e a falha seria "a configuração não carrega", não "falta um teste". Quem mexer no Dockerfile para enxugar a imagem precisa saber disso. - RN1a. O plugin de PWA não roda no modo de teste; o de React roda. São os dois plugins daquele arquivo. O de PWA existe para gerar service worker, e gerar service worker durante uma suíte unitária é trabalho puro sem consumidor. O de React fica, e o motivo não é "sem ele o JSX não compila" — o esbuild do Vite transforma TSX sozinho, lendo o jsx: "react-jsx" do tsconfig.json. Ele fica porque a RN1 manda reutilizar o vite.config.ts para que o teste veja o que o app vê: tirar um plugin de transformação do caminho é exatamente o tipo de divergência que a RN1 existe para não ter.

Desligar o de PWA não exige reestruturar o arquivo, e vale registrar porque o contrário seria caro: a configuração já é uma função de { mode } (apps/web/vite.config.ts:17), e o Vitest roda com mode igual a test — então o VitePWA(…) passa a entrar na lista de plugins condicionalmente, sem tocar em mais nada.

Um efeito de segunda ordem que é bom, e é por isso que está escrito: com mode em test, o loadEnv da linha 18 passa a ler também um .env.test, se alguém criar um. Não é por onde a RN5b anula a variável — ela é anulada na própria seção test, que não depende de arquivo existir na máquina de quem roda. - RN2. O ambiente de execução simula DOM (jsdom). É o que permite montar componente e perguntar o que está escrito na tela — que é a RN3. - RN3. As asserções são sobre o que se lê, nunca sobre estado interno. Procurar por texto, por rótulo e por papel de acessibilidade; não por classe de CSS, não por props, não por estado de hook. Um teste que afirma sobre estado interno passa com a tela quebrada, que é precisamente a classe de teste que esta spec existe para não produzir. - RN3a. Uma consequência que vale escrever: os data-testid não são o seletor preferido aqui. Eles existem para o e2e, onde não há alternativa; na unidade, procurar pelo texto que a pessoa lê é o que faz o teste falhar quando o texto muda — e o texto mudar errado é metade dos defeitos da spec 0046.

Onde os testes moram, e como a forma se propaga

  • RN4. O arquivo de teste fica ao lado do código, com o mesmo nome e sufixo .test.ts ou .test.tsx. Não há pasta espelho: a Parte II estabelece que a forma se propaga por cópia do vizinho, e vizinho a dois diretórios de distância não é vizinho.
  • RN5. O preparo comum mora num lugar só. Montar componente do apps/web exige tema MUI, roteador e — nos formulários — provedor de formulário. Isso é escrito uma vez, num utilitário de teste compartilhado, e cada caso o usa sem saber que existe. Se um caso novo precisar configurar provedor à mão, o utilitário está incompleto: é ele que muda, não o caso.
  • RN5a. A suíte não faz requisição de rede nenhuma, e o bloqueio é do fetch global. O preparo compartilhado substitui globalThis.fetch: caminho declarado pelo caso devolve a resposta declarada, e qualquer outro não sai para a rede.

Quem reprova o caso é o encerramento, e isso não é detalhe de implementação. O caminho óbvio — o dublê lançar exceção — não funciona aqui, e os dois módulos que a regra cita são a prova: apps/web/src/components/shared/CepField.tsx:65 tem } catch { … setWarning("Não foi possível buscar o CEP…") } e apps/web/src/shared/identity/refresh.ts:37 tem } catch { return null }. Os dois engoliriam a exceção do dublê exatamente como engolem a da rede, o campo ficaria vazio, a tela renderizaria sem identidade, e o caso passaria — que é o "passa com a tela vazia" que esta regra existe para impedir.

Então o dublê anota cada caminho não declarado num registro e devolve promessa rejeitada — rejeitada, e não pendente, porque uma promessa que nunca resolve trava o caso até o tempo limite e transforma um diagnóstico de uma linha numa espera de segundos. O preparo compartilhado afirma, ao fim de cada caso, que o registro está vazio — dizendo quais caminhos foram pedidos. O catch do código sob teste continua engolindo a rejeição, e é por isso que quem reprova é o registro e não a rejeição. A reprovação fica fora do alcance de qualquer catch do código sob teste, que é o que a torna confiável.

Substituir o módulo providers/api-fetch não bastaria, e a conta é conferível: há 26 ocorrências de fetch( no apps/web/src fora dele, e cinco módulos no caminho dos dez alvos — pages/onboarding/api.ts (o envio do StepTenant), components/shared/CepField.tsx (montado por cinco dos dez), shared/identity/refresh.ts (de que a RN5c depende), pages/renters/api.ts e shared/api/documents.ts (alcançado pelo DocumentsStep da análise de crédito). Substituindo só o módulo, a chamada de qualquer um desses cinco escaparia para a rede de verdade — e o catch do refresh.ts faria a falha ser silenciosa em vez de visível.

A camada que todos atravessam é o fetch, e é nela que o bloqueio mora. Conferido que não há outra: nenhuma ocorrência de XMLHttpRequest, EventSource, sendBeacon ou WebSocket no apps/web/src. - RN5b. O ambiente de teste anula VITE_API_URL e fixa a origem do DOM simulado num host que não existe. É a segunda tranca, e as duas são necessárias:

API_URL é import.meta.env.VITE_API_URL || window.location.origin (apps/web/src/providers/constants.ts). O primeiro termo vem do carregamento de ambiente do Vite — arquivos .env e variáveis VITE_* do shell de quem roda. Numa máquina configurada para o dev-remote, um caso que monta apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx e submete criaria inquilino, imóvel e análise no tenant compartilhado — a mesma classe de acidente que o CLAUDE.md da raiz registra da spec 0025.

Anulada a variável, sobra o segundo termo: a origem do DOM simulado. Deixá-la apontando para a URL do app — plausível, já que ele roda sob basename="/app" — devolveria o destino real com a RN5b "cumprida". Por isso a origem também é fixada, e num host que não resolve.

As duas trancas fecham o destino; a RN5a fecha a saída. Nenhuma delas basta sozinha. - RN5c. O preparo semeia o repositório de identidade, e não o token. Três dos dez alvos usam useCurrentUser — CreditAnalysisForm.tsx, renters/form.tsx e my-garantidora/index.tsx. O gancho só dispara refreshIdentity() quando há token em localStorage (apps/web/src/shared/hooks/useCurrentUser.ts); sem token, ele devolve user: null sem tocar em rede.

Os dois caminhos levam a lugares diferentes, e por isso a escolha é regra e não detalhe: semeando token, o refreshIdentity dispara, o dublê da RN5a bloqueia e anota a chamada, e os casos dessas três telas ficam vermelhos por causa do preparo — cada um deles obrigado a declarar /api/v1/auth/me para nada; semeando o repositório, o gancho encontra o usuário e não há chamada nenhuma. É o segundo. - RN5d. As respostas da API são declaradas pelo caso, não pelo preparo. O preparo dá o mecanismo; qual resposta cada caso quer — inclusive a recusa 422 com details[], que é o que três dos dez arquivos existem para provar — é dito no próprio caso, onde se lê junto com a asserção. - RN6. Cada um dos dez arquivos em escopo nasce com pelo menos um caso. É o que faz o molde existir onde ele é procurado — e é o que impede que a suíte cubra três arquivos e os outros sete fiquem sem exemplo para copiar.

Os comandos

  • RN7. make test-web roda a suíte do apps/web inteira, a partir da raiz.
  • RN7a. O mesmo alvo aceita um filtro opcional de arquivo, em variável — make test-web ARQ=src/shared/utils/masks.test.ts. É filtro de caminho, e só: o argumento posicional do executor casa caminho de arquivo, não nome de caso (para nome há uma opção própria, -t, que este alvo não expõe). Prometer as duas coisas na mesma variável faria ARQ=remascara não casar arquivo nenhum e cair na RN9 — o comando falharia dizendo o padrão usado, que é comportamento correto para um erro e péssimo para uma capacidade documentada. Em variável, e não como palavra solta, porque make test-web <caminho> faz o make ler o caminho como um segundo alvo e responder No rule to make target. É a forma que o repositório já usa em make spec-lint SPEC=… (Makefile:130). A saída filtrada é a que o executor dá; ela precisa deixar evidente que foi filtrada, e é isso que se confere. (decisão do usuário, 2026-08-26: das quatro exigências sobre a forma da saída — RN10, RN10a, RN10c e RN10d —, só a da RN10a é construída; as demais se ajustam ao que o executor entrega. O cronômetro do orçamento é da RN13a, não é uma das quatro, e é construído.)
  • RN7b. make test-web-watch é o modo contínuo.
  • RN7c. make test passa a rodar as duas suítes, o web primeiro; falhando o web, ele para antes do pytest. (decisão do usuário, 2026-08-26)
  • RN7d. Os três alvos aparecem no make help.
  • RN8. Os alvos garantem as dependências do apps/web antes de rodar (decisão do usuário, 2026-08-26), com instalação congelada (--frozen-lockfile) para não reescrever o pnpm-lock.yaml e sujar a árvore no meio de uma execução de teste (CA1). O instalador é o pnpm — que é o do apps/web (.npmrc, pnpm-lock.yaml) e o que os Dockerfiles de infra/prd e infra/dev-remote usam com --frozen-lockfile — e é o que o terceiro, o apps/web/Dockerfile do template do Refine, escolheria por último, caindo em npm ci se um package-lock.json aparecesse. Cair em npm criaria um package-lock.json que sujaria a árvore (CA1) e divergiria do lockfile congelado no deploy.

O que a saída precisa entregar

  • RN9. Suíte que não encontra nenhum caso falha, dizendo o padrão de busca usado. Uma suíte vazia que devolve sucesso responde "sem falhas" a quem perguntou "está tudo certo".
  • RN10. Numa falha, saem o nome do caso, o arquivo:linha, e o esperado × recebido empilhados — a 80 colunas, lado a lado não cabe.
  • RN10a. A mensagem de "não encontrei" traz os textos da tela, não a árvore de marcação — e esta é a única das quatro exigências sobre a forma da saída (RN10, RN10a, RN10c, RN10d) que se constrói (decisão do usuário, 2026-08-26). Por padrão, a biblioteca de asserção imprime a árvore DOM truncada por tamanho, devolvendo os div e fieldset que a seção 7 descreve como o caso ruim. A troca é o getElementError do configure() de @testing-library/dom — um gancho que a própria biblioteca oferece, escrito uma vez no preparo global (apps/web/src/test/setup.ts), não um relator novo.
  • RN10b. Na falha em massa a execução vai até o fim — não para na primeira (decisão do usuário, 2026-08-26). A forma como as falhas são apresentadas é a do executor.
  • RN10c. No sucesso, a saída é a do executor no modo mais enxuto que ele ofereça, e ela diz quanto a execução demorou — que é o insumo da RN13.
  • RN10d. O progresso é o do executor. O que a RN10c e a RN13 exigem é que o resultado seja legível ao fim, não que a régua desapareça de um jeito específico.

O portão

  • RN11. O make preflight passa a falhar com a suíte do web vermelha, junto das verificações que já roda. (decisão do usuário, 2026-08-26) Verificador que não barra é verificador que não verifica — foi o argumento que criou o próprio preflight.
  • RN11a. A verificação roda só quando a branch tocou o apps/web, e chama o alvo make test-web — não o executor direto.

O recorte por branch é o que o preflight já sabe fazer, mas a peça exata importa: linhas_novas() alimenta NOVAS, o mapa de arquivo → linhas que a branch acrescentou (scripts/preflight.py:80-121). O relevante() da linha 124 responde por um arquivo; a pergunta desta regra — a branch tocou o apps/web? — se responde varrendo as chaves de NOVAS. (A função arquivos_da_branch, da linha 45, parece servir e não é chamada por ninguém.)

NOVAS é None em dois casos, e a regra decide os dois: sob --all, e quando o git merge-base com a main falha — num clone raso, por exemplo: são os dois return None de linhas_novas(), o do ramo --all e o do clone raso, cujo comentário já diz que um preflight que falha aberto é mais honesto que um que não roda. Nos dois, a suíte roda. É a mesma escolha que o relevante() já faz na linha 126 (return True quando não sabe), e pelo mesmo motivo: não sabendo o que a branch tocou, deixar de verificar é a falha silenciosa, e verificar demais só custa tempo. Sem isto escrito, NOVAS.keys() sobre None é AttributeError e o make preflight --all — modo documentado — quebra com stack trace. Mas o recorte não é regra geral dele: as verificações de tutorial, de deploy e os meta-testes rodam fora do filtro por desenho. Então isto é escolha desta regra, não herança: rodar a suíte do web numa branch que só tocou a API é minuto gasto sem hipótese a testar.

Chamar pelo alvo, e não pelo executor, também é escolha e tem consequência: é assim que a verificação herda a garantia de dependências da RN8 e o cronômetro da RN13a. Chamando o executor direto, um apps/web/node_modules ausente reprovaria o preflight sem haver defeito nenhum — que é a espécie de reprovação que o próprio preflight.py já se preocupa em evitar, porque ensina a ignorar o portão.

O que fica de fora, e continua no e2e

  • RN12. Comportamento que depende de layout medido continua no e2e, e não migra: o quanto a tela rola, o que o teclado virtual cobre, e o que depende de visualViewport. O ambiente simulado não tem geometria de verdade — afirmar sobre rolagem ali seria escrever o teste que passa sem provar nada, que é o defeito que a Parte I documenta.
  • RN12a. Nenhum caso sai da suíte e2e nesta spec. (decisão do usuário, 2026-08-26)

O orçamento

  • RN13. São dois orçamentos, porque são duas execuções diferentes (número revisado na implementação, 2026-08-27, contra medição — ver a nota abaixo):
Execução Orçamento Quem paga
um arquivo (make test-web ARQ=…) 20 segundos quem está corrigindo, dezenas de vezes por dia
a suíte inteira (make test-web) 120 segundos o preflight, uma vez por portão

Os dois números têm folga para a carga da máquina, e isso é decisão. A mesma suíte foi medida em 67s, 78s e 100s em execuções diferentes desta caixa — dois núcleos, compartilhada com outros projetos; numa delas havia um navegador headless de outro repositório a 74% de CPU. Um aviso que dispara pela carga do vizinho perde o sinal que ele existe para dar, que é "a suíte engordou".

E há um limite diferente deste, que não é orçamento: o tempo máximo por caso, no vite.config.ts. O padrão do executor são 5 segundos, e sob carga isso vira intermitência — casos de ~2s estouram, e teste que falha por carga do vizinho ensina a ignorar a suíte. Está em 15 segundos, e é rede de segurança contra caso que trava de vez, não portão de desempenho: quem cobra tempo é o orçamento acima, e ele avisa sem barrar.

A versão anterior tinha um número só, 30 segundos para a suíte inteira, e ele estava errado nas duas pontas. Errado por otimista: com seis dos dez arquivos a suíte já levava 32,6s, e faltavam quatro, todos de página. E errado por medir a coisa errada: quem está no meio de uma correção não roda a suíte inteira — roda o arquivo que está mexendo, e é esse número que decide se a ferramenta é usada ou adiada.

O que a medição mostrou, com a suíte completa (cache quente):

  • arquivo puro, sem montar tela — masks.test.ts 1,5s, foco-no-erro.test.ts 1,7s;
  • componente isolado — MaskedField.test.tsx 3,5s, SelectionBlock.test.tsx 5,5s;
  • arquivo de página — entre 8,7s e 10,5s, o mais lento sendo a análise de crédito;
  • a suíte inteira, 77 casos em 10 arquivos: 67,3s.

Os dois orçamentos cabem, com folga: 10,5s contra 15, e 67,3s contra 90.

O número de página já foi 12,2s, e depois 17 a 22s, antes de cair para ~9s. O que mudou não foi otimização: foi trocar user.type por colagem onde o que se afirma não é o ato de digitar. Digitar um CNPJ são catorze eventos, e cada um repinta o formulário — o arquivo da imobiliária chegou a estourar o limite de 5 segundos por caso em uma de cada três execuções. O helper preencher do utilitário compartilhado faz clique e colagem, um evento, e as duas coisas se resolveram juntas: o tempo e a intermitência.

O custo dominante é montar tela e digitar, não afirmar: user.type emite tecla a tecla e cada uma repinta o formulário. É por isso que o arquivo de página custa oito vezes o arquivo puro, e é a alavanca a puxar se um dia o número apertar — não há por que caçar milissegundos antes disso.

Os dois orçamentos continuam servindo à mesma promessa da Parte II, e vale dizê-lo com o número na mão: hoje, provar comportamento de tela custa /devremote-rebuild mais a suíte e2e, algo como quinze minutos. Doze segundos para o arquivo que se está mexendo é outra ordem de grandeza, ainda que não seja "instantâneo". - RN13a. Estourar o orçamento avisa e não barra (decisão do usuário, 2026-08-26). O make test-web imprime que passou do orçamento — o de arquivo quando roda filtrado, o de suíte quando roda inteiro —, e o cronômetro é do alvo, não do executor: é o Makefile que marca o início, chama o executor e compara ao devolver o código de saída dele. Não é uma das quatro exigências sobre a forma da saída (RN10, RN10a, RN10c, RN10d) — aquelas são sobre a forma do que o executor imprime, e só a da RN10a se constrói; esta é o cronômetro do orçamento, que o executor não conhece porque não conhece nenhum dos dois números. O preflight da RN11 não falha por isso. Tempo não é defeito, e barrar por lentidão cria o incentivo errado: sob pressão, o caminho mais curto para o portão passar seria apagar um teste bom.

Os dados dos casos

  • RN14. Nenhum documento, telefone ou nome de pessoa real aparece nos casos. Os documentos usados são gerados no próprio apps/web, por um gerador de fixture com verificador correto — irmão do que o apps/e2e já tem em apps/e2e/fixtures/documentos.ts, e escrito de novo aqui em vez de importado: o apps/web não depende do apps/e2e nem do apps/api, e criar essa dependência para economizar trinta linhas amarraria o build de um app ao outro.
  • RN14a. A lista compartilhada da spec 0046 (apps/api/tests/fixtures/documentos.json) não é lida por esta suíte. Ela existe para provar que as duas implementações de validação — a do Python e a do navegador — concordam, e quem a lê são a API e o e2e. A suíte unitária do web testa outra coisa: o comportamento da tela dado um documento. Ler o arquivo daqui criaria um terceiro consumidor de algo fora da raiz do apps/web e faria a §11 mentir ao declarar apps/api sem impacto.

O build de produção

  • RN15. Os arquivos de teste ficam fora do tsc do build, e dentro do tsc do lint (decisão do usuário, 2026-08-27). Hoje os dois chamam tsc pelado e leem o mesmo arquivo: make lint faz cd apps/web && npx tsc --noEmit (Makefile), e o build do apps/web/package.json é tsc && refine build, invocado pelo RUN pnpm build dos Dockerfiles de infra/prd e infra/dev-remote (e pelo RUN npm run build do apps/web/Dockerfile, resto do template do Refine). Pôr um exclude no apps/web/tsconfig.json tiraria os testes dos dois, e eles ficariam sem conferência nenhuma.

O mecanismo, então, é este: - apps/web/tsconfig.json continua como está e passa a alcançar os testes, porque eles moram dentro de src (RN4). É o que o make lint usa, sem mudança de invocação. - Nasce um tsconfig.build.json ao lado dele, no apps/web, estendendo-o e acrescentando o exclude dos *.test.* e do diretório src/test/ — sem o segundo, o build de produção passaria a type-checar o utilitário, o preparo global e o gerador de fixture, que importam vitest e @testing-library. - O script build do apps/web/package.json passa a apontar para ele — tsc -p tsconfig.build.json && refine build. É a única invocação que muda, e ela é do package.json, não do Makefile nem dos Dockerfiles. - RN15a. A conferência de tipos não entra na execução da suíte. Ligá-la no executor poria o custo no lugar errado: é a execução que precisa caber em segundos (RN13) para a suíte ser rodada no meio de uma correção. O make lint já existe e já é o lugar disso. - RN15b. A prova de que os dois tsconfig fazem o que a RN15 diz mede-se contra cd apps/web && npx tsc --noEmit e pnpm build, não contra o alvo make lint. Um erro de tipo num arquivo de teste tem de quebrar o primeiro e não o segundo.

A distinção não é preciosismo, e é o próprio Makefile que a impõe: lint roda cd apps/api && uv run ruff check . antes do tsc do web (Makefile:154-155), o make aborta o alvo na primeira linha que sai diferente de zero, e o CLAUDE.md da raiz registra que make lint não passa hoje — dívida antiga de line-too-long na API, que ninguém vai pagar dentro desta spec. Medir o critério pelo alvo o tornaria impossível de verificar: ele ficaria vermelho com e sem o erro de tipo, e não distinguiria nada.

O que a RN15 afirma continua inteiro — os arquivos de teste estão dentro do tsconfig que o make lint usa, e passam a ser conferidos assim que a dívida do ruff for paga. O que muda é só contra qual comando o critério se mede hoje.

A documentação que esta spec torna falsa

  • RN16. A RN7c e a RN11 mudam o significado de comandos documentados, e a correção entra no mesmo commit. Não é higiene: quem lê o CLAUDE.md para saber o que rodar vai rodar o que ele disser. Não é uma lista de pontos, e contá-los foi o erro — a rodada 9 mostrou que o recorte "comandos documentados" deixava de fora cinco frases inteiras. São três famílias de frase, e o que fecha cada uma é ter varrido a família, não ter marcado N itens: Família A — "make test é o pytest da API". A RN7c a torna falsa.
  • a tabela de Comandos do CLAUDE.md da raiz, onde make test é "pytest da API";
  • a Política de testes do mesmo arquivo — "make test (pytest da API) — sempre";
  • a linha de ajuda test: ## Roda a suíte pytest da API (Makefile:99), que é o que o CA8 e a RN7d mandam olhar em make help;
  • a linha de ajuda test-count: ## Quantos testes a suíte tem hoje (Makefile:108), e a descrição correspondente no CLAUDE.md, que passam a precisar dizer de qual suíte. O alvo não muda — segue coletando só o pytest da API; o que muda é a frase. Fazê-lo somar as duas seria inventar um mecanismo de contagem para o executor do web sem ninguém ter pedido, e a pergunta que o alvo responde continua legítima;
  • o /tweak, que condiciona o make test a ter tocado a API (é a RN16a);
  • .claude/skills/make/SKILL.md, que traz uma tabela de alvos com test → pytest da API. Descoberto na implementação, varrendo a família — nenhuma das nove rodadas de portão o listou, e ele não estava em nenhum dos inventários anteriores. É o argumento da própria regra: varrer a família acha o que contar pontos não acha.

Família B — "o make preflight leva segundos". A RN11 a torna falsa, e a instalação da RN8 pode disparar na primeira execução. - a descrição do make preflight no CLAUDE.md da raiz; - .claude/skills/spec/SKILL.md:606 — "a. make preflight. Segundos."; - .claude/skills/fix/SKILL.md:387 — "Comece pelo degrau barato. Segundos"; - .claude/skills/tweak/SKILL.md:99 — "make preflight sempre (segundos)"; - as promessas de 0,2s, mais específicas e portanto mais erradas, em .claude/skills/spec/SKILL.md:516 e .claude/skills/fix/SKILL.md:277; - a mensagem que o próprio comando imprime ao fim de cada execução (scripts/preflight.py:1123).

Família C — "o apps/web não tem suíte". É a que esta spec extingue, e a que mais importa: são as frases que dizem a quem vier depois como trabalhar no web. Deixá-las de pé é construir a ferramenta e manter a instrução de não usá-la — a suíte fica parada e o defeito da seção 1 volta inteiro. - apps/web/CLAUDE.md:33 — "There is no test runner configured"; - CLAUDE.md:226 — na tabela do Superpowers, "No apps/web não há suíte unitária: ali o teste-primeiro é o spec e2e"; - CLAUDE.md:320 — "o apps/web não tem como provar uma correção… Enquanto não houver suíte ali, o substituto é enumerar os estados"; - .claude/skills/spec/SKILL.md:291 — "no web não há suíte unitária, então tsc verde só diz que compila"; - .claude/skills/spec/SKILL.md:313 — "não há suíte unitária hoje; ali o teste-primeiro é o spec e2e"; - Makefile:140 — "O apps/web não tem suíte, e este é o único lugar onde uma máquina de estados dele é verificada de verdade". - RN16a. Um skill condiciona o make test à API, e é o que precisa mudar: o .claude/skills/tweak/SKILL.md diz "make test se tocou a API". Com a RN7c, um tweak que toca só o web passa a ter suíte, e essa instrução manda pular exatamente o caso novo. Os skills /spec e /fix rodam make test incondicionalmente no Passo 5 — quanto ao make test, eles já passam a rodar a suíte do web sozinhos e não têm nada a mudar.

Mas os três têm edição a fazer, e é outra: a descrição do make preflight, no sexto ponto da RN16. É o mesmo defeito do CLAUDE.md, copiado para os skills — e corrigir só o CLAUDE.md deixaria a frase errada exatamente onde ela é lida no meio do trabalho.

A prova de que a suíte pega o que o e2e pegava

  • RN17. Os três defeitos da seção 1 nascem com caso, e cada caso morre se a correção da 0046 for revertida. É a regra que a spec inteira existe para cumprir: uma suíte que roda em segundos e não teria pegado nenhum dos três não substitui rodada de e2e nenhuma, por mais verde que fique. Os três casos, os arquivos onde moram e o que se reverte em cada um estão no CA20 — e a reversão é no código, sem reconstruir ambiente, que é justamente a diferença em relação ao caminho de hoje.
  • RN17a. É a única exigência de conteúdo de caso desta spec, e a tabela da seção 11 tem de ser lida com isso. Aquela tabela diz, para cada um dos dez arquivos, o que o primeiro caso prova — mas só as três linhas marcadas (CA20) são exigência; as outras sete são proposta, e quem implementa pode trocá-las por algo melhor sem que isso seja desvio da spec. A RN6 pede um caso por arquivo e não diz qual: os sete existem para o segundo caso sair barato, e o que eles afirmam é escolha de quem escreve.

Nos três arquivos do CA20, o caso da RN17 vem primeiro — e não ao lado de outro. Escrever primeiro um caso de outro assunto naqueles arquivos é como o red-green do CA20 falha sem ninguém perceber: reverte-se a correção, o arquivo tem caso vermelho, e ninguém confere se é o caso.

O que a suíte não pode fazer com o código que ela testa

  • RN18. Nenhum arquivo-fonte do apps/web é alterado por esta spec. Os dez alvos e os módulos que eles atravessam são lidos e exercitados, não tocados. Se algum precisar mudar para poder ser testado, isso é achado, não licença: o teste se adapta ao código.

A regra existe porque a tentação é concreta, e a spec já a encontra em três pontos. O catch { return null } do refresh.ts:37 e o catch do CepField.tsx:65 engolem o dublê da RN5a; o API_URL do constants.ts:8 é o que torna a suíte perigosa. Nos três, o caminho mais curto para um caso verde é mexer no arquivo do app — e nos três isso trocaria o comportamento em produção pela conveniência da suíte.

E há um quarto, que os casos do CA20 encontram de cara: levarAoCampo chama window.scrollBy (apps/web/src/shared/utils/foco-no-erro.ts:135) e lê visualViewport (apps/web/src/shared/utils/foco-no-erro.ts:30), e o jsdom não implementa nenhum dos dois — imprime "Not implemented: window.scrollBy" a cada caso. Não derruba o teste, e é ruído vermelho num caso verde, que é a pior forma da tentação: o caminho curto para calar é editar foco-no-erro.ts. O certo é o preparo global da RN5 dublar scrollBy e visualViewport — o barulho é do ambiente simulado, e é lá que se resolve.

Sem regra numerada, nada reprova nenhum dos quatro na revisão de implementação. - RN18a. As exceções são cinco, e nenhuma é arquivo-fonte — são configuração, dependência e documentação:

  • apps/web/package.json — as cinco dependências de desenvolvimento, e o script build apontando para o tsconfig.build.json (RN15);
  • apps/web/pnpm-lock.yaml — obrigatório, e não higiene: a RN8 instala com --frozen-lockfile, que falha quando o package.json traz dependência ausente do lockfile. Sem commitá-lo, o CA1 e o CA9 quebram por falta dele;
  • o tsconfig.build.json, que nasce no apps/web (RN15);
  • apps/web/vite.config.ts — a seção test e o defineConfig de vitest/config (RN1);
  • apps/web/CLAUDE.md — a seção de teste, no lugar do "There is no test runner configured" (linha 33);
  • apps/web/package.json, em pnpm.onlyBuiltDependencies — o pnpm 11 pede decisão explícita sobre quais scripts de instalação podem rodar. Declarado esbuild, que é quem transforma TS e JSX; o core-js-pure fica de fora, porque o script dele é uma mensagem de financiamento.

    Isto começou como pnpm-workspace.yaml e derrubou um deploy. O pnpm 11 cria esse arquivo sozinho, com um texto de espera no lugar dos valores, e eu o preenchi ali. O pnpm trata pnpm-workspace.yaml como definição de workspace e exige o campo packages: — sem ele, pnpm build recusa com "packages field missing or empty". Na máquina não aparecia (pnpm 11 tolera); no container de produção, que usa pnpm 9 e faz COPY apps/web/ ./, o build quebrou.

    Nada disto seria pego pelo que esta spec construiu, e é o limite dela dito com franqueza: a suíte, os dois tsc e o preflight ficaram verdes o tempo todo. Nenhum deles builda imagem. Configuração de instalação só é exercitada pelo build do container, e a única verificação honesta é rodar esse build — docker build -f infra/prd/web.Dockerfile ., que leva minutos e foi o que confirmou a correção.

Nenhuma muda o comportamento do app servido, que é o que a RN18 protege. Uma delas muda o build, e a RN1 diz onde: com o defineConfig de vitest/config, o vite.config.ts passa a exigir as dependências de desenvolvimento instaladas, e deixa de carregar no dia em que alguém instalar só as de produção. Hoje os três Dockerfiles instalam tudo. Quem for enxugar um deles precisa das duas frases, não de uma.

10. Autorização e isolamento

Sem impacto, e a justificativa é de estrutura, não de intenção.

Esta spec não cria endpoint, não altera nenhum existente, não acrescenta permissão ao rbac_seed.py, e não toca repositório, service ou router.

"Não lê nem grava no banco" não é observação — é o que a RN5a e a RN5b garantem, em duas camadas diferentes, e vale dizer por quê. As telas em escopo chamam a API na montagem e no envio; API_URL cai em window.location.origin quando VITE_API_URL não está definida, e o vite.config.ts que a RN1 manda reutilizar carrega o .env da máquina. Sem aquelas duas regras, make test-web numa máquina configurada para o dev-remote gravaria no tenant compartilhado. A isenção desta seção depende delas, e é por isso que elas são regra e não recomendação.

O artefato que ela produz — arquivos de teste, um utilitário de teste e alvos de Makefile — não é servido a navegador nenhum e não entra no bundle de produção: ele roda na máquina de quem desenvolve, a partir de um repositório que a pessoa já tem inteiro.

As quatro perguntas da seção, respondidas para não ficarem por dizer:

  • Matriz de papéis: não se aplica. Não há usuário autenticado nesta superfície; quem roda é quem tem acesso ao código-fonte, e esse acesso já é anterior e maior que qualquer permissão do produto.
  • Isolamento por garantidora: não se aplica. Nenhuma consulta, nenhum tenant_id, nenhuma FK vinda de corpo de requisição.
  • Isolamento por imobiliária: não se aplica, pelo mesmo motivo.
  • Plataforma × cliente: não se aplica. Nada é exposto a ADMIN_GLOBAL nem a ninguém — não há resposta de API, item de menu, coluna nova ou contagem.

Um cuidado que é de segurança e cabe aqui: os casos de teste vão conter documentos e telefones de exemplo, e nunca dado de pessoa real. Eles saem do gerador de fixture do próprio apps/web (RN14) — não da lista compartilhada da spec 0046, que a RN14a mantém fora desta suíte. É o mesmo princípio que a nota de versão segue, e pelo mesmo motivo: o repositório é lido por mais gente do que a produção.

11. Impacto por app

apps/api

Sem impacto. Nenhum arquivo alterado — inclusive apps/api/app/db/rbac_seed.py, que a seção 10 cita justamente para dizer que nenhuma permissão nova nasce desta spec.

apps/web

  • Dependências novas (todas de desenvolvimento), seis: o executor, o ambiente de DOM, a biblioteca de asserção sobre tela, a extensão de asserções de DOM, a de simulação de interação do usuário e — declarada direto, não como transitiva — a biblioteca de consulta ao DOM de onde sai o configure() da RN10a.

A de interação entra por causa de dois dos dez casos iniciais: o do MaskedField exige que "a colagem que excede é recusada", e o da análise de crédito, que "trocar o tipo de pessoa remascara o documento". Colar e digitar num campo com máscara é exatamente o que ela faz — com fireEvent seria preciso reconstruir à mão a sequência de eventos que o navegador emite, e um campo mascarado reage a essa sequência. Reconstruí-la errado dá caso verde sobre comportamento que o usuário não tem.

A quinta é declarada por causa do pnpm. O apps/web/.npmrc traz só legacy-peer-deps e strict-peer-dependencies; sem shamefully-hoist nem node-linker=hoisted, o node_modules isolado não expõe dependência transitiva ao código do próprio pacote. Importá-la sem declarar faria src/test/setup.ts não resolver, e a suíte inteira não subiria — falha de instalação, não de teste, e das que consomem uma tarde. Nenhuma entra no bundle de produção — o build continua chamando tsc antes do refine build, mudando só qual tsconfig ele lê (RN15). - Configuração: a seção de teste no apps/web/vite.config.ts que já existe (RN1), e um arquivo de preparo global para as asserções de DOM. - Arquivos-fonte tocados: nenhum — é a RN18, e o CA21 a confere. Os dez do escopo — apps/web/src/shared/utils/masks.ts, apps/web/src/shared/utils/foco-no-erro.ts, apps/web/src/shared/components/MaskedField.tsx, apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx, apps/web/src/pages/credit-analyses/components/SelectionBlock.tsx, apps/web/src/pages/agencies/form.tsx, apps/web/src/pages/properties/form.tsx, apps/web/src/pages/renters/form.tsx, apps/web/src/pages/my-garantidora/index.tsx e apps/web/src/pages/onboarding/StepTenant.tsx — são lidos e exercitados, não alterados. Se algum precisar mudar para poder ser testado, isso é achado: o teste se adapta ao código, e não o contrário. O apps/web/src/components/shared/CepField.tsx está fora do escopo, pelo motivo na seção 5. - Utilitário de teste compartilhado, em apps/web/src/test/render.tsx (RN5, RN5a–RN5d): monta componente com tema, roteador e provedor de formulário, substitui a camada de rede e semeia identidade. É a peça que decide o custo de escrever o segundo caso — e a que impede a suíte de falar com um servidor de verdade. - Gerador de fixture de documento, em apps/web/src/test/documentos.ts (RN14). - Arquivo de preparo global, em apps/web/src/test/setup.ts — onde moram o dublê da RN5a, a semeadura da RN5c e o gancho da mensagem de erro da RN10a.

Os três moram em src/test/, e o diretório é decisão, não acaso (RN15): dentro de src, ficam sob o tsc do make lint, que é o único lugar onde este código é conferido; e por estarem sob um diretório próprio, o exclude do build consegue tirá-los sem depender do nome de cada arquivo. Eles não casam com *.test.* — por isso as buscas dos CA4, CA17 e CA19a varrem src/test/ junto. - apps/web/tsconfig.json (RN15): não muda — passa a alcançar os testes porque eles moram em src, e é o que o make lint já usa. - tsconfig.build.json (RN15): nasce no apps/web, estendendo o anterior e excluindo os *.test.* e o diretório src/test/ — os dois, porque os três arquivos de preparo não casam com o primeiro padrão e importam vitest. - apps/web/package.json (RN15): o script build passa a apontar para o tsconfig de build. É a única invocação de tsc que muda, e os três Dockerfiles ficam como estão porque chamam o script (pnpm build / npm run build), não o tsc.

O Makefile também fica como está, mas por outro motivo, e a distinção é o desenho inteiro da RN15: Makefile:155 chama npx tsc --noEmit direto, sem passar pelo script — e é exatamente por isso que ele continua lendo o tsconfig.json de sempre, com os arquivos de teste dentro. Se alguém "arrumasse" o Makefile para chamar o script, os arquivos de teste sairiam da única conferência de tipos que eles têm. - apps/web/src/providers/constants.ts: não é alterado. A RN5b o cita porque é dele que sai o API_URL cujo padrão torna a suíte perigosa; o conserto é do ambiente de teste, e mexer no arquivo mudaria o comportamento do app em produção para resolver um problema de teste. - apps/web/src/shared/api/documents.ts e apps/web/src/shared/hooks/useCurrentUser.ts: não são alterados. O primeiro é citado na RN5a como um dos cinco módulos de fetch cru no caminho dos alvos; o segundo, na RN5c, como o gancho cujo comportamento decide o que o preparo semeia. Os dois são lidos para escrever as regras, não tocados. - apps/web/src/shared/identity/refresh.ts: não é alterado. A RN5a o cita como um dos cinco pontos de fetch cru no caminho dos alvos — e como aquele cujo catch { return null } engoliria tanto a falha de rede quanto a exceção de um dublê. O conserto é o bloqueio no fetch global, não mexer no arquivo: o catch existe por um motivo em produção, e alterá-lo para facilitar teste seria trocar o comportamento do app pela conveniência da suíte. - apps/e2e/fixtures/documentos.ts: não é alterado, e não é importado. A RN14 o cita como o irmão que já existe — o gerador do apps/web nasce a partir da mesma ideia, escrito de novo, para não criar dependência entre os dois apps. - Um arquivo de teste por arquivo do escopo (RN6) — os dez da tabela abaixo:

Arquivo O que o primeiro caso prova

| shared/utils/masks.test.ts | conferirCPF/conferirCNPJ/conferirDocumento/conferirTelefone distinguem incompleto de inválido (RN10 da 0046); termoDeBusca devolve dígitos quando não há letra e o termo cru quando há; formatDocumento devolve cru fora de 11 e 14 | | shared/utils/foco-no-erro.test.ts | primeiroCampoComErro escolhe pela ordem do DOM, não de registro; e aplicarErrosDaApi, com dois campos recusados — um com aria-invalid e outro sem —, manda só o sem para a sobra; e não marca campo ausente da tela. É o mecanismo por trás do CA20, sem ser o caso designado dele | | shared/components/MaskedField.test.tsx | o erro vence o texto de ajuda (RN11a da 0046); error={false} do chamador não anula o erro do formulário (RN11c); a colagem que excede é recusada, e o aviso não apaga um erro já existente | | pages/credit-analyses/CreditAnalysisForm.test.tsx | trocar o tipo de pessoa remascara o documento; a recusa da API marca o campo mapeado e o que sobra vai para a faixa | | pages/credit-analyses/components/SelectionBlock.test.tsx | (CA20) com o bloco em erro e um campo inválido abaixo dele, o alvo da ida é o texto do erro do bloco — não a moldura, e não o campo. Morre se , [data-erro="true"] sair do seletor de primeiroCampoComErro (apps/web/src/shared/utils/foco-no-erro.ts:51) | | pages/agencies/form.test.tsx | documento completo e inválido acusa na hora; incompleto fica calado | | pages/properties/form.test.tsx | (CA20) a API recusa state, registrado sem error/helperText: a mensagem aparece na faixa do topo e a tela não fica muda. Morre se a sobra do aplicarErrosDaApi voltar a ser "campo não registrado" | | pages/renters/form.test.tsx | os textos de erro são os da tabela da spec 0046 — nem "CPF inválido" solto, nem o texto de ajuda | | pages/my-garantidora/index.test.tsx | (CA20) o erro do telefone fixo diz o que fazer em vez de repetir o texto de ajuda — morre se o rules do phone_landline sair | | pages/onboarding/StepTenant.test.tsx | idem, na tela que uma garantidora nova preenche primeiro |

  • data-testid: nenhum novo. A RN3a diz o contrário — a unidade procura por texto.
  • Sider, rotas, permissões: sem impacto. Nenhum módulo, nenhuma tela.
  • Responsividade e PWA: sem impacto. Nada é servido ao navegador.

apps/e2e

Sem impacto (RN12a). Nenhum caso sai, nenhum entra.

Raiz

  • Makefile: test-web, test-web-watch, e o test combinado (RN7 a RN7d).
  • scripts/preflight.py: a verificação da RN11.
  • apps/web/CLAUDE.md: a seção que hoje diz "There is no test runner configured" passa a descrever a suíte, onde os arquivos moram e de onde se copia o molde.
  • CLAUDE.md da raiz — quatro dos sete pontos da RN16, que a RN7c e a RN11 tornam falsos e que precisam mudar no mesmo commit:
  • a tabela de Comandos, onde make test está descrito como "pytest da API";
  • a Política de testes, que diz "make test (pytest da API) — sempre";
  • a descrição do make preflight como algo que "leva segundos" — com a suíte dentro, e com a instalação da RN8 podendo disparar, deixa de ser verdade na primeira execução;
  • o make test-count, documentado como "quantos testes a suíte tem hoje", que passa a precisar dizer de qual suíte (o alvo não muda — só a frase).
  • O próprio Makefile, nas linhas de ajuda de test e test-count (RN16, quinto ponto) — são as que o make help do CA8 mostra.
  • .claude/skills/spec/SKILL.md, .claude/skills/fix/SKILL.md e .claude/skills/tweak/SKILL.md (RN16, sexto ponto, e RN16a): a descrição do make preflight como coisa de segundos, repetida nos três (RN16, sexto ponto), e as duas promessas de "0,2s" em .claude/skills/spec/SKILL.md:443 e .claude/skills/fix/SKILL.md:257 (sétimo ponto) — mesmos arquivos, trechos diferentes. O /tweak muda mais uma linha — a que condiciona o make test à API (RN16a).

12. Contrato de API

Sem impacto. Nenhum endpoint criado, alterado ou removido; nenhuma migration, e portanto nenhum downgrade.

13. Critérios de aceite

O executor e a forma

  • [x] CA1. make test-web roda a suíte e sai com código zero, sem que nenhum arquivo versionado mude (git status limpo depois) — inclusive o pnpm-lock.yaml, porque a instalação da RN8 é congelada (--frozen-lockfile), como a dos Dockerfiles. Lockfile dessincronizado faz o alvo falhar dizendo isso, em vez de reescrever o arquivo. (RN1, RN7, RN8)
  • [x] CA2. Um caso que monta um formulário do escopo passa sem que o arquivo de teste configure tema, roteador, provedor de formulário, camada de rede ou identidade à mão. (RN5, RN5a, RN5c)
  • [x] CA2a. Com VITE_API_URL apontando para um host real, make test-web não emite requisição nenhuma para fora. Conferido rodando VITE_API_URL=https://dev.garantora.com.br make test-web com o tráfego observado — ss -tnp durante a execução, ou o alvo apontado a um host que registra acesso — e nenhuma conexão sai para ele. (RN5a)
  • [x] CA2a-ii. A segunda tranca é conferida separadamente, porque a primeira a esconde. Um caso lê API_URL durante a execução, com VITE_API_URL definida no shell, e o valor não é o host real: é a origem inexistente do DOM simulado. Sem este critério o CA2a passaria com a RN5b ausente ou mal implementada — o dublê do fetch cala a saída qualquer que seja o destino, e a isenção da seção 10, que se apoia em duas camadas, teria prova de uma só. (RN5b)
  • [x] CA2b. Um caso que monta uma tela e não declara a resposta de uma chamada que ela faz falha, dizendo qual caminho foi pedido — em vez de passar com a tela vazia. Vale em especial para uma tela que monta o CepField, cujo catch engoliria uma exceção lançada pelo dublê: a reprovação vem do encerramento do caso, não da chamada. (RN5a)
  • [x] CA2c. Um caso declara uma recusa 422 com details[] e afirma sobre o campo marcado. (RN5d)
  • [x] CA3. Os dez arquivos do escopo têm arquivo de teste ao lado, com ao menos um caso cada. (RN4, RN6)
  • [x] CA4. Nenhum caso da suíte afirma sobre estado interno ou classe de CSS — conferível por busca: nenhuma ocorrência de .className, .props, container.querySelector com seletor de classe, ou acesso a estado de hook nos arquivos *.test.* e em src/test/. (RN3, RN3a)
  • [x] CA4a. Um caso monta um componente e encontra um elemento pelo texto visível — o que só é possível com o ambiente de DOM configurado. (RN2)
  • [x] CA4b. A execução da suíte não gera service worker nem qualquer artefato de PWA. (RN1a)

Os comandos

  • [x] CA5. make test-web ARQ=<caminho> roda só aquele arquivo, e a saída deixa evidente que foi filtrada. (RN7a)
  • [x] CA6. make test-web-watch entra no modo contínuo e reexecuta ao salvar um arquivo alcançado pela mudança. (RN7b)
  • [x] CA7. make test roda o web antes do pytest; com o web vermelho, o pytest não roda. (RN7c)
  • [x] CA8. Os três alvos aparecem em make help. (RN7d)
  • [x] CA9. Apagando apps/web/node_modules, make test-web instala e roda, sem passo manual. (RN8)

A saída

  • [x] CA10. Apontando o alvo para um padrão que não casa com arquivo nenhum, o comando falha — código de saída diferente de zero — e a saída diz qual padrão foi usado. (RN9)
  • [x] CA11. Numa falha, a saída traz o nome do caso, arquivo:linha e esperado × recebido — conferido com a largura do terminal fixada em 80 colunas (COLUMNS=80), para o critério medir a saída e não o terminal de quem confere. (RN10)
  • [x] CA12. Num caso que procura texto ausente num formulário montado, o "recebido" mostra os textos da tela — e não a árvore de marcação. (RN10a)
  • [x] CA13. Alterando o comportamento de uma função de apps/web/src/shared/utils/masks.ts — sem quebrar a importação, para os arquivos continuarem sendo coletados —, a execução vai até o fim: o número de casos executados é o mesmo da execução verde, e a contagem de falhas é maior que um. (RN10b)
  • [x] CA14. Na execução verde, a saída traz quanto a execução demorou, e o executor está no modo mais enxuto que oferece. (RN10c)
  • [x] CA14a. Terminada a execução, o resultado é legível sem rolar — resumo no verde, falhas no vermelho. (RN10d)

O portão

  • [x] CA14b. Um erro de tipo introduzido de propósito num arquivo *.test.tsx quebra cd apps/web && npx tsc --noEmit e não quebra cd apps/web && pnpm build; sem ele, os dois ficam verdes. Mede-se pelos dois comandos, e não pelo alvo make lint, que hoje aborta no ruff da API antes de chegar ao tsc (RN15b). (RN15, RN15b)
  • [x] CA14c. A execução imprime quanto demorou, e avisa ao passar do orçamento — o de arquivo quando filtrada, o de suíte quando inteira. O aviso confere-se baixando o limite temporariamente para 1 segundo, não esperando a suíte engordar: nas duas formas ela avisa e sai com zero, e o make preflight não falha por isso. (RN13, RN13a)
  • [x] CA14d. A execução da suíte não faz conferência de tipos — o mesmo erro do CA14b não a derruba. (RN15a)
  • [x] CA14e. As três famílias de frase da RN16 foram varridas — não "os N pontos marcados". Conferível por busca, e é assim que se confere: nenhuma ocorrência restante de "pytest da API" descrevendo o make test (família A), de "segundos"/"0,2s" descrevendo o make preflight (B), e de "não há suíte unitária"/"não tem suíte" sobre o apps/web (C) — em CLAUDE.md, apps/web/CLAUDE.md, Makefile, scripts/preflight.py e os três skills. (RN16, RN16a)
  • [x] CA15. Com um caso vermelho numa branch que tocou o apps/web, make preflight falha. (RN11)
  • [x] CA16. Numa branch que não tocou o apps/web, o preflight não roda a suíte. (RN11a)
  • [x] CA16a. make preflight --all roda a suíte do web e não quebra — nem ali, nem num clone sem merge-base com a main. É o ramo em que o recorte por branch não sabe responder. (RN11a)

A fronteira

  • [x] CA17. Nenhum caso da suíte unitária afirma sobre rolagem, geometria ou teclado virtual — conferível por busca: nenhuma ocorrência de scrollTop, scrollIntoView, getBoundingClientRect ou visualViewport numa asserção — em *.test.* ou em src/test/. A busca é por asserção, e não por ocorrência, de propósito: a RN18 manda o preparo global dublar scrollBy e visualViewport no src/test/setup.ts, e uma busca por ocorrência acharia lá o dublê que a outra regra exige — deixando quem confere entre apagar o dublê e declarar o critério satisfeito por julgamento próprio, que é justamente o que este critério existe para dispensar. Declarar um dublê não é afirmar sobre layout. (RN12, RN18)
  • [x] CA18. npx playwright test --list, no apps/e2e, responde o mesmo total de antes desta spec — hoje Total: 369 tests in 48 files (48 porque o --list conta também o arquivo de autenticação, que não é .spec.ts; find acha 47). O que se confere é a igualdade antes/depois, com o comando à vista. (RN12a)
  • [x] CA19. Nenhum documento, telefone ou nome de pessoa real aparece nos casos; os documentos vêm do gerador de fixture do próprio apps/web. (RN14)
  • [x] CA19a. Nenhum arquivo da suíte — os *.test.* e os três de src/test/ — importa de fora da raiz do apps/web; em especial, nenhum lê apps/api/tests/fixtures/documentos.json. (RN14a)

A prova que a spec existe para viabilizar

  • [x] CA20. Os três defeitos da seção 1 têm caso, e cada um fica vermelho quando a correção da spec 0046 é revertida no código — sem reconstruir o ambiente. O conjunto é este, e não fica a cargo de quem confere reconstruí-lo:

    Defeito da seção 1 Caso — o arquivo e a asserção O que se reverte
    clicar em Salvar e a tela não fazer nada pages/properties/form.test.tsx: a API recusa state — registrado sem error/helperText — e a mensagem aparece na faixa do topo, em vez de a tela ficar muda em shared/utils/foco-no-erro.ts, a sobra voltar a ser "campo não registrado" em vez de "não ficou com aria-invalid"
    o erro aparecer sem dizer o que fazer pages/my-garantidora/index.test.tsx: o erro do telefone fixo diz o que fazer, e não repete o texto de ajuda tirar o rules de conferência do phone_landline
    a tela levar ao campo errado pages/credit-analyses/components/SelectionBlock.test.tsx: com o bloco de seleção em erro e um campo inválido abaixo dele, o alvo da ida é o texto do erro do bloco, não o campo tirar , [data-erro="true"] do seletor de primeiroCampoComErro (apps/web/src/shared/utils/foco-no-erro.ts:51)

    A asserção está escrita porque só ela morre na reversão. "Clicar em Salvar faz alguma coisa" sobrevive à reversão da sobra, porque aplicarErrosDaApi só roda em recusa da API (apps/web/src/shared/utils/foco-no-erro.ts:223, guardado por e instanceof ApiError) e submissão inválida no cliente nunca chega lá. Escrever o defeito no lugar da asserção é como este critério ficaria verde sem provar nada.

    O terceiro caso mudou de arquivo, e o motivo vale mais que a mudança. A escolha óbvia era a imobiliária, revertendo o segundo argumento do handleSubmit no onClick (no onClick do Salvar, em apps/web/src/pages/agencies/form.tsx) — que é, de fato, onde a 0046 corrigiu este defeito. Não serve, e a asserção ficaria verde com a correção revertida: em agencies/form.tsx os campos são registrados na ordem do JSX, que é a ordem do DOM, então a ordem de registro do shouldFocusError e a ordem visual de primeiroCampoComErro escolhem sempre o mesmo campo. O mesmo vale para properties/form.tsx. E o MaskedField encaminha field.ref, então nem os campos de Controller escapam ao shouldFocusError.

    O que de fato distingue os dois nesses formulários é levarAoCampo trazer o texto do erro para dentro da área visível contando com o teclado virtual — e isso é comportamento de layout medido, que a RN12 mantém no e2e por decisão.

    Este primeiro caso saiu da tela do imóvel e voltou para ela, e a ida e a volta valem mais que o destino. Durante a implementação, medi que ele não morria com a reversão e o movi para o teste do módulo. A medição estava errada, e não por descuido de leitura: naquele momento o recusa422 do preparo montava o corpo no envelope errado, aplicarErrosDaApi devolvia false antes de chegar ao filtro da sobra, e reverter o filtro não mudava nada — o caso ficava verde pelos dois lados por um terceiro motivo. Consertado o envelope, ele morre: medido duas vezes, e a segunda pelo revisor do portão de implementação.

    A lição é sobre medir com o instrumento quebrado. Um red-green feito contra um dublê errado responde à pergunta certa com o aparelho descalibrado, e o resultado é pior que não medir: vira decisão escrita. Antes de mover um caso do CA20 por causa de uma reversão que não matou, confira que o caso passa pelo motivo certo — aqui, bastaria ver que a faixa nascia com a mensagem geral, e não com o campo marcado.

    O caso do módulo (shared/utils/foco-no-erro.test.ts) continua na suíte e continua morrendo com a mesma reversão. Ele prova o mecanismo; o da tela prova o defeito da seção 1, que é o que este critério cobra.

    Sobra a diferença estrutural, que é a que se prova aqui: shouldFocusError só alcança campo registrado, e primeiroCampoComErro também alcança [data-erro="true"] — a marca que a 0046 criou para os blocos de seleção, que não são campos de formulário. Reverter o seletor tira o bloco do alcance, e a ida vai parar no campo de baixo. É o mesmo defeito da seção 1 — levar ao lugar errado —, num ponto em que o jsdom consegue vê-lo.

    É o critério que mede a razão de ser desta spec, e a única exigência sobre o que um caso afirma: os outros nove nascem como molde, e o que eles provam é escolha de quem os escreve. (RN17, RN17a) - [x] CA21. git diff main...HEAD não mostra alteração em nenhum arquivo-fonte do apps/web — nem nos dez alvos, nem nos módulos que eles atravessam (api-fetch, constants.ts, refresh.ts, useCurrentUser.ts, CepField.tsx, documents.ts). Fora de src/test/ e dos *.test.*, o que muda no apps/web é só o que a RN18a enumera: package.json, pnpm-lock.yaml, tsconfig.build.json, vite.config.ts e CLAUDE.md. (RN18, RN18a)

14. Plano de implementação

A ordem api → web → e2e não se aplica — só o apps/web e a raiz são tocados. A ordem aqui é outra, e tem um motivo: o utilitário de preparo antes dos casos, porque é ele que decide se escrever o segundo caso é barato; e um caso de verdade antes dos outros nove, para o molde nascer testado.

  • [ ] apps/web — dependências de desenvolvimento e a seção de teste no vite.config.ts
  • [ ] apps/web — src/test/setup.ts, o preparo global: o dublê de fetch (RN5a), a anulação do destino (RN5b), a semeadura de identidade (RN5c) e o getElementError (RN10a). Vem antes do utilitário porque é ele que a seção test registra em setupFiles
  • [ ] apps/web — src/test/render.tsx, o utilitário de preparo compartilhado (RN5)
  • [ ] apps/web — o gerador de fixture src/test/documentos.ts (RN14). Vem aqui, antes do primeiro caso: já o masks.test.ts exercita conferirCPF/conferirCNPJ/formatDocumento, e a RN14 manda o documento vir do gerador
  • [ ] apps/web — o primeiro arquivo de teste, shared/utils/masks.test.ts, que é puro e não precisa do utilitário de montagem: serve para provar o executor antes de provar o preparo
  • [ ] apps/web — o segundo, shared/components/MaskedField.test.tsx, que é o primeiro a montar componente e portanto o primeiro a exercitar o utilitário
  • [ ] apps/web — os outros oito arquivos (RN6)
  • [ ] apps/web — tsconfig.build.json e o script build do package.json (RN15)
  • [ ] raiz — Makefile: test-web, test-web-watch, test combinado (RN7–RN8), mais o cronômetro do orçamento que avisa ao passar de 30s (RN13a) — é do alvo, não do executor
  • [ ] raiz — scripts/preflight.py: a verificação da RN11, com o recorte da RN11a
  • [ ] apps/web/CLAUDE.md — a seção de teste, e a remoção do "no test runner configured"
  • [ ] Os sete pontos de documentação da RN16, no mesmo commit: o CLAUDE.md da raiz, as duas linhas de ajuda do Makefile, a descrição do preflight nos três skills, as duas promessas de "0,2s", e a linha do /tweak que condiciona o make test à API (RN16a)
  • [ ] Red-green de cada caso do CA20 — revertendo a correção no código e vendo vermelho
  • [ ] make test-web verde, make preflight limpo, e cd apps/web && npx tsc --noEmit limpo — não make lint, que aborta antes no ruff da API por dívida anterior a esta spec (RN15b). O make test completo depende do pytest, que nesta máquina roda pelo container do dev-remote.

15. Decisões em aberto

Nenhuma. Todas foram decididas pelo usuário e estão registradas nas regras que elas governam:

Decisão Regra Quando
escopo: os dez arquivos (seis formulários, o bloco de seleção e três módulos) RN6, §5 2026-08-26
o make test roda as duas suítes, web primeiro RN7c 2026-08-26
o alvo instala as dependências sozinho RN8 2026-08-26
a suíte entra no preflight RN11 2026-08-26
das quatro exigências sobre a forma da saída (RN10, RN10a, RN10c, RN10d), só a mensagem de erro é construída (o cronômetro da RN13a é outra coisa, e é construído) RN7a, RN10a–RN10d, RN13a 2026-08-26
falha em massa vai até o fim RN10b 2026-08-26
o orçamento avisa e não barra RN13a 2026-08-26
nada sai do e2e RN12a 2026-08-26
dois tsconfig: o lint confere os testes, o build os ignora RN15, RN15b 2026-08-27

16. Revisão do detalhamento

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/detalhamento.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 — — — REPROVADA
2 — — — REPROVADA
3 1 4 5 REPROVADA
4 1 5 7 REPROVADA
5 1 3 4 REPROVADA
6 1 7 4 REPROVADA
7 1 3 3 REPROVADA
8 1 2 3 REPROVADA
9 — 4 3 REPROVADA

As contagens das rodadas 1 e 2 não foram anotadas na hora, e não se reconstroem sem adivinhação — o veredicto de cada uma está registrado, os números não.

O que cada rodada trouxe, e por que quatro. A 1 e a 2 acharam lacuna de conteúdo — regra sem critério, decisão empurrada para a implementação. A 3 e a 4 acharam a mesma classe de defeito, e é a que vale registrar: mecanismo descrito que não é executável como descrito. Na 3, dois tsconfig que liam o mesmo arquivo e um plugin a desligar junto com o que compila JSX; na 4, o bloqueio do fetch "falhando o caso" por exceção, dentro de um app cujos dois módulos de rede crus têm catch que a engoliria — e a exceção morreria no CepField, que cinco dos dez alvos montam. Nos dois casos a decisão estava certa e o mecanismo não, e nenhuma leitura da spec sozinha mostrava isso: só abrir o arquivo citado mostrava.

A 5 achou a mesma classe uma terceira vez, e uma delas eu tinha acabado de criar: a correção da rodada 4 escreveu na RN13a que o cronômetro era "a segunda das quatro exigências de saída que se constrói", contradizendo três lugares que dizem que só a da RN10a é. O Critical da rodada foi de fora: a RN15b mandava provar os dois tsconfig por make lint, e o Makefile:154-155 roda o ruff da API antes do tsc do web — com a dívida de line-too-long que o CLAUDE.md registra, o alvo nunca chega ao tsc, e o critério ficaria vermelho com e sem o erro de tipo que ele existe para detectar.

A 6 mudou de assunto, e o assunto novo é o que importa registrar: com os mecanismos já conferidos, o que sobrou foi o plano e a tabela da seção 11 não terem acompanhado as regras. O plano não tinha passo para o tsconfig.build.json, para o gerador de fixture nem para os sete pontos de documentação — três frentes que as regras declaram obrigatórias e que têm critério de aceite —, e fechava exigindo make lint verde, que a RN15b acabara de registrar como impossível dentro desta spec. E a tabela dos dez primeiros casos prescrevia, para dois dos três arquivos do CA20, um caso de outro assunto: escrito assim, reverter-se-ia a correção da 0046, o arquivo teria caso vermelho, e ninguém conferiria se era o caso. Foi o achado mais caro das seis rodadas, e não era mecanismo — era a spec descrevendo uma coisa em dois lugares e só um deles tendo sido atualizado.

A 7 achou o mesmo defeito de novo, dentro da minha correção dele — e é o registro mais útil desta seção. Ao consertar a tabela da seção 11 na rodada 6, escrevi para dois dos três arquivos do CA20 uma asserção que sobreviveria à reversão: "o Salvar leva ao campo" continua verdadeiro sem o onInvalid, porque o shouldFocusError do react-hook-form foca assim mesmo — pela ordem de registro, que é o defeito; e "clicar em Salvar faz alguma coisa" não toca o aplicarErrosDaApi, que só roda em recusa da API. Os dois red-green ficariam verdes, e o critério que justifica a spec inteira teria sido marcado como cumprido sem provar nada.

O conserto foi estrutural, não textual: a coluna do CA20 passou a carregar a asserção, não só o nome do arquivo, e as duas tabelas repetem a mesma frase de propósito, com um comentário dizendo que mudar uma obriga a mudar a outra. Enquanto o que estava escrito era o defeito ("a tela levar ao campo errado"), qualquer caso do arquivo parecia servir.

A 8 mostrou que o conserto da 7 ainda não bastava, e o achado é o mais instrutivo das oito. A asserção que eu escrevi para a imobiliária — "leva ao primeiro campo na tela, não ao primeiro registrado" — está certa como descrição do defeito e ainda assim sobreviveria à reversão, porque naquele formulário a ordem de registro e a ordem do DOM são a mesma: as duas mecânicas escolhem sempre o mesmo campo. Ou seja, uma asserção pode ser verdadeira, específica, corresponder ao defeito, e mesmo assim não provar nada — o que ela precisa é discriminar entre o código com e sem a correção, naquele arquivo. O terceiro caso mudou de arquivo e de reversão por isso, e o raciocínio ficou escrito abaixo do CA20 para não se perder.

A oitava foi a primeira com o registro de afirmações conferidas e o --propagacao --base ligados. O revisor tomou 24 fatos como dados em vez de reabrir os arquivos, e a propagação apontou três linhas do registro tocadas pelas correções — todas ainda verdadeiras, conferidas em segundos. As duas mudanças de ferramental estão no commit bdc5676.

A 9 foi a primeira sem Critical, e o revisor simulou as três reversões do CA20 uma a uma antes de dizer que discriminam. O que ela achou foi de outra natureza, e vale registrar as duas mais caras.

A primeira é o recorte de uma regra. A RN16 caçava a família "make test é o pytest da API" e se dava por completa; ficavam de pé cinco frases dizendo que o apps/web não tem suíte — em CLAUDE.md, no Makefile e no skill do /spec —, e são justamente as que dizem a quem vier depois como trabalhar no web. Merge assim, e a instrução vigente manda escrever spec e2e como teste-primeiro porque não há suíte unitária: a ferramenta fica parada e o defeito da seção 1 volta inteiro. A regra passou a ser por família de frase, não por lista de pontos — contar pontos foi o que criou o buraco.

A segunda foi auto-infligida, e é uma lição sobre o próprio registro. O commit bdc5676, que melhorou os skills, acrescentou linhas antes dos trechos que a RN16 citava: contra a main os números seguiam certos e contra esta branch quatro dos cinco passaram a apontar para o parágrafo errado — e o registro de afirmações, criado para poupar verificação, tinha acabado de instruir o revisor a não reconferir aqueles. Daí a segunda regra de expiração, escrita junto da tabela: citação de linha em arquivo que a própria branch edita não entra no registro.

Resultado: fechada por decisão do usuário na rodada 9 (2026-08-27), e não "aprovada". A distinção é a honestidade do registro: a rodada 9 devolveu REPROVADA com quatro Important e três Minor. Os sete foram corrigidos, e as correções não passaram por revisão — não há décima rodada. O usuário optou por seguir para a implementação com isso à vista.

A decisão é defensável, e é bom dizer por quê: a 9 foi a primeira rodada sem Critical, nenhum dos quatro Important era defeito de desenho — eram recorte de regra (a RN16 caçando uma família de frase e ignorando outra), uma contradição entre CA17 e RN18, um ramo não decidido do preflight, e citações de linha que o próprio commit de ferramental desta branch tinha movido. Todos com conserto conhecido e escrito. O portão de implementação mede o código contra esta spec e roda quatro vezes (uma por fase, mais a branch inteira): é lá que um erro remanescente aqui aparece como código errado, que é a forma mais barata de encontrá-lo agora.

O que fica em risco, dito sem eufemismo: se algum dos sete consertos estiver errado como o da rodada 7 estava — verdadeiro e sem provar nada —, quem descobre é o portão da implementação, e o custo é reescrever o teste, não a spec.

Riscos aceitos: nenhum de acesso. A seção 10 declara ausência de superfície, e as nove rodadas julgaram a declaração em vez de presumi-la — a última confirmando, uma a uma, que não há endpoint, permissão, item de menu, coluna nem campo novo em resposta. O único risco real desta feature — make test-web gravando no tenant compartilhado do dev-remote — está fechado em duas camadas (RN5a e RN5b), cada uma com critério próprio desde a rodada 8.

17. Revisão da implementação

Preenchido pelo /spec implement, rubrica .claude/rubrics/implementacao.md. As revisões de fase (api, web, e2e) vão nas primeiras linhas; a da branch inteira, na última.

Rodada Escopo Critical Important Minor Veredicto
1 branch inteira 1 5 9 REPROVADA

Não houve revisão por fase, e a razão é do escopo: as fases da ordem api → web → e2e não existem aqui. O apps/api e o apps/e2e não são tocados — o diff confirma —, e o que sobra é o apps/web mais a raiz, que é uma fase só. Revisar duas vezes o mesmo diff custaria o dobro sem cobrir nada a mais.

Resultado: fechada por decisão do usuário na rodada 1 (2026-08-28), e não "aprovada". Os quinze achados foram corrigidos e as correções não passaram por revisão — não houve segunda rodada. O usuário optou por fechar com isso à vista.

O que a rodada 1 provou, e vale mais que o veredicto. O revisor não leu: rodou. Confirmou as três reversões do CA20 uma a uma com restauração conferida, mediu as duas trancas de rede com VITE_API_URL definida no shell, injetou erro de tipo para separar os dois tsconfig, quebrou um caso para ver o preflight barrar, e conferiu que a RN18 foi respeitada arquivo por arquivo. É o portão fazendo o que a rubrica pede de um artefato que é teste: perguntar se cada caso morreria sem o comportamento que afirma.

O Critical foi o mais caro da spec inteira, e não porque o defeito fosse grande: porque inverteu uma decisão que eu havia medido, escrito na spec e relatado como fechada. Está contado na seção 18.

Defeitos aceitos: nenhum. Os quinze foram corrigidos — inclusive os nove Minor, porque quatro deles eram asserção frouxa, que num artefato de teste não é acabamento: é o teste não provando o que diz provar.

18. Registro

Achado da implementação, e é o segundo defeito da seção 1 vivo numa tela — 2026-08-27.

aplicarErrosDaApi, que a spec 0046 criou para levar cada mensagem de details[] ao campo a que pertence, é usada por três telas: pages/agencies/form.tsx, pages/properties/form.tsx e pages/credit-analyses/CreditAnalysisForm.tsx. pages/my-garantidora/index.tsx não a usa — o catch do onSubmit faz apenas setSaveError(err.message), e o que sobra na tela é "Verifique os detalhes abaixo", com nada abaixo. É literalmente o segundo defeito descrito na seção 1 desta spec, e ele continua lá.

Foi encontrado rodando, no primeiro caso que essa tela ganhou — não em nenhuma das nove rodadas de portão desta spec, nem nas dez da 0046.

Não foi corrigido aqui, e isso é a RN18: esta spec não altera arquivo-fonte do apps/web. O caso index.test.tsx afirma o estado de hoje e explica por quê; consertada a tela, ele fica vermelho, e é o sinal de que a lacuna fechou. O conserto é um /fix próprio.

Duas correções no preparo compartilhado, as duas descobertas por execução:

  • O dublê de fetch casava só por caminho, e o primeiro vencia. my-garantidora faz GET e PATCH no mesmo /tenants/{id}: a declaração da carga casava também a gravação, e um caso que declarava recusa 422 para o PATCH recebia o corpo do GET — a tela dizia "Dados salvos com sucesso". Agora o método entra na conta e a última declaração vence.
  • Quem diagnosticou isso em um comando foi o gancho da RN10a: a mensagem de falha listou os textos da tela, e "Dados salvos com sucesso" estava entre eles. Era a exigência de saída que a spec mandou construir, e ela se pagou no terceiro arquivo.

Um arquivo que a spec não previa: apps/web/pnpm-workspace.yaml (ver RN18a).

O portão da implementação reprovou, e o Critical inverteu uma decisão minha — com medição. Eu tinha movido o primeiro caso do CA20 da tela do imóvel para o teste do módulo, por ter medido que ele não morria com a reversão. A medição estava errada: naquele momento o recusa422 montava o corpo no envelope errado, aplicarErrosDaApi devolvia false antes de chegar ao filtro da sobra, e reverter o filtro não mudava nada — o caso ficava verde pelos dois lados por um terceiro motivo. Consertado o envelope, ele morre; conferido duas vezes, uma delas pelo revisor. O CA20 voltou para a tela, e a nota abaixo dele registra a ida e a volta.

A lição não é sobre o CA20: um red-green feito contra um dublê errado responde à pergunta certa com o aparelho descalibrado, e o resultado é pior que não medir — vira decisão escrita.

Um oitavo ponto de documentação, achado varrendo a família e não contando pontos. .claude/skills/make/SKILL.md traz uma tabela de alvos com test → pytest da API. Nenhuma das nove rodadas de portão o listou, e ele não estava em inventário nenhum. É o argumento da RN16 se provando: a regra passou a ser por família justamente porque contar pontos deixa buraco.

Uma prova que eu dei e estava inválida, e vale o registro. Relatei o CA14b como provado antes de o tsconfig.build.json existir: o arquivo tinha sido escrito num caminho errado — o cat > correu com o diretório de trabalho já dentro de apps/web — e sumiu. O tsc -p de um projeto inexistente saiu com 0 naquele contexto, o que leu como sucesso. Refeito com caminho absoluto e conferido nos dois padrões do exclude (um *.test.ts e um arquivo de src/test/): erro de tipo quebra o tsc do lint com 2 e não quebra o do build, que sai com 0. A lição é de método: exit 0 de um comando que não achou o que procurar não é verde.

O CA20 mudou de arquivo pela terceira vez, e desta vez por medição. O primeiro defeito — clicar em Salvar e a tela não fazer nada — ia ser provado pela tela do imóvel. Rodado o red-green, o caso não morre: revertida a sobra para "não está no DOM", ele continua verde, porque o register("state") vai para o slotProps.htmlInput de um TextField select e não há elemento com name="state" no DOM — state fica na sobra pelos dois critérios. Onde a reversão mata é no teste do módulo, com dois campos lado a lado, um com aria-invalid e outro sem. O caso da tela continua na suíte; o que ele não faz é servir de prova.

O "ponto em aberto" da tela do imóvel era um defeito do preparo, e foi fechado. Estava registrado aqui que, com owner_document recusado, o campo não acendia e a mensagem caía na faixa — e que não dava para saber se era a tela ou o DOM simulado. Era nenhum dos dois. O recusa422 do preparo montava o corpo como { detail, details }, e a API de verdade usa { error: { code, message, details } }, que é o que providers/api-fetch.ts lê. Com o envelope errado, details chegava vazio, aplicarErrosDaApi devolvia false, e toda tela caía no ramo da mensagem crua.

Corrigido o envelope, o campo acende e a asserção forte passa nas duas telas. A hipótese do requestAnimationFrame estava errada, e vale dizer por que ela era sedutora: explicava o sintoma, era plausível, e apontava para longe do meu código. O que a derrubou foi comparar um campo mascarado com um campo comum na mesma tela — os dois falhavam igual, e "é do MaskedController" caiu junto.

A lição é sobre o dublê, não sobre a tela: um dublê que responde no formato errado é indistinguível de um produto quebrado, e leva a registrar defeito onde não há. Vale para qualquer resposta que o preparo passe a montar.

O orçamento da RN13 foi reescrito contra medição, e virou dois (decisão do usuário, 2026-08-27). O número anterior — 30 segundos para a suíte inteira — estava errado nas duas pontas: otimista, porque com seis dos dez arquivos a suíte já levava 32,6s; e medindo a coisa errada, porque quem está corrigindo roda o arquivo que está mexendo, não a suíte.

Medido com cache quente, em execuções repetidas: arquivo puro 1,5s, arquivo de página 12,2s (estável em duas execuções), suíte de seis arquivos 32,6s. Os orçamentos passaram a ser 15s por arquivo e 90s para a suíte. O custo dominante é montar tela e digitar — user.type emite tecla a tecla e cada uma repinta o formulário —, e é essa a alavanca se o número um dia apertar.

Afirmações conferidas

Fatos sobre o repositório que uma rodada de portão já conferiu abrindo o arquivo. O revisor da rodada seguinte pode tomá-los como dados — enquanto a frase da spec que os carrega não mudar. Mexeu na frase, a linha sai daqui e volta a ser conferível.

E há uma segunda expiração, que esta spec descobriu do jeito caro. Afirmação com número de linha sobre arquivo que esta branch também edita vence a cada commit da branch. O commit bdc5676, que melhorou os skills do /spec e do /fix, acrescentou ~85 e ~20 linhas antes dos trechos que a RN16 citava: contra a main os números continuavam certos, e contra a branch em que a spec vive — que é onde a implementação vai acontecer — quatro dos cinco passaram a apontar para o parágrafo errado. O registro, que existe para poupar verificação, tinha acabado de instruir o revisor a não reconferir justamente aqueles.

Por isso as citações de linha em arquivo que a branch edita não entram nesta tabela. Quem confere é o make preflight a cada execução — e note que nem ele pega este caso, porque afrouxa a régua para arquivo que a branch tocou (scripts/preflight.py:880-892).

Afirmação Onde na spec Conferida em
26 ocorrências de fetch( no apps/web/src fora de providers/api-fetch RN5a 4, 5, 6, 7
Zero ocorrências de XMLHttpRequest, EventSource, sendBeacon e WebSocket no apps/web/src RN5a 4, 6, 7
Os cinco módulos de fetch cru no caminho dos dez alvos são os que a RN5a nomeia RN5a, §11 5, 6, 7
CepField é montado por cinco dos dez alvos; useCurrentUser, por três RN5a, RN5c 5, 6, 7
apps/web/src/components/shared/CepField.tsx:65 é } catch { com o setWarning do CEP RN5a, RN18 5, 6, 7
apps/web/src/shared/identity/refresh.ts:37 é } catch { return null } RN5a, RN18 5, 6, 7
useCurrentUser só dispara refreshIdentity() havendo token, e sai antes disso se o repositório já tem identidade RN5c 6, 7
apps/web/src/providers/constants.ts:8 é import.meta.env.VITE_API_URL \|\| window.location.origin RN5b, §10 7
apps/web/vite.config.ts: importa defineConfig de "vite" (linha 2), é função de { mode } (17) e chama loadEnv (18) RN1, RN1a 7
O vite.config.ts tem dois plugins: react() e VitePWA RN1a 5, 6
apps/web/tsconfig.json: jsx: "react-jsx", noEmit, include: ["src"] RN1, RN15 5, 7
apps/web/package.json: build é tsc && refine build RN15 5, 6, 7
São três Dockerfiles que buildam o web (prd, dev-remote e o do template do Refine) RN8, RN15, §11 5, 6, 7
apps/web/.npmrc não tem shamefully-hoist nem node-linker=hoisted §11 (dependências) 7
Makefile:154-155 roda o ruff da API antes do tsc do web, no alvo lint RN15b 5, 7
Makefile:99 e :108 são as linhas de ajuda de test e test-count RN16 6, 7
Makefile:130 é a invocação de spec_lint.py $(SPEC) RN7a 7
/spec e /fix rodam make test incondicionalmente; só o /tweak o condiciona RN16a 4, 6, 7
O /tweak traz os dois trechos que a RN16 e a RN16a mandam corrigir — o make preflight "sempre (segundos)" e o make test "se tocou a API" RN16, RN16a 7, 9
Os três pontos de reversão do CA20 existem no código, nas linhas citadas CA20 4, 6, 7
apps/web/src/pages/properties/form.tsx:511 registra state sem error/helperText §11, CA20 7
npx playwright test --list no apps/e2e responde Total: 369 tests in 48 files; find acha 47 .spec.ts CA18 5, 6, 7
Nenhum dos dez alvos importa @refinedev/* RN5 7
scripts/preflight.py:45 define arquivos_da_branch, que não é chamada por ninguém RN11a 7

Esta seção nasceu na rodada 8, depois de o relatório da rodada 7 ter reconferido do zero tudo que está acima — com o texto intacto desde a rodada 4. Sete rodadas pagaram a mesma verificação de três a quatro vezes cada.