|
| 1 | +/** |
| 2 | + * O SELO DE TRADUÇÃO — manifesto, hash e leitura, sem I/O e sem `process.exit`. |
| 3 | + * |
| 4 | + * ## O problema que o selo resolve |
| 5 | + * |
| 6 | + * Documento traduzido não tem catraca nenhuma. O original é corrigido, as |
| 7 | + * cópias ficam para trás, e ninguém percebe: markdown errado compila, e uma |
| 8 | + * tradução defasada parece exatamente igual a uma tradução em dia. O rodapé de |
| 9 | + * `docs/white-label.md` chegou a declarar isso como dívida — *"sem um gate que |
| 10 | + * reprove tradução defasada, três cópias divergem no primeiro conserto |
| 11 | + * seguinte"* — e declarar não conserta. |
| 12 | + * |
| 13 | + * O selo é o hash do original gravado na primeira linha da tradução. Editar o |
| 14 | + * original muda o hash; a tradução ainda carrega o antigo; `pnpm test:unit` |
| 15 | + * reprova nomeando o comando de re-selar. É a mesma disciplina de |
| 16 | + * `tests/unit/manifest-x-migrations.test.ts` (texto que precisa de catraca) e de |
| 17 | + * `lib/channels/meta/contract-hash.ts` (hash como âncora de obsolescência). |
| 18 | + * |
| 19 | + * ## Por que é um módulo separado do script |
| 20 | + * |
| 21 | + * Precedente medido no repo: `scripts/inventario-de-telas.leitura.ts` e |
| 22 | + * `scripts/lint-channels.pattern.ts` existem porque os scripts deles varrem o |
| 23 | + * disco e chamam `process.exit` no topo do módulo — importá-los de um teste |
| 24 | + * rodaria o script. Mesma restrição aqui: `tests/unit/traducao-nao-defasa.test.ts` |
| 25 | + * precisa do MESMO manifesto e do MESMO hash que `scripts/selar-traducao.ts` |
| 26 | + * usa. Duas listas em sincronia seriam o defeito que o selo existe para pegar. |
| 27 | + * |
| 28 | + * ## Por que só o par `white-label`, e não os READMEs |
| 29 | + * |
| 30 | + * Decisão declarada, com razão medida. Os três READMEs são o arquivo mais |
| 31 | + * editado do repositório (~30 KB cada, ~490 linhas): selá-los transformaria |
| 32 | + * cada conserto de README num PR bloqueado até duas re-traduções completas. O |
| 33 | + * desfecho realista disso não é "traduções em dia" — é alguém rodar |
| 34 | + * `selar-traducao.ts --todas` sem traduzir, que é o único jeito de o selo |
| 35 | + * morrer. Um gate que desenha o próprio mecanismo de derrota é pior que gate |
| 36 | + * nenhum, porque ainda parece proteção. `docs/white-label.md` tem 197 linhas e |
| 37 | + * baixa rotatividade: aqui o custo cabe. |
| 38 | + * |
| 39 | + * Acrescentar par a esta lista é decisão de quem vai pagar o custo, não zelo de |
| 40 | + * completude. |
| 41 | + */ |
| 42 | + |
| 43 | +import { createHash } from "node:crypto"; |
| 44 | + |
| 45 | +/** Um par original → tradução, sob selo. */ |
| 46 | +export type ParTraduzido = { |
| 47 | + /** Caminho do original, a partir da raiz do repo. É o que vai DENTRO do selo. */ |
| 48 | + readonly original: string; |
| 49 | + /** Caminho da tradução, a partir da raiz do repo. */ |
| 50 | + readonly traducao: string; |
| 51 | + /** Só para a mensagem de falha ficar legível. */ |
| 52 | + readonly idioma: string; |
| 53 | +}; |
| 54 | + |
| 55 | +export const TRADUCOES: readonly ParTraduzido[] = [ |
| 56 | + { original: "docs/white-label.md", traducao: "docs/white-label.en.md", idioma: "inglês" }, |
| 57 | + { original: "docs/white-label.md", traducao: "docs/white-label.es.md", idioma: "espanhol" }, |
| 58 | +]; |
| 59 | + |
| 60 | +/** O comando que conserta — a mensagem de falha SEMPRE o nomeia. */ |
| 61 | +export const COMANDO_DE_RESELO = "pnpm exec tsx scripts/selar-traducao.ts --todas"; |
| 62 | + |
| 63 | +/** |
| 64 | + * A primeira linha de toda tradução. |
| 65 | + * |
| 66 | + * O caminho do original entra no selo de propósito: sem ele, um selo copiado de |
| 67 | + * OUTRO arquivo passaria por todas as outras conferências (existe, casa a |
| 68 | + * regex, e o hash é de um original de verdade). É o modo de falha que a |
| 69 | + * asserção 3 do teste guarda. |
| 70 | + * |
| 71 | + * Comentário HTML porque o GitHub não renderiza nada — o leitor da tradução não |
| 72 | + * vê o selo, e quem edita o arquivo vê na primeira linha. |
| 73 | + */ |
| 74 | +export const SELO = /^<!-- traduzido-de: (\S+)@([0-9a-f]{12}) -->$/; |
| 75 | + |
| 76 | +/** Monta a linha do selo. Fonte única: o script grava o que a regex lê. */ |
| 77 | +export function linhaDoSelo(original: string, hash: string): string { |
| 78 | + return `<!-- traduzido-de: ${original}@${hash} -->`; |
| 79 | +} |
| 80 | + |
| 81 | +/** Lê a linha 1 de uma tradução. `null` = não é um selo. */ |
| 82 | +export function lerSelo(conteudo: string): { original: string; hash: string } | null { |
| 83 | + const primeira = conteudo.split("\n", 1)[0] ?? ""; |
| 84 | + const m = SELO.exec(primeira.trimEnd()); |
| 85 | + return m?.[1] && m[2] ? { original: m[1], hash: m[2] } : null; |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * O hash do original — 12 hex de um SHA-256 do conteúdo NORMALIZADO. |
| 90 | + * |
| 91 | + * A normalização não é enfeite: sem ela, um editor configurado com CRLF ou um |
| 92 | + * `format` que apara espaço à direita reprova as duas traduções sem que uma |
| 93 | + * palavra do texto tenha mudado. Gate que acende sem defeito é gate que se |
| 94 | + * aprende a re-selar no automático — exatamente o que este arquivo evita. |
| 95 | + * |
| 96 | + * Normaliza o que NÃO muda o texto lido: fim de linha, espaço à direita de cada |
| 97 | + * linha e linhas em branco no fim do arquivo. Qualquer outra diferença — uma |
| 98 | + * vírgula, uma palavra, um parágrafo novo — muda o hash, que é o ponto. |
| 99 | + * |
| 100 | + * 12 hex (48 bits) porque o selo é lido por humano na primeira linha do arquivo |
| 101 | + * e o adversário aqui é o esquecimento, não a colisão adversarial. |
| 102 | + */ |
| 103 | +export function hashDoOriginal(conteudo: string): string { |
| 104 | + const normalizado = conteudo |
| 105 | + .replace(/\r\n?/g, "\n") |
| 106 | + .split("\n") |
| 107 | + .map((linha) => linha.replace(/[ \t]+$/, "")) |
| 108 | + .join("\n") |
| 109 | + .replace(/\n+$/, "\n"); |
| 110 | + return createHash("sha256").update(normalizado, "utf8").digest("hex").slice(0, 12); |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * Controle positivo: o manifesto tem de estar vivo antes de a ausência de |
| 115 | + * achado valer alguma coisa. |
| 116 | + * |
| 117 | + * Lista vazia devolveria "nenhuma tradução defasada" — indistinguível de "não |
| 118 | + * há tradução nenhuma sob gate". É o mesmo modo de falha de `conferirVacuidade` |
| 119 | + * em `inventario-de-telas.leitura.ts`, e por isso LANÇA em vez de devolver |
| 120 | + * `false`: quem esquecer de chamar não fica verde por omissão. |
| 121 | + */ |
| 122 | +export function conferirVacuidade(pares: readonly ParTraduzido[]): void { |
| 123 | + if (pares.length === 0) { |
| 124 | + throw new Error( |
| 125 | + "TRADUCOES está vazio — o gate de tradução não está medindo nada. " + |
| 126 | + "Se a intenção foi remover um par, remova também o teste que o cita.", |
| 127 | + ); |
| 128 | + } |
| 129 | +} |
0 commit comments