delimit-cli 4.6.1 → 4.6.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.
@@ -26,6 +26,16 @@ class ChangeType(Enum):
26
26
  SECURITY_SCOPE_REMOVED = "security_scope_removed"
27
27
  MAX_LENGTH_DECREASED = "max_length_decreased"
28
28
  MIN_LENGTH_INCREASED = "min_length_increased"
29
+ # LED-1600: a field that was REQUIRED becoming OPTIONAL. In a RESPONSE this
30
+ # is BREAKING — consumers can no longer rely on the field always being
31
+ # present. In a REQUEST it is non-breaking (the server relaxes what it
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
+ FIELD_REQUIREMENT_RELAXED = "field_requirement_relaxed"
29
39
 
30
40
  # Non-breaking changes
31
41
  ENDPOINT_ADDED = "endpoint_added"
@@ -39,6 +49,28 @@ class ChangeType(Enum):
39
49
  DEPRECATED_ADDED = "deprecated_added"
40
50
  DEFAULT_CHANGED = "default_changed"
41
51
 
52
+ # Change types that are ALWAYS breaking, independent of request/response
53
+ # context. The context-sensitive types (field add/remove/requirement) are
54
+ # handled separately in Change.is_breaking.
55
+ _ALWAYS_BREAKING = frozenset({
56
+ ChangeType.ENDPOINT_REMOVED,
57
+ ChangeType.METHOD_REMOVED,
58
+ ChangeType.REQUIRED_PARAM_ADDED,
59
+ ChangeType.PARAM_REMOVED,
60
+ ChangeType.RESPONSE_REMOVED,
61
+ ChangeType.TYPE_CHANGED,
62
+ ChangeType.FORMAT_CHANGED,
63
+ ChangeType.ENUM_VALUE_REMOVED,
64
+ ChangeType.PARAM_TYPE_CHANGED,
65
+ ChangeType.PARAM_REQUIRED_CHANGED,
66
+ ChangeType.RESPONSE_TYPE_CHANGED,
67
+ ChangeType.SECURITY_REMOVED,
68
+ ChangeType.SECURITY_SCOPE_REMOVED,
69
+ ChangeType.MAX_LENGTH_DECREASED,
70
+ ChangeType.MIN_LENGTH_INCREASED,
71
+ })
72
+
73
+
42
74
  @dataclass
43
75
  class Change:
44
76
  type: ChangeType
@@ -46,50 +78,113 @@ class Change:
46
78
  details: Dict[str, Any]
47
79
  severity: str # high, medium, low
48
80
  message: str
49
-
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.
90
+ context: Optional[str] = None
91
+
50
92
  @property
51
93
  def is_breaking(self) -> bool:
52
- return self.type in [
53
- ChangeType.ENDPOINT_REMOVED,
54
- ChangeType.METHOD_REMOVED,
55
- ChangeType.REQUIRED_PARAM_ADDED,
56
- ChangeType.PARAM_REMOVED,
57
- ChangeType.RESPONSE_REMOVED,
58
- ChangeType.REQUIRED_FIELD_ADDED,
59
- ChangeType.FIELD_REMOVED,
60
- ChangeType.TYPE_CHANGED,
61
- ChangeType.FORMAT_CHANGED,
62
- ChangeType.ENUM_VALUE_REMOVED,
63
- ChangeType.PARAM_TYPE_CHANGED,
64
- ChangeType.PARAM_REQUIRED_CHANGED,
65
- ChangeType.RESPONSE_TYPE_CHANGED,
66
- ChangeType.SECURITY_REMOVED,
67
- ChangeType.SECURITY_SCOPE_REMOVED,
68
- ChangeType.MAX_LENGTH_DECREASED,
69
- ChangeType.MIN_LENGTH_INCREASED,
70
- ]
94
+ ct = self.type
95
+
96
+ if ct in _ALWAYS_BREAKING:
97
+ return True
98
+
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
+ if ct == ChangeType.REQUIRED_FIELD_ADDED:
105
+ return self.context != "response"
106
+
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
+ if ct == ChangeType.FIELD_REMOVED:
113
+ return self.context != "request"
114
+
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
+ if ct == ChangeType.FIELD_REQUIREMENT_RELAXED:
120
+ return self.context != "request"
121
+
122
+ return False
71
123
 
