delimit-cli 4.7.3 → 4.7.4

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.
@@ -30,11 +30,6 @@ class ChangeType(Enum):
30
30
  # is BREAKING — consumers can no longer rely on the field always being
31
31
  # present. In a REQUEST it is non-breaking (the server relaxes what it
32
32
  # demands). Direction is resolved by Change.context, not by the type alone.
33
- # NOTE (LOUD): this is the ONE new ChangeType added for LED-1600. Prior
34
- # canon pinned the enum at 27; it is now 28. No existing value was renamed
35
- # or removed (the four-corner add/remove/required-add cases were already
36
- # covered); required->optional simply had NO representation before, which
37
- # is exactly the silent-leak this LED closes.
38
33
  FIELD_REQUIREMENT_RELAXED = "field_requirement_relaxed"
39
34
 
40
35
  # Non-breaking changes
@@ -78,15 +73,7 @@ class Change:
78
73
  details: Dict[str, Any]
79
74
  severity: str # high, medium, low
80
75
  message: str
81
- # LED-1600: request/response context. The breaking-ness of a FIELD change
82
- # flips with direction (see is_breaking). Values: "request", "response",
83
- # or None when the engine cannot determine direction (e.g. a bare
84
- # component-schema comparison, or a hand-constructed Change). When None,
85
- # is_breaking falls back to the conservative, direction-agnostic verdict
86
- # that the engine has always produced — so existing callers and stored
87
- # Change objects are unaffected. This field is ADDITIVE (defaulted), so the
88
- # public construction signature and the delimit_diff return shape are
89
- # unchanged.
76
+ # LED-1600: request/response context.
90
77
  context: Optional[str] = None
91
78
 
92
79
  @property
@@ -96,26 +83,12 @@ class Change:
96
83
  if ct in _ALWAYS_BREAKING:
97
84
  return True
98
85
 
99
- # ── LED-1600: context-aware classification ──────────────────────
100
- # REQUIRED_FIELD_ADDED:
101
- # REQUEST -> breaking (clients must now send it)
102
- # RESPONSE -> non-breaking (server returns MORE; consumers ignore it)
103
- # unknown -> breaking (conservative: never silently downgrade)
104
86
  if ct == ChangeType.REQUIRED_FIELD_ADDED:
105
87
  return self.context != "response"
106
88
 
107
- # FIELD_REMOVED:
108
- # RESPONSE -> breaking (consumers lose the field)
109
- # REQUEST -> non-breaking (server stops requiring/accepting it)
110
- # unknown -> breaking (conservative; covers component schemas, which
111
- # may back a response, and matches pre-LED-1600 behavior)
112
89
  if ct == ChangeType.FIELD_REMOVED:
113
90
  return self.context != "request"
114
91
 
115
- # FIELD_REQUIREMENT_RELAXED (required -> optional):
116
- # RESPONSE -> breaking (consumers can no longer rely on its presence)
117
- # REQUEST -> non-breaking (server demands less)
118
- # unknown -> breaking (conservative)
119
92
  if ct == ChangeType.FIELD_REQUIREMENT_RELAXED:
120
93
  return self.context != "request"
121
94
 
@@ -126,13 +99,13 @@ class OpenAPIDiffEngine:
126
99
 
127
100
  def __init__(self):
128
101
  self.changes: List[Change] = []
129
- # LED-1588: fail-open skips (unresolvable refs, malformed nodes) are
130
- # surfaced here as a structured side-channel so a clean `changes` list
131
- # is not mistaken for "proven safe". Advisories are NOT Change objects
132
- # and are never appended to self.changes.
102
+ # LED-1588: fail-open skips (unresolvable refs, malformed nodes)
133
103
  self.advisories: List[Dict[str, Any]] = []
134
- self._old_spec: Dict = {}
135
- self._new_spec: Dict = {}
104
+ # Roots for resolving local $ref pointers; populated per compare().
105
+ self._old_root: Dict = {}
106
+ self._new_root: Dict = {}
107
+ # (old_ref, new_ref) pairs on the current descent path — cycle guard.
108
+ self._ref_stack: Set[tuple] = set()
136
109
 
137
110
  def _add_advisory(self, kind: str, path: str, detail: str) -> None:
138
111
  """Record a fail-open skip. Dedupes identical (kind, path, detail)."""
@@ -146,10 +119,19 @@ class OpenAPIDiffEngine:
146
119
  self.advisories = []
147
120
  old_spec = old_spec or {}
148
121
  new_spec = new_spec or {}
149
- # Store full specs so $ref pointers (#/components/schemas/*) can be
150
- # resolved during deep schema comparison.
151
- self._old_spec = old_spec
152
- self._new_spec = new_spec
122
+
123
+ self._old_root = old_spec
124
+ self._new_root = new_spec
125
+ self._ref_stack = set()
126
+
127
+ # Honesty advisory (LED-1588): Swagger 2.0 detection.
128
+ if "swagger" in old_spec or "swagger" in new_spec:
129
+ self._add_advisory(
130
+ "partial_spec_support", "(spec)",
131
+ "Swagger 2.0 detected: top-level definitions are compared, but "
132
+ "v2-style inline path/response/body schemas (responses[].schema, "
133
+ "in:body parameters) are not yet deep-compared",
134
+ )
153
135
 
154
136
  # Compare paths
155
137
  self._compare_paths(old_spec.get("paths", {}), new_spec.get("paths", {}))
@@ -162,55 +144,31 @@ class OpenAPIDiffEngine:
162
144
  _new_components.get("schemas", {}) if isinstance(_new_components, dict) else {},
163
145
  )
164
146
 
165
- # Compare top-level definitions (Swagger 2.0). v2 stores schemas here,
166
- # not under components/schemas, so without this a breaking change
167
- # inside a v2 definition — and behind a #/definitions/X ref — is missed
168
- # entirely (the ref's same-target rule defers to this comparison).
147
+ # Compare top-level definitions (Swagger 2.0)
169
148
  self._compare_schemas(
170
149
  old_spec.get("definitions", {}),
171
150
  new_spec.get("definitions", {}),
172
151
  path_prefix="#/definitions",
173
152
  )
174
153
 
175
- # Honesty advisory (LED-1588 channel): the path/operation comparison is
176
- # OpenAPI-3.x-shaped (responses[].content, requestBody). A Swagger 2.0
177
- # spec's definitions are now compared, but v2-style inline schemas
178
- # (responses[].schema, in:body parameters) are not yet deep-compared —
179
- # flag it so a clean diff isn't mistaken for full v2 coverage.
180
- if "swagger" in old_spec or "swagger" in new_spec:
181
- self._add_advisory(
182
- "partial_spec_support", "(spec)",
183
- "Swagger 2.0 detected: top-level definitions are compared, but "
184
- "v2-style inline path/response/body schemas (responses[].schema, "
185
- "in:body parameters) are not yet deep-compared",
186
- )
187
-
188
154
  # Compare security schemes
189
155
  self._compare_security(
190
- old_spec.get("components", {}).get("securitySchemes", {}),
191
- new_spec.get("components", {}).get("securitySchemes", {})
156
+ old_spec.get("components", {}).get("securitySchemes", {}) if isinstance(old_spec.get("components"), dict) else {},
157
+ new_spec.get("components", {}).get("schemas", {}) if False else # Dummy to match gateway structure
158
+ new_spec.get("components", {}).get("securitySchemes", {}) if isinstance(new_spec.get("components"), dict) else {}
192
159
  )
193
160
 
194
161
  return self.changes
195
162
 
196
163
  def _compare_paths(self, old_paths: Dict, new_paths: Dict):
197
164
  """Compare API paths/endpoints."""
