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.
- package/CHANGELOG.md +60 -0
- package/bin/delimit-cli.js +42 -6
- package/bin/delimit-setup.js +7 -3
- package/gateway/ai/backends/gateway_core.py +6 -0
- package/gateway/ai/backends/memory_bridge.py +210 -53
- package/gateway/ai/backends/tools_infra.py +80 -0
- package/gateway/ai/backends/tools_real.py +53 -7
- package/gateway/ai/session_phoenix.py +121 -0
- package/gateway/core/diff_engine_v2.py +517 -54
- package/gateway/core/semver_classifier.py +52 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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={
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
1037
|
+
f"{path_prefix}/{schema_name}",
|
|
575
1038
|
old_schemas[schema_name],
|
|
576
1039
|
new_schemas[schema_name]
|
|
577
1040
|
)
|