Pular para conteúdo

0020 — O preflight reprova citação arquivo:linha que está correta

Status CORRIGIDO
Branch fix/0020-preflight-reprova-citacao-correta
Aberto em 2026-09-26
Severidade MÉDIA
Apps afetados sem impacto em api · web · e2e — o defeito é em scripts/preflight.py
Revisão do diagnóstico Encerrada na rodada 5 por decisão do usuário — não aprovada (nota 6); parte preditiva cortada, pendências aceitas na seção 5
Revisão da correção Aprovada na rodada 3 (nota 8, sem Critical); os Important e Minor dela foram corrigidos nesta branch

Status: RASCUNHO → DIAGNOSTICADO → EM CORREÇÃO → CORRIGIDO

1. Sintoma

make preflight reprova a verificação "Citação arquivo:linha aponta para o que diz" apontando citações que estão certas. Quem paga é qualquer branch que toque um registro de spec ou fix — mesmo tocando uma linha que não é citação nenhuma, porque a checagem entra no escopo por arquivo.

Aconteceu em 2026-09-26, no tweak que acertou o status: das specs. O diff mexia só em status:, updated: e numa linha de tabela; o preflight devolveu onze achados, nove deles falsos:

✗ Citação `arquivo:linha` aponta para o que diz
    0035-menu-por-permissao: cita `routers/operations_center.py:23`, e o arquivo não existe
    0054-cobranca-por-tipo-e-canal: cita `apps/api/app/models/billing.py:200` com um
      trecho que não está no arquivo
    … e mais 9

A citação de apps/api/app/models/billing.py:200 está exata: a linha 200 daquele arquivo é paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)), que é o que o parágrafo descreve.

O custo não é o tempo do comando — é a confiança. O preflight existe para ser o degrau barato antes dos portões caros, e a instrução do CLAUDE.md é rodá-lo antes de chamar qualquer portão do /spec ou do /fix. Um verificador que reprova documento correto ensina a ignorar o resultado, que é exatamente o que o comentário de TRECHO (scripts/preflight.py:858) já registra como pior que verificador nenhum.

2. Reprodução

Determinística, sem ambiente: o defeito é de parsing de texto.

A — o sintoma inteiro, pelo comando real:

  1. git checkout -b qualquer-branch a partir da main
  2. Mudar uma linha qualquer de docs/specifications/0054-cobranca-por-tipo-e-canal/specification.md — inclusive uma que não seja citação, como o updated: do front matter
  3. make preflight

→ dez achados "com um trecho que não está no arquivo". Nove são falso positivo; o décimo, billing_repo.py:155, é citação errada de verdade — ver o alcance.

B — o mecanismo isolado, em três linhas: o pareamento de crases de um trecho que quebra linha.

python3 - <<'PY'
import re
TRECHO = re.compile(r"`([^`\n]*)`")
bloco = ("alocado, não sobre `amount_paid`:** `discount_amount = amount − (setup_paid +\n"
         "guarantee_paid + misc_paid)`, quando positivo. Medi-la por `amount_paid` deixaria\n")
print(TRECHO.findall(bloco))
PY

→ ['amount_paid', ', quando positivo. Medi-la por '] — o segundo "trecho de código" é prosa.

C — a guarda que não dispara: citação com intervalo de linhas é invisível ao regex.

python3 -c "
import re
CITACAO = re.compile(r'\`([\w.-]+(?:/[\w.-]+)+\.\w+):(\d+)\`')
print(CITACAO.findall('(\`apps/api/app/services/scoping.py:42-48\`) e (\`app/repositories/base.py:56-57\`)'))"

→ []. Nenhuma das duas é contada.

  • Ambiente: qualquer — é python3 puro sobre arquivos versionados. Não depende de local, dev-remote nem produção.
  • Superfície: não se aplica (ferramental de linha de comando).
  • Dados/pré-condições: nenhuma. Os registros que disparam já estão na main.

3. Esperado × Atual

  • Esperado: a verificação reprova quando a citação está errada — arquivo que não existe, linha além do fim do arquivo, ou trecho citado que não está na linha citada — e passa quando está certa.
  • Atual: reprova citação correta, e o parágrafo em que a citação vive decide se ela escapa ou não. No registro que o tweak tocou, nove dos onze achados eram falso positivo; no corpus inteiro a proporção se inverte, porque a maioria dos achados são caminhos que realmente não resolvem. Quantos de cada, o script do alcance diz na hora. A verificação de linha — a que o fix 0013 motivou — não foi exercitada por nenhuma medição deste registro, e o porquê está no alcance: o script roda em modo --all, e nesse modo o ramo dela (o ramo depois do if relevante(alvo), à época em :973-982) é código morto. Não há evidência aqui de que ela funcione, e não se afirma que funcione.

4. Causa raiz

Onde

scripts/preflight.py:966, sob a guarda de scripts/preflight.py:948:

confere_trecho = len(citacoes) == 1      # :948
...
trecho = max(trechos, key=len)           # :966

A verificação infere uma associação que o texto não codifica. Markdown não liga um trecho entre crases ao arquivo:linha que está por perto: são duas marcações independentes. A checagem inventa o vínculo — "o trecho mais longo deste bloco pertence à única citação deste bloco" — e, quando a invenção erra, a mensagem de erro culpa o documento por algo que só existe na suposição de quem verifica.

O mecanismo

A cadeia, do texto ao falso vermelho:

registro.md
  → bloco entre linhas vazias        (:928)  → às vezes um assunto, às vezes oito
  → CITACAO.finditer                 (:947)  → conta as citações do bloco (regex em :844)
  → len(citacoes) == 1               (:948)  → guarda: "é uma só, posso conferir o trecho"
  → _trechos_de_codigo               (:863)  → candidatos do bloco (filtro em :870)
  → max(key=len)                     (:966)  → O VÍNCULO INVENTADO: o mais longo é "dela"
  → _casa_no_texto contra o arquivo   (:968)  → não acha → "trecho que não está no arquivo"

O passo :966 é o único que afirma algo sem evidência. Os outros medem o que está escrito; ele escolhe por comprimento, que não é sinal de pertencimento nenhum. É por isso que a citação de apps/api/app/models/billing.py:200 reprova estando exata: o bloco em que ela vive também enuncia a identidade da RN27, que é mais longa, e a identidade não está — nem deveria estar — dentro de um arquivo de model.