198
- # Defend against malformed specs where `paths` is a list rather
199
- # than the spec-required dict (Map[string, PathItem]). Same family
200
- # as the Kong-class properties-as-list fix; treat as empty rather
201
- # than crashing on `.keys()`.
202
165
  if not isinstance(old_paths, dict):
203
- self._add_advisory(
204
- "malformed_node", "paths",
205
- f"old spec `paths` is not a dict (got {type(old_paths).__name__}); skipped",
206
- )
166
+ self._add_advisory("malformed_node", "paths", f"old spec `paths` is not a dict (got {type(old_paths).__name__}); skipped")
207
167
  old_paths = {}
208
168
  if not isinstance(new_paths, dict):
209
- self._add_advisory(
210
- "malformed_node", "paths",
211
- f"new spec `paths` is not a dict (got {type(new_paths).__name__}); skipped",
212
- )
169
+ self._add_advisory("malformed_node", "paths", f"new spec `paths` is not a dict (got {type(new_paths).__name__}); skipped")
213
170
  new_paths = {}
171
+
214
172
  old_set = set(old_paths.keys())
215
173
  new_set = set(new_paths.keys())
216
174
 
@@ -223,7 +181,7 @@ class OpenAPIDiffEngine:
223
181
  severity="high",
224
182
  message=f"Endpoint removed: {path}"
225
183
  ))
226
-
184
+
227
185
  # Check added endpoints
228
186
  for path in new_set - old_set:
229
187
  self.changes.append(Change(
@@ -233,61 +191,61 @@ class OpenAPIDiffEngine:
233
191
  severity="low",
234
192
  message=f"New endpoint added: {path}"
235
193
  ))
236
-
237
- # Check modified endpoints
194
+
195
+ # Compare existing endpoints
238
196
  for path in old_set & new_set:
239
197
  self._compare_methods(path, old_paths[path], new_paths[path])
240
-
241
- # LED-290: include "trace" (OpenAPI 3.0+) and "query" (OpenAPI 3.2.0
242
- # adds the QUERY HTTP method for safe, idempotent requests with bodies).
198
+
243
199
  HTTP_METHODS = ("get", "post", "put", "delete", "patch", "head", "options", "trace", "query")
244
200
 
245
201
  def _compare_methods(self, path: str, old_methods: Dict, new_methods: Dict):
246
202
  """Compare HTTP methods for an endpoint."""
247
- # Same defensive pattern as _compare_paths — methods at a path
248
- # MUST be a dict per spec, but malformed inputs see real-world.
249
203
  if not isinstance(old_methods, dict):
250
- self._add_advisory(
251
- "malformed_node", path,
252
- f"old path-item methods at {path} is not a dict "
253
- f"(got {type(old_methods).__name__}); skipped",
254
- )
204
+ self._add_advisory("malformed_node", path, f"old path-item methods at {path} is not a dict (got {type(old_methods).__name__}); skipped")
255
205
  old_methods = {}
256
206
  if not isinstance(new_methods, dict):
257
- self._add_advisory(
258
- "malformed_node", path,
259
- f"new path-item methods at {path} is not a dict "
260
- f"(got {type(new_methods).__name__}); skipped",
261
- )
207
+ self._add_advisory("malformed_node", path, f"new path-item methods at {path} is not a dict (got {type(new_methods).__name__}); skipped")
262
208
  new_methods = {}
263
- old_set = set(m for m in old_methods.keys() if m in self.HTTP_METHODS)
264
- new_set = set(m for m in new_methods.keys() if m in self.HTTP_METHODS)
209
+
210
+ old_set = set(m for m in old_methods.keys() if m.lower() in self.HTTP_METHODS)
211
+ new_set = set(m for m in new_methods.keys() if m.lower() in self.HTTP_METHODS)
265
212
 
266
213
  # Check removed methods
267
214
  for method in old_set - new_set:
268
215
  self.changes.append(Change(
269
216
  type=ChangeType.METHOD_REMOVED,
270
217
  path=f"{path}:{method.upper()}",
271
- details={"endpoint": path, "method": method.upper()},
218
+ details={"method": method.upper(), "endpoint": path},
272
219
  severity="high",
273
- message=f"Method removed: {method.upper()} {path}"
220
+ message=f"Method {method.upper()} removed from {path}"
274
221
  ))
275
222
 
276
- # Check modified methods
223
+ # Check added methods (non-breaking)
224
+ for method in new_set - old_set:
225
+ self.changes.append(Change(
226
+ type=ChangeType.METHOD_ADDED,
227
+ path=f"{path}:{method.upper()}",
228
+ details={"method": method.upper(), "endpoint": path},
229
+ severity="low",
230
+ message=f"Method {method.upper()} added to {path}"
231
+ ))
232
+
233
+ # Compare existing methods
277
234
  for method in old_set & new_set:
278
- self._compare_operation(
279
- f"{path}:{method.upper()}",
280
- old_methods[method],
281
- new_methods[method]
282
- )
283
-
235
+ self._compare_operation(f"{path}:{method.upper()}", old_methods[method], new_methods[method])
236
+
284
237
  def _compare_operation(self, operation_id: str, old_op: Dict, new_op: Dict):
285
- """Compare operation details (parameters, responses, etc.)."""
286
-
287
- # Compare parameters skip unresolved $ref entries (common in Swagger 2.0)
288
- # which lack inline name/in fields and would crash downstream accessors.
289
- old_params = {self._param_key(p): p for p in old_op.get("parameters", []) if "name" in p}
290
- new_params = {self._param_key(p): p for p in new_op.get("parameters", []) if "name" in p}
238
+ """Compare specific operations."""
239
+ if not isinstance(old_op, dict):
240
+ self._add_advisory("malformed_node", operation_id, "old operation is not a dict")
241
+ return
242
+ if not isinstance(new_op, dict):
243
+ self._add_advisory("malformed_node", operation_id, "new operation is not a dict")
244
+ return
245
+
246
+ # Compare parameters
247
+ old_params = {self._param_key(p): p for p in old_op.get("parameters", []) if isinstance(p, dict) and "name" in p}
248
+ new_params = {self._param_key(p): p for p in new_op.get("parameters", []) if isinstance(p, dict) and "name" in p}
291
249
 
292
250
  # Check removed parameters
293
251
  for param_key in set(old_params.keys()) - set(new_params.keys()):
@@ -295,21 +253,21 @@ class OpenAPIDiffEngine:
295
253
  self.changes.append(Change(
296
254
  type=ChangeType.PARAM_REMOVED,
297
255
  path=operation_id,
298
- details={"parameter": param["name"], "in": param["in"]},
256
+ details={"parameter": param.get("name"), "in": param.get("in")},
299
257
  severity="high",
300
- message=f"Parameter removed: {param['name']} from {operation_id}"
258
+ message=f"Parameter removed: {param.get('name')} from {operation_id}"
301
259
  ))
302
-
303
- # Check added required parameters
260
+
261
+ # Check added required parameters (breaking)
304
262
  for param_key in set(new_params.keys()) - set(old_params.keys()):
305
263
  param = new_params[param_key]
306
264
  if param.get("required", False):
307
265
  self.changes.append(Change(
308
266
  type=ChangeType.REQUIRED_PARAM_ADDED,
309
267
  path=operation_id,
310
- details={"parameter": param["name"], "in": param["in"]},
268
+ details={"parameter": param.get("name"), "in": param.get("in")},
311
269
  severity="high",
312
- message=f"Required parameter added: {param['name']} to {operation_id}"
270
+ message=f"Required parameter added: {param.get('name')} to {operation_id}"
313
271
  ))
314
272
 
315
273
  # Check added optional parameters (non-breaking)
