@subhamxcod/apiguard 1.0.2

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.
@@ -0,0 +1,3848 @@
1
+ // src/core/config/loader.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { ZodError } from "zod";
6
+
7
+ // src/core/config/schema.ts
8
+ import { z } from "zod";
9
+ var severityValueSchema = z.enum(["error", "warning", "info", "off"]);
10
+ var rulesSchema = z.record(z.string().min(1), severityValueSchema);
11
+ var ignoreSchema = z.strictObject({
12
+ paths: z.array(z.string()).optional().default([]),
13
+ operations: z.array(z.string()).optional().default([]),
14
+ schemas: z.array(z.string()).optional().default([]),
15
+ changes: z.array(z.string()).optional().default([]),
16
+ "show-ignored": z.boolean().optional().default(false)
17
+ });
18
+ var outputSchema = z.strictObject({
19
+ format: z.enum(["terminal", "json", "markdown"]).optional().default("terminal"),
20
+ "include-non-breaking": z.boolean().optional().default(false),
21
+ color: z.enum(["auto", "always", "never"]).optional().default("auto")
22
+ });
23
+ var versioningSchema = z.strictObject({
24
+ suggest: z.boolean().optional().default(true)
25
+ });
26
+ var loaderSchema = z.strictObject({
27
+ "allow-remote-refs": z.boolean().optional().default(false)
28
+ });
29
+ var githubSchema = z.strictObject({
30
+ comment: z.boolean().optional().default(true),
31
+ "check-run": z.boolean().optional().default(true),
32
+ "update-existing-comment": z.boolean().optional().default(true)
33
+ });
34
+ var configSchema = z.strictObject({
35
+ schemaVersion: z.literal(1, {
36
+ error: "schemaVersion must be 1 (other versions are rejected; see docs/configuration.md)"
37
+ }),
38
+ rules: rulesSchema.optional().default({}),
39
+ ignore: ignoreSchema.optional(),
40
+ output: outputSchema.optional(),
41
+ "fail-on": z.enum(["error", "warning", "never"]).optional(),
42
+ versioning: versioningSchema.optional(),
43
+ loader: loaderSchema.optional(),
44
+ github: githubSchema.optional()
45
+ });
46
+
47
+ // src/core/config/defaults.ts
48
+ var DEFAULT_CONFIG = {
49
+ schemaVersion: 1,
50
+ rules: {},
51
+ ignore: {
52
+ paths: [],
53
+ operations: [],
54
+ schemas: [],
55
+ changes: [],
56
+ showIgnored: false
57
+ },
58
+ output: {
59
+ format: "terminal",
60
+ includeNonBreaking: false,
61
+ color: "auto"
62
+ },
63
+ failOn: "error",
64
+ // §13 golden output shows the bump line "only with --suggest-version"; config may opt in.
65
+ versioning: { suggest: false },
66
+ loader: { allowRemoteRefs: false },
67
+ github: { comment: true, checkRun: true, updateExistingComment: true }
68
+ };
69
+
70
+ // src/utils/errors.ts
71
+ var ExitCode = {
72
+ Ok: 0,
73
+ BreakingChanges: 1,
74
+ Usage: 2,
75
+ SpecError: 3,
76
+ Internal: 4,
77
+ Sigint: 130
78
+ };
79
+ var ApiguardError = class extends Error {
80
+ constructor(message) {
81
+ super(message);
82
+ this.name = new.target.name;
83
+ }
84
+ };
85
+ var CliUsageError = class extends ApiguardError {
86
+ exitCode = ExitCode.Usage;
87
+ constructor(message) {
88
+ super(message);
89
+ }
90
+ };
91
+ var ConfigError = class extends ApiguardError {
92
+ exitCode = ExitCode.Usage;
93
+ constructor(message) {
94
+ super(message);
95
+ }
96
+ };
97
+ var SpecLoadError = class extends ApiguardError {
98
+ exitCode = ExitCode.SpecError;
99
+ constructor(message) {
100
+ super(message);
101
+ }
102
+ };
103
+ var SpecValidationError = class extends ApiguardError {
104
+ exitCode = ExitCode.SpecError;
105
+ constructor(message) {
106
+ super(message);
107
+ }
108
+ };
109
+ function errorMessage(err) {
110
+ if (err instanceof Error) return err.message;
111
+ return String(err);
112
+ }
113
+
114
+ // src/core/config/loader.ts
115
+ var CONFIG_FILE_CANDIDATES = [
116
+ "apiguard.yaml",
117
+ "apiguard.yml",
118
+ "apiguard.json",
119
+ ".apiguard.yaml",
120
+ ".apiguard.yml"
121
+ ];
122
+ var LEGACY_RULE_ALIASES = {
123
+ "removed-request-property": { canonical: "property-removed", scope: "request-body" },
124
+ "removed-response-property": { canonical: "property-removed", scope: "response-body" },
125
+ "new-required-request-property": { canonical: "required-property-added", scope: "request-body" }
126
+ };
127
+ function readConfigFile(filePath) {
128
+ let text;
129
+ try {
130
+ text = fs.readFileSync(filePath, "utf8");
131
+ } catch {
132
+ throw new ConfigError(`config file not found: ${filePath}`);
133
+ }
134
+ try {
135
+ if (filePath.toLowerCase().endsWith(".json")) return JSON.parse(text);
136
+ return parseYaml(text);
137
+ } catch (e) {
138
+ throw new ConfigError(`${filePath}: invalid YAML/JSON: ${errorMessage(e)}`);
139
+ }
140
+ }
141
+ function toResolved(parsed) {
142
+ return {
143
+ schemaVersion: 1,
144
+ rules: parsed.rules ?? {},
145
+ ignore: {
146
+ paths: parsed.ignore?.paths ?? [],
147
+ operations: parsed.ignore?.operations ?? [],
148
+ schemas: parsed.ignore?.schemas ?? [],
149
+ changes: parsed.ignore?.changes ?? [],
150
+ showIgnored: parsed.ignore?.["show-ignored"] ?? false
151
+ },
152
+ output: {
153
+ format: parsed.output?.format ?? "terminal",
154
+ includeNonBreaking: parsed.output?.["include-non-breaking"] ?? false,
155
+ color: parsed.output?.color ?? "auto"
156
+ },
157
+ failOn: parsed["fail-on"] ?? "error",
158
+ versioning: { suggest: parsed.versioning?.suggest ?? true },
159
+ loader: { allowRemoteRefs: parsed.loader?.["allow-remote-refs"] ?? false },
160
+ github: {
161
+ comment: parsed.github?.comment ?? true,
162
+ checkRun: parsed.github?.["check-run"] ?? true,
163
+ updateExistingComment: parsed.github?.["update-existing-comment"] ?? true
164
+ }
165
+ };
166
+ }
167
+ function resolveConfigObject(raw, sourcePath) {
168
+ let parsed;
169
+ try {
170
+ parsed = configSchema.parse(raw);
171
+ } catch (e) {
172
+ if (e instanceof ZodError) {
173
+ const first = e.issues[0];
174
+ const where = first?.path?.length ? ` at key "${first.path.join(".")}"` : "";
175
+ throw new ConfigError(
176
+ `${sourcePath}: invalid config${where}: ${first?.message ?? "validation failed"}`
177
+ );
178
+ }
179
+ throw new ConfigError(`${sourcePath}: invalid config: ${errorMessage(e)}`);
180
+ }
181
+ return toResolved(parsed);
182
+ }
183
+ function applyAliases(raw, warnings) {
184
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return raw;
185
+ const obj = raw;
186
+ const rules = obj.rules;
187
+ if (rules === null || typeof rules !== "object" || Array.isArray(rules)) return raw;
188
+ const out = { ...obj, rules: { ...rules } };
189
+ for (const key of Object.keys(out.rules)) {
190
+ const alias = LEGACY_RULE_ALIASES[key];
191
+ if (alias) {
192
+ const canonicalKey = alias.scope ? `${alias.canonical}@${alias.scope}` : alias.canonical;
193
+ const value = out.rules[key];
194
+ delete out.rules[key];
195
+ out.rules[canonicalKey] = value;
196
+ warnings.push(
197
+ `config: rule key "${key}" is deprecated (v1 alias); use "${canonicalKey}" instead`
198
+ );
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+ function loadConfigFrom(explicitPath) {
204
+ const warnings = [];
205
+ const raw = readConfigFile(explicitPath);
206
+ const withAliases = applyAliases(raw, warnings);
207
+ const config = resolveConfigObject(withAliases, explicitPath);
208
+ return { config, sourcePath: explicitPath, warnings };
209
+ }
210
+ function discoverAndLoadConfig(opts = {}) {
211
+ const cwd = opts.cwd ?? process.cwd();
212
+ if (opts.explicit || opts.env) {
213
+ const target = opts.explicit ?? opts.env ?? "";
214
+ const abs = path.isAbsolute(target) ? target : path.resolve(cwd, target);
215
+ if (!fs.existsSync(abs)) {
216
+ throw new ConfigError(`config file not found: ${abs}`);
217
+ }
218
+ return loadConfigFrom(abs);
219
+ }
220
+ for (const candidate of CONFIG_FILE_CANDIDATES) {
221
+ const abs = path.resolve(cwd, candidate);
222
+ if (fs.existsSync(abs)) {
223
+ return loadConfigFrom(abs);
224
+ }
225
+ }
226
+ return { config: { ...DEFAULT_CONFIG }, sourcePath: "", warnings: [] };
227
+ }
228
+ function mergeWithFlags(base, flags) {
229
+ return {
230
+ ...base,
231
+ output: {
232
+ format: flags.format ?? base.output.format,
233
+ includeNonBreaking: flags.includeNonBreaking ?? base.output.includeNonBreaking,
234
+ color: flags.noColor ? "never" : base.output.color
235
+ },
236
+ failOn: flags.failOn ?? base.failOn,
237
+ versioning: {
238
+ suggest: flags.suggestVersion ?? base.versioning.suggest
239
+ }
240
+ };
241
+ }
242
+
243
+ // src/loaders/spec-loader.ts
244
+ import fs2 from "fs";
245
+ import path2 from "path";
246
+ import SwaggerParser from "@apidevtools/swagger-parser";
247
+ import { parse as parseYaml2 } from "yaml";
248
+
249
+ // src/utils/hash.ts
250
+ import { createHash } from "crypto";
251
+ function canonicalize(value) {
252
+ return stableStringify(value);
253
+ }
254
+ function stableStringify(value) {
255
+ if (value === null || typeof value !== "object") {
256
+ return JSON.stringify(value) ?? "null";
257
+ }
258
+ if (Array.isArray(value)) {
259
+ return "[" + value.map(stableStringify).join(",") + "]";
260
+ }
261
+ const obj = value;
262
+ const keys = Object.keys(obj).sort();
263
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
264
+ }
265
+ function sha256OfValue(value) {
266
+ return createHash("sha256").update(canonicalize(value)).digest("hex");
267
+ }
268
+ function changeId(parts) {
269
+ const method = parts.method ?? "";
270
+ const oldJson = parts.oldValue === void 0 ? "" : canonicalize(parts.oldValue);
271
+ const newJson = parts.newValue === void 0 ? "" : canonicalize(parts.newValue);
272
+ const key = [
273
+ parts.ruleId,
274
+ parts.context,
275
+ parts.path,
276
+ method,
277
+ parts.pointerOld,
278
+ parts.pointerNew,
279
+ oldJson,
280
+ newJson
281
+ ].join("|");
282
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
283
+ }
284
+
285
+ // src/core/engine/normalize.ts
286
+ function normalizeDocument(document) {
287
+ return normalizeNode(document);
288
+ }
289
+ function normalizeNode(node) {
290
+ if (node === null || typeof node !== "object") return node;
291
+ if (Array.isArray(node)) {
292
+ return node.map((v) => normalizeNode(v));
293
+ }
294
+ const obj = node;
295
+ const minimum = obj.minimum;
296
+ const maximum = obj.maximum;
297
+ let exclusiveMin = obj.exclusiveMinimum;
298
+ let exclusiveMax = obj.exclusiveMaximum;
299
+ const minIsExclusive = exclusiveMin === true && typeof minimum === "number";
300
+ const maxIsExclusive = exclusiveMax === true && typeof maximum === "number";
301
+ if (minIsExclusive) exclusiveMin = minimum;
302
+ else if (exclusiveMin === false) exclusiveMin = void 0;
303
+ if (maxIsExclusive) exclusiveMax = maximum;
304
+ else if (exclusiveMax === false) exclusiveMax = void 0;
305
+ let typeValue = obj.type;
306
+ const nullable = obj.nullable === true;
307
+ if (typeof typeValue === "string") typeValue = [typeValue];
308
+ if (Array.isArray(typeValue)) typeValue = [...typeValue].sort();
309
+ if (nullable && Array.isArray(typeValue)) {
310
+ typeValue = typeValue.includes("null") ? typeValue : [...typeValue, "null"].sort();
311
+ }
312
+ const out = {};
313
+ for (const key of Object.keys(obj).sort()) {
314
+ switch (key) {
315
+ case "type":
316
+ if (typeValue !== void 0) out.type = typeValue;
317
+ break;
318
+ case "nullable":
319
+ break;
320
+ case "exclusiveMinimum":
321
+ if (exclusiveMin !== void 0) out.exclusiveMinimum = exclusiveMin;
322
+ break;
323
+ case "exclusiveMaximum":
324
+ if (exclusiveMax !== void 0) out.exclusiveMaximum = exclusiveMax;
325
+ break;
326
+ case "minimum":
327
+ if (!minIsExclusive && minimum !== void 0) out.minimum = minimum;
328
+ break;
329
+ case "maximum":
330
+ if (!maxIsExclusive && maximum !== void 0) out.maximum = maximum;
331
+ break;
332
+ default:
333
+ out[key] = normalizeNode(obj[key]);
334
+ break;
335
+ }
336
+ }
337
+ return out;
338
+ }
339
+ function toNormalizedSpec(loaded, normalized) {
340
+ return { ...loaded, normalized, sha256: sha256OfValue(normalized) };
341
+ }
342
+
343
+ // src/loaders/spec-loader.ts
344
+ function parseSpecText(text, source) {
345
+ let parsed;
346
+ const isJsonByExt = source.trimEnd().toLowerCase().endsWith(".json");
347
+ if (isJsonByExt) {
348
+ try {
349
+ parsed = JSON.parse(text);
350
+ } catch (e) {
351
+ throw new SpecLoadError(`${source}: JSON parse error: ${errorMessage(e)}`);
352
+ }
353
+ } else {
354
+ try {
355
+ parsed = parseYaml2(text, { schema: "core" });
356
+ } catch (e) {
357
+ throw new SpecLoadError(`${source}: YAML parse error: ${errorMessage(e)}`);
358
+ }
359
+ }
360
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
361
+ throw new SpecLoadError(
362
+ `${source}: spec root must be a mapping object with an 'openapi' field (got ${describeRoot(parsed)})`
363
+ );
364
+ }
365
+ return parsed;
366
+ }
367
+ function describeRoot(v) {
368
+ if (v === null) return "null";
369
+ if (Array.isArray(v)) return "an array";
370
+ return `a ${typeof v}`;
371
+ }
372
+ function findRemoteRefs(document) {
373
+ const found = [];
374
+ const stack = [document];
375
+ while (stack.length > 0) {
376
+ const node = stack.pop();
377
+ if (node === null || typeof node !== "object") continue;
378
+ if (Array.isArray(node)) {
379
+ for (const v of node) stack.push(v);
380
+ continue;
381
+ }
382
+ const obj = node;
383
+ for (const [k, v] of Object.entries(obj)) {
384
+ if (k === "$ref" && typeof v === "string") {
385
+ const target = v.split("#")[0] ?? "";
386
+ if (/^https?:\/\//i.test(target)) found.push(v);
387
+ } else if (v !== null && typeof v === "object") {
388
+ stack.push(v);
389
+ }
390
+ }
391
+ }
392
+ return found;
393
+ }
394
+ function findExternalFileRefs(document, specDir) {
395
+ const found = [];
396
+ const stack = [document];
397
+ while (stack.length > 0) {
398
+ const node = stack.pop();
399
+ if (node === null || typeof node !== "object") continue;
400
+ if (Array.isArray(node)) {
401
+ for (const v of node) stack.push(v);
402
+ continue;
403
+ }
404
+ const obj = node;
405
+ for (const [k, v] of Object.entries(obj)) {
406
+ if (k === "$ref" && typeof v === "string") {
407
+ const filePart = v.split("#")[0] ?? "";
408
+ if (filePart && !/^[a-z][a-z0-9+.-]*:/i.test(filePart)) {
409
+ const resolved = path2.resolve(specDir, filePart);
410
+ const rel = path2.relative(specDir, resolved);
411
+ if (rel.startsWith("..") || path2.isAbsolute(rel)) found.push(v);
412
+ }
413
+ } else if (v !== null && typeof v === "object") {
414
+ stack.push(v);
415
+ }
416
+ }
417
+ }
418
+ return found;
419
+ }
420
+ async function loadSpec(filePath, options = {}) {
421
+ const abs = path2.resolve(filePath);
422
+ let text;
423
+ try {
424
+ text = fs2.readFileSync(abs, "utf8");
425
+ } catch (e) {
426
+ throw new SpecLoadError(`${filePath}: cannot read file: ${errorMessage(e)}`);
427
+ }
428
+ if (text.trim() === "") {
429
+ throw new SpecLoadError(`${filePath}: file is empty \u2014 a spec must contain an OpenAPI document`);
430
+ }
431
+ const parsed = parseSpecText(text, filePath);
432
+ const openapi = typeof parsed.openapi === "string" ? parsed.openapi : "";
433
+ if (!openapi) {
434
+ throw new SpecLoadError(
435
+ `${filePath}: missing 'openapi' version field \u2014 this does not look like an OpenAPI 3.x document`
436
+ );
437
+ }
438
+ if (!openapi.startsWith("3.")) {
439
+ throw new SpecLoadError(
440
+ `${filePath}: unsupported OpenAPI version "${openapi}" \u2014 API Guard supports 3.0.x and 3.1.x only`
441
+ );
442
+ }
443
+ const specDir = path2.dirname(abs);
444
+ const allowRemote = options.loader?.allowRemoteRefs ?? false;
445
+ const allowExternal = options.loader?.allowExternalFiles ?? false;
446
+ const remoteRefs = findRemoteRefs(parsed);
447
+ if (remoteRefs.length > 0 && !allowRemote) {
448
+ throw new SpecLoadError(
449
+ `${filePath}: remote $ref(s) denied by default (found ${remoteRefs.length}: ${remoteRefs.slice(0, 3).join(", ")}${remoteRefs.length > 3 ? ", \u2026" : ""}). Set loader.allow-remote-refs: true in apiguard.yaml to opt in.`
450
+ );
451
+ }
452
+ const externalRefs = findExternalFileRefs(parsed, specDir);
453
+ if (externalRefs.length > 0 && !allowExternal) {
454
+ throw new SpecLoadError(
455
+ `${filePath}: $ref(s) point outside the spec's directory (found ${externalRefs.length}: ${externalRefs.slice(0, 3).join(", ")}${externalRefs.length > 3 ? ", \u2026" : ""}). External-file refs are not supported in v1.`
456
+ );
457
+ }
458
+ let document;
459
+ try {
460
+ document = await SwaggerParser.bundle(abs);
461
+ } catch (e) {
462
+ throw new SpecLoadError(`${filePath}: parser error: ${errorMessage(e)}`);
463
+ }
464
+ const info = parsed.info ?? {};
465
+ const title = typeof info.title === "string" ? info.title : "";
466
+ const loaded = {
467
+ document,
468
+ source: filePath,
469
+ openapiVersion: openapi,
470
+ title,
471
+ sha256: ""
472
+ };
473
+ return toNormalizedSpec(loaded, normalizeDocument(document));
474
+ }
475
+
476
+ // src/loaders/validate.ts
477
+ function asObject(v) {
478
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : void 0;
479
+ }
480
+ function asRecordArray(v) {
481
+ if (!Array.isArray(v)) return [];
482
+ return v.filter((x) => x !== null && typeof x === "object" && !Array.isArray(x));
483
+ }
484
+ function validateSemantics(document, file) {
485
+ const issues = [];
486
+ const doc = asObject(document);
487
+ if (!doc)
488
+ return { issues: [{ level: "error", file, message: "document is not an object" }], ok: false };
489
+ const schemes = asObject(doc.components)?.securitySchemes;
490
+ const schemeNames = new Set(schemes ? Object.keys(schemes) : []);
491
+ const checkRequirements = (reqs, where) => {
492
+ for (const req of asRecordArray(reqs)) {
493
+ for (const name of Object.keys(req)) {
494
+ if (!schemeNames.has(name)) {
495
+ issues.push({
496
+ level: "error",
497
+ file,
498
+ message: `${where}: security requirement references unknown scheme "${name}"`
499
+ });
500
+ }
501
+ }
502
+ }
503
+ };
504
+ checkRequirements(doc.security, "#/");
505
+ const paths = asObject(doc.paths) ?? {};
506
+ for (const [p, item] of Object.entries(paths)) {
507
+ const pathItem = asObject(item);
508
+ if (!pathItem) continue;
509
+ checkRequirements(pathItem.security, `${p} (path-level)`);
510
+ for (const method of Object.keys(pathItem)) {
511
+ if (!isHttpMethod(method)) continue;
512
+ const op = asObject(pathItem[method]);
513
+ if (!op) continue;
514
+ checkRequirements(op.security, `${p} ${method.toUpperCase()}`);
515
+ }
516
+ }
517
+ const seenIds = /* @__PURE__ */ new Map();
518
+ for (const [p, item] of Object.entries(paths)) {
519
+ const pathItem = asObject(item);
520
+ if (!pathItem) continue;
521
+ for (const method of Object.keys(pathItem)) {
522
+ if (!isHttpMethod(method)) continue;
523
+ const op = asObject(pathItem[method]);
524
+ const opId = op?.operationId;
525
+ if (typeof opId === "string" && opId.length > 0) {
526
+ const prev = seenIds.get(opId);
527
+ if (prev !== void 0) {
528
+ issues.push({
529
+ level: "warning",
530
+ file,
531
+ message: `duplicate operationId "${opId}" (also at ${prev})`
532
+ });
533
+ } else {
534
+ seenIds.set(opId, `${p} ${method.toUpperCase()}`);
535
+ }
536
+ }
537
+ }
538
+ }
539
+ for (const [p, item] of Object.entries(paths)) {
540
+ const pathItem = asObject(item);
541
+ if (!pathItem) {
542
+ issues.push({ level: "warning", file, message: `${p}: path item is empty` });
543
+ continue;
544
+ }
545
+ const hasOp = Object.keys(pathItem).some((k) => isHttpMethod(k));
546
+ if (!hasOp) {
547
+ issues.push({ level: "warning", file, message: `${p}: path item has no operations` });
548
+ }
549
+ for (const method of Object.keys(pathItem)) {
550
+ if (!isHttpMethod(method)) continue;
551
+ const op = asObject(pathItem[method]);
552
+ if (!op) continue;
553
+ const where = `${p} ${method.toUpperCase()}`;
554
+ const responses = asObject(op.responses);
555
+ if (responses) {
556
+ for (const key of Object.keys(responses)) {
557
+ if (!/^(default|[1-5](\d{2}|XX))$/.test(key)) {
558
+ issues.push({
559
+ level: "error",
560
+ file,
561
+ message: `${where}: invalid response status key "${key}" (expected 1XX-5XX, code, or "default")`
562
+ });
563
+ }
564
+ }
565
+ }
566
+ for (const param of asRecordArray(op.parameters)) {
567
+ checkParameterShape(param, where, issues, file);
568
+ }
569
+ for (const param of asRecordArray(pathItem.parameters)) {
570
+ checkParameterShape(param, `${p} (path-level)`, issues, file);
571
+ }
572
+ }
573
+ }
574
+ const schemas = asObject(doc.components)?.schemas;
575
+ for (const [name, schema] of Object.entries(schemas ?? {})) {
576
+ const obj = asObject(schema);
577
+ if (!obj) continue;
578
+ const props = asObject(obj.properties);
579
+ for (const req of stringArray(obj.required)) {
580
+ if (props && !(req in props)) {
581
+ issues.push({
582
+ level: "warning",
583
+ file,
584
+ message: `components.schemas.${name}: required entry "${req}" is not a declared property`
585
+ });
586
+ }
587
+ }
588
+ }
589
+ return { issues, ok: !issues.some((i) => i.level === "error") };
590
+ }
591
+ function checkParameterShape(param, where, issues, file) {
592
+ const name = typeof param.name === "string" ? param.name : "<unnamed>";
593
+ if ("schema" in param && "content" in param) {
594
+ issues.push({
595
+ level: "error",
596
+ file,
597
+ message: `${where}: parameter "${name}" has both 'schema' and 'content' (mutually exclusive)`
598
+ });
599
+ }
600
+ }
601
+ function stringArray(v) {
602
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
603
+ }
604
+ function isHttpMethod(key) {
605
+ return ["get", "post", "put", "patch", "delete", "head", "options", "trace"].includes(
606
+ key.toLowerCase()
607
+ );
608
+ }
609
+ function formatIssues(issues) {
610
+ const errors = issues.filter((i) => i.level === "error");
611
+ const warnings = issues.filter((i) => i.level === "warning");
612
+ const lines = [];
613
+ if (errors.length > 0) {
614
+ lines.push("Errors:");
615
+ for (const i of errors) lines.push(` ${i.file}: ${i.message}`);
616
+ }
617
+ if (warnings.length > 0) {
618
+ lines.push("Warnings:");
619
+ for (const i of warnings) lines.push(` ${i.file}: ${i.message}`);
620
+ }
621
+ return lines;
622
+ }
623
+
624
+ // src/core/versioning/semver-advisor.ts
625
+ function suggestVersion(changes) {
626
+ const errors = changes.filter((c) => c.severity === "error");
627
+ if (errors.length > 0) {
628
+ const counts = /* @__PURE__ */ new Map();
629
+ for (const c of errors) counts.set(c.ruleId, (counts.get(c.ruleId) ?? 0) + 1);
630
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3).map(([id, n]) => `${id}(${n})`);
631
+ return {
632
+ bump: "major",
633
+ reason: `${errors.length} breaking change(s); driven by ${top.join(", ")}`,
634
+ triggers: [...new Set(errors.map((c) => c.ruleId))].sort()
635
+ };
636
+ }
637
+ const minorish = changes.filter(
638
+ (c) => c.severity === "warning" || c.kind === "addition" || c.kind === "removal" || c.kind === "modification"
639
+ );
640
+ if (minorish.length > 0) {
641
+ const triggers = [...new Set(minorish.map((c) => c.ruleId))].sort();
642
+ return {
643
+ bump: "minor",
644
+ reason: `${minorish.length} non-breaking functional change(s) (additions/modifications/warnings)`,
645
+ triggers
646
+ };
647
+ }
648
+ const patchish = changes.filter((c) => c.kind === "relaxation" || c.kind === "documentation");
649
+ if (patchish.length > 0) {
650
+ const triggers = [...new Set(patchish.map((c) => c.ruleId))].sort();
651
+ return {
652
+ bump: "patch",
653
+ reason: `${patchish.length} documentation/relaxation change(s)`,
654
+ triggers
655
+ };
656
+ }
657
+ return { bump: "none", reason: "no changes", triggers: [] };
658
+ }
659
+
660
+ // src/core/models/report.ts
661
+ var TOOL_VERSION = "0.1.0";
662
+
663
+ // src/core/rules/rule-registry.ts
664
+ function fmt(v) {
665
+ if (v === void 0) return "absent";
666
+ if (typeof v === "string") return `\`${v}\``;
667
+ if (v === null) return "null";
668
+ return `\`${JSON.stringify(v)}\``;
669
+ }
670
+ function sideLabel(loc) {
671
+ switch (loc.context) {
672
+ case "request-body":
673
+ return "Request property";
674
+ case "response-body":
675
+ return "Response property";
676
+ case "request-header":
677
+ return "Request header";
678
+ case "response-header":
679
+ return "Response header";
680
+ case "parameter":
681
+ return "Parameter";
682
+ case "component":
683
+ return "Component property";
684
+ case "callback":
685
+ return "Callback payload property";
686
+ case "webhook":
687
+ return "Webhook payload property";
688
+ default:
689
+ return "Property";
690
+ }
691
+ }
692
+ function whereLabel(loc) {
693
+ switch (loc.context) {
694
+ case "component":
695
+ return loc.componentName ? ` in component ${loc.componentName}` : " in component";
696
+ case "parameter":
697
+ return " parameter";
698
+ default:
699
+ return "";
700
+ }
701
+ }
702
+ function nameOf(ctx) {
703
+ const source = ctx.location.pointerNew || ctx.location.pointerOld;
704
+ const tokens = source.split("/").filter(Boolean);
705
+ const last = tokens[tokens.length - 1];
706
+ return (last ?? "value").replace(/~1/g, "/").replace(/~0/g, "~");
707
+ }
708
+ var RULES = [
709
+ // ---------------- §10.1 API / document level ----------------
710
+ {
711
+ id: "openapi-version-changed",
712
+ defaultSeverity: "info",
713
+ defaultKind: "documentation",
714
+ contexts: ["api"],
715
+ trigger: "The `openapi` version field changed (after normalization).",
716
+ message: (c) => `OpenAPI version changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
717
+ suggestion: () => "verify tooling supports the new OpenAPI version"
718
+ },
719
+ {
720
+ id: "title-changed",
721
+ defaultSeverity: "info",
722
+ defaultKind: "documentation",
723
+ contexts: ["api"],
724
+ trigger: "The document title changed.",
725
+ message: (c) => `API title changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
726
+ suggestion: () => "update generated clients/docs that embed the title"
727
+ },
728
+ {
729
+ id: "description-changed",
730
+ defaultSeverity: "info",
731
+ defaultKind: "documentation",
732
+ contexts: ["api"],
733
+ trigger: "The document description changed.",
734
+ message: (c) => `API description changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
735
+ suggestion: () => "no functional impact; update dependent docs if desired"
736
+ },
737
+ {
738
+ id: "server-url-added",
739
+ defaultSeverity: "info",
740
+ defaultKind: "addition",
741
+ contexts: ["api"],
742
+ trigger: "A server URL was added (treated as additive in v1).",
743
+ message: (c) => `Server URL added: ${fmt(c.newValue)}`,
744
+ suggestion: () => "clients can now target the new server; no action required"
745
+ },
746
+ {
747
+ id: "server-url-removed",
748
+ defaultSeverity: "error",
749
+ defaultKind: "removal",
750
+ contexts: ["api"],
751
+ trigger: "A server URL was removed (clients may still target it).",
752
+ message: (c) => `Server URL removed: ${fmt(c.oldValue)}`,
753
+ suggestion: () => "publish a migration note; clients pinned to this URL will fail"
754
+ },
755
+ {
756
+ id: "server-metadata-changed",
757
+ defaultSeverity: "info",
758
+ defaultKind: "documentation",
759
+ contexts: ["api"],
760
+ trigger: "Server description or variables changed.",
761
+ message: (c) => `Server metadata changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
762
+ suggestion: () => "review server variable defaults for environment impact"
763
+ },
764
+ {
765
+ id: "external-docs-changed",
766
+ defaultSeverity: "info",
767
+ defaultKind: "documentation",
768
+ contexts: ["api"],
769
+ trigger: "externalDocs changed.",
770
+ message: (c) => `External docs changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
771
+ suggestion: () => "update bookmarks/links to external documentation"
772
+ },
773
+ {
774
+ id: "license-changed",
775
+ defaultSeverity: "info",
776
+ defaultKind: "documentation",
777
+ contexts: ["api"],
778
+ trigger: "License metadata changed.",
779
+ message: (c) => `License changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
780
+ suggestion: () => "review licensing implications before publishing"
781
+ },
782
+ {
783
+ id: "contact-changed",
784
+ defaultSeverity: "info",
785
+ defaultKind: "documentation",
786
+ contexts: ["api"],
787
+ trigger: "Contact metadata changed.",
788
+ message: (c) => `Contact changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
789
+ suggestion: () => "update support contacts in client tooling"
790
+ },
791
+ // ---------------- §10.2 Operation level ----------------
792
+ {
793
+ id: "operation-added",
794
+ defaultSeverity: "info",
795
+ defaultKind: "addition",
796
+ contexts: ["operation"],
797
+ trigger: "A new path+method was added.",
798
+ message: (c) => `Operation added: ${String(c.location.method ?? "").toUpperCase()} ${c.location.path}`,
799
+ suggestion: () => "additive; regenerate clients to expose the new operation"
800
+ },
801
+ {
802
+ id: "removed-method",
803
+ defaultSeverity: "error",
804
+ defaultKind: "removal",
805
+ contexts: ["operation"],
806
+ trigger: "The path still exists but a method disappeared.",
807
+ message: (c) => `Operation removed: ${String(c.location.method ?? "").toUpperCase()} ${c.location.path}`,
808
+ suggestion: () => "clients calling this operation will get 404/405; version the API instead"
809
+ },
810
+ {
811
+ id: "removed-path",
812
+ defaultSeverity: "error",
813
+ defaultKind: "removal",
814
+ contexts: ["operation"],
815
+ trigger: "An entire path disappeared (reported once instead of per-method rows).",
816
+ message: (c) => `Path removed: ${c.location.path} (all operations)`,
817
+ suggestion: () => "clients using any operation under this path will break; version the API"
818
+ },
819
+ {
820
+ id: "operation-id-changed",
821
+ defaultSeverity: "warning",
822
+ defaultKind: "modification",
823
+ contexts: ["operation"],
824
+ trigger: "operationId changed (SDK/client code impact).",
825
+ message: (c) => `operationId changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
826
+ suggestion: () => "regenerate SDKs; code referencing the old method name must be updated"
827
+ },
828
+ {
829
+ id: "operation-deprecated-added",
830
+ defaultSeverity: "warning",
831
+ defaultKind: "modification",
832
+ contexts: ["operation"],
833
+ trigger: "`deprecated: true` was added to an operation.",
834
+ message: () => "Operation is now deprecated",
835
+ suggestion: () => "plan migration before the deprecation window ends"
836
+ },
837
+ {
838
+ id: "operation-deprecated-removed",
839
+ defaultSeverity: "info",
840
+ defaultKind: "documentation",
841
+ contexts: ["operation"],
842
+ trigger: "Deprecation was removed from an operation.",
843
+ message: () => "Operation deprecation removed",
844
+ suggestion: () => "update client roadmaps that tracked this deprecation"
845
+ },
846
+ {
847
+ id: "operation-tags-changed",
848
+ defaultSeverity: "info",
849
+ defaultKind: "documentation",
850
+ contexts: ["operation"],
851
+ trigger: "The operation tag list changed in either direction.",
852
+ message: (c) => `Operation tags changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
853
+ suggestion: () => "update client grouping/filtering that relies on tags"
854
+ },
855
+ {
856
+ id: "operation-summary-changed",
857
+ defaultSeverity: "info",
858
+ defaultKind: "documentation",
859
+ contexts: ["operation"],
860
+ trigger: "The operation summary changed.",
861
+ message: (c) => `Operation summary changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
862
+ suggestion: () => "documentation-only change"
863
+ },
864
+ {
865
+ id: "operation-description-changed",
866
+ defaultSeverity: "info",
867
+ defaultKind: "documentation",
868
+ contexts: ["operation"],
869
+ trigger: "The operation description changed.",
870
+ message: (c) => `Operation description changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
871
+ suggestion: () => "documentation-only change"
872
+ },
873
+ {
874
+ id: "callback-added",
875
+ defaultSeverity: "info",
876
+ defaultKind: "addition",
877
+ contexts: ["operation"],
878
+ trigger: "A new callback was added (OpenAPI 3.0/3.1).",
879
+ message: (c) => `Callback added: ${fmt(c.newValue)}`,
880
+ suggestion: () => "additive; implement the new callback when ready"
881
+ },
882
+ {
883
+ id: "callback-removed",
884
+ defaultSeverity: "error",
885
+ defaultKind: "removal",
886
+ contexts: ["operation"],
887
+ trigger: "A callback was removed.",
888
+ message: (c) => `Callback removed: ${fmt(c.oldValue)}`,
889
+ suggestion: () => "consumers relying on this callback will stop receiving events"
890
+ },
891
+ {
892
+ id: "webhook-added",
893
+ defaultSeverity: "info",
894
+ defaultKind: "addition",
895
+ contexts: ["webhook"],
896
+ trigger: "A new top-level webhook was added (OpenAPI 3.1).",
897
+ message: (c) => `Webhook added: ${fmt(c.newValue)}`,
898
+ suggestion: () => "additive; subscribe to the new webhook when ready"
899
+ },
900
+ {
901
+ id: "webhook-removed",
902
+ defaultSeverity: "error",
903
+ defaultKind: "removal",
904
+ contexts: ["webhook"],
905
+ trigger: "A top-level webhook was removed (OpenAPI 3.1).",
906
+ message: (c) => `Webhook removed: ${fmt(c.oldValue)}`,
907
+ suggestion: () => "consumers listening for this webhook will break"
908
+ },
909
+ // ---------------- §10.3 Parameters and bodies ----------------
910
+ {
911
+ id: "parameter-added-required",
912
+ defaultSeverity: "error",
913
+ defaultKind: "addition",
914
+ contexts: ["parameter"],
915
+ trigger: "A new required header/query/cookie/path parameter was added.",
916
+ message: (c) => `Required parameter \`${nameOf(c)}\` added`,
917
+ suggestion: (c) => `clients must now send \`${nameOf(c)}\` on every request`
918
+ },
919
+ {
920
+ id: "parameter-added-optional",
921
+ defaultSeverity: "info",
922
+ defaultKind: "addition",
923
+ contexts: ["parameter"],
924
+ trigger: "A new optional parameter was added.",
925
+ message: (c) => `Optional parameter \`${nameOf(c)}\` added`,
926
+ suggestion: () => "additive; clients can adopt the parameter when ready"
927
+ },
928
+ {
929
+ id: "removed-parameter",
930
+ defaultSeverity: "warning",
931
+ defaultKind: "removal",
932
+ contexts: ["parameter", "request-header"],
933
+ trigger: "A parameter was removed; error when it was required, a header, or a path param \u2014 else warning (\xA710.5).",
934
+ message: (c) => `Parameter \`${nameOf(c)}\` removed`,
935
+ suggestion: (c) => `stop sending \`${nameOf(c)}\`; servers will ignore unknown parameters`
936
+ },
937
+ {
938
+ id: "parameter-made-required",
939
+ defaultSeverity: "error",
940
+ defaultKind: "modification",
941
+ contexts: ["parameter"],
942
+ trigger: "An optional parameter became required.",
943
+ message: (c) => `Parameter \`${nameOf(c)}\` changed from optional to required`,
944
+ suggestion: (c) => `clients must now provide \`${nameOf(c)}\` on every request`
945
+ },
946
+ {
947
+ id: "parameter-type-changed",
948
+ defaultSeverity: "error",
949
+ defaultKind: "modification",
950
+ contexts: ["parameter"],
951
+ trigger: "A parameter schema type changed.",
952
+ message: (c) => `Parameter \`${nameOf(c)}\` type changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
953
+ suggestion: () => "update clients to send the new type; old payloads will be rejected"
954
+ },
955
+ {
956
+ id: "parameter-format-changed",
957
+ defaultSeverity: "warning",
958
+ defaultKind: "modification",
959
+ contexts: ["parameter"],
960
+ trigger: "A parameter format changed.",
961
+ message: (c) => `Parameter \`${nameOf(c)}\` format changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
962
+ suggestion: () => "formats are advisory but commonly validated; verify client payloads"
963
+ },
964
+ {
965
+ id: "parameter-location-changed",
966
+ defaultSeverity: "error",
967
+ defaultKind: "modification",
968
+ contexts: ["parameter"],
969
+ trigger: "A parameter moved between in: query/header/path/cookie (same name).",
970
+ message: (c) => `Parameter \`${nameOf(c)}\` moved from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
971
+ suggestion: () => "clients must send the value in the new location"
972
+ },
973
+ {
974
+ id: "parameter-style-changed",
975
+ defaultSeverity: "warning",
976
+ defaultKind: "modification",
977
+ contexts: ["parameter"],
978
+ trigger: "A parameter style/explode changed.",
979
+ message: (c) => `Parameter \`${nameOf(c)}\` style changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
980
+ suggestion: () => "update serialization of array/object parameters in clients"
981
+ },
982
+ {
983
+ id: "parameter-default-changed",
984
+ defaultSeverity: "warning",
985
+ defaultKind: "modification",
986
+ contexts: ["parameter"],
987
+ trigger: "A parameter default changed (server-side behavior may differ).",
988
+ message: (c) => `Parameter \`${nameOf(c)}\` default changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
989
+ suggestion: () => "verify clients that relied on the previous default behavior"
990
+ },
991
+ {
992
+ id: "parameter-metadata-changed",
993
+ defaultSeverity: "info",
994
+ defaultKind: "documentation",
995
+ contexts: ["parameter"],
996
+ trigger: "description/example/deprecated changed on a parameter.",
997
+ message: (c) => `Parameter \`${nameOf(c)}\` metadata changed`,
998
+ suggestion: () => "documentation-only change"
999
+ },
1000
+ {
1001
+ id: "request-body-removed",
1002
+ defaultSeverity: "warning",
1003
+ defaultKind: "removal",
1004
+ contexts: ["request-body"],
1005
+ trigger: "The request body existed and is gone; error if the old body was required (\xA710.5).",
1006
+ message: () => "Request body removed",
1007
+ suggestion: () => "clients must stop sending a body; verify server tolerance"
1008
+ },
1009
+ {
1010
+ id: "request-body-added-required",
1011
+ defaultSeverity: "error",
1012
+ defaultKind: "addition",
1013
+ contexts: ["request-body"],
1014
+ trigger: "No body before; a body with `required: true` now.",
1015
+ message: () => "Required request body added",
1016
+ suggestion: () => "clients must now send a body on every request"
1017
+ },
1018
+ {
1019
+ id: "request-body-added-optional",
1020
+ defaultSeverity: "info",
1021
+ defaultKind: "addition",
1022
+ contexts: ["request-body"],
1023
+ trigger: "An optional request body was added.",
1024
+ message: () => "Optional request body added",
1025
+ suggestion: () => "additive; clients can send the body when ready"
1026
+ },
1027
+ {
1028
+ id: "request-body-required-changed",
1029
+ defaultSeverity: "error",
1030
+ defaultKind: "modification",
1031
+ contexts: ["request-body"],
1032
+ trigger: "An optional body became `required: true`.",
1033
+ message: () => "Request body changed from optional to required",
1034
+ suggestion: () => "clients must now send the body on every request"
1035
+ },
1036
+ {
1037
+ id: "content-type-removed",
1038
+ defaultSeverity: "error",
1039
+ defaultKind: "removal",
1040
+ contexts: ["request-body", "response-body"],
1041
+ trigger: "A previously supported media type was removed (request or response).",
1042
+ message: (c) => `Media type ${fmt(c.oldValue)} removed from ${c.location.context === "request-body" ? "request" : "response"}`,
1043
+ suggestion: () => "clients sending/accepting this media type will fail content negotiation"
1044
+ },
1045
+ {
1046
+ id: "content-type-added",
1047
+ defaultSeverity: "info",
1048
+ defaultKind: "addition",
1049
+ contexts: ["request-body", "response-body"],
1050
+ trigger: "A media type was added.",
1051
+ message: (c) => `Media type ${fmt(c.newValue)} added to ${c.location.context === "request-body" ? "request" : "response"}`,
1052
+ suggestion: () => "additive; adopt when ready"
1053
+ },
1054
+ {
1055
+ id: "encoding-changed",
1056
+ defaultSeverity: "warning",
1057
+ defaultKind: "modification",
1058
+ contexts: ["request-body"],
1059
+ trigger: "multipart/form-data encoding properties changed.",
1060
+ message: (c) => `Multipart encoding changed for ${fmt(c.oldValue)} \u2192 ${fmt(c.newValue)}`,
1061
+ suggestion: () => "verify multipart part serialization in clients"
1062
+ },
1063
+ {
1064
+ id: "response-added",
1065
+ defaultSeverity: "info",
1066
+ defaultKind: "addition",
1067
+ contexts: ["response-body"],
1068
+ trigger: "A new status code (or `default` when none existed) was added.",
1069
+ message: (c) => `Response ${fmt(c.newValue)} added`,
1070
+ suggestion: () => "additive; handle the new status in clients when ready"
1071
+ },
1072
+ {
1073
+ id: "response-removed",
1074
+ defaultSeverity: "error",
1075
+ defaultKind: "removal",
1076
+ contexts: ["response-body"],
1077
+ trigger: "A declared status code was removed.",
1078
+ message: (c) => `Response ${fmt(c.oldValue)} removed`,
1079
+ suggestion: () => "clients expecting this status will treat it as unexpected; update error handling"
1080
+ },
1081
+ {
1082
+ id: "default-response-removed",
1083
+ defaultSeverity: "error",
1084
+ defaultKind: "removal",
1085
+ contexts: ["response-body"],
1086
+ trigger: "The `default` response was removed while it existed.",
1087
+ message: () => "Default response removed",
1088
+ suggestion: () => "clients relying on the default error contract lose their fallback schema"
1089
+ },
1090
+ {
1091
+ id: "response-header-removed",
1092
+ defaultSeverity: "error",
1093
+ defaultKind: "removal",
1094
+ contexts: ["response-header"],
1095
+ trigger: "A response header was removed.",
1096
+ message: (c) => `Response header \`${nameOf(c)}\` removed`,
1097
+ suggestion: () => "clients reading this header will see it missing; update parsing"
1098
+ },
1099
+ {
1100
+ id: "response-header-added",
1101
+ defaultSeverity: "info",
1102
+ defaultKind: "addition",
1103
+ contexts: ["response-header"],
1104
+ trigger: "A response header was added.",
1105
+ message: (c) => `Response header \`${nameOf(c)}\` added`,
1106
+ suggestion: () => "additive; parse the header when ready"
1107
+ },
1108
+ {
1109
+ id: "request-header-removed",
1110
+ defaultSeverity: "error",
1111
+ defaultKind: "removal",
1112
+ contexts: ["request-header"],
1113
+ trigger: "A property inside a header parameter schema disappeared (\xA710.3).",
1114
+ message: (c) => `Request header schema property \`${nameOf(c)}\` removed`,
1115
+ suggestion: () => "stop sending this header property; verify server acceptance"
1116
+ },
1117
+ // ---------------- §10.4 Schema / property level ----------------
1118
+ {
1119
+ id: "property-added",
1120
+ defaultSeverity: "info",
1121
+ defaultKind: "addition",
1122
+ contexts: [
1123
+ "request-body",
1124
+ "response-body",
1125
+ "parameter",
1126
+ "component",
1127
+ "callback",
1128
+ "webhook",
1129
+ "request-header",
1130
+ "response-header"
1131
+ ],
1132
+ trigger: "A new property that is NOT required was added.",
1133
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` added${whereLabel(c.location)}`,
1134
+ suggestion: () => "additive; clients can consume the new field when ready"
1135
+ },
1136
+ {
1137
+ id: "required-property-added",
1138
+ defaultSeverity: "error",
1139
+ defaultKind: "modification",
1140
+ contexts: [
1141
+ "request-body",
1142
+ "response-body",
1143
+ "parameter",
1144
+ "component",
1145
+ "callback",
1146
+ "webhook",
1147
+ "request-header",
1148
+ "response-header"
1149
+ ],
1150
+ trigger: "A property is newly present and required, or an optional property became required (\xA710.5).",
1151
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` changed from optional to required`,
1152
+ suggestion: (c) => `clients must now provide \`${nameOf(c)}\``
1153
+ },
1154
+ {
1155
+ id: "property-removed",
1156
+ defaultSeverity: "warning",
1157
+ defaultKind: "removal",
1158
+ contexts: [
1159
+ "request-body",
1160
+ "response-body",
1161
+ "parameter",
1162
+ "component",
1163
+ "callback",
1164
+ "webhook",
1165
+ "request-header",
1166
+ "response-header"
1167
+ ],
1168
+ trigger: "A property was removed; severity resolves per the \xA710.5 context table.",
1169
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` was removed${whereLabel(c.location)}`,
1170
+ suggestion: (c) => `stop relying on \`${nameOf(c)}\`${c.location.context === "response-body" ? "; it will no longer be returned" : ""}`
1171
+ },
1172
+ {
1173
+ id: "property-type-changed",
1174
+ defaultSeverity: "error",
1175
+ defaultKind: "modification",
1176
+ contexts: [
1177
+ "request-body",
1178
+ "response-body",
1179
+ "parameter",
1180
+ "component",
1181
+ "callback",
1182
+ "webhook",
1183
+ "request-header",
1184
+ "response-header"
1185
+ ],
1186
+ trigger: "A schema type changed (including array item type via pointer to items).",
1187
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` type changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
1188
+ suggestion: () => "update serializers/deserializers for the new type"
1189
+ },
1190
+ {
1191
+ id: "property-format-changed",
1192
+ defaultSeverity: "warning",
1193
+ defaultKind: "modification",
1194
+ contexts: [
1195
+ "request-body",
1196
+ "response-body",
1197
+ "parameter",
1198
+ "component",
1199
+ "callback",
1200
+ "webhook",
1201
+ "request-header",
1202
+ "response-header"
1203
+ ],
1204
+ trigger: "A schema format changed (advisory; pair with type change for error).",
1205
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` format changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
1206
+ suggestion: () => "verify validation libraries that enforce the format"
1207
+ },
1208
+ {
1209
+ id: "property-default-changed",
1210
+ defaultSeverity: "warning",
1211
+ defaultKind: "modification",
1212
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1213
+ trigger: "A schema default changed.",
1214
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` default changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
1215
+ suggestion: () => "verify clients that relied on the previous default"
1216
+ },
1217
+ {
1218
+ id: "enum-value-removed",
1219
+ defaultSeverity: "error",
1220
+ defaultKind: "removal",
1221
+ contexts: [
1222
+ "request-body",
1223
+ "response-body",
1224
+ "parameter",
1225
+ "component",
1226
+ "callback",
1227
+ "webhook",
1228
+ "request-header",
1229
+ "response-header"
1230
+ ],
1231
+ trigger: "An enum member was removed (anywhere).",
1232
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` enum value ${fmt(c.oldValue)} removed`,
1233
+ suggestion: () => "stop sending/expecting the removed enum value; update switch statements"
1234
+ },
1235
+ {
1236
+ id: "enum-value-added",
1237
+ defaultSeverity: "info",
1238
+ defaultKind: "addition",
1239
+ contexts: [
1240
+ "request-body",
1241
+ "response-body",
1242
+ "parameter",
1243
+ "component",
1244
+ "callback",
1245
+ "webhook",
1246
+ "request-header",
1247
+ "response-header"
1248
+ ],
1249
+ trigger: "An enum member was added.",
1250
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` enum value ${fmt(c.newValue)} added`,
1251
+ suggestion: () => "handle the new enum value in client logic"
1252
+ },
1253
+ {
1254
+ id: "const-added",
1255
+ defaultSeverity: "error",
1256
+ defaultKind: "modification",
1257
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1258
+ trigger: "`const` was introduced where none existed.",
1259
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` must now be exactly ${fmt(c.newValue)}`,
1260
+ suggestion: () => "clients sending other values will be rejected"
1261
+ },
1262
+ {
1263
+ id: "const-changed",
1264
+ defaultSeverity: "error",
1265
+ defaultKind: "modification",
1266
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1267
+ trigger: "A `const` value changed.",
1268
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` const changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
1269
+ suggestion: () => "update clients to the new constant value"
1270
+ },
1271
+ {
1272
+ id: "const-removed",
1273
+ defaultSeverity: "info",
1274
+ defaultKind: "relaxation",
1275
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1276
+ trigger: "A `const` constraint was removed.",
1277
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` const constraint removed`,
1278
+ suggestion: () => "relaxation; no client action required"
1279
+ },
1280
+ {
1281
+ id: "constraint-added",
1282
+ defaultSeverity: "error",
1283
+ defaultKind: "modification",
1284
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1285
+ trigger: "A restrictive constraint was introduced where none existed (pattern, minLength, minItems, min/max, exclusive*, multipleOf).",
1286
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` now requires ${constraintText(c)}`,
1287
+ suggestion: () => "update clients to satisfy the new constraint"
1288
+ },
1289
+ {
1290
+ id: "constraint-tightened",
1291
+ defaultSeverity: "error",
1292
+ defaultKind: "modification",
1293
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1294
+ trigger: "An existing constraint was made stricter (numeric compare after normalization).",
1295
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` constraint tightened: ${constraintText(c)}`,
1296
+ suggestion: () => "previously-valid payloads may now be rejected; update clients"
1297
+ },
1298
+ {
1299
+ id: "constraint-relaxed",
1300
+ defaultSeverity: "info",
1301
+ defaultKind: "relaxation",
1302
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1303
+ trigger: "A constraint was loosened.",
1304
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` constraint relaxed: ${constraintText(c)}`,
1305
+ suggestion: () => "relaxation; no client action required"
1306
+ },
1307
+ {
1308
+ id: "constraint-removed",
1309
+ defaultSeverity: "info",
1310
+ defaultKind: "relaxation",
1311
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1312
+ trigger: "A constraint was deleted.",
1313
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` constraint ${constraintText(c)} removed`,
1314
+ suggestion: () => "relaxation; no client action required"
1315
+ },
1316
+ {
1317
+ id: "required-removed",
1318
+ defaultSeverity: "info",
1319
+ defaultKind: "relaxation",
1320
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1321
+ trigger: "A property was removed from the `required` array (either context).",
1322
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` is no longer required`,
1323
+ suggestion: () => "relaxation; clients may omit the field"
1324
+ },
1325
+ {
1326
+ id: "map-closed",
1327
+ defaultSeverity: "error",
1328
+ defaultKind: "modification",
1329
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1330
+ trigger: "`additionalProperties` became false or a schema where previously true/absent.",
1331
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` no longer allows unknown properties`,
1332
+ suggestion: () => "clients sending extra fields will be rejected"
1333
+ },
1334
+ {
1335
+ id: "map-opened",
1336
+ defaultSeverity: "info",
1337
+ defaultKind: "relaxation",
1338
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1339
+ trigger: "`additionalProperties` became true/absent where it was false or a schema.",
1340
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` now allows unknown properties`,
1341
+ suggestion: () => "relaxation; no client action required"
1342
+ },
1343
+ {
1344
+ id: "allOf-member-added",
1345
+ defaultSeverity: "warning",
1346
+ defaultKind: "modification",
1347
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1348
+ trigger: "A new allOf member was added (may restrict or conflict).",
1349
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` gained an allOf constraint`,
1350
+ suggestion: () => "verify payloads still satisfy the combined schema"
1351
+ },
1352
+ {
1353
+ id: "allOf-member-removed",
1354
+ defaultSeverity: "info",
1355
+ defaultKind: "relaxation",
1356
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1357
+ trigger: "An allOf member was removed.",
1358
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` lost an allOf constraint`,
1359
+ suggestion: () => "relaxation; no client action required"
1360
+ },
1361
+ {
1362
+ id: "anyOf-oneOf-member-added",
1363
+ defaultSeverity: "info",
1364
+ defaultKind: "addition",
1365
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1366
+ trigger: "A new anyOf/oneOf alternative branch was added.",
1367
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` gained an alternative schema`,
1368
+ suggestion: () => "additive; clients can use the new branch when ready"
1369
+ },
1370
+ {
1371
+ id: "anyOf-oneOf-member-removed",
1372
+ defaultSeverity: "error",
1373
+ defaultKind: "removal",
1374
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1375
+ trigger: "An anyOf/oneOf alternative was removed (payloads matching only that branch break).",
1376
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` lost an alternative schema`,
1377
+ suggestion: () => "payloads matching only the removed branch will fail validation"
1378
+ },
1379
+ {
1380
+ id: "discriminator-added",
1381
+ defaultSeverity: "warning",
1382
+ defaultKind: "modification",
1383
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1384
+ trigger: "A discriminator was introduced.",
1385
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` now requires a discriminator`,
1386
+ suggestion: () => "clients must include the discriminator field in polymorphic payloads"
1387
+ },
1388
+ {
1389
+ id: "discriminator-changed",
1390
+ defaultSeverity: "error",
1391
+ defaultKind: "modification",
1392
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1393
+ trigger: "The discriminator property or mapping changed/removed.",
1394
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` discriminator changed`,
1395
+ suggestion: () => "update polymorphic deserialization to the new discriminator"
1396
+ },
1397
+ {
1398
+ id: "schema-description-changed",
1399
+ defaultSeverity: "info",
1400
+ defaultKind: "documentation",
1401
+ contexts: ["request-body", "response-body", "parameter", "component", "callback", "webhook"],
1402
+ trigger: "A schema-level description/title/example changed.",
1403
+ message: (c) => `${sideLabel(c.location)} \`${nameOf(c)}\` documentation changed`,
1404
+ suggestion: () => "documentation-only change"
1405
+ },
1406
+ // ---------------- §10.6 Security ----------------
1407
+ {
1408
+ id: "security-scheme-added",
1409
+ defaultSeverity: "info",
1410
+ defaultKind: "addition",
1411
+ contexts: ["security"],
1412
+ trigger: "A new scheme was added to components.securitySchemes.",
1413
+ message: (c) => `Security scheme ${fmt(c.newValue)} added`,
1414
+ suggestion: () => "additive; adopt the new scheme when ready"
1415
+ },
1416
+ {
1417
+ id: "security-scheme-removed",
1418
+ defaultSeverity: "error",
1419
+ defaultKind: "removal",
1420
+ contexts: ["security"],
1421
+ trigger: "A scheme was removed and is still referenced by \u22651 requirement; else info.",
1422
+ message: (c) => `Security scheme ${fmt(c.oldValue)} removed`,
1423
+ suggestion: () => "clients authenticating with this scheme will fail; migrate first"
1424
+ },
1425
+ {
1426
+ id: "security-type-changed",
1427
+ defaultSeverity: "error",
1428
+ defaultKind: "modification",
1429
+ contexts: ["security"],
1430
+ trigger: "A scheme type changed (http/apiKey/oauth2/openIdConnect/mutualTLS).",
1431
+ message: (c) => `Security scheme \`${nameOf(c)}\` type changed from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
1432
+ suggestion: () => "client credentials/flows for the old type will fail"
1433
+ },
1434
+ {
1435
+ id: "api-key-location-changed",
1436
+ defaultSeverity: "error",
1437
+ defaultKind: "modification",
1438
+ contexts: ["security"],
1439
+ trigger: "An apiKey scheme in: header/query/cookie changed.",
1440
+ message: (c) => `API key \`${nameOf(c)}\` moved from ${fmt(c.oldValue)} to ${fmt(c.newValue)}`,
1441
+ suggestion: () => "clients must send the key in the new location"
1442
+ },
1443
+ {
1444
+ id: "oauth-scope-removed",
1445
+ defaultSeverity: "error",
1446
+ defaultKind: "removal",
1447
+ contexts: ["security"],
1448
+ trigger: "A scope was removed from a scheme that \u22651 operation requirement references; else info.",
1449
+ message: (c) => `OAuth scope \`${nameOf(c)}\` removed`,
1450
+ suggestion: () => "tokens granted only the removed scope will fail authorization"
1451
+ },
1452
+ {
1453
+ id: "oauth-scope-added",
1454
+ defaultSeverity: "info",
1455
+ defaultKind: "addition",
1456
+ contexts: ["security"],
1457
+ trigger: "A scope was added to a scheme.",
1458
+ message: (c) => `OAuth scope \`${nameOf(c)}\` added`,
1459
+ suggestion: () => "additive; request the new scope when needed"
1460
+ },
1461
+ {
1462
+ id: "security-requirement-added",
1463
+ defaultSeverity: "error",
1464
+ defaultKind: "modification",
1465
+ contexts: ["security"],
1466
+ trigger: "An operation with no/optional security now requires security.",
1467
+ message: (c) => `Operation ${c.location.path} now requires authentication`,
1468
+ suggestion: () => "previously-anonymous clients must obtain credentials"
1469
+ },
1470
+ {
1471
+ id: "security-requirement-changed",
1472
+ defaultSeverity: "error",
1473
+ defaultKind: "modification",
1474
+ contexts: ["security"],
1475
+ trigger: "The scheme set changed with no overlap (old credentials fail).",
1476
+ message: (c) => `Operation ${c.location.path} security requirements changed incompatibly`,
1477
+ suggestion: () => "clients must obtain credentials for the new scheme(s)"
1478
+ },
1479
+ {
1480
+ id: "security-scope-required-added",
1481
+ defaultSeverity: "error",
1482
+ defaultKind: "modification",
1483
+ contexts: ["security"],
1484
+ trigger: "An operation requirement now demands a scope not previously required.",
1485
+ message: (c) => `Operation ${c.location.path} now requires scope \`${String(c.newValue ?? "")}\``,
1486
+ suggestion: () => "clients must request the additional scope from the authorization server"
1487
+ },
1488
+ {
1489
+ id: "security-requirement-removed",
1490
+ defaultSeverity: "warning",
1491
+ defaultKind: "relaxation",
1492
+ contexts: ["security"],
1493
+ trigger: "Operation-level security was removed entirely (exposure risk \u2192 warning).",
1494
+ message: (c) => `Operation ${c.location.path} no longer requires authentication`,
1495
+ suggestion: () => "review exposure: the operation is now reachable anonymously"
1496
+ },
1497
+ {
1498
+ id: "security-scope-required-removed",
1499
+ defaultSeverity: "info",
1500
+ defaultKind: "relaxation",
1501
+ contexts: ["security"],
1502
+ trigger: "A narrower scope set is now accepted.",
1503
+ message: (c) => `Operation ${c.location.path} no longer requires scope \`${String(c.oldValue ?? "")}\``,
1504
+ suggestion: () => "relaxation; existing tokens keep working"
1505
+ }
1506
+ ];
1507
+ function getRule(id) {
1508
+ const rule = RULES.find((r) => r.id === id);
1509
+ if (!rule) throw new Error(`internal error: unknown rule id "${id}"`);
1510
+ return rule;
1511
+ }
1512
+ function constraintText(c) {
1513
+ const name = nameOf(c);
1514
+ const oldV = c.oldValue === void 0 ? void 0 : JSON.stringify(c.oldValue);
1515
+ const newV = c.newValue === void 0 ? void 0 : JSON.stringify(c.newValue);
1516
+ if (oldV !== void 0 && newV !== void 0) return `${name}: ${oldV} \u2192 ${newV}`;
1517
+ if (newV !== void 0) return `${name}: ${newV}`;
1518
+ return name;
1519
+ }
1520
+
1521
+ // src/core/engine/classify.ts
1522
+ function sideClassOf(context) {
1523
+ switch (context) {
1524
+ case "request-body":
1525
+ case "request-header":
1526
+ case "parameter":
1527
+ return "send";
1528
+ case "response-body":
1529
+ case "response-header":
1530
+ case "callback":
1531
+ case "webhook":
1532
+ return "parse";
1533
+ case "component":
1534
+ return "parse";
1535
+ default:
1536
+ return "parse";
1537
+ }
1538
+ }
1539
+ function parseRuleKey(key) {
1540
+ const at = key.indexOf("@");
1541
+ if (at === -1) return { ruleId: key };
1542
+ return { ruleId: key.slice(0, at), context: key.slice(at + 1) };
1543
+ }
1544
+ function resolveContextSeverity(ruleId, context, opts) {
1545
+ const side = opts.sideClass ?? sideClassOf(context);
1546
+ if (ruleId === "property-removed") {
1547
+ if (side === "parse") return "error";
1548
+ if (context === "request-header") return "error";
1549
+ if (context === "parameter") return "error";
1550
+ return opts.wasRequired ? "error" : "warning";
1551
+ }
1552
+ if (ruleId === "required-property-added") {
1553
+ if (side === "parse") return "info";
1554
+ return "error";
1555
+ }
1556
+ if (ruleId === "request-body-removed") {
1557
+ return opts.wasRequired ? "error" : "warning";
1558
+ }
1559
+ if (ruleId === "removed-parameter") {
1560
+ return opts.wasRequired ? "error" : "warning";
1561
+ }
1562
+ return getRule(ruleId).defaultSeverity;
1563
+ }
1564
+ function classify(ruleId, context, options, doctrine) {
1565
+ const rule = getRule(ruleId);
1566
+ let override;
1567
+ for (const [key, value] of Object.entries(options.rules)) {
1568
+ const parsed = parseRuleKey(key);
1569
+ if (parsed.ruleId !== ruleId) continue;
1570
+ if (parsed.context !== void 0 && parsed.context === context) override = value;
1571
+ else if (parsed.context === void 0 && override === void 0) override = value;
1572
+ }
1573
+ if (override === "off") return null;
1574
+ const DOCTRINE_RULES = /* @__PURE__ */ new Set([
1575
+ "property-removed",
1576
+ "required-property-added",
1577
+ "request-body-removed",
1578
+ "removed-parameter"
1579
+ ]);
1580
+ const severity = override ?? (DOCTRINE_RULES.has(ruleId) ? resolveContextSeverity(ruleId, context, doctrine ?? {}) : rule.defaultSeverity);
1581
+ return { severity, kind: rule.defaultKind };
1582
+ }
1583
+
1584
+ // src/utils/sort.ts
1585
+ var SEVERITY_RANK = { error: 0, warning: 1, info: 2 };
1586
+ var METHOD_ORDER = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"];
1587
+ var METHOD_RANK = {};
1588
+ for (const [i, m] of METHOD_ORDER.entries()) METHOD_RANK[m] = i;
1589
+ function methodRank(method) {
1590
+ if (!method) return METHOD_ORDER.length;
1591
+ const r = METHOD_RANK[method.toUpperCase()];
1592
+ return r ?? METHOD_ORDER.length + 1;
1593
+ }
1594
+ function compareChanges(a, b) {
1595
+ const sev = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
1596
+ if (sev !== 0) return sev;
1597
+ const path3 = (a.location.path ?? "").localeCompare(b.location.path ?? "", "en", {
1598
+ sensitivity: "variant"
1599
+ });
1600
+ if (path3 !== 0) return path3;
1601
+ const meth = methodRank(a.location.method) - methodRank(b.location.method);
1602
+ if (meth !== 0) return meth;
1603
+ const po = a.location.pointerOld.localeCompare(b.location.pointerOld, "en", {
1604
+ sensitivity: "variant"
1605
+ });
1606
+ if (po !== 0) return po;
1607
+ const pn = a.location.pointerNew.localeCompare(b.location.pointerNew, "en", {
1608
+ sensitivity: "variant"
1609
+ });
1610
+ if (pn !== 0) return pn;
1611
+ const rule = a.ruleId.localeCompare(b.ruleId, "en", { sensitivity: "variant" });
1612
+ if (rule !== 0) return rule;
1613
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
1614
+ }
1615
+ function sortChanges(changes) {
1616
+ changes.sort(compareChanges);
1617
+ return changes;
1618
+ }
1619
+ function dedupeChanges(changes) {
1620
+ const seen = /* @__PURE__ */ new Set();
1621
+ const out = [];
1622
+ for (const c of changes) {
1623
+ if (seen.has(c.id)) continue;
1624
+ seen.add(c.id);
1625
+ out.push(c);
1626
+ }
1627
+ return out;
1628
+ }
1629
+ function escapePointerToken(token) {
1630
+ return token.replace(/~/g, "~0").replace(/\//g, "~1");
1631
+ }
1632
+ function pointerJoin(...tokens) {
1633
+ return `#/${tokens.map((t) => escapePointerToken(String(t))).join("/")}`;
1634
+ }
1635
+
1636
+ // src/core/engine/index-build.ts
1637
+ function asObject2(v) {
1638
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : void 0;
1639
+ }
1640
+ function buildSpecIndex(document) {
1641
+ const doc = asObject2(document) ?? {};
1642
+ const pathsObj = asObject2(doc.paths) ?? {};
1643
+ const paths = Object.keys(pathsObj).sort();
1644
+ const pathItems = /* @__PURE__ */ new Map();
1645
+ const operations = [];
1646
+ for (const p of paths) {
1647
+ const item = asObject2(pathsObj[p]);
1648
+ if (!item) continue;
1649
+ pathItems.set(p, item);
1650
+ const methods = Object.keys(item).filter((k) => isHttpMethod(k)).sort((a, b) => (METHOD_RANK[a.toUpperCase()] ?? 99) - (METHOD_RANK[b.toUpperCase()] ?? 99));
1651
+ for (const m of methods) {
1652
+ const op = asObject2(item[m]);
1653
+ if (!op) continue;
1654
+ operations.push({ path: p, method: m.toUpperCase(), operation: op, pathItem: item });
1655
+ }
1656
+ }
1657
+ const components = asObject2(doc.components) ?? {};
1658
+ const schemasObj = asObject2(components.schemas) ?? {};
1659
+ const schemas = /* @__PURE__ */ new Map();
1660
+ for (const [name, schema] of Object.entries(schemasObj)) {
1661
+ const obj = asObject2(schema);
1662
+ if (obj) schemas.set(name, obj);
1663
+ }
1664
+ const webhooksObj = asObject2(doc.webhooks) ?? {};
1665
+ const webhooks = /* @__PURE__ */ new Map();
1666
+ for (const [name, wh] of Object.entries(webhooksObj)) {
1667
+ const obj = asObject2(wh);
1668
+ if (obj) webhooks.set(name, obj);
1669
+ }
1670
+ return { paths, pathItems, operations, schemas, webhooks };
1671
+ }
1672
+ function mergedParameters(pathItem, operation) {
1673
+ const out = [];
1674
+ const seen = /* @__PURE__ */ new Set();
1675
+ const opParams = paramList(operation.parameters);
1676
+ for (const p of opParams) {
1677
+ const key = paramKey(p);
1678
+ seen.add(key);
1679
+ out.push(p);
1680
+ }
1681
+ for (const p of paramList(pathItem.parameters)) {
1682
+ const key = paramKey(p);
1683
+ if (!seen.has(key)) out.push(p);
1684
+ }
1685
+ return out;
1686
+ }
1687
+ function paramList(v) {
1688
+ if (!Array.isArray(v)) return [];
1689
+ return v.filter((x) => x !== null && typeof x === "object" && !Array.isArray(x));
1690
+ }
1691
+ function paramKey(p) {
1692
+ const name = typeof p.name === "string" ? p.name : "";
1693
+ const loc = typeof p.in === "string" ? p.in : "";
1694
+ return `${name}::${loc}`;
1695
+ }
1696
+
1697
+ // src/core/engine/comparator.ts
1698
+ function deepEqual(a, b) {
1699
+ if (a === b) return true;
1700
+ if (typeof a !== typeof b) return false;
1701
+ if (a === null || b === null || typeof a !== "object") return false;
1702
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
1703
+ if (Array.isArray(a) && Array.isArray(b)) {
1704
+ if (a.length !== b.length) return false;
1705
+ return a.every((v, i) => deepEqual(v, b[i]));
1706
+ }
1707
+ const ao = a;
1708
+ const bo = b;
1709
+ const ak = Object.keys(ao).sort();
1710
+ const bk = Object.keys(bo).sort();
1711
+ if (ak.length !== bk.length) return false;
1712
+ if (!ak.every((k, i) => k === bk[i])) return false;
1713
+ return ak.every((k) => deepEqual(ao[k], bo[k]));
1714
+ }
1715
+ function typeLabel(t) {
1716
+ if (typeof t === "string") return t;
1717
+ if (Array.isArray(t)) return t.join("|");
1718
+ return JSON.stringify(t) ?? "unknown";
1719
+ }
1720
+ function isObject(v) {
1721
+ return v !== null && typeof v === "object" && !Array.isArray(v);
1722
+ }
1723
+ function num(v) {
1724
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
1725
+ }
1726
+ function boundDirection(kind, oldV, newV) {
1727
+ if (oldV === void 0 && newV === void 0) return "same";
1728
+ if (oldV === newV) return "same";
1729
+ if (oldV === void 0)
1730
+ return newV === void 0 ? "same" : kind === "lower" ? "tightened" : "tightened";
1731
+ if (newV === void 0) return "relaxed";
1732
+ if (oldV === newV) return "same";
1733
+ if (kind === "lower") return newV > oldV ? "tightened" : "relaxed";
1734
+ return newV < oldV ? "tightened" : "relaxed";
1735
+ }
1736
+ var LOWER_BOUNDS = ["minimum", "minLength", "minItems"];
1737
+
1738
+ // src/core/engine/walker.ts
1739
+ function escape(token) {
1740
+ return token.replaceAll("~", "~0").replaceAll("/", "~1");
1741
+ }
1742
+ function unescape(token) {
1743
+ return token.replaceAll("~1", "/").replaceAll("~0", "~");
1744
+ }
1745
+ var REF = "$ref";
1746
+ var MAX_DEREF_DEPTH = 256;
1747
+ function isObj(v) {
1748
+ return v !== null && typeof v === "object" && !Array.isArray(v);
1749
+ }
1750
+ function structuralDeref(root) {
1751
+ const components = root.components;
1752
+ if (!isObj(components)) return;
1753
+ const schemasMap = components.schemas;
1754
+ if (!isObj(schemasMap)) return;
1755
+ const schemas = schemasMap;
1756
+ const resolved = /* @__PURE__ */ new Map();
1757
+ const inProgress = /* @__PURE__ */ new Set();
1758
+ const resolve = (name, schema, depth) => {
1759
+ const memo = resolved.get(name);
1760
+ if (memo) return memo;
1761
+ if (inProgress.has(name)) return schema;
1762
+ inProgress.add(name);
1763
+ const out = expand(schema, depth);
1764
+ inProgress.delete(name);
1765
+ resolved.set(name, out);
1766
+ return out;
1767
+ };
1768
+ function expand(node, depth) {
1769
+ const out = {};
1770
+ for (const [k, v] of Object.entries(node)) {
1771
+ if (k === REF && typeof v === "string") {
1772
+ const target = depth >= MAX_DEREF_DEPTH ? void 0 : targetOf(v, schemas);
1773
+ if (target) {
1774
+ const expanded = resolve(target.name, target.schema, depth + 1);
1775
+ for (const [ek, ev] of Object.entries(expanded)) out[ek] = ev;
1776
+ continue;
1777
+ }
1778
+ out[k] = v;
1779
+ continue;
1780
+ }
1781
+ out[k] = walkValue(v, depth);
1782
+ }
1783
+ return out;
1784
+ }
1785
+ function walkValue(v, depth) {
1786
+ if (Array.isArray(v)) return v.map((x) => walkValue(x, depth));
1787
+ if (isObj(v)) return expand(v, depth);
1788
+ return v;
1789
+ }
1790
+ for (const [name, schema] of Object.entries(schemas)) {
1791
+ if (isObj(schema)) schemas[name] = resolve(name, schema, 0);
1792
+ }
1793
+ }
1794
+ function targetOf(ref, schemas) {
1795
+ if (!ref.startsWith("#/components/schemas/")) return void 0;
1796
+ const parts = ref.slice("#/components/schemas/".length).split("/").map(unescape);
1797
+ let cur = schemas;
1798
+ for (const part of parts) {
1799
+ if (!isObj(cur)) return void 0;
1800
+ cur = cur[part];
1801
+ }
1802
+ return isObj(cur) ? { name: parts.join("/"), schema: cur } : void 0;
1803
+ }
1804
+ function isRefNode(node) {
1805
+ return isObj(node) && typeof node[REF] === "string";
1806
+ }
1807
+ function dereffedPair(oldNode, newNode, oldSchemas, newSchemas, seen) {
1808
+ const oldRef = isRefNode(oldNode) ? refTarget(oldNode.$ref, oldSchemas) : void 0;
1809
+ const newRef = isRefNode(newNode) ? refTarget(newNode.$ref, newSchemas) : void 0;
1810
+ if (!oldRef && !newRef) {
1811
+ return isObj(oldNode) && isObj(newNode) ? { old: oldNode, new: newNode } : void 0;
1812
+ }
1813
+ const oldResolved = oldRef ? oldRef.schema : isObj(oldNode) ? stripRef(oldNode) : void 0;
1814
+ const newResolved = newRef ? newRef.schema : isObj(newNode) ? stripRef(newNode) : void 0;
1815
+ if (!oldResolved || !newResolved) return void 0;
1816
+ const key = `${oldRef?.name ?? "\u2022"}|${newRef?.name ?? "\u2022"}`;
1817
+ if (oldRef && newRef && seen.has(key)) return void 0;
1818
+ if (oldRef && newRef) seen.add(key);
1819
+ return { old: oldResolved, new: newResolved };
1820
+ }
1821
+ function refTarget(ref, schemas) {
1822
+ if (!isObj(schemas) || !ref.startsWith("#/components/schemas/")) return void 0;
1823
+ const parts = ref.slice("#/components/schemas/".length).split("/").map(unescape);
1824
+ let cur = schemas;
1825
+ for (const part of parts) {
1826
+ if (!isObj(cur)) return void 0;
1827
+ cur = cur[part];
1828
+ }
1829
+ return isObj(cur) ? { name: parts.join("/"), schema: cur } : void 0;
1830
+ }
1831
+ function stripRef(node) {
1832
+ if (!(REF in node)) return node;
1833
+ const out = { ...node };
1834
+ delete out[REF];
1835
+ return out;
1836
+ }
1837
+
1838
+ // src/core/engine/schema-differ.ts
1839
+ var MAX_DEPTH = 256;
1840
+ var CONSTRAINT_LABEL = {
1841
+ pattern: "pattern",
1842
+ minLength: "minLength",
1843
+ maxLength: "maxLength",
1844
+ minItems: "minItems",
1845
+ maxItems: "maxItems",
1846
+ multipleOf: "multipleOf",
1847
+ minimum: "minimum",
1848
+ maximum: "maximum",
1849
+ exclusiveMinimum: "exclusiveMinimum",
1850
+ exclusiveMaximum: "exclusiveMaximum"
1851
+ };
1852
+ var NUMERIC_KEYS = /* @__PURE__ */ new Set([
1853
+ "minimum",
1854
+ "maximum",
1855
+ "exclusiveMinimum",
1856
+ "exclusiveMaximum",
1857
+ "multipleOf"
1858
+ ]);
1859
+ function diffSchemaNode(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, opts) {
1860
+ const depth = opts.depth ?? 0;
1861
+ const seen = opts.seen ?? /* @__PURE__ */ new Set();
1862
+ if (depth > MAX_DEPTH) return;
1863
+ if (isRefNode(oldSchema) || isRefNode(newSchema)) {
1864
+ if (depth === 0) return;
1865
+ const pair = dereffedPair(
1866
+ oldSchema,
1867
+ newSchema,
1868
+ ctx.baselineIndex.schemas,
1869
+ ctx.currentIndex.schemas,
1870
+ seen
1871
+ );
1872
+ if (!pair) return;
1873
+ oldSchema = pair.old;
1874
+ newSchema = pair.new;
1875
+ }
1876
+ const name = lastToken(pointerNew) || lastToken(pointerOld) || "value";
1877
+ if (!deepEqual(oldSchema.type, newSchema.type)) {
1878
+ emit(ctx, {
1879
+ ruleId: "property-type-changed",
1880
+ location: { ...location, pointerOld, pointerNew },
1881
+ oldValue: compactType(oldSchema.type),
1882
+ newValue: compactType(newSchema.type)
1883
+ });
1884
+ }
1885
+ if (!deepEqual(oldSchema.format, newSchema.format)) {
1886
+ emit(ctx, {
1887
+ ruleId: "property-format-changed",
1888
+ location: {
1889
+ ...location,
1890
+ pointerOld: `${pointerOld}/format`,
1891
+ pointerNew: `${pointerNew}/format`
1892
+ },
1893
+ oldValue: oldSchema.format,
1894
+ newValue: newSchema.format
1895
+ });
1896
+ }
1897
+ if (!deepEqual(oldSchema.default, newSchema.default)) {
1898
+ emit(ctx, {
1899
+ ruleId: "property-default-changed",
1900
+ location: {
1901
+ ...location,
1902
+ pointerOld: `${pointerOld}/default`,
1903
+ pointerNew: `${pointerNew}/default`
1904
+ },
1905
+ oldValue: compact(oldSchema.default),
1906
+ newValue: compact(newSchema.default)
1907
+ });
1908
+ }
1909
+ diffEnum(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name);
1910
+ diffConst(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name);
1911
+ diffConstraints(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name);
1912
+ diffAdditionalProperties(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name);
1913
+ diffComposition(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name);
1914
+ diffDiscriminator(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name);
1915
+ if (!deepEqual(oldSchema.description, newSchema.description) || !deepEqual(oldSchema.title, newSchema.title) || !deepEqual(oldSchema.example, newSchema.example)) {
1916
+ emit(ctx, {
1917
+ ruleId: "schema-description-changed",
1918
+ location: { ...location, pointerOld, pointerNew }
1919
+ });
1920
+ }
1921
+ const oldProps = isObject(oldSchema.properties) ? oldSchema.properties : {};
1922
+ const newProps = isObject(newSchema.properties) ? newSchema.properties : {};
1923
+ const reqOld = opts.requiredOld;
1924
+ const reqNew = opts.requiredNew;
1925
+ for (const propName of Object.keys(oldProps).sort()) {
1926
+ const oldChild = oldProps[propName];
1927
+ const newChild = newProps[propName];
1928
+ if (newChild === void 0) {
1929
+ emit(ctx, {
1930
+ ruleId: "property-removed",
1931
+ location: {
1932
+ ...location,
1933
+ pointerOld: `${pointerOld}/properties/${escape(propName)}`,
1934
+ pointerNew: ""
1935
+ },
1936
+ oldValue: propName,
1937
+ doctrine: {
1938
+ wasRequired: reqOld.includes(propName),
1939
+ sideClass: opts.sideClass
1940
+ }
1941
+ });
1942
+ continue;
1943
+ }
1944
+ if (!isObject(oldChild) || !isObject(newChild)) continue;
1945
+ const wasReq = reqOld.includes(propName);
1946
+ const isReq = reqNew.includes(propName);
1947
+ if (!wasReq && isReq) {
1948
+ emit(ctx, {
1949
+ ruleId: "required-property-added",
1950
+ location: {
1951
+ ...location,
1952
+ pointerOld: `${pointerOld}/properties/${escape(propName)}`,
1953
+ pointerNew: `${pointerNew}/properties/${escape(propName)}`
1954
+ },
1955
+ oldValue: propName,
1956
+ doctrine: { wasRequired: true, sideClass: opts.sideClass }
1957
+ });
1958
+ } else if (!wasReq && !isReq) {
1959
+ }
1960
+ diffSchemaNode(
1961
+ ctx,
1962
+ oldChild,
1963
+ newChild,
1964
+ `${pointerOld}/properties/${escape(propName)}`,
1965
+ `${pointerNew}/properties/${escape(propName)}`,
1966
+ location,
1967
+ {
1968
+ requiredOld: stringArray2(oldChild.required),
1969
+ requiredNew: stringArray2(newChild.required),
1970
+ sideClass: opts.sideClass
1971
+ }
1972
+ );
1973
+ }
1974
+ for (const propName of Object.keys(newProps).sort()) {
1975
+ if (oldProps[propName] !== void 0) continue;
1976
+ const newChild = newProps[propName];
1977
+ if (!isObject(newChild)) continue;
1978
+ const isReq = reqNew.includes(propName);
1979
+ if (!isReq) {
1980
+ emit(ctx, {
1981
+ ruleId: "property-added",
1982
+ location: {
1983
+ ...location,
1984
+ pointerOld: "",
1985
+ pointerNew: `${pointerNew}/properties/${escape(propName)}`
1986
+ },
1987
+ newValue: propName
1988
+ });
1989
+ } else {
1990
+ emit(ctx, {
1991
+ ruleId: "required-property-added",
1992
+ location: {
1993
+ ...location,
1994
+ pointerOld: "",
1995
+ pointerNew: `${pointerNew}/properties/${escape(propName)}`
1996
+ },
1997
+ newValue: propName,
1998
+ doctrine: { wasRequired: true, sideClass: opts.sideClass }
1999
+ });
2000
+ }
2001
+ }
2002
+ const oldItems = isObject(oldSchema.items) ? oldSchema.items : void 0;
2003
+ const newItems = isObject(newSchema.items) ? newSchema.items : void 0;
2004
+ if (oldItems && newItems) {
2005
+ diffSchemaNode(
2006
+ ctx,
2007
+ oldItems,
2008
+ newItems,
2009
+ `${pointerOld}/items`,
2010
+ `${pointerNew}/items`,
2011
+ location,
2012
+ {
2013
+ requiredOld: stringArrayOf(oldItems.required),
2014
+ requiredNew: stringArrayOf(newItems.required),
2015
+ sideClass: opts.sideClass
2016
+ }
2017
+ );
2018
+ }
2019
+ for (const propName of reqOld) {
2020
+ if (!reqNew.includes(propName) && newProps[propName] !== void 0) {
2021
+ emit(ctx, {
2022
+ ruleId: "required-removed",
2023
+ location: {
2024
+ ...location,
2025
+ pointerOld: `${pointerOld}/required`,
2026
+ pointerNew: `${pointerNew}/required`
2027
+ },
2028
+ oldValue: propName
2029
+ });
2030
+ }
2031
+ }
2032
+ }
2033
+ function diffEnum(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name) {
2034
+ const oldEnum = Array.isArray(oldSchema.enum) ? oldSchema.enum : void 0;
2035
+ const newEnum = Array.isArray(newSchema.enum) ? newSchema.enum : void 0;
2036
+ if (!oldEnum || !newEnum) return;
2037
+ const ptr = `${pointerOld}/enum`;
2038
+ const ptrNew = `${pointerNew}/enum`;
2039
+ const compactOld = compactEnum(oldEnum);
2040
+ const compactNew = compactEnum(newEnum);
2041
+ for (const v of oldEnum) {
2042
+ if (!newEnum.some((n) => deepEqual(n, v))) {
2043
+ emit(ctx, {
2044
+ ruleId: "enum-value-removed",
2045
+ location: { ...location, pointerOld: ptr, pointerNew: ptrNew },
2046
+ oldValue: v,
2047
+ newValue: compactNew
2048
+ });
2049
+ }
2050
+ }
2051
+ for (const v of newEnum) {
2052
+ if (!oldEnum.some((o) => deepEqual(o, v))) {
2053
+ emit(ctx, {
2054
+ ruleId: "enum-value-added",
2055
+ location: { ...location, pointerOld: ptr, pointerNew: ptrNew },
2056
+ oldValue: compactOld,
2057
+ newValue: v
2058
+ });
2059
+ }
2060
+ }
2061
+ void name;
2062
+ }
2063
+ function compactEnum(e) {
2064
+ if (e.length <= 8) return e;
2065
+ return `${e.length} values`;
2066
+ }
2067
+ function diffConst(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name) {
2068
+ const oldConst = oldSchema.const;
2069
+ const newConst = newSchema.const;
2070
+ const base = { ...location };
2071
+ void name;
2072
+ if (oldConst === void 0 && newConst !== void 0) {
2073
+ emit(ctx, {
2074
+ ruleId: "const-added",
2075
+ location: { ...base, pointerOld: `${pointerOld}/const`, pointerNew: `${pointerNew}/const` },
2076
+ oldValue: void 0,
2077
+ newValue: newConst
2078
+ });
2079
+ } else if (oldConst !== void 0 && newConst !== void 0 && !deepEqual(oldConst, newConst)) {
2080
+ emit(ctx, {
2081
+ ruleId: "const-changed",
2082
+ location: { ...base, pointerOld: `${pointerOld}/const`, pointerNew: `${pointerNew}/const` },
2083
+ oldValue: oldConst,
2084
+ newValue: newConst
2085
+ });
2086
+ } else if (oldConst !== void 0 && newConst === void 0) {
2087
+ emit(ctx, {
2088
+ ruleId: "const-removed",
2089
+ location: { ...base, pointerOld: `${pointerOld}/const`, pointerNew: `${pointerNew}/const` },
2090
+ oldValue: oldConst
2091
+ });
2092
+ }
2093
+ }
2094
+ function diffConstraints(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name) {
2095
+ for (const key of Object.keys(CONSTRAINT_LABEL)) {
2096
+ const oldV = oldSchema[key];
2097
+ const newV = newSchema[key];
2098
+ if (oldV === void 0 && newV === void 0) continue;
2099
+ const ptrOld = `${pointerOld}/${key}`;
2100
+ const ptrNew = `${pointerNew}/${key}`;
2101
+ const base = { ...location };
2102
+ if (oldV === void 0 && newV !== void 0) {
2103
+ emit(ctx, {
2104
+ ruleId: "constraint-added",
2105
+ location: { ...base, pointerOld: ptrOld, pointerNew: ptrNew },
2106
+ oldValue: void 0,
2107
+ newValue: newV
2108
+ });
2109
+ continue;
2110
+ }
2111
+ if (oldV !== void 0 && newV === void 0) {
2112
+ emit(ctx, {
2113
+ ruleId: "constraint-removed",
2114
+ location: { ...base, pointerOld: ptrOld, pointerNew: ptrNew },
2115
+ oldValue: oldV
2116
+ });
2117
+ continue;
2118
+ }
2119
+ if (deepEqual(oldV, newV)) continue;
2120
+ if (key === "pattern") {
2121
+ emit(ctx, {
2122
+ ruleId: "constraint-tightened",
2123
+ location: { ...base, pointerOld: ptrOld, pointerNew: ptrNew },
2124
+ oldValue: oldV,
2125
+ newValue: newV
2126
+ });
2127
+ continue;
2128
+ }
2129
+ if (NUMERIC_KEYS.has(key)) {
2130
+ const o2 = num(oldV);
2131
+ const n2 = num(newV);
2132
+ const isLower = LOWER_BOUNDS.includes(key) || key === "exclusiveMinimum";
2133
+ const dir = boundDirection(isLower ? "lower" : "upper", o2, n2);
2134
+ emit(ctx, {
2135
+ ruleId: dir === "tightened" ? "constraint-tightened" : "constraint-relaxed",
2136
+ location: { ...base, pointerOld: ptrOld, pointerNew: ptrNew },
2137
+ oldValue: oldV,
2138
+ newValue: newV
2139
+ });
2140
+ continue;
2141
+ }
2142
+ const o = num(oldV);
2143
+ const n = num(newV);
2144
+ if (o !== void 0 && n !== void 0) {
2145
+ const isLower = LOWER_BOUNDS.includes(key);
2146
+ const dir = boundDirection(isLower ? "lower" : "upper", o, n);
2147
+ emit(ctx, {
2148
+ ruleId: dir === "tightened" ? "constraint-tightened" : "constraint-relaxed",
2149
+ location: { ...base, pointerOld: ptrOld, pointerNew: ptrNew },
2150
+ oldValue: oldV,
2151
+ newValue: newV
2152
+ });
2153
+ }
2154
+ void name;
2155
+ }
2156
+ }
2157
+ function diffAdditionalProperties(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name) {
2158
+ const oldClosed = isClosed(oldSchema.additionalProperties);
2159
+ const newClosed = isClosed(newSchema.additionalProperties);
2160
+ void name;
2161
+ if (!oldClosed && newClosed) {
2162
+ emit(ctx, {
2163
+ ruleId: "map-closed",
2164
+ location: {
2165
+ ...location,
2166
+ pointerOld: `${pointerOld}/additionalProperties`,
2167
+ pointerNew: `${pointerNew}/additionalProperties`
2168
+ }
2169
+ });
2170
+ } else if (oldClosed && !newClosed) {
2171
+ emit(ctx, {
2172
+ ruleId: "map-opened",
2173
+ location: {
2174
+ ...location,
2175
+ pointerOld: `${pointerOld}/additionalProperties`,
2176
+ pointerNew: `${pointerNew}/additionalProperties`
2177
+ }
2178
+ });
2179
+ }
2180
+ }
2181
+ function isClosed(v) {
2182
+ if (v === void 0 || v === true) return false;
2183
+ return true;
2184
+ }
2185
+ function diffComposition(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name) {
2186
+ void name;
2187
+ const oldAll = Array.isArray(oldSchema.allOf) ? oldSchema.allOf : [];
2188
+ const newAll = Array.isArray(newSchema.allOf) ? newSchema.allOf : [];
2189
+ if (newAll.length > oldAll.length) {
2190
+ emit(ctx, {
2191
+ ruleId: "allOf-member-added",
2192
+ location: {
2193
+ ...location,
2194
+ pointerOld: `${pointerOld}/allOf`,
2195
+ pointerNew: `${pointerNew}/allOf`
2196
+ },
2197
+ oldValue: oldAll.length,
2198
+ newValue: newAll.length
2199
+ });
2200
+ } else if (newAll.length < oldAll.length) {
2201
+ emit(ctx, {
2202
+ ruleId: "allOf-member-removed",
2203
+ location: {
2204
+ ...location,
2205
+ pointerOld: `${pointerOld}/allOf`,
2206
+ pointerNew: `${pointerNew}/allOf`
2207
+ },
2208
+ oldValue: oldAll.length,
2209
+ newValue: newAll.length
2210
+ });
2211
+ }
2212
+ for (const key of ["anyOf", "oneOf"]) {
2213
+ const oldArr = Array.isArray(oldSchema[key]) ? oldSchema[key] : [];
2214
+ const newArr = Array.isArray(newSchema[key]) ? newSchema[key] : [];
2215
+ if (newArr.length > oldArr.length) {
2216
+ emit(ctx, {
2217
+ ruleId: "anyOf-oneOf-member-added",
2218
+ location: {
2219
+ ...location,
2220
+ pointerOld: `${pointerOld}/${key}`,
2221
+ pointerNew: `${pointerNew}/${key}`
2222
+ },
2223
+ oldValue: oldArr.length,
2224
+ newValue: newArr.length
2225
+ });
2226
+ } else if (newArr.length < oldArr.length) {
2227
+ emit(ctx, {
2228
+ ruleId: "anyOf-oneOf-member-removed",
2229
+ location: {
2230
+ ...location,
2231
+ pointerOld: `${pointerOld}/${key}`,
2232
+ pointerNew: `${pointerNew}/${key}`
2233
+ },
2234
+ oldValue: oldArr.length,
2235
+ newValue: newArr.length
2236
+ });
2237
+ }
2238
+ }
2239
+ }
2240
+ function diffDiscriminator(ctx, oldSchema, newSchema, pointerOld, pointerNew, location, name) {
2241
+ void name;
2242
+ const oldDisc = isObject(oldSchema.discriminator) ? oldSchema.discriminator : void 0;
2243
+ const newDisc = isObject(newSchema.discriminator) ? newSchema.discriminator : void 0;
2244
+ if (!oldDisc && newDisc) {
2245
+ emit(ctx, {
2246
+ ruleId: "discriminator-added",
2247
+ location: {
2248
+ ...location,
2249
+ pointerOld: `${pointerOld}/discriminator`,
2250
+ pointerNew: `${pointerNew}/discriminator`
2251
+ },
2252
+ newValue: newDisc.propertyName
2253
+ });
2254
+ } else if (oldDisc && newDisc && !deepEqual(oldDisc, newDisc)) {
2255
+ emit(ctx, {
2256
+ ruleId: "discriminator-changed",
2257
+ location: {
2258
+ ...location,
2259
+ pointerOld: `${pointerOld}/discriminator`,
2260
+ pointerNew: `${pointerNew}/discriminator`
2261
+ },
2262
+ oldValue: oldDisc.propertyName,
2263
+ newValue: newDisc.propertyName
2264
+ });
2265
+ } else if (oldDisc && !newDisc) {
2266
+ emit(ctx, {
2267
+ ruleId: "discriminator-changed",
2268
+ location: {
2269
+ ...location,
2270
+ pointerOld: `${pointerOld}/discriminator`,
2271
+ pointerNew: `${pointerNew}/discriminator`
2272
+ },
2273
+ oldValue: oldDisc.propertyName,
2274
+ newValue: void 0
2275
+ });
2276
+ }
2277
+ }
2278
+ function compactType(t) {
2279
+ if (t === void 0) return void 0;
2280
+ return typeLabel(t);
2281
+ }
2282
+ function compact(v) {
2283
+ if (v === void 0 || v === null) return v;
2284
+ if (typeof v !== "object") return v;
2285
+ const json = JSON.stringify(v);
2286
+ if (json === void 0 || json.length <= 80) return v;
2287
+ return `${json.slice(0, 77)}\u2026`;
2288
+ }
2289
+ function stringArrayOf(v) {
2290
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
2291
+ }
2292
+ function stringArray2(v) {
2293
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
2294
+ }
2295
+ function lastToken(pointer) {
2296
+ const tokens = pointer.split("/").filter(Boolean);
2297
+ const last = tokens[tokens.length - 1];
2298
+ return (last ?? "").replace(/~1/g, "/").replace(/~0/g, "~");
2299
+ }
2300
+
2301
+ // src/core/engine/differ.ts
2302
+ function emit(ctx, opts) {
2303
+ const resolved = classify(
2304
+ opts.ruleId,
2305
+ opts.location.context,
2306
+ { rules: ctx.config.rules },
2307
+ opts.doctrine
2308
+ );
2309
+ const rule = getRule(opts.ruleId);
2310
+ const severity = opts.severityOverride ?? (resolved ? resolved.severity : rule.defaultSeverity);
2311
+ const kind = opts.kindOverride ?? (resolved ? resolved.kind : rule.defaultKind);
2312
+ if (resolved === null) return;
2313
+ const templateCtx = {
2314
+ location: opts.location,
2315
+ oldValue: opts.oldValue,
2316
+ newValue: opts.newValue
2317
+ };
2318
+ const change = {
2319
+ id: changeId({
2320
+ ruleId: opts.ruleId,
2321
+ context: opts.location.context,
2322
+ path: opts.location.path,
2323
+ method: opts.location.method,
2324
+ pointerOld: opts.location.pointerOld,
2325
+ pointerNew: opts.location.pointerNew,
2326
+ oldValue: opts.oldValue,
2327
+ newValue: opts.newValue
2328
+ }),
2329
+ ruleId: opts.ruleId,
2330
+ severity,
2331
+ kind,
2332
+ breaking: severity === "error",
2333
+ location: opts.location,
2334
+ message: rule.message(templateCtx),
2335
+ suggestion: rule.suggestion(templateCtx),
2336
+ ...opts.oldValue !== void 0 ? { oldValue: opts.oldValue } : {},
2337
+ ...opts.newValue !== void 0 ? { newValue: opts.newValue } : {}
2338
+ };
2339
+ ctx.candidates.push(change);
2340
+ }
2341
+ function diffDocumentLevel(ctx) {
2342
+ const oldDoc = isObject(ctx.baseline.normalized) ? ctx.baseline.normalized : {};
2343
+ const newDoc = isObject(ctx.current.normalized) ? ctx.current.normalized : {};
2344
+ if (!deepEqual(oldDoc.openapi, newDoc.openapi)) {
2345
+ emit(ctx, {
2346
+ ruleId: "openapi-version-changed",
2347
+ location: {
2348
+ path: "/",
2349
+ pointerOld: pointerJoin("openapi"),
2350
+ pointerNew: pointerJoin("openapi"),
2351
+ context: "api"
2352
+ },
2353
+ oldValue: oldDoc.openapi,
2354
+ newValue: newDoc.openapi
2355
+ });
2356
+ }
2357
+ const infoOld = isObject(oldDoc.info) ? oldDoc.info : {};
2358
+ const infoNew = isObject(newDoc.info) ? newDoc.info : {};
2359
+ if (!deepEqual(infoOld.title, infoNew.title)) {
2360
+ emit(ctx, {
2361
+ ruleId: "title-changed",
2362
+ location: {
2363
+ path: "/",
2364
+ pointerOld: pointerJoin("info", "title"),
2365
+ pointerNew: pointerJoin("info", "title"),
2366
+ context: "api"
2367
+ },
2368
+ oldValue: infoOld.title,
2369
+ newValue: infoNew.title
2370
+ });
2371
+ }
2372
+ if (!deepEqual(infoOld.description, infoNew.description)) {
2373
+ emit(ctx, {
2374
+ ruleId: "description-changed",
2375
+ location: {
2376
+ path: "/",
2377
+ pointerOld: pointerJoin("info", "description"),
2378
+ pointerNew: pointerJoin("info", "description"),
2379
+ context: "api"
2380
+ },
2381
+ oldValue: infoOld.description,
2382
+ newValue: infoNew.description
2383
+ });
2384
+ }
2385
+ if (!deepEqual(infoOld.contact, infoNew.contact)) {
2386
+ emit(ctx, {
2387
+ ruleId: "contact-changed",
2388
+ location: {
2389
+ path: "/",
2390
+ pointerOld: pointerJoin("info", "contact"),
2391
+ pointerNew: pointerJoin("info", "contact"),
2392
+ context: "api"
2393
+ },
2394
+ oldValue: compact2(infoOld.contact),
2395
+ newValue: compact2(infoNew.contact)
2396
+ });
2397
+ }
2398
+ if (!deepEqual(infoOld.license, infoNew.license)) {
2399
+ emit(ctx, {
2400
+ ruleId: "license-changed",
2401
+ location: {
2402
+ path: "/",
2403
+ pointerOld: pointerJoin("info", "license"),
2404
+ pointerNew: pointerJoin("info", "license"),
2405
+ context: "api"
2406
+ },
2407
+ oldValue: compact2(infoOld.license),
2408
+ newValue: compact2(infoNew.license)
2409
+ });
2410
+ }
2411
+ if (!deepEqual(oldDoc.externalDocs, newDoc.externalDocs)) {
2412
+ emit(ctx, {
2413
+ ruleId: "external-docs-changed",
2414
+ location: {
2415
+ path: "/",
2416
+ pointerOld: pointerJoin("externalDocs"),
2417
+ pointerNew: pointerJoin("externalDocs"),
2418
+ context: "api"
2419
+ },
2420
+ oldValue: compact2(oldDoc.externalDocs),
2421
+ newValue: compact2(newDoc.externalDocs)
2422
+ });
2423
+ }
2424
+ diffServers(ctx, oldDoc, newDoc);
2425
+ }
2426
+ function diffServers(ctx, oldDoc, newDoc) {
2427
+ const oldServers = Array.isArray(oldDoc.servers) ? oldDoc.servers : [];
2428
+ const newServers = Array.isArray(newDoc.servers) ? newDoc.servers : [];
2429
+ const oldUrls = oldServers.map((s) => isObject(s) ? s.url : void 0);
2430
+ const newUrls = newServers.map((s) => isObject(s) ? s.url : void 0);
2431
+ for (const url of oldUrls) {
2432
+ if (typeof url === "string" && !newUrls.includes(url)) {
2433
+ emit(ctx, {
2434
+ ruleId: "server-url-removed",
2435
+ location: {
2436
+ path: "/",
2437
+ pointerOld: pointerJoin("servers"),
2438
+ pointerNew: pointerJoin("servers"),
2439
+ context: "api"
2440
+ },
2441
+ oldValue: url
2442
+ });
2443
+ }
2444
+ }
2445
+ for (const url of newUrls) {
2446
+ if (typeof url === "string" && !oldUrls.includes(url)) {
2447
+ emit(ctx, {
2448
+ ruleId: "server-url-added",
2449
+ location: {
2450
+ path: "/",
2451
+ pointerOld: pointerJoin("servers"),
2452
+ pointerNew: pointerJoin("servers"),
2453
+ context: "api"
2454
+ },
2455
+ newValue: url
2456
+ });
2457
+ }
2458
+ }
2459
+ for (const oldS of oldServers) {
2460
+ if (!isObject(oldS)) continue;
2461
+ const url = oldS.url;
2462
+ if (typeof url !== "string") continue;
2463
+ const newS = newServers.find((s) => isObject(s) && s.url === url);
2464
+ if (!newS || !isObject(newS)) continue;
2465
+ if (!deepEqual(oldS.description, newS.description) || !deepEqual(oldS.variables, newS.variables)) {
2466
+ emit(ctx, {
2467
+ ruleId: "server-metadata-changed",
2468
+ location: {
2469
+ path: "/",
2470
+ pointerOld: pointerJoin("servers"),
2471
+ pointerNew: pointerJoin("servers"),
2472
+ context: "api"
2473
+ },
2474
+ oldValue: compact2({ description: oldS.description, variables: oldS.variables }),
2475
+ newValue: compact2({ description: newS.description, variables: newS.variables })
2476
+ });
2477
+ }
2478
+ }
2479
+ }
2480
+ function diffOperations(ctx) {
2481
+ const { baselineIndex: bi, currentIndex: ci } = ctx;
2482
+ for (const p of bi.paths) {
2483
+ if (!ci.pathItems.has(p)) {
2484
+ emit(ctx, {
2485
+ ruleId: "removed-path",
2486
+ location: {
2487
+ path: p,
2488
+ pointerOld: pointerJoin("paths", p),
2489
+ pointerNew: "",
2490
+ context: "operation"
2491
+ }
2492
+ });
2493
+ }
2494
+ }
2495
+ const commonPaths = bi.paths.filter((p) => ci.pathItems.has(p));
2496
+ for (const p of commonPaths) {
2497
+ const oldItem = bi.pathItems.get(p);
2498
+ const newItem = ci.pathItems.get(p);
2499
+ const oldMethods = methodKeys(oldItem);
2500
+ const newMethods = methodKeys(newItem);
2501
+ for (const m of oldMethods) {
2502
+ if (!newMethods.includes(m)) {
2503
+ emit(ctx, {
2504
+ ruleId: "removed-method",
2505
+ location: {
2506
+ path: p,
2507
+ method: m,
2508
+ pointerOld: pointerJoin("paths", p, m.toLowerCase()),
2509
+ pointerNew: "",
2510
+ context: "operation"
2511
+ }
2512
+ });
2513
+ }
2514
+ }
2515
+ for (const m of newMethods) {
2516
+ if (!oldMethods.includes(m)) {
2517
+ emit(ctx, {
2518
+ ruleId: "operation-added",
2519
+ location: {
2520
+ path: p,
2521
+ method: m,
2522
+ pointerOld: "",
2523
+ pointerNew: pointerJoin("paths", p, m.toLowerCase()),
2524
+ context: "operation"
2525
+ }
2526
+ });
2527
+ }
2528
+ }
2529
+ for (const m of oldMethods.filter((m2) => newMethods.includes(m2))) {
2530
+ diffOperationPair(ctx, p, m, oldItem, newItem);
2531
+ }
2532
+ }
2533
+ for (const p of ci.paths) {
2534
+ if (!bi.pathItems.has(p)) {
2535
+ const newItem = ci.pathItems.get(p);
2536
+ for (const m of methodKeys(newItem)) {
2537
+ emit(ctx, {
2538
+ ruleId: "operation-added",
2539
+ location: {
2540
+ path: p,
2541
+ method: m,
2542
+ pointerOld: "",
2543
+ pointerNew: pointerJoin("paths", p, m.toLowerCase()),
2544
+ context: "operation"
2545
+ }
2546
+ });
2547
+ }
2548
+ }
2549
+ }
2550
+ diffWebhooks(ctx);
2551
+ }
2552
+ function methodKeys(item) {
2553
+ return Object.keys(item).filter(
2554
+ (k) => ["get", "post", "put", "patch", "delete", "head", "options", "trace"].includes(
2555
+ k.toLowerCase()
2556
+ )
2557
+ ).map((k) => k.toUpperCase()).sort();
2558
+ }
2559
+ function diffOperationPair(ctx, path3, method, oldItem, newItem) {
2560
+ const oldOp = isObject(oldItem[method.toLowerCase()]) ? oldItem[method.toLowerCase()] : {};
2561
+ const newOp = isObject(newItem[method.toLowerCase()]) ? newItem[method.toLowerCase()] : {};
2562
+ const baseLoc = { path: path3, method };
2563
+ if (!deepEqual(oldOp.operationId, newOp.operationId)) {
2564
+ emit(ctx, {
2565
+ ruleId: "operation-id-changed",
2566
+ location: {
2567
+ ...baseLoc,
2568
+ operationId: typeof newOp.operationId === "string" ? newOp.operationId : void 0,
2569
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "operationId"),
2570
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "operationId"),
2571
+ context: "operation"
2572
+ },
2573
+ oldValue: oldOp.operationId,
2574
+ newValue: newOp.operationId
2575
+ });
2576
+ }
2577
+ const oldDep = oldOp.deprecated === true;
2578
+ const newDep = newOp.deprecated === true;
2579
+ if (newDep && !oldDep) {
2580
+ emit(ctx, {
2581
+ ruleId: "operation-deprecated-added",
2582
+ location: {
2583
+ ...baseLoc,
2584
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "deprecated"),
2585
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "deprecated"),
2586
+ context: "operation"
2587
+ }
2588
+ });
2589
+ } else if (oldDep && !newDep) {
2590
+ emit(ctx, {
2591
+ ruleId: "operation-deprecated-removed",
2592
+ location: {
2593
+ ...baseLoc,
2594
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "deprecated"),
2595
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "deprecated"),
2596
+ context: "operation"
2597
+ }
2598
+ });
2599
+ }
2600
+ if (!deepEqual(oldOp.tags, newOp.tags)) {
2601
+ emit(ctx, {
2602
+ ruleId: "operation-tags-changed",
2603
+ location: {
2604
+ ...baseLoc,
2605
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "tags"),
2606
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "tags"),
2607
+ context: "operation"
2608
+ },
2609
+ oldValue: compact2(oldOp.tags),
2610
+ newValue: compact2(newOp.tags)
2611
+ });
2612
+ }
2613
+ if (!deepEqual(oldOp.summary, newOp.summary)) {
2614
+ emit(ctx, {
2615
+ ruleId: "operation-summary-changed",
2616
+ location: {
2617
+ ...baseLoc,
2618
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "summary"),
2619
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "summary"),
2620
+ context: "operation"
2621
+ },
2622
+ oldValue: oldOp.summary,
2623
+ newValue: newOp.summary
2624
+ });
2625
+ }
2626
+ if (!deepEqual(oldOp.description, newOp.description)) {
2627
+ emit(ctx, {
2628
+ ruleId: "operation-description-changed",
2629
+ location: {
2630
+ ...baseLoc,
2631
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "description"),
2632
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "description"),
2633
+ context: "operation"
2634
+ },
2635
+ oldValue: oldOp.description,
2636
+ newValue: newOp.description
2637
+ });
2638
+ }
2639
+ diffNamedMap(ctx, oldOp.callbacks, newOp.callbacks, "callback-added", "callback-removed", {
2640
+ ...baseLoc,
2641
+ context: "operation"
2642
+ });
2643
+ diffParameters(ctx, baseLoc, oldItem, oldOp, newItem, newOp);
2644
+ diffRequestBody(ctx, baseLoc, oldOp, newOp);
2645
+ diffResponses(ctx, baseLoc, oldOp, newOp);
2646
+ }
2647
+ function diffNamedMap(ctx, oldMap, newMap, addedRule, removedRule, baseLoc) {
2648
+ const oldObj = isObject(oldMap) ? oldMap : {};
2649
+ const newObj = isObject(newMap) ? newMap : {};
2650
+ for (const name of Object.keys(oldObj).sort()) {
2651
+ if (!(name in newObj)) {
2652
+ emit(ctx, {
2653
+ ruleId: removedRule,
2654
+ location: {
2655
+ ...baseLoc,
2656
+ pointerOld: pointerJoin(
2657
+ "paths",
2658
+ baseLoc.path,
2659
+ baseLoc.method?.toLowerCase() ?? "",
2660
+ "callbacks",
2661
+ name
2662
+ ),
2663
+ pointerNew: ""
2664
+ },
2665
+ oldValue: name
2666
+ });
2667
+ }
2668
+ }
2669
+ for (const name of Object.keys(newObj).sort()) {
2670
+ if (!(name in oldObj)) {
2671
+ emit(ctx, {
2672
+ ruleId: addedRule,
2673
+ location: {
2674
+ ...baseLoc,
2675
+ pointerOld: "",
2676
+ pointerNew: pointerJoin(
2677
+ "paths",
2678
+ baseLoc.path,
2679
+ baseLoc.method?.toLowerCase() ?? "",
2680
+ "callbacks",
2681
+ name
2682
+ )
2683
+ },
2684
+ newValue: name
2685
+ });
2686
+ }
2687
+ }
2688
+ }
2689
+ function diffParameters(ctx, baseLoc, oldItem, oldOp, newItem, newOp) {
2690
+ const oldParams = mergedParameters(oldItem, oldOp);
2691
+ const newParams = mergedParameters(newItem, newOp);
2692
+ const keyOf = (p) => `${typeof p.name === "string" ? p.name : ""}`;
2693
+ const oldByKey = /* @__PURE__ */ new Map();
2694
+ for (const p of oldParams) {
2695
+ const k = keyOf(p);
2696
+ if (!oldByKey.has(k)) oldByKey.set(k, p);
2697
+ }
2698
+ const newByKey = /* @__PURE__ */ new Map();
2699
+ for (const p of newParams) {
2700
+ const k = keyOf(p);
2701
+ if (!newByKey.has(k)) newByKey.set(k, p);
2702
+ }
2703
+ for (const [key, oldP] of oldByKey) {
2704
+ const name = typeof oldP.name === "string" ? oldP.name : key;
2705
+ const wasIn = typeof oldP.in === "string" ? oldP.in : "";
2706
+ const newP = newByKey.get(key);
2707
+ if (!newP) {
2708
+ const wasRequired = oldP.required === true || wasIn === "path" || wasIn === "header";
2709
+ emit(ctx, {
2710
+ ruleId: "removed-parameter",
2711
+ location: {
2712
+ ...baseLoc,
2713
+ pointerOld: pointerJoin(
2714
+ "paths",
2715
+ baseLoc.path,
2716
+ baseLoc.method.toLowerCase(),
2717
+ "parameters",
2718
+ name
2719
+ ),
2720
+ pointerNew: "",
2721
+ context: wasIn === "header" ? "request-header" : "parameter"
2722
+ },
2723
+ oldValue: name,
2724
+ doctrine: { wasRequired }
2725
+ });
2726
+ continue;
2727
+ }
2728
+ const newIn = typeof newP.in === "string" ? newP.in : "";
2729
+ if (newIn !== wasIn) {
2730
+ emit(ctx, {
2731
+ ruleId: "parameter-location-changed",
2732
+ location: {
2733
+ ...baseLoc,
2734
+ pointerOld: pointerJoin(
2735
+ "paths",
2736
+ baseLoc.path,
2737
+ baseLoc.method.toLowerCase(),
2738
+ "parameters",
2739
+ name
2740
+ ),
2741
+ pointerNew: pointerJoin(
2742
+ "paths",
2743
+ baseLoc.path,
2744
+ baseLoc.method.toLowerCase(),
2745
+ "parameters",
2746
+ name
2747
+ ),
2748
+ context: "parameter"
2749
+ },
2750
+ oldValue: wasIn,
2751
+ newValue: newIn
2752
+ });
2753
+ continue;
2754
+ }
2755
+ const oldReq = oldP.required === true;
2756
+ const newReq = newP.required === true;
2757
+ if (!oldReq && newReq) {
2758
+ emit(ctx, {
2759
+ ruleId: "parameter-made-required",
2760
+ location: {
2761
+ ...baseLoc,
2762
+ pointerOld: pointerJoin(
2763
+ "paths",
2764
+ baseLoc.path,
2765
+ baseLoc.method.toLowerCase(),
2766
+ "parameters",
2767
+ name
2768
+ ),
2769
+ pointerNew: pointerJoin(
2770
+ "paths",
2771
+ baseLoc.path,
2772
+ baseLoc.method.toLowerCase(),
2773
+ "parameters",
2774
+ name
2775
+ ),
2776
+ context: "parameter"
2777
+ }
2778
+ });
2779
+ }
2780
+ const oldSchema = isObject(oldP.schema) ? oldP.schema : void 0;
2781
+ const newSchema = isObject(newP.schema) ? newP.schema : void 0;
2782
+ if (oldSchema && newSchema) {
2783
+ diffSchemaNode(
2784
+ ctx,
2785
+ oldSchema,
2786
+ newSchema,
2787
+ pointerJoin(
2788
+ "paths",
2789
+ baseLoc.path,
2790
+ baseLoc.method.toLowerCase(),
2791
+ "parameters",
2792
+ name,
2793
+ "schema"
2794
+ ),
2795
+ pointerJoin(
2796
+ "paths",
2797
+ baseLoc.path,
2798
+ baseLoc.method.toLowerCase(),
2799
+ "parameters",
2800
+ name,
2801
+ "schema"
2802
+ ),
2803
+ { context: "parameter", path: baseLoc.path, method: baseLoc.method },
2804
+ {
2805
+ requiredOld: stringArrayOf2(oldSchema.required),
2806
+ requiredNew: stringArrayOf2(newSchema.required),
2807
+ sideClass: "send"
2808
+ }
2809
+ );
2810
+ }
2811
+ if (!deepEqual(oldP.description, newP.description) || !deepEqual(oldP.example, newP.example) || !deepEqual(oldP.deprecated, newP.deprecated)) {
2812
+ emit(ctx, {
2813
+ ruleId: "parameter-metadata-changed",
2814
+ location: {
2815
+ ...baseLoc,
2816
+ pointerOld: pointerJoin(
2817
+ "paths",
2818
+ baseLoc.path,
2819
+ baseLoc.method.toLowerCase(),
2820
+ "parameters",
2821
+ name
2822
+ ),
2823
+ pointerNew: pointerJoin(
2824
+ "paths",
2825
+ baseLoc.path,
2826
+ baseLoc.method.toLowerCase(),
2827
+ "parameters",
2828
+ name
2829
+ ),
2830
+ context: "parameter"
2831
+ }
2832
+ });
2833
+ }
2834
+ }
2835
+ for (const [key, newP] of newByKey) {
2836
+ if (oldByKey.has(key)) continue;
2837
+ const name = typeof newP.name === "string" ? newP.name : key;
2838
+ const isIn = typeof newP.in === "string" ? newP.in : "";
2839
+ const required = newP.required === true || isIn === "path";
2840
+ emit(ctx, {
2841
+ ruleId: required ? "parameter-added-required" : "parameter-added-optional",
2842
+ location: {
2843
+ ...baseLoc,
2844
+ pointerOld: "",
2845
+ pointerNew: pointerJoin(
2846
+ "paths",
2847
+ baseLoc.path,
2848
+ baseLoc.method.toLowerCase(),
2849
+ "parameters",
2850
+ name
2851
+ ),
2852
+ context: "parameter"
2853
+ },
2854
+ newValue: name
2855
+ });
2856
+ }
2857
+ }
2858
+ function diffRequestBody(ctx, baseLoc, oldOp, newOp) {
2859
+ const oldBody = isObject(oldOp.requestBody) ? oldOp.requestBody : void 0;
2860
+ const newBody = isObject(newOp.requestBody) ? newOp.requestBody : void 0;
2861
+ if (oldBody && !newBody) {
2862
+ emit(ctx, {
2863
+ ruleId: "request-body-removed",
2864
+ location: {
2865
+ ...baseLoc,
2866
+ pointerOld: pointerJoin("paths", baseLoc.path, baseLoc.method.toLowerCase(), "requestBody"),
2867
+ pointerNew: "",
2868
+ context: "request-body"
2869
+ },
2870
+ doctrine: { wasRequired: oldBody.required === true }
2871
+ });
2872
+ return;
2873
+ }
2874
+ if (!oldBody && newBody) {
2875
+ emit(ctx, {
2876
+ ruleId: newBody.required === true ? "request-body-added-required" : "request-body-added-optional",
2877
+ location: {
2878
+ ...baseLoc,
2879
+ pointerOld: "",
2880
+ pointerNew: pointerJoin("paths", baseLoc.path, baseLoc.method.toLowerCase(), "requestBody"),
2881
+ context: "request-body"
2882
+ }
2883
+ });
2884
+ return;
2885
+ }
2886
+ if (oldBody && newBody) {
2887
+ if (!oldBody.required && newBody.required === true) {
2888
+ emit(ctx, {
2889
+ ruleId: "request-body-required-changed",
2890
+ location: {
2891
+ ...baseLoc,
2892
+ pointerOld: pointerJoin(
2893
+ "paths",
2894
+ baseLoc.path,
2895
+ baseLoc.method.toLowerCase(),
2896
+ "requestBody",
2897
+ "required"
2898
+ ),
2899
+ pointerNew: pointerJoin(
2900
+ "paths",
2901
+ baseLoc.path,
2902
+ baseLoc.method.toLowerCase(),
2903
+ "requestBody",
2904
+ "required"
2905
+ ),
2906
+ context: "request-body"
2907
+ }
2908
+ });
2909
+ }
2910
+ const oldContent = isObject(oldBody.content) ? oldBody.content : {};
2911
+ const newContent = isObject(newBody.content) ? newBody.content : {};
2912
+ for (const mt of Object.keys(oldContent).sort()) {
2913
+ if (!(mt in newContent)) {
2914
+ emit(ctx, {
2915
+ ruleId: "content-type-removed",
2916
+ location: {
2917
+ ...baseLoc,
2918
+ mediaType: mt,
2919
+ pointerOld: pointerJoin(
2920
+ "paths",
2921
+ baseLoc.path,
2922
+ baseLoc.method.toLowerCase(),
2923
+ "requestBody",
2924
+ "content",
2925
+ mt
2926
+ ),
2927
+ pointerNew: "",
2928
+ context: "request-body"
2929
+ },
2930
+ oldValue: mt
2931
+ });
2932
+ }
2933
+ }
2934
+ for (const mt of Object.keys(newContent).sort()) {
2935
+ if (!(mt in oldContent)) {
2936
+ emit(ctx, {
2937
+ ruleId: "content-type-added",
2938
+ location: {
2939
+ ...baseLoc,
2940
+ mediaType: mt,
2941
+ pointerOld: "",
2942
+ pointerNew: pointerJoin(
2943
+ "paths",
2944
+ baseLoc.path,
2945
+ baseLoc.method.toLowerCase(),
2946
+ "requestBody",
2947
+ "content",
2948
+ mt
2949
+ ),
2950
+ context: "request-body"
2951
+ },
2952
+ newValue: mt
2953
+ });
2954
+ }
2955
+ }
2956
+ for (const mt of Object.keys(oldContent).sort()) {
2957
+ if (!(mt in newContent)) continue;
2958
+ const oldMt = isObject(oldContent[mt]) ? oldContent[mt] : {};
2959
+ const newMt = isObject(newContent[mt]) ? newContent[mt] : {};
2960
+ const oldSchemaRaw = isObject(oldMt.schema) ? oldMt.schema : void 0;
2961
+ const newSchemaRaw = isObject(newMt.schema) ? newMt.schema : void 0;
2962
+ if (oldSchemaRaw && newSchemaRaw) {
2963
+ const oldSchema = applyView(oldSchemaRaw, "request");
2964
+ const newSchema = applyView(newSchemaRaw, "request");
2965
+ diffSchemaNode(
2966
+ ctx,
2967
+ oldSchema,
2968
+ newSchema,
2969
+ pointerJoin(
2970
+ "paths",
2971
+ baseLoc.path,
2972
+ baseLoc.method.toLowerCase(),
2973
+ "requestBody",
2974
+ "content",
2975
+ mt,
2976
+ "schema"
2977
+ ),
2978
+ pointerJoin(
2979
+ "paths",
2980
+ baseLoc.path,
2981
+ baseLoc.method.toLowerCase(),
2982
+ "requestBody",
2983
+ "content",
2984
+ mt,
2985
+ "schema"
2986
+ ),
2987
+ {
2988
+ context: "request-body",
2989
+ path: baseLoc.path,
2990
+ method: baseLoc.method,
2991
+ mediaType: mt
2992
+ },
2993
+ {
2994
+ requiredOld: stringArrayOf2(oldSchema.required),
2995
+ requiredNew: stringArrayOf2(newSchema.required),
2996
+ sideClass: "send"
2997
+ }
2998
+ );
2999
+ }
3000
+ }
3001
+ }
3002
+ }
3003
+ function diffResponses(ctx, baseLoc, oldOp, newOp) {
3004
+ const oldRes = isObject(oldOp.responses) ? oldOp.responses : {};
3005
+ const newRes = isObject(newOp.responses) ? newOp.responses : {};
3006
+ for (const status of Object.keys(oldRes).sort()) {
3007
+ if (!(status in newRes)) {
3008
+ emit(ctx, {
3009
+ ruleId: status === "default" ? "default-response-removed" : "response-removed",
3010
+ location: {
3011
+ ...baseLoc,
3012
+ statusCode: status,
3013
+ pointerOld: pointerJoin(
3014
+ "paths",
3015
+ baseLoc.path,
3016
+ baseLoc.method.toLowerCase(),
3017
+ "responses",
3018
+ status
3019
+ ),
3020
+ pointerNew: "",
3021
+ context: "response-body"
3022
+ },
3023
+ oldValue: status
3024
+ });
3025
+ continue;
3026
+ }
3027
+ const oldR = isObject(oldRes[status]) ? oldRes[status] : {};
3028
+ const newR = isObject(newRes[status]) ? newRes[status] : {};
3029
+ const oldHeaders = isObject(oldR.headers) ? oldR.headers : {};
3030
+ const newHeaders = isObject(newR.headers) ? newR.headers : {};
3031
+ for (const h of Object.keys(oldHeaders).sort()) {
3032
+ if (!(h in newHeaders)) {
3033
+ emit(ctx, {
3034
+ ruleId: "response-header-removed",
3035
+ location: {
3036
+ ...baseLoc,
3037
+ statusCode: status,
3038
+ pointerOld: pointerJoin(
3039
+ "paths",
3040
+ baseLoc.path,
3041
+ baseLoc.method.toLowerCase(),
3042
+ "responses",
3043
+ status,
3044
+ "headers",
3045
+ h
3046
+ ),
3047
+ pointerNew: "",
3048
+ context: "response-header"
3049
+ },
3050
+ oldValue: h
3051
+ });
3052
+ }
3053
+ }
3054
+ for (const h of Object.keys(newHeaders).sort()) {
3055
+ if (!(h in oldHeaders)) {
3056
+ emit(ctx, {
3057
+ ruleId: "response-header-added",
3058
+ location: {
3059
+ ...baseLoc,
3060
+ statusCode: status,
3061
+ pointerOld: "",
3062
+ pointerNew: pointerJoin(
3063
+ "paths",
3064
+ baseLoc.path,
3065
+ baseLoc.method.toLowerCase(),
3066
+ "responses",
3067
+ status,
3068
+ "headers",
3069
+ h
3070
+ ),
3071
+ context: "response-header"
3072
+ },
3073
+ newValue: h
3074
+ });
3075
+ }
3076
+ }
3077
+ const oldContent = isObject(oldR.content) ? oldR.content : {};
3078
+ const newContent = isObject(newR.content) ? newR.content : {};
3079
+ for (const mt of Object.keys(oldContent).sort()) {
3080
+ if (!(mt in newContent)) {
3081
+ emit(ctx, {
3082
+ ruleId: "content-type-removed",
3083
+ location: {
3084
+ ...baseLoc,
3085
+ statusCode: status,
3086
+ mediaType: mt,
3087
+ pointerOld: pointerJoin(
3088
+ "paths",
3089
+ baseLoc.path,
3090
+ baseLoc.method.toLowerCase(),
3091
+ "responses",
3092
+ status,
3093
+ "content",
3094
+ mt
3095
+ ),
3096
+ pointerNew: "",
3097
+ context: "response-body"
3098
+ },
3099
+ oldValue: mt
3100
+ });
3101
+ }
3102
+ }
3103
+ for (const mt of Object.keys(newContent).sort()) {
3104
+ if (!(mt in oldContent)) {
3105
+ emit(ctx, {
3106
+ ruleId: "content-type-added",
3107
+ location: {
3108
+ ...baseLoc,
3109
+ statusCode: status,
3110
+ mediaType: mt,
3111
+ pointerOld: "",
3112
+ pointerNew: pointerJoin(
3113
+ "paths",
3114
+ baseLoc.path,
3115
+ baseLoc.method.toLowerCase(),
3116
+ "responses",
3117
+ status,
3118
+ "content",
3119
+ mt
3120
+ ),
3121
+ context: "response-body"
3122
+ },
3123
+ newValue: mt
3124
+ });
3125
+ }
3126
+ }
3127
+ for (const mt of Object.keys(oldContent).sort()) {
3128
+ if (!(mt in newContent)) continue;
3129
+ const oldMt = isObject(oldContent[mt]) ? oldContent[mt] : {};
3130
+ const newMt = isObject(newContent[mt]) ? newContent[mt] : {};
3131
+ const oldSchemaRaw = isObject(oldMt.schema) ? oldMt.schema : void 0;
3132
+ const newSchemaRaw = isObject(newMt.schema) ? newMt.schema : void 0;
3133
+ if (oldSchemaRaw && newSchemaRaw) {
3134
+ const oldSchema = applyView(oldSchemaRaw, "response");
3135
+ const newSchema = applyView(newSchemaRaw, "response");
3136
+ diffSchemaNode(
3137
+ ctx,
3138
+ oldSchema,
3139
+ newSchema,
3140
+ pointerJoin(
3141
+ "paths",
3142
+ baseLoc.path,
3143
+ baseLoc.method.toLowerCase(),
3144
+ "responses",
3145
+ status,
3146
+ "content",
3147
+ mt,
3148
+ "schema"
3149
+ ),
3150
+ pointerJoin(
3151
+ "paths",
3152
+ baseLoc.path,
3153
+ baseLoc.method.toLowerCase(),
3154
+ "responses",
3155
+ status,
3156
+ "content",
3157
+ mt,
3158
+ "schema"
3159
+ ),
3160
+ {
3161
+ context: "response-body",
3162
+ path: baseLoc.path,
3163
+ method: baseLoc.method,
3164
+ statusCode: status,
3165
+ mediaType: mt
3166
+ },
3167
+ {
3168
+ requiredOld: stringArrayOf2(oldSchema.required),
3169
+ requiredNew: stringArrayOf2(newSchema.required),
3170
+ sideClass: "parse"
3171
+ }
3172
+ );
3173
+ }
3174
+ }
3175
+ }
3176
+ for (const status of Object.keys(newRes).sort()) {
3177
+ if (status in oldRes) continue;
3178
+ emit(ctx, {
3179
+ ruleId: "response-added",
3180
+ location: {
3181
+ ...baseLoc,
3182
+ statusCode: status,
3183
+ pointerOld: "",
3184
+ pointerNew: pointerJoin(
3185
+ "paths",
3186
+ baseLoc.path,
3187
+ baseLoc.method.toLowerCase(),
3188
+ "responses",
3189
+ status
3190
+ ),
3191
+ context: "response-body"
3192
+ },
3193
+ newValue: status
3194
+ });
3195
+ }
3196
+ }
3197
+ function diffWebhooks(ctx) {
3198
+ for (const name of ctx.baselineIndex.webhooks.keys()) {
3199
+ if (!ctx.currentIndex.webhooks.has(name)) {
3200
+ emit(ctx, {
3201
+ ruleId: "webhook-removed",
3202
+ location: {
3203
+ path: "",
3204
+ pointerOld: pointerJoin("webhooks", name),
3205
+ pointerNew: "",
3206
+ context: "webhook"
3207
+ },
3208
+ oldValue: name
3209
+ });
3210
+ }
3211
+ }
3212
+ for (const name of ctx.currentIndex.webhooks.keys()) {
3213
+ if (!ctx.baselineIndex.webhooks.has(name)) {
3214
+ emit(ctx, {
3215
+ ruleId: "webhook-added",
3216
+ location: {
3217
+ path: "",
3218
+ pointerOld: "",
3219
+ pointerNew: pointerJoin("webhooks", name),
3220
+ context: "webhook"
3221
+ },
3222
+ newValue: name
3223
+ });
3224
+ }
3225
+ }
3226
+ }
3227
+ function compact2(v) {
3228
+ if (v === void 0 || v === null) return v;
3229
+ if (typeof v !== "object") return v;
3230
+ const json = JSON.stringify(v);
3231
+ if (json === void 0 || json.length <= 80) return v;
3232
+ return `${json.slice(0, 77)}\u2026`;
3233
+ }
3234
+ function runStructuralDiff(ctx) {
3235
+ ctx.candidates = [];
3236
+ diffDocumentLevel(ctx);
3237
+ diffOperations(ctx);
3238
+ diffComponents(ctx);
3239
+ diffSecurity(ctx);
3240
+ return ctx.candidates;
3241
+ }
3242
+ function diffComponents(ctx) {
3243
+ const { baselineIndex: bi, currentIndex: ci } = ctx;
3244
+ const usedOld = usedSchemas(ctx.baseline.normalized);
3245
+ const usedNew = usedSchemas(ctx.current.normalized);
3246
+ for (const name of bi.schemas.keys()) {
3247
+ const oldSchema = bi.schemas.get(name);
3248
+ const newSchema = ci.schemas.get(name);
3249
+ if (!newSchema) continue;
3250
+ if (!oldSchema) continue;
3251
+ if (!usedOld.has(name) && !usedNew.has(name)) continue;
3252
+ const usages = usageClasses(ctx, name);
3253
+ const sideClass = usages.parse > 0 ? "parse" : "send";
3254
+ const usageCount = usages.send + usages.parse;
3255
+ const firstUsage = firstUsagePointer(ctx, name);
3256
+ diffComponentSchema(ctx, name, oldSchema, newSchema, sideClass, usageCount, firstUsage);
3257
+ }
3258
+ }
3259
+ function diffComponentSchema(ctx, name, oldSchema, newSchema, sideClass, usageCount, firstUsage) {
3260
+ const base = {
3261
+ context: "component",
3262
+ path: "",
3263
+ componentName: name
3264
+ };
3265
+ void firstUsage;
3266
+ const view = sideClass === "send" ? "request" : "response";
3267
+ const before = ctx.candidates.length;
3268
+ diffSchemaNode(
3269
+ ctx,
3270
+ applyView(oldSchema, view),
3271
+ applyView(newSchema, view),
3272
+ pointerJoin("components", "schemas", name),
3273
+ pointerJoin("components", "schemas", name),
3274
+ base,
3275
+ {
3276
+ requiredOld: stringArrayOf2(applyView(oldSchema, view).required),
3277
+ requiredNew: stringArrayOf2(applyView(newSchema, view).required),
3278
+ sideClass
3279
+ }
3280
+ );
3281
+ for (let i = before; i < ctx.candidates.length; i++) {
3282
+ const c = ctx.candidates[i];
3283
+ if (c !== void 0 && c.location.context === "component") {
3284
+ c.location.usageCount = usageCount;
3285
+ }
3286
+ }
3287
+ }
3288
+ function stringArrayOf2(v) {
3289
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
3290
+ }
3291
+ var VIEW_MAX_DEPTH = 512;
3292
+ function applyView(schema, view) {
3293
+ const pruneKey = view === "request" ? "readOnly" : "writeOnly";
3294
+ const visit = (node, depth) => {
3295
+ if (depth > VIEW_MAX_DEPTH) return node;
3296
+ const out = { ...node };
3297
+ const props = isObject(out.properties) ? out.properties : void 0;
3298
+ if (props) {
3299
+ const kept = {};
3300
+ for (const [name, child] of Object.entries(props)) {
3301
+ const childObj = isObject(child) ? child : void 0;
3302
+ if (childObj && childObj[pruneKey] === true) continue;
3303
+ kept[name] = childObj ? visit(childObj, depth + 1) : child;
3304
+ }
3305
+ out.properties = kept;
3306
+ }
3307
+ if (Array.isArray(out.required)) {
3308
+ out.required = out.required.filter((r) => {
3309
+ if (typeof r !== "string") return true;
3310
+ const prop = props?.[r];
3311
+ const propObj = isObject(prop) ? prop : void 0;
3312
+ return !(propObj && propObj[pruneKey] === true);
3313
+ });
3314
+ }
3315
+ for (const key of ["items", "additionalProperties"]) {
3316
+ const child = out[key];
3317
+ const childObj = isObject(child) ? child : void 0;
3318
+ if (childObj) out[key] = visit(childObj, depth + 1);
3319
+ }
3320
+ for (const key of ["allOf", "anyOf", "oneOf"]) {
3321
+ if (Array.isArray(out[key])) {
3322
+ out[key] = out[key].map((m) => {
3323
+ const mObj = isObject(m) ? m : void 0;
3324
+ return mObj ? visit(mObj, depth + 1) : m;
3325
+ });
3326
+ }
3327
+ }
3328
+ return out;
3329
+ };
3330
+ return visit(schema, 0);
3331
+ }
3332
+ function usedSchemas(document) {
3333
+ const used = /* @__PURE__ */ new Set();
3334
+ const stack = [document];
3335
+ const seen = /* @__PURE__ */ new Set();
3336
+ while (stack.length > 0) {
3337
+ const node = stack.pop();
3338
+ if (node === null || typeof node !== "object") continue;
3339
+ if (seen.has(node)) continue;
3340
+ seen.add(node);
3341
+ if (Array.isArray(node)) {
3342
+ for (const v of node) stack.push(v);
3343
+ continue;
3344
+ }
3345
+ const obj = node;
3346
+ for (const [k, v] of Object.entries(obj)) {
3347
+ if (k === "$ref" && typeof v === "string") {
3348
+ const m = v.match(/^#\/components\/schemas\/([^/]+)/);
3349
+ if (m && m[1]) used.add(m[1].replace(/~1/g, "/").replace(/~0/g, "~"));
3350
+ } else if (v !== null && typeof v === "object") {
3351
+ stack.push(v);
3352
+ }
3353
+ }
3354
+ }
3355
+ return used;
3356
+ }
3357
+ function usageClasses(ctx, name) {
3358
+ let send = 0;
3359
+ let parse = 0;
3360
+ const refPattern = new RegExp(`^#/components/schemas/${escapeRegExp(name)}$`);
3361
+ const classifyUse = (doc) => {
3362
+ const docObj = isObject(doc) ? doc : {};
3363
+ const pathsObj = isObject(docObj.paths) ? docObj.paths : {};
3364
+ for (const p of Object.keys(pathsObj).sort()) {
3365
+ const item = isObject(pathsObj[p]) ? pathsObj[p] : {};
3366
+ for (const method of Object.keys(item).sort()) {
3367
+ if (!isHttpMethod(method)) continue;
3368
+ const op = isObject(item[method]) ? item[method] : {};
3369
+ countIn(op.requestBody, "send");
3370
+ countIn(op.responses, "parse");
3371
+ countIn(op.parameters, "send");
3372
+ countIn(item.parameters, "send");
3373
+ }
3374
+ }
3375
+ const webhooksObj = isObject(docObj.webhooks) ? docObj.webhooks : {};
3376
+ for (const w of Object.keys(webhooksObj)) {
3377
+ countIn(webhooksObj[w], "parse");
3378
+ }
3379
+ };
3380
+ const countIn = (node, side) => {
3381
+ const stack = [node];
3382
+ while (stack.length > 0) {
3383
+ const n = stack.pop();
3384
+ if (n === null || typeof n !== "object") continue;
3385
+ if (Array.isArray(n)) {
3386
+ for (const v of n) stack.push(v);
3387
+ continue;
3388
+ }
3389
+ const obj = n;
3390
+ for (const [k, v] of Object.entries(obj)) {
3391
+ if (k === "$ref" && typeof v === "string" && refPattern.test(v)) {
3392
+ if (side === "send") send++;
3393
+ else parse++;
3394
+ } else if (v !== null && typeof v === "object") {
3395
+ stack.push(v);
3396
+ }
3397
+ }
3398
+ }
3399
+ };
3400
+ classifyUse(ctx.baseline.normalized);
3401
+ classifyUse(ctx.current.normalized);
3402
+ return { send, parse };
3403
+ }
3404
+ function firstUsagePointer(ctx, name) {
3405
+ const ref = `#/components/schemas/${name}`;
3406
+ const findIn = (doc) => {
3407
+ const docObj = isObject(doc) ? doc : {};
3408
+ const pathsObj = isObject(docObj.paths) ? docObj.paths : {};
3409
+ for (const p of Object.keys(pathsObj).sort()) {
3410
+ const item = isObject(pathsObj[p]) ? pathsObj[p] : {};
3411
+ const found = findRefIn(item, ref, pointerJoin("paths", p));
3412
+ if (found) return found;
3413
+ }
3414
+ return "";
3415
+ };
3416
+ return findIn(ctx.current.normalized) || findIn(ctx.baseline.normalized);
3417
+ }
3418
+ function findRefIn(node, ref, pointer) {
3419
+ const stack = [{ node, pointer }];
3420
+ while (stack.length > 0) {
3421
+ const { node: n, pointer: ptr } = stack.pop();
3422
+ if (n === null || typeof n !== "object") continue;
3423
+ if (Array.isArray(n)) {
3424
+ n.forEach((v, i) => stack.push({ node: v, pointer: `${ptr}/${i}` }));
3425
+ continue;
3426
+ }
3427
+ const obj = n;
3428
+ for (const [k, v] of Object.entries(obj)) {
3429
+ if (k === "$ref" && v === ref) return ptr;
3430
+ if (v !== null && typeof v === "object") {
3431
+ stack.push({ node: v, pointer: `${ptr}/${k}` });
3432
+ }
3433
+ }
3434
+ }
3435
+ return "";
3436
+ }
3437
+ function escapeRegExp(s) {
3438
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3439
+ }
3440
+ function diffSecurity(ctx) {
3441
+ const oldDoc = isObject(ctx.baseline.normalized) ? ctx.baseline.normalized : {};
3442
+ const newDoc = isObject(ctx.current.normalized) ? ctx.current.normalized : {};
3443
+ const oldComp = isObject(oldDoc.components) ? oldDoc.components : {};
3444
+ const newComp = isObject(newDoc.components) ? newDoc.components : {};
3445
+ const oldSchemes = isObject(oldComp.securitySchemes) ? oldComp.securitySchemes : {};
3446
+ const newSchemes = isObject(newComp.securitySchemes) ? newComp.securitySchemes : {};
3447
+ const oldGlobal = Array.isArray(oldDoc.security) ? oldDoc.security : [];
3448
+ const newGlobal = Array.isArray(newDoc.security) ? newDoc.security : [];
3449
+ const referencedOld = referencedSchemeNames(oldDoc);
3450
+ const referencedNew = referencedSchemeNames(newDoc);
3451
+ for (const name of Object.keys(oldSchemes).sort()) {
3452
+ const oldScheme = isObject(oldSchemes[name]) ? oldSchemes[name] : {};
3453
+ const newScheme = isObject(newSchemes[name]) ? newSchemes[name] : {};
3454
+ if (!(name in newSchemes)) {
3455
+ const wasReferenced = referencedOld.has(name);
3456
+ emit(ctx, {
3457
+ ruleId: "security-scheme-removed",
3458
+ location: {
3459
+ context: "security",
3460
+ path: "/",
3461
+ pointerOld: pointerJoin("components", "securitySchemes", name),
3462
+ pointerNew: ""
3463
+ },
3464
+ oldValue: name,
3465
+ severityOverride: wasReferenced ? "error" : "info"
3466
+ });
3467
+ continue;
3468
+ }
3469
+ if (!deepEqual(oldScheme.type, newScheme.type)) {
3470
+ emit(ctx, {
3471
+ ruleId: "security-type-changed",
3472
+ location: {
3473
+ context: "security",
3474
+ path: "/",
3475
+ pointerOld: pointerJoin("components", "securitySchemes", name, "type"),
3476
+ pointerNew: pointerJoin("components", "securitySchemes", name, "type")
3477
+ },
3478
+ oldValue: oldScheme.type,
3479
+ newValue: newScheme.type
3480
+ });
3481
+ }
3482
+ if (oldScheme.type === "apiKey" && newScheme.type === "apiKey" && !deepEqual(oldScheme.in, newScheme.in)) {
3483
+ emit(ctx, {
3484
+ ruleId: "api-key-location-changed",
3485
+ location: {
3486
+ context: "security",
3487
+ path: "/",
3488
+ pointerOld: pointerJoin("components", "securitySchemes", name, "in"),
3489
+ pointerNew: pointerJoin("components", "securitySchemes", name, "in")
3490
+ },
3491
+ oldValue: oldScheme.in,
3492
+ newValue: newScheme.in
3493
+ });
3494
+ }
3495
+ diffOauthScopes(ctx, name, oldScheme, newScheme, referencedOld, referencedNew);
3496
+ }
3497
+ for (const name of Object.keys(newSchemes).sort()) {
3498
+ if (name in oldSchemes) continue;
3499
+ emit(ctx, {
3500
+ ruleId: "security-scheme-added",
3501
+ location: {
3502
+ context: "security",
3503
+ path: "/",
3504
+ pointerOld: "",
3505
+ pointerNew: pointerJoin("components", "securitySchemes", name)
3506
+ },
3507
+ newValue: name
3508
+ });
3509
+ }
3510
+ diffSecurityRequirements(ctx, oldDoc, newDoc, oldGlobal, newGlobal, oldSchemes, newSchemes);
3511
+ }
3512
+ function diffOauthScopes(ctx, name, oldScheme, newScheme, referencedOld, referencedNew) {
3513
+ const oldFlows = isObject(oldScheme.flows) ? oldScheme.flows : {};
3514
+ const newFlows = isObject(newScheme.flows) ? newScheme.flows : {};
3515
+ for (const flow of Object.keys(oldFlows).sort()) {
3516
+ const oldFlow = isObject(oldFlows[flow]) ? oldFlows[flow] : {};
3517
+ const newFlow = isObject(newFlows[flow]) ? newFlows[flow] : {};
3518
+ const oldScopes = isObject(oldFlow.scopes) ? oldFlow.scopes : {};
3519
+ const newScopes = isObject(newFlow.scopes) ? newFlow.scopes : {};
3520
+ for (const scope of Object.keys(oldScopes).sort()) {
3521
+ if (scope in newScopes) continue;
3522
+ const stillUsed = referencedNew.has(name);
3523
+ emit(ctx, {
3524
+ ruleId: "oauth-scope-removed",
3525
+ location: {
3526
+ context: "security",
3527
+ path: "/",
3528
+ pointerOld: pointerJoin(
3529
+ "components",
3530
+ "securitySchemes",
3531
+ name,
3532
+ "flows",
3533
+ flow,
3534
+ "scopes",
3535
+ scope
3536
+ ),
3537
+ pointerNew: ""
3538
+ },
3539
+ oldValue: scope,
3540
+ severityOverride: stillUsed ? void 0 : "info"
3541
+ });
3542
+ }
3543
+ for (const scope of Object.keys(newScopes).sort()) {
3544
+ if (scope in oldScopes) continue;
3545
+ emit(ctx, {
3546
+ ruleId: "oauth-scope-added",
3547
+ location: {
3548
+ context: "security",
3549
+ path: "/",
3550
+ pointerOld: "",
3551
+ pointerNew: pointerJoin(
3552
+ "components",
3553
+ "securitySchemes",
3554
+ name,
3555
+ "flows",
3556
+ flow,
3557
+ "scopes",
3558
+ scope
3559
+ )
3560
+ },
3561
+ newValue: scope
3562
+ });
3563
+ }
3564
+ }
3565
+ void referencedOld;
3566
+ }
3567
+ function referencedSchemeNames(doc) {
3568
+ const names = /* @__PURE__ */ new Set();
3569
+ const collect = (reqs) => {
3570
+ if (!Array.isArray(reqs)) return;
3571
+ for (const req of reqs) {
3572
+ if (isObject(req)) for (const n of Object.keys(req)) names.add(n);
3573
+ }
3574
+ };
3575
+ collect(doc.security);
3576
+ const pathsObj = isObject(doc.paths) ? doc.paths : {};
3577
+ for (const p of Object.keys(pathsObj)) {
3578
+ const item = isObject(pathsObj[p]) ? pathsObj[p] : {};
3579
+ collect(item.security);
3580
+ for (const m of Object.keys(item)) {
3581
+ if (!isHttpMethod(m)) continue;
3582
+ const op = isObject(item[m]) ? item[m] : {};
3583
+ collect(op.security);
3584
+ }
3585
+ }
3586
+ return names;
3587
+ }
3588
+ function isNoSecurity(v) {
3589
+ return Array.isArray(v) && v.length === 1 && isObject(v[0]) && Object.keys(v[0]).length === 0;
3590
+ }
3591
+ function diffSecurityRequirements(ctx, oldDoc, newDoc, oldGlobal, newGlobal, oldSchemes, newSchemes) {
3592
+ void oldSchemes;
3593
+ void newSchemes;
3594
+ void oldDoc;
3595
+ void newDoc;
3596
+ const effectiveOld = (op, item) => op.security !== void 0 ? safeArray(op.security) : item.security !== void 0 ? safeArray(item.security) : oldGlobal;
3597
+ const effectiveNew = (op, item) => op.security !== void 0 ? safeArray(op.security) : item.security !== void 0 ? safeArray(item.security) : newGlobal;
3598
+ const walk = (doc, other, side) => {
3599
+ const pathsObj = isObject(doc.paths) ? doc.paths : {};
3600
+ const otherPaths = isObject(other.paths) ? other.paths : {};
3601
+ for (const p of Object.keys(pathsObj).sort()) {
3602
+ const item = isObject(pathsObj[p]) ? pathsObj[p] : {};
3603
+ const otherItem = isObject(otherPaths[p]) ? otherPaths[p] : {};
3604
+ for (const m of Object.keys(item).sort()) {
3605
+ if (!isHttpMethod(m)) continue;
3606
+ const op = isObject(item[m]) ? item[m] : {};
3607
+ const otherOp = isObject(otherItem[m]) ? otherItem[m] : {};
3608
+ const reqs = side === "old" ? effectiveOld(op, item) : effectiveNew(op, item);
3609
+ const otherReqs = side === "old" ? effectiveNew(otherOp, otherItem) : effectiveOld(otherOp, otherItem);
3610
+ if (side === "old") {
3611
+ compareSecurity(ctx, p, m, reqs, otherReqs);
3612
+ }
3613
+ }
3614
+ }
3615
+ };
3616
+ walk(ctx.baseline.normalized, ctx.current.normalized, "old");
3617
+ void newDoc;
3618
+ }
3619
+ function safeArray(v) {
3620
+ return Array.isArray(v) ? v : [];
3621
+ }
3622
+ function compareSecurity(ctx, path3, method, oldReqs, newReqs) {
3623
+ const oldEmpty = oldReqs.length === 0 || oldReqs.every(isNoSecurity);
3624
+ const newEmpty = newReqs.length === 0 || newReqs.every(isNoSecurity);
3625
+ if (!oldEmpty && newEmpty) {
3626
+ emit(ctx, {
3627
+ ruleId: "security-requirement-removed",
3628
+ location: {
3629
+ context: "security",
3630
+ path: path3,
3631
+ method,
3632
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "security"),
3633
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "security")
3634
+ }
3635
+ });
3636
+ return;
3637
+ }
3638
+ if (oldEmpty && !newEmpty) {
3639
+ emit(ctx, {
3640
+ ruleId: "security-requirement-added",
3641
+ location: {
3642
+ context: "security",
3643
+ path: path3,
3644
+ method,
3645
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "security"),
3646
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "security")
3647
+ }
3648
+ });
3649
+ return;
3650
+ }
3651
+ if (oldEmpty && newEmpty) return;
3652
+ const oldSatisfied = oldReqs.every((oldReq) => satisfiesAny(oldReq, newReqs));
3653
+ if (!oldSatisfied) {
3654
+ emit(ctx, {
3655
+ ruleId: "security-requirement-changed",
3656
+ location: {
3657
+ context: "security",
3658
+ path: path3,
3659
+ method,
3660
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "security"),
3661
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "security")
3662
+ },
3663
+ oldValue: compact2(oldReqs),
3664
+ newValue: compact2(newReqs)
3665
+ });
3666
+ } else {
3667
+ for (const oldReq of oldReqs) {
3668
+ if (!isObject(oldReq)) continue;
3669
+ for (const [scheme, scopes] of Object.entries(oldReq)) {
3670
+ const scopeList = Array.isArray(scopes) ? scopes : [];
3671
+ for (const newReq of newReqs) {
3672
+ if (!isObject(newReq)) continue;
3673
+ if (!(scheme in newReq)) continue;
3674
+ const newScopes = Array.isArray(newReq[scheme]) ? newReq[scheme] : [];
3675
+ for (const s of scopeList) {
3676
+ if (!newScopes.includes(s)) {
3677
+ emit(ctx, {
3678
+ ruleId: "security-scope-required-removed",
3679
+ location: {
3680
+ context: "security",
3681
+ path: path3,
3682
+ method,
3683
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "security"),
3684
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "security")
3685
+ },
3686
+ oldValue: s
3687
+ });
3688
+ }
3689
+ }
3690
+ for (const s of newScopes) {
3691
+ if (!scopeList.includes(s)) {
3692
+ emit(ctx, {
3693
+ ruleId: "security-scope-required-added",
3694
+ location: {
3695
+ context: "security",
3696
+ path: path3,
3697
+ method,
3698
+ pointerOld: pointerJoin("paths", path3, method.toLowerCase(), "security"),
3699
+ pointerNew: pointerJoin("paths", path3, method.toLowerCase(), "security")
3700
+ },
3701
+ newValue: s
3702
+ });
3703
+ }
3704
+ }
3705
+ }
3706
+ }
3707
+ }
3708
+ }
3709
+ }
3710
+ function satisfiesAny(oldReq, newReqs) {
3711
+ if (!isObject(oldReq)) return true;
3712
+ return newReqs.some((newReq) => {
3713
+ if (!isObject(newReq)) return false;
3714
+ for (const [scheme, scopes] of Object.entries(oldReq)) {
3715
+ if (!(scheme in newReq)) continue;
3716
+ const oldScopes = Array.isArray(scopes) ? scopes : [];
3717
+ const newScopes = Array.isArray(newReq[scheme]) ? newReq[scheme] : [];
3718
+ if (newScopes.every((s) => oldScopes.includes(s))) return true;
3719
+ }
3720
+ return false;
3721
+ });
3722
+ }
3723
+
3724
+ // src/core/ignore/ignore.ts
3725
+ import micromatch from "micromatch";
3726
+ function changePath(change) {
3727
+ return change.location.path ?? "";
3728
+ }
3729
+ function isIgnored(change, config) {
3730
+ const ignore = config.ignore;
3731
+ if (ignore.changes.length > 0) {
3732
+ if (micromatch.isMatch(change.ruleId, ignore.changes, { nocase: false })) return true;
3733
+ }
3734
+ const path3 = changePath(change);
3735
+ if (ignore.schemas.length > 0) {
3736
+ const name = change.location.componentName;
3737
+ if (name && micromatch.isMatch(name, ignore.schemas)) return true;
3738
+ const pointer = (change.location.pointerNew || change.location.pointerOld || "").replace(
3739
+ /^#\//,
3740
+ ""
3741
+ );
3742
+ const SCHEMAS_SEG = "components/schemas/";
3743
+ if (pointer && (pointer.includes(`/${SCHEMAS_SEG}`) || pointer.startsWith(SCHEMAS_SEG))) {
3744
+ const after = pointer.split(SCHEMAS_SEG)[1] ?? "";
3745
+ const rootName = after.split("/")[0]?.replace(/~1/g, "/");
3746
+ if (rootName) {
3747
+ if (micromatch.isMatch(rootName, ignore.schemas)) return true;
3748
+ const lastSegments = ignore.schemas.map((s) => s.split("/").pop() ?? s);
3749
+ if (micromatch.isMatch(rootName, lastSegments)) return true;
3750
+ const prefixGlobs = ignore.schemas.map((s) => `components/schemas/${s}**`);
3751
+ if (micromatch.isMatch(pointer, prefixGlobs)) return true;
3752
+ }
3753
+ }
3754
+ }
3755
+ if (ignore.operations.length > 0 && change.location.method !== void 0) {
3756
+ const opKey = `${change.location.method.toUpperCase()} ${path3}`;
3757
+ if (ignore.operations.includes(opKey)) return true;
3758
+ }
3759
+ if (ignore.paths.length > 0 && path3 && path3 !== "") {
3760
+ if (micromatch.isMatch(path3, ignore.paths)) return true;
3761
+ }
3762
+ return false;
3763
+ }
3764
+ function applyIgnores(changes, config) {
3765
+ const kept = [];
3766
+ const ignored = [];
3767
+ for (const c of changes) {
3768
+ if (isIgnored(c, config)) ignored.push(c);
3769
+ else kept.push(c);
3770
+ }
3771
+ return { kept, ignored };
3772
+ }
3773
+
3774
+ // src/core/engine/analyze.ts
3775
+ function summarize(changes, ignoredCount) {
3776
+ const summary = {
3777
+ total: changes.length,
3778
+ bySeverity: { error: 0, warning: 0, info: 0 },
3779
+ byKind: { addition: 0, removal: 0, modification: 0, relaxation: 0, documentation: 0 },
3780
+ ignored: ignoredCount
3781
+ };
3782
+ for (const c of changes) {
3783
+ summary.bySeverity[c.severity]++;
3784
+ summary.byKind[c.kind]++;
3785
+ }
3786
+ return summary;
3787
+ }
3788
+ function metaOf(spec) {
3789
+ return {
3790
+ source: spec.source,
3791
+ openapiVersion: spec.openapiVersion,
3792
+ title: spec.title,
3793
+ sha256: spec.sha256
3794
+ };
3795
+ }
3796
+ async function analyze(baselinePath, currentPath, options = {}) {
3797
+ const loaded = options.config ? { config: options.config, sourcePath: "", warnings: [] } : discoverAndLoadConfig({ explicit: options.configPath });
3798
+ const config = loaded.config;
3799
+ const [baseline, current] = await Promise.all([
3800
+ loadSpec(baselinePath, { loader: { allowRemoteRefs: config.loader.allowRemoteRefs } }),
3801
+ loadSpec(currentPath, { loader: { allowRemoteRefs: config.loader.allowRemoteRefs } })
3802
+ ]);
3803
+ structuralDeref(baseline.normalized);
3804
+ structuralDeref(current.normalized);
3805
+ const ctx = {
3806
+ baseline,
3807
+ current,
3808
+ baselineIndex: buildSpecIndex(baseline.normalized),
3809
+ currentIndex: buildSpecIndex(current.normalized),
3810
+ config,
3811
+ candidates: []
3812
+ };
3813
+ runStructuralDiff(ctx);
3814
+ const deduped = dedupeChanges(sortChanges([...ctx.candidates]));
3815
+ const { kept, ignored } = applyIgnores(deduped, config);
3816
+ const summary = summarize(kept, ignored.length);
3817
+ const semver = suggestVersion(kept);
3818
+ const report = {
3819
+ schemaVersion: "1.0",
3820
+ tool: { name: "apiguard", version: TOOL_VERSION },
3821
+ baseline: metaOf(baseline),
3822
+ current: metaOf(current),
3823
+ summary,
3824
+ changes: kept,
3825
+ semver
3826
+ };
3827
+ if (options.showIgnored && ignored.length > 0) {
3828
+ report.ignored = ignored.map((c) => ({ ...c, ignored: true }));
3829
+ }
3830
+ return { report, ignored };
3831
+ }
3832
+
3833
+ export {
3834
+ ExitCode,
3835
+ ApiguardError,
3836
+ CliUsageError,
3837
+ SpecValidationError,
3838
+ errorMessage,
3839
+ loadConfigFrom,
3840
+ discoverAndLoadConfig,
3841
+ mergeWithFlags,
3842
+ loadSpec,
3843
+ TOOL_VERSION,
3844
+ validateSemantics,
3845
+ formatIssues,
3846
+ suggestVersion,
3847
+ analyze
3848
+ };