72
124
  class OpenAPIDiffEngine:
73
125
  """Advanced diff engine for OpenAPI specifications."""
74
126
 
75
127
  def __init__(self):
76
128
  self.changes: List[Change] = []
77
-
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.
133
+ self.advisories: List[Dict[str, Any]] = []
134
+ self._old_spec: Dict = {}
135
+ self._new_spec: Dict = {}
136
+
137
+ def _add_advisory(self, kind: str, path: str, detail: str) -> None:
138
+ """Record a fail-open skip. Dedupes identical (kind, path, detail)."""
139
+ entry = {"kind": kind, "path": path, "detail": detail}
140
+ if entry not in self.advisories:
141
+ self.advisories.append(entry)
142
+
78
143
  def compare(self, old_spec: Dict, new_spec: Dict) -> List[Change]:
79
144
  """Compare two OpenAPI specifications and return all changes."""
80
145
  self.changes = []
146
+ self.advisories = []
81
147
  old_spec = old_spec or {}
82
148
  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
83
153
 
84
154
  # Compare paths
85
155
  self._compare_paths(old_spec.get("paths", {}), new_spec.get("paths", {}))
86
156
 
87
- # Compare components/schemas
157
+ # Compare components/schemas (OpenAPI 3.x)
158
+ _old_components = old_spec.get("components", {})
159
+ _new_components = new_spec.get("components", {})
88
160
  self._compare_schemas(
89
- old_spec.get("components", {}).get("schemas", {}),
90
- new_spec.get("components", {}).get("schemas", {})
161
+ _old_components.get("schemas", {}) if isinstance(_old_components, dict) else {},
162
+ _new_components.get("schemas", {}) if isinstance(_new_components, dict) else {},
91
163
  )
92
-
164
+
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).
169
+ self._compare_schemas(
170
+ old_spec.get("definitions", {}),
171
+ new_spec.get("definitions", {}),
172
+ path_prefix="#/definitions",
173
+ )
174
+
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
+
93
188
  # Compare security schemes
94
189
  self._compare_security(
95
190
  old_spec.get("components", {}).get("securitySchemes", {}),
@@ -105,8 +200,16 @@ class OpenAPIDiffEngine:
105
200
  # as the Kong-class properties-as-list fix; treat as empty rather
106
201
  # than crashing on `.keys()`.
107
202
  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
+ )
108
207
  old_paths = {}
109
208
  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
+ )
110
213
  new_paths = {}
111
214
  old_set = set(old_paths.keys())
112
215
  new_set = set(new_paths.keys())
@@ -144,8 +247,18 @@ class OpenAPIDiffEngine:
144
247
  # Same defensive pattern as _compare_paths — methods at a path
145
248
  # MUST be a dict per spec, but malformed inputs see real-world.
146
249
  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
+ )
147
255
  old_methods = {}
148
256
  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
+ )
149
262
  new_methods = {}
150
263
  old_set = set(m for m in old_methods.keys() if m in self.HTTP_METHODS)
151
264
  new_set = set(m for m in new_methods.keys() if m in self.HTTP_METHODS)
@@ -260,6 +373,30 @@ class OpenAPIDiffEngine:
260
373
  new_schema = new_param.get("schema", {})
261
374
  param_name = old_param.get("name", old_param.get("$ref", "unknown"))
262
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
383
+
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
+
263
400
  # Check type changes — emit both PARAM_TYPE_CHANGED (specific) and TYPE_CHANGED (legacy)
264
401
  if old_schema.get("type") != new_schema.get("type"):
265
402
  self.changes.append(Change(
@@ -343,6 +480,18 @@ class OpenAPIDiffEngine:
343
480
  # Compare content types
344
481
  raw_old_content = old_body.get("content", {})
345
482
  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
+ )
346
495
  old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
347
496
  new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
348
497
 
@@ -350,15 +499,26 @@ class OpenAPIDiffEngine:
350
499
  self._compare_schema_deep(
351
500
  f"{operation_id}:request",
352
501
  old_content[content_type].get("schema", {}),
353
- new_content[content_type].get("schema", {})
502
+ new_content[content_type].get("schema", {}),
503
+ context="request",
354
504
  )
355
505
 
356
506
  def _compare_responses(self, operation_id: str, old_responses: Dict, new_responses: Dict):
357
507
  """Compare response definitions."""
358
508
  # Defend against malformed specs where `responses` is a list.
359
509
  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
+ )
360
515
  old_responses = {}
