diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb465e3253..b99cdc6cd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 :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) diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 420d5257e9..93f97ce5e3 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -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. diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 102fef12b0..1ae4e9ab05 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -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: diff --git a/package.json b/package.json index ddb7876338..ea8323a251 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check/check-openapi-breaking.mjs b/scripts/check/check-openapi-breaking.mjs new file mode 100644 index 0000000000..cc761537b2 --- /dev/null +++ b/scripts/check/check-openapi-breaking.mjs @@ -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 :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, byPath: Record, 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 :` 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 `. + // 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 ${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(); diff --git a/tests/unit/build/check-openapi-breaking.test.ts b/tests/unit/build/check-openapi-breaking.test.ts new file mode 100644 index 0000000000..b7b8db34cb --- /dev/null +++ b/tests/unit/build/check-openapi-breaking.test.ts @@ -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}` + ); +});