Três fatores agravam, e nenhum é a causa. A distinção importa porque a correção muda conforme a resposta:

  1. A unidade de análise — o re.split(r"\n\s*\n", texto) de scripts/preflight.py, à época em :928. Numa lista Markdown não há linha vazia entre os itens, então um bloco pode ir de 3 a 67 linhas e cobrir dezesseis regras de negócio contando como um "parágrafo". Quanto maior o bloco, mais candidatos concorrem no max(key=len), e menor a chance de o vencedor ser o certo.
  2. O CITACAO não vê parte das citações — scripts/preflight.py:844, que exige caminho com barra e crase imediatamente após o número. Ficam de fora a citação com intervalo (`models/document.py:15-26`), com lista (`repositories/document_repo.py:17-53,78-84`), a abreviada que segue citando o mesmo arquivo (`:65-75`) e a sem diretório (`main.py:484`, forma que o comentário de scripts/preflight.py:837-843 diz recusar de propósito). A guarda de :948 conta 1 onde há seis ou oito, e abre justamente nos blocos onde mais devia fechar.
  3. Trecho de código que quebra linha vira prosa — scripts/preflight.py:860, TRECHO = re.compile(r"`([^`\n]*)`"). O [^\n]*proíbe atravessar\n, então a crase de abertura no fim de uma linha fica órfã e a **crase de fechamento** da linha seguinte pareia com a **de abertura** do trecho seguinte, capturando o texto entre elas — o', quando positivo. Medi-la por 'da reprodução 2-B. Sendo longo e tendo espaço, ele passa o filtro descripts/preflight.py:870e costuma vencer omax`.

Esta é a mesma classe que o commit 017223b tratou, e o conserto foi incompleto: ele atacou a órfã criada pelo filtro de comprimento dentro da regex, movendo o filtro para _trechos_de_codigo. A órfã criada pela quebra de linha não tem nada com o filtro, e ficou.

A prova de que são agravantes e não a causa: simulando as três frentes juntas — unidade de item de lista e de linha de tabela, CITACAO reconhecendo intervalo, lista, abreviada e sem diretório, e TRECHO atravessando \n —, 4 dos 10 achados de trecho da spec 0054 continuam de pé: operations_center_service.py:568, documents.py:122, billing_service.py:102 e partner_agency_service.py:118. O caso de documents.py:122 é decisivo, e é conferível justamente sob a granularidade mais fina que a frente 1 propõe: reduzida a unidade à linha da tabela, o concorrente deixa de ser o GET /api/v1/guarantees/{gid}/billings/{bid}/charge do bloco inteiro e passa a ser o GET /api/v1/documents/storage-summary daquela linha — 37 caracteres, que ainda vencem o require_system_role("ADMIN_TENANT") de 35, que está na linha 122. Afinar a unidade troca o concorrente e não muda o resultado, porque o defeito é escolher por comprimento.

O alcance

A medição não mora neste texto — mora no script, em docs/fixes/assets/0020-preflight-reprova-citacao-correta/medir-alcance.py. Ele importa o próprio scripts/preflight.py com NOVAS = None (o modo --all), percorre os registros que a verificação de fato examina — docs/fixes/*.md e docs/specifications/*/specification.md, e não o conjunto maior que o docs_lint percorre, que inclui ADRs, tweaks e operações sem citação conferida — e imprime o inventário: quantos achados, quais, em que registro, e o veredicto de cada um.

Rode-o; não confie num número copiado para cá. Três rodadas de revisão deste registro reprovaram por número absoluto escrito em prosa, e a lição é que inventário em texto envelhece calado enquanto um script continua dizendo a verdade. Por isso os critérios da seção 8 são diferenciais: a linha de base é a saída dele antes da correção.

O que o script mede sozinho, e o que não mede. O total e a separação entre caminho que não resolve e trecho que não casa são mecânicos. Dizer se a citação está certa, não — e essa é a parte interessante, porque é o próprio defeito reaparecendo no instrumento de medida: decidir de qual citação é um trecho exige a associação que o Markdown não codifica. Duas automações falharam em direções opostas. A primeira classificava pela mensagem de erro e escondia achado legítimo entre os falsos; a segunda tentou corroborar sozinha e casou tokens genéricos (billing, GUARANTEE, previsto) longe da linha citada, inventando três derivas que não existiam. Então o veredicto de cada achado de trecho é conferido à mão — abrindo a linha citada contra o que o parágrafo afirma dela — e vive no script como dado, em VEREDICTO_CONFERIDO, com a nota do que cada linha realmente tem.

As duas naturezas, que são opostas e decidem o desenho da correção:

  • Falso positivo — a citação está certa e a verificação errou ao emparelhar o trecho. É o defeito deste fix, e a esmagadora maioria está na spec 0054.
  • Achado verdadeiro — a verificação está certa, e ninguém viu porque aquele registro não foi tocado desde então. Quase todos são caminho escrito relativo à raiz do app em vez da raiz do repositório (renters/form.tsx, pages/onboarding/index.tsx, app/schemas/common.py, …); um é deriva real de arquivo, a FIX-0019 citando um BureauInquiryPanel.tsx apagado pela spec 0052 — o componente da Consulta de crédito hoje é CreditCheckGroup.tsx.

Dois achados verdadeiros são os que mais restringem a correção, porque são os únicos da classe que o fix 0013 motivou — citação que aponta para a linha errada, e que a verificação acusa hoje pela mensagem errada:

  • docs/fixes/0008-caixa-azul-do-passo-documentos.md cita a linha 324 de apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx afirmando que ali está if (a.status === "PENDING") setStep(2). A 324 é um comentário; o código está na 429.
  • A spec 0054 cita a linha 155 de apps/api/app/repositories/billing_repo.py como o ramo que implementa o sentinela sem-competencia. Ela foi renumerada errada por edição posterior: no commit d9a468e, que escreveu o parágrafo, a citação era billing_repo.py:126-127 e estava correta — o ramo do sentinela estava mesmo ali. O commit de implementação 18c4c04 trocou o número para :155 no mesmo diff em que removeu aquele ramo. Hoje a 155 é o corpo do ramo overdue_only.

As duas são acusadas hoje por acidente do defeito, e isso é conferido: em nenhum dos dois casos o trecho que produz a acusação é o trecho que a citação descreve — em ambos o eleito vem de outro item do mesmo bloco (blockEntity() na FIX-0008, GET /billings/reference-months na 0054). Reduzido o bloco ao item de lista que contém a citação, os dois itens ficam sem candidato a trecho.

Daí um limite de desenho que precisa estar escrito, e uma coisa que este diagnóstico deliberadamente não prevê. O limite: a verificação só sabe conferir uma linha quando o texto lhe dá um trecho para localizar; citação sem trecho conferível é invisível para ela, e supor um trecho é exatamente o defeito. O que não se prevê aqui: se cada uma das duas continua detectável depois da correção. Isso depende de detalhes do desenho que só o implement decide — por exemplo, se o TRECHO passa a juntar linha de continuação, o que mudaria a resposta para a FIX-0008, cujo trecho quebra linha. Prever isso foi o que reprovou duas rodadas seguidas desta revisão; medir depois custa um comando.

Uma quinta forma, que este próprio registro exibe. A verificação acusa este arquivo, e o script do alcance lista os achados dele à parte, justamente porque esse número muda a cada edição do texto — foi o que aconteceu duas vezes enquanto este registro era escrito, e é a razão de não haver contagem aqui. São três casos possíveis, de naturezas diferentes:

  1. Cerca de código lida como citação. O bloco ``` da seção 1 transcreve a saída do preflight, e o caminho relativo que aparece lá dentro é lido como citação de verdade — a verificação não conhece cerca de código.
  2. O defeito-raiz batendo num item de lista, sem cerca de código nenhuma — um item com uma citação só, em que o trecho eleito pertence a outro assunto. É o mesmo caso que este registro descreve nos outros documentos, e ele aparece ou não aqui conforme a redação de um item de lista muda.
  3. Narrar uma citação quebrada é indistinguível de escrevê-la. Este registro precisa nomear os caminhos que outros documentos erraram; nomeá-los é reproduzi-los. O comentário de scripts/preflight.py:837-843 mostra que a situação é conhecida: é por isso que o CITACAO exige diretório, para um deploy.sh:91 narrado não virar achado. Mas a exigência não alcança um caminho narrado que tem diretório — e é o que acontece com o caminho da spec 0035 transcrito na cerca da seção 1. É tensão de desenho, e este fix não a resolve.

