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:
Diego Rodrigues de Sa e Souza
2026-06-15 23:47:43 -03:00
committed by GitHub
parent bc32c6710e
commit dbf9293c7d
6 changed files with 599 additions and 0 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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:

View File

@@ -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",

View File

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

View File

@@ -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}`
);
});