484 lines
19 KiB
JavaScript
484 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
// 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)$/;
|
|
// Production TypeScript sources (excludes test files, handled separately via TEST_RE).
|
|
const PROD_SRC_RE = /\.(ts|tsx|mts|cts)$/;
|
|
|
|
/** 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;
|
|
}
|
|
|
|
// ─── (6348) Subcheck 4: inline-reimplemented prod conditions (REPORT-ONLY) ───
|
|
// A test that copies a conditional expression out of production code (instead of
|
|
// importing and exercising the symbol that owns it) is the wrong-shape-contract-test
|
|
// class (#6216): the assertion re-encodes the branch locally, so it stays green even
|
|
// when the real prod condition drifts. This subcheck is a HEURISTIC, textual gate
|
|
// mirroring the count* helpers above — it never parses an AST. It is REPORT-ONLY for
|
|
// now (warns, does not fail the gate).
|
|
|
|
/** Collapse all runs of whitespace to a single space and trim. */
|
|
function normalizeWhitespace(s) {
|
|
return (s || "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
/**
|
|
* Count "significant" tokens in a normalized condition. Single-char identifiers
|
|
* (`x`, `i`) and single-digit numeric literals (`0`, `1`) are treated as noise and
|
|
* NOT counted; operators and multi-char identifiers/numbers ARE. This is what makes
|
|
* `x > 0` trivial (1 significant token) while `status >= 500` is meaningful (3):
|
|
* - `status >= 500` → status, >=, 500 → 3
|
|
* - `x === LIMIT && y` → ===, LIMIT, && → 3
|
|
* - `x > 0` → > → 1
|
|
*/
|
|
export function countSignificantTokens(cond) {
|
|
const tokens =
|
|
(cond || "").match(
|
|
/===|!==|==|!=|>=|<=|&&|\|\||[<>+\-*/%!]|[A-Za-z_$][\w$]*|\d+(?:\.\d+)?/g
|
|
) || [];
|
|
let count = 0;
|
|
for (const tk of tokens) {
|
|
if (/^[A-Za-z_$]/.test(tk)) {
|
|
if (tk.length >= 2) count++; // multi-char identifier
|
|
} else if (/^\d/.test(tk)) {
|
|
if (tk.length >= 2) count++; // multi-digit number
|
|
} else {
|
|
count++; // operator
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/** A condition is "meaningful" when it carries ≥3 significant tokens. */
|
|
function isSignificantCondition(cond) {
|
|
return countSignificantTokens(cond) >= 3;
|
|
}
|
|
|
|
/**
|
|
* Extract meaningful (≥3-token) conditional expressions from a production source,
|
|
* paired with the nearest enclosing declared symbol that "owns" them. Covers
|
|
* `if (...)` (via paren balancing) and comparison-bearing ternaries (`a === b ? … : …`).
|
|
* Returns [{ condition (whitespace-normalized), owner }].
|
|
*/
|
|
export function extractProdConditions(src) {
|
|
const results = [];
|
|
if (!src) return results;
|
|
|
|
// Declarations (function / const / let / var) with their positions, so each
|
|
// condition can be attributed to the symbol whose body it lives in.
|
|
const decls = [];
|
|
const declRe =
|
|
/(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g;
|
|
let dm;
|
|
while ((dm = declRe.exec(src))) {
|
|
decls.push({ index: dm.index, name: dm[1] || dm[2] });
|
|
}
|
|
const ownerAt = (idx) => {
|
|
let owner = "";
|
|
for (const d of decls) {
|
|
if (d.index <= idx) owner = d.name;
|
|
else break;
|
|
}
|
|
return owner;
|
|
};
|
|
|
|
const seen = new Set();
|
|
const pushCond = (raw, owner) => {
|
|
const norm = normalizeWhitespace(raw);
|
|
if (!norm || seen.has(norm) || !isSignificantCondition(norm)) return;
|
|
seen.add(norm);
|
|
results.push({ condition: norm, owner });
|
|
};
|
|
|
|
// if (...) — balance parentheses to capture the full condition.
|
|
const ifRe = /\bif\s*\(/g;
|
|
let m;
|
|
while ((m = ifRe.exec(src))) {
|
|
let depth = 1;
|
|
let i = m.index + m[0].length;
|
|
for (; i < src.length && depth > 0; i++) {
|
|
const ch = src[i];
|
|
if (ch === "(") depth++;
|
|
else if (ch === ")") depth--;
|
|
}
|
|
pushCond(src.slice(m.index + m[0].length, i - 1), ownerAt(m.index));
|
|
}
|
|
|
|
// Comparison-bearing ternaries: `<lhs> <cmp> <rhs> ? … : …` (best-effort, low-noise).
|
|
const ternRe =
|
|
/([A-Za-z_$][\w$).\]]*\s*(?:===|!==|==|!=|>=|<=|>|<)\s*[^?;{}\n]+?)\s*\?/g;
|
|
let t;
|
|
while ((t = ternRe.exec(src))) {
|
|
pushCond(t[1], ownerAt(t.index));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Collect the identifiers/module specifiers a test file imports, so we can tell
|
|
* whether it exercises a prod symbol through the real import (clean) or merely
|
|
* re-implements one of its conditions locally (masked). Returns a Set of names:
|
|
* imported bindings, module paths, and module basenames.
|
|
*/
|
|
export function extractImports(src) {
|
|
const names = new Set();
|
|
if (!src) return names;
|
|
const addModule = (mod) => {
|
|
names.add(mod);
|
|
const base = mod.split("/").pop().replace(/\.\w+$/, "");
|
|
if (base) names.add(base);
|
|
};
|
|
let m;
|
|
const importRe = /import\s+(?:type\s+)?([^;]*?)\s+from\s+['"]([^'"]+)['"]/g;
|
|
while ((m = importRe.exec(src))) {
|
|
addModule(m[2]);
|
|
for (const id of m[1].match(/[A-Za-z_$][\w$]*/g) || []) {
|
|
if (id !== "as" && id !== "type") names.add(id);
|
|
}
|
|
}
|
|
const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
while ((m = dynRe.exec(src))) addModule(m[1]);
|
|
const reqRe = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
while ((m = reqRe.exec(src))) addModule(m[1]);
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* PURE core of subcheck 4. Given the sources of the prod files changed in a PR,
|
|
* one test file's source, and the set of names that test imports: return the prod
|
|
* conditions the test re-implements textually WITHOUT importing the symbol that owns
|
|
* them. Whitespace is squashed on both sides so spacing differences never mask a hit.
|
|
* Returns [{ condition, owner }].
|
|
*/
|
|
export function findReimplementedConditions(prodSources, testSource, testImports) {
|
|
const flags = [];
|
|
if (!testSource) return flags;
|
|
const imports =
|
|
testImports instanceof Set ? testImports : new Set(testImports || []);
|
|
const squash = (s) => (s || "").replace(/\s+/g, "");
|
|
const testSq = squash(testSource);
|
|
const seen = new Set();
|
|
for (const prod of prodSources || []) {
|
|
for (const { condition, owner } of extractProdConditions(prod)) {
|
|
if (owner && imports.has(owner)) continue; // exercised through the real import
|
|
if (seen.has(condition)) continue;
|
|
if (testSq.includes(squash(condition))) {
|
|
seen.add(condition);
|
|
flags.push({ condition, owner: owner || null });
|
|
}
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
/**
|
|
* (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).
|
|
*
|
|
* `deletionAllowlist` (`_deletedWithReplacement` no test-masking-allowlist.json)
|
|
* isenta uma deleção SOMENTE quando o substituto declarado existe no HEAD e é
|
|
* ele próprio um arquivo de teste — o caso "reescrito em outro path sem rename
|
|
* detectável" (conteúdo novo demais para o -M do git). Qualquer entrada cujo
|
|
* substituto não exista ou não seja teste continua flagada.
|
|
*/
|
|
export function evaluateDeletedFiles(
|
|
deletedPaths,
|
|
deletionAllowlist = {},
|
|
fileExists = fs.existsSync
|
|
) {
|
|
const flags = [];
|
|
for (const f of deletedPaths) {
|
|
if (!TEST_RE.test(f)) continue;
|
|
const entry = deletionAllowlist[f];
|
|
if (entry && typeof entry.replacement === "string") {
|
|
if (TEST_RE.test(entry.replacement) && fileExists(entry.replacement)) continue;
|
|
flags.push(
|
|
`${f}: deleção allowlistada mas o substituto declarado (${entry.replacement}) não existe ou não é arquivo de teste`
|
|
);
|
|
continue;
|
|
}
|
|
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),
|
|
});
|
|
}
|
|
|
|
// 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();
|
|
let deletionAllowlist = {};
|
|
let reimplementedAllowlist = 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("_")));
|
|
deletionAllowlist = raw._deletedWithReplacement || {};
|
|
reimplementedAllowlist = new Set(raw._reimplementedConditions || []);
|
|
} catch {
|
|
// no allowlist file — treat as empty
|
|
}
|
|
|
|
// (6348 subcheck 4, REPORT-ONLY) Tests that inline-reimplement a prod condition
|
|
// instead of importing the symbol that owns it. Prod files changed in this PR
|
|
// (added/copied/modified TS sources) are the reference corpus; each changed test
|
|
// file is scanned against them. Warns only — it never fails the gate for now.
|
|
const prodChanged = git(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter((f) => PROD_SRC_RE.test(f) && !TEST_RE.test(f) && fs.existsSync(f));
|
|
const prodSources = prodChanged.map((f) => {
|
|
try {
|
|
return fs.readFileSync(f, "utf8");
|
|
} catch {
|
|
return "";
|
|
}
|
|
});
|
|
const changedTests = git(["diff", "--name-only", "--diff-filter=ACM", `${base}...HEAD`])
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter((f) => TEST_RE.test(f) && fs.existsSync(f));
|
|
const reimplementedFlags = [];
|
|
if (prodSources.length) {
|
|
for (const tf of changedTests) {
|
|
if (reimplementedAllowlist.has(tf)) continue;
|
|
const src = fs.readFileSync(tf, "utf8");
|
|
for (const hit of findReimplementedConditions(prodSources, src, extractImports(src))) {
|
|
reimplementedFlags.push(
|
|
`${tf}: re-implementa a condição \`${hit.condition}\`` +
|
|
(hit.owner ? ` (dona: ${hit.owner})` : "") +
|
|
" — asserte através do import real em vez de copiar a condição"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (reimplementedFlags.length) {
|
|
console.warn(
|
|
`[test-masking] (report-only) ${reimplementedFlags.length} teste(s) re-implementam ` +
|
|
`condição de produção em vez de importar o símbolo dono (classe #6216):\n` +
|
|
reimplementedFlags.map((f) => " ⚠ " + f).join("\n") +
|
|
`\n → importe o símbolo/função dono e asserte através dele (evita contrato duplicado ` +
|
|
`que diverge silenciosamente). Report-only por enquanto — não falha o gate.`
|
|
);
|
|
}
|
|
|
|
const deletedFlags = evaluateDeletedFiles(
|
|
[...deletedTests, ...relocatedOutOfTest],
|
|
deletionAllowlist
|
|
);
|
|
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();
|