Release v3.8.24 (#3747)

Release v3.8.24 — see CHANGELOG.md [3.8.24] for the full notes and the PR description for the contributors hall. Integration of release/v3.8.24 into main.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-13 17:27:40 -03:00
committed by GitHub
parent 420d62b420
commit 76a07cf7a5
308 changed files with 22331 additions and 5873 deletions

View File

@@ -2,6 +2,8 @@
// scripts/quality/check-quality-ratchet.mjs
// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs:
// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido.
//
// v2 (6A.5): --require-tighten, eps por métrica, warning de métricas órfãs.
import fs from "node:fs";
import path from "node:path";
@@ -18,7 +20,13 @@ const UPDATE = process.argv.includes("--update");
// Uso local: cobertura só existe no CI; localmente quality:gate roda com este flag.
// No CI o job quality-gate roda SEM o flag (estrito — baixa o coverage mergeado antes).
const ALLOW_MISSING = process.argv.includes("--allow-missing");
const EPS = 0.01;
// --require-tighten: falha quando uma métrica melhorou além de tightenSlack sem que o
// baseline tenha sido apertado. Garante que melhorias permanentes sejam capturadas.
// Sem esta flag, melhorias são apenas registradas (comportamento v1 — retrocompat).
const REQUIRE_TIGHTEN = process.argv.includes("--require-tighten");
// Global fallback eps. Cada métrica pode sobrepor via `eps` no baseline.
const GLOBAL_EPS = 0.01;
function load(p) {
if (!fs.existsSync(p)) {
@@ -31,6 +39,7 @@ function load(p) {
const baseline = load(BASELINE);
const metrics = load(METRICS);
const failures = [];
const tightenFailures = [];
const improvements = [];
const rows = [];
@@ -38,6 +47,13 @@ for (const [key, spec] of Object.entries(baseline.metrics)) {
const current = metrics[key];
const base = spec.value;
const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor
// Per-metric eps; falls back to global EPS if not specified in the spec.
const eps = spec.eps !== undefined ? spec.eps : GLOBAL_EPS;
// Per-metric tightenSlack; falls back to eps (same tolerance as regression check).
const tightenSlack = spec.tightenSlack !== undefined ? spec.tightenSlack : eps;
if (current === undefined) {
if (ALLOW_MISSING) {
rows.push([key, base, "—", "SKIP (ausente)"]);
@@ -49,25 +65,47 @@ for (const [key, spec] of Object.entries(baseline.metrics)) {
}
let status = "ok";
if (dir === "down") {
if (current > base + EPS) {
if (current > base + eps) {
failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`);
status = "REGRESSÃO";
} else if (current < base - EPS) {
} else if (current < base - eps) {
improvements.push([key, current]);
status = "↑ melhorou";
if (REQUIRE_TIGHTEN && base - current > tightenSlack) {
tightenFailures.push(
`${key}: melhorou de ${base} para ${current} (delta ${(base - current).toFixed(4)} > slack ${tightenSlack}) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`,
);
}
}
} else {
if (current < base - EPS) {
if (current < base - eps) {
failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`);
status = "REGRESSÃO";
} else if (current > base + EPS) {
} else if (current > base + eps) {
improvements.push([key, current]);
status = "↑ melhorou";
if (REQUIRE_TIGHTEN && current - base > tightenSlack) {
tightenFailures.push(
`${key}: melhorou de ${base} para ${current} (delta ${(current - base).toFixed(4)} > slack ${tightenSlack}) — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`,
);
}
}
}
rows.push([key, base, current, status]);
}
// Behavior 3: warn about orphan metrics (present in collected metrics but absent in baseline).
const baselineKeys = new Set(Object.keys(baseline.metrics));
const orphans = Object.keys(metrics).filter((k) => !baselineKeys.has(k));
if (orphans.length > 0) {
console.warn(
`[quality-ratchet] WARN: ${orphans.length} métrica(s) órfã(s) — presente(s) em ${path.basename(METRICS)} mas sem entrada no baseline: ${orphans.join(", ")}`,
);
console.warn(
`[quality-ratchet] WARN: adicione ${orphans.length === 1 ? "essa métrica" : "essas métricas"} ao baseline (com value/direction) para que sejam catraceadas.`,
);
}
if (SUMMARY) {
const md = [
"# Quality Ratchet",
@@ -84,6 +122,9 @@ if (SUMMARY) {
fs.writeFileSync(SUMMARY, md + "\n");
}
// Tighten check runs only when there are no regressions (regressions take priority).
// With --update, improvements are captured into the baseline, so tighten check
// is bypassed (the update itself is the required action).
if (UPDATE && failures.length === 0 && improvements.length) {
for (const [key, val] of improvements) baseline.metrics[key].value = val;
fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n");
@@ -94,4 +135,14 @@ if (failures.length) {
console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n"));
process.exit(1);
}
// Behavior 1: --require-tighten gate (only triggers when there are no regressions and no --update).
if (REQUIRE_TIGHTEN && !UPDATE && tightenFailures.length > 0) {
console.error(
"[quality-ratchet] FALHOU (--require-tighten): métrica(s) melhoraram mas o baseline não foi apertado:\n" +
tightenFailures.map((f) => " ✗ " + f).join("\n"),
);
process.exit(1);
}
console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`);

View File

@@ -2,9 +2,15 @@
// scripts/quality/collect-metrics.mjs — emite quality-metrics.json
// Coletores incrementais: Fase 1 traz ESLint warnings + cobertura.
// Fases 3/4 estendem com duplicação (jscpd), tamanho de arquivo e cobertura por módulo.
// Fase 6A.11: openapiCoverage.pct + i18nUiCoverage.pct (mínimo entre locales).
// Task 7.9: coverage.<modulo>.lines para ~8 módulos críticos, lidos do
// coverage/coverage-summary.json se existir (sem erro se ausente).
import fs from "node:fs";
import { promises as fsAsync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import yaml from "js-yaml";
const cwd = process.cwd();
const out = {};
@@ -37,7 +43,213 @@ function coverage() {
out["coverage.branches"] = t.branches.pct;
}
eslintCounts();
coverage();
fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n");
console.log("[collect-metrics]", JSON.stringify(out));
// 3) Coverage per critical module (Task 7.9)
// Reads coverage/coverage-summary.json (produced by `npm run test:coverage` via c8).
// If the file is absent → silently skips (no error). This allows the gate to
// function normally in environments where coverage was not run (e.g. lint-only CI).
//
// The summary JSON produced by c8 looks like:
// { "total": {...}, "/abs/path/to/file.ts": { lines: { pct: 78 }, ... }, ... }
//
// modulePaths is a record of { metricSuffix: relPathFromRoot[] } — the first
// matching key in the summary wins.
/**
* Pure function — extracts per-module line-coverage percentages from a
* coverage-summary.json object.
*
* @param {Record<string, { lines?: { pct: number } }>} summaryJson
* The parsed coverage-summary.json (keys are absolute file paths or "total").
* @param {Record<string, string[]>} modulePaths
* Map of { metricKey: [relPath, ...fallbacks] } where relPath is relative to
* the repo root (forward slashes). Returns the lines.pct of the first match.
* @param {string} repoRoot Absolute path to the repo root (used to build keys).
* @returns {Record<string, number>} Map of metricKey → lines.pct (0-100).
*/
export function extractModuleCoverage(summaryJson, modulePaths, repoRoot) {
const result = {};
// Build a normalised lookup: absolute path (forward slashes) → pct
const lookup = new Map();
for (const [rawKey, data] of Object.entries(summaryJson)) {
if (rawKey === "total") continue;
const norm = rawKey.replace(/\\/g, "/");
const pct = data?.lines?.pct;
if (typeof pct === "number") lookup.set(norm, pct);
}
const normRoot = repoRoot.replace(/\\/g, "/").replace(/\/$/, "");
for (const [metricKey, candidates] of Object.entries(modulePaths)) {
for (const rel of candidates) {
const abs = `${normRoot}/${rel.replace(/\\/g, "/").replace(/^\//, "")}`;
if (lookup.has(abs)) {
result[metricKey] = lookup.get(abs);
break;
}
}
}
return result;
}
/** The 8 critical modules tracked by Task 7.9 (relative paths from repo root). */
export const CRITICAL_MODULE_PATHS = {
"coverage.chatCore.lines": ["open-sse/handlers/chatCore.ts"],
"coverage.combo.lines": ["open-sse/services/combo.ts"],
"coverage.accountFallback.lines": ["open-sse/services/accountFallback.ts"],
"coverage.auth.lines": ["src/sse/services/auth.ts"],
"coverage.routeGuard.lines": ["src/server/authz/routeGuard.ts"],
"coverage.error.lines": ["open-sse/utils/error.ts"],
"coverage.publicCreds.lines": ["open-sse/utils/publicCreds.ts"],
"coverage.circuitBreaker.lines": ["src/shared/utils/circuitBreaker.ts"],
};
function coverageByModule() {
const p = path.join(cwd, "coverage", "coverage-summary.json");
if (!fs.existsSync(p)) return; // absent → skip silently (Task 7.9 spec)
let summaryJson;
try {
summaryJson = JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return; // malformed file → skip
}
const moduleMetrics = extractModuleCoverage(summaryJson, CRITICAL_MODULE_PATHS, cwd);
Object.assign(out, moduleMetrics);
}
// 4) OpenAPI coverage: percentage of implemented routes documented in openapi.yaml
function openapiCoverage() {
const API_ROOT = path.join(cwd, "src", "app", "api");
const OPENAPI_PATH = path.join(cwd, "docs", "reference", "openapi.yaml");
if (!fs.existsSync(API_ROOT) || !fs.existsSync(OPENAPI_PATH)) return;
function collectRoutePaths(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const paths = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
paths.push(...collectRoutePaths(fullPath));
continue;
}
if (entry.isFile() && entry.name === "route.ts") {
const apiPath = path
.dirname(fullPath)
.replace(API_ROOT, "")
.replace(/\[([^\]]+)\]/g, "{$1}");
paths.push(`/api${apiPath}`);
}
}
return paths;
}
function normalizePath(p) {
return p.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}").replace(/\[([^\]]+)\]/g, "{$1}");
}
const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath);
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const documentedPaths = new Set(Object.keys(raw.paths || {}));
const covered = implementedPaths.filter((p) => documentedPaths.has(p)).length;
const total = implementedPaths.length;
if (total > 0) out["openapiCoverage.pct"] = parseFloat(((covered / total) * 100).toFixed(1));
}
// 4) i18n UI coverage: minimum real coverage across all non-en locales
async function i18nUiCoverage() {
const MESSAGES_DIR = path.join(cwd, "src", "i18n", "messages");
const CONFIG_PATH = path.join(cwd, "config", "i18n.json");
const SOURCE_LOCALE = "en";
const PLACEHOLDER_PREFIX = "__MISSING__:";
if (!fs.existsSync(MESSAGES_DIR)) return;
const sourcePath = path.join(MESSAGES_DIR, `${SOURCE_LOCALE}.json`);
if (!fs.existsSync(sourcePath)) return;
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function collectLeafPaths(obj, prefix = []) {
const paths = [];
for (const [key, value] of Object.entries(obj)) {
const next = [...prefix, key];
if (isPlainObject(value)) {
paths.push(...collectLeafPaths(value, next));
} else {
paths.push(next);
}
}
return paths;
}
const FORBIDDEN_KEY_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
function lookupPath(obj, parts) {
let cur = obj;
for (const part of parts) {
if (!isPlainObject(cur)) return undefined;
if (FORBIDDEN_KEY_SEGMENTS.has(part)) return undefined;
if (!Object.prototype.hasOwnProperty.call(cur, part)) return undefined;
const entry = Object.entries(cur).find(([k]) => k === part);
cur = entry ? entry[1] : undefined;
}
return cur;
}
const source = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
const enPaths = collectLeafPaths(source);
const totalEn = enPaths.length;
if (totalEn === 0) return;
let configCodes = null;
if (fs.existsSync(CONFIG_PATH)) {
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
if (Array.isArray(cfg.locales)) configCodes = new Set(cfg.locales.map((l) => l.code));
} catch {
/* ignore */
}
}
const onDisk = (await fsAsync.readdir(MESSAGES_DIR))
.filter((f) => f.endsWith(".json") && f !== `${SOURCE_LOCALE}.json`)
.map((f) => f.slice(0, -5))
.filter((code) => (configCodes ? configCodes.has(code) : true));
let minCoverage = 100;
for (const locale of onDisk) {
const localePath = path.join(MESSAGES_DIR, `${locale}.json`);
let target;
try {
target = JSON.parse(fs.readFileSync(localePath, "utf8"));
} catch {
minCoverage = 0;
continue;
}
let present = 0;
let placeholder = 0;
for (const pathParts of enPaths) {
const value = lookupPath(target, pathParts);
if (value === undefined || isPlainObject(value)) continue;
present++;
if (typeof value === "string" && value.startsWith(PLACEHOLDER_PREFIX)) placeholder++;
}
const coverage = ((present - placeholder) / totalEn) * 100;
if (coverage < minCoverage) minCoverage = coverage;
}
if (onDisk.length > 0) out["i18nUiCoverage.pct"] = parseFloat(minCoverage.toFixed(1));
}
// Only run the collection pipeline when this file is executed directly.
// When imported (e.g. in tests), only the exported pure functions are available
// without triggering the expensive ESLint + i18n filesystem walks.
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
eslintCounts();
coverage();
coverageByModule();
openapiCoverage();
await i18nUiCoverage();
fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n");
console.log("[collect-metrics]", JSON.stringify(out));
}

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
// scripts/quality/run-all-gates.mjs
// Agregador paralelo de quality gates determinísticos.
// Roda os gates filesystem-only em paralelo (pool ~4), agrega resultados, e exibe
// uma tabela consolidada com {gate, status, durationMs}. Sai com código 1 se
// qualquer gate falhar. Alvo: < 3 min no total.
//
// Usage:
// node scripts/quality/run-all-gates.mjs # run all gates
// node scripts/quality/run-all-gates.mjs --fast # skip slow gates (duplication)
//
// Via npm: npm run quality:scan
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
const FAST_ONLY = process.argv.includes("--fast");
// Gates determinísticos filesystem-only, agrupados por tempo estimado.
// Cada entrada: { name: string, cmd: string[], label?: string }
// "slow" gates são omitidos com --fast.
const GATES = [
// Group A — instant (<1s)
{ name: "check:tracked-artifacts", cmd: ["node", "scripts/check/check-tracked-artifacts.mjs"] },
{ name: "check:any-budget:t11", cmd: ["node", "scripts/check/check-t11-any-budget.mjs"] },
{ name: "check:migration-numbering", cmd: ["node", "scripts/check/check-migration-numbering.mjs"] },
{ name: "check:node-runtime", cmd: ["node", "--import", "tsx", "scripts/check/check-supported-node-runtime.ts"] },
// Group B — fast (<5s)
{ name: "check:provider-consistency", cmd: ["node", "--import", "tsx", "scripts/check/check-provider-consistency.ts"] },
{ name: "check:public-creds", cmd: ["node", "scripts/check/check-public-creds.mjs"] },
{ name: "check:error-helper", cmd: ["node", "scripts/check/check-error-helper.mjs"] },
{ name: "check:fetch-targets", cmd: ["node", "scripts/check/check-fetch-targets.mjs"] },
{ name: "check:openapi-routes", cmd: ["node", "scripts/check/check-openapi-routes.mjs"] },
{ name: "check:deps", cmd: ["node", "scripts/check/check-deps.mjs"] },
// Group C — moderate (<15s)
{ name: "check:db-rules", cmd: ["node", "scripts/check/check-db-rules.mjs"] },
{ name: "check:file-size", cmd: ["node", "scripts/check/check-file-size.mjs"] },
{ name: "check:complexity", cmd: ["node", "scripts/check/check-complexity.mjs"] },
{ name: "check:docs-symbols", cmd: ["node", "scripts/check/check-docs-symbols.mjs"] },
{ name: "check:known-symbols", cmd: ["node", "--import", "tsx", "scripts/check/check-known-symbols.ts"] },
{ name: "check:route-guard-membership", cmd: ["node", "--import", "tsx", "scripts/check/check-route-guard-membership.ts"] },
{ name: "check:test-discovery", cmd: ["node", "scripts/check/check-test-discovery.mjs"] },
{ name: "check:test-masking", cmd: ["node", "scripts/check/check-test-masking.mjs"] },
// Group D — slow (>15s); skipped with --fast
{ name: "check:duplication", cmd: ["node", "scripts/check/check-duplication.mjs"], slow: true },
{ name: "check:cycles", cmd: ["node", "scripts/check/check-cycles.mjs"], slow: true },
];
const CONCURRENCY = 4;
/**
* Run a single gate command, capturing last line of stdout/stderr.
* @param {{ name: string, cmd: string[] }} gate
* @returns {Promise<{ name: string, exitCode: number, durationMs: number, lastLine: string }>}
*/
function runGate(gate) {
return new Promise((resolve) => {
const start = Date.now();
const [bin, ...args] = gate.cmd;
const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], cwd: process.cwd() });
const lines = [];
const collectLine = (chunk) => {
const text = chunk.toString();
for (const line of text.split("\n")) {
const t = line.trim();
if (t) lines.push(t);
}
};
proc.stdout.on("data", collectLine);
proc.stderr.on("data", collectLine);
proc.on("close", (code) => {
const durationMs = Date.now() - start;
const lastLine = lines[lines.length - 1] ?? "";
resolve({ name: gate.name, exitCode: code ?? 1, durationMs, lastLine });
});
proc.on("error", (err) => {
const durationMs = Date.now() - start;
resolve({ name: gate.name, exitCode: 1, durationMs, lastLine: err.message });
});
});
}
/**
* Run gates with a concurrency pool.
* @param {typeof GATES} gates
* @param {number} concurrency
* @returns {Promise<ReturnType<typeof runGate>[]>}
*/
async function runWithPool(gates, concurrency) {
const results = [];
let idx = 0;
async function worker() {
while (idx < gates.length) {
const gate = gates[idx++];
const result = await runGate(gate);
results.push(result);
}
}
const workers = Array.from({ length: Math.min(concurrency, gates.length) }, () => worker());
await Promise.all(workers);
return results;
}
function formatTable(results) {
const COL_NAME = 35;
const COL_STATUS = 8;
const COL_MS = 10;
const COL_MSG = 60;
const header =
" " +
"GATE".padEnd(COL_NAME) +
"STATUS".padEnd(COL_STATUS) +
"TIME(ms)".padEnd(COL_MS) +
"LAST LINE";
const separator = " " + "─".repeat(COL_NAME + COL_STATUS + COL_MS + COL_MSG);
const rows = results.map((r) => {
const status = r.exitCode === 0 ? "PASS" : "FAIL";
const name = r.name.padEnd(COL_NAME);
const statusCol = (r.exitCode === 0 ? "✔ " + status : "✗ " + status).padEnd(COL_STATUS + 2);
const ms = String(r.durationMs).padStart(6) + "ms ";
const msg = r.lastLine.slice(0, COL_MSG);
return ` ${name}${statusCol}${ms}${msg}`;
});
return [separator, header, separator, ...rows, separator].join("\n");
}
async function main() {
const gates = FAST_ONLY ? GATES.filter((g) => !g.slow) : GATES;
console.log(`\n[quality:scan] Running ${gates.length} gate(s) with concurrency=${CONCURRENCY}...\n`);
const wallStart = Date.now();
const results = await runWithPool(gates, CONCURRENCY);
const wallMs = Date.now() - wallStart;
const failed = results.filter((r) => r.exitCode !== 0);
const passed = results.filter((r) => r.exitCode === 0);
console.log(formatTable(results));
console.log(
`\n Summary: ${passed.length} passed, ${failed.length} failed` +
` | Total: ${(wallMs / 1000).toFixed(1)}s (wall clock)\n`
);
if (failed.length > 0) {
console.error(`[quality:scan] FAIL — ${failed.length} gate(s) failed:`);
for (const r of failed) {
console.error(`${r.name}`);
}
process.exit(1);
}
console.log("[quality:scan] All gates passed.");
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();