mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
ci(quality): add OpenAPI breaking-change gate (oasdiff, advisory) (#3951)
New advisory gate (Fase 8 B.4) that diffs the public API contract docs/reference/openapi.yaml against the base branch via oasdiff, surfacing removed endpoints, newly-required params, removed response fields, etc. - scripts/check/check-openapi-breaking.mjs: resolves base spec via git show <BASE_REF>:docs/reference/openapi.yaml to a temp file, runs oasdiff breaking --format json, parses+counts breaking changes, emits openapiBreaking=N (KEY=VALUE for collect-metrics). ADVISORY: exit 0 always; graceful SKIP when oasdiff is absent or the base spec can't be resolved. - package.json: check:openapi-breaking script. - .github/workflows/ci.yml (quality-extended, advisory): install oasdiff via gh release download, add the breaking-change step (BASE_REF via env, never shell-interpolated), fetch-depth: 0 so git show can read the base spec. - tests/unit/build/check-openapi-breaking.test.ts: parser counting/grouping + binary-absent SKIP integration. - docs/architecture/QUALITY_GATES.md: document the quality-extended job table. - docs/reference/openapi.yaml: define the BadRequest/NotFound/InternalError response components that were referenced (14 call sites) but never defined — a pre-existing dangling-$ref defect that blocked oasdiff from loading the spec. No path behavior changes; purely additive to components.responses.
This commit is contained in:
committed by
GitHub
parent
bc32c6710e
commit
dbf9293c7d
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -147,7 +147,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||
# spec via `git show <base_ref>:docs/reference/openapi.yaml`; a shallow clone
|
||||
# would lack the base ref and the gate would self-skip (base-unresolved).
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
@@ -195,6 +200,10 @@ jobs:
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin"
|
||||
# zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin
|
||||
pipx install zizmor || pip install --user zizmor
|
||||
# oasdiff — download latest linux amd64 tarball via gh (authed), extract binary
|
||||
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
|
||||
gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
|
||||
tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff
|
||||
# ALWAYS export the bin dir (even if any step above failed)
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
# diagnostics — prove what installed on the next CI run
|
||||
@@ -202,6 +211,7 @@ jobs:
|
||||
"$HOME/.local/bin/gitleaks" version || true
|
||||
"$HOME/.local/bin/actionlint" -version || true
|
||||
"$HOME/.local/bin/osv-scanner" --version || true
|
||||
"$HOME/.local/bin/oasdiff" --version || true
|
||||
zizmor --version || true
|
||||
- name: Secret scan (gitleaks; skips if absent)
|
||||
run: npm run check:secrets
|
||||
@@ -209,6 +219,15 @@ jobs:
|
||||
run: npm run check:vuln-ratchet
|
||||
- name: Workflow lint (actionlint+zizmor; skips if absent)
|
||||
run: npm run check:workflows
|
||||
# OpenAPI breaking-change detection (oasdiff). Diffs the PR's public API
|
||||
# contract (docs/reference/openapi.yaml) against the base branch's spec.
|
||||
# ADVISORY: reports `openapiBreaking=N` and self-skips when oasdiff is absent
|
||||
# or the base spec can't be resolved. BASE_REF is read by the script from the
|
||||
# env (never interpolated into a shell body) — workflow-injection-safe.
|
||||
- name: OpenAPI breaking-change (oasdiff; advisory)
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: npm run check:openapi-breaking
|
||||
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
|
||||
@@ -54,6 +54,21 @@ Runs after `test-coverage`. Blocks merge on failure.
|
||||
| `check:duplication` | Code duplication (jscpd@4) does not exceed baseline in `quality-baseline.json` | Yes |
|
||||
| `check:complexity` | File-level cyclomatic complexity does not exceed the cap | Yes |
|
||||
|
||||
### Job: `quality-extended`
|
||||
|
||||
Entire job is advisory (`continue-on-error: true`). The npm-based ratchets run for
|
||||
real; the external scanners install via `gh release download` and self-skip (exit 0)
|
||||
when a binary is still absent.
|
||||
|
||||
| Script | Validates | Blocking |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------ |
|
||||
| `check:circular-deps` | No circular dependencies (dpdm) | **Advisory** |
|
||||
| `check:bundle-size` | Bundle size does not exceed the cap | **Advisory** |
|
||||
| `check:secrets` | Secret scanning (gitleaks) — skips if binary absent | **Advisory** |
|
||||
| `check:vuln-ratchet` | Dependency vulnerabilities (osv-scanner) do not regress — skips if binary absent | **Advisory** |
|
||||
| `check:workflows` | Workflow lint (actionlint + zizmor) — skips if binaries absent | **Advisory** |
|
||||
| `check:openapi-breaking` | Breaking changes to the public API contract (`openapi.yaml`) vs the base branch (oasdiff) — emits `openapiBreaking=N`; skips if oasdiff absent or base spec unresolvable | **Advisory** |
|
||||
|
||||
### Job: `docs-sync-strict`
|
||||
|
||||
Runs on every PR to `main`. Blocks merge on failure.
|
||||
|
||||
@@ -5007,6 +5007,39 @@ components:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ValidationErrorResponse"
|
||||
BadRequest:
|
||||
description: The request was malformed or failed validation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApiErrorResponse"
|
||||
example:
|
||||
error:
|
||||
message: Invalid request
|
||||
type: invalid_request_error
|
||||
requestId: 8c2b1d44-7a3e-4c91-9b0f-1e2d3c4b5a60
|
||||
NotFound:
|
||||
description: The requested resource was not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApiErrorResponse"
|
||||
example:
|
||||
error:
|
||||
message: Resource not found
|
||||
type: not_found_error
|
||||
requestId: 4d5e6f70-1a2b-3c4d-5e6f-7a8b9c0d1e2f
|
||||
InternalError:
|
||||
description: An unexpected server error occurred
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ApiErrorResponse"
|
||||
example:
|
||||
error:
|
||||
message: Internal server error
|
||||
type: api_error
|
||||
requestId: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
|
||||
|
||||
schemas:
|
||||
PlaygroundPreset:
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"check:provider-consistency": "node --import tsx scripts/check/check-provider-consistency.ts",
|
||||
"check:fetch-targets": "node scripts/check/check-fetch-targets.mjs",
|
||||
"check:openapi-routes": "node scripts/check/check-openapi-routes.mjs",
|
||||
"check:openapi-breaking": "node scripts/check/check-openapi-breaking.mjs",
|
||||
"check:deps": "node scripts/check/check-deps.mjs",
|
||||
"check:file-size": "node scripts/check/check-file-size.mjs",
|
||||
"check:duplication": "node scripts/check/check-duplication.mjs",
|
||||
|
||||
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();
|
||||
174
tests/unit/build/check-openapi-breaking.test.ts
Normal file
174
tests/unit/build/check-openapi-breaking.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// tests/unit/build/check-openapi-breaking.test.ts
|
||||
// TDD unit tests for scripts/check/check-openapi-breaking.mjs — Fase 8 B.4 oasdiff.
|
||||
//
|
||||
// Strategy:
|
||||
// • parseOasdiffBreaking() — pure parser, tested with synthetic oasdiff JSON
|
||||
// (the REAL shape emitted by `oasdiff breaking --format json`, verified at 1.19.1).
|
||||
// • binary-absent SKIP — spawn the gate with a PATH stripped of oasdiff and assert
|
||||
// it prints `openapiBreaking=SKIP reason=binary-absent` and exits 0 (advisory).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import { parseOasdiffBreaking } from "../../../scripts/check/check-openapi-breaking.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
||||
const GATE = path.join(REPO_ROOT, "scripts/check/check-openapi-breaking.mjs");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — REAL shape of `oasdiff breaking --format json` (oasdiff 1.19.1).
|
||||
// e.g. removing a path emits:
|
||||
// {"id":"api-path-removed-without-deprecation","text":"api path removed...",
|
||||
// "level":3,"operation":"GET","path":"/api/bar","section":"paths",...}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBreaking(
|
||||
overrides: {
|
||||
id?: string;
|
||||
text?: string;
|
||||
level?: number;
|
||||
operation?: string;
|
||||
path?: string;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? "api-path-removed-without-deprecation",
|
||||
text: overrides.text ?? "api path removed without deprecation",
|
||||
level: overrides.level ?? 3,
|
||||
operation: overrides.operation ?? "GET",
|
||||
path: overrides.path ?? "/api/foo",
|
||||
section: "paths",
|
||||
baseSource: { file: "/tmp/base.yaml", line: 12, column: 5 },
|
||||
fingerprint: "33e8aeb41d4a",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — empty / invalid input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: null retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(null);
|
||||
assert.equal(r.count, 0);
|
||||
assert.deepEqual(r.byId, {});
|
||||
assert.deepEqual(r.byPath, {});
|
||||
assert.deepEqual(r.items, []);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: undefined retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(undefined as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: array vazio (sem breaking change) retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking([]);
|
||||
assert.equal(r.count, 0);
|
||||
assert.deepEqual(r.byId, {});
|
||||
assert.deepEqual(r.byPath, {});
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: objeto (não-array) retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking({ id: "x" } as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: string retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking("breaking" as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: número retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(42 as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: 1 breaking change retorna count=1", () => {
|
||||
const r = parseOasdiffBreaking([makeBreaking()]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items.length, 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: 3 breaking changes retorna count=3", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/a" }),
|
||||
makeBreaking({ id: "request-parameter-became-required", path: "/api/b" }),
|
||||
makeBreaking({ id: "response-property-removed", path: "/api/c" }),
|
||||
]);
|
||||
assert.equal(r.count, 3);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: ignora entradas null/não-objeto no array", () => {
|
||||
const r = parseOasdiffBreaking([makeBreaking(), null, 5, makeBreaking({ path: "/api/x" })]);
|
||||
assert.equal(r.count, 2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: agrupa por id em byId", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/a" }),
|
||||
makeBreaking({ id: "response-property-removed", path: "/api/b" }),
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/c" }),
|
||||
]);
|
||||
assert.equal(r.byId["api-path-removed-without-deprecation"], 2);
|
||||
assert.equal(r.byId["response-property-removed"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: agrupa por path em byPath", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ path: "/api/chat", operation: "POST" }),
|
||||
makeBreaking({ path: "/api/chat", operation: "GET" }),
|
||||
makeBreaking({ path: "/api/models" }),
|
||||
]);
|
||||
assert.equal(r.byPath["/api/chat"], 2);
|
||||
assert.equal(r.byPath["/api/models"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: id ausente usa 'unknown'", () => {
|
||||
const r = parseOasdiffBreaking([{ path: "/api/x", text: "weird" }]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.byId["unknown"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: path ausente usa 'unknown'", () => {
|
||||
const r = parseOasdiffBreaking([{ id: "some-rule", text: "weird" }]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.byPath["unknown"], 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration — binary-absent SKIP (advisory, exit 0)
|
||||
//
|
||||
// Run the gate with a PATH that does NOT contain oasdiff. `findOasdiff()` then
|
||||
// returns null and the gate must SKIP gracefully with exit 0. We point HOME to a
|
||||
// temp dir too so a user-local ~/.local/bin/oasdiff cannot leak into the lookup.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gate: SKIP graceful (exit 0) quando oasdiff ausente do PATH", () => {
|
||||
const res = spawnSync(process.execPath, [GATE, "--quiet"], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
// Minimal PATH with only the dir holding the node binary — no oasdiff.
|
||||
PATH: path.dirname(process.execPath),
|
||||
HOME: "/nonexistent-home-for-oasdiff-test",
|
||||
BASE_REF: "origin/release/v3.8.26",
|
||||
},
|
||||
});
|
||||
assert.equal(res.status, 0, `expected exit 0, got ${res.status}; stderr: ${res.stderr}`);
|
||||
assert.match(
|
||||
res.stdout,
|
||||
/openapiBreaking=SKIP reason=binary-absent/,
|
||||
`expected binary-absent SKIP, got stdout: ${res.stdout}`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user