Release v3.8.26 (#3875)

OmniRoute v3.8.26 — see CHANGELOG.md [3.8.26] for the full notes.

Highlights: Vertex AI media generation (#3929), GLM-5.2 effort-tier routing (#3885),
sticky round-robin combos (#3846), OpenRouter connection presets (#3878), compression
prompt-cache fix (#3936/#3890), and a security pass (form-data/vite + workflow hardening, #3949).

Co-authored-by: artickc <artickc@users.noreply.github.com>
Co-authored-by: rdself <rdself@users.noreply.github.com>
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Jack Smith <16862258+YunyunZhai@users.noreply.github.com>
Co-authored-by: dhaern <dhaern@users.noreply.github.com>
Co-authored-by: adivekar-utexas <adivekar-utexas@users.noreply.github.com>
Co-authored-by: megamen32 <megamen32@users.noreply.github.com>
Co-authored-by: zhiru <zhiru@users.noreply.github.com>
Co-authored-by: insoln <insoln@users.noreply.github.com>
Co-authored-by: diego-anselmo <diego-anselmo@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-16 01:00:40 -03:00
committed by GitHub
parent 1f87a9589c
commit 81a37b67ed
272 changed files with 14856 additions and 2991 deletions

View File

@@ -11,19 +11,38 @@
// codeqlAlerts=SKIP reason=no-auth — `gh` presente mas sem autenticação
// codeqlAlerts=SKIP reason=api-error:<code> — erro da API GitHub
//
// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado
// pelo motor quality-baseline.json no CI (Task 7.3 INT).
// RATCHET BLOQUEANTE (default): lê metrics.codeqlAlerts.value de
// config/quality/quality-baseline.json e SAI 1 SE — E SOMENTE SE — a contagem
// MEDIDA for MAIOR que o baseline (regressão real, mais alertas CodeQL abertos).
// Qualquer falha de MEDIÇÃO (gh ausente / sem auth / sem repo / erro de API) é um
// SKIP gracioso que SAI 0 — nunca bloqueia o build por falta de infraestrutura.
// Direction: down (a contagem só pode CAIR). Suporta --update para ratchetar.
//
// Uso:
// node scripts/check/check-codeql-ratchet.mjs
// node scripts/check/check-codeql-ratchet.mjs --json # imprime array de alertas
// node scripts/check/check-codeql-ratchet.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-codeql-ratchet.mjs --json # imprime array de alertas
// node scripts/check/check-codeql-ratchet.mjs --quiet # suprime logs de diagnóstico
// node scripts/check/check-codeql-ratchet.mjs --update # ratcheta o baseline (queda)
// node scripts/check/check-codeql-ratchet.mjs --advisory # nunca falha (modo coletor)
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
// --advisory: nunca falha pela contagem (modo coletor legado). Sem esta flag o
// gate é BLOQUEANTE: sai 1 numa regressão real (medida > baseline).
const ADVISORY = process.argv.includes("--advisory");
const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.json")
);
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
@@ -88,6 +107,23 @@ export function parseCodeQLAlerts(alerts) {
return { alertCount, bySeverity, byRule };
}
/**
* Avalia a contagem MEDIDA de alertas CodeQL contra o baseline.
* Direction: down (a contagem só pode CAIR — mais alertas = regressão).
*
* Exported for unit testing — espelha evaluateDeadCode em check-dead-code.mjs.
*
* @param {number} current - Contagem de alertas medida agora.
* @param {number} baseline - Contagem congelada em quality-baseline.json.
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateCodeqlRatchet(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
// ---------------------------------------------------------------------------
// Repository detection
// ---------------------------------------------------------------------------
@@ -101,10 +137,14 @@ export function parseCodeQLAlerts(alerts) {
*/
export function detectRepo(ghBin) {
try {
const stdout = execFileSync(ghBin, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], {
encoding: "utf8",
timeout: 15_000,
});
const stdout = execFileSync(
ghBin,
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
{
encoding: "utf8",
timeout: 15_000,
}
);
return stdout.trim() || null;
} catch {
return null;
@@ -184,7 +224,11 @@ function fetchCodeQLAlerts(ghBin, repo) {
const errMsg = String(err.stderr ?? err.message ?? "");
// Sem autenticação
if (errMsg.includes("authentication") || errMsg.includes("401") || errMsg.includes("not logged")) {
if (
errMsg.includes("authentication") ||
errMsg.includes("401") ||
errMsg.includes("not logged")
) {
return { error: "no-auth", message: errMsg };
}
@@ -198,8 +242,10 @@ function fetchCodeQLAlerts(ghBin, repo) {
try {
page_alerts = JSON.parse(stdout);
} catch (parseErr) {
process.stderr.write(`[codeql-ratchet] ERRO ao parsear resposta da API: ${parseErr.message}\n`);
process.exit(2);
// A malformed (but HTTP-200) API response is a MEASUREMENT failure, not a
// regression. A blocking gate must never red on it — return the same
// {error,message} shape the caller already maps to a graceful SKIP (exit 0).
return { error: "parse-error", message: String(parseErr.message ?? parseErr) };
}
// A API retorna null quando não há mais páginas (ou array vazio)
@@ -216,6 +262,81 @@ function fetchCodeQLAlerts(ghBin, repo) {
return allAlerts;
}
// ---------------------------------------------------------------------------
// Baseline
// ---------------------------------------------------------------------------
/**
* Lê metrics.codeqlAlerts.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes (modo coletor puro:
* sem baseline não há ratchet, só emissão da contagem).
*
* @returns {number|null}
*/
function readBaselineCodeqlValue() {
if (!fs.existsSync(BASELINE_PATH)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.codeqlAlerts;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Define process.exitCode = 1 numa regressão real (medida > baseline) salvo
* --advisory. Ratcheta o baseline com --update quando a contagem cai.
*
* Exported for unit testing (drives o efeito em process.exitCode).
*
* @param {number} alertCount - Contagem MEDIDA (medição bem-sucedida).
*/
export function applyRatchet(alertCount) {
const baselineValue = readBaselineCodeqlValue();
// Sem baseline → modo coletor puro (emite a contagem, não falha).
if (baselineValue === null) {
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] baseline ausente (metrics.codeqlAlerts) — modo coletor, sem ratchet.\n"
);
}
process.exitCode = 0;
return;
}
const { regressed, improved } = evaluateCodeqlRatchet(alertCount, baselineValue);
if (UPDATE && improved) {
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
baselineJson.metrics.codeqlAlerts.value = alertCount;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[codeql-ratchet] baseline ratcheado: ${alertCount} (era ${baselineValue})`);
}
if (regressed && !ADVISORY) {
process.stderr.write(
`[codeql-ratchet] REGRESSÃO — ${alertCount} alertas CodeQL abertos > baseline ${baselineValue}\n` +
" → Corrija os novos alertas em Security → Code scanning, ou rode\n" +
" 'node scripts/check/check-codeql-ratchet.mjs --update' se a contagem caiu legitimamente.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
const verdict = regressed ? "ADVISORY — regressão ignorada (--advisory)" : "OK — sem regressão";
process.stderr.write(
`[codeql-ratchet] ${verdict}${alertCount} alertas (baseline ${baselineValue})\n`
);
}
process.exitCode = 0;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@@ -228,8 +349,8 @@ function main() {
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] SKIP — `gh` CLI não encontrado no PATH.\n" +
"[codeql-ratchet] Instale via: https://cli.github.com/\n" +
"[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
"[codeql-ratchet] Instale via: https://cli.github.com/\n" +
"[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
);
}
process.exitCode = 0;
@@ -243,7 +364,7 @@ function main() {
if (!QUIET) {
process.stderr.write(
"[codeql-ratchet] SKIP — não foi possível detectar o repositório GitHub.\n" +
"[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n"
"[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n"
);
}
process.exitCode = 0;
@@ -262,7 +383,9 @@ function main() {
const { error, message } = result;
console.log(`codeqlAlerts=SKIP reason=${error}`);
if (!QUIET) {
process.stderr.write(`[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`);
process.stderr.write(
`[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`
);
}
process.exitCode = 0;
return;
@@ -279,14 +402,16 @@ function main() {
console.log(`codeqlAlerts=${alertCount}`);
if (!QUIET) {
const severitySummary = Object.entries(bySeverity)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "nenhum";
const topRules = Object.entries(byRule)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([r, n]) => `${r}(${n})`)
.join(", ") || "nenhum";
const severitySummary =
Object.entries(bySeverity)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "nenhum";
const topRules =
Object.entries(byRule)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([r, n]) => `${r}(${n})`)
.join(", ") || "nenhum";
process.stderr.write(
`[codeql-ratchet] Alertas CodeQL abertos (não-dismissed): ${alertCount}\n`
@@ -295,13 +420,11 @@ function main() {
process.stderr.write(`[codeql-ratchet] Por severidade: ${severitySummary}\n`);
process.stderr.write(`[codeql-ratchet] Top regras: ${topRules}\n`);
}
process.stderr.write(
"[codeql-ratchet] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n"
);
}
// Sai 0 sempre nesta versão (advisory)
process.exitCode = 0;
// Medição bem-sucedida → aplica o ratchet (bloqueante salvo --advisory).
// Qualquer falha de MEDIÇÃO acima já retornou com exit 0 (skip gracioso).
applyRatchet(alertCount);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -31,7 +31,7 @@ const ESLINT_BIN = path.join(ROOT, "node_modules", ".bin", "eslint");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "quality-baseline.json")
: path.join(ROOT, "config/quality/quality-baseline.json")
);
const ESLINT_ARGS = [

View File

@@ -19,7 +19,7 @@ const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "complexity-baseline.json")
: path.join(ROOT, "config/quality/complexity-baseline.json")
);
const UPDATE = process.argv.includes("--update");
const CONFIG_PATH = path.join(ROOT, "eslint.complexity.config.mjs");

View File

@@ -28,7 +28,7 @@ const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "quality-baseline.json")
: path.join(ROOT, "config/quality/quality-baseline.json")
);
/**

View File

@@ -29,7 +29,7 @@ import { execFileSync } from "node:child_process";
import { assertNoStale } from "./lib/allowlist.mjs";
const ROOT = process.cwd();
const ALLOWLIST_PATH = path.join(ROOT, "dependency-allowlist.json");
const ALLOWLIST_PATH = path.join(ROOT, "config/quality/dependency-allowlist.json");
// Directories to exclude when discovering package.json files.
// Using a set of path segment prefixes (relative to ROOT, forward slashes).
@@ -141,16 +141,12 @@ export function queryNpmRegistry(pkgName, timeoutMs = 8000) {
// Scope packages need URL-encoding for the `npm view` command.
// `npm view` accepts scoped packages natively — no encoding needed.
try {
const raw = execFileSync(
"npm",
["view", pkgName, "time.created", "--json"],
{
encoding: "utf8",
timeout: timeoutMs,
// Suppress npm progress/warn output on stderr
stdio: ["ignore", "pipe", "pipe"],
}
);
const raw = execFileSync("npm", ["view", pkgName, "time.created", "--json"], {
encoding: "utf8",
timeout: timeoutMs,
// Suppress npm progress/warn output on stderr
stdio: ["ignore", "pipe", "pipe"],
});
// npm view --json emits a quoted string or null/empty for missing fields
const trimmed = raw.trim();
if (!trimmed) {
@@ -165,7 +161,11 @@ export function queryNpmRegistry(pkgName, timeoutMs = 8000) {
// npm exits with code 1 when the package is NOT found ("E404")
const stderr = err.stderr?.toString() || "";
const stdout = err.stdout?.toString() || "";
if (stderr.includes("E404") || stdout.includes("E404") || stderr.includes("npm ERR! code E404")) {
if (
stderr.includes("E404") ||
stdout.includes("E404") ||
stderr.includes("npm ERR! code E404")
) {
return { exists: false, createdMs: null };
}
// Any other error (ETIMEDOUT, ENOTFOUND, etc.) = network/offline — return null

View File

@@ -1,14 +1,26 @@
#!/usr/bin/env node
// Validates that count-based assertions in docs match the actual code state.
// Examples checked:
// - executors count in open-sse/executors/
// - routing strategies in src/shared/constants/routingStrategies.ts
// - OAuth providers in src/lib/oauth/providers/
// - A2A skills in src/lib/a2a/skills/
// - Cloud agents in src/lib/cloudAgent/agents/
//
// Exits 0 on success, 1 on detected drift.
// Two tiers of checks:
// • STRICT (always blocking — exit 1 on drift): high-confidence, slow-moving counts
// that historically caused the worst drift across README / AGENTS / docs.
// - provider count (source of truth: docs/reference/PROVIDER_REFERENCE.md total,
// which is auto-generated from src/shared/constants/providers.ts)
// - i18n locale count (source of truth: config/i18n.json `locales`)
// • SOFT (heuristic — only fails with --strict): file-count based assertions that can
// false-positive.
// - executors count in open-sse/executors/
// - routing strategies in src/shared/constants/routingStrategies.ts
// - OAuth providers in src/lib/oauth/providers/
// - A2A skills in src/lib/a2a/skills/
// - Cloud agents in src/lib/cloudAgent/agents/
//
// Exits 0 on success, 1 on STRICT drift (or any drift with --strict).
// Run: node scripts/check/check-docs-counts-sync.mjs
//
// NOTE: the provider check trusts PROVIDER_REFERENCE.md as the canonical total. If a
// provider is added to the code but the reference is not regenerated, this guard will
// not catch it — regenerate with `npm run gen:provider-reference` before relying on it.
import fs from "node:fs";
import path from "node:path";
@@ -48,68 +60,144 @@ function countRoutingStrategies() {
return (m[1].match(/"[^"]+"/g) || []).length;
}
function docContains(docPath, needle) {
const abs = path.join(ROOT, "docs", docPath);
if (!fs.existsSync(abs)) return false;
return fs.readFileSync(abs, "utf8").includes(needle);
// PURE: parse the canonical provider total out of the auto-generated catalog text.
export function parseProviderTotal(referenceText) {
if (!referenceText) return 0;
const m = referenceText.match(/Total providers:\s*\*\*(\d+)\*\*/);
return m ? Number(m[1]) : 0;
}
const checks = [
{
label: "Executors count",
actual: countFiles("open-sse/executors"),
docKey: "executors",
docs: ["architecture/ARCHITECTURE.md", "architecture/CODEBASE_DOCUMENTATION.md"],
},
{
label: "Routing strategies count",
actual: countRoutingStrategies(),
docKey: "strategies",
docs: ["routing/AUTO-COMBO.md", "architecture/RESILIENCE_GUIDE.md"],
},
{
label: "OAuth providers count",
actual: countFiles("src/lib/oauth/providers"),
docKey: "OAuth providers",
docs: ["architecture/ARCHITECTURE.md"],
},
{
label: "A2A skills count",
actual: countFiles("src/lib/a2a/skills"),
docKey: "A2A skills",
docs: ["frameworks/A2A-SERVER.md"],
},
{
label: "Cloud agents count",
actual: countFiles("src/lib/cloudAgent/agents"),
docKey: "cloud agents",
docs: ["frameworks/CLOUD_AGENT.md", "frameworks/AGENT_PROTOCOLS_GUIDE.md"],
},
];
// STRICT: canonical provider total, read from the auto-generated catalog.
export function readProviderTotal() {
const abs = path.join(ROOT, "docs", "reference", "PROVIDER_REFERENCE.md");
if (!fs.existsSync(abs)) return 0;
return parseProviderTotal(fs.readFileSync(abs, "utf8"));
}
let drift = 0;
console.log("Docs counts sync report");
console.log("=======================");
for (const c of checks) {
console.log(`\n${c.label}: ${c.actual} (real)`);
for (const doc of c.docs) {
const found = docContains(doc, String(c.actual));
if (found) {
console.log(` ✓ docs/${doc} mentions "${c.actual}"`);
} else {
console.log(` ⚠ docs/${doc} does NOT mention "${c.actual}" for ${c.docKey}`);
drift++;
}
// STRICT: canonical i18n locale count, read from the shared config.
export function countLocales() {
const abs = path.join(ROOT, "config", "i18n.json");
if (!fs.existsSync(abs)) return 0;
try {
const cfg = JSON.parse(fs.readFileSync(abs, "utf8"));
return Array.isArray(cfg.locales) ? cfg.locales.length : 0;
} catch {
return 0;
}
}
console.log();
if (drift > 0) {
console.warn(`${drift} potential drift(s) detected. Review the docs above.`);
// Soft-fail by default (count-based heuristic can false-positive).
// To enforce, pass --strict.
if (process.argv.includes("--strict")) process.exit(1);
} else {
console.log("✓ All checks pass.");
// PURE: tally STRICT vs SOFT drift for a list of checks, given a content lookup.
// `getContent(file) -> string | null`. A check whose `actual` is 0 is skipped (the
// source count could not be determined). Returns { strict, soft, lines }.
export function tallyDrift(checks, getContent) {
let strict = 0;
let soft = 0;
const lines = [];
for (const c of checks) {
const tier = c.strict ? "STRICT" : "soft";
lines.push(`\n${c.label}: ${c.actual} (real) [${tier}]`);
if (!c.actual) {
lines.push(` ⚠ could not determine ${c.docKey} count from source — skipping`);
continue;
}
for (const f of c.files) {
const content = getContent(f);
const found = content != null && content.includes(String(c.actual));
if (found) {
lines.push(`${f} mentions "${c.actual}"`);
} else {
lines.push(` ${c.strict ? "✗" : "⚠"} ${f} does NOT mention "${c.actual}" for ${c.docKey}`);
if (c.strict) strict++;
else soft++;
}
}
}
return { strict, soft, lines };
}
export function buildChecks() {
return [
{
label: "Provider count",
actual: readProviderTotal(),
docKey: "providers",
strict: true,
files: ["README.md", "AGENTS.md", "CLAUDE.md"],
},
{
label: "i18n locales count",
actual: countLocales(),
docKey: "i18n locales",
strict: true,
files: ["docs/README.md", "docs/guides/I18N.md", "AGENTS.md"],
},
{
label: "Executors count",
actual: countFiles("open-sse/executors"),
docKey: "executors",
strict: false,
files: ["docs/architecture/ARCHITECTURE.md", "docs/architecture/CODEBASE_DOCUMENTATION.md"],
},
{
label: "Routing strategies count",
actual: countRoutingStrategies(),
docKey: "strategies",
strict: false,
files: ["docs/routing/AUTO-COMBO.md", "docs/architecture/RESILIENCE_GUIDE.md"],
},
{
label: "OAuth providers count",
actual: countFiles("src/lib/oauth/providers"),
docKey: "OAuth providers",
strict: false,
files: ["docs/architecture/ARCHITECTURE.md"],
},
{
label: "A2A skills count",
actual: countFiles("src/lib/a2a/skills"),
docKey: "A2A skills",
strict: false,
files: ["docs/frameworks/A2A-SERVER.md"],
},
{
label: "Cloud agents count",
actual: countFiles("src/lib/cloudAgent/agents"),
docKey: "cloud agents",
strict: false,
files: ["docs/frameworks/CLOUD_AGENT.md", "docs/frameworks/AGENT_PROTOCOLS_GUIDE.md"],
},
];
}
function main() {
const checks = buildChecks();
const getContent = (relPath) => {
const abs = path.join(ROOT, relPath);
return fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null;
};
console.log("Docs counts sync report");
console.log("=======================");
const { strict, soft, lines } = tallyDrift(checks, getContent);
for (const l of lines) console.log(l);
console.log();
if (strict > 0) {
console.error(
`${strict} STRICT drift(s) detected. ` +
`Update the docs above to the real counts, or regenerate auto-generated sources ` +
`(npm run gen:provider-reference).`
);
process.exit(1);
}
if (soft > 0) {
console.warn(`${soft} potential (soft) drift(s) detected. Review the docs above.`);
if (process.argv.includes("--strict")) process.exit(1);
} else {
console.log("✓ All checks pass.");
}
}
const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) main();

View File

@@ -15,13 +15,23 @@ const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "duplication-baseline.json")
: path.join(ROOT, "config/quality/duplication-baseline.json")
);
const UPDATE = process.argv.includes("--update");
const EPS = 0.05; // tolerância de ruído de float (jscpd é determinístico; isto é margem)
// Use local binary (pinned in package.json devDependencies — no registry download at CI time)
const JSCPD_BIN = path.join(ROOT, "node_modules", ".bin", "jscpd");
const JSCPD_FIXED_ARGS = ["src", "open-sse", "--reporters", "json", "--silent", "--min-tokens", "50", "--ignore", "**/*.test.ts,**/*.test.tsx,**/__tests__/**"];
const JSCPD_FIXED_ARGS = [
"src",
"open-sse",
"--reporters",
"json",
"--silent",
"--min-tokens",
"50",
"--ignore",
"**/*.test.ts,**/*.test.tsx,**/__tests__/**",
];
/** Avalia a % atual contra o baseline. */
export function evaluateDuplication(current, baseline, eps = EPS) {

View File

@@ -76,6 +76,9 @@ const IGNORE_FROM_CODE = new Set([
// CI providers (set by the runner).
"GITHUB_BASE_REF",
"GITHUB_BASE_SHA",
// CI passes BASE_REF=${{ github.base_ref }} to the OpenAPI breaking-change gate
// (scripts/check/check-openapi-breaking.mjs) — a build/check signal, not OmniRoute runtime config.
"BASE_REF",
// PR body injected by GitHub Actions into the pr-evidence gate (github.event.pull_request.body);
// a CI-only signal, never an OmniRoute runtime config (Phase 7.10).
"PR_BODY",

View File

@@ -273,8 +273,8 @@ const ENV_VAR_DENYLIST = new Set([
// Gate allowlist constant names (JS identifiers, not env vars) — documented in
// docs/architecture/QUALITY_GATES.md and docs/research/DISCOVERY_TOOL_DESIGN.md
"KNOWN_STALE_DOC_REFS", // export const in check-docs-symbols.mjs
"KNOWN_MISSING", // export const in check-fetch-targets.mjs
"KNOWN_RAW_SQL", // export const in check-db-rules.mjs
"KNOWN_MISSING", // export const in check-fetch-targets.mjs
"KNOWN_RAW_SQL", // export const in check-db-rules.mjs
]);
/** Endpoints that don't follow the standard route.ts pattern. */
@@ -313,6 +313,9 @@ const SKIP_DOC_FILES = new Set([
"docs/reference/PROVIDER_REFERENCE.md", // auto-generated from providers.ts
"docs/reference/openapi.yaml",
"docs/i18n", // translations — separate workflow
// Point-in-time documentation audit (v3.8.24): intentionally references drift,
// counts, and not-yet-existing files as part of documenting them — not living docs.
"docs/ops/DOCUMENTATION_AUDIT_REPORT.md",
]);
// ── File discovery ─────────────────────────────────────────────────────────

View File

@@ -15,7 +15,9 @@ function getArg(name, fallback) {
const i = process.argv.indexOf(name);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const BASELINE_PATH = path.resolve(getArg("--baseline", path.join(ROOT, "file-size-baseline.json")));
const BASELINE_PATH = path.resolve(
getArg("--baseline", path.join(ROOT, "config/quality/file-size-baseline.json"))
);
const UPDATE = process.argv.includes("--update");
const SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
// Directories to skip when walking — build artifacts and installed packages.
@@ -30,7 +32,8 @@ export function evaluateFileSizes(currentLocByFile, frozen, cap) {
const improvements = [];
for (const [file, loc] of Object.entries(currentLocByFile)) {
if (file in frozen) {
if (loc > frozen[file]) violations.push(`${file}: ${loc} > congelado ${frozen[file]} (não pode crescer)`);
if (loc > frozen[file])
violations.push(`${file}: ${loc} > congelado ${frozen[file]} (não pode crescer)`);
else if (loc < frozen[file]) improvements.push([file, loc]);
} else if (loc > cap) {
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
@@ -49,7 +52,11 @@ function walk(dir, acc = []) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name)) walk(p, acc);
} else if (/\.(ts|tsx)$/.test(e.name) && !/\.test\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name)) {
} else if (
/\.(ts|tsx)$/.test(e.name) &&
!/\.test\.tsx?$/.test(e.name) &&
!/\.d\.ts$/.test(e.name)
) {
acc.push(p);
}
}
@@ -59,7 +66,8 @@ function walk(dir, acc = []) {
function collectLoc() {
const out = {};
for (const d of SCAN_DIRS)
for (const f of walk(path.join(ROOT, d))) out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
for (const f of walk(path.join(ROOT, d)))
out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
return out;
}
@@ -76,7 +84,8 @@ function main() {
if (UPDATE && violations.length === 0 && improvements.length) {
for (const [file, loc] of improvements) {
if (loc <= cap) delete frozen[file]; // caiu para dentro do cap → sai do baseline
if (loc <= cap)
delete frozen[file]; // caiu para dentro do cap → sai do baseline
else frozen[file] = loc; // continua grande mas encolheu → trava no novo valor
}
baseline.frozen = Object.fromEntries(Object.entries(frozen).sort());

View File

@@ -12,7 +12,8 @@
// (2) COMBO STRATEGIES — a cadeia de despacho `strategy === "..."` em
// open-sse/services/combo.ts DEVE tratar exatamente o conjunto canônico de
// ROUTING_STRATEGY_VALUES (src/shared/constants/routingStrategies.ts), exceto
// as estratégias-default implícitas (priority não tem branch; cai no
// as estratégias-default implícitas documentadas em IMPLICIT_DEFAULT_STRATEGIES
// (estratégias canônicas sem NENHUMA referência `strategy === "..."`; caem no
// ordenamento padrão). Adicionar um valor canônico sem fiá-lo no despacho, ou
// fiar uma string de estratégia que não é canônica (inventada), falha aqui.
//
@@ -38,10 +39,21 @@
// Catraca: cada divergência pré-existente fica numa allowlist documentada e sai 0 hoje.
// Padrão herdado de scripts/check/check-provider-consistency.ts (gate .ts via
// `node --import tsx` que IMPORTA módulos reais + funções puras + main() guardado).
//
// Stale-enforcement (6A.3): a ÚNICA allowlist de SUPRESSÃO deste gate é
// IMPLICIT_DEFAULT_STRATEGIES — cada entrada suprime uma violação `canonicalNotHandled`
// (estratégia canônica sem branch de despacho). Uma entrada que não suprime mais
// nenhuma violação real (porque a estratégia ganhou um branch `strategy === "..."`)
// é obsoleta → o gate falha com instrução de remoção, fechando o furo de regressão
// silenciosa. As demais listas (KNOWN_TRANSLATOR_PAIRS, KNOWN_MCP_TOOL_NAMES) NÃO são
// allowlists de supressão e sim snapshots-catraca (falham na REMOÇÃO, não na presença):
// uma entrada nelas exige que o par/tool continue VIVO no registry — o oposto de
// supressão — então a semântica de stale-enforcement não se aplica a elas.
import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve as resolvePath, basename, extname } from "node:path";
import { assertNoStale } from "./lib/allowlist.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolvePath(HERE, "..", "..");
@@ -53,12 +65,18 @@ const REPO_ROOT = resolvePath(HERE, "..", "..");
/**
* Estratégias canônicas que NÃO têm um branch `strategy === "..."` na cadeia de
* despacho porque são o comportamento padrão (sem reordenamento explícito). Cada
* uma documentada. Remover daqui se um branch dedicado for adicionado.
* uma documentada. Adicionar aqui se uma estratégia canônica não tiver NENHUMA
* referência `strategy === "..."` em combo.ts (do contrário extractHandledStrategies
* já a considera tratada e a entrada vira obsoleta — stale-enforcement abaixo falha).
*
* Atualmente vazio: a entrada `priority` foi removida porque combo.ts passou a
* referenciar `strategy === "priority"` (pre-screen de latência em resolveComboTargets),
* o que torna `priority` já-tratada por extractHandledStrategies — a supressão não
* suprimia mais nenhuma violação `canonicalNotHandled` (era stale). Se o pre-screen
* for removido no futuro, `priority` reaparecerá como `canonicalNotHandled` e o gate
* pedirá para refiá-la no despacho OU redocumentá-la aqui.
*/
export const IMPLICIT_DEFAULT_STRATEGIES: Record<string, string> = {
priority:
'Default sem branch: combo.ts não tem `strategy === "priority"`; cai no ordenamento padrão de resolveComboTargets (ordem de prioridade declarada). É o fallback de normalizeRoutingStrategy.',
};
export const IMPLICIT_DEFAULT_STRATEGIES: Record<string, string> = {};
/** Extrai todas as strings literais de `strategy === "..."` da fonte do combo. */
export function extractHandledStrategies(comboSource: string): Set<string> {
@@ -458,6 +476,16 @@ async function main(): Promise<void> {
const canonical = strategiesMod.ROUTING_STRATEGY_VALUES as readonly string[];
const comboSource = readFileSync(resolvePath(REPO_ROOT, "open-sse/services/combo.ts"), "utf8");
const handled = extractHandledStrategies(comboSource);
// Stale-enforcement (6A.3): IMPLICIT_DEFAULT_STRATEGIES is a suppression allowlist —
// each entry exists ONLY to suppress a `canonicalNotHandled` violation (a canonical
// strategy with no `strategy === "..."` dispatch reference). The live violations it
// suppresses are the canonical strategies NOT already in `handled` (computed with an
// EMPTY implicit-defaults map). An entry whose key IS already in `handled` suppresses
// nothing → it is stale and the gate must fail asking for its removal.
const liveImplicitNeeded = diffComboStrategies(canonical, handled, {}).canonicalNotHandled;
assertNoStale(Object.keys(IMPLICIT_DEFAULT_STRATEGIES), liveImplicitNeeded, "known-symbols:combo");
const { canonicalNotHandled, handledNotCanonical } = diffComboStrategies(
canonical,
handled,
@@ -635,6 +663,9 @@ async function main(): Promise<void> {
console.error(`[known-symbols] ${failures.length} sub-checagem(ns) falharam:\n\n${failures.join("\n\n")}`);
process.exit(1);
}
// assertNoStale (combo) seta process.exitCode=1 sem lançar — não imprima o OK
// enganoso; a mensagem de stale já foi logada no stderr pelo helper.
if (process.exitCode === 1) return;
const newPairsNote = newPairs.length
? ` (${newPairs.length} par(es) novo(s) não-congelado(s): ${newPairs.join(", ")} — atualize KNOWN_TRANSLATOR_PAIRS se intencional)`

View File

@@ -22,7 +22,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const ALLOWLIST_PATH = path.join(ROOT, ".license-allowlist.json");
const ALLOWLIST_PATH = path.join(ROOT, "config/quality/.license-allowlist.json");
const CHECKER_BIN = path.join(ROOT, "node_modules", ".bin", "license-checker-rseidelsohn");
const VERBOSE = process.argv.includes("--verbose");
@@ -39,7 +39,9 @@ const PRINT_JSON = process.argv.includes("--json");
*/
export function loadAllowlist() {
if (!fs.existsSync(ALLOWLIST_PATH)) {
throw new Error(`Allowlist not found: ${ALLOWLIST_PATH}. Create .license-allowlist.json first.`);
throw new Error(
`Allowlist not found: ${ALLOWLIST_PATH}. Create .license-allowlist.json first.`
);
}
const raw = fs.readFileSync(ALLOWLIST_PATH, "utf-8");
const parsed = JSON.parse(raw);
@@ -86,7 +88,10 @@ export function classifyLicense(packageName, license, allowlist) {
}
// 4. Denied
return { status: "denied", reason: `license '${license}' not in allowlist and no exception registered for '${baseName}'` };
return {
status: "denied",
reason: `license '${license}' not in allowlist and no exception registered for '${baseName}'`,
};
}
/**
@@ -195,7 +200,9 @@ function main() {
// Print exceptions (informational)
if (exceptions.length > 0) {
console.log("\n[check-licenses] Exceções registradas (não bloqueantes, revisar periodicamente):");
console.log(
"\n[check-licenses] Exceções registradas (não bloqueantes, revisar periodicamente):"
);
for (const { pkgKey, license } of exceptions) {
const baseName = stripVersion(pkgKey);
const exc = allowlist.exceptions[baseName];
@@ -217,7 +224,9 @@ function main() {
// Print violations and fail
if (violations.length > 0) {
console.error("\n[check-licenses] ❌ VIOLAÇÕES DE POLÍTICA — deps de produção com licença não permitida:");
console.error(
"\n[check-licenses] ❌ VIOLAÇÕES DE POLÍTICA — deps de produção com licença não permitida:"
);
for (const { pkgKey, license, reason } of violations) {
console.error(`${pkgKey}: ${license}`);
console.error(`${reason}`);
@@ -231,11 +240,14 @@ function main() {
return;
}
console.log("\n[check-licenses] ✅ Todos os pacotes de produção estão em conformidade com a política de licenças.");
console.log(
"\n[check-licenses] ✅ Todos os pacotes de produção estão em conformidade com a política de licenças."
);
}
// Run only when invoked directly (not when imported by tests)
const isMain = process.argv[1] === pathToFileURL(import.meta.url).pathname ||
const isMain =
process.argv[1] === pathToFileURL(import.meta.url).pathname ||
process.argv[1]?.endsWith("check-licenses.mjs");
if (isMain) {

View File

@@ -0,0 +1,357 @@
#!/usr/bin/env node
// scripts/check/check-openapi-breaking.mjs
// Catraca de breaking-change na API pública (Fase 8 B.4 — backlog opcional).
//
// Diffa a spec do PR (docs/reference/openapi.yaml na working tree = HEAD) contra
// a MESMA spec no branch base, via `oasdiff breaking`. Pega regressões de contrato:
// endpoint removido, parâmetro novo obrigatório, campo de resposta removido, enum
// estreitado, etc. — mudanças que quebram clientes existentes.
//
// Complementa os gates anti-alucinação existentes:
// • check-openapi-routes.mjs — toda `path` na spec resolve a uma rota real.
// • check-openapi-coverage.mjs — % de rotas reais documentadas (ratchet).
// Nenhum dos dois compara DUAS versões da spec; este sim.
//
// Saída (stdout, KEY=VALUE para o coletor de métricas collect-metrics.mjs):
// openapiBreaking=N — número de breaking changes
// openapiBreaking=SKIP reason=binary-absent — oasdiff não está no PATH
// openapiBreaking=SKIP reason=base-unresolved — a spec base não pôde ser lida
// (arquivo não existia no base, ou
// clone shallow sem o ref base)
//
// Esta versão é ADVISORY (sai 0 SEMPRE, mesmo com N>0). Promove a bloqueante
// depois (mesma trajetória de todo gate novo neste repo: report → ratchet → block).
//
// Base ref:
// • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/v3.8.26").
// • Local: default origin/release/v3.8.26.
// A spec base é extraída com `git show <BASE_REF>:docs/reference/openapi.yaml`.
//
// Uso:
// node scripts/check/check-openapi-breaking.mjs
// BASE_REF=origin/release/v3.8.26 node scripts/check/check-openapi-breaking.mjs
// node scripts/check/check-openapi-breaking.mjs --json # imprime JSON bruto do oasdiff
// node scripts/check/check-openapi-breaking.mjs --quiet # suprime logs de diagnóstico
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const SPEC_REL = "docs/reference/openapi.yaml";
const SPEC_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml");
const DEFAULT_BASE_REF = "origin/release/v3.8.26";
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
/**
* Conta breaking changes no JSON emitido por `oasdiff breaking --format json`.
*
* O oasdiff emite um array de objetos (ou array vazio quando não há breaking
* change). Cada objeto tem a forma:
* [
* {
* id: string, // ex.: "api-path-removed-without-deprecation"
* text: string, // descrição legível
* level: number, // 3 = ERR, 2 = WARN, 1 = INFO
* operation: string, // ex.: "GET"
* path: string, // ex.: "/api/bar"
* section: string,
* source?: string,
* baseSource?: { file, line, column },
* fingerprint: string
* },
* ...
* ]
*
* @param {Array|null} oasdiffJson - Array de breaking changes do oasdiff (ou null).
* @returns {{ count: number, byId: Record<string, number>, byPath: Record<string, number>, items: Array }}
*/
export function parseOasdiffBreaking(oasdiffJson) {
// null, undefined ou array vazio = nenhum breaking change.
if (
oasdiffJson === null ||
oasdiffJson === undefined ||
(Array.isArray(oasdiffJson) && oasdiffJson.length === 0)
) {
return { count: 0, byId: {}, byPath: {}, items: [] };
}
// Defensivo: qualquer coisa que não seja array é tratada como "sem dados".
if (!Array.isArray(oasdiffJson)) {
return { count: 0, byId: {}, byPath: {}, items: [] };
}
let count = 0;
const byId = {};
const byPath = {};
const items = [];
for (const change of oasdiffJson) {
if (!change || typeof change !== "object") continue;
count++;
items.push(change);
const id = change.id ?? change.ID ?? "unknown";
byId[id] = (byId[id] ?? 0) + 1;
const p = change.path ?? change.Path ?? "unknown";
byPath[p] = (byPath[p] ?? 0) + 1;
}
return { count, byId, byPath, items };
}
// ---------------------------------------------------------------------------
// Binary detection
// ---------------------------------------------------------------------------
/**
* Detecta se o binário `oasdiff` está disponível no PATH.
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
*
* @returns {string|null} Caminho para o binário, ou null se ausente.
*/
export function findOasdiff() {
try {
const result = spawnSync("which", ["oasdiff"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim();
}
} catch {
// which não disponível
}
// Fallback: tentar executar diretamente para distinguir ENOENT de "existe".
try {
const result = spawnSync("oasdiff", ["--version"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.error?.code === "ENOENT") return null;
if (result.status !== null) return "oasdiff"; // encontrado no PATH
} catch {
// noop
}
return null;
}
// ---------------------------------------------------------------------------
// Base spec resolution
// ---------------------------------------------------------------------------
/**
* Extrai a spec base via `git show <BASE_REF>:<SPEC_REL>` para um arquivo temp.
* Retorna o caminho do temp (chamador é responsável por limpar), ou null se a
* spec não pôde ser resolvida (ref ausente em clone shallow, ou arquivo não
* existia naquele ref). NUNCA lança — falha → null → SKIP gracioso.
*
* @param {string} baseRef
* @returns {string|null} caminho do arquivo temp com a spec base, ou null.
*/
export function resolveBaseSpec(baseRef) {
let stdout;
try {
stdout = execFileSync("git", ["show", `${baseRef}:${SPEC_REL}`], {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 30_000,
stdio: ["ignore", "pipe", "ignore"], // descarta stderr ruidoso do git
});
} catch {
// ref desconhecido (shallow clone), arquivo inexistente no base, etc.
return null;
}
if (!stdout || !stdout.trim()) return null;
const tmpFile = path.join(
os.tmpdir(),
`oasdiff-base-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.yaml`
);
try {
fs.writeFileSync(tmpFile, stdout, "utf8");
} catch {
return null;
}
return tmpFile;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const baseRef = (process.env.BASE_REF || "").trim() || DEFAULT_BASE_REF;
// 1) HEAD spec precisa existir (working tree).
if (!fs.existsSync(SPEC_PATH)) {
console.log("openapiBreaking=SKIP reason=head-spec-absent");
if (!QUIET) {
process.stderr.write(`[openapi-breaking] SKIP — spec não encontrada: ${SPEC_PATH}\n`);
}
process.exitCode = 0;
return;
}
// 2) Binário oasdiff precisa estar no PATH.
const oasdiffBin = findOasdiff();
if (!oasdiffBin) {
console.log("openapiBreaking=SKIP reason=binary-absent");
if (!QUIET) {
process.stderr.write(
"[openapi-breaking] SKIP — oasdiff não encontrado no PATH.\n" +
"[openapi-breaking] Instale via: https://github.com/oasdiff/oasdiff\n" +
"[openapi-breaking] ADVISORY — este gate sai 0 (promove a bloqueante depois).\n"
);
}
process.exitCode = 0;
return;
}
// 3) Resolver a spec base (git show → temp). SKIP se não der.
const baseTmp = resolveBaseSpec(baseRef);
if (!baseTmp) {
console.log(`openapiBreaking=SKIP reason=base-unresolved ref=${baseRef}`);
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] SKIP — não consegui ler ${SPEC_REL} em '${baseRef}'.\n` +
"[openapi-breaking] Causas: clone shallow sem o ref base, arquivo novo (não existia no base),\n" +
"[openapi-breaking] ou ref inválido. Em CI use fetch-depth: 0 ou git fetch do base ref.\n"
);
}
process.exitCode = 0;
return;
}
try {
// 4) Rodar `oasdiff breaking --format json <baseTmp> <headSpec>`.
// oasdiff sai 0 por padrão mesmo com breaking changes (só com --fail-on
// é que sai 1). Capturamos stdout independentemente do exit code.
const args = ["breaking", "--format", "json", baseTmp, SPEC_PATH];
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] Rodando: oasdiff breaking --format json <base:${baseRef}> ${SPEC_REL} ...\n`
);
}
let stdout = "";
try {
stdout = execFileSync(oasdiffBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 90_000,
});
} catch (err) {
// oasdiff PODE sair !=0 (ex.: com --fail-on em versões futuras, ou erro real).
// Capturamos stdout de qualquer jeito: se ele tem JSON parseável, é o resultado.
stdout = err.stdout ? String(err.stdout) : "";
const stderr = err.stderr ? String(err.stderr) : "";
if (!stdout.trim()) {
// Sem stdout = erro real do oasdiff (spec inválida, etc.). Advisory → SKIP.
console.log("openapiBreaking=SKIP reason=oasdiff-error");
if (!QUIET) {
process.stderr.write(`[openapi-breaking] SKIP — oasdiff falhou: ${err.message}\n`);
if (stderr) {
process.stderr.write(`[openapi-breaking] stderr: ${stderr.slice(0, 500)}\n`);
}
}
process.exitCode = 0;
return;
}
}
const trimmed = stdout.trim();
let parsed = [];
if (trimmed && trimmed !== "null") {
try {
parsed = JSON.parse(trimmed);
} catch (parseErr) {
// JSON inesperado — advisory, não derruba o build.
console.log("openapiBreaking=SKIP reason=parse-error");
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] SKIP — JSON do oasdiff não parseável: ${parseErr.message}\n` +
`[openapi-breaking] stdout (primeiros 500): ${trimmed.slice(0, 500)}\n`
);
}
process.exitCode = 0;
return;
}
}
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(parsed, null, 2) + "\n");
return;
}
const { count, byId, byPath, items } = parseOasdiffBreaking(parsed);
// Emitir KEY=VALUE para o coletor de métricas.
console.log(`openapiBreaking=${count}`);
if (!QUIET) {
if (count > 0) {
const topIds = Object.entries(byId)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([id, n]) => `${id}(${n})`)
.join(", ");
process.stderr.write(
`[openapi-breaking] ⚠️ ${count} breaking change(s) vs '${baseRef}' (top: ${topIds})\n`
);
for (const it of items.slice(0, 20)) {
const op = it.operation ?? it.Operation ?? "?";
const p = it.path ?? it.Path ?? "?";
const txt = it.text ?? it.Text ?? it.id ?? "";
process.stderr.write(`[openapi-breaking] ✗ ${op} ${p}${txt}\n`);
}
if (items.length > 20) {
process.stderr.write(`[openapi-breaking] … +${items.length - 20} more\n`);
}
// Pista de mitigação: por path.
const topPaths = Object.entries(byPath)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([p, n]) => `${p}(${n})`)
.join(", ");
process.stderr.write(`[openapi-breaking] affected paths: ${topPaths}\n`);
process.stderr.write(
"[openapi-breaking] ADVISORY — promove a BLOQUEANTE depois. Se a quebra é\n" +
"[openapi-breaking] intencional (major bump), documente no PR; senão, ajuste a spec.\n"
);
} else {
process.stderr.write(
`[openapi-breaking] OK — nenhuma breaking change na spec vs '${baseRef}'.\n`
);
}
}
// ADVISORY — sai 0 SEMPRE nesta versão, mesmo com count > 0.
process.exitCode = 0;
} finally {
// Limpa o arquivo temp da spec base.
try {
fs.unlinkSync(baseTmp);
} catch {
// best-effort
}
}
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();