@@ -319,9 +277,9 @@ class OpenAPIDiffEngine:
319
277
  self.changes.append(Change(
320
278
  type=ChangeType.OPTIONAL_PARAM_ADDED,
321
279
  path=operation_id,
322
- details={"parameter": param["name"], "in": param["in"]},
280
+ details={"parameter": param.get("name"), "in": param.get("in")},
323
281
  severity="low",
324
- message=f"Optional parameter added: {param['name']} to {operation_id}"
282
+ message=f"Optional parameter added: {param.get('name')} to {operation_id}"
325
283
  ))
326
284
 
327
285
  # Check parameter schema changes
@@ -341,9 +299,7 @@ class OpenAPIDiffEngine:
341
299
  )
342
300
 
343
301
  # Check deprecated flag
344
- old_deprecated = old_op.get("deprecated", False)
345
- new_deprecated = new_op.get("deprecated", False)
346
- if not old_deprecated and new_deprecated:
302
+ if not old_op.get("deprecated", False) and new_op.get("deprecated", False):
347
303
  self.changes.append(Change(
348
304
  type=ChangeType.DEPRECATED_ADDED,
349
305
  path=operation_id,
@@ -371,33 +327,8 @@ class OpenAPIDiffEngine:
371
327
  """Compare parameter schemas for type changes, required changes, and constraints."""
372
328
  old_schema = old_param.get("schema", {})
373
329
  new_schema = new_param.get("schema", {})
374
- param_name = old_param.get("name", old_param.get("$ref", "unknown"))
375
-
376
- old_ref = old_schema.get("$ref") if isinstance(old_schema, dict) else None
377
- new_ref = new_schema.get("$ref") if isinstance(new_schema, dict) else None
378
-
379
- if old_ref is not None and new_ref is not None and old_ref == new_ref:
380
- # Both sides reference the same component schema; it is deep-compared
381
- # once in _compare_schemas. Don't re-emit its internal changes here.
382
- return
330
+ param_name = old_param.get("name", "unknown")
383
331
 
384
- # Resolve local $refs so a ref-vs-inline (or differing-ref) parameter
385
- # schema change is detected. Unresolvable refs fall through unchanged
386
- # and simply won't match — never crash, never fabricate.
387
- if old_ref is not None:
388
- resolved = self._resolve_schema(old_schema, self._old_spec)
389
- if isinstance(resolved, dict) and "$ref" not in resolved:
390
- old_schema = resolved
391
- else:
392
- self._advise_unverifiable_ref(operation_id, old_ref, self._old_spec)
393
- if new_ref is not None:
394
- resolved = self._resolve_schema(new_schema, self._new_spec)
395
- if isinstance(resolved, dict) and "$ref" not in resolved:
396
- new_schema = resolved
397
- else:
398
- self._advise_unverifiable_ref(operation_id, new_ref, self._new_spec)
399
-
400
- # Check type changes — emit both PARAM_TYPE_CHANGED (specific) and TYPE_CHANGED (legacy)
401
332
  if old_schema.get("type") != new_schema.get("type"):
402
333
  self.changes.append(Change(
403
334
  type=ChangeType.PARAM_TYPE_CHANGED,
@@ -410,6 +341,7 @@ class OpenAPIDiffEngine:
410
341
  severity="high",
411
342
  message=f"Parameter type changed: {param_name} from {old_schema.get('type')} to {new_schema.get('type')} in {operation_id}"
412
343
  ))
344
+ # Legacy ChangeType for back-compat
413
345
  self.changes.append(Change(
414
346
  type=ChangeType.TYPE_CHANGED,
415
347
  path=operation_id,
@@ -422,7 +354,6 @@ class OpenAPIDiffEngine:
422
354
  message=f"Parameter type changed: {param_name} from {old_schema.get('type')} to {new_schema.get('type')}"
423
355
  ))
424
356
 
425
- # Check required changed (optional -> required)
426
357
  old_required = old_param.get("required", False)
427
358
  new_required = new_param.get("required", False)
428
359
  if not old_required and new_required:
@@ -434,7 +365,6 @@ class OpenAPIDiffEngine:
434
365
  message=f"Parameter changed from optional to required: {param_name} in {operation_id}"
435
366
  ))
436
367
 
437
- # Check constraint changes
438
368
  self._compare_constraints(f"{operation_id}:{param_name}", old_schema, new_schema)
439
369
 
440
370
  # Check default value changes
@@ -453,11 +383,11 @@ class OpenAPIDiffEngine:
453
383
  # Check enum changes
454
384
  if "enum" in old_schema or "enum" in new_schema:
455
385
  self._compare_enums(
456
- f"{operation_id}:{old_param['name']}",
386
+ f"{operation_id}:{param_name}",
457
387
  old_schema.get("enum", []),
458
388
  new_schema.get("enum", [])
459
389
  )
460
-
390
+
461
391
  def _compare_request_body(self, operation_id: str, old_body: Optional[Dict], new_body: Optional[Dict]):
462
392
  """Compare request body schemas."""
463
393
  if old_body and not new_body:
@@ -477,21 +407,8 @@ class OpenAPIDiffEngine:
477
407
  message=f"Required request body added to {operation_id}"
478
408
  ))
479
409
  elif old_body and new_body:
480
- # Compare content types
481
410
  raw_old_content = old_body.get("content", {})
482
411
  raw_new_content = new_body.get("content", {})
483
- if not isinstance(raw_old_content, dict):
484
- self._add_advisory(
485
- "malformed_node", f"{operation_id}:request",
486
- f"old requestBody `content` at {operation_id} is not a dict "
487
- f"(got {type(raw_old_content).__name__}); skipped",
488
- )
489
- if not isinstance(raw_new_content, dict):
490
- self._add_advisory(
491
- "malformed_node", f"{operation_id}:request",
492
- f"new requestBody `content` at {operation_id} is not a dict "
493
- f"(got {type(raw_new_content).__name__}); skipped",
494
- )
495
412
  old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
496
413
  new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
497
414
 
@@ -502,30 +419,20 @@ class OpenAPIDiffEngine:
502
419
  new_content[content_type].get("schema", {}),
503
420
  context="request",
504
421
  )
505
-
422
+
506
423
  def _compare_responses(self, operation_id: str, old_responses: Dict, new_responses: Dict):
507
424
  """Compare response definitions."""
508
- # Defend against malformed specs where `responses` is a list.
509
425
  if not isinstance(old_responses, dict):
510
- self._add_advisory(
511
- "malformed_node", operation_id,
512
- f"old `responses` at {operation_id} is not a dict "
513
- f"(got {type(old_responses).__name__}); skipped",
514
- )
426
+ self._add_advisory("malformed_node", operation_id, "old 'responses' is not a dict")
515
427
  old_responses = {}
516
428
  if not isinstance(new_responses, dict):
517
- self._add_advisory(
518
- "malformed_node", operation_id,
519
- f"new `responses` at {operation_id} is not a dict "
520
- f"(got {type(new_responses).__name__}); skipped",
521
- )
429
+ self._add_advisory("malformed_node", operation_id, "new 'responses' is not a dict")
522
430
  new_responses = {}
431
+
523
432
  old_codes = set(old_responses.keys())
524
433
  new_codes = set(new_responses.keys())
525
434
 
526
- # Check removed responses
527
435
  for code in old_codes - new_codes:
528
- # Only flag 2xx responses as breaking
529
436
  if code.startswith("2"):
530
437
  self.changes.append(Change(
531
438
  type=ChangeType.RESPONSE_REMOVED,
@@ -535,45 +442,23 @@ class OpenAPIDiffEngine:
535
442
  message=f"Success response {code} removed from {operation_id}"
536
443
  ))
537
444
 
538
- # Compare response schemas
539
445
  for code in old_codes & new_codes:
540
446
  old_resp = old_responses[code]
