specdiff 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1628 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { realpathSync } from "fs";
5
+ import { fileURLToPath } from "url";
6
+ import { parseArgs } from "util";
7
+
8
+ // package.json
9
+ var package_default = {
10
+ name: "specdiff",
11
+ version: "0.1.0",
12
+ description: "Compare two OpenAPI 3.x documents and classify every change as breaking, warning or safe.",
13
+ keywords: [
14
+ "openapi",
15
+ "openapi3",
16
+ "swagger",
17
+ "api",
18
+ "diff",
19
+ "breaking-changes",
20
+ "api-compatibility",
21
+ "contract",
22
+ "cli",
23
+ "ci"
24
+ ],
25
+ homepage: "https://specdiff.netlify.app/",
26
+ repository: {
27
+ type: "git",
28
+ url: "git+https://github.com/yagnang-vyas/spec-diff.git"
29
+ },
30
+ bugs: {
31
+ url: "https://github.com/yagnang-vyas/spec-diff/issues"
32
+ },
33
+ license: "MIT",
34
+ author: "Yagnang Vyas",
35
+ type: "module",
36
+ bin: {
37
+ specdiff: "./dist/cli.js"
38
+ },
39
+ files: [
40
+ "dist",
41
+ "README.md",
42
+ "LICENSE",
43
+ "CHANGELOG.md"
44
+ ],
45
+ engines: {
46
+ node: ">=22.19.0"
47
+ },
48
+ scripts: {
49
+ build: "tsup",
50
+ typecheck: "tsc --noEmit",
51
+ lint: "eslint . && prettier --check .",
52
+ test: "vitest run --coverage",
53
+ check: "npm run typecheck && npm run lint && npm run build && publint && npm run test",
54
+ prepack: "npm run build",
55
+ prepublishOnly: "npm run check"
56
+ },
57
+ dependencies: {
58
+ "@apidevtools/swagger-parser": "^13.1.0"
59
+ },
60
+ devDependencies: {
61
+ "@eslint/js": "^10.0.1",
62
+ "@types/node": "^22.20.5",
63
+ "@vitest/coverage-v8": "^5.0.5",
64
+ eslint: "^10.13.0",
65
+ "openapi-types": "^12.1.3",
66
+ prettier: "^3.9.10",
67
+ publint: "^0.3.25",
68
+ tsup: "^9.0.0",
69
+ typescript: "~5.9.3",
70
+ "typescript-eslint": "^8.72.0",
71
+ vitest: "^5.0.5",
72
+ yaml: "^2.9.1"
73
+ },
74
+ publishConfig: {
75
+ access: "public"
76
+ }
77
+ };
78
+
79
+ // src/load.ts
80
+ import SwaggerParser from "@apidevtools/swagger-parser";
81
+
82
+ // src/errors.ts
83
+ var SpecdiffError = class extends Error {
84
+ constructor(message) {
85
+ super(message);
86
+ this.name = "SpecdiffError";
87
+ }
88
+ };
89
+
90
+ // src/load.ts
91
+ var SUPPORTED = /^3\.[01](\.\d+)?(-.+)?$/;
92
+ function isObject(value) {
93
+ return typeof value === "object" && value !== null && !Array.isArray(value);
94
+ }
95
+ async function loadSpec(file) {
96
+ let document;
97
+ try {
98
+ document = await SwaggerParser.validate(file, {
99
+ // Stay offline and deterministic: only local files are resolved.
100
+ resolve: { http: false }
101
+ });
102
+ } catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ throw new SpecdiffError(`${file}: ${message}`);
105
+ }
106
+ const root = isObject(document) ? document : {};
107
+ const openapi = root.openapi;
108
+ if (typeof openapi !== "string" || !SUPPORTED.test(openapi)) {
109
+ const found = typeof openapi === "string" ? `OpenAPI ${openapi}` : `Swagger ${String(root.swagger)}`;
110
+ throw new SpecdiffError(
111
+ `${file}: unsupported specification (found ${found}); SpecDiff supports OpenAPI 3.0.x and 3.1.x`
112
+ );
113
+ }
114
+ const info = isObject(root.info) ? root.info : {};
115
+ return {
116
+ info: {
117
+ file,
118
+ openapi,
119
+ title: String(info.title),
120
+ version: String(info.version)
121
+ },
122
+ document: root
123
+ };
124
+ }
125
+
126
+ // src/model.ts
127
+ var HTTP_METHODS = [
128
+ "get",
129
+ "put",
130
+ "post",
131
+ "delete",
132
+ "options",
133
+ "head",
134
+ "patch",
135
+ "trace"
136
+ ];
137
+
138
+ // src/normalize.ts
139
+ function isObject2(value) {
140
+ return typeof value === "object" && value !== null && !Array.isArray(value);
141
+ }
142
+ function asArray(value) {
143
+ return Array.isArray(value) ? value : [];
144
+ }
145
+ function pathShape(template) {
146
+ return template.replace(/\{[^}]*\}/g, "{}");
147
+ }
148
+ function pathParamNames(template) {
149
+ return [...template.matchAll(/\{([^}]*)\}/g)].map((m) => m[1] ?? "");
150
+ }
151
+ function normalizeMediaType(mediaType) {
152
+ return (mediaType.split(";")[0] ?? "").trim().toLowerCase();
153
+ }
154
+ function normalizeStatus(status) {
155
+ return status.trim().toUpperCase() === "DEFAULT" ? "default" : status.trim().toUpperCase();
156
+ }
157
+ function parameterKey(param, paramNames) {
158
+ const location = String(param.in);
159
+ const name = String(param.name);
160
+ if (location === "path") {
161
+ const index = paramNames.indexOf(name);
162
+ return index >= 0 ? `path:#${index}` : `path:${name}`;
163
+ }
164
+ return `${location}:${location === "header" ? name.toLowerCase() : name}`;
165
+ }
166
+ function serialization(param) {
167
+ if (isObject2(param.content)) return "content";
168
+ const location = String(param.in);
169
+ const style = typeof param.style === "string" ? param.style : location === "query" || location === "cookie" ? "form" : "simple";
170
+ const explode = typeof param.explode === "boolean" ? param.explode : style === "form";
171
+ return `style=${style}, explode=${String(explode)}`;
172
+ }
173
+ function parameterSchema(param) {
174
+ if (isObject2(param.schema)) return { schema: param.schema, path: ["schema"] };
175
+ if (isObject2(param.content)) {
176
+ const [mediaType, entry] = Object.entries(param.content)[0] ?? [];
177
+ if (mediaType !== void 0) {
178
+ const schema = isObject2(entry) && isObject2(entry.schema) ? entry.schema : void 0;
179
+ return { schema, path: ["content", mediaType, "schema"] };
180
+ }
181
+ }
182
+ return { schema: void 0, path: ["schema"] };
183
+ }
184
+ function normalizeParameter(param, paramNames) {
185
+ const location = String(param.in);
186
+ const { schema, path } = parameterSchema(param);
187
+ return {
188
+ key: parameterKey(param, paramNames),
189
+ name: String(param.name),
190
+ in: location,
191
+ required: location === "path" ? true : param.required === true,
192
+ schema,
193
+ schemaPath: path,
194
+ serialization: serialization(param)
195
+ };
196
+ }
197
+ function mergeParameters(pathLevel, operationLevel, paramNames) {
198
+ const result = /* @__PURE__ */ new Map();
199
+ for (const list of [pathLevel, operationLevel]) {
200
+ for (const param of asArray(list)) {
201
+ if (!isObject2(param)) continue;
202
+ const normalized = normalizeParameter(param, paramNames);
203
+ result.set(normalized.key, normalized);
204
+ }
205
+ }
206
+ return result;
207
+ }
208
+ function normalizeContent(content) {
209
+ const result = /* @__PURE__ */ new Map();
210
+ if (!isObject2(content)) return result;
211
+ for (const [key, entry] of Object.entries(content)) {
212
+ const schema = isObject2(entry) && isObject2(entry.schema) ? entry.schema : void 0;
213
+ result.set(normalizeMediaType(key), { key, schema });
214
+ }
215
+ return result;
216
+ }
217
+ function normalizeBody(body) {
218
+ if (!isObject2(body)) return void 0;
219
+ return { required: body.required === true, content: normalizeContent(body.content) };
220
+ }
221
+ function normalizeResponses(responses) {
222
+ const result = /* @__PURE__ */ new Map();
223
+ if (!isObject2(responses)) return result;
224
+ for (const [key, response] of Object.entries(responses)) {
225
+ if (key.startsWith("x-")) continue;
226
+ const content = isObject2(response) ? normalizeContent(response.content) : /* @__PURE__ */ new Map();
227
+ result.set(normalizeStatus(key), { key, content });
228
+ }
229
+ return result;
230
+ }
231
+ function normalizeSecurity(security) {
232
+ return asArray(security).filter(isObject2).map((requirement) => {
233
+ const entries = Object.entries(requirement).sort(([a], [b]) => a.localeCompare(b)).map(([scheme, scopes]) => [scheme, asArray(scopes).map(String).sort()]);
234
+ return new Map(entries);
235
+ });
236
+ }
237
+ function normalizeOperation(method, operation, pathItem, paramNames, documentSecurity) {
238
+ return {
239
+ method,
240
+ deprecated: operation.deprecated === true,
241
+ parameters: mergeParameters(pathItem.parameters, operation.parameters, paramNames),
242
+ requestBody: normalizeBody(operation.requestBody),
243
+ responses: normalizeResponses(operation.responses),
244
+ security: "security" in operation ? normalizeSecurity(operation.security) : documentSecurity
245
+ };
246
+ }
247
+ function normalizeSpec(spec) {
248
+ const paths = /* @__PURE__ */ new Map();
249
+ const documentSecurity = normalizeSecurity(spec.document.security);
250
+ const rawPaths = isObject2(spec.document.paths) ? spec.document.paths : {};
251
+ for (const [template, pathItem] of Object.entries(rawPaths)) {
252
+ if (template.startsWith("x-") || !isObject2(pathItem)) continue;
253
+ const key = pathShape(template);
254
+ const existing = paths.get(key);
255
+ if (existing) {
256
+ throw new SpecdiffError(
257
+ `${spec.info.file}: paths "${existing.template}" and "${template}" have the same template shape and cannot be told apart`
258
+ );
259
+ }
260
+ const paramNames = pathParamNames(template);
261
+ const operations = /* @__PURE__ */ new Map();
262
+ for (const method of HTTP_METHODS) {
263
+ const operation = pathItem[method];
264
+ if (isObject2(operation)) {
265
+ operations.set(
266
+ method,
267
+ normalizeOperation(method, operation, pathItem, paramNames, documentSecurity)
268
+ );
269
+ }
270
+ }
271
+ paths.set(key, { key, template, paramNames, operations });
272
+ }
273
+ return { info: spec.info, paths };
274
+ }
275
+ var UNSUPPORTED_KEYWORDS = [
276
+ "oneOf",
277
+ "anyOf",
278
+ "not",
279
+ "if",
280
+ "then",
281
+ "else",
282
+ "patternProperties",
283
+ "dependentSchemas",
284
+ "dependentRequired",
285
+ "prefixItems",
286
+ "contains",
287
+ "propertyNames",
288
+ "unevaluatedProperties",
289
+ "unevaluatedItems"
290
+ ];
291
+ var SCALAR_CONSTRAINTS = [
292
+ "maxLength",
293
+ "minLength",
294
+ "maxItems",
295
+ "minItems",
296
+ "maxProperties",
297
+ "minProperties",
298
+ "uniqueItems",
299
+ "pattern",
300
+ "format",
301
+ "multipleOf"
302
+ ];
303
+ function canonicalJson(value) {
304
+ const stack = [];
305
+ const walk = (v) => {
306
+ if (typeof v !== "object" || v === null) return JSON.stringify(v) ?? "undefined";
307
+ if (stack.includes(v)) return '"[Circular]"';
308
+ stack.push(v);
309
+ let out;
310
+ if (Array.isArray(v)) {
311
+ out = `[${v.map(walk).join(",")}]`;
312
+ } else {
313
+ const keys = Object.keys(v).sort();
314
+ out = `{${keys.map((k) => `${JSON.stringify(k)}:${walk(v[k])}`).join(",")}}`;
315
+ }
316
+ stack.pop();
317
+ return out;
318
+ };
319
+ return walk(value);
320
+ }
321
+ function asSchema(value) {
322
+ if (isObject2(value)) return value;
323
+ if (value === true) return {};
324
+ if (value === false) return { not: {} };
325
+ return void 0;
326
+ }
327
+ function typeList(type) {
328
+ if (typeof type === "string") return [type];
329
+ return asArray(type).filter((t) => typeof t === "string");
330
+ }
331
+ function collectMembers(schema, seen, out) {
332
+ if (seen.has(schema)) return;
333
+ seen.add(schema);
334
+ for (const member of asArray(schema.allOf)) {
335
+ const resolved = asSchema(member);
336
+ if (resolved) collectMembers(resolved, seen, out);
337
+ }
338
+ out.push(schema);
339
+ }
340
+ function boundOf(schema, limit, exclusiveKey) {
341
+ const plain = schema[limit];
342
+ const exclusive = schema[exclusiveKey];
343
+ if (typeof exclusive === "number") {
344
+ const base = typeof plain === "number" ? plain : void 0;
345
+ const upper = limit === "maximum";
346
+ if (base !== void 0 && (upper ? base < exclusive : base > exclusive)) {
347
+ return { value: base, exclusive: false };
348
+ }
349
+ return { value: exclusive, exclusive: true };
350
+ }
351
+ if (typeof plain === "number") return { value: plain, exclusive: exclusive === true };
352
+ return void 0;
353
+ }
354
+ var viewCache = /* @__PURE__ */ new WeakMap();
355
+ function describeSchema(schema) {
356
+ const cached = viewCache.get(schema);
357
+ if (cached) return cached;
358
+ const view = buildView(schema);
359
+ viewCache.set(schema, view);
360
+ return view;
361
+ }
362
+ function emptyView() {
363
+ return {
364
+ anyType: true,
365
+ types: /* @__PURE__ */ new Set(),
366
+ nullable: false,
367
+ properties: /* @__PURE__ */ new Map(),
368
+ required: /* @__PURE__ */ new Set(),
369
+ items: [],
370
+ additionalProperties: void 0,
371
+ enumValues: void 0,
372
+ constraints: /* @__PURE__ */ new Map(),
373
+ upper: void 0,
374
+ lower: void 0,
375
+ readOnly: false,
376
+ writeOnly: false,
377
+ unsupported: /* @__PURE__ */ new Map()
378
+ };
379
+ }
380
+ function mergeType(view, member) {
381
+ let typed = false;
382
+ if (member.type !== void 0) {
383
+ const declared = typeList(member.type);
384
+ typed = true;
385
+ view.types = new Set(declared.filter((t) => t !== "null"));
386
+ view.nullable = declared.includes("null");
387
+ }
388
+ if (member.nullable === true) view.nullable = true;
389
+ if (member.readOnly === true) view.readOnly = true;
390
+ if (member.writeOnly === true) view.writeOnly = true;
391
+ return typed;
392
+ }
393
+ function mergeStructure(view, member, additional) {
394
+ if (isObject2(member.properties)) {
395
+ for (const [name, prop] of Object.entries(member.properties)) {
396
+ const resolved = asSchema(prop);
397
+ if (!resolved) continue;
398
+ const list = view.properties.get(name) ?? [];
399
+ list.push(resolved);
400
+ view.properties.set(name, list);
401
+ }
402
+ }
403
+ for (const name of asArray(member.required)) view.required.add(String(name));
404
+ const items = asSchema(member.items);
405
+ if (items) view.items.push(items);
406
+ if (member.additionalProperties === false) additional.forbidden = true;
407
+ else if (member.additionalProperties === true) additional.allowed = true;
408
+ else if (isObject2(member.additionalProperties))
409
+ additional.schemas.push(member.additionalProperties);
410
+ }
411
+ function mergeEnum(view, member) {
412
+ const values = Array.isArray(member.enum) ? member.enum : "const" in member ? [member.const] : void 0;
413
+ if (!values) return;
414
+ const next = new Map(values.map((v) => [canonicalJson(v), v]));
415
+ view.enumValues = view.enumValues ? new Map([...view.enumValues].filter(([k]) => next.has(k))) : next;
416
+ }
417
+ function mergeConstraints(view, member) {
418
+ for (const key of SCALAR_CONSTRAINTS) {
419
+ if (member[key] !== void 0) view.constraints.set(key, member[key]);
420
+ }
421
+ view.upper = boundOf(member, "maximum", "exclusiveMaximum") ?? view.upper;
422
+ view.lower = boundOf(member, "minimum", "exclusiveMinimum") ?? view.lower;
423
+ for (const key of UNSUPPORTED_KEYWORDS) {
424
+ if (member[key] !== void 0) {
425
+ view.unsupported.set(key, [...view.unsupported.get(key) ?? [], member[key]]);
426
+ }
427
+ }
428
+ }
429
+ function inferType(view) {
430
+ if (view.properties.size > 0 || view.additionalProperties !== void 0) {
431
+ view.anyType = false;
432
+ view.types = /* @__PURE__ */ new Set(["object"]);
433
+ } else if (view.items.length > 0) {
434
+ view.anyType = false;
435
+ view.types = /* @__PURE__ */ new Set(["array"]);
436
+ }
437
+ }
438
+ function buildView(schema) {
439
+ const members = [];
440
+ collectMembers(schema, /* @__PURE__ */ new Set(), members);
441
+ const view = emptyView();
442
+ const additional = { forbidden: false, allowed: false, schemas: [] };
443
+ let typed = false;
444
+ for (const member of members) {
445
+ typed = mergeType(view, member) || typed;
446
+ mergeStructure(view, member, additional);
447
+ mergeEnum(view, member);
448
+ mergeConstraints(view, member);
449
+ }
450
+ if (additional.forbidden) view.additionalProperties = false;
451
+ else if (additional.schemas.length > 0) view.additionalProperties = additional.schemas;
452
+ else if (additional.allowed) view.additionalProperties = true;
453
+ if (typed) view.anyType = false;
454
+ else inferType(view);
455
+ return view;
456
+ }
457
+ function typeLabel(view) {
458
+ if (view.anyType) return "any";
459
+ if (view.types.size === 0) return "null";
460
+ return [...view.types].sort().join(" | ");
461
+ }
462
+
463
+ // src/rules.ts
464
+ var RULES = {
465
+ // Paths and operations
466
+ PATH_REMOVED: {
467
+ severity: "breaking",
468
+ summary: "path removed",
469
+ template: "path {path} removed"
470
+ },
471
+ PATH_ADDED: { severity: "safe", summary: "path added", template: "path {path} added" },
472
+ PATH_PARAM_RENAMED: {
473
+ severity: "info",
474
+ summary: "path template renamed with the same shape",
475
+ template: "path template renamed from {from} to {to} (same shape, treated as the same path)"
476
+ },
477
+ OP_REMOVED: {
478
+ severity: "breaking",
479
+ summary: "HTTP method removed from a path that still exists",
480
+ template: "operation {method} {path} removed"
481
+ },
482
+ OP_ADDED: {
483
+ severity: "safe",
484
+ summary: "HTTP method added",
485
+ template: "operation {method} {path} added"
486
+ },
487
+ OP_DEPRECATED: {
488
+ severity: "warning",
489
+ summary: "operation newly deprecated",
490
+ template: "operation is now deprecated"
491
+ },
492
+ // Parameters
493
+ PARAM_ADDED_REQUIRED: {
494
+ severity: "breaking",
495
+ summary: "new required parameter",
496
+ template: 'required {in} parameter "{name}" added'
497
+ },
498
+ PARAM_ADDED_OPTIONAL: {
499
+ severity: "safe",
500
+ summary: "new optional parameter",
501
+ template: 'optional {in} parameter "{name}" added'
502
+ },
503
+ PARAM_BECAME_REQUIRED: {
504
+ severity: "breaking",
505
+ summary: "parameter became required",
506
+ template: '{in} parameter "{name}" is now required'
507
+ },
508
+ PARAM_BECAME_OPTIONAL: {
509
+ severity: "safe",
510
+ summary: "parameter became optional",
511
+ template: '{in} parameter "{name}" is now optional'
512
+ },
513
+ PARAM_REMOVED: {
514
+ severity: "warning",
515
+ summary: "parameter removed",
516
+ template: '{in} parameter "{name}" removed'
517
+ },
518
+ PARAM_LOCATION_CHANGED: {
519
+ severity: "breaking",
520
+ summary: "parameter moved to a different location",
521
+ template: 'parameter "{name}" moved from {from} to {to}'
522
+ },
523
+ PARAM_SERIALIZATION_CHANGED: {
524
+ severity: "breaking",
525
+ summary: "effective style/explode changed",
526
+ template: '{in} parameter "{name}" serialization changed from ({from}) to ({to})'
527
+ },
528
+ // Request bodies
529
+ REQ_BODY_BECAME_REQUIRED: {
530
+ severity: "breaking",
531
+ summary: "request body became required, or a required body was added",
532
+ template: "request body is now required"
533
+ },
534
+ REQ_BODY_BECAME_OPTIONAL: {
535
+ severity: "safe",
536
+ summary: "request body became optional",
537
+ template: "request body is now optional"
538
+ },
539
+ REQ_BODY_ADDED: {
540
+ severity: "safe",
541
+ summary: "optional request body added",
542
+ template: "optional request body added"
543
+ },
544
+ REQ_BODY_REMOVED: {
545
+ severity: "warning",
546
+ summary: "request body removed",
547
+ template: "request body removed (servers may now reject or ignore it)"
548
+ },
549
+ REQ_MEDIA_TYPE_REMOVED: {
550
+ severity: "breaking",
551
+ summary: "request content type removed",
552
+ template: 'request content type "{mediaType}" removed'
553
+ },
554
+ REQ_MEDIA_TYPE_ADDED: {
555
+ severity: "safe",
556
+ summary: "request content type added",
557
+ template: 'request content type "{mediaType}" added'
558
+ },
559
+ // Request schemas (parameters and request bodies): stricter is breaking
560
+ REQ_PROPERTY_REQUIRED_ADDED: {
561
+ severity: "breaking",
562
+ summary: "property became required, or a required property was added",
563
+ template: "property {at} {what}"
564
+ },
565
+ REQ_PROPERTY_REQUIRED_REMOVED: {
566
+ severity: "safe",
567
+ summary: "property is no longer required",
568
+ template: "property {at} is no longer required"
569
+ },
570
+ REQ_PROPERTY_ADDED: {
571
+ severity: "safe",
572
+ summary: "optional property added",
573
+ template: "optional property {at} added"
574
+ },
575
+ REQ_PROPERTY_REMOVED: {
576
+ severity: "warning",
577
+ summary: "property removed",
578
+ template: "property {at} removed (servers may now reject or ignore it)"
579
+ },
580
+ REQ_ENUM_NARROWED: {
581
+ severity: "breaking",
582
+ summary: "enum value removed, or enum added to a free value",
583
+ template: "enum of {at} narrowed: {detail}"
584
+ },
585
+ REQ_ENUM_WIDENED: {
586
+ severity: "safe",
587
+ summary: "enum value added, or enum removed",
588
+ template: "enum of {at} widened: {detail}"
589
+ },
590
+ REQ_CONSTRAINT_TIGHTENED: {
591
+ severity: "breaking",
592
+ summary: "constraint tightened, or an opaque constraint added or changed",
593
+ template: "{constraint} of {at} {change}"
594
+ },
595
+ REQ_CONSTRAINT_LOOSENED: {
596
+ severity: "safe",
597
+ summary: "constraint loosened or removed",
598
+ template: "{constraint} of {at} {change}"
599
+ },
600
+ REQ_ADDITIONAL_PROPERTIES_DISALLOWED: {
601
+ severity: "breaking",
602
+ summary: "additionalProperties changed to false",
603
+ template: "additional properties of {at} are no longer allowed"
604
+ },
605
+ REQ_ADDITIONAL_PROPERTIES_ALLOWED: {
606
+ severity: "safe",
607
+ summary: "additionalProperties changed from false",
608
+ template: "additional properties of {at} are now allowed"
609
+ },
610
+ REQ_BECAME_NON_NULLABLE: {
611
+ severity: "breaking",
612
+ summary: "value that could be null no longer can",
613
+ template: "{at} no longer accepts null"
614
+ },
615
+ REQ_BECAME_NULLABLE: {
616
+ severity: "safe",
617
+ summary: "value that could not be null now can",
618
+ template: "{at} now accepts null"
619
+ },
620
+ // Responses: looser or lossy is breaking
621
+ RESP_STATUS_REMOVED: {
622
+ severity: "breaking",
623
+ summary: "documented status code removed (warning unless 2xx)",
624
+ template: "response status {status} removed"
625
+ },
626
+ RESP_STATUS_ADDED: {
627
+ severity: "safe",
628
+ summary: "status code added",
629
+ template: "response status {status} added"
630
+ },
631
+ RESP_MEDIA_TYPE_REMOVED: {
632
+ severity: "breaking",
633
+ summary: "response content type removed",
634
+ template: 'response content type "{mediaType}" removed'
635
+ },
636
+ RESP_MEDIA_TYPE_ADDED: {
637
+ severity: "safe",
638
+ summary: "response content type added",
639
+ template: 'response content type "{mediaType}" added'
640
+ },
641
+ RESP_PROPERTY_REMOVED: {
642
+ severity: "breaking",
643
+ summary: "property removed",
644
+ template: "property {at} removed"
645
+ },
646
+ RESP_PROPERTY_ADDED: {
647
+ severity: "safe",
648
+ summary: "property added",
649
+ template: "property {at} added"
650
+ },
651
+ RESP_PROPERTY_BECAME_OPTIONAL: {
652
+ severity: "breaking",
653
+ summary: "property removed from required",
654
+ template: "property {at} is no longer always present (removed from required)"
655
+ },
656
+ RESP_PROPERTY_BECAME_REQUIRED: {
657
+ severity: "safe",
658
+ summary: "property added to required",
659
+ template: "property {at} is now always present (added to required)"
660
+ },
661
+ RESP_BECAME_NULLABLE: {
662
+ severity: "breaking",
663
+ summary: "value can now be null",
664
+ template: "{at} can now be null"
665
+ },
666
+ RESP_BECAME_NON_NULLABLE: {
667
+ severity: "safe",
668
+ summary: "value can no longer be null",
669
+ template: "{at} can no longer be null"
670
+ },
671
+ RESP_ENUM_WIDENED: {
672
+ severity: "warning",
673
+ summary: "enum value added, or enum removed",
674
+ template: "enum of {at} widened: {detail}"
675
+ },
676
+ RESP_ENUM_NARROWED: {
677
+ severity: "safe",
678
+ summary: "enum value removed, or enum added to a free value",
679
+ template: "enum of {at} narrowed: {detail}"
680
+ },
681
+ RESP_CONSTRAINT_LOOSENED: {
682
+ severity: "breaking",
683
+ summary: "constraint loosened or removed (warning for opaque changes)",
684
+ template: "{constraint} of {at} {change}"
685
+ },
686
+ RESP_CONSTRAINT_TIGHTENED: {
687
+ severity: "safe",
688
+ summary: "constraint tightened or added",
689
+ template: "{constraint} of {at} {change}"
690
+ },
691
+ // Both directions
692
+ TYPE_CHANGED: {
693
+ severity: "breaking",
694
+ summary: "type changed (safe when the change is compatible in this direction)",
695
+ template: "type of {at} changed from {from} to {to}"
696
+ },
697
+ UNSUPPORTED_CONSTRUCT: {
698
+ severity: "info",
699
+ summary: "an unanalyzed keyword differs between versions",
700
+ template: "{what} at {at} (not analyzed, review manually)"
701
+ },
702
+ // Security
703
+ SECURITY_ADDED: {
704
+ severity: "breaking",
705
+ summary: "security requirement became stricter",
706
+ template: "security requirement became stricter (was {from}, now {to})"
707
+ },
708
+ SECURITY_RELAXED: {
709
+ severity: "safe",
710
+ summary: "security requirement became less strict",
711
+ template: "security requirement became less strict (was {from}, now {to})"
712
+ }
713
+ };
714
+ var RULE_IDS = Object.keys(RULES);
715
+ function isRuleId(value) {
716
+ return Object.hasOwn(RULES, value);
717
+ }
718
+ function formatMessage(id, params = {}) {
719
+ return RULES[id].template.replace(/\{(\w+)\}/g, (_, name) => {
720
+ const value = params[name];
721
+ if (value === void 0) throw new Error(`rule ${id}: missing message parameter "${name}"`);
722
+ return String(value);
723
+ });
724
+ }
725
+
726
+ // src/diff/context.ts
727
+ var Loc = class _Loc {
728
+ constructor(base, head) {
729
+ this.base = base;
730
+ this.head = head;
731
+ }
732
+ base;
733
+ head;
734
+ /** Descend into the same key in both documents (or different keys, e.g. a renamed path). */
735
+ child(baseKey, headKey = baseKey) {
736
+ return new _Loc(this.base && [...this.base, baseKey], this.head && [...this.head, headKey]);
737
+ }
738
+ /** An element that exists only in the base document. */
739
+ removed(...keys) {
740
+ return new _Loc(this.base && [...this.base, ...keys], null);
741
+ }
742
+ /** An element that exists only in the head document. */
743
+ added(...keys) {
744
+ return new _Loc(null, this.head && [...this.head, ...keys]);
745
+ }
746
+ /** Descend by independent key sequences, e.g. a parameter that moved. */
747
+ descend(baseKeys, headKeys) {
748
+ return new _Loc(
749
+ this.base && [...this.base, ...baseKeys],
750
+ this.head && [...this.head, ...headKeys]
751
+ );
752
+ }
753
+ toJSON() {
754
+ return { base: this.base?.join(".") ?? null, head: this.head?.join(".") ?? null };
755
+ }
756
+ };
757
+ function createEmitter(changes, operation) {
758
+ return (id, loc, params, options) => {
759
+ const text = formatMessage(id, params);
760
+ changes.push({
761
+ id,
762
+ severity: options?.severity ?? RULES[id].severity,
763
+ operation,
764
+ location: loc.toJSON(),
765
+ message: options?.scope ? `${options.scope}: ${text}` : text
766
+ });
767
+ };
768
+ }
769
+
770
+ // src/diff/schema.ts
771
+ var MAX_DEPTH = 100;
772
+ var ANY_SCHEMA = Object.freeze({});
773
+ function diffSchema(base, head, loc, emit, direction, scope) {
774
+ const state = {
775
+ emit: (...args) => {
776
+ state.emitted += 1;
777
+ emit(...args);
778
+ },
779
+ direction,
780
+ scope,
781
+ active: /* @__PURE__ */ new Map(),
782
+ clean: /* @__PURE__ */ new Map(),
783
+ emitted: 0
784
+ };
785
+ compareSchemas(base ?? ANY_SCHEMA, head ?? ANY_SCHEMA, loc, [], state, 0);
786
+ }
787
+ function display(path) {
788
+ if (path.length === 0) return "value";
789
+ let text = "";
790
+ for (const segment of path) {
791
+ if (segment === "[]") text = `${text || "value"}[]`;
792
+ else text = text ? `${text}.${segment}` : segment;
793
+ }
794
+ return `"${text}"`;
795
+ }
796
+ function merged(list) {
797
+ return list.length === 1 && list[0] ? list[0] : { allOf: list };
798
+ }
799
+ function visibleProperties(view, direction) {
800
+ const hidden = direction === "request" ? "readOnly" : "writeOnly";
801
+ const result = /* @__PURE__ */ new Map();
802
+ for (const [name, list] of view.properties) {
803
+ const schema = merged(list);
804
+ if (!describeSchema(schema)[hidden]) result.set(name, schema);
805
+ }
806
+ return result;
807
+ }
808
+ function typesSubset(a, b) {
809
+ if (b.anyType) return true;
810
+ if (a.anyType) return false;
811
+ return [...a.types].every((t) => b.types.has(t) || t === "integer" && b.types.has("number"));
812
+ }
813
+ function typesDisjoint(a, b) {
814
+ if (a.anyType || b.anyType) return false;
815
+ const related = (t, other) => other.has(t) || t === "integer" && other.has("number") || t === "number" && other.has("integer");
816
+ return [...a.types].every((t) => !related(t, b.types));
817
+ }
818
+ function sameTypes(a, b) {
819
+ return a.anyType === b.anyType && a.types.size === b.types.size && [...a.types].every((t) => b.types.has(t));
820
+ }
821
+ function compareSchemas(base, head, loc, path, state, depth) {
822
+ const seen = state.active.get(base);
823
+ if (seen?.has(head) || state.clean.get(base)?.has(head)) return;
824
+ if (depth > MAX_DEPTH) {
825
+ state.emit(
826
+ "UNSUPPORTED_CONSTRUCT",
827
+ loc,
828
+ {
829
+ what: `nesting deeper than ${MAX_DEPTH} levels`,
830
+ at: display(path)
831
+ },
832
+ { scope: state.scope }
833
+ );
834
+ return;
835
+ }
836
+ const set = seen ?? /* @__PURE__ */ new Set();
837
+ set.add(head);
838
+ state.active.set(base, set);
839
+ const before = state.emitted;
840
+ try {
841
+ compareContents(base, head, loc, path, state, depth);
842
+ } finally {
843
+ set.delete(head);
844
+ }
845
+ if (state.emitted === before) {
846
+ const clean = state.clean.get(base) ?? /* @__PURE__ */ new Set();
847
+ clean.add(head);
848
+ state.clean.set(base, clean);
849
+ }
850
+ }
851
+ function compareContents(base, head, loc, path, state, depth) {
852
+ const b = describeSchema(base);
853
+ const h = describeSchema(head);
854
+ const at = display(path);
855
+ const emit = (id, where2, params, severity) => state.emit(id, where2, { at, ...params }, { scope: state.scope, severity });
856
+ compareTypes(b, h, loc, emit, state.direction);
857
+ compareNullability(b, h, loc, emit, state.direction);
858
+ compareUnsupported(b, h, loc, emit);
859
+ if (typesDisjoint(b, h)) return;
860
+ compareEnums(b, h, loc, emit, state.direction);
861
+ compareConstraints(b, h, loc, emit, state.direction);
862
+ if (b.types.has("object") && h.types.has("object")) {
863
+ compareObjects(b, h, loc, path, state, depth, emit);
864
+ }
865
+ if (b.types.has("array") && h.types.has("array") && b.items.length > 0 && h.items.length > 0) {
866
+ compareSchemas(
867
+ merged(b.items),
868
+ merged(h.items),
869
+ loc.child("items"),
870
+ [...path, "[]"],
871
+ state,
872
+ depth + 1
873
+ );
874
+ }
875
+ }
876
+ function compareTypes(b, h, loc, emit, direction) {
877
+ if (sameTypes(b, h)) return;
878
+ const compatible = direction === "request" ? typesSubset(b, h) : typesSubset(h, b);
879
+ emit(
880
+ "TYPE_CHANGED",
881
+ loc,
882
+ { from: typeLabel(b), to: typeLabel(h) },
883
+ compatible ? "safe" : "breaking"
884
+ );
885
+ }
886
+ function compareNullability(b, h, loc, emit, direction) {
887
+ if (b.anyType || h.anyType || b.nullable === h.nullable) return;
888
+ if (direction === "request") {
889
+ emit(h.nullable ? "REQ_BECAME_NULLABLE" : "REQ_BECAME_NON_NULLABLE", loc, {});
890
+ } else {
891
+ emit(h.nullable ? "RESP_BECAME_NULLABLE" : "RESP_BECAME_NON_NULLABLE", loc, {});
892
+ }
893
+ }
894
+ function listValues(values) {
895
+ return [...values].map((v) => canonicalJson(v)).sort().join(", ");
896
+ }
897
+ function compareEnums(b, h, loc, emit, direction) {
898
+ const be = b.enumValues;
899
+ const he = h.enumValues;
900
+ if (!be && !he) return;
901
+ const request = direction === "request";
902
+ const narrowed = request ? "REQ_ENUM_NARROWED" : "RESP_ENUM_NARROWED";
903
+ const widened = request ? "REQ_ENUM_WIDENED" : "RESP_ENUM_WIDENED";
904
+ if (!be && he) {
905
+ emit(narrowed, loc, { detail: `enum constraint added (${listValues(he.values())})` });
906
+ } else if (be && !he) {
907
+ emit(widened, loc, { detail: `enum constraint removed (was ${listValues(be.values())})` });
908
+ } else if (be && he) {
909
+ const removed = [...be].filter(([key]) => !he.has(key)).map(([, v]) => v);
910
+ const added = [...he].filter(([key]) => !be.has(key)).map(([, v]) => v);
911
+ if (removed.length > 0) emit(narrowed, loc, { detail: `removed ${listValues(removed)}` });
912
+ if (added.length > 0) emit(widened, loc, { detail: `added ${listValues(added)}` });
913
+ }
914
+ }
915
+ var MAX_KEYS = /* @__PURE__ */ new Set(["maxLength", "maxItems", "maxProperties"]);
916
+ var MIN_KEYS = /* @__PURE__ */ new Set(["minLength", "minItems", "minProperties"]);
917
+ function showBound(bound) {
918
+ return bound.exclusive ? `${bound.value} (exclusive)` : String(bound.value);
919
+ }
920
+ function describeChange(verdict, from, to) {
921
+ if (from === void 0) return `added (${String(to)})`;
922
+ if (to === void 0) return `removed (was ${from})`;
923
+ const word = verdict === "tighter" ? "tightened" : verdict === "looser" ? "loosened" : "changed";
924
+ return `${word} from ${from} to ${to}`;
925
+ }
926
+ function numericChange(constraint, from, to, upper, unsetValue) {
927
+ if (from === to) return void 0;
928
+ const effectiveFrom = from ?? unsetValue;
929
+ const effectiveTo = to ?? unsetValue;
930
+ if (effectiveFrom === effectiveTo) return void 0;
931
+ const tighter = upper ? effectiveTo < effectiveFrom : effectiveTo > effectiveFrom;
932
+ const verdict = tighter ? "tighter" : "looser";
933
+ return {
934
+ constraint,
935
+ verdict,
936
+ text: describeChange(verdict, from?.toString(), to?.toString())
937
+ };
938
+ }
939
+ function boundChange(constraint, from, to, upper) {
940
+ if (!from && !to) return void 0;
941
+ if (from && to && from.value === to.value && from.exclusive === to.exclusive) return void 0;
942
+ let verdict;
943
+ if (!from) verdict = "tighter";
944
+ else if (!to) verdict = "looser";
945
+ else if (from.value !== to.value) {
946
+ verdict = (upper ? to.value < from.value : to.value > from.value) ? "tighter" : "looser";
947
+ } else {
948
+ verdict = to.exclusive ? "tighter" : "looser";
949
+ }
950
+ return {
951
+ constraint,
952
+ verdict,
953
+ text: describeChange(verdict, from && showBound(from), to && showBound(to))
954
+ };
955
+ }
956
+ function opaqueChange(constraint, from, to) {
957
+ if (from === void 0 && to === void 0) return void 0;
958
+ if (canonicalJson(from) === canonicalJson(to)) return void 0;
959
+ const verdict = from === void 0 ? "tighter" : to === void 0 ? "looser" : "changed";
960
+ return {
961
+ constraint,
962
+ verdict,
963
+ text: describeChange(
964
+ verdict,
965
+ from === void 0 ? void 0 : canonicalJson(from),
966
+ to === void 0 ? void 0 : canonicalJson(to)
967
+ )
968
+ };
969
+ }
970
+ function constraintChanges(b, h) {
971
+ const out = [];
972
+ for (const key of SCALAR_CONSTRAINTS) {
973
+ const from = b.constraints.get(key);
974
+ const to = h.constraints.get(key);
975
+ if (MAX_KEYS.has(key) || MIN_KEYS.has(key)) {
976
+ out.push(
977
+ numericChange(
978
+ key,
979
+ typeof from === "number" ? from : void 0,
980
+ typeof to === "number" ? to : void 0,
981
+ MAX_KEYS.has(key),
982
+ MAX_KEYS.has(key) ? Infinity : 0
983
+ )
984
+ );
985
+ } else if (key === "uniqueItems") {
986
+ if (from === true !== (to === true)) {
987
+ const verdict = to === true ? "tighter" : "looser";
988
+ out.push({
989
+ constraint: key,
990
+ verdict,
991
+ text: describeChange(verdict, String(from === true), String(to === true))
992
+ });
993
+ }
994
+ } else {
995
+ out.push(opaqueChange(key, from, to));
996
+ }
997
+ }
998
+ out.push(
999
+ boundChange("maximum", b.upper, h.upper, true),
1000
+ boundChange("minimum", b.lower, h.lower, false)
1001
+ );
1002
+ return out.filter((c) => c !== void 0);
1003
+ }
1004
+ function compareConstraints(b, h, loc, emit, direction) {
1005
+ for (const { constraint, verdict, text } of constraintChanges(b, h)) {
1006
+ const params = { constraint, change: text };
1007
+ if (direction === "request") {
1008
+ if (verdict === "looser") emit("REQ_CONSTRAINT_LOOSENED", loc, params);
1009
+ else
1010
+ emit(
1011
+ "REQ_CONSTRAINT_TIGHTENED",
1012
+ loc,
1013
+ params,
1014
+ verdict === "changed" ? "warning" : void 0
1015
+ );
1016
+ } else if (verdict === "tighter") {
1017
+ emit("RESP_CONSTRAINT_TIGHTENED", loc, params);
1018
+ } else {
1019
+ emit("RESP_CONSTRAINT_LOOSENED", loc, params, verdict === "changed" ? "warning" : void 0);
1020
+ }
1021
+ }
1022
+ }
1023
+ function relevantNames(b, h, bProps, hProps) {
1024
+ const hidden = (name, props, view) => view.properties.has(name) && !props.has(name);
1025
+ return [.../* @__PURE__ */ new Set([...bProps.keys(), ...hProps.keys(), ...b.required, ...h.required])].filter((name) => !hidden(name, bProps, b) && !hidden(name, hProps, h)).sort();
1026
+ }
1027
+ function compareObjects(b, h, loc, path, state, depth, emit) {
1028
+ const direction = state.direction;
1029
+ const bProps = visibleProperties(b, direction);
1030
+ const hProps = visibleProperties(h, direction);
1031
+ for (const name of relevantNames(b, h, bProps, hProps)) {
1032
+ const bs = bProps.get(name);
1033
+ const hs = hProps.get(name);
1034
+ const where2 = [...path, name];
1035
+ const at = display(where2);
1036
+ if (bs && !hs) {
1037
+ const id = direction === "request" ? "REQ_PROPERTY_REMOVED" : "RESP_PROPERTY_REMOVED";
1038
+ emit(id, loc.removed("properties", name), { at });
1039
+ } else if (!bs && hs) {
1040
+ emitPropertyAdded(h.required.has(name), loc.added("properties", name), at, direction, emit);
1041
+ } else {
1042
+ if (bs && hs) {
1043
+ const here = loc.descend(["properties", name], ["properties", name]);
1044
+ compareSchemas(bs, hs, here, where2, state, depth + 1);
1045
+ }
1046
+ const was = b.required.has(name);
1047
+ compareRequired(was, h.required.has(name), loc.child("required"), at, direction, emit);
1048
+ }
1049
+ }
1050
+ compareAdditionalProperties(b, h, loc, path, state, depth, emit);
1051
+ }
1052
+ function emitPropertyAdded(required, loc, at, direction, emit) {
1053
+ if (direction === "response") emit("RESP_PROPERTY_ADDED", loc, { at });
1054
+ else if (required) emit("REQ_PROPERTY_REQUIRED_ADDED", loc, { at, what: "added as required" });
1055
+ else emit("REQ_PROPERTY_ADDED", loc, { at });
1056
+ }
1057
+ function compareRequired(was, now, loc, at, direction, emit) {
1058
+ if (was === now) return;
1059
+ if (direction === "request") {
1060
+ if (now) emit("REQ_PROPERTY_REQUIRED_ADDED", loc, { at, what: "is now required" });
1061
+ else emit("REQ_PROPERTY_REQUIRED_REMOVED", loc, { at });
1062
+ } else {
1063
+ emit(now ? "RESP_PROPERTY_BECAME_REQUIRED" : "RESP_PROPERTY_BECAME_OPTIONAL", loc, { at });
1064
+ }
1065
+ }
1066
+ function additionalOf(view) {
1067
+ if (view.additionalProperties === false) return false;
1068
+ if (Array.isArray(view.additionalProperties)) return merged(view.additionalProperties);
1069
+ return void 0;
1070
+ }
1071
+ function compareAdditionalProperties(b, h, loc, path, state, depth, emit) {
1072
+ const from = additionalOf(b);
1073
+ const to = additionalOf(h);
1074
+ if (from === false && to === false) return;
1075
+ if (from === false || to === false) {
1076
+ if (state.direction === "request") {
1077
+ emit(
1078
+ to === false ? "REQ_ADDITIONAL_PROPERTIES_DISALLOWED" : "REQ_ADDITIONAL_PROPERTIES_ALLOWED",
1079
+ loc.child("additionalProperties"),
1080
+ {}
1081
+ );
1082
+ }
1083
+ return;
1084
+ }
1085
+ if (from === void 0 && to === void 0) return;
1086
+ compareSchemas(
1087
+ from ?? ANY_SCHEMA,
1088
+ to ?? ANY_SCHEMA,
1089
+ loc.child("additionalProperties"),
1090
+ [...path, "*"],
1091
+ state,
1092
+ depth + 1
1093
+ );
1094
+ }
1095
+ function compareUnsupported(b, h, loc, emit) {
1096
+ for (const keyword of UNSUPPORTED_KEYWORDS) {
1097
+ const from = b.unsupported.get(keyword);
1098
+ const to = h.unsupported.get(keyword);
1099
+ if (!from && !to) continue;
1100
+ if (from && to && canonicalJson(from) === canonicalJson(to)) continue;
1101
+ const where2 = !from ? loc.added(keyword) : !to ? loc.removed(keyword) : loc.child(keyword);
1102
+ emit("UNSUPPORTED_CONSTRUCT", where2, { what: `"${keyword}" differs between versions` });
1103
+ }
1104
+ }
1105
+
1106
+ // src/diff/parameters.ts
1107
+ function where(param) {
1108
+ return ["parameters", param.in, param.name];
1109
+ }
1110
+ function extractMoves(removed, added) {
1111
+ const moves = [];
1112
+ for (const gone of [...removed]) {
1113
+ const moved = added.find(
1114
+ (p) => p.name.toLowerCase() === gone.name.toLowerCase() && p.in !== gone.in
1115
+ );
1116
+ if (!moved) continue;
1117
+ removed.splice(removed.indexOf(gone), 1);
1118
+ added.splice(added.indexOf(moved), 1);
1119
+ moves.push([gone, moved]);
1120
+ }
1121
+ return moves;
1122
+ }
1123
+ function diffParameters(base, head, loc, emit) {
1124
+ const removed = [...base.parameters.values()].filter((p) => !head.parameters.has(p.key));
1125
+ const added = [...head.parameters.values()].filter((p) => !base.parameters.has(p.key));
1126
+ for (const [from, to] of extractMoves(removed, added)) {
1127
+ const params = { name: from.name, from: from.in, to: to.in };
1128
+ emit("PARAM_LOCATION_CHANGED", loc.descend(where(from), where(to)), params);
1129
+ }
1130
+ for (const param of removed) {
1131
+ emit("PARAM_REMOVED", loc.removed(...where(param)), { in: param.in, name: param.name });
1132
+ }
1133
+ for (const param of added) {
1134
+ const id = param.required ? "PARAM_ADDED_REQUIRED" : "PARAM_ADDED_OPTIONAL";
1135
+ emit(id, loc.added(...where(param)), { in: param.in, name: param.name });
1136
+ }
1137
+ for (const [key, b] of base.parameters) {
1138
+ const h = head.parameters.get(key);
1139
+ if (h) diffSharedParameter(b, h, loc, emit);
1140
+ }
1141
+ }
1142
+ function diffSharedParameter(base, head, loc, emit) {
1143
+ const here = loc.descend(where(base), where(head));
1144
+ const names = { in: head.in, name: head.name };
1145
+ if (!base.required && head.required) emit("PARAM_BECAME_REQUIRED", here, names);
1146
+ if (base.required && !head.required) emit("PARAM_BECAME_OPTIONAL", here, names);
1147
+ if (base.serialization !== head.serialization) {
1148
+ const params = { ...names, from: base.serialization, to: head.serialization };
1149
+ emit("PARAM_SERIALIZATION_CHANGED", here, params);
1150
+ }
1151
+ diffSchema(
1152
+ base.schema,
1153
+ head.schema,
1154
+ loc.descend([...where(base), ...base.schemaPath], [...where(head), ...head.schemaPath]),
1155
+ emit,
1156
+ "request",
1157
+ `${head.in} parameter "${head.name}"`
1158
+ );
1159
+ }
1160
+
1161
+ // src/diff/requestBody.ts
1162
+ function diffRequestBody(base, head, loc, emit) {
1163
+ if (!base || !head) {
1164
+ if (head) {
1165
+ emit(head.required ? "REQ_BODY_BECAME_REQUIRED" : "REQ_BODY_ADDED", loc.added("requestBody"));
1166
+ } else if (base) {
1167
+ emit("REQ_BODY_REMOVED", loc.removed("requestBody"));
1168
+ }
1169
+ return;
1170
+ }
1171
+ const here = loc.child("requestBody");
1172
+ if (!base.required && head.required) emit("REQ_BODY_BECAME_REQUIRED", here.child("required"));
1173
+ if (base.required && !head.required) emit("REQ_BODY_BECAME_OPTIONAL", here.child("required"));
1174
+ const content = here.child("content");
1175
+ for (const [mediaType, entry] of base.content) {
1176
+ if (!head.content.has(mediaType)) {
1177
+ emit("REQ_MEDIA_TYPE_REMOVED", content.removed(entry.key), { mediaType: entry.key });
1178
+ }
1179
+ }
1180
+ for (const [mediaType, entry] of head.content) {
1181
+ const before = base.content.get(mediaType);
1182
+ if (!before) {
1183
+ emit("REQ_MEDIA_TYPE_ADDED", content.added(entry.key), { mediaType: entry.key });
1184
+ continue;
1185
+ }
1186
+ diffSchema(
1187
+ before.schema,
1188
+ entry.schema,
1189
+ content.descend([before.key, "schema"], [entry.key, "schema"]),
1190
+ emit,
1191
+ "request",
1192
+ `request body ${entry.key}`
1193
+ );
1194
+ }
1195
+ }
1196
+
1197
+ // src/diff/responses.ts
1198
+ function diffResponses(base, head, loc, emit) {
1199
+ const statuses = [.../* @__PURE__ */ new Set([...base.responses.keys(), ...head.responses.keys()])].sort();
1200
+ for (const status of statuses) {
1201
+ const b = base.responses.get(status);
1202
+ const h = head.responses.get(status);
1203
+ if (b && !h) {
1204
+ emit(
1205
+ "RESP_STATUS_REMOVED",
1206
+ loc.removed("responses", b.key),
1207
+ { status: b.key },
1208
+ {
1209
+ // Only a documented success path is a hard contract; error responses are best-effort.
1210
+ severity: status.startsWith("2") ? "breaking" : "warning"
1211
+ }
1212
+ );
1213
+ } else if (!b && h) {
1214
+ emit("RESP_STATUS_ADDED", loc.added("responses", h.key), { status: h.key });
1215
+ } else if (b && h) {
1216
+ diffResponseContent(b, h, loc.descend(["responses", b.key], ["responses", h.key]), emit);
1217
+ }
1218
+ }
1219
+ }
1220
+ function diffResponseContent(base, head, loc, emit) {
1221
+ const content = loc.child("content");
1222
+ for (const [mediaType, entry] of base.content) {
1223
+ if (!head.content.has(mediaType)) {
1224
+ const params = { mediaType: entry.key };
1225
+ emit("RESP_MEDIA_TYPE_REMOVED", content.removed(entry.key), params, { scope: head.key });
1226
+ }
1227
+ }
1228
+ for (const [mediaType, entry] of head.content) {
1229
+ const before = base.content.get(mediaType);
1230
+ if (!before) {
1231
+ const params = { mediaType: entry.key };
1232
+ emit("RESP_MEDIA_TYPE_ADDED", content.added(entry.key), params, { scope: head.key });
1233
+ continue;
1234
+ }
1235
+ const here = content.descend([before.key, "schema"], [entry.key, "schema"]);
1236
+ diffSchema(before.schema, entry.schema, here, emit, "response", `${head.key} ${entry.key}`);
1237
+ }
1238
+ }
1239
+
1240
+ // src/diff/security.ts
1241
+ var ANONYMOUS = /* @__PURE__ */ new Map();
1242
+ function satisfiedBy(needs, credentials) {
1243
+ for (const [scheme, scopes] of needs) {
1244
+ const held = credentials.get(scheme);
1245
+ if (!held || !scopes.every((scope) => held.includes(scope))) return false;
1246
+ }
1247
+ return true;
1248
+ }
1249
+ function accepts(requirement, client) {
1250
+ return requirement.length === 0 || requirement.some((alt) => satisfiedBy(alt, client));
1251
+ }
1252
+ function clients(requirement) {
1253
+ return requirement.length === 0 ? [ANONYMOUS] : requirement;
1254
+ }
1255
+ function describeSecurity(requirement) {
1256
+ if (requirement.length === 0) return "none";
1257
+ return requirement.map((alt) => {
1258
+ if (alt.size === 0) return "anonymous";
1259
+ return [...alt].map(([scheme, scopes]) => scopes.length > 0 ? `${scheme}[${scopes.join(",")}]` : scheme).join(" + ");
1260
+ }).join(" | ");
1261
+ }
1262
+ function diffSecurity(base, head, loc, emit) {
1263
+ const params = { from: describeSecurity(base.security), to: describeSecurity(head.security) };
1264
+ if (clients(base.security).some((client) => !accepts(head.security, client))) {
1265
+ emit("SECURITY_ADDED", loc.child("security"), params);
1266
+ } else if (clients(head.security).some((client) => !accepts(base.security, client))) {
1267
+ emit("SECURITY_RELAXED", loc.child("security"), params);
1268
+ }
1269
+ }
1270
+
1271
+ // src/diff/operations.ts
1272
+ function diffPathOperations(base, head, pathLoc, changes) {
1273
+ for (const method of HTTP_METHODS) {
1274
+ const b = base.operations.get(method);
1275
+ const h = head.operations.get(method);
1276
+ const label = `${method.toUpperCase()} ${head.template}`;
1277
+ const params = { method: method.toUpperCase(), path: head.template };
1278
+ if (b && !h) {
1279
+ createEmitter(changes, label)("OP_REMOVED", pathLoc.removed(method), params);
1280
+ } else if (h && !b) {
1281
+ createEmitter(changes, label)("OP_ADDED", pathLoc.added(method), params);
1282
+ } else if (b && h) {
1283
+ diffOperation(b, h, pathLoc.child(method), createEmitter(changes, label));
1284
+ }
1285
+ }
1286
+ }
1287
+ function diffOperation(base, head, loc, emit) {
1288
+ if (!base.deprecated && head.deprecated) emit("OP_DEPRECATED", loc.child("deprecated"));
1289
+ diffParameters(base, head, loc, emit);
1290
+ diffRequestBody(base.requestBody, head.requestBody, loc, emit);
1291
+ diffResponses(base, head, loc, emit);
1292
+ diffSecurity(base, head, loc, emit);
1293
+ }
1294
+
1295
+ // src/diff/paths.ts
1296
+ function diffPaths(base, head, changes) {
1297
+ const keys = [.../* @__PURE__ */ new Set([...base.paths.keys(), ...head.paths.keys()])].sort();
1298
+ for (const key of keys) {
1299
+ const b = base.paths.get(key);
1300
+ const h = head.paths.get(key);
1301
+ if (b && !h) {
1302
+ createEmitter(changes, b.template)("PATH_REMOVED", new Loc(["paths", b.template], null), {
1303
+ path: b.template
1304
+ });
1305
+ } else if (h && !b) {
1306
+ createEmitter(changes, h.template)("PATH_ADDED", new Loc(null, ["paths", h.template]), {
1307
+ path: h.template
1308
+ });
1309
+ } else if (b && h) {
1310
+ const loc = new Loc(["paths", b.template], ["paths", h.template]);
1311
+ if (b.template !== h.template) {
1312
+ createEmitter(changes, h.template)("PATH_PARAM_RENAMED", loc, {
1313
+ from: b.template,
1314
+ to: h.template
1315
+ });
1316
+ }
1317
+ diffPathOperations(b, h, loc, changes);
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ // src/diff/index.ts
1323
+ var METHOD_RANK = new Map(HTTP_METHODS.map((m, i) => [m.toUpperCase(), i]));
1324
+ function splitOperation(operation) {
1325
+ const match = /^([A-Z]+) (\/.*)$/.exec(operation);
1326
+ if (!match) return { path: operation, rank: -1 };
1327
+ return { path: match[2] ?? operation, rank: METHOD_RANK.get(match[1] ?? "") ?? 99 };
1328
+ }
1329
+ function compare(a, b) {
1330
+ return a < b ? -1 : a > b ? 1 : 0;
1331
+ }
1332
+ function sortChanges(changes) {
1333
+ const key = (c) => c.location.head ?? c.location.base ?? "";
1334
+ return [...changes].sort((a, b) => {
1335
+ const oa = splitOperation(a.operation);
1336
+ const ob = splitOperation(b.operation);
1337
+ return compare(oa.path, ob.path) || oa.rank - ob.rank || compare(key(a), key(b)) || compare(a.id, b.id) || compare(a.message, b.message) || compare(a.location.base ?? "", b.location.base ?? "");
1338
+ });
1339
+ }
1340
+ function diffSpecs(base, head) {
1341
+ const changes = [];
1342
+ diffPaths(base, head, changes);
1343
+ return sortChanges(changes);
1344
+ }
1345
+ async function compareFiles(baseFile, headFile) {
1346
+ const [base, head] = await Promise.all([loadSpec(baseFile), loadSpec(headFile)]);
1347
+ return {
1348
+ base: base.info,
1349
+ head: head.info,
1350
+ changes: diffSpecs(normalizeSpec(base), normalizeSpec(head))
1351
+ };
1352
+ }
1353
+
1354
+ // src/report/index.ts
1355
+ var FORMATS = ["text", "json", "markdown"];
1356
+ function summarize(changes) {
1357
+ const summary = { breaking: 0, warning: 0, safe: 0, info: 0, total: changes.length };
1358
+ for (const change of changes) summary[change.severity] += 1;
1359
+ return summary;
1360
+ }
1361
+ function visibleChanges(report) {
1362
+ return report.includeSafe ? report.changes : report.changes.filter((c) => c.severity !== "safe");
1363
+ }
1364
+ function groupByOperation(changes) {
1365
+ const groups = [];
1366
+ for (const change of changes) {
1367
+ const last = groups[groups.length - 1];
1368
+ if (last && last[0] === change.operation) last[1].push(change);
1369
+ else groups.push([change.operation, [change]]);
1370
+ }
1371
+ return groups;
1372
+ }
1373
+ function describeSpec(info) {
1374
+ const name = [info.title, info.version].filter((part) => part !== "").join(" ");
1375
+ return `${info.file} (${name ? `${name}, ` : ""}OpenAPI ${info.openapi})`;
1376
+ }
1377
+
1378
+ // src/report/json.ts
1379
+ function renderJson(report) {
1380
+ const visible = visibleChanges(report);
1381
+ const summary = summarize(report.changes);
1382
+ const output = {
1383
+ schemaVersion: 1,
1384
+ tool: report.tool,
1385
+ base: report.base,
1386
+ head: report.head,
1387
+ changes: visible.map((c) => ({
1388
+ id: c.id,
1389
+ severity: c.severity,
1390
+ operation: c.operation,
1391
+ location: { base: c.location.base, head: c.location.head },
1392
+ message: c.message
1393
+ })),
1394
+ summary: { ...summary, hiddenSafe: report.includeSafe ? 0 : summary.safe }
1395
+ };
1396
+ return `${JSON.stringify(output, null, 2)}
1397
+ `;
1398
+ }
1399
+
1400
+ // src/report/markdown.ts
1401
+ function escapeMarkdown(text) {
1402
+ return text.replace(/\r?\n/g, " ").replace(/[\\`*_[\]<>&|]/g, (ch) => {
1403
+ if (ch === "<") return "&lt;";
1404
+ if (ch === ">") return "&gt;";
1405
+ if (ch === "&") return "&amp;";
1406
+ return `\\${ch}`;
1407
+ });
1408
+ }
1409
+ function codeSpan(text) {
1410
+ const escaped = text.replace(/\|/g, "\\|");
1411
+ return text.includes("`") ? `\`\` ${escaped} \`\`` : `\`${escaped}\``;
1412
+ }
1413
+ var LABELS = { breaking: "Breaking", warning: "Warning", safe: "Safe", info: "Info" };
1414
+ function renderMarkdown(report) {
1415
+ const visible = visibleChanges(report);
1416
+ const summary = summarize(report.changes);
1417
+ const lines = [
1418
+ "## SpecDiff report",
1419
+ "",
1420
+ `- Base: ${codeSpan(describeSpec(report.base))}`,
1421
+ `- Head: ${codeSpan(describeSpec(report.head))}`,
1422
+ "",
1423
+ "| Severity | Changes |",
1424
+ "| --- | ---: |",
1425
+ `| Breaking | ${summary.breaking} |`,
1426
+ `| Warning | ${summary.warning} |`,
1427
+ `| Safe | ${summary.safe}${report.includeSafe || summary.safe === 0 ? "" : " (hidden)"} |`,
1428
+ `| Info | ${summary.info} |`,
1429
+ ""
1430
+ ];
1431
+ if (visible.length === 0) lines.push("No API changes detected.", "");
1432
+ for (const [operation, changes] of groupByOperation(visible)) {
1433
+ lines.push(
1434
+ `### ${codeSpan(operation)}`,
1435
+ "",
1436
+ "| Severity | Rule | Change |",
1437
+ "| --- | --- | --- |"
1438
+ );
1439
+ for (const c of changes) {
1440
+ lines.push(`| ${LABELS[c.severity]} | ${codeSpan(c.id)} | ${escapeMarkdown(c.message)} |`);
1441
+ }
1442
+ lines.push("");
1443
+ }
1444
+ return `${lines.join("\n").trimEnd()}
1445
+ `;
1446
+ }
1447
+
1448
+ // src/report/text.ts
1449
+ function renderText(report) {
1450
+ const visible = visibleChanges(report);
1451
+ const summary = summarize(report.changes);
1452
+ const idWidth = Math.max(0, ...visible.map((c) => c.id.length));
1453
+ const lines = [
1454
+ `${report.tool.name} ${report.tool.version}`,
1455
+ `base: ${describeSpec(report.base)}`,
1456
+ `head: ${describeSpec(report.head)}`,
1457
+ ""
1458
+ ];
1459
+ if (visible.length === 0) lines.push("No API changes detected.", "");
1460
+ for (const [operation, changes] of groupByOperation(visible)) {
1461
+ lines.push(operation);
1462
+ for (const c of changes) {
1463
+ lines.push(` ${c.severity.toUpperCase().padEnd(8)} ${c.id.padEnd(idWidth)} ${c.message}`);
1464
+ }
1465
+ lines.push("");
1466
+ }
1467
+ const parts = [`${summary.breaking} breaking`, `${summary.warning} warning`];
1468
+ if (report.includeSafe) parts.push(`${summary.safe} safe`);
1469
+ parts.push(`${summary.info} info`);
1470
+ let line = `Summary: ${parts.join(", ")}`;
1471
+ if (!report.includeSafe && summary.safe > 0) {
1472
+ line += ` (${summary.safe} safe hidden, use --include-safe to list them)`;
1473
+ }
1474
+ lines.push(line);
1475
+ return `${lines.join("\n")}
1476
+ `;
1477
+ }
1478
+
1479
+ // src/cli.ts
1480
+ var FAIL_ON = ["breaking", "warning", "never"];
1481
+ var EXIT = { ok: 0, changes: 1, usage: 2, interrupted: 130, pipeClosed: 141 };
1482
+ var HELP = `specdiff ${package_default.version}
1483
+ Compare two OpenAPI 3.x documents and classify every change as breaking, warning or safe.
1484
+
1485
+ Usage:
1486
+ specdiff <base> <head> [options]
1487
+
1488
+ Options:
1489
+ --format <text|json|markdown> Output format (default: text)
1490
+ --fail-on <breaking|warning|never>
1491
+ Exit 1 when a change at or above this severity exists
1492
+ (default: breaking)
1493
+ --include-safe List safe changes (hidden by default)
1494
+ --ignore <RULE_ID,...> Ignore rules by ID (repeatable)
1495
+ --version Print the version
1496
+ --help Print this help
1497
+
1498
+ Exit codes:
1499
+ 0 nothing at or above the --fail-on threshold
1500
+ 1 changes at or above the threshold
1501
+ 2 usage or input error (including invalid specs)
1502
+ 130 interrupted
1503
+ 141 output pipe closed early
1504
+ `;
1505
+ var UsageError = class extends Error {
1506
+ };
1507
+ function choice(flag, value, allowed) {
1508
+ const found = allowed.find((candidate) => candidate === value);
1509
+ if (found === void 0) {
1510
+ throw new UsageError(`invalid ${flag} "${value}" (expected ${allowed.join(", ")})`);
1511
+ }
1512
+ return found;
1513
+ }
1514
+ function ignoredRules(entries) {
1515
+ const ids = new Set(
1516
+ entries.flatMap((entry) => entry.split(",")).map((id) => id.trim()).filter((id) => id !== "")
1517
+ );
1518
+ const unknown = [...ids].filter((id) => !isRuleId(id));
1519
+ if (unknown.length > 0)
1520
+ throw new UsageError(`unknown rule ID in --ignore: ${unknown.join(", ")}`);
1521
+ return ids;
1522
+ }
1523
+ function parseOptions(argv) {
1524
+ let parsed;
1525
+ try {
1526
+ parsed = parseArgs({
1527
+ args: argv,
1528
+ allowPositionals: true,
1529
+ options: {
1530
+ format: { type: "string", default: "text" },
1531
+ "fail-on": { type: "string", default: "breaking" },
1532
+ "include-safe": { type: "boolean", default: false },
1533
+ ignore: { type: "string", multiple: true },
1534
+ version: { type: "boolean", default: false },
1535
+ help: { type: "boolean", default: false }
1536
+ }
1537
+ });
1538
+ } catch (error) {
1539
+ throw new UsageError(error instanceof Error ? error.message : String(error));
1540
+ }
1541
+ const { values, positionals } = parsed;
1542
+ if (values.help) return "help";
1543
+ if (values.version) return "version";
1544
+ const format = choice("--format", values.format, FORMATS);
1545
+ const failOn = choice("--fail-on", values["fail-on"], FAIL_ON);
1546
+ const ignore = ignoredRules(values.ignore ?? []);
1547
+ const [base, head, ...extra] = positionals;
1548
+ if (base === void 0 || head === void 0 || extra.length > 0) {
1549
+ throw new UsageError("expected exactly two arguments: <base> <head>");
1550
+ }
1551
+ return { base, head, format, failOn, includeSafe: values["include-safe"], ignore };
1552
+ }
1553
+ var RENDERERS = {
1554
+ text: renderText,
1555
+ json: renderJson,
1556
+ markdown: renderMarkdown
1557
+ };
1558
+ var FAILING = {
1559
+ breaking: ["breaking"],
1560
+ warning: ["breaking", "warning"],
1561
+ never: []
1562
+ };
1563
+ async function run(argv, io) {
1564
+ try {
1565
+ const options = parseOptions(argv);
1566
+ if (options === "help") {
1567
+ io.stdout(HELP);
1568
+ return EXIT.ok;
1569
+ }
1570
+ if (options === "version") {
1571
+ io.stdout(`${package_default.version}
1572
+ `);
1573
+ return EXIT.ok;
1574
+ }
1575
+ const comparison = await compareFiles(options.base, options.head);
1576
+ const changes = comparison.changes.filter((c) => !options.ignore.has(c.id));
1577
+ const report = {
1578
+ tool: { name: "specdiff", version: package_default.version },
1579
+ base: comparison.base,
1580
+ head: comparison.head,
1581
+ changes,
1582
+ includeSafe: options.includeSafe
1583
+ };
1584
+ io.stdout(RENDERERS[options.format](report));
1585
+ const failing = FAILING[options.failOn];
1586
+ return changes.some((c) => failing.includes(c.severity)) ? EXIT.changes : EXIT.ok;
1587
+ } catch (error) {
1588
+ if (error instanceof UsageError) {
1589
+ io.stderr(`specdiff: ${error.message}
1590
+ Run "specdiff --help" for usage.
1591
+ `);
1592
+ return EXIT.usage;
1593
+ }
1594
+ if (error instanceof SpecdiffError) {
1595
+ io.stderr(`specdiff: ${error.message}
1596
+ `);
1597
+ return EXIT.usage;
1598
+ }
1599
+ const detail = error instanceof Error ? error.stack ?? error.message : String(error);
1600
+ io.stderr(`specdiff: unexpected error: ${detail}
1601
+ `);
1602
+ return EXIT.usage;
1603
+ }
1604
+ }
1605
+ function isMain() {
1606
+ const entry = process.argv[1];
1607
+ if (entry === void 0) return false;
1608
+ try {
1609
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
1610
+ } catch {
1611
+ return false;
1612
+ }
1613
+ }
1614
+ if (isMain()) {
1615
+ process.on("SIGINT", () => process.exit(EXIT.interrupted));
1616
+ process.stdout.on("error", (error) => {
1617
+ if (error.code === "EPIPE") process.exit(EXIT.pipeClosed);
1618
+ throw error;
1619
+ });
1620
+ process.exitCode = await run(process.argv.slice(2), {
1621
+ stdout: (text) => process.stdout.write(text),
1622
+ stderr: (text) => process.stderr.write(text)
1623
+ });
1624
+ }
1625
+ export {
1626
+ EXIT,
1627
+ run
1628
+ };