Spaces:
Runtime error
Runtime error
| // scripts/check/check-test-masking.mjs | |
| // Gate anti test-masking (a preocupação nº1 do CLAUDE.md: "subagente não pode | |
| // enfraquecer/remover asserts pra ficar verde"). Para cada arquivo de teste MODIFICADO | |
| // num PR, compara a contagem de asserts base vs HEAD: sinaliza REMOÇÃO LÍQUIDA de asserts | |
| // e NOVAS tautologias `assert.ok(true)`. Heurístico mas alto-sinal. Espelha o plumbing | |
| // de check-pr-test-policy.mjs (diff base...HEAD); no-op fora de contexto de PR. | |
| // | |
| // v2 (6A.10): acrescenta 3 novos subchecks: | |
| // 1. Arquivos de teste DELETADOS: --diff-filter=MDR com detecção de rename. | |
| // 2. Aumento líquido de .skip/.todo/.only/{skip:true}: esconde asserts sem remover. | |
| // 3. Tautologias extras: expect(true).toBe(true), assert.equal(1,1), assert.ok(true). | |
| import fs from "node:fs"; | |
| import { execFileSync } from "node:child_process"; | |
| import { pathToFileURL } from "node:url"; | |
| const TEST_RE = /\.(test|spec)\.(ts|tsx)$/; | |
| /** Conta chamadas de assert.*( / assert( / expect( . */ | |
| export function countAssertions(src) { | |
| const a = (src.match(/\bassert\b\s*[.(]/g) || []).length; | |
| const e = (src.match(/\bexpect\s*\(/g) || []).length; | |
| return a + e; | |
| } | |
| /** Conta tautologias assert.ok(true). */ | |
| export function countTautologies(src) { | |
| return (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length; | |
| } | |
| /** | |
| * (6A.10 subcheck 2) Conta marcadores de skip/todo/only que silenciam testes: | |
| * - .skip(, .todo(, .only( — em qualquer runner (node:test, jest, vitest) | |
| * - { skip: true } — opção de objeto node:test | |
| */ | |
| export function countSkips(src) { | |
| const modifiers = (src.match(/\.\s*(?:skip|todo|only)\s*\(/g) || []).length; | |
| const skipOpt = (src.match(/\{\s*skip\s*:\s*true\s*\}/g) || []).length; | |
| return modifiers + skipOpt; | |
| } | |
| /** | |
| * (6A.10 subcheck 3) Conta tautologias que mantêm os asserts no texto mas nunca | |
| * verificam nada real: | |
| * - expect(true).toBe(true) | |
| * - assert.equal(1, 1) / assert.strictEqual(1, 1) | |
| * - assert.ok(true) (já coberto por countTautologies; incluído aqui para completude) | |
| */ | |
| export function countExtendedTautologies(src) { | |
| let count = 0; | |
| // expect(true).toBe(true) | |
| count += (src.match(/\bexpect\s*\(\s*true\s*\)\s*\.\s*toBe\s*\(\s*true\s*\)/g) || []).length; | |
| // assert.equal(1, 1) / assert.strictEqual(1, 1) — literal numeric identity | |
| count += (src.match(/\bassert\s*\.\s*(?:strict)?[Ee]qual\s*\(\s*1\s*,\s*1\s*\)/g) || []).length; | |
| // assert.ok(true) | |
| count += (src.match(/\bassert\s*\.\s*ok\s*\(\s*true\s*\)/g) || []).length; | |
| return count; | |
| } | |
| /** | |
| * (6A.10 subcheck 1) Sinaliza arquivos de teste DELETADOS ou renomeados-e-não- | |
| * substituídos. Recebe lista de paths de arquivos de teste que foram deletados | |
| * (filtro D do git diff --diff-filter=MDR). | |
| */ | |
| export function evaluateDeletedFiles(deletedPaths) { | |
| const flags = []; | |
| for (const f of deletedPaths) { | |
| if (TEST_RE.test(f)) { | |
| flags.push( | |
| `${f}: arquivo de teste deletado — revisão humana obrigatória (mascaramento alto-sinal)` | |
| ); | |
| } | |
| } | |
| return flags; | |
| } | |
| /** | |
| * Parse `git diff --name-status -M --diff-filter=DR` output, separating TRUE | |
| * test-file deletions ("D\tpath") from RENAMES ("R<score>\told\tnew"). | |
| * | |
| * A rename whose destination is still a test file is a *relocation* (the test | |
| * was substituted at a new path, not removed) — per this file's subcheck-1 | |
| * contract it must NOT be treated as a deletion; the assert-reduction check | |
| * still runs across the rename to catch gutting-via-rename. A rename that lands | |
| * OUTSIDE test scope (test → non-test) removes the test and is treated as a | |
| * deletion. Returns test-file paths only. | |
| */ | |
| export function partitionDeletedRenamed(nameStatusOutput) { | |
| const deletedTests = []; | |
| const renames = []; | |
| for (const line of (nameStatusOutput || "").split("\n")) { | |
| if (!line.trim()) continue; | |
| const parts = line.split("\t").map((s) => s.trim()); | |
| const status = parts[0] || ""; | |
| if (status.startsWith("D")) { | |
| if (TEST_RE.test(parts[1] || "")) deletedTests.push(parts[1]); | |
| } else if (status.startsWith("R")) { | |
| const from = parts[1] || ""; | |
| const to = parts[2] || ""; | |
| if (TEST_RE.test(from)) renames.push({ from, to }); | |
| } | |
| } | |
| return { deletedTests, renames }; | |
| } | |
| /** | |
| * Avalia por-arquivo: flag em remoção líquida de asserts, nova tautologia, | |
| * aumento líquido de skips, ou nova tautologia extendida. | |
| * | |
| * Cada entrada de perFile deve ter: | |
| * { file, baseAsserts, headAsserts, baseTaut, headTaut, | |
| * baseSkips, headSkips, baseExtTaut, headExtTaut } | |
| * | |
| * Os campos de skip e extTaut são opcionais (default 0) para compatibilidade | |
| * com chamadas legadas que só passam baseAsserts/headAsserts/baseTaut/headTaut. | |
| */ | |
| export function evaluateMasking(perFile, assertReductionAllowlist = new Set()) { | |
| const flags = []; | |
| for (const f of perFile) { | |
| const baseSkips = f.baseSkips ?? 0; | |
| const headSkips = f.headSkips ?? 0; | |
| const baseExtTaut = f.baseExtTaut ?? 0; | |
| const headExtTaut = f.headExtTaut ?? 0; | |
| // The net-assert-REDUCTION signal can be allowlisted per file when the reduction is a | |
| // verified-legitimate refactor/field-removal (config/quality/test-masking-allowlist.json). | |
| // The tautology / skip / deletion signals below are NEVER allowlisted. | |
| if (f.headAsserts < f.baseAsserts && !assertReductionAllowlist.has(f.file)) | |
| flags.push( | |
| `${f.file}: asserts ${f.baseAsserts} → ${f.headAsserts} (REMOÇÃO de ${f.baseAsserts - f.headAsserts} — enfraquecimento?)` | |
| ); | |
| if (f.headTaut > f.baseTaut) | |
| flags.push(`${f.file}: nova(s) ${f.headTaut - f.baseTaut} tautologia(s) assert.ok(true)`); | |
| if (headSkips > baseSkips) | |
| flags.push( | |
| `${f.file}: ${headSkips - baseSkips} novo(s) .skip/.todo/.only (asserts silenciados sem remoção)` | |
| ); | |
| if (headExtTaut > baseExtTaut) | |
| flags.push( | |
| `${f.file}: nova(s) ${headExtTaut - baseExtTaut} tautologia(s) estendida(s) (expect(true).toBe(true) / assert.equal(1,1))` | |
| ); | |
| } | |
| return flags; | |
| } | |
| function git(args) { | |
| try { | |
| return execFileSync("git", args, { encoding: "utf8" }); | |
| } catch { | |
| return ""; | |
| } | |
| } | |
| function resolveBase() { | |
| if (process.env.GITHUB_BASE_SHA) return process.env.GITHUB_BASE_SHA; | |
| if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`; | |
| return null; | |
| } | |
| function main() { | |
| const base = resolveBase(); | |
| if (!base) { | |
| console.log("[test-masking] sem base ref (não é PR) — pulando."); | |
| return; | |
| } | |
| // (6A.10 subcheck 1) Arquivos de teste deletados/renomeados via MDR filter. | |
| // Renames test→test são RELOCAÇÕES (substituição) e passam pela verificação de | |
| // redução de asserts abaixo (gutting-via-rename ainda flaga); só deleções reais | |
| // e renames test→não-teste contam como remoção de teste. | |
| const { deletedTests, renames } = partitionDeletedRenamed( | |
| git(["diff", "--name-status", "-M", "--diff-filter=DR", `${base}...HEAD`]) | |
| ); | |
| const relocatedOutOfTest = []; | |
| const renamePerFile = []; | |
| for (const { from, to } of renames) { | |
| if (!TEST_RE.test(to)) { | |
| // test → non-test: the test was removed from coverage. | |
| relocatedOutOfTest.push(from); | |
| continue; | |
| } | |
| // test → test: compare the original (base) against the relocated (head) file so | |
| // a clean relocation passes but a rename that drops asserts/adds tautologies fires. | |
| const baseSrc = git(["show", `${base}:${from}`]); | |
| const headSrc = fs.existsSync(to) ? fs.readFileSync(to, "utf8") : ""; | |
| renamePerFile.push({ | |
| file: to, | |
| baseAsserts: countAssertions(baseSrc), | |
| headAsserts: countAssertions(headSrc), | |
| baseTaut: countTautologies(baseSrc), | |
| headTaut: countTautologies(headSrc), | |
| baseSkips: countSkips(baseSrc), | |
| headSkips: countSkips(headSrc), | |
| baseExtTaut: countExtendedTautologies(baseSrc), | |
| headExtTaut: countExtendedTautologies(headSrc), | |
| }); | |
| } | |
| const deletedFlags = evaluateDeletedFiles([...deletedTests, ...relocatedOutOfTest]); | |
| // Arquivos de teste modificados (subcheck original + skips + extTaut) | |
| const changed = git(["diff", "--name-only", "--diff-filter=M", `${base}...HEAD`]) | |
| .split("\n") | |
| .map((s) => s.trim()) | |
| .filter((f) => TEST_RE.test(f) && fs.existsSync(f)); | |
| const perFile = [...renamePerFile]; | |
| for (const file of changed) { | |
| const baseSrc = git(["show", `${base}:${file}`]); | |
| const headSrc = fs.readFileSync(file, "utf8"); | |
| perFile.push({ | |
| file, | |
| baseAsserts: countAssertions(baseSrc), | |
| headAsserts: countAssertions(headSrc), | |
| baseTaut: countTautologies(baseSrc), | |
| headTaut: countTautologies(headSrc), | |
| baseSkips: countSkips(baseSrc), | |
| headSkips: countSkips(headSrc), | |
| baseExtTaut: countExtendedTautologies(baseSrc), | |
| headExtTaut: countExtendedTautologies(headSrc), | |
| }); | |
| } | |
| // Per-file allowlist for verified-legitimate net-assert reductions (refactor/field-removal). | |
| // Only exempts the reduction signal; tautology/skip/deletion signals still fire. | |
| let assertReductionAllowlist = new Set(); | |
| try { | |
| const raw = JSON.parse(fs.readFileSync("config/quality/test-masking-allowlist.json", "utf8")); | |
| assertReductionAllowlist = new Set(Object.keys(raw).filter((k) => !k.startsWith("_"))); | |
| } catch { | |
| // no allowlist file — treat as empty | |
| } | |
| const maskingFlags = evaluateMasking(perFile, assertReductionAllowlist); | |
| const allFlags = [...deletedFlags, ...maskingFlags]; | |
| if (allFlags.length) { | |
| console.error( | |
| `[test-masking] ${allFlags.length} sinal(is) de enfraquecimento de teste:\n` + | |
| allFlags.map((f) => " ✗ " + f).join("\n") + | |
| `\n → se a redução é legítima (refator/consolidação), explique no PR; senão, restaure os asserts.` | |
| ); | |
| process.exit(1); | |
| } | |
| console.log( | |
| `[test-masking] OK — ${changed.length} modificado(s), ${renames.length} renomeado(s) (relocação), ` + | |
| `${deletedTests.length} deletado(s) — sem enfraquecimento` | |
| ); | |
| } | |
| if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); | |