mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 09:34:46 +08:00
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>
296 lines
14 KiB
TypeScript
296 lines
14 KiB
TypeScript
/**
|
||
* 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)`);
|