View File

@@ -28,6 +28,21 @@ const QUIET = process.argv.includes("--quiet");
const PRINT_JSON = process.argv.includes("--json");
const GITLEAKS_CONFIG = path.join(ROOT, ".gitleaks.toml");
// Source directories to scan for secrets. We deliberately scope to the
// production/source trees instead of scanning the whole working dir:
// • `gitleaks dir .` (and `detect --no-git --source .`) WALKS the entire tree
// and READS every file — including a real `node_modules/` (90k+ files) when
// present (CI runs `npm ci`). gitleaks has no traversal-exclude flag: the
// `.gitleaks.toml [allowlist].paths` list filters FINDINGS *after* each file
// is read, so it does NOT speed up the walk. The full walk blows past the
// timeout in CI (confirmed: ETIMEDOUT) → the gate silently never produces a
// value. Scoping the scan to the source dirs keeps it fast (~6s) while still
// covering every place an embedded secret would actually be a risk (the same
// dirs Hard Rule #8 governs: src/open-sse/electron/bin, plus scripts/).
// • We also drop git-history mode (scanning 4500+ commits is slow and grows
// unbounded); the current working tree is what ships.
const SECRET_SCAN_DIRS = ["src", "open-sse", "bin", "electron", "scripts"];
// ---------------------------------------------------------------------------
// Pure parsing function (exported for tests)
// ---------------------------------------------------------------------------
@@ -140,66 +155,94 @@ function main() {
if (!QUIET) {
process.stderr.write(
"[check-secrets] SKIP — gitleaks não encontrado no PATH.\n" +
"[check-secrets] Instale via: https://github.com/gitleaks/gitleaks\n" +
"[check-secrets] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
"[check-secrets] Instale via: https://github.com/gitleaks/gitleaks\n" +
"[check-secrets] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
);
}
process.exitCode = 0;
return;
}
// Construir args sem interpolação de variáveis no script (Hard Rule #13)
const args = [
"detect",
"--no-git", // escanear diretório em vez de histórico git (mais rápido em CI)
"--report-format", "json",
"--report-path", "-", // output para stdout
"--source", ROOT,
"--no-banner",
];
// Resolver os diretórios de fonte que realmente existem (robusto se um sumir).
const scanDirs = SECRET_SCAN_DIRS.filter((d) => fs.existsSync(path.join(ROOT, d)));
// Adicionar config personalizada se existir
if (fs.existsSync(GITLEAKS_CONFIG)) {
args.push("--config", GITLEAKS_CONFIG);
if (scanDirs.length === 0) {
// Nenhum dir de fonte encontrado — nada a escanear (advisory, sai 0).
console.log("secretFindings=0");
if (!QUIET) {
process.stderr.write("[check-secrets] Nenhum diretório de fonte encontrado para escanear.\n");
}
process.exitCode = 0;
return;
}
if (!QUIET) {
process.stderr.write("[check-secrets] Rodando gitleaks detect --no-git --report-format json ...\n");
process.stderr.write(
`[check-secrets] Rodando gitleaks dir <dir> --report-format json para: ${scanDirs.join(", ")} ...\n`
);
}
let stdout = "";
try {
stdout = execFileSync(gitleaksBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 120_000, // 2 min
});
} catch (err) {
// exit 1 com stdout = findings encontrados (comportamento esperado do gitleaks)
stdout = err.stdout ? String(err.stdout) : "";
const stderr = err.stderr ? String(err.stderr) : "";
// `gitleaks dir` aceita UM ÚNICO path posicional (uso: `gitleaks dir [flags]
// [path]`). Passar múltiplos paths faz o gitleaks ignorar os extras e cair para
// escanear o CWD inteiro (`.`) — o que re-traz node_modules/docs/tests e o
// timeout original. Por isso escaneamos CADA diretório de fonte em uma invocação
// separada e concatenamos os findings.
const gitleaksJson = [];
for (const dir of scanDirs) {
const args = [
"dir",
dir,
"--report-format",
"json",
"--report-path",
"-", // output para stdout
"--no-banner",
];
if (fs.existsSync(GITLEAKS_CONFIG)) {
args.push("--config", GITLEAKS_CONFIG);
}
if (err.status === 1 && stdout.trim()) {
// Normal: gitleaks achou findings e saiu com exit 1
} else if (!stdout.trim()) {
process.stderr.write(`[check-secrets] ERRO ao executar gitleaks: ${err.message}\n`);
if (stderr) process.stderr.write(`[check-secrets] stderr: ${stderr.slice(0, 500)}\n`);
let stdout = "";
try {
stdout = execFileSync(gitleaksBin, args, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: 90_000, // 90s por dir — o scan escopado completa em ~10s; folga ampla
});
} catch (err) {
// exit 1 com stdout = findings encontrados (comportamento esperado do gitleaks)
stdout = err.stdout ? String(err.stdout) : "";
const stderr = err.stderr ? String(err.stderr) : "";
if (err.status === 1 && stdout.trim()) {
// Normal: gitleaks achou findings neste dir e saiu com exit 1
} else if (!stdout.trim()) {
process.stderr.write(
`[check-secrets] ERRO ao executar gitleaks em '${dir}': ${err.message}\n`
);
if (stderr) process.stderr.write(`[check-secrets] stderr: ${stderr.slice(0, 500)}\n`);
process.exit(2);
}
}
if (!stdout.trim() || stdout.trim() === "null") {
continue; // sem findings neste dir
}
let parsed;
try {
parsed = JSON.parse(stdout.trim());
} catch (parseErr) {
process.stderr.write(
`[check-secrets] ERRO ao parsear JSON do gitleaks em '${dir}': ${parseErr.message}\n`
);
process.stderr.write(
`[check-secrets] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`
);
process.exit(2);
}
}
let gitleaksJson;
if (!stdout.trim() || stdout.trim() === "null") {
gitleaksJson = [];
} else {
try {
const parsed = JSON.parse(stdout.trim());
gitleaksJson = parsed === null ? [] : parsed;
} catch (parseErr) {
process.stderr.write(`[check-secrets] ERRO ao parsear JSON do gitleaks: ${parseErr.message}\n`);
process.stderr.write(`[check-secrets] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`);
process.exit(2);
if (Array.isArray(parsed)) {
gitleaksJson.push(...parsed);
}
}
@@ -223,7 +266,7 @@ function main() {
process.stderr.write(`[check-secrets] Findings: ${findingCount} (top rules: ${topRules})\n`);
process.stderr.write(
"[check-secrets] Para allowlistar findings legítimos (fixtures de teste, creds públicas),\n" +
"[check-secrets] adicione entradas em .gitleaks.toml [[allowlist]] com comentário.\n"
"[check-secrets] adicione entradas em .gitleaks.toml [[allowlist]] com comentário.\n"
);
} else {
process.stderr.write("[check-secrets] Nenhum finding detectado.\n");

View File

@@ -34,7 +34,7 @@ const ROOT = process.cwd();
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "test-discovery-baseline.json")
: path.join(ROOT, "config/quality/test-discovery-baseline.json")
);
const UPDATE = process.argv.includes("--update");

View File

@@ -15,7 +15,10 @@ import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const FORBIDDEN_PREFIXES = ["node_modules/", ".next/", "coverage/"];
const FORBIDDEN_EXACT = new Set(["quality-metrics.json"]);
const FORBIDDEN_EXACT = new Set([
"quality-metrics.json", // legacy root location (still forbidden if a stale run writes it)
"config/quality/quality-metrics.json", // current generated location (collect-metrics.mjs)
]);
/**
* Verifica se algum caminho na lista de arquivos rastreados corresponde a um
@@ -80,7 +83,9 @@ function main() {
process.exit(0);
}
console.error(`[tracked-artifacts] FAIL — ${violations.length} forbidden artifact(s) tracked by git:`);
console.error(
`[tracked-artifacts] FAIL — ${violations.length} forbidden artifact(s) tracked by git:`
);
for (const v of violations) {
console.error(`${v}`);
}

View File

@@ -33,7 +33,7 @@ const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "quality-baseline.json")
: path.join(ROOT, "config/quality/quality-baseline.json")
);
// Small epsilon to absorb float noise between runs (type-coverage can vary ~0.01%).