Os casos 1 e 2 caem dentro do que este fix conserta; o 3 é limite declarado do desenho.

Consequência do alcance para o desenho da correção: um conserto que funcione afrouxando — deixar de conferir trecho, ou parar de examinar o que não entende — calaria os dois achados de linha errada junto com os falsos positivos, e com eles a única parte desta verificação que foi motivada por defeito real (o número de linha errado do fix 0013) e que, até hoje, nenhuma medição deste registro exercitou. Os achados de caminho que não resolve sobreviveriam: a checagem de arquivo existe roda em todas as citações, antes da guarda (scripts/preflight.py:947-954). A correção tem de separar as duas coisas: conferir onde há evidência de associação, e não inventar associação onde não há.

5. Revisão do diagnóstico

Rubrica .claude/rubrics/diagnostico.md, subagente de contexto limpo.

Rodada Critical Important Minor Veredicto
1 2 3 2 reprovado
2 2 4 1 reprovado
3 1 4 4 reprovado
4 1 4 2 reprovado (nota 7)
5 3 2 3 reprovado (nota 6)

Resultado: —

O que a rodada 1 achou, e o que mudou. Os dois Critical eram do par causa/alcance, e os dois se resolveram lendo e remedindo — nenhum era pergunta ao usuário:

  • A causa raiz estava no lugar errado. A rodada 1 declarava a unidade de análise (:928) como causa única e afirmava que as três formas do sintoma saíam dela. O revisor simulou as três frentes propostas e mostrou 4 dos 10 falsos positivos da spec 0054 de pé, com a tabela de documents.py:122 como caso decisivo. Confirmei abrindo o bloco: é uma tabela de 23 linhas, e os quatro trechos mais longos são rotas. A seção 4 foi reescrita — a causa é o vínculo inventado de :966, e a unidade, o CITACAO e o TRECHO passaram a fatores agravantes.
  • Os números do alcance não reproduziam. A rodada 1 dizia "96 registros, 25 achados, 11 verdadeiros". O corpus real da função são 73 registros (o 96 é do docs_lint, que conta ADRs, tweaks e operações, sem citação conferida), e são 48 achados: 34 verdadeiros e 14 falsos. O erro era do meu script de medição, que pulava parágrafo com mais de uma citação — mas a checagem de arquivo existe roda em todas as citações, e só a de trecho é que depende da guarda. Os critérios de aceite estavam escritos contra os números errados, de um jeito que premiava silenciar achado legítimo; foram reescritos, e o script de medição virou arquivo próprio em docs/fixes/assets/0020-preflight-reprova-citacao-correta/medir-alcance.py.

Os três Important e os dois Minor também foram corrigidos: os números de bloco da seção 4 eram um composto de três blocos diferentes e saíram; a divisão por causa dos dez achados da 0054 saiu junto (ela dependia da minha classificação errada); a quinta forma do defeito — citação dentro de bloco de código transcrito, que este próprio registro exibe — entrou no alcance; :972 virou :968, que é onde _casa_no_texto é chamado; e a descrição do que o CITACAO perde passou a incluir a forma sem diretório e a abreviada, não só o intervalo.

O que a rodada 2 achou, e o que mudou. A causa raiz passou pela falsificação — o revisor construiu as três frentes e confirmou que os 4 sobreviventes são exatamente os 4 nomeados. O que reprovou foi a mesma classe da rodada 1 um degrau abaixo: a medição da natureza de cada achado, que eu fazia pela mensagem de erro em vez de abrindo o arquivo.

  • Um dos "14 falsos positivos" era achado verdadeiro. A FIX-0008 cita a linha 324 de CreditAnalysisForm.tsx com um código que está na 429 — deriva de linha legítima, da classe que o fix 0013 motivou. Conferi os 14 à mão: 13 falsos, 1 verdadeiro. Os números viraram 35 verdadeiros e 13 falsos, e CA2 e CA6 foram reescritos — como estavam, exigiam silenciar aquela deriva para fechar.
  • Os achados sobre este próprio registro são 3, não 4, e vêm de três lugares diferentes — só um deles de dentro da cerca de código. O antigo CA7 ("não acusa nada") era inalcançável pelo remédio que a seção 6 lhe dava, e saiu; no lugar entrou o critério que faltava, que é medir quanto o CA5 amplia o conjunto conferido.
  • Os Important restantes: o caso decisivo passou a ser argumentado sob a granularidade fina (é GET /api/v1/documents/storage-summary, 37, contra require_system_role("ADMIN_TENANT"), 35 — e não o charge do bloco inteiro), a seção 9 passou a nomear :966 em vez de listar só os agravantes, o "11" que sobrara na seção 7 saiu junto com a lacuna que ele escondia, e o rótulo do diagrama virou :863.

