spring-runtime-mcp 0.2.0__py3-none-any.whl

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.
Files changed (118) hide show
  1. spring_runtime_mcp/__init__.py +3 -0
  2. spring_runtime_mcp/adapters/__init__.py +1 -0
  3. spring_runtime_mcp/adapters/beans.py +159 -0
  4. spring_runtime_mcp/adapters/conditions.py +333 -0
  5. spring_runtime_mcp/adapters/config.py +295 -0
  6. spring_runtime_mcp/adapters/drift.py +790 -0
  7. spring_runtime_mcp/adapters/health.py +164 -0
  8. spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
  9. spring_runtime_mcp/adapters/mappings.py +294 -0
  10. spring_runtime_mcp/adapters/migrations.py +373 -0
  11. spring_runtime_mcp/adapters/openapi.py +683 -0
  12. spring_runtime_mcp/adapters/source_catalog.py +119 -0
  13. spring_runtime_mcp/adapters/startup.py +202 -0
  14. spring_runtime_mcp/analyzers/__init__.py +1 -0
  15. spring_runtime_mcp/analyzers/bean_graph.py +172 -0
  16. spring_runtime_mcp/analyzers/condition_failures.py +359 -0
  17. spring_runtime_mcp/cli.py +248 -0
  18. spring_runtime_mcp/contracts/__init__.py +1 -0
  19. spring_runtime_mcp/contracts/definitions.py +425 -0
  20. spring_runtime_mcp/diagnostics.py +254 -0
  21. spring_runtime_mcp/models/__init__.py +1 -0
  22. spring_runtime_mcp/models/bean_graph.py +77 -0
  23. spring_runtime_mcp/models/common.py +99 -0
  24. spring_runtime_mcp/models/conditions.py +56 -0
  25. spring_runtime_mcp/models/queries.py +140 -0
  26. spring_runtime_mcp/models/source_catalog.py +35 -0
  27. spring_runtime_mcp/models/timestamps.py +71 -0
  28. spring_runtime_mcp/policy/__init__.py +1 -0
  29. spring_runtime_mcp/policy/configuration.py +32 -0
  30. spring_runtime_mcp/policy/output_guard.py +117 -0
  31. spring_runtime_mcp/policy/sanitization.py +616 -0
  32. spring_runtime_mcp/py.typed +0 -0
  33. spring_runtime_mcp/replay.py +220 -0
  34. spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
  35. spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
  36. spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
  37. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
  38. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
  39. spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
  40. spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
  41. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
  42. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
  43. spring_runtime_mcp/replay_data/conformance/README.md +41 -0
  44. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
  45. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
  46. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
  47. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
  48. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
  49. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
  50. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
  51. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
  52. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
  53. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
  54. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
  55. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
  56. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
  57. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
  58. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
  59. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
  60. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
  61. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
  62. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
  63. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
  64. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
  65. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
  66. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
  67. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
  68. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
  69. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
  70. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
  71. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
  72. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
  73. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
  74. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
  75. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
  76. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
  77. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
  78. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
  79. spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
  80. spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
  81. spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
  82. spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
  83. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
  84. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
  85. spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
  86. spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
  87. spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
  88. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
  89. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
  90. spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
  91. spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
  92. spring_runtime_mcp/schemas/tools-contract.json +1902 -0
  93. spring_runtime_mcp/server.py +322 -0
  94. spring_runtime_mcp/services.py +44 -0
  95. spring_runtime_mcp/snapshots.py +340 -0
  96. spring_runtime_mcp/tools/__init__.py +1 -0
  97. spring_runtime_mcp/tools/analyze_startup.py +79 -0
  98. spring_runtime_mcp/tools/check_migrations.py +99 -0
  99. spring_runtime_mcp/tools/detect_drift.py +323 -0
  100. spring_runtime_mcp/tools/diagnose_health.py +88 -0
  101. spring_runtime_mcp/tools/inspect_beans.py +479 -0
  102. spring_runtime_mcp/tools/inspect_config.py +102 -0
  103. spring_runtime_mcp/tools/registry.py +48 -0
  104. spring_runtime_mcp/tools/search_openapi.py +79 -0
  105. spring_runtime_mcp/tools/support.py +166 -0
  106. spring_runtime_mcp/tools/verify_mappings.py +62 -0
  107. spring_runtime_mcp/transport/__init__.py +1 -0
  108. spring_runtime_mcp/transport/actuator_http.py +459 -0
  109. spring_runtime_mcp/transport/bounded_json.py +144 -0
  110. spring_runtime_mcp/transport/replay_files.py +71 -0
  111. spring_runtime_mcp/transport/response_gate.py +186 -0
  112. spring_runtime_mcp/transport/responses.py +82 -0
  113. spring_runtime_mcp/transport/stdio.py +44 -0
  114. spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
  115. spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
  116. spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
  117. spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  118. spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,683 @@