361
516
  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
+ )
362
522
  new_responses = {}
363
523
  old_codes = set(old_responses.keys())
364
524
  new_codes = set(new_responses.keys())
@@ -379,10 +539,38 @@ class OpenAPIDiffEngine:
379
539
  for code in old_codes & new_codes:
380
540
  old_resp = old_responses[code]
381
541
  new_resp = new_responses[code]
382
-
542
+
543
+ # A response item must itself be a dict (Response Object).
544
+ 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 = {}
551
+ 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 = {}
558
+
383
559
  if "content" in old_resp or "content" in new_resp:
384
560
  raw_old_content = old_resp.get("content", {})
385
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
+ )
386
574
  old_content = raw_old_content if isinstance(raw_old_content, dict) else {}
387
575
  new_content = raw_new_content if isinstance(raw_new_content, dict) else {}
388
576
 
@@ -390,20 +578,152 @@ class OpenAPIDiffEngine:
390
578
  self._compare_schema_deep(
391
579
  f"{operation_id}:{code}",
392
580
  old_content[content_type].get("schema", {}),
393
- new_content[content_type].get("schema", {})
581
+ new_content[content_type].get("schema", {}),
582
+ context="response",
394
583
  )
395
584
 
396
- def _compare_schema_deep(self, path: str, old_schema: Dict, new_schema: Dict, required_fields: Optional[Set[str]] = None):
397
- """Deep comparison of schemas including nested objects."""
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
+ """
597
+ if not isinstance(ref, str) or not ref.startswith("#/"):
598
+ # External URL, relative-file, or non-pointer ref: unresolvable.
599
+ 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)
605
+ if isinstance(node, dict) and token in node:
606
+ node = node[token]
607
+ else:
608
+ return None
609
+ return node if isinstance(node, dict) else None
610
+
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
+ """
618
+ 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
+ )
624
+ 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.
633
+
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
+ """
639
+ if not isinstance(schema, dict) or "$ref" not in schema:
640
+ 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
+
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
+ """
398
674
  # Guard against None schemas
399
675
  if old_schema is None:
400
676
  old_schema = {}
401
677
  if new_schema is None:
402
678
  new_schema = {}
403
679
 
404
- # Handle references
405
- if "$ref" in old_schema or "$ref" in new_schema:
406
- # TODO: Resolve references properly
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
+ old_ref = old_schema.get("$ref") if isinstance(old_schema, dict) else None
686
+ new_ref = new_schema.get("$ref") if isinstance(new_schema, dict) else None
687
+
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)
722
+ return
723
+
724
+ self._compare_schema_deep(
725
+ path, resolved_old, resolved_new, required_fields, _visited, context
726
+ )
407
727
  return
408
728
 
409
729
  # Compare types
@@ -435,8 +755,29 @@ class OpenAPIDiffEngine:
435
755
  ))
436
756
  return
437
757
 
438
- # Compare object properties
439
- if old_type == "object":
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
+ def _is_object_shaped(s: Any) -> bool:
770
+ return isinstance(s, dict) and (
771
+ "properties" in s or "required" in s
772
+ )
773
+
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
+ )
780
+ if is_object:
440
781
  raw_old_props = old_schema.get("properties", {})
441
782
  raw_new_props = new_schema.get("properties", {})
442
783
  # Defend against malformed specs where `properties` is a list of
@@ -454,26 +795,132 @@ class OpenAPIDiffEngine:
454
795
  old_required = set(raw_old_required) if isinstance(raw_old_required, list) else set()
455
796
  new_required = set(raw_new_required) if isinstance(raw_new_required, list) else set()
456
797
 
457
- # Check removed fields
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.
458
809
  for prop in set(old_props.keys()) - set(new_props.keys()):