541
447
  new_resp = new_responses[code]
542
448
 
543
- # A response item must itself be a dict (Response Object).
544
449
  if not isinstance(old_resp, dict):
545
- self._add_advisory(
546
- "malformed_node", f"{operation_id}:{code}",
547
- f"old response item {code} at {operation_id} is not a dict "
548
- f"(got {type(old_resp).__name__}); skipped",
549
- )
550
- old_resp = {}
450
+ self._add_advisory("malformed_node", f"{operation_id}:{code}", f"old response {code} is {type(old_resp).__name__}")
451
+ continue
551
452
  if not isinstance(new_resp, dict):
552
- self._add_advisory(
553
- "malformed_node", f"{operation_id}:{code}",
554
- f"new response item {code} at {operation_id} is not a dict "
555
- f"(got {type(new_resp).__name__}); skipped",
556
- )
557
- new_resp = {}
453
+ self._add_advisory("malformed_node", f"{operation_id}:{code}", f"new response {code} is {type(new_resp).__name__}")
454
+ continue
558
455
 
559
- if "content" in old_resp or "content" in new_resp:
560
- raw_old_content = old_resp.get("content", {})
561
- raw_new_content = new_resp.get("content", {})
562
- if not isinstance(raw_old_content, dict):
563
- self._add_advisory(
564
- "malformed_node", f"{operation_id}:{code}",
565
- f"old response {code} `content` at {operation_id} is not a dict "
566
- f"(got {type(raw_old_content).__name__}); skipped",
567
- )
568
- if not isinstance(raw_new_content, dict):
569
- self._add_advisory(
570
- "malformed_node", f"{operation_id}:{code}",
571
- f"new response {code} `content` at {operation_id} is not a dict "
572
- f"(got {type(raw_new_content).__name__}); skipped",
573
- )
574
- old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
575
- new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
456
+ raw_old_content = old_resp.get("content", {})
457
+ raw_new_content = new_resp.get("content", {})
458
+ old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
459
+ new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
576
460
 
461
+ if old_content or new_content:
577
462
  for content_type in old_content.keys() & new_content.keys():
578
463
  self._compare_schema_deep(
579
464
  f"{operation_id}:{code}",
@@ -581,163 +466,90 @@ class OpenAPIDiffEngine:
581
466
  new_content[content_type].get("schema", {}),
582
467
  context="response",
583
468
  )
469
+ elif "schema" in old_resp or "schema" in new_resp:
470
+ # Swagger 2.0 style inline schema
471
+ self._compare_schema_deep(
472
+ f"{operation_id}:{code}",
473
+ old_resp.get("schema", {}),
474
+ new_resp.get("schema", {}),
475
+ context="response",
476
+ )
584
477
 
585
- @staticmethod
586
- def _unescape_json_pointer_token(token: str) -> str:
587
- """Decode a JSON Pointer reference token (~1 -> /, ~0 -> ~)."""
588
- return token.replace("~1", "/").replace("~0", "~")
589
-
590
- def _resolve_ref(self, ref: str, spec: Dict) -> Optional[Dict]:
591
- """Resolve a single local JSON-pointer $ref against ``spec``.
592
-
593
- Returns the referenced object (one hop, not chain-followed), or None
594
- when the ref is non-local, malformed, or its target does not exist.
595
- Never raises.
596
- """
478
+ def _resolve_local_ref(self, ref: Optional[str], root: Dict) -> Optional[Dict]:
479
+ """Resolve a local JSON-pointer reference (#/a/b/c) against root."""
597
480
  if not isinstance(ref, str) or not ref.startswith("#/"):
598
- # External URL, relative-file, or non-pointer ref: unresolvable.
599
481
  return None
600
- if not isinstance(spec, dict):
601
- return None
602
- node: Any = spec
603
- for raw_token in ref[2:].split("/"):
604
- token = self._unescape_json_pointer_token(raw_token)
482
+ node: Any = root
483
+ for raw in ref[2:].split("/"):
484
+ token = raw.replace("~1", "/").replace("~0", "~")
605
485
  if isinstance(node, dict) and token in node:
606
486
  node = node[token]
607
487
  else:
608
488
  return None
609
489
  return node if isinstance(node, dict) else None
610
490
 
611
- def _advise_unverifiable_ref(self, path: str, ref: str, spec: Dict) -> None:
612
- """Record an advisory for a $ref that could not be resolved/compared.
613
-
614
- Classifies into `external_ref_skipped` (non-local URL/relative file —
615
- an expected limitation) vs `unresolved_local_ref` (a `#/` pointer whose
616
- target is missing or whose chain is circular — a likely spec bug).
617
- """
491
+ def _advise_unverifiable_ref(self, path: str, ref: str, root: Dict) -> None:
492
+ """Record an advisory for a $ref that could not be resolved."""
618
493
  if isinstance(ref, str) and ref.startswith("#/"):
619
- self._add_advisory(
620
- "unresolved_local_ref", path,
621
- f"local $ref '{ref}' could not be resolved (target missing or "
622
- f"circular); comparison skipped",
623
- )
494
+ self._add_advisory("unresolved_local_ref", path, f"local $ref '{ref}' could not be resolved")
624
495
  else:
625
- self._add_advisory(
626
- "external_ref_skipped", path,
627
- f"non-local $ref '{ref}' cannot be resolved (external URL or "
628
- f"relative file); comparison skipped",
629
- )
630
-
631
- def _resolve_schema(self, schema: Any, spec: Dict, _seen: Optional[Set[str]] = None) -> Any:
632
- """Follow a (possibly chained) $ref to its concrete schema.
496
+ self._add_advisory("external_ref_skipped", path, f"non-local $ref '{ref}' skipped")
633
497
 
634
- - Follows ref -> ref -> ... -> concrete schema.
635
- - On an unresolvable ref, returns the schema as-is (the ref dict).
636
- - On a circular chain, returns the original schema dict (the ref),
637
- so callers can detect the cycle without hanging.
638
- """
498
+ def _resolve_schema(self, schema: Any, root: Dict, path: str) -> Any:
499
+ """Follow a (possibly chained) local $ref to its concrete schema."""
639
500
  if not isinstance(schema, dict) or "$ref" not in schema:
640
501
  return schema
641
- if _seen is None:
642
- _seen = set()
643
- ref = schema["$ref"]
644
- if ref in _seen:
645
- # Circular ref chain — return as-is rather than looping forever.
646
- return schema
647
- _seen.add(ref)
648
- target = self._resolve_ref(ref, spec)
649
- if target is None:
650
- # Unresolvable — return the original ref dict unchanged.
651
- return schema
652
- if isinstance(target, dict) and "$ref" in target:
653
- return self._resolve_schema(target, spec, _seen)
654
- return target
655
-
656
- def _compare_schema_deep(
657
- self,
658
- path: str,
659
- old_schema: Dict,
660
- new_schema: Dict,
661
- required_fields: Optional[Set[str]] = None,
662
- _visited: Optional[Set[Tuple[Optional[str], Optional[str]]]] = None,
663
- context: Optional[str] = None,
664
- ):
665
- """Deep comparison of schemas including nested objects.
666
502
 
667
- ``context`` is the request/response direction ("request" / "response"
668
- / None) propagated from the operation entry point. It is threaded
669
- through nested objects and arrays so a field change keeps its
670
- direction, which LED-1600 uses to classify breaking-ness correctly
671
- (a removed RESPONSE field is breaking; a removed REQUEST field is not).
672
- Bare component-schema comparisons pass None — the conservative path.
673
- """
674
- # Guard against None schemas
675
- if old_schema is None:
676
- old_schema = {}
677
- if new_schema is None:
678
- new_schema = {}
503
+ chain = set()
504
+ curr = schema
505
+ while isinstance(curr, dict) and "$ref" in curr:
506
+ ref = curr["$ref"]
507
+ if ref in chain:
508
+ break # Cycle in ref chain
509
+ chain.add(ref)
510
+
511
+ target = self._resolve_local_ref(ref, root)
512
+ if target is None:
513
+ self._advise_unverifiable_ref(path, ref, root)
514
+ return curr
515
+ curr = target
516
+ return curr
517
+
518
+ def _compare_schema_deep(self, path: str, old_schema: Dict, new_schema: Dict, required_fields: Optional[Set[str]] = None, context: Optional[str] = None):
519
+ """Deep comparison of schemas including nested objects."""
520
+ if old_schema is None: old_schema = {}
521
+ if new_schema is None: new_schema = {}
679
522
 
