Files
Rafael MelgaçoandClaude Opus 5 28e24ba161 fix(cerca): o "Channel provider leak" passa a dizer QUAL linha nomeia o provider (#1322)
O erro listava o arquivo e a doutrina, e deixava a pessoa procurar. Num arquivo
de duzentas linhas com duas menções parecidas, procurar dá errado: em 19/09 um
maestro apontou a linha errada ao executor DUAS vezes, a segunda marcada como
urgente, e o conserto sugerido não teria tirado o vermelho. A cerca sabia a
resposta o tempo todo — ela só não a imprimia.

Agora imprime `linha N: <trecho>` para cada linha que casa, truncando em 96
caracteres.

E o erro passa a responder a pergunta seguinte, que é a que todo mundo faz
quando a linha acusada é um comentário: por que a cerca lê comentário?

Porque separar exigiria remover comentários antes de casar, e um removedor de
comentários apaga o que vem depois de `//` DENTRO de string — medido no repo:
`"http://waha:3000"` e as URLs `https://graph.facebook.com/...`. O resultado
seria falso negativo num gate que já teve exatamente essa classe de furo (issue
#118, onde `\b` não fechava entre `WAHA` e `_`). Um gate com furo é pior que
gate nenhum: o verde afirma que a doutrina está sendo respeitada.

Então a mensagem diz o que fazer: citar a seção da doutrina pelo assunto
(`a seção de anti-banimento`), não pelo provider.

Provado nos dois sentidos: contra a main, `ok (62 de dívida conhecida, nenhum
novo)`, exit 0; com um ofensor plantado, nomeia `linha 2` e o trecho.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:15:52 -03:00

296 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Invariante 1 da doutrina de restrição de canal
* (`docs/doctrine/restricao-de-canal.md`): **nenhuma feature nomeia um
* provider**. Rodado pelo `gov:verify`.
*
* Varredura por walk recursivo, não `fs.globSync`: a função só existe em node
* 22+, o repo já foi node 20, e walk custa 8 linhas e nenhuma dependência.
*
* O lint NÃO se auto-varre: `scripts/` está fora de `ROOTS` de propósito — este
* arquivo precisa escrever os nomes proibidos para poder proibi-los.
*
* ─── Por que existe uma lista de dívida (e não uma allowlist muda) ───────────
*
* Na primeira execução o lint apontou **56 arquivos**, não os 4 que o plano das
* Fases 0–2 estimava. Limpar todos exigiria reescrever cópia de UI visível,
* renomear campo de resposta de API pública (`checks.waha`) e mover a família de
* rotas `/api/v1/webhooks/waha/*` — tudo **mudança de comportamento**, que a
* Global Constraint nº 1 daquele plano proíbe, e que é trabalho da Fase 3
* (quando `lib/waha/` for absorvido por `lib/channels/`).
*
* Então o mecanismo é uma **catraca**, não uma anistia:
* - arquivo novo com nome de provider → reprova (o invariante vale daqui pra frente);
* - arquivo que saiu da lista mas continua sujo → reprova;
* - arquivo que ficou limpo e esqueceram de tirar da lista → **também reprova**,
* para a lista só poder encolher. Dívida sem mecanismo anti-morte é dívida
* que envelhece em silêncio (`docs/doctrine/sistema-vivo.md`).
*
* Cada entrada abaixo tem categoria e razão escrita. Entrada sem razão é dívida
* silenciosa — se você precisar acrescentar uma, escreva o porquê junto.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join, sep } from "node:path";
// O padrão vive em módulo próprio para poder ser testado sem executar o lint —
// ver a justificativa das duas fronteiras (issue #118) lá.
import { nomeiaProvider } from "./lint-channels.pattern";
const ROOTS = ["app", "lib", "components", "workers"];
const ALLOWED = [
/^lib\/channels\//,
// SEGUNDA FRONTEIRA, não exceção de feature — e a diferença é o que mantém a
// catraca honesta. `lib/channels/` é onde vive quem ENTREGA MENSAGEM, com as
// capabilities que descrevem o que a plataforma deixa dizer (janela, template,
// ban, intervalo). Reportar conversão para a plataforma de anúncio não tem
// nenhuma dessas físicas, e os dois eixos são independentes: dá para receber
// lead de anúncio clique-para-WhatsApp num número servido por qualquer
// transporte. Enfiar o transporte de conversões em `lib/channels/` amarraria
// "reportar venda" a "ter canal oficial conectado".
//
// O que NÃO muda: continua valendo que só a fronteira nomeia o transporte.
// Nenhuma feature (`lib/conversoes/` inclusive) importa `meta/conversions` —
// ela pede ao registro pelo slug da plataforma. Ver o cabeçalho de
// `lib/plataformas-de-anuncio/types.ts`.
/^lib\/plataformas-de-anuncio\//,
// O transporte que o adapter embrulha; some quando a Fase 3 o absorver.
/^lib\/waha\//,
// Saída de `supabase gen types`: os nomes são COLUNAS. Editar à mão é o defeito.
/^lib\/database\.types\.ts$/,
];
/**
* Dívida conhecida, medida em 2026-07-27 (Task 7 do plano de seam de canais) e
* RE-medida em 2026-08-05 ao consertar a fronteira do padrão (issue #118).
*
* As 7 entradas marcadas `(#118)` não são dívida nova: elas já violavam o
* invariante desde sempre e a catraca é que não as enxergava, porque `\b` não
* fecha antes de `_`. Entram DECLARADAS em vez de consertadas no mesmo passo —
* congelar o que já existia e reprovar só o novo é o que deixa o gate nascer
* verde; limpá-las exige renomear coluna de banco e campo de API pública, que é
* mudança de comportamento e trabalho da Fase 3 do seam.
*
* Ordem alfabética dentro de cada grupo, para o diff ficar legível.
*/
const KNOWN_DEBT: { reason: string; files: string[] }[] = [
{
reason:
"Superfície de TRANSPORTE do provider legado (control plane de sessão, " +
"webhook receiver, download de mídia). Mesma natureza de `lib/waha/`, que " +
"já é exceção: não são features perguntando identidade, são o próprio " +
"canal. Saem junto com `lib/waha/` na Fase 3. O teste de rota entra pela " +
"mesma porta: para exercitar a revogação por canal ele precisa montar as " +
"duas linhas da união e dublar o cliente do transporte — sai quando a " +
"rota que ele cobre sair.",
files: [
"app/api/v1/channel-sessions/[id]/qr/route.ts",
"app/api/v1/channel-sessions/[id]/reconnect/route.ts",
"app/api/v1/channel-sessions/[id]/route.test.ts",
"app/api/v1/channel-sessions/[id]/route.ts",
"app/api/v1/channel-sessions/route.ts",
"app/api/v1/health/route.ts",
"app/api/v1/messages/[id]/media/route.ts",
"app/api/v1/onboarding/whatsapp/qr/route.ts",
"app/api/v1/onboarding/whatsapp/session/route.ts",
"app/api/v1/webhooks/waha/[token]/route.ts",
"app/api/v1/webhooks/waha/route.ts",
// (#118) Lê `process.env.WAHA_API_BASE_URL`/`WAHA_API_KEY` só para
// decidir se o transporte está configurado — o nome está no ENV, não
// numa pergunta de identidade. Sai quando o env virar config de canal.
"app/app/connections/page.tsx",
"app/onboarding/connect-whatsapp/page.tsx",
"lib/agent-engine/edge/crm/session-reconciler.ts",
"workers/media-persist-worker.ts",
],
},
{
reason:
"Texto VISÍVEL ao usuário (cópia de tela) ou nome de campo de resposta de " +
"API pública (`checks.waha`, `waha_ban`, `waha_sessions_count`, o código " +
"de erro `waha_error`). Trocar é mudança de comportamento observável — " +
"proibida nas Fases 0–2, e no caso do código de erro quebraria cliente de " +
"API de terceiro. A cópia neutra de canal entra junto com o seletor de " +
"canal da Fase 3a, que é quando o usuário passa a ter mais de um canal " +
"para distinguir.",
files: [
"app/api/v1/admin/dashboard/kpis/route.ts",
"app/api/v1/admin/tenants/[id]/health/route.ts",
// (#118) Emite `waha_sessions_count` na resposta do admin.
"app/api/v1/admin/tenants/[id]/route.ts",
"app/design/sections/SectionPatterns.tsx",
"app/onboarding/connect-whatsapp/_client.tsx",
"components/admin/dashboard/AlertItem.tsx",
"components/admin/dashboard/KPICards.tsx",
"components/admin/tenants/HealthGrid.tsx",
// (#118) Fixture do teste do componente logo abaixo, que já é dívida:
// sai junto com ele, pelo mesmo motivo.
"components/admin/tenants/TenantOverview.test.tsx",
"components/admin/tenants/TenantOverview.tsx",
"components/connections/ConnectionsClient.tsx",
// (#118) `waha_error` no catálogo de códigos de erro da API pública.
"lib/api/errors.ts",
],
},
{
reason:
"(#118) Leem a COLUNA `channel_sessions.waha_session_name`. Aqui o nome do " +
"provider está no SCHEMA, não na feature: nenhum destes pergunta 'é WAHA?' " +
"— só leem o identificador da sessão pelo nome que a coluna tem hoje. " +
"Limpar é renomear a coluna (migration + apêndice no baseline + toda a " +
"leitura), que é a mesma mudança de schema que a Fase 3 do seam já prevê " +
"ao absorver `lib/waha/`. Consertar aqui, antes disso, espalharia um " +
"alias por 3 arquivos sem tirar o nome de lugar nenhum.",
files: [
"app/api/v1/ai/pacing/route.ts",
"app/api/v1/cron/contact-avatars/route.ts",
// (#573) Fixture do teste do reconciliador de sessão: monta a linha que
// `session-reconciler.ts` (dívida de TRANSPORTE, logo acima) seleciona, e
// a linha traz a coluna. É a ÚNICA menção do arquivo — medida linha a
// linha: `wahaBaseUrl`/`wahaApiKey` do mesmo teste não casam com o padrão
// (letra colada em letra não é fronteira). Sai quando a coluna sair.
"lib/agent-engine/edge/crm/session-reconciler.test.ts",
// `components/connections/AntiBanSheet.tsx` SAIU desta lista: ele lia
// `waha_session_name` como último degrau do NOME que o usuário vê, e por
// isso um canal sem apelido aparecia no painel como `org_2dd5e6ea`. Agora
// usa `nomeDoCanal`, que não conhece provedor nenhum. A catraca só encolhe.
],
},
{
reason:
"`WahaChannelAdapter` — o ChannelAdapter PRÉ-seam do agent-engine (F2-25), " +
"abstração paralela à de `lib/channels/`. Unificar as duas é decisão de " +
"arquitetura com superfície própria, não passo de um lint.",
files: ["lib/agent-engine/agent/followup-turn.ts", "lib/agent-engine/agent/inbound-turn.ts"],
},
{
reason:
"Menção em COMENTÁRIO/prosa técnica — não há acoplamento nenhum no código. " +
"O regex é o da doutrina (que fala em 'string') e não distingue prosa de " +
"código. Medido ao vivo nesta task: o comentário que eu escrevi explicando " +
"de onde uma função tinha saído virou um infrator novo. Reescrever prosa " +
"correta ('o container converte o áudio no servidor') para escapar de um " +
"regex PIORA o código — por isso a decisão é registrar, não reescrever.",
files: [
"app/api/v1/ai/agents/[id]/versions/[vid]/test/route.ts",
"app/api/v1/conversations/[id]/media/route.ts",
"app/api/v1/webhook-sources/route.ts",
"app/api/v1/webhooks/in/[token]/route.ts",
"app/app/ai/agents/[id]/_components/TestPanel.tsx",
"components/inbox/media/media-utils.ts",
"lib/agent-engine/channel-adapter.ts",
"lib/agent-engine/cron/scheduler.ts",
"lib/agent-engine/edge/channel/waha-adapter.ts",
"lib/agent-engine/edge/crm/mcp-client.ts",
"lib/agent-engine/edge/crm/send-message.ts",
"lib/agent-engine/edge/crm/session-watchdog.ts",
"lib/agent-engine/edge/egress.ts",
"lib/agent-engine/env.ts",
"lib/agent-engine/health/circuit.ts",
"lib/agent-engine/obs/metrics.ts",
"lib/ai/dispatcher/triggers.ts",
"lib/ai/runtime/finalize.ts",
"lib/automation/start-conversation.ts",
"lib/env.ts",
"lib/followup/reactivity.ts",
"lib/messaging/media/types.ts",
"lib/messaging/media/waha-source.ts",
"lib/schemas/channels.ts",
"lib/supabase/admin.ts",
"lib/types/messaging.ts",
"lib/webhooks/secrets.ts",
"workers/agent-worker/main.ts",
"workers/ai-response-worker.ts",
],
},
{
reason:
"`lib/i18n/dicionario.ts` guarda, como CHAVE de tradução, a cópia de tela " +
"verbatim das rotas do grupo 'Texto VISÍVEL ao usuário' acima " +
"(`app/api/v1/onboarding/whatsapp/session/route.ts` e pares) — a varredura " +
"de cobertura do espanhol centraliza toda string visível para traduzir, " +
"dívida já registrada incluída. Reescrever essa cópia para tirar o nome do " +
"provider é a MESMA mudança de comportamento observável que a entrada " +
"irmã já recusa fazer nas Fases 0–2 — só que agora duplicada aqui porque " +
"o dicionário é espelho, não fonte. Sai junto com a Fase 3 do seam, quando " +
"a cópia de tela na fonte deixar de nomear o provider.",
files: ["lib/i18n/dicionario.ts"],
},
];
const DEBT = new Set(KNOWN_DEBT.flatMap((g) => g.files));
/**
* Caminhos SEMPRE em barra normal.
*
* `join()` devolve barra invertida no Windows, e as duas listas contra as
* quais estes caminhos são comparados usam barra normal: `ALLOWED` é regex
* ancorada em `^lib/channels/`, e `DEBT` é um Set de strings. Falhava nas
* DUAS pontas — nada era permitido, e nada era reconhecido como dívida já
* registrada —, e o script acusava 155 arquivos numa árvore limpa. No CI
* (Linux) passa, então só quem contribui do Windows via.
*/
function walk(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = join(dir, e.name);
if (e.isDirectory()) return e.name === "node_modules" ? [] : walk(p);
return /\.tsx?$/.test(e.name) ? [p.split(sep).join("/")] : [];
});
}
/**
* As linhas que nomeiam provider, para o erro dizer ONDE — sem isto, quem leva o
* vermelho abre um arquivo de 200 linhas sabendo só que "alguma" delas nomeia.
* Medido em 19/09: um maestro apontou a linha errada ao executor, DUAS vezes, e
* a segunda veio marcada como urgente.
*/
function ondeNomeia(conteudo: string): { linha: number; texto: string }[] {
return conteudo
.split("\n")
.map((texto, i) => ({ linha: i + 1, texto: texto.trim() }))
.filter(({ texto }) => nomeiaProvider(texto))
.map(({ linha, texto }) => ({
linha,
texto: texto.length > 96 ? `${texto.slice(0, 96)}…` : texto,
}));
}
const offenders = ROOTS.flatMap(walk)
.filter((f) => !ALLOWED.some((re) => re.test(f)))
.filter((f) => nomeiaProvider(readFileSync(f, "utf8")));
const novos = offenders.filter((f) => !DEBT.has(f));
const stale = [...DEBT].filter((f) => !offenders.includes(f)).sort();
if (novos.length) {
console.error(
"Nome de provider fora de lib/channels/ (doutrina restricao-de-canal, invariante 1):",
);
for (const f of novos.sort()) {
console.error(` ${f}`);
for (const { linha, texto } of ondeNomeia(readFileSync(f, "utf8"))) {
console.error(` linha ${linha}: ${texto}`);
}
}
console.error(
"\nPergunte uma CAPACIDADE (`capabilitiesOf`), peça o adapter (`getAdapter`) ou o\n" +
"identificador da sessão (`resolveSessionRef`) — nunca nomeie o provider.\n" +
"\nSe a linha acusada for um COMENTÁRIO: a cerca não distingue comentário de\n" +
"código, e isso é deliberado — separar exigiria remover comentários antes de\n" +
"casar, e um removedor de comentários apaga `\"http://waha:3000\"` e as URLs do\n" +
"Graph que moram DENTRO de string, criando falso negativo (a mesma classe da\n" +
"issue #118). Reescreva a prosa sem o nome: cite a seção da doutrina pelo\n" +
"assunto (`a seção de anti-banimento`), não pelo provider.",
);
}
if (stale.length) {
console.error(
"\nEntradas de KNOWN_DEBT que já não vazam (ou o arquivo sumiu) — apague-as de\n" +
"scripts/lint-channels.ts para a catraca não afrouxar:",
);
for (const f of stale) console.error(` ${f}`);
}
if (novos.length || stale.length) process.exit(1);
console.info(`lint-channels: ok (${DEBT.size} arquivos de dívida conhecida, nenhum novo)`);