1
+ """Local OpenAPI $ref expansion. Remote references are never fetched."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ import re
7
+ from urllib.parse import unquote
8
+
9
+ from ..contracts.definitions import glob_matches
10
+ from ..models.common import AnalysisBudget, AnalysisLimit
11
+ from ..models.queries import OpenApiQuery
12
+
13
+ _EXPANSION_LIMIT_GAP = "REF_EXPANSION_LIMIT"
14
+ _OAS_DIALECT = "https://spec.openapis.org/oas/3.1/dialect/base"
15
+ _DIALECTS = frozenset({_OAS_DIALECT, "https://json-schema.org/draft/2020-12/schema"})
16
+ _DOCUMENT_DIALECT = object()
17
+
18
+ _METHODS = {
19
+ "get",
20
+ "head",
21
+ "post",
22
+ "put",
23
+ "patch",
24
+ "delete",
25
+ "options",
26
+ "trace",
27
+ }
28
+ _SCHEMA_KEYS = {
29
+ "type",
30
+ "format",
31
+ "nullable",
32
+ "readOnly",
33
+ "writeOnly",
34
+ "minimum",
35
+ "maximum",
36
+ "exclusiveMinimum",
37
+ "exclusiveMaximum",
38
+ "multipleOf",
39
+ "minLength",
40
+ "maxLength",
41
+ "pattern",
42
+ "minItems",
43
+ "maxItems",
44
+ "uniqueItems",
45
+ "required",
46
+ "enum",
47
+ }
48
+ _SCHEMA_TYPES = frozenset(
49
+ {"null", "boolean", "object", "array", "number", "string", "integer"}
50
+ )
51
+ _METADATA_ARRAY_LIMITS = {"type": 7, "required": 50, "enum": 16}
52
+
53
+
54
+ def _pointer(document: dict[str, Any], ref: str) -> Any:
55
+ if not ref.startswith("#/"):
56
+ return None
57
+ node: Any = document
58
+ try:
59
+ fragment = unquote(ref[2:], errors="strict")
60
+ except UnicodeError:
61
+ return None
62
+ for raw in fragment.split("/"):
63
+ if re.search(r"~(?![01])", raw):
64
+ return None
65
+ part = raw.replace("~1", "/").replace("~0", "~")
66
+ if isinstance(node, dict) and part in node:
67
+ node = node[part]
68
+ elif (
69
+ isinstance(node, list)
70
+ and re.fullmatch(r"0|[1-9][0-9]*", part)
71
+ and int(part) < len(node)
72
+ ):
73
+ node = node[int(part)]
74
+ else:
75
+ return None
76
+ return node
77
+
78
+
79
+ def _schema_name(ref: str) -> str | None:
80
+ marker = "/components/schemas/"
81
+ if marker not in ref:
82
+ return None
83
+ return ref.rsplit("/", 1)[-1] or None
84
+
85
+
86
+ class _Expander:
87
+ """Project local schemas under one shared, per-query analysis budget.
88
+
89
+ The budget counts every expansion node across all operations and shared
90
+ references, so a wide fan-out of `$ref`s cannot multiply work per property
91
+ or per reference. Exhaustion yields a bounded placeholder plus an explicit
92
+ gap instead of building the remaining subtrees.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ document: dict[str, Any],
98
+ max_depth: int,
99
+ budget: AnalysisBudget | None = None,
100
+ ) -> None:
101
+ self.document = document
102
+ self.max_depth = max_depth
103
+ self.budget = AnalysisBudget() if budget is None else budget
104
+ self.gaps: set[str] = set()
105
+ self.blocked_name: str | None = None
106
+ self._exhausted = False
107
+ self.is_31 = str(document.get("openapi", "")).startswith("3.1.")
108
+ self.dialect = document.get("jsonSchemaDialect", _OAS_DIALECT)
109
+ self._targets: dict[str, Any] = {}
110
+ self._target_dialects: dict[str, Any] = {}
111
+ self._unsupported_target_bases: set[str] = set()
112
+
113
+ def target(self, ref: str) -> Any:
114
+ if len(ref) > 512:
115
+ self.gaps.add("REF_INVALID")
116
+ return None
117
+ # Cache pointer lookup separately from path-local cycle detection.
118
+ if ref not in self._targets:
119
+ self._targets[ref] = _pointer(self.document, ref)
120
+ # Determine the target's lexical scope, independently of the caller.
121
+ # Map containers are not Schema objects: a business field named
122
+ # $schema must not change the dialect of its siblings.
123
+ node: Any = self.document
124
+ dialect = self.dialect
125
+ role = "outside"
126
+ parts = [
127
+ part.replace("~1", "/").replace("~0", "~")
128
+ for part in unquote(ref[2:]).split("/")
129
+ ]
130
+ for index, part in enumerate(parts):
131
+ if not self._consume():
132
+ self._targets[ref] = None
133
+ break
134
+ if role == "schema" and isinstance(node, dict):
135
+ dialect = node.get("$schema", dialect)
136
+ if any(key in node for key in ("$id", "$dynamicAnchor")):
137
+ self._unsupported_target_bases.add(ref)
138
+ if role in {"map", "array"}:
139
+ next_role = "schema"
140
+ elif role == "schema":
141
+ next_role = (
142
+ "map"
143
+ if part
144
+ in {"properties", "$defs", "definitions", "patternProperties"}
145
+ else "array"
146
+ if part in {"allOf", "oneOf", "anyOf", "prefixItems"}
147
+ else "schema"
148
+ if part
149
+ in {
150
+ "items",
151
+ "additionalProperties",
152
+ "contains",
153
+ "not",
154
+ "if",
155
+ "then",
156
+ "else",
157
+ }
158
+ else "outside"
159
+ )
160
+ else:
161
+ next_role = (
162
+ "map"
163
+ if parts[: index + 1] == ["components", "schemas"]
164
+ else "schema"
165
+ if part == "schema"
166
+ else "outside"
167
+ )
168
+ if isinstance(node, dict):
169
+ node = node.get(part)
170
+ elif (
171
+ isinstance(node, list)
172
+ and part.isdecimal()
173
+ and int(part) < len(node)
174
+ ):
175
+ node = node[int(part)]
176
+ else:
177
+ node = None
178
+ role = next_role
179
+ self._target_dialects[ref] = dialect
180
+ return self._targets[ref]
181
+
182
+ def _consume(self) -> bool:
183
+ """Charge one expansion visit; False once the shared budget is spent."""
184
+ if self._exhausted:
185
+ return False
186
+ try:
187
+ self.budget.consume()
188
+ except AnalysisLimit:
189
+ self._exhausted = True
190
+ self.gaps.add(_EXPANSION_LIMIT_GAP)
191
+ return False
192
+ return True
193
+
194
+ def expand(
195
+ self,
196
+ schema: Any,
197
+ depth: int,
198
+ seen: frozenset[str],
199
+ inherited_dialect: Any = _DOCUMENT_DIALECT,
200
+ ) -> tuple[dict[str, Any], bool]:
201
+ if not self._consume():
202
+ return {
203
+ "referenceState": "unsupported",
204
+ "omissionCodes": [_EXPANSION_LIMIT_GAP],
205
+ }, False
206
+ if not isinstance(schema, dict):
207
+ self.gaps.add("OPENAPI_SCHEMA_UNREADABLE")
208
+ return {"referenceState": "invalid"}, False
209
+ dialect = schema.get(
210
+ "$schema",
211
+ self.dialect
212
+ if inherited_dialect is _DOCUMENT_DIALECT
213
+ else inherited_dialect,
214
+ )
215
+ if not isinstance(dialect, str) or dialect not in _DIALECTS:
216
+ self.gaps.add("OPENAPI_SCHEMA_DIALECT_UNSUPPORTED")
217
+ return {
218
+ "referenceState": "unsupported",
219
+ "omissionCodes": ["OPENAPI_SCHEMA_DIALECT_UNSUPPORTED"],
220
+ }, False
221
+ if any(key in schema for key in ("$id", "$dynamicRef", "$dynamicAnchor")):
222
+ self.gaps.add("OPENAPI_SCHEMA_BASE_UNSUPPORTED")
223
+ return {
224
+ "referenceState": "unsupported",
225
+ "omissionCodes": ["OPENAPI_SCHEMA_BASE_UNSUPPORTED"],
226
+ }, False
227
+ ref = schema.get("$ref")
228
+ if isinstance(ref, str):
229
+ resolved, complete = self._resolve(ref, depth, seen)
230
+ if self.is_31 and set(schema) - {
231
+ "$ref",
232
+ "description",
233
+ "summary",
234
+ "title",
235
+ "example",
236
+ "examples",
237
+ "default",
238
+ }:
239
+ sibling, sibling_complete = self._project(schema, depth, seen, dialect)
240
+ # Conjunction preserves conflicting constraints without flattening.
241
+ return {"allOf": [resolved, sibling]}, complete and sibling_complete
242
+ return resolved, complete
243
+ return self._project(schema, depth, seen, dialect)
244
+
245
+ def _resolve(
246
+ self, ref: str, depth: int, seen: frozenset[str]
247
+ ) -> tuple[dict[str, Any], bool]:
248
+ if not ref.startswith("#"):
249
+ self.gaps.add("REF_EXTERNAL_BLOCKED")
250
+ return {"referenceState": "externalBlocked"}, False
251
+ if len(ref) > 512 or not ref.startswith("#/"):
252
+ self.gaps.add("REF_INVALID")
253
+ return {"referenceState": "invalid"}, False
254
+ if ref in seen:
255
+ self.gaps.add("REF_CYCLE")
256
+ return {"$ref": ref, "referenceState": "cycle"}, False
257
+ if depth >= self.max_depth:
258
+ self.gaps.add("REF_DEPTH_LIMIT")
259
+ self.blocked_name = self.blocked_name or _schema_name(ref)
260
+ target = self.target(ref)
261
+ kind = target.get("type") if isinstance(target, dict) else None
262
+ return {
263
+ "type": kind if isinstance(kind, str) else "object",
264
+ "referenceState": "depthLimit",
265
+ "$ref": ref,
266
+ }, False
267
+ target = self.target(ref)
268
+ if not isinstance(target, dict):
269
+ self.gaps.add("REF_INVALID")
270
+ return {"$ref": ref, "referenceState": "invalid"}, False
271
+ if ref in self._unsupported_target_bases:
272
+ self.gaps.add("OPENAPI_SCHEMA_BASE_UNSUPPORTED")
273
+ return {
274
+ "referenceState": "unsupported",
275
+ "omissionCodes": ["OPENAPI_SCHEMA_BASE_UNSUPPORTED"],
276
+ }, False
277
+ projected, complete = self.expand(
278
+ target, depth + 1, seen | {ref}, self._target_dialects[ref]
279
+ )
280
+ projected["$ref"] = ref
281
+ projected["referenceState"] = "resolved"
282
+ return projected, complete
283
+
284
+ def _project(
285
+ self, schema: dict[str, Any], depth: int, seen: frozenset[str], dialect: str
286
+ ) -> tuple[dict[str, Any], bool]:
287
+ projected: dict[str, Any] = {}
288
+ complete = True
289
+ supported = _SCHEMA_KEYS | {
290
+ "$ref",
291
+ "properties",
292
+ "items",
293
+ "additionalProperties",
294
+ "allOf",
295
+ "oneOf",
296
+ "anyOf",
297
+ }
298
+ omitted = {
299
+ "description",
300
+ "summary",
301
+ "title",
302
+ "example",
303
+ "examples",
304
+ "default",
305
+ "$defs",
306
+ "$schema",
307
+ }
308
+ if any(
309
+ key not in supported | omitted and not key.startswith("x-")
310
+ for key in schema
311
+ ):
312
+ self.gaps.add("OPENAPI_SCHEMA_UNSUPPORTED")
313
+ complete = False
314
+ for key in _SCHEMA_KEYS:
315
+ if key not in schema:
316
+ continue
317
+ value = schema[key]
318
+ # Never borrow a mutable node from the source. A cached $ref target
319
+ # can be expanded along several paths in the same output tree.
320
+ if isinstance(value, list):
321
+ limit = _METADATA_ARRAY_LIMITS.get(key, 0)
322
+ if not limit or len(value) > limit:
323
+ complete = False
324
+ continue
325
+ copied: list[Any] = []
326
+ valid = True
327
+ for member in value:
328
+ if not self._consume():
329
+ return {
330
+ "referenceState": "unsupported",
331
+ "omissionCodes": [_EXPANSION_LIMIT_GAP],
332
+ }, False
333
+ if (
334
+ not isinstance(member, (str, int, float, bool, type(None)))
335
+ or (isinstance(member, str) and len(member) > 512)
336
+ or (
337
+ key == "required"
338
+ and (not isinstance(member, str) or len(member) > 128)
339
+ )
340
+ or (
341
+ key == "type"
342
+ and (
343
+ not isinstance(member, str)
344
+ or member not in _SCHEMA_TYPES
345
+ )
346
+ )
347
+ ):
348
+ valid = False
349
+ break
350
+ copied.append(member)
351
+ if valid:
352
+ projected[key] = copied
353
+ else:
354
+ complete = False
355
+ continue
356
+ if isinstance(value, dict) or key in {"required", "enum"}:
357
+ complete = False
358
+ continue
359
+ if isinstance(value, str) and any(char.isspace() for char in value):
360
+ complete = False
361
+ continue
362
+ projected[key] = value
363
+ properties = schema.get("properties")
364
+ if isinstance(properties, dict):
365
+ kept: dict[str, Any] = {}
366
+ for name in sorted(properties):
367
+ if (
368
+ not isinstance(name, str)
369
+ or len(name) > 128
370
+ or any(char.isspace() for char in name)
371
+ ):
372
+ complete = False
373
+ continue
374
+ child, child_complete = self.expand(
375
+ properties[name], depth, seen, dialect
376
+ )
377
+ if self._exhausted:
378
+ return {
379
+ "referenceState": "unsupported",
380
+ "omissionCodes": [_EXPANSION_LIMIT_GAP],
381
+ }, False
382
+ kept[name] = child
383
+ complete = complete and child_complete
384
+ if len(kept) >= 50 and len(properties) > 50:
385
+ self.gaps.add("OPENAPI_PROPERTY_LIMIT")
386
+ complete = False
387
+ break
388
+ projected["properties"] = kept
389
+ if "items" in schema:
390
+ child, child_complete = self.expand(schema["items"], depth, seen, dialect)
391
+ if self._exhausted:
392
+ return {
393
+ "referenceState": "unsupported",
394
+ "omissionCodes": [_EXPANSION_LIMIT_GAP],
395
+ }, False
396
+ projected["items"] = child
397
+ complete = complete and child_complete
398
+ if "additionalProperties" in schema:
399
+ additional = schema["additionalProperties"]
400
+ if isinstance(additional, bool):
401
+ projected["additionalProperties"] = additional
402
+ else:
403
+ child, child_complete = self.expand(additional, depth, seen, dialect)
404
+ projected["additionalProperties"] = child
405
+ complete = complete and child_complete
406
+ for key in ("allOf", "oneOf", "anyOf"):
407
+ if key not in schema:
408
+ continue
409
+ raw = schema[key]
410
+ if not isinstance(raw, list) or not raw:
411
+ self.gaps.add("OPENAPI_SCHEMA_UNREADABLE")
412
+ complete = False
413
+ continue
414
+ children = []
415
+ for part in raw[:16]:
416
+ child, child_complete = self.expand(part, depth, seen, dialect)
417
+ children.append(child)
418
+ complete = complete and child_complete
419
+ if self._exhausted:
420
+ return {
421
+ "referenceState": "unsupported",
422
+ "omissionCodes": [_EXPANSION_LIMIT_GAP],
423
+ }, False
424
+ projected[key] = children
425
+ if len(raw) > 16:
426
+ self.gaps.add("OPENAPI_COMPOSITION_LIMIT")
427
+ complete = False
428
+ if not complete:
429
+ self.gaps.add("OPENAPI_SCHEMA_PARTIAL")
430
+ return projected, complete
431
+
432
+
433
+ def _path_item(value: Any, expander: _Expander) -> dict[str, Any]:
434
+ """Keep disjoint PathItem fields; conflicting fields have no defined winner."""
435
+ fields: dict[str, Any] = {}
436
+ conflicts: set[str] = set()
437
+ seen: set[str] = set()
438
+ while True:
439
+ if not isinstance(value, dict):
440
+ expander.gaps.add("OPENAPI_PATH_UNREADABLE")
441
+ break
442
+ for key, child in value.items():
443
+ if not expander._consume():
444
+ expander.gaps.add("OPENAPI_PATH_UNREADABLE")
445
+ return fields
446
+ if key == "$ref":
447
+ continue
448
+ if key in fields or key in conflicts:
449
+ fields.pop(key, None)
450
+ conflicts.add(key)
451
+ expander.gaps.add("OPENAPI_PATH_FIELD_CONFLICT")
452
+ else:
453
+ fields[key] = child
454
+ if "$ref" not in value:
455
+ break
456
+ ref = value["$ref"]
457
+ if not isinstance(ref, str) or not ref.startswith("#/") or len(ref) > 512:
458
+ expander.gaps.update(
459
+ {
460
+ "REF_EXTERNAL_BLOCKED"
461
+ if isinstance(ref, str) and not ref.startswith("#")
462
+ else "REF_INVALID",
463
+ "OPENAPI_PATH_UNREADABLE",
464
+ }
465
+ )
466
+ break
467
+ if ref in seen or len(seen) >= 8 or not expander._consume():
468
+ expander.gaps.update(
469
+ {
470
+ "REF_CYCLE"
471
+ if ref in seen
472
+ else _EXPANSION_LIMIT_GAP
473
+ if expander._exhausted
474
+ else "REF_DEPTH_LIMIT",
475
+ "OPENAPI_PATH_UNREADABLE",
476
+ }
477
+ )
478
+ break
479
+ seen.add(ref)
480
+ value = expander.target(ref)
481
+ return fields
482
+
483
+
484
+ def _object(value: Any, expander: _Expander) -> dict[str, Any] | None:
485
+ """Resolve bounded local Parameter/RequestBody references, never URLs."""
486
+ seen: set[str] = set()
487
+ while isinstance(value, dict) and "$ref" in value:
488
+ ref = value["$ref"]
489
+ if not isinstance(ref, str) or not ref.startswith("#/"):
490
+ expander.gaps.add("REF_EXTERNAL_BLOCKED")
491
+ return None
492
+ if ref in seen:
493
+ expander.gaps.add("REF_CYCLE")
494
+ return None
495
+ if len(seen) >= 8 or not expander._consume():
496
+ expander.gaps.add(
497
+ "REF_DEPTH_LIMIT" if not expander._exhausted else _EXPANSION_LIMIT_GAP
498
+ )
499
+ return None
500
+ seen.add(ref)
501
+ value = expander.target(ref)
502
+ if not isinstance(value, dict):
503
+ expander.gaps.add("OPENAPI_OBJECT_UNREADABLE")
504
+ return None
505
+ return value
506
+
507
+
508
+ def _parameters(
509
+ operation: dict[str, Any], expander: _Expander, inherited: Any
510
+ ) -> list[dict[str, Any]]:
511
+ rows: list[dict[str, Any]] = []
512
+ raw = operation.get("parameters", [])
513
+ if not isinstance(raw, list) or not isinstance(inherited, list):
514
+ expander.gaps.add("OPENAPI_PARAMETERS_UNREADABLE")
515
+ return rows
516
+ selected: dict[tuple[str, str], dict[str, Any]] = {}
517
+ for value in [*inherited, *raw]:
518
+ item = _object(value, expander)
519
+ if not isinstance(item, dict) or not isinstance(item.get("name"), str):
520
+ expander.gaps.add("OPENAPI_PARAMETERS_UNREADABLE")
521
+ continue
522
+ location = item.get("in")
523
+ if location not in {"path", "query", "header", "cookie"}:
524
+ expander.gaps.add("OPENAPI_PARAMETERS_UNREADABLE")
525
+ continue
526
+ selected[(location, item["name"])] = item
527
+ if len(selected) > 16:
528
+ expander.gaps.add("OPENAPI_PARAMETER_LIMIT")
529
+ for (location, name), item in sorted(selected.items())[:16]:
530
+ schema, _ = expander.expand(item.get("schema"), 0, frozenset())
531
+ rows.append(
532
+ {
533
+ "name": name[:128],
534
+ "in": location,
535
+ "required": item.get("required") is True,
536
+ "schema": schema,
537
+ }
538
+ )
539
+ return rows
540
+
541
+
542
+ def _request_body(
543
+ operation: dict[str, Any], expander: _Expander
544
+ ) -> list[dict[str, Any]]:
545
+ if "requestBody" not in operation:
546
+ return []
547
+ body = _object(operation["requestBody"], expander)
548
+ content = body.get("content") if body is not None else None
549
+ if not isinstance(content, dict) or not content:
550
+ expander.gaps.add("OPENAPI_REQUEST_BODY_UNREADABLE")
551
+ return []
552
+ if len(content) > 8:
553
+ expander.gaps.add("OPENAPI_MEDIA_LIMIT")
554
+ rows = []
555
+ for media in sorted(content)[:8]:
556
+ entry = content[media]
557
+ schema, _ = expander.expand(
558
+ entry.get("schema") if isinstance(entry, dict) else None, 0, frozenset()
559
+ )
560
+ rows.append({"mediaType": media[:128], "schema": schema})
561
+ return rows
562
+
563
+
564
+ def normalize_openapi(
565
+ body: Any, query: OpenApiQuery
566
+ ) -> tuple[str | None, str, list[dict[str, Any]], list[str], str | None]:
567
+ if not isinstance(body, dict) or not isinstance(body.get("paths"), dict):
568
+ raise ValueError("INVALID_OPENAPI_SHAPE")
569
+ version = (
570
+ body.get("openapi")
571
+ if isinstance(body.get("openapi"), str)
572
+ else body.get("swagger")
573
+ )
574
+ version = version if isinstance(version, str) else None
575
+ expander = _Expander(body, query.max_ref_depth)
576
+ items: list[dict[str, Any]] = []
577
+ if query.schema_name:
578
+ schemas = (
579
+ body.get("components", {}).get("schemas", {})
580
+ if isinstance(body.get("components"), dict)
581
+ else {}
582
+ )
583
+ schema = schemas.get(query.schema_name) if isinstance(schemas, dict) else None
584
+ if isinstance(schema, dict):
585
+ projected, complete = expander.expand(schema, 0, frozenset())
586
+ items.append(
587
+ {
588
+ "id": "openapi_000001",
589
+ "kind": "schema",
590
+ "path": None,
591
+ "httpMethod": None,
592
+ "operationId": None,
593
+ "schemaName": query.schema_name,
594
+ "parameters": [],
595
+ "requestBody": [],
596
+ "responses": [],
597
+ "schema": projected,
598
+ "expansionComplete": complete,
599
+ }
600
+ )
601
+ return version, "default", items, sorted(expander.gaps), expander.blocked_name
602
+ paths = body["paths"]
603
+ for path in sorted(paths):
604
+ if not isinstance(path, str) or not path.startswith("/"):
605
+ expander.gaps.add("OPENAPI_PATH_UNREADABLE")
606
+ continue
607
+ if query.path_pattern and not glob_matches(query.path_pattern, path):
608
+ continue
609
+ operations = _path_item(paths[path], expander)
610
+ for method in sorted(operations):
611
+ if method not in _METHODS:
612
+ continue
613
+ http_method = method.upper()
614
+ if query.http_method and http_method != query.http_method:
615
+ continue
616
+ operation = operations[method]
617
+ if not isinstance(operation, dict):
618
+ expander.gaps.add("OPENAPI_OPERATION_UNREADABLE")
619
+ continue
620
+ operation_id = operation.get("operationId")
621
+ if query.operation_id and operation_id != query.operation_id:
622
+ continue
623
+ responses: list[dict[str, Any]] = []
624
+ complete = True
625
+ raw_responses = operation.get("responses")
626
+ if isinstance(raw_responses, dict):
627
+ for status in sorted(raw_responses):
628
+ if len(responses) >= 16:
629
+ complete = False
630
+ break
631
+ entry = _object(raw_responses[status], expander)
632
+ if entry is None:
633
+ complete = False
634
+ content = entry.get("content") if isinstance(entry, dict) else None
635
+ if not isinstance(content, dict) or not content:
636
+ responses.append(
637
+ {
638
+ "status": str(status)[:16],
639
+ "mediaType": None,
640
+ "schema": None,
641
+ }
642
+ )
643
+ continue
644
+ media = min(content)
645
+ schema = (
646
+ content[media].get("schema")
647
+ if isinstance(content[media], dict)
648
+ else None
649
+ )
650
+ projected, schema_complete = expander.expand(schema, 0, frozenset())
651
+ complete = complete and schema_complete
652
+ responses.append(
653
+ {
654
+ "status": str(status)[:16],
655
+ "mediaType": media if isinstance(media, str) else None,
656
+ "schema": projected,
657
+ }
658
+ )
659
+ else:
660
+ complete = False
661
+ parameters = _parameters(
662
+ operation, expander, operations.get("parameters", [])
663
+ )
664
+ request_body = _request_body(operation, expander)
665
+ items.append(
666
+ {
667
+ "kind": "operation",
668
+ "path": path[:1024],
669
+ "httpMethod": http_method,
670
+ "operationId": operation_id
671
+ if isinstance(operation_id, str)
672
+ else None,
673
+ "schemaName": None,
674
+ "parameters": parameters,
675
+ "requestBody": request_body,
676
+ "responses": responses,
677
+ "schema": None,
678
+ "expansionComplete": complete and not expander.gaps,
679
+ }
680
+ )
681
+ for index, item in enumerate(items, start=1):
682
+ item["id"] = f"openapi_{index:06d}"
683
+ return version, "default", items, sorted(expander.gaps), expander.blocked_name