O que a rodada 3 achou, e o que mudou. A causa raiz passou pela falsificação outra vez, agora por simulação independente do revisor. O Critical foi, de novo, um número: um dos 14 veredictos que eu conferi à mão estava invertido. A spec 0054 cita a linha 155 de billing_repo.py como o ramo do sentinela sem-competencia, e essa citação nasceu errada — no commit d9a468e, que escreveu o parágrafo, o ramo estava nas linhas 126-127 e a 155 era uma assinatura de função. Conferi com git show; o revisor está certo. Viraram 36 verdadeiros e 12 falsos, e CA1 dizia que "as dez da 0054 estão certas", o que mandava silenciar um achado legítimo.

Daí a mudança de forma dos critérios, que é a resposta ao padrão das três rodadas. As três reprovaram pela mesma classe — número absoluto escrito em prosa, desmentido por uma conferência mais atenta. Contar melhor já falhou três vezes. Os critérios da seção 8 passaram a ser diferenciais: guarda-se a saída do medir-alcance.py antes de mexer no código e compara-se depois por identidade do achado, não por total. CA2 passou a exigir que nenhum verdadeiro desapareça, nomeando as duas citações erradas de linha, e admitindo que elas mudem de mensagem.

Os Important e Minor restantes foram corrigidos e um deles virou critério novo: o script roda em modo --all, e nesse modo o ramo da conferência de linha (o ramo depois do if relevante(alvo), à época em :973-982) é código morto — então nenhuma medição deste registro exercita a parte da verificação que o fix 0013 motivou. A seção 3 deixou de afirmar que ela funciona, e entrou o CA8, que exige teste para ela. Também saíram: a saída do script citada com número que ele não imprime, o caso 2 da quinta forma (que não dispara, porque o parágrafo nomeia o caminho sem número de linha), o 34 que sobrara na seção 6, o rótulo :844 do diagrama onde o finditer é :947, a nota que trocava o bloco do inquilino pelo do imóvel, e a afirmação de que o script está versionado — ele não está: nada neste fix foi commitado.

O que a rodada 4 achou, e o que mudou. Foi a rodada em que tirar o inventário da prosa provou valer: o revisor conferiu os 14 veredictos um a um, inclusive a arqueologia do commit d9a468e, e nenhum estava invertido — o que sobrou de errado era resíduo de prosa, não medição. O Critical, porém, é o achado mais útil das quatro rodadas, e é de desenho: os critérios diferenciais não tinham sido testados contra a correção que a seção 6 propõe. Testados, CA2 e CA6 se excluíam.

A citação errada de billing_repo.py:155 é acusada hoje por acidente do defeito — o trecho eleito, GET /billings/reference-months, pertence a outro item do mesmo bloco. Reduzida a unidade ao item de lista, aquele item fica com zero candidatos a trecho (conferido: um caminho sem espaço e um 422, ambos descartados pelo filtro de :870). Então a correção perde essa detecção, e não há como não perder: a verificação só confere uma linha quando o texto lhe dá um trecho para localizar. Fingir o contrário é o próprio defeito.

Três consequências entraram no registro: o limite de desenho está escrito no alcance; o CA2 virou três critérios (o de caminho, que não admite exceção; o da FIX-0008, que passa a ser acusada pela mensagem certa; e o da 0054, que deixa de ser acusada depois de a citação ser corrigida); e corrigir as duas citações erradas entrou no escopo da seção 6 — a da 0054 porque, depois da correção, ninguém mais a encontraria.

Os Important e Minor eram prosa que o script desmentia, e saíram: a reprodução 2-A dizia "dez achados, todos em citações corretas" quando um dos dez é citação errada de verdade; a seção 3 afirmava que a maioria dos achados do corpus é falso positivo, e é o contrário; o caso 2 da quinta forma apontava um achado que não ocorre e o caso 3 apontava o parágrafo errado; e o número de documentos do docs_lint saiu do texto, porque envelhece a cada documento novo.

O que a rodada 5 achou, e a decisão que fechou o portão. A nota caiu de 7 para 6, e os três Critical eram todos sobre o que eu havia acrescentado depois da rodada 4 — texto que nenhuma revisão tinha lido:

  • A arqueologia do commit estava errada, e eu a afirmara como conferida. Eu disse que a citação de billing_repo.py:155 "nasceu errada"; na verdade, em d9a468e a spec citava :126-127, que estava correto ali. O :155 entrou no commit de implementação 18c4c04, que no mesmo diff removeu o ramo citado. Eu havia conferido o arquivo de código naquele commit e não o que a spec dizia nele — um lado só. Corrigido no texto e no script.
  • A assimetria entre as duas citações erradas não existia. Eu afirmara que a da FIX-0008 seguiria detectável e a da 0054 não. As duas são acusadas hoje pelo mesmo acidente — trecho emprestado de outro item do bloco — e nenhuma tem candidato a trecho no próprio item.
  • Um par novo de critérios se excluía, pela mesma razão da rodada 4: o CA5 mandava reconhecer citação sem diretório, o que faria aparecer dezenas de achados novos só na 0054, enquanto o CA6 exigia preflight verde na branch dela. Pior: o comentário de scripts/preflight.py:837-843 exclui essa forma de propósito, para citação narrada não virar achado — eu tinha pedido o contrário de uma decisão registrada, sem notar.

O padrão, que é meu e não do defeito: a cada rodada eu respondia acrescentando afirmação forense — arqueologia, assimetria, previsão de qual achado sobreviveria à correção — e cada uma tem dois lados a conferir, dos quais eu conferia um. O laço passou a produzir erro na mesma velocidade que consertava.

