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.
- package/bin/delimit-cli.js +152 -1
- package/bin/delimit-setup.js +88 -6
- package/bin/delimit.js +10 -25
- package/gateway/ai/backends/governance_bridge.py +52 -0
- package/gateway/ai/backends/repo_bridge.py +12 -0
- package/gateway/ai/backends/tools_infra.py +43 -1
- package/gateway/ai/cli_contract.py +12 -0
- package/gateway/ai/custom_gemini_repl.py +80 -0
- package/gateway/ai/delimit_daemon.py +8 -0
- package/gateway/ai/gemini_vertex_shim.py +38 -0
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/release_sync.py +43 -8
- package/gateway/ai/route_daemon.py +98 -0
- package/gateway/ai/server.py +71 -1
- package/gateway/ai/session_phoenix.py +101 -136
- package/gateway/ai/supabase_sync.py +58 -0
- package/gateway/ai/swarm.py +2 -0
- package/gateway/ai/tui.py +81 -0
- package/gateway/core/ci_formatter.py +89 -61
- package/gateway/core/diff_engine_v2.py +208 -627
- package/gateway/core/explainer.py +67 -34
- package/lib/ai-sbom-engine.js +1 -0
- package/lib/auth-setup.js +10 -1
- package/lib/chat-repl.js +244 -0
- package/lib/cross-model-hooks.js +111 -0
- package/lib/timeline-engine.js +60 -0
- package/lib/wrap-engine.js +67 -11
- package/package.json +1 -1
|
@@ -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.
|
|
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)
|
|
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
|
-
|
|
135
|
-
self.
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
self.
|
|
152
|
-
self.
|
|
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)
|
|
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("
|
|
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
|
-
#
|
|
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
|
-
|
|
264
|
-
|
|
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={"
|
|
218
|
+
details={"method": method.upper(), "endpoint": path},
|
|
272
219
|
severity="high",
|
|
273
|
-
message=f"Method
|
|
220
|
+
message=f"Method {method.upper()} removed from {path}"
|
|
274
221
|
))
|
|
275
222
|
|
|
276
|
-
# Check
|
|
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
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
256
|
+
details={"parameter": param.get("name"), "in": param.get("in")},
|
|
299
257
|
severity="high",
|
|
300
|
-
message=f"Parameter removed: {param
|
|
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
|
|
268
|
+
details={"parameter": param.get("name"), "in": param.get("in")},
|
|
311
269
|
severity="high",
|
|
312
|
-
message=f"Required parameter added: {param
|
|
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
|
|
280
|
+
details={"parameter": param.get("name"), "in": param.get("in")},
|
|
323
281
|
severity="low",
|
|
324
|
-
message=f"Optional parameter added: {param
|
|
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
|
-
|
|
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",
|
|
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}:{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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,
|
|
612
|
-
"""Record an advisory for a $ref that could not be resolved
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
689
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
932
|
-
|
|
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.
|
|
648
|
+
type=ChangeType.DEPRECATED_ADDED,
|
|
948
649
|
path=f"{path}.{prop}",
|
|
949
|
-
details={"
|
|
650
|
+
details={"target": "field", "field": prop},
|
|
950
651
|
severity="low",
|
|
951
|
-
message=f"
|
|
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
|
-
|
|
1013
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1099
|
-
for
|
|
1100
|
-
|
|
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
|
-
|
|
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
|
+
}
|