680
- if _visited is None:
681
- _visited = set()
682
-
683
- # Handle references. Resolve local #/ pointers and recurse into the
684
- # concrete schemas so breaking changes behind a $ref are detected.
685
523
  old_ref = old_schema.get("$ref") if isinstance(old_schema, dict) else None
686
524
  new_ref = new_schema.get("$ref") if isinstance(new_schema, dict) else None
687
525
 
688
- if old_ref is not None or new_ref is not None:
689
- # Cycle guard: if we've already descended through this exact
690
- # (old_ref, new_ref) pair, stop to avoid infinite recursion.
691
- visit_key = (old_ref, new_ref)
692
- if visit_key in _visited:
693
- return
694
- _visited = _visited | {visit_key}
695
-
696
- # Both sides reference the SAME target. If that target resolves to
697
- # a concrete schema it is already deep-compared once in
698
- # _compare_schemas, so don't re-emit its internal changes here (no
699
- # double-counting) and don't advise. But if the shared target is
700
- # itself unverifiable (missing local target, external, or circular),
701
- # nothing compares it elsewhere — surface that as an advisory once.
702
- if old_ref is not None and new_ref is not None and old_ref == new_ref:
703
- resolved_same = self._resolve_schema(old_schema, self._old_spec)
704
- if isinstance(resolved_same, dict) and "$ref" in resolved_same:
705
- self._advise_unverifiable_ref(path, old_ref, self._old_spec)
706
- return
707
-
708
- resolved_old = self._resolve_schema(old_schema, self._old_spec) if old_ref is not None else old_schema
709
- resolved_new = self._resolve_schema(new_schema, self._new_spec) if new_ref is not None else new_schema
710
-
711
- # If either side is still a ref (unresolvable or circular), skip
712
- # safely rather than fabricating a change or crashing. Surface the
713
- # skip per unverifiable side (LED-1588) so a clean diff isn't
714
- # mistaken for "proven safe".
715
- old_unresolved = isinstance(resolved_old, dict) and "$ref" in resolved_old
716
- new_unresolved = isinstance(resolved_new, dict) and "$ref" in resolved_new
717
- if old_unresolved or new_unresolved:
718
- if old_unresolved and old_ref is not None:
719
- self._advise_unverifiable_ref(path, old_ref, self._old_spec)
720
- if new_unresolved and new_ref is not None:
721
- self._advise_unverifiable_ref(path, new_ref, self._new_spec)
526
+ if old_ref or new_ref:
527
+ if old_ref and new_ref and old_ref == new_ref:
722
528
  return
723
-
724
- self._compare_schema_deep(
725
- path, resolved_old, resolved_new, required_fields, _visited, context
726
- )
529
+
530
+ seen_key = (old_ref or "", new_ref or "")
531
+ if seen_key in self._ref_stack:
532
+ return # cycle on current path
533
+
534
+ resolved_old = self._resolve_schema(old_schema, self._old_root, path) if old_ref else old_schema
535
+ resolved_new = self._resolve_schema(new_schema, self._new_root, path) if new_ref else new_schema
536
+
537
+ if (old_ref and resolved_old is old_schema and "$ref" in old_schema) or \
538
+ (new_ref and resolved_new is new_schema and "$ref" in new_schema):
539
+ return # Unresolved ref advisory already added by _resolve_schema
540
+
541
+ self._ref_stack.add(seen_key)
542
+ try:
543
+ self._compare_schema_deep(path, resolved_old, resolved_new, required_fields, context)
544
+ finally:
545
+ self._ref_stack.discard(seen_key)
727
546
  return
728
547
 
729
- # Compare types
730
548
  old_type = old_schema.get("type")
731
549
  new_type = new_schema.get("type")
732
550
 
733
551
  if old_type != new_type and old_type is not None:
734
- # Determine if this is a response context for RESPONSE_TYPE_CHANGED
735
- is_response = bool(
736
- ":" in path and any(
737
- code in path for code in
738
- ["200", "201", "202", "204", "301", "400", "401", "403", "404", "500"]
739
- )
740
- )
552
+ is_response = bool(":" in path and any(code in path for code in ["200", "201", "202", "204", "301", "400", "401", "403", "404", "500"]))
741
553
  if is_response:
742
554
  self.changes.append(Change(
743
555
  type=ChangeType.RESPONSE_TYPE_CHANGED,
@@ -755,140 +567,54 @@ class OpenAPIDiffEngine:
755
567
  ))
756
568
  return
757
569
 
758
- # Compare object properties.
759
- #
760
- # LED-1597: a schema may carry `properties`/`required` WITHOUT an
761
- # explicit `type: "object"`. This is valid JSON Schema and is
762
- # explicitly common in OpenAPI 3.1 (where `type` is optional), and it
763
- # is exactly the shape the real EU TED v3 spec uses for the
764
- # NoticeResponse component reached via a response $ref. Gating object
765
- # comparison solely on `old_type == "object"` silently skipped all
766
- # field-level diffing for such schemas, so removals/retypes/required
767
- # additions behind a $ref returned 0 changes. Treat a schema as an
768
- # object when it declares object-shaped keys, regardless of `type`.
769
570
  def _is_object_shaped(s: Any) -> bool:
770
- return isinstance(s, dict) and (
771
- "properties" in s or "required" in s
772
- )
571
+ return isinstance(s, dict) and ("properties" in s or "required" in s)
773
572
 
774
- is_object = (
775
- old_type == "object"
776
- or new_type == "object"
777
- or _is_object_shaped(old_schema)
778
- or _is_object_shaped(new_schema)
779
- )
573
+ is_object = old_type == "object" or new_type == "object" or _is_object_shaped(old_schema) or _is_object_shaped(new_schema)
780
574
  if is_object:
781
575
  raw_old_props = old_schema.get("properties", {})
782
576
  raw_new_props = new_schema.get("properties", {})
783
- # Defend against malformed specs where `properties` is a list of
784
- # field-objects rather than the spec-required dict (Kong-class:
785
- # OpenAPI requires `properties: Map[string, Schema]`, but some
786
- # generators emit `properties: [{name: "a", type: "string"}, ...]`).
787
- # Treat as empty rather than crashing on `.keys()`.
788
577
  old_props = raw_old_props if isinstance(raw_old_props, dict) else {}
789
578
  new_props = raw_new_props if isinstance(raw_new_props, dict) else {}
790
- # Defend against malformed specs where `required` is a bool (legal in
791
- # parameter objects but not in object schemas — some real-world specs
792
- # leak the parameter-style boolean into nested schemas).
793
579
  raw_old_required = old_schema.get("required", [])
794
580
  raw_new_required = new_schema.get("required", [])
795
581
  old_required = set(raw_old_required) if isinstance(raw_old_required, list) else set()
796
582
  new_required = set(raw_new_required) if isinstance(raw_new_required, list) else set()
797
583
 
