#!/usr/bin/env node /** * API compatibility diff tool * Compares two OpenAPI 3.x specs (YAML or JSON) and reports additive vs breaking changes. * * Usage: * node scripts/api-compat-diff.mjs [--output json|text] [--fail-on-breaking] * * Output (text): * - Added operations (additive) * - Removed operations (breaking) * - Added responses (additive) * - Removed responses (breaking) * * Output (json): * { * additive: { operations: [...], responses: [...] }, * breaking: { operations: [...], responses: [...] } * } * * Exit codes: * 0 => success * 1 => invalid/missing args or IO/parsing error * 2 => breaking changes detected with --fail-on-breaking */ import fs from 'fs'; import path from 'path'; import process from 'process'; import yaml from 'yaml'; function panic(message) { console.error(`[api-compat-diff] ${message}`); process.exit(1); } function parseArgs(argv) { const args = argv.slice(2); const opts = { output: 'text', failOnBreaking: false }; if (args.length < 2) { panic('Usage: node scripts/api-compat-diff.mjs [--output json|text] [--fail-on-breaking]'); } [opts.oldSpec, opts.newSpec] = args.slice(0, 2); for (let i = 2; i < args.length; i += 1) { const arg = args[i]; if (arg === '--output' && args[i + 1]) { opts.output = args[i + 1].toLowerCase(); i += 1; } else if (arg === '--fail-on-breaking') { opts.failOnBreaking = true; } } if (!['text', 'json'].includes(opts.output)) { panic(`Unsupported output mode: ${opts.output}`); } return opts; } function loadSpec(specPath) { if (!fs.existsSync(specPath)) { panic(`Spec not found: ${specPath}`); } const raw = fs.readFileSync(specPath, 'utf8'); const ext = path.extname(specPath).toLowerCase(); try { if (ext === '.json') { return JSON.parse(raw); } return yaml.parse(raw); } catch (err) { panic(`Failed to parse ${specPath}: ${err.message}`); } } function enumerateOperations(spec) { const ops = new Map(); if (!spec?.paths || typeof spec.paths !== 'object') { return ops; } for (const [pathKey, pathItem] of Object.entries(spec.paths)) { if (!pathItem || typeof pathItem !== 'object') { continue; } for (const method of Object.keys(pathItem)) { const lowerMethod = method.toLowerCase(); if (!['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'].includes(lowerMethod)) { continue; } const opId = `${lowerMethod} ${pathKey}`; const responses = pathItem[method]?.responses ?? {}; ops.set(opId, { method: lowerMethod, path: pathKey, responses: new Set(Object.keys(responses)), }); } } return ops; } function diffOperations(oldOps, newOps) { const additiveOps = []; const breakingOps = []; const additiveResponses = []; const breakingResponses = []; // Operations added or removed for (const [id, op] of newOps.entries()) { if (!oldOps.has(id)) { additiveOps.push(id); } } for (const [id] of oldOps.entries()) { if (!newOps.has(id)) { breakingOps.push(id); } } // Response-level diffs for shared operations for (const [id, newOp] of newOps.entries()) { if (!oldOps.has(id)) continue; const oldOp = oldOps.get(id); for (const code of newOp.responses) { if (!oldOp.responses.has(code)) { additiveResponses.push(`${id} -> ${code}`); } } for (const code of oldOp.responses) { if (!newOp.responses.has(code)) { breakingResponses.push(`${id} -> ${code}`); } } } return { additive: { operations: additiveOps.sort(), responses: additiveResponses.sort(), }, breaking: { operations: breakingOps.sort(), responses: breakingResponses.sort(), }, }; } function renderText(diff) { const lines = []; lines.push('Additive:'); lines.push(` Operations: ${diff.additive.operations.length}`); diff.additive.operations.forEach((op) => lines.push(` + ${op}`)); lines.push(` Responses: ${diff.additive.responses.length}`); diff.additive.responses.forEach((resp) => lines.push(` + ${resp}`)); lines.push('Breaking:'); lines.push(` Operations: ${diff.breaking.operations.length}`); diff.breaking.operations.forEach((op) => lines.push(` - ${op}`)); lines.push(` Responses: ${diff.breaking.responses.length}`); diff.breaking.responses.forEach((resp) => lines.push(` - ${resp}`)); return lines.join('\n'); } function main() { const opts = parseArgs(process.argv); const oldSpec = loadSpec(opts.oldSpec); const newSpec = loadSpec(opts.newSpec); const diff = diffOperations(enumerateOperations(oldSpec), enumerateOperations(newSpec)); if (opts.output === 'json') { console.log(JSON.stringify(diff, null, 2)); } else { console.log(renderText(diff)); } if (opts.failOnBreaking && (diff.breaking.operations.length > 0 || diff.breaking.responses.length > 0)) { process.exit(2); } } if (import.meta.url === `file://${process.argv[1]}`) { main(); }