459
- if prop in old_required:
460
- self.changes.append(Change(
461
- type=ChangeType.FIELD_REMOVED,
462
- path=f"{path}.{prop}",
463
- details={"field": prop},
464
- severity="high",
465
- message=f"Required field '{prop}' removed at {path}"
466
- ))
810
+ 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
+ is_breaking_removal = context != "request"
819
+ self.changes.append(Change(
820
+ type=ChangeType.FIELD_REMOVED,
821
+ 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
+ },
829
+ 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,
837
+ ))
467
838
 
468
839
  # Check new required fields
469
840
  for prop in new_required - old_required:
470
841
  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
+ is_breaking_add = context != "response"
848
+ self.changes.append(Change(
849
+ type=ChangeType.REQUIRED_FIELD_ADDED,
850
+ path=f"{path}.{prop}",
851
+ details={"field": prop, "context": context or ""},
852
+ 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,
859
+ ))
860
+ 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
+ is_breaking_tighten = context != "response"
471
868
  self.changes.append(Change(
472
869
  type=ChangeType.REQUIRED_FIELD_ADDED,
473
870
  path=f"{path}.{prop}",
474
- details={"field": prop},
475
- severity="high",
476
- message=f"New required field '{prop}' added at {path}"
871
+ details={
872
+ "field": prop,
873
+ "context": context or "",
874
+ "was_optional": "true",
875
+ },
876
+ 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,
884
+ ))
885
+
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
+ for prop in old_required - new_required:
893
+ if prop in new_props:
894
+ is_breaking_relax = context != "request"
895
+ self.changes.append(Change(
896
+ type=ChangeType.FIELD_REQUIREMENT_RELAXED,
897
+ path=f"{path}.{prop}",
898
+ details={"field": prop, "context": context or ""},
899
+ 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,
908
+ ))
909
+
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
+ for prop in set(new_props.keys()) - set(old_props.keys()):
916
+ if prop not in new_required:
917
+ self.changes.append(Change(
918
+ type=ChangeType.OPTIONAL_FIELD_ADDED,
919
+ path=f"{path}.{prop}",
920
+ details={"field": prop, "context": context or ""},
921
+ severity="low",
922
+ message=f"Optional field '{prop}' added at {path}",
923
+ context=context,
477
924
  ))
478
925
 
479
926
  # Recursively compare nested properties
@@ -511,7 +958,9 @@ class OpenAPIDiffEngine:
511
958
  f"{path}.{prop}",
512
959
  old_prop_schema,
513
960
  new_prop_schema,
514
- old_required if prop in old_required else None
961
+ old_required if prop in old_required else None,
962
+ _visited,
963
+ context,
515
964
  )
516
965
 
517
966
  # Compare arrays
@@ -520,7 +969,10 @@ class OpenAPIDiffEngine:
520
969
  self._compare_schema_deep(
521
970
  f"{path}[]",
522
971
  old_schema["items"],
523
- new_schema["items"]
972
+ new_schema["items"],
973
+ None,
974
+ _visited,
975
+ context,
524
976
  )
525
977
 
526
978
  # Compare enums
@@ -556,22 +1008,33 @@ class OpenAPIDiffEngine:
556
1008
  message=f"Enum value '{value}' added at {path}"
557
1009
  ))
558
1010
 
559
- def _compare_schemas(self, old_schemas: Dict, new_schemas: Dict):
560
- """Compare component schemas."""
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 = {}
561
1024
  # Schema removal is breaking if referenced
562
1025
  for schema_name in set(old_schemas.keys()) - set(new_schemas.keys()):
563
1026
  self.changes.append(Change(
564
1027
  type=ChangeType.FIELD_REMOVED,
565
- path=f"#/components/schemas/{schema_name}",
1028
+ path=f"{path_prefix}/{schema_name}",
566
1029
  details={"schema": schema_name},
567
1030
  severity="medium",
568
1031
  message=f"Schema '{schema_name}' removed"
569
1032
  ))
570
-
1033
+
571
1034
  # Compare existing schemas
572
1035
  for schema_name in set(old_schemas.keys()) & set(new_schemas.keys()):
573
1036
  self._compare_schema_deep(
574
- f"#/components/schemas/{schema_name}",
1037
+ f"{path_prefix}/{schema_name}",
575
1038
  old_schemas[schema_name],
576
1039
  new_schemas[schema_name]
577
1040
  )