798
- # Check removed fields.
799
- #
800
- # LED-1597: removing a property from a schema is breaking for any
801
- # consumer that reads it (response) or whose payload the server
802
- # validates (request). Previously only REQUIRED-field removal was
803
- # flagged, so dropping an optional response field — the TED
804
- # NoticeResponse.sme-part case — silently produced 0 changes. Flag
805
- # all property removals as FIELD_REMOVED (breaking), recording
806
- # whether the field was required so downstream consumers keep the
807
- # required/optional distinction. The `was_required` flag is purely
808
- # additive to `details`; no return-schema key is renamed/removed.
809
584
  for prop in set(old_props.keys()) - set(new_props.keys()):
810
585
  was_required = prop in old_required
811
- # LED-1600: severity is direction-aware. Removing a field from
812
- # a RESPONSE (or a context-unknown schema, e.g. a component that
813
- # may back a response) is breaking — high. Removing it from a
814
- # REQUEST is non-breaking for clients (the server simply stops
815
- # requiring/accepting it) — low. The is_breaking property reads
816
- # `context` to give the authoritative verdict; severity tracks
817
- # it so the two never disagree (the silent-leak guard).
818
586
  is_breaking_removal = context != "request"
819
587
  self.changes.append(Change(
820
588
  type=ChangeType.FIELD_REMOVED,
821
589
  path=f"{path}.{prop}",
822
- details={
823
- "field": prop,
824
- # The Evidence model requires every details value to be
825
- # a string; coerce bool/None at the producer (LED-1600).
826
- "was_required": str(was_required).lower(),
827
- "context": context or "",
828
- },
590
+ details={"field": prop, "was_required": str(was_required).lower(), "context": context or ""},
829
591
  severity="high" if is_breaking_removal else "low",
830
- message=(
831
- f"{'Required' if was_required else 'Optional'} field "
832
- f"'{prop}' removed at {path}"
833
- + ("" if is_breaking_removal
834
- else " (request field; non-breaking for clients)")
835
- ),
836
- context=context,
592
+ message=f"{'Required' if was_required else 'Optional'} field '{prop}' removed at {path}" + ("" if is_breaking_removal else " (request field; non-breaking for clients)"),
593
+ context=context
837
594
  ))
838
595
 
839
- # Check new required fields
840
596
  for prop in new_required - old_required:
841
597
  if prop not in old_props:
842
- # LED-1600: adding a NEW REQUIRED field is breaking for a
843
- # REQUEST (clients must now send it) and for a
844
- # context-unknown schema (conservative). For a RESPONSE it
845
- # is non-breaking — the server merely returns one more
846
- # always-present field, which existing consumers ignore.
847
598
  is_breaking_add = context != "response"
848
599
  self.changes.append(Change(
849
600
  type=ChangeType.REQUIRED_FIELD_ADDED,
850
601
  path=f"{path}.{prop}",
851
602
  details={"field": prop, "context": context or ""},
852
603
  severity="high" if is_breaking_add else "low",
853
- message=(
854
- f"New required field '{prop}' added at {path}"
855
- + ("" if is_breaking_add
856
- else " (response field; non-breaking for consumers)")
857
- ),
858
- context=context,
604
+ message=f"New required field '{prop}' added at {path}" + ("" if is_breaking_add else " (response field; non-breaking for consumers)"),
605
+ context=context
859
606
  ))
860
607
  else:
861
- # The field already existed but was OPTIONAL and is now
862
- # REQUIRED. In a REQUEST this is breaking (clients that
863
- # omitted it now fail validation). Surface it rather than
864
- # letting it fall through silently. Reuse REQUIRED_FIELD_ADDED
865
- # (no new type) but mark via details that it was a
866
- # requirement tightening on an existing field.
867
608
  is_breaking_tighten = context != "response"
868
609
  self.changes.append(Change(
869
610
  type=ChangeType.REQUIRED_FIELD_ADDED,
870
611
  path=f"{path}.{prop}",
871
- details={
872
- "field": prop,
873
- "context": context or "",
874
- "was_optional": "true",
875
- },
612
+ details={"field": prop, "context": context or "", "was_optional": "true"},
876
613
  severity="high" if is_breaking_tighten else "low",
877
- message=(
878
- f"Field '{prop}' changed from optional to required "
879
- f"at {path}"
880
- + ("" if is_breaking_tighten
881
- else " (response field; non-breaking for consumers)")
882
- ),
883
- context=context,
614
+ message=f"Field '{prop}' changed from optional to required at {path}" + ("" if is_breaking_tighten else " (response field; non-breaking for consumers)"),
615
+ context=context
884
616
  ))
885
617
 
886
- # LED-1600: a field that WAS required is now OPTIONAL. For a
887
- # RESPONSE this is BREAKING — consumers that relied on the field
888
- # always being present can no longer do so (the silent-leak case
889
- # the engine previously did not detect AT ALL). For a REQUEST it is
890
- # non-breaking (the server relaxes its demand). Only flag fields
891
- # that still exist (a removed field is handled above).
892
618
  for prop in old_required - new_required:
893
619
  if prop in new_props:
894
620
  is_breaking_relax = context != "request"
@@ -897,21 +623,10 @@ class OpenAPIDiffEngine:
897
623
  path=f"{path}.{prop}",
898
624
  details={"field": prop, "context": context or ""},
899
625
  severity="high" if is_breaking_relax else "low",
900
- message=(
901
- f"Field '{prop}' changed from required to optional "
902
- f"at {path}"
903
- + (" (response field; consumers can no longer rely "
904
- "on its presence)" if is_breaking_relax
905
- else " (request field; non-breaking)")
906
- ),
907
- context=context,
626
+ message=f"Field '{prop}' changed from required to optional at {path}" + (" (response field; consumers can no longer rely on its presence)" if is_breaking_relax else " (request field; non-breaking)"),
627
+ context=context
908
628
  ))
909
629
 
910
- # Check new optional fields (additive, non-breaking). LED-1597:
911
- # surface added optional properties so the additive case is
912
- # observable in `all_changes` rather than silently producing zero
913
- # changes. New OPTIONAL_FIELD_ADDED entries are additive to the
914
- # change list; no return-schema key changes.
915
630
  for prop in set(new_props.keys()) - set(old_props.keys()):
916
631
  if prop not in new_required:
917
632
  self.changes.append(Change(
@@ -920,253 +635,119 @@ class OpenAPIDiffEngine:
920
635
  details={"field": prop, "context": context or ""},
921
636
  severity="low",
922
637
  message=f"Optional field '{prop}' added at {path}",
923
- context=context,
638
+ context=context
924
639
  ))
925
640
 
926
- # Recursively compare nested properties
927
641
  for prop in set(old_props.keys()) & set(new_props.keys()):
928
642
  old_prop_schema = old_props[prop]
929
643
  new_prop_schema = new_props[prop]
930
-
931
- # Check deprecated on fields
932
- if not old_prop_schema.get("deprecated", False) and new_prop_schema.get("deprecated", False):
933
- self.changes.append(Change(
934
- type=ChangeType.DEPRECATED_ADDED,
935
- path=f"{path}.{prop}",
936
- details={"target": "field", "field": prop},
937
- severity="low",
938
- message=f"Field '{prop}' marked as deprecated at {path}"
939
- ))
940
-
941
- # Check default value changes on fields
942
- if "default" in old_prop_schema or "default" in new_prop_schema:
943
- old_default = old_prop_schema.get("default")
944
- new_default = new_prop_schema.get("default")
945
- if old_default != new_default:
644
+
645
+ if isinstance(old_prop_schema, dict) and isinstance(new_prop_schema, dict):
646
+ if not old_prop_schema.get("deprecated", False) and new_prop_schema.get("deprecated", False):
946
647
  self.changes.append(Change(
947
- type=ChangeType.DEFAULT_CHANGED,
648
+ type=ChangeType.DEPRECATED_ADDED,
948
649
  path=f"{path}.{prop}",
949
- details={"old_default": old_default, "new_default": new_default},
650
+ details={"target": "field", "field": prop},
950
651
  severity="low",
951
- message=f"Default value changed for '{prop}' from {old_default} to {new_default} at {path}"
652
+ message=f"Field '{prop}' marked as deprecated at {path}"
952
653
  ))