Por decisão do usuário, o portão foi encerrado na rodada 5 com o corte da parte preditiva. O que a rubrica pede de um diagnóstico — sintoma, reprodução, causa raiz com mecanismo e alcance — está verificado: a causa (:966, o vínculo inventado) sobreviveu à falsificação independente em quatro revisões, e os 14 veredictos do VEREDICTO_CONFERIDO foram auditados um a um sem inversão. O que saiu foi tudo o que previa o comportamento depois da correção: os critérios voltaram a ser medidos, não prometidos — CA2 exige medir e registrar o destino de cada achado verdadeiro em vez de prever qual sobrevive, e CA6 exige medir o preflight em vez de exigir verde.

Resultado: o portão não aprovou. Foi encerrado na rodada 5 por decisão do usuário, com a parte preditiva removida e as pendências abaixo aceitas.

Pendências aceitas:

  • A conferência de linha — o ramo depois do if relevante(alvo), à época em :973-982 — nunca foi exercitada, e este diagnóstico não demonstra que funciona — o modo --all do script a torna inalcançável. Virou o CA8.
  • O alcance do CA5 não foi medido para o repositório inteiro: sabe-se que só na spec 0054 a frente 2 faz aparecer 5 achados de trecho novos, e que reconhecer a forma sem diretório faria aparecer dezenas — daí ela ficar fora. Medir o total é o primeiro passo do implement (CA7).
  • Quais das duas citações erradas seguem detectáveis depois da correção não está previsto, de propósito. Depende de decisão de desenho do implement — por exemplo, se o TRECHO passa a juntar linha de continuação.
  • A tensão de scripts/preflight.py:837-843 — narrar uma citação quebrada é indistinguível de escrevê-la — fica sem solução, e este registro é um exemplo dela.

Pendências aceitas: —

6. Correção

scripts/preflight.py

  • Onde: citacoes_dos_registros (:890–:983) e os dois regexes que ela usa, CITACAO (:844) e TRECHO (:860)
  • O quê: a decidir no implement, mas a causa raiz restringe o desenho. O que tem de mudar é o vínculo inventado de :966: conferir trecho só onde haja evidência de associação, em vez de eleger o mais longo do bloco. Duas formas de ter evidência, e as duas são conferíveis no texto:
  • unicidade — o bloco tem uma citação e um candidato a trecho; então não há o que emparelhar errado;
  • adjacência — o trecho e a citação estão na mesma sentença, ou a uma distância curta e medida um do outro.

Os três agravantes entram no mesmo diff, porque cada um deles produz achado sozinho: a unidade de análise (:928) reconhecer item de lista e linha de tabela; o CITACAO (:844) reconhecer intervalo, lista, abreviada e sem diretório — o que também melhora a conferência de linha, hoje cega a todas elas; e o TRECHO (:860) deixar de produzir prosa a partir de crase órfã de trecho que quebra linha. A quinta forma pede ainda ignorar o conteúdo de cerca de código (```), que é transcrição, não citação. - Sem afrouxar, e isto é a metade difícil: os achados verdadeiros do alcance têm de continuar aparecendo, e a citação errada plantada — o teste que o 017223b fez à mão, um número de linha trocado como o deploy.sh:91 do fix 0013 — tem de continuar sendo pega. Um conserto que deixe de conferir trecho passa a CA1 e destrói a verificação.

As duas citações erradas que o alcance encontrou

Entram no diff, e não como faxina de passagem: as duas são acusadas hoje por acidente do defeito — o trecho que as denuncia pertence a outro item do mesmo bloco —, então a correção pode muito bem deixar de encontrá-las. Corrigi-las aqui é o que não depende dessa dúvida.

  • docs/specifications/0054-cobranca-por-tipo-e-canal/specification.md — a citação de billing_repo.py:155 aponta para o ramo do overdue_only; o ramo do sentinela sem-competencia que o texto descreve não existe mais no arquivo. A correção é reescrever a citação para o que hoje é verdade — que a própria implementação da 0054 removeu aquele ramo —, não trocar o número por outro.
  • docs/fixes/0008-caixa-azul-do-passo-documentos.md — a citação de CreditAnalysisForm.tsx:324 deve apontar para a 429.

Os achados verdadeiros de caminho que não resolve ficam fora — quase todos são caminho relativo à raiz do app, e um é arquivo apagado (a FIX-0019 citando um BureauInquiryPanel.tsx que a spec 0052 removeu). São muitos, são de registros antigos, e arrumá-los é decisão do usuário (seção 7). O que este fix garante é que continuem sendo acusados.

apps/api, apps/web, apps/e2e

Sem impacto. O defeito é do ferramental; nenhum código de produto muda.

7. Risco / impacto colateral

scripts/preflight.py é chamado por make preflight e é degrau obrigatório antes dos portões do /spec e do /fix. Dois riscos, opostos:

  • Afrouxar e não perceber. Um conserto que faça a verificação parar de reprovar também o que está errado passa desapercebido — o sinal de sucesso aqui ("ficou verde") é o mesmo de uma regressão. É o que o teste de citação errada plantada precisa cobrir.
  • Endurecer e travar o repositório. Fazer o CITACAO ver as citações com intervalo amplia o conjunto conferido: citações que hoje ninguém examina passam a ser examinadas, e podem acusar registros antigos. Quanto, este diagnóstico não mediu — é a lacuna conhecida dele. Só na spec 0054 a simulação da frente 2 já fez aparecer 5 achados de trecho que hoje ninguém examina; o medir-alcance.py roda em segundos, então medir isso é o primeiro passo do implement. Se o número crescer, arrumar os registros antigos é decisão do usuário e não entra neste fix.

Não toca dinheiro, schema, permissão nem contrato de API.

8. Critérios de aceite

Os critérios são diferenciais, de propósito. As três rodadas da revisão reprovaram sempre pela mesma classe: número absoluto escrito em prosa, que uma conferência mais atenta desmente. A defesa não é contar melhor — é comparar a saída do mesmo comando antes e depois, por identidade do achado e não por total. Guarde a saída de docs/fixes/assets/0020-preflight-reprova-citacao-correta/medir-alcance.py antes de mexer no código; ela é a linha de base.

  • [x] CA1. Nenhum achado marcado falso no VEREDICTO_CONFERIDO do script sobrevive à correção. O script os nomeia um a um — a lista, não a contagem, é o critério.
  • [x] CA2. Nenhum achado verdadeiro é silenciado sem decisão escrita. Comparando as duas saídas do script por caminho:linha: todo achado de caminho que não resolve continua presente — essa parte não admite exceção. Para os dois achados de linha errada (CreditAnalysisForm.tsx:324 e billing_repo.py:155), o critério é medir e registrar o que aconteceu com cada um: continuou acusado, mudou de mensagem para "não nessa linha", ou deixou de ser detectável — e, neste último caso, o registro diz por quê. O que não vale é sumir sem ninguém notar.
  • [x] CA2a. As duas citações erradas estão corrigidas nos registros da FIX-0008 e da spec 0054 (seção 6). Esse é o critério que não depende de o verificador conseguir pegá-las depois.
  • [x] CA3. Com uma citação errada plantada num registro — número de linha trocado, como o deploy.sh:91 do fix 0013 —, a verificação reprova.
  • [x] CA4. TRECHO não devolve prosa para o bloco da reprodução 2-B.
  • [x] CA5. A contagem da guarda de :948 passa a ver as formas que hoje escapam — intervalo (models/document.py:15-26), lista (document_repo.py:17-53,78-84), abreviada (:65-75) e sem diretório —, sem que nenhuma delas passe a ser conferida. Medido no implement, trocando o regex de conferência e rodando a verificação: conferi-las levaria os achados de 34 para 102 — 68 novos, em 13 registros. Contar e conferir viram duas coisas separadas, e é o que resolve a cegueira da guarda a custo zero.
  • [x] CA6. O make preflight da branch da reprodução 2-A é medido e registrado. Verde é o alvo; se ficar vermelho por achado verdadeiro que passou a ser visto, isso é resultado legítimo e vai para o registro com a decisão sobre ele — não se aperta a régua para ficar verde.

Ampliar o que é conferido fica fora, e agora com número. Medido no implement: reconhecer intervalo e lista para conferência leva os achados de 34 para 102 — 68 novos, em 13 registros —, quase todos caminho relativo à raiz do app, a mesma dívida antiga que a seção 7 põe fora de escopo. E a forma sem diretório é excluída por decisão registrada em scripts/preflight.py:837-843: o diretório é exigido para que um deploy.sh:91 narrado não vire achado. Daí a separação do CA5 entre contar e conferir: contar resolve a cegueira da guarda sem tocar no conjunto conferido, e arrumar os 48 continua sendo decisão do usuário.

  • [x] CA7. Medido antes de corrigir (ver seção 7): quantos achados novos o CA5 faz aparecer, por passar a examinar citação que hoje escapa. O critério não é um número — é o número estar medido e escrito, com a decisão sobre ele registrada.
  • [x] CA8. A verificação de linha (hoje o ramo do if relevante(alvo), em torno de scripts/preflight.py:1076) fica exercitada por teste. Hoje nenhuma medição deste registro a alcança, porque o modo --all a deixa inalcançável — e é a parte que o fix 0013 motivou, nunca exercitada desde então.

Não há critério sobre este próprio registro, e é decisão. Um critério auto-referencial muda a cada edição do arquivo que ele mede — durante a escrita deste registro a contagem de achados sobre ele mesmo mudou duas vezes por causa de reescritas de prosa. O script lista esses achados à parte, o que basta para acompanhá-los sem transformá-los em critério.

9. Plano

  • [x] Escrever o teste que reproduz o bug e vê-lo falhar — casos de citacoes_dos_registros com texto fixo (bloco de lista multi-assunto, citação com intervalo, trecho que quebra linha), mais o negativo da citação errada plantada
  • [x] Medir o que o CA5 faz aparecer de achado novo, antes de corrigir (seção 7)
  • [x] Corrigir a causa raiz: o vínculo inventado de max(trechos, key=len) — conferir trecho só onde haja evidência de associação (adjacência, medida e escolhida)
  • [x] Corrigir os três agravantes no mesmo diff — a unidade de análise, a contagem da guarda e o TRECHO —, porque cada um produz achado sozinho
  • [x] Corrigir as duas citações erradas nos registros da FIX-0008 e da spec 0054 (seção 6), mais uma terceira imprecisão que a correção revelou: paráfrase entre crases colada a uma citação, na CA52 da 0054
  • [x] Red-green verificado: com scripts/preflight.py da main no lugar, a suíte dá FAILED (failures=8, errors=1) — 9 dos 16 casos morrem; restaurada, Ran 16 tests ... OK
  • [x] make preflight rodado e o resultado registrado, achado por achado — a branch terá tocado os registros da FIX-0008, da spec 0054 e este, e este é ele mesmo um caso do defeito (seção 4), então "limpo" não é o critério; "explicado" é. E make test sem apontamento novo
  • [x] Sem impacto no web: não há browser a validar

Onde mora o teste. O repositório não tem suíte para scripts/, e os meta-testes que o preflight roda (porteiro, permissão órfã, fronteira de transação) vivem em apps/api/tests/. Decidir no implement se o teste deste fix entra ali ou num arquivo próximo do script — o que não vale é corrigir sem teste, porque o defeito é precisamente uma heurística que ninguém exercita.

10. Decisões em aberto

  • [ ] Nenhuma. A causa raiz está estabelecida e medida; o desenho da correção é técnico e não depende de informação que só o usuário tem.

11. Revisão da correção

Rubrica .claude/rubrics/correcao.md, subagente de contexto limpo.

Rodada Critical Important Minor Veredicto
1 0 3 4 reprovado
2 0 5 4 reprovado (nota 7)
3 0 3 3 aprovado (nota 8)

Resultado: aprovado na rodada 3 — sem Critical, nota 8. Os três Important e os três Minor dela foram corrigidos em vez de aceitos como pendência.

O teste sobreviveria à reversão? Sim, e está medido. Com git show main:scripts/preflight.py no lugar do arquivo corrigido, a suíte dá FAILED (failures=8, errors=1); restaurada, Ran 16 tests ... OK. Nove dos dezesseis casos morrem com a reversão; os outros são as negativas, que por desenho sobrevivem — elas não provam a correção, provam que ela não afrouxou nada. O revisor mutou cada guarda uma a uma para conferir que as negativas têm dente: desligar _trecho_adjacente mata linha_trocada e trecho_ausente; tirar o ramo de arquivo inexistente mata caminho_que_nao_resolve; tirar a contagem de linhas mata linha_alem_do_fim; tirar o SEM_CERCA mata cerca. Nenhuma passaria com a verificação desligada.

O que a rodada 1 achou, e o que mudou. Nenhum Critical: o revisor refez a medição que decide a régua de adjacência e confirmou os dois números que a sustentam (112 citações em unidade de uma citação só e 8 com trecho colado, no estado de 88c6af6), implementou a régua larga por conta própria e mediu os falsos positivos que ela deixaria. Os três Important eram:

  • O alvo preflight-autoteste não podia reprovar o preflight. Ele era python3 ... 2>&1 | tail -3, e o make herda o status do último comando do pipe — o tail, que sempre sai zero. Reproduzi com um script que imprime FAILED e sai 1: o make saía 0. O preflight diria "tudo limpo" com a suíte da própria verificação vermelha, que é o mesmo defeito de "sai zero sem ter feito" que o fix 0013 registrou no deploy. Corrigido capturando a saída; provado depois: com a correção revertida, make preflight-autoteste sai 2.
  • A afirmação de que as duas derivas de linha se perderam era falsa para uma delas — e falsa a favor da correção. Ver a seção 12: a da FIX-0008 continua acusada, pela mensagem certa. O que me enganou foi medir no modo --all, onde o ramo da conferência de linha é código morto.
  • A coluna "Antes" da tabela da seção 12 não reproduzia (49 e 1, não 48 e 3), e o número do CA5/CA7 também não (68 achados novos em 20 registros, não 48 em 12). Remedidos rodando.

Dos Minor, dois viraram mudança de código: a ligação de contar_citacoes à guarda não tinha teste — trocá-la de volta por len(citacoes) deixava os outros casos todos verdes, e agora há um que pega a mutação (verificado: FAILED (failures=1) com ela, OK sem); e _trechos_de_codigo havia virado código morto com a régua dele duplicada dentro de _trecho_adjacente — as duas cópias viraram o predicado único _parece_codigo. Os outros dois eram citações que a própria correção deslocou dentro de scripts/preflight.py, reescritas para nomear o ramo em vez do número.

O que a rodada 2 achou, e o que mudou. Outra vez sem Critical, e o achado mais útil foi de novo contra o meu próprio verificador: o alvo preflight-autoteste rodava depois do python3 scripts/preflight.py, e o make aborta a receita na primeira linha que falha — então a autoteste só era alcançada quando o verificador já estava verde, o oposto exato do cenário deste fix, que é preflight vermelho em documento correto. Subiu para antes, e está provado: com a correção revertida, a autoteste roda primeiro e mostra as falhas.

Duas peças mudadas não tinham teste que morresse ao revertê-las — _parece_codigo (sem o filtro, o achado do corpus real ia de 34 para 40, um deles justamente a linha da CA52 que esta branch teve de reescrever) e unidades (revertida, a detecção da FIX-0008 desaparece em silêncio). Ganharam caso, e hoje sete mutações das peças da correção são todas pegas: repor max(key=len), _parece_codigo sempre verdadeiro, a unidade de volta ao bloco, a contagem estrita, o TRECHO proibindo \n, o SEM_CERCA desligado e o SO_SEPARADOR aceitando qualquer coisa.

O resto era número que o script desmentia, e é a classe que reprovou a rodada 1 reaparecendo em lugar novo: o comentário de CITACAO_CONTAGEM ficou com o 48/12 que o registro já havia corrigido para 68; as contagens do red-green estavam desatualizadas porque a rodada 1 acrescentou um teste; e o "20 registros" era o total no modo amplo, não os 13 em que os novos aparecem. Os números da régua larga saíram de vez: "mesma frase" nunca foi definido, e dois revisores mediram valores diferentes pela mesma razão — a fronteira de frase é arbitrária, e é justamente por isso que aquela régua não serve. Ficaram os dois que reproduzem, ditos contra o estado a que se referem: 112 citações conferíveis e 8 com trecho colado em 88c6af6, 111 e 7 na árvore da branch.

O que a rodada 3 achou, e foi fechado mesmo tendo aprovado. Nota 8 sem Critical aprova pela régua do repositório, e os três Important viraram correção porque eram de minutos e um deles era uma lacuna de prova real:

  • A oitava mutação escapava. _trecho_adjacente olha o trecho colado antes ou depois da citação, e largar o ramo "depois" deixava os quinze casos verdes — custando, no corpus real, 4 das 5 detecções de linha. Entrou o caso da ordem inversa, e agora essa mutação é pega.
  • Os números da régua não reproduziam nesta árvore (111 e 7, não 112 e 8), porque 112/8 é o estado de 88c6af6 — antes das três correções de texto que este diff faz. Agora cada número diz contra que estado vale, e o docstring do teste deixou de repetir a decomposição que nunca reproduziu.
  • A correção envelheceu uma citação do próprio registro (:928, onde o re.split estava) e não a corrigiu. É o defeito que este fix existe para pegar, cometido dentro do registro dele. Passou para a forma abreviada, que a verificação não confere de propósito.

Os Minor: o CA8 apontava o ramo vizinho (:1071, a conferência de trecho) em vez do da conferência de linha; o docstring de FalsoPositivoDeEmparelhamento dizia prender o defeito-raiz quando quem o prende sozinho é test_trecho_longe_da_citacao_nao_e_conferido; e o VEREDICTO_CONFERIDO ficou com as chaves da linha de base, o que fazia o script avisar "sem veredicto" quando rodado na branch — ganhou a entrada da citação já corrigida.

E a tabela de alcance perdeu a linha do total. Ele conta os achados que este próprio registro produz, muda a cada edição da prosa daqui, e ficou desatualizado duas vezes por isso. As duas linhas que sobraram não dependem do texto deste arquivo.

Defeitos aceitos: —

12. Registro

O que era e o que virou

A verificação elegia max(trechos, key=len) dentro de um bloco separado por linha vazia e declarava esse trecho pertencente à citação do bloco. Agora ela confere o trecho colado na citação — antes ou depois, só separador entre os dois — dentro de uma unidade que é o item de lista ou a linha de tabela, e não o bloco inteiro. Não havendo trecho colado, não há evidência de associação e não se confere nada: nem para aprovar, nem para reprovar.

Os três agravantes caíram no mesmo diff. O TRECHO passou a parear crases sequencialmente, atravessando quebra de linha — era a proibição de \n que deixava a crase de abertura órfã e fazia a prosa entre dois trechos virar "código". A contagem da guarda virou uma regex própria, CITACAO_CONTAGEM, que vê intervalo, lista, abreviada e sem diretório, sem ampliar o que é conferido. E a cerca de código sai do texto antes de tudo, porque o que está dentro dela é transcrição.

O que divergiu do plano, e é a parte que vale registrar

A "unicidade" que a seção 6 oferecia como alternativa à adjacência não serve, e foi medido. Primeiro implementei corroboração por qualquer candidato da unidade: os 13 falsos positivos originais sumiram e 10 novos apareceram, em specs que antes escapavam — afinar a unidade faz mais unidades terem uma citação só, e quando a unidade tem um candidato e ele é de outro assunto, "qualquer" é "o errado". Foi a terceira vez neste fix que apertar uma heurística trocou uma família de falso positivo por outra.

A adjacência resolveu, e a escolha entre as duas réguas possíveis também foi medida, não argumentada. Contadas no estado documental de 88c6af6 — a linha de base deste fix —, 112 citações vivem em unidade de uma citação só e 8 têm trecho colado; na árvore da branch, depois das três correções de texto, são 111 e 7. A grande maioria das outras não tem trecho nenhum, e para essas não há o que conferir em régua nenhuma. Uma régua mais larga — "o trecho mais próximo na mesma frase" — cobriria mais citações e deixava falso positivo em texto correto: entre 3 e 8, conforme onde se ponha a fronteira de frase, e é por essa fronteira ser arbitrária que a régua larga não serve. A estrita cobre as 8 com zero. Num verificador que existe porque falso positivo destruiu a confiança nele, precisão vale mais que alcance — e o limite ficou explícito em teste (test_trecho_longe_da_citacao_nao_e_conferido), para ser lido em vez de descoberto por acidente.

Uma imprecisão de texto a mais entrou no diff, revelada pela própria correção: a CA52 da spec 0054 punha is_admin_global or is_admin_tenant entre crases colado a uma citação, e o código diz not ... and not .... A verificação está certa em apontar — o texto afirmava código que não existe no arquivo —, e a correção foi tirar a paráfrase das crases.

O alcance, antes e depois

Medido com o medir-alcance.py, o código da main no lugar para a coluna "Antes":

Antes Depois
caminho que não resolve 34 34 — nenhum silenciado
trecho que não casa 14 0

O total não entra na tabela, e é decisão. Ele inclui os achados que este próprio registro produz, e esse número muda a cada edição da prosa daqui — mudou três vezes enquanto o fix era escrito, e duas dessas vezes a tabela ficou desatualizada sem que nada de verdade tivesse mudado. As duas linhas acima não dependem do texto deste arquivo, e são as que respondem ao que importa: nenhum achado verdadeiro silenciado, nenhum falso positivo de trecho sobrando.

Os 34 que ficam são a dívida antiga de caminho relativo à raiz do app, que a seção 7 põe fora de escopo: arrumá-los é decisão do usuário.

As duas citações que apontavam para a linha errada não tiveram o mesmo destino, e o diagnóstico errou ao prever que as duas se perderiam. Medido no modo real da verificação — NOVAS contendo só o registro, que é o caso de quem escreve um fix:

  • a da FIX-0008 continua acusada, e agora pela mensagem certa: "o trecho citado está no arquivo, mas não nessa linha". Ela tem o trecho colado na citação, e o TRECHO novo atravessa a quebra de linha que antes o partia — então a conferência de linha, que nunca havia sido exercitada, passa a agir. É ganho da correção, não perda;
  • a da spec 0054 se perde, porque o item que a cita não tem trecho colado nenhum — e é o limite de desenho que a seção 4 registra.

Isto não foi medido no diagnóstico porque o medir-alcance.py roda em --all, e nesse modo o ramo da conferência de linha é inalcançável: era a pendência aceita, e ela escondia justamente esta distinção. As duas citações foram corrigidas nos registros neste mesmo diff (CA2a), que é o que não dependia de nenhuma das duas respostas.

O que rodou, e o que não tinha o que rodar

make preflight na branch: verde, "tudo limpo", com os 16 casos do autoteste OK no meio — é o CA6, e o número fica aqui porque um critério que pede "medido e registrado" precisa do registro. A suíte unitária da API e a do web não rodaram, e não havia o que rodar: a branch não toca apps/api nem apps/web (o diff é scripts/, Makefile e docs/), e nesta máquina não há uv no host — o pytest roda pelo container do dev-remote, que o próprio make preflight exercita nos meta-testes do repositório. Nenhum e2e: não há tela envolvida.

Uma armadilha de ambiente, que custou uma investigação e vale registrar: um .pyc esquecido em scripts/__pycache__ — deixado por um python3 -m py_compile que rodei para conferir avisos de sintaxe — passou a sombrear as edições seguintes, e a suíte mediu o código de antes sem avisar nada. O sintoma era absurdo (a unidade de análise "não separava" item de lista, embora a mesma lógica isolada separasse), e a causa era bytecode velho. O carregador do teste agora põe sys.dont_write_bytecode = True e não deixa __pycache__ atrás de si.

Onde o teste mora, e por quê

scripts/test_preflight_citacoes.py, com a unittest da biblioteca padrão, rodando no host por python3. Não foi para apps/api/tests/ porque os meta-testes de lá rodam por pytest dentro do container do dev-remote, e a imagem daquele container tem só apps/api em /app — um teste de scripts/preflight.py não alcançaria o arquivo que precisa exercitar. Está pendurado no make preflight (alvo preflight-autoteste), porque verificador que depende de alguém lembrar não é rodado.

Sete dos 16 casos são negativos, de propósito: caminho inexistente, linha além do fim, trecho ausente e linha trocada continuam reprovando, e os outros guardam o limite da régua e as duas peças que a sustentam. É a metade que impede a próxima pessoa de "consertar" um falso positivo desligando a verificação.