mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
1f87a9589c
commit
81a37b67ed
@@ -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();
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
357
scripts/check/check-openapi-breaking.mjs
Normal file
357
scripts/check/check-openapi-breaking.mjs
Normal 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();
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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%).
|
||||
|
||||
290
scripts/docs/sync-wiki.mjs
Normal file
290
scripts/docs/sync-wiki.mjs
Normal file
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/docs/sync-wiki.mjs
|
||||
// Full GitHub wiki content + cover-count sync.
|
||||
//
|
||||
// WHY: the wiki has no generator and historically drifts (it sat at "212+ providers /
|
||||
// 14 strategies / 37 MCP tools" while code was at 226 / 15 / 87, and new docs like
|
||||
// SUPPLY_CHAIN never appeared). This closes the loop: content + counts, automated per
|
||||
// release by .github/workflows/wiki-sync.yml.
|
||||
//
|
||||
// DESIGN — update-in-place, never duplicates:
|
||||
// 1. The wiki page names are hand-curated and NOT deterministically reproducible
|
||||
// (e.g. "API-Reference" vs "Fly-io-Deployment-Guide"). So we iterate the EXISTING
|
||||
// wiki pages and fuzzy-match each to a docs/ source by normalized key
|
||||
// (lowercase, strip non-alphanumeric). When a source exists we rewrite that exact
|
||||
// page → zero risk of creating a parallel/duplicate page.
|
||||
// 2. A curated allowlist (NEW_PAGE_EXCLUDE) keeps internal docs (audit reports, plans,
|
||||
// the docs index) off the public wiki; every other unmatched docs page is ADDED
|
||||
// with a deterministic acronym-aware name.
|
||||
// 3. Hand-curated pages with no docs source (Home, _Sidebar, Header, _Footer,
|
||||
// Languages) are left untouched — except the four cover counts on Home.md.
|
||||
// 4. EN by default. Localized mirrors (<locale>‐Page) are pure update-in-place and
|
||||
// only touched with --include-i18n (the i18n source lags and is validated
|
||||
// separately).
|
||||
//
|
||||
// Content transform: strip the YAML frontmatter, prepend the wiki language banner.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> # write
|
||||
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --dry-run # report only
|
||||
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --check # exit 1 on drift
|
||||
// node scripts/docs/sync-wiki.mjs --wiki-dir <path> --include-i18n # also localized
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// U+2010 HYPHEN separates the locale prefix in localized wiki page names.
|
||||
const LOCALE_SEP = "‐";
|
||||
export const WIKI_BANNER = "> 🌍 [View in other languages](Languages)\n\n\n";
|
||||
|
||||
// Docs that must never become public wiki pages (internal reports/plans/index).
|
||||
export const NEW_PAGE_EXCLUDE = new Set([
|
||||
"README", // docs index, not a page
|
||||
"DOCUMENTATION_AUDIT_REPORT",
|
||||
"DOCUMENTATION_OVERHAUL_PLAN",
|
||||
"E2E_DASHBOARD_SHAKEDOWN_v3.8.0",
|
||||
"SUBMIT_PR",
|
||||
"fix-opencode-context",
|
||||
"plugins", // docs/dev/plugins.md — internal dev note
|
||||
"SOCKET_DEV_FINDINGS",
|
||||
]);
|
||||
|
||||
// Acronyms kept upper-case when minting a NEW page name (existing pages keep their
|
||||
// curated name via fuzzy match, so this only affects brand-new pages).
|
||||
const ACRONYMS = new Set([
|
||||
"api", "mcp", "a2a", "acp", "cli", "sse", "i18n", "pii", "oauth", "vm", "ai",
|
||||
"llm", "sdk", "ide", "ui", "ux", "tls", "mitm", "ws", "cors", "jwt", "db", "vps",
|
||||
]);
|
||||
|
||||
/** Normalized matching key: lowercase, drop extension + every non-alphanumeric char. */
|
||||
export function normKey(s) {
|
||||
return s.toLowerCase().replace(/\.md$/, "").replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
/** Deterministic wiki page name for a brand-new page (acronym-aware Title-Case-dashed). */
|
||||
export function toWikiName(basename) {
|
||||
return basename
|
||||
.replace(/\.md$/, "")
|
||||
.split(/[_\-\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((t) => (ACRONYMS.has(t.toLowerCase()) ? t.toUpperCase() : t[0].toUpperCase() + t.slice(1).toLowerCase()))
|
||||
.join("-");
|
||||
}
|
||||
|
||||
/** Strip YAML frontmatter and prepend the wiki language banner. Pure; exported for tests. */
|
||||
export function toWikiContent(docMarkdown) {
|
||||
const body = docMarkdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").replace(/^\s+/, "");
|
||||
return WIKI_BANNER + body.replace(/\s*$/, "") + "\n";
|
||||
}
|
||||
|
||||
function read(rel) {
|
||||
const p = path.join(ROOT, rel);
|
||||
return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
|
||||
}
|
||||
|
||||
// ---- cover-page counts (source of truth) ----
|
||||
function providerCount() {
|
||||
const m = read("docs/reference/PROVIDER_REFERENCE.md").match(/Total providers:\s*\*\*(\d+)\*\*/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
function strategyCount() {
|
||||
const m = read("src/shared/constants/routingStrategies.ts").match(
|
||||
/ROUTING_STRATEGY_VALUES\s*=\s*\[([^\]]*)\]/
|
||||
);
|
||||
return m ? (m[1].match(/"[^"]+"/g) || []).length : null;
|
||||
}
|
||||
function localeCount() {
|
||||
try {
|
||||
const c = JSON.parse(read("config/i18n.json"));
|
||||
return Array.isArray(c.locales) ? c.locales.length : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function mcpToolCount() {
|
||||
// Prefer a literal; the constant is computed at runtime so best-effort only.
|
||||
const m = read("open-sse/mcp-server/server.ts").match(/TOTAL_MCP_TOOL_COUNT\s*=\s*(\d+)\b/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
export function readCounts() {
|
||||
return {
|
||||
providers: providerCount(),
|
||||
strategies: strategyCount(),
|
||||
mcpTools: mcpToolCount(),
|
||||
locales: localeCount(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply cover-page count substitutions to Home.md text. Pure; exported for tests. */
|
||||
export function syncHomeCounts(home, counts) {
|
||||
let out = home;
|
||||
if (counts.providers) {
|
||||
out = out
|
||||
.replace(/Connect every AI tool to \d+ providers/g, `Connect every AI tool to ${counts.providers} providers`)
|
||||
.replace(/\*\*\d+ AI Providers\*\*/g, `**${counts.providers} AI Providers**`)
|
||||
.replace(/All \d+ supported providers/g, `All ${counts.providers} supported providers`)
|
||||
.replace(/\b\d+ providers\b/g, `${counts.providers} providers`);
|
||||
}
|
||||
if (counts.strategies) {
|
||||
out = out.replace(/\*\*\d+ Routing Strategies\*\*/g, `**${counts.strategies} Routing Strategies**`);
|
||||
}
|
||||
if (counts.mcpTools) {
|
||||
out = out.replace(/(\|\s*\*\*MCP Server\*\*\s*\|\s*)\d+( tools)/g, `$1${counts.mcpTools}$2`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- docs discovery ----
|
||||
function walkMarkdown(dir, acc = []) {
|
||||
if (!fs.existsSync(dir)) return acc;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walkMarkdown(p, acc);
|
||||
else if (e.name.endsWith(".md")) acc.push(p);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Build normKey → docs absolute path for English docs (docs/ minus docs/i18n). */
|
||||
function indexEnglishDocs() {
|
||||
const docsRoot = path.join(ROOT, "docs");
|
||||
const files = walkMarkdown(docsRoot).filter((f) => !f.includes(`${path.sep}i18n${path.sep}`));
|
||||
const byKey = new Map();
|
||||
for (const f of files) {
|
||||
const base = path.basename(f, ".md");
|
||||
const k = normKey(base);
|
||||
// First-writer-wins keeps a deterministic pick for basename collisions.
|
||||
if (!byKey.has(k)) byKey.set(k, { file: f, base });
|
||||
}
|
||||
return byKey;
|
||||
}
|
||||
|
||||
/** Build normKey → docs path for a given locale's i18n tree. */
|
||||
function indexLocaleDocs(locale) {
|
||||
const root = path.join(ROOT, "docs", "i18n", locale);
|
||||
const byKey = new Map();
|
||||
for (const f of walkMarkdown(root)) {
|
||||
const k = normKey(path.basename(f, ".md"));
|
||||
if (!byKey.has(k)) byKey.set(k, f);
|
||||
}
|
||||
return byKey;
|
||||
}
|
||||
|
||||
function listWikiPages(wikiDir) {
|
||||
return fs
|
||||
.readdirSync(wikiDir)
|
||||
.filter((n) => n.endsWith(".md"))
|
||||
.map((n) => n.slice(0, -3));
|
||||
}
|
||||
|
||||
/** Split a wiki page name into { locale, name }. EN pages have locale = null. */
|
||||
export function parseWikiPage(page) {
|
||||
const idx = page.indexOf(LOCALE_SEP);
|
||||
if (idx === -1) return { locale: null, name: page };
|
||||
return { locale: page.slice(0, idx), name: page.slice(idx + 1) };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const wikiDir = args.includes("--wiki-dir") ? args[args.indexOf("--wiki-dir") + 1] : null;
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const check = args.includes("--check");
|
||||
const includeI18n = args.includes("--include-i18n");
|
||||
const updateExisting = args.includes("--update-existing");
|
||||
if (!wikiDir || !fs.existsSync(wikiDir)) {
|
||||
console.error("usage: sync-wiki.mjs --wiki-dir <path> [--dry-run|--check] [--include-i18n]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const enDocs = indexEnglishDocs();
|
||||
const wikiPages = listWikiPages(wikiDir);
|
||||
const enWikiKeys = new Set();
|
||||
const localeIndexes = new Map();
|
||||
|
||||
const plan = { update: [], add: [], untouched: [], countsChanged: false };
|
||||
|
||||
// 1. Update existing wiki pages from their docs source.
|
||||
for (const page of wikiPages) {
|
||||
if (page === "Home") continue; // handled by counts below
|
||||
const { locale, name } = parseWikiPage(page);
|
||||
const key = normKey(name);
|
||||
let srcFile = null;
|
||||
if (!locale) {
|
||||
enWikiKeys.add(key);
|
||||
srcFile = enDocs.get(key)?.file ?? null;
|
||||
} else if (includeI18n) {
|
||||
if (!localeIndexes.has(locale)) localeIndexes.set(locale, indexLocaleDocs(locale));
|
||||
srcFile = localeIndexes.get(locale).get(key) ?? null;
|
||||
}
|
||||
if (!srcFile) {
|
||||
plan.untouched.push(page);
|
||||
continue;
|
||||
}
|
||||
const next = toWikiContent(fs.readFileSync(srcFile, "utf8"));
|
||||
const cur = fs.readFileSync(path.join(wikiDir, `${page}.md`), "utf8");
|
||||
if (next !== cur) plan.update.push({ page, srcFile });
|
||||
}
|
||||
|
||||
// 2. Add curated new English pages (unmatched docs, minus the exclude list).
|
||||
for (const [key, { file, base }] of enDocs) {
|
||||
if (enWikiKeys.has(key)) continue;
|
||||
if (NEW_PAGE_EXCLUDE.has(base)) continue;
|
||||
plan.add.push({ page: toWikiName(base), srcFile: file, base });
|
||||
}
|
||||
|
||||
// 3. Home cover counts.
|
||||
const counts = readCounts();
|
||||
const homePath = path.join(wikiDir, "Home.md");
|
||||
let homeAfter = null;
|
||||
if (fs.existsSync(homePath)) {
|
||||
const before = fs.readFileSync(homePath, "utf8");
|
||||
homeAfter = syncHomeCounts(before, counts);
|
||||
plan.countsChanged = homeAfter !== before;
|
||||
}
|
||||
|
||||
// ---- report ----
|
||||
// Updating existing pages is opt-in: several docs SOURCES carry stale counts (e.g.
|
||||
// ARCHITECTURE.md still says "177 providers / 37 MCP tools" while the wiki cover was
|
||||
// hand-patched to 226/87). Overwriting from a staler source would REGRESS the wiki, so
|
||||
// by default we only ADD missing pages and sync Home counts. Pass --update-existing
|
||||
// once the docs sources are regenerated (see docs/ops/DOCUMENTATION_AUDIT_REPORT.md).
|
||||
const updates = updateExisting ? plan.update : [];
|
||||
const total = updates.length + plan.add.length + (plan.countsChanged ? 1 : 0);
|
||||
console.log(`[wiki-sync] counts: ${JSON.stringify(counts)}`);
|
||||
console.log(
|
||||
`[wiki-sync] add: ${plan.add.length} | Home counts: ${plan.countsChanged ? "drift" : "in-sync"} | ` +
|
||||
`existing-page updates: ${plan.update.length} (${updateExisting ? "ENABLED" : "skipped — needs --update-existing"}) | untouched: ${plan.untouched.length}`
|
||||
);
|
||||
if (dryRun || check) {
|
||||
if (plan.add.length) console.log(` add → ${plan.add.map((a) => a.page).join(", ")}`);
|
||||
if (plan.update.length)
|
||||
console.log(
|
||||
` ${updateExisting ? "update" : "would-update (skipped)"} → ${plan.update.map((u) => u.page).slice(0, 60).join(", ")}${plan.update.length > 60 ? " …" : ""}`
|
||||
);
|
||||
if (check) {
|
||||
if (total > 0) {
|
||||
console.error(`✗ wiki out of sync (${total} change(s) pending)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✓ wiki in sync");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- write ----
|
||||
for (const { page, srcFile } of [...updates, ...plan.add]) {
|
||||
fs.writeFileSync(path.join(wikiDir, `${page}.md`), toWikiContent(fs.readFileSync(srcFile, "utf8")));
|
||||
}
|
||||
if (plan.countsChanged && homeAfter != null) fs.writeFileSync(homePath, homeAfter);
|
||||
console.log(
|
||||
`[wiki-sync] wrote ${total} page(s) (add: ${plan.add.length}, updates: ${updates.length}, counts: ${plan.countsChanged ? 1 : 0}).`
|
||||
);
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (invokedDirectly) main();
|
||||
@@ -12,8 +12,12 @@ 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.resolve(getArg("--baseline", path.join(cwd, "quality-baseline.json")));
|
||||
const METRICS = path.resolve(getArg("--metrics", path.join(cwd, "quality-metrics.json")));
|
||||
const BASELINE = path.resolve(
|
||||
getArg("--baseline", path.join(cwd, "config/quality/quality-baseline.json"))
|
||||
);
|
||||
const METRICS = path.resolve(
|
||||
getArg("--metrics", path.join(cwd, "config/quality/quality-metrics.json"))
|
||||
);
|
||||
const SUMMARY = getArg("--summary", null);
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
// --allow-missing: pula métricas do baseline ausentes do metrics (em vez de falhar).
|
||||
@@ -55,8 +59,9 @@ for (const [key, spec] of Object.entries(baseline.metrics)) {
|
||||
const tightenSlack = spec.tightenSlack !== undefined ? spec.tightenSlack : eps;
|
||||
|
||||
if (current === undefined) {
|
||||
if (ALLOW_MISSING) {
|
||||
rows.push([key, base, "—", "SKIP (ausente)"]);
|
||||
if (ALLOW_MISSING || spec.dedicatedGate === true) {
|
||||
const reason = spec.dedicatedGate === true ? "SKIP (dedicated gate)" : "SKIP (ausente)";
|
||||
rows.push([key, base, "—", reason]);
|
||||
} else {
|
||||
failures.push(`métrica "${key}" ausente em ${path.basename(METRICS)}`);
|
||||
rows.push([key, base, "—", "MISSING"]);
|
||||
@@ -73,7 +78,7 @@ for (const [key, spec] of Object.entries(baseline.metrics)) {
|
||||
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`,
|
||||
`${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`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,7 +91,7 @@ for (const [key, spec] of Object.entries(baseline.metrics)) {
|
||||
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`,
|
||||
`${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`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,10 +104,10 @@ 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(", ")}`,
|
||||
`[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.`,
|
||||
`[quality-ratchet] WARN: adicione ${orphans.length === 1 ? "essa métrica" : "essas métricas"} ao baseline (com value/direction) para que sejam catraceadas.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,7 +145,7 @@ if (failures.length) {
|
||||
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"),
|
||||
tightenFailures.map((f) => " ✗ " + f).join("\n")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -250,6 +250,9 @@ if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
||||
coverageByModule();
|
||||
openapiCoverage();
|
||||
await i18nUiCoverage();
|
||||
fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n");
|
||||
fs.writeFileSync(
|
||||
path.join(cwd, "config/quality/quality-metrics.json"),
|
||||
JSON.stringify(out, null, 2) + "\n"
|
||||
);
|
||||
console.log("[collect-metrics]", JSON.stringify(out));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user