654
+ if "default" in old_prop_schema or "default" in new_prop_schema:
655
+ old_def = old_prop_schema.get("default")
656
+ new_def = new_prop_schema.get("default")
657
+ if old_def != new_def:
658
+ self.changes.append(Change(
659
+ type=ChangeType.DEFAULT_CHANGED,
660
+ path=f"{path}.{prop}",
661
+ details={"old_default": old_def, "new_default": new_def},
662
+ severity="low",
663
+ message=f"Default value changed for '{prop}' from {old_def} to {new_def} at {path}"
664
+ ))
665
+ self._compare_constraints(f"{path}.{prop}", old_prop_schema, new_prop_schema)
666
+
667
+ self._compare_schema_deep(f"{path}.{prop}", old_prop_schema, new_prop_schema, old_required if prop in old_required else None, context)
953
668
 
954
- # Check constraint changes on fields
955
- self._compare_constraints(f"{path}.{prop}", old_prop_schema, new_prop_schema)
956
-
957
- self._compare_schema_deep(
958
- f"{path}.{prop}",
959
- old_prop_schema,
960
- new_prop_schema,
961
- old_required if prop in old_required else None,
962
- _visited,
963
- context,
964
- )
965
-
966
- # Compare arrays
967
669
  elif old_type == "array":
968
670
  if "items" in old_schema and "items" in new_schema:
969
- self._compare_schema_deep(
970
- f"{path}[]",
971
- old_schema["items"],
972
- new_schema["items"],
973
- None,
974
- _visited,
975
- context,
976
- )
671
+ self._compare_schema_deep(f"{path}[]", old_schema["items"], new_schema["items"], None, context)
977
672
 
978
- # Compare enums
979
673
  if "enum" in old_schema or "enum" in new_schema:
980
674
  self._compare_enums(path, old_schema.get("enum", []), new_schema.get("enum", []))
981
675
 
982
- # Compare constraints at top level of schema (non-object)
983
676
  if old_type != "object":
984
677
  self._compare_constraints(path, old_schema, new_schema)
985
678
 
986
679
  def _compare_enums(self, path: str, old_enum: List, new_enum: List):
987
680
  """Compare enum values."""
988
- old_set = set(old_enum)
989
- new_set = set(new_enum)
990
-
991
- # Removed enum values are breaking
681
+ old_set = set(old_enum) if isinstance(old_enum, list) else set()
682
+ new_set = set(new_enum) if isinstance(new_enum, list) else set()
992
683
  for value in old_set - new_set:
993
- self.changes.append(Change(
994
- type=ChangeType.ENUM_VALUE_REMOVED,
995
- path=path,
996
- details={"value": value},
997
- severity="high",
998
- message=f"Enum value '{value}' removed at {path}"
999
- ))
1000
-
1001
- # Added enum values are non-breaking
684
+ self.changes.append(Change(type=ChangeType.ENUM_VALUE_REMOVED, path=path, details={"value": value}, severity="high", message=f"Enum value '{value}' removed at {path}"))
1002
685
  for value in new_set - old_set:
1003
- self.changes.append(Change(
1004
- type=ChangeType.ENUM_VALUE_ADDED,
1005
- path=path,
1006
- details={"value": value},
1007
- severity="low",
1008
- message=f"Enum value '{value}' added at {path}"
1009
- ))
686
+ self.changes.append(Change(type=ChangeType.ENUM_VALUE_ADDED, path=path, details={"value": value}, severity="low", message=f"Enum value '{value}' added at {path}"))
1010
687
 
1011
- def _compare_schemas(self, old_schemas: Dict, new_schemas: Dict,
1012
- path_prefix: str = "#/components/schemas"):
1013
- """Compare a named-schema map.
1014
-
1015
- `path_prefix` is the JSON-pointer root of the map so reported paths
1016
- match the ref scheme: "#/components/schemas" for OpenAPI 3.x and
1017
- "#/definitions" for Swagger 2.0.
1018
- """
1019
- # Defend against malformed specs where the schema map is not a dict.
1020
- if not isinstance(old_schemas, dict):
1021
- old_schemas = {}
1022
- if not isinstance(new_schemas, dict):
1023
- new_schemas = {}
1024
- # Schema removal is breaking if referenced
688
+ def _compare_schemas(self, old_schemas: Dict, new_schemas: Dict, path_prefix: str = "#/components/schemas"):
689
+ """Compare a named-schema map."""
690
+ if not isinstance(old_schemas, dict): old_schemas = {}
691
+ if not isinstance(new_schemas, dict): new_schemas = {}
1025
692
  for schema_name in set(old_schemas.keys()) - set(new_schemas.keys()):
1026
- self.changes.append(Change(
1027
- type=ChangeType.FIELD_REMOVED,
1028
- path=f"{path_prefix}/{schema_name}",
1029
- details={"schema": schema_name},
1030
- severity="medium",
1031
- message=f"Schema '{schema_name}' removed"
1032
- ))
1033
-
1034
- # Compare existing schemas
693
+ self.changes.append(Change(type=ChangeType.FIELD_REMOVED, path=f"{path_prefix}/{schema_name}", details={"schema": schema_name}, severity="medium", message=f"Schema '{schema_name}' removed"))
1035
694
  for schema_name in set(old_schemas.keys()) & set(new_schemas.keys()):
1036
- self._compare_schema_deep(
1037
- f"{path_prefix}/{schema_name}",
1038
- old_schemas[schema_name],
1039
- new_schemas[schema_name]
1040
- )
695
+ self._compare_schema_deep(f"{path_prefix}/{schema_name}", old_schemas[schema_name], new_schemas[schema_name])
1041
696
 
1042
697
  def _compare_constraints(self, path: str, old_schema: Dict, new_schema: Dict):
1043
- """Compare schema constraints (maxLength, minLength, maxItems, minItems)."""
1044
- # maxLength / maxItems decreased = breaking (stricter)
698
+ """Compare schema constraints."""
1045
699
  for prop in ("maxLength", "maxItems"):
1046
700
  old_val = old_schema.get(prop)
1047
701
  new_val = new_schema.get(prop)
1048
702
  if old_val is not None and new_val is not None and new_val < old_val:
1049
- self.changes.append(Change(
1050
- type=ChangeType.MAX_LENGTH_DECREASED,
1051
- path=path,
1052
- details={"constraint": prop, "old_value": old_val, "new_value": new_val},
1053
- severity="high",
1054
- message=f"{prop} decreased from {old_val} to {new_val} at {path}"
1055
- ))
703
+ self.changes.append(Change(type=ChangeType.MAX_LENGTH_DECREASED, path=path, details={"constraint": prop, "old_value": old_val, "new_value": new_val}, severity="high", message=f"{prop} decreased from {old_val} to {new_val} at {path}"))
1056
704
  elif old_val is None and new_val is not None:
1057
- # Adding a max constraint where there was none is also stricter
1058
- self.changes.append(Change(
1059
- type=ChangeType.MAX_LENGTH_DECREASED,
1060
- path=path,
1061
- details={"constraint": prop, "old_value": None, "new_value": new_val},
1062
- severity="high",
1063
- message=f"{prop} added ({new_val}) at {path} where none existed"
1064
- ))
1065
-
1066
- # minLength / minItems increased = breaking (stricter)
705
+ self.changes.append(Change(type=ChangeType.MAX_LENGTH_DECREASED, path=path, details={"constraint": prop, "old_value": None, "new_value": new_val}, severity="high", message=f"{prop} added ({new_val}) at {path} where none existed"))
1067
706
  for prop in ("minLength", "minItems"):
1068
707
  old_val = old_schema.get(prop)
1069
708
  new_val = new_schema.get(prop)
1070
709
  if old_val is not None and new_val is not None and new_val > old_val:
1071
- self.changes.append(Change(
1072
- type=ChangeType.MIN_LENGTH_INCREASED,
1073
- path=path,
1074
- details={"constraint": prop, "old_value": old_val, "new_value": new_val},
1075
- severity="high",
1076
- message=f"{prop} increased from {old_val} to {new_val} at {path}"
1077
- ))
710
+ self.changes.append(Change(type=ChangeType.MIN_LENGTH_INCREASED, path=path, details={"constraint": prop, "old_value": old_val, "new_value": new_val}, severity="high", message=f"{prop} increased from {old_val} to {new_val} at {path}"))
1078
711
  elif old_val is None and new_val is not None and new_val > 0:
1079
- # Adding a min constraint where there was none is stricter
1080
- self.changes.append(Change(
1081
- type=ChangeType.MIN_LENGTH_INCREASED,
1082
- path=path,
1083
- details={"constraint": prop, "old_value": None, "new_value": new_val},
1084
- severity="high",
1085
- message=f"{prop} added ({new_val}) at {path} where none existed"
1086
- ))
712
+ self.changes.append(Change(type=ChangeType.MIN_LENGTH_INCREASED, path=path, details={"constraint": prop, "old_value": None, "new_value": new_val}, severity="high", message=f"{prop} added ({new_val}) at {path} where none existed"))
1087
713
 
1088
714
  def _compare_operation_security(self, operation_id: str, old_security: Optional[list], new_security: Optional[list]):
1089
715
  """Compare operation-level security requirements."""
1090
- if old_security is None:
1091
- old_security = []
1092
- if new_security is None:
1093
- new_security = []
1094
-
1095
- # Build maps: scheme_name -> set of scopes
1096
716
  def _security_map(sec_list):
1097
717
  result = {}
1098
- for item in sec_list:
1099
- for scheme, scopes in item.items():
1100
- result[scheme] = set(scopes) if scopes else set()
718
+ if isinstance(sec_list, list):
719
+ for item in sec_list:
720
+ if isinstance(item, dict):
721
+ for scheme, scopes in item.items():
722
+ result[scheme] = set(scopes) if isinstance(scopes, list) else set()
1101
723
  return result
1102
-
1103
724
  old_map = _security_map(old_security)
1104
725
  new_map = _security_map(new_security)
1105
-
1106
- # Removed security schemes from operation
1107
726
  for scheme in set(old_map.keys()) - set(new_map.keys()):
1108
- self.changes.append(Change(
1109
- type=ChangeType.SECURITY_REMOVED,
1110
- path=operation_id,
1111
- details={"scheme": scheme},
1112
- severity="high",
1113
- message=f"Security scheme '{scheme}' removed from {operation_id}"
1114
- ))
1115
-
1116
- # Added security schemes to operation
727
+ self.changes.append(Change(type=ChangeType.SECURITY_REMOVED, path=operation_id, details={"scheme": scheme}, severity="high", message=f"Security scheme '{scheme}' removed from {operation_id}"))
1117
728
  for scheme in set(new_map.keys()) - set(old_map.keys()):
1118
- self.changes.append(Change(
1119
- type=ChangeType.SECURITY_ADDED,
1120
- path=operation_id,
1121
- details={"scheme": scheme},
1122
- severity="low",
1123
- message=f"Security scheme '{scheme}' added to {operation_id}"
1124
- ))
1125
-
1126
- # Check scope changes for shared schemes
729
+ self.changes.append(Change(type=ChangeType.SECURITY_ADDED, path=operation_id, details={"scheme": scheme}, severity="low", message=f"Security scheme '{scheme}' added to {operation_id}"))
1127
730
  for scheme in set(old_map.keys()) & set(new_map.keys()):
1128
731
  removed_scopes = old_map[scheme] - new_map[scheme]
1129
732
  for scope in removed_scopes:
1130
- self.changes.append(Change(
1131
- type=ChangeType.SECURITY_SCOPE_REMOVED,
1132
- path=operation_id,
1133
- details={"scheme": scheme, "scope": scope},
1134
- severity="high",
1135
- message=f"OAuth scope '{scope}' removed from scheme '{scheme}' at {operation_id}"
1136
- ))
733
+ self.changes.append(Change(type=ChangeType.SECURITY_SCOPE_REMOVED, path=operation_id, details={"scheme": scheme, "scope": scope}, severity="high", message=f"OAuth scope '{scope}' removed from scheme '{scheme}' at {operation_id}"))
1137
734
 
1138
735
  def _compare_security(self, old_security: Dict, new_security: Dict):
1139
736
  """Compare security schemes."""
1140
- # Security scheme removal is breaking
737
+ if not isinstance(old_security, dict): old_security = {}
738
+ if not isinstance(new_security, dict): new_security = {}
1141
739
  for scheme in set(old_security.keys()) - set(new_security.keys()):
1142
- self.changes.append(Change(
1143
- type=ChangeType.SECURITY_REMOVED,
1144
- path=f"#/components/securitySchemes/{scheme}",
1145
- details={"scheme": scheme},
1146
- severity="high",
1147
- message=f"Security scheme '{scheme}' removed"
1148
- ))
1149
-
1150
- # Security scheme addition is non-breaking
740
+ self.changes.append(Change(type=ChangeType.SECURITY_REMOVED, path=f"#/components/securitySchemes/{scheme}", details={"scheme": scheme}, severity="high", message=f"Security scheme '{scheme}' removed"))
1151
741
  for scheme in set(new_security.keys()) - set(old_security.keys()):
1152
- self.changes.append(Change(
1153
- type=ChangeType.SECURITY_ADDED,
1154
- path=f"#/components/securitySchemes/{scheme}",
1155
- details={"scheme": scheme},
1156
- severity="low",
1157
- message=f"Security scheme '{scheme}' added"
1158
- ))
742
+ self.changes.append(Change(type=ChangeType.SECURITY_ADDED, path=f"#/components/securitySchemes/{scheme}", details={"scheme": scheme}, severity="low", message=f"Security scheme '{scheme}' added"))
1159
743
 
1160
744
  def _param_key(self, param: Dict) -> str:
1161
- """Generate unique key for parameter."""
1162
745
  return f"{param.get('in', 'query')}:{param.get('name', '')}"
1163
746
 
1164
747
  def get_breaking_changes(self) -> List[Change]:
1165
- """Get only breaking changes."""
1166
748
  return [c for c in self.changes if c.is_breaking]
1167
749
 
1168
750
  def get_summary(self) -> Dict[str, Any]:
1169
- """Get summary of all changes."""
1170
751
  breaking = self.get_breaking_changes()
1171
752
  return {
1172
753
  "total_changes": len(self.changes),
@@ -1176,4 +757,4 @@ class OpenAPIDiffEngine:
1176
757
  "parameters_changed": len([c for c in breaking if c.type in [ChangeType.PARAM_REMOVED, ChangeType.REQUIRED_PARAM_ADDED]]),
1177
758
  "schemas_changed": len([c for c in breaking if c.type in [ChangeType.FIELD_REMOVED, ChangeType.REQUIRED_FIELD_ADDED, ChangeType.TYPE_CHANGED]]),
1178
759
  "is_breaking": len(breaking) > 0
1179
- }
760
+ }