Spaces:
Runtime error
Runtime error
File size: 18,423 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 | #!/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/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)
//
// Por default é ADVISORY (sai 0 SEMPRE, mesmo com N>0). Passe --ratchet para
// tornar BLOQUEANTE: lê metrics.openapiBreaking.value de
// config/quality/quality-baseline.json, compara a contagem MEDIDA e SAI 1 SE — E
// SOMENTE SE — a medida for MAIOR que o baseline (regressão real, direction:down).
// Qualquer SKIP gracioso (oasdiff ausente do PATH, spec base não resolvível em
// clone shallow, JSON inválido) SAI 0 MESMO com --ratchet — uma falha de MEDIÇÃO
// nunca bloqueia, só uma regressão MEDIDA bloqueia (mesma trajetória de todo gate
// neste repo: report → ratchet → block).
//
// Base ref:
// • CI passa BASE_REF=${{ github.base_ref }} (ex.: "release/vX.Y.Z").
// • Local: default derivado da versão do package.json (releaseBranchForVersion),
// ex.: package 3.8.29 → "origin/release/v3.8.29" — nunca fica stale entre ciclos.
// A spec base é extraída com `git show <BASE_REF>:docs/openapi.yaml`.
//
// Uso:
// node scripts/check/check-openapi-breaking.mjs
// BASE_REF=origin/release/vX.Y.Z 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
// node scripts/check/check-openapi-breaking.mjs --ratchet # falha (exit 1) numa regressão
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 RATCHET = process.argv.includes("--ratchet");
const SPEC_REL = "docs/openapi.yaml";
const SPEC_PATH = path.join(ROOT, "docs", "openapi.yaml");
/**
* Deriva o branch base de release a partir de uma versão semver
* (ex.: "3.8.29" → "origin/release/v3.8.29"). Mantém o default sincronizado com
* o ciclo de release SEM hard-code: o version-bump atualiza package.json a cada
* ciclo, então o default nunca fica stale (era "origin/release/v3.8.27" fixo).
* Ignora sufixos de prerelease/build (ex.: "3.8.29-dev.2" → v3.8.29).
*
* @param {string|null|undefined} version
* @returns {string|null} branch base (sem `origin/` ausente) ou null se não-semver
*/
export function releaseBranchForVersion(version) {
const m = String(version ?? "")
.trim()
.match(/^(\d+)\.(\d+)\.(\d+)/);
return m ? `origin/release/v${m[1]}.${m[2]}.${m[3]}` : null;
}
function readPackageVersion() {
try {
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
} catch {
return null;
}
}
// CI sempre passa BASE_REF=${{ github.base_ref }} e vence; este default só vale
// para runs locais. Derivado da versão para não re-driftar a cada release.
const DEFAULT_BASE_REF = releaseBranchForVersion(readPackageVersion()) || "origin/release/v3.8.29";
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
// ---------------------------------------------------------------------------
// 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 };
}
// ---------------------------------------------------------------------------
// Ratchet (direction:down) — exported for tests
// ---------------------------------------------------------------------------
/**
* Avalia a contagem MEDIDA de breaking changes contra o baseline.
* Direction: down (a contagem só pode CAIR — mais breaking changes = regressão).
*
* Uma medição ausente (current null/undefined) OU um baseline ausente
* (baseline null/undefined) → { regressed:false, skipped:true }: sem uma das
* duas pontas não há ratchet possível, então o caller trata como SKIP gracioso
* (exit 0 mesmo com --ratchet). Uma falha de MEDIÇÃO nunca bloqueia.
*
* @param {object} args
* @param {number|null} args.current - Breaking changes medidos agora (null = sem medição).
* @param {number|null} args.baseline - Contagem congelada em quality-baseline.json (null = sem baseline).
* @returns {{ regressed: boolean, skipped: boolean }}
*/
export function evaluateOpenapiRatchet({ current, baseline }) {
if (current === null || current === undefined || baseline === null || baseline === undefined) {
return { regressed: false, skipped: true };
}
return { regressed: current > baseline, skipped: false };
}
/**
* Lê metrics.openapiBreaking.value do quality-baseline.json.
* Retorna null se o arquivo ou a métrica estiverem ausentes/inválidos (sem
* baseline não há ratchet possível — o caller trata como SKIP gracioso, exit 0).
*
* @param {string} baselinePath
* @returns {number|null}
*/
export function readBaselineOpenapiValue(baselinePath = BASELINE_PATH) {
if (!fs.existsSync(baselinePath)) return null;
let baselineJson;
try {
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
} catch {
return null;
}
const metric = baselineJson?.metrics?.openapiBreaking;
if (!metric || typeof metric.value !== "number") return null;
return metric.value;
}
// ---------------------------------------------------------------------------
// 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`);
if (RATCHET) {
process.stderr.write(
"[openapi-breaking] Se a quebra é intencional (major bump), documente no PR e\n" +
"[openapi-breaking] re-baseline metrics.openapiBreaking em config/quality/quality-baseline.json\n" +
"[openapi-breaking] com justificativa + issue de tracking; senão, ajuste a spec.\n"
);
} else {
process.stderr.write(
"[openapi-breaking] ADVISORY — passe --ratchet para BLOQUEAR uma regressão. 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`
);
}
}
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
applyRatchet(count);
} finally {
// Limpa o arquivo temp da spec base.
try {
fs.unlinkSync(baseTmp);
} catch {
// best-effort
}
}
}
/**
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
*
* @param {number} count - Contagem MEDIDA de breaking changes (medição bem-sucedida).
*/
function applyRatchet(count) {
if (!RATCHET) {
process.exitCode = 0;
return;
}
const baselineValue = readBaselineOpenapiValue(BASELINE_PATH);
const { regressed, skipped } = evaluateOpenapiRatchet({
current: count,
baseline: baselineValue,
});
if (skipped) {
if (!QUIET) {
process.stderr.write(
"[openapi-breaking] baseline ausente (metrics.openapiBreaking) — SKIP gracioso, sai 0.\n"
);
}
process.exitCode = 0;
return;
}
if (regressed) {
process.stderr.write(
`[openapi-breaking] REGRESSÃO — ${count} breaking change(s) > baseline ${baselineValue}\n` +
" → Ajuste a spec para não quebrar clientes existentes. Se a quebra é intencional\n" +
" (major bump), re-baseline metrics.openapiBreaking em\n" +
" config/quality/quality-baseline.json com justificativa + issue de tracking.\n"
);
process.exitCode = 1;
return;
}
if (!QUIET) {
process.stderr.write(
`[openapi-breaking] OK — sem regressão (${count} breaking change(s), baseline ${baselineValue}).\n`
);
}
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|