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,164 @@
1
+ """Normalize Boot 2.x details trees and 3.x/4.x component trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from ..models.common import AnalysisBudget
9
+
10
+ _ABNORMAL = {"DOWN", "OUT_OF_SERVICE", "UNKNOWN"}
11
+ _CLASS = re.compile(r"(?:[A-Za-z_$][A-Za-z0-9_$]*\.)+[A-Za-z_$][A-Za-z0-9_$]*\Z")
12
+
13
+
14
+ def _children(node: dict[str, Any]) -> dict[str, Any]:
15
+ components = node.get("components")
16
+ if isinstance(components, dict):
17
+ return components
18
+ details = node.get("details")
19
+ if isinstance(details, dict):
20
+ return {
21
+ str(key): value
22
+ for key, value in details.items()
23
+ if isinstance(value, dict) and "status" in value
24
+ }
25
+ return {}
26
+
27
+
28
+ def _scalars(
29
+ node: dict[str, Any], gaps: set[str], *, report_limit: bool
30
+ ) -> list[dict[str, Any]]:
31
+ details = node.get("details")
32
+ if not isinstance(details, dict):
33
+ return []
34
+ rows: list[dict[str, Any]] = []
35
+ for key, value in sorted(details.items(), key=lambda item: str(item[0])):
36
+ if isinstance(value, dict) and "status" in value:
37
+ continue
38
+ finite = (
39
+ isinstance(value, bool) or not isinstance(value, float) or value == value
40
+ )
41
+ if (isinstance(value, (str, int, float, bool)) or value is None) and finite:
42
+ rows.append({"name": str(key)[:128], "value": value})
43
+ if len(rows) > 10:
44
+ if report_limit:
45
+ gaps.add("HEALTH_DETAILS_LIMIT")
46
+ return rows[:10]
47
+ return rows
48
+
49
+
50
+ def _error_type(node: dict[str, Any]) -> str | None:
51
+ details = node.get("details")
52
+ raw = details.get("error") if isinstance(details, dict) else None
53
+ if not isinstance(raw, str) or not raw:
54
+ return None
55
+ tokens = raw.split(":", 1)[0].strip().split()
56
+ if not tokens:
57
+ return None
58
+ token = tokens[-1]
59
+ if len(token) <= 256 and _CLASS.fullmatch(token):
60
+ return token
61
+ return None
62
+
63
+
64
+ def _codes(path: tuple[str, ...], hidden: bool) -> list[str]:
65
+ if hidden:
66
+ return ["REVIEW_HEALTH_DETAILS_VISIBILITY"]
67
+ names = " ".join(path).casefold()
68
+ if any(token in names for token in ("db", "database", "datasource", "sql")):
69
+ return ["REVIEW_DATABASE_CONNECTIVITY"]
70
+ if any(token in names for token in ("disk", "diskspace")):
71
+ return ["REVIEW_DISK_SPACE"]
72
+ if any(token in names for token in ("redis", "kafka", "rabbit", "mongo")):
73
+ return ["REVIEW_MESSAGE_DEPENDENCY"]
74
+ return ["REVIEW_HEALTH_COMPONENT"]
75
+
76
+
77
+ def normalize_health(
78
+ body: Any,
79
+ *,
80
+ component_path: tuple[str, ...],
81
+ include_details: bool,
82
+ include_healthy: bool,
83
+ max_depth: int,
84
+ ) -> tuple[str | None, list[dict[str, Any]], list[str]]:
85
+ if not isinstance(body, dict):
86
+ raise ValueError("INVALID_HEALTH_SHAPE")
87
+ overall = body.get("status") if isinstance(body.get("status"), str) else None
88
+ found: list[dict[str, Any]] = []
89
+ gaps: set[str] = set()
90
+ budget = AnalysisBudget()
91
+
92
+ def walk(node: Any, path: tuple[str, ...], depth: int) -> None:
93
+ budget.consume()
94
+ if not isinstance(node, dict):
95
+ gaps.add("HEALTH_COMPONENT_UNREADABLE")
96
+ return
97
+ children = _children(node)
98
+ if "components" in node and not isinstance(node["components"], dict):
99
+ gaps.add("HEALTH_COMPONENT_UNREADABLE")
100
+ raw_status = node.get("status")
101
+ status = raw_status if isinstance(raw_status, str) and raw_status else "UNKNOWN"
102
+ if not isinstance(raw_status, str) or not raw_status:
103
+ gaps.add("HEALTH_STATUS_UNOBSERVED")
104
+ scalars = _scalars(node, gaps, report_limit=include_details)
105
+ # Root UP is an observed aggregate status, not evidence that components
106
+ # are absent. Explicit components={} is a reported empty inventory.
107
+ hidden_root = (
108
+ depth == 0 and "components" not in node and not children and not scalars
109
+ )
110
+ hidden = hidden_root or (status != "UP" and not children and not scalars)
111
+ if hidden:
112
+ gaps.add("HEALTH_DETAILS_NOT_VISIBLE")
113
+ if depth > max_depth:
114
+ gaps.add("HEALTH_DEPTH_LIMIT")
115
+ return
116
+ selected = (
117
+ not component_path or path[: len(component_path)] == component_path
118
+ ) and (depth > 0 or not children)
119
+ visible = not component_path or path[: len(component_path)] == component_path
120
+ if selected and visible and (include_healthy or status != "UP" or hidden):
121
+ found.append(
122
+ {
123
+ "path": path,
124
+ "status": status[:64],
125
+ "details": scalars if include_details else [],
126
+ "errorType": _error_type(node),
127
+ "remediationCodes": _codes(path, hidden),
128
+ "childrenComplete": not hidden and depth < max_depth,
129
+ }
130
+ )
131
+ if depth >= max_depth and children:
132
+ gaps.add("HEALTH_DEPTH_LIMIT")
133
+ return
134
+ for name in sorted(children):
135
+ if (
136
+ len(path) + 1 > 16
137
+ or len(name) > 128
138
+ or any(ord(char) < 32 for char in name)
139
+ ):
140
+ gaps.add("HEALTH_COMPONENT_UNREADABLE")
141
+ continue
142
+ walk(children[name], path + (name,), depth + 1)
143
+
144
+ walk(body, (), 0)
145
+ items = []
146
+ previous: dict[tuple[str, ...], str] = {}
147
+ for index, row in enumerate(found, start=1):
148
+ identifier = f"health_{index:06d}"
149
+ path = row["path"]
150
+ previous[path] = identifier
151
+ parent = previous.get(path[:-1]) if path else None
152
+ items.append(
153
+ {
154
+ "id": identifier,
155
+ "componentPath": list(path),
156
+ "parentId": parent,
157
+ "status": row["status"],
158
+ "details": row["details"],
159
+ "errorType": row["errorType"],
160
+ "remediationCodes": row["remediationCodes"],
161
+ "childrenComplete": row["childrenComplete"],
162
+ }
163
+ )
164
+ return overall[:64] if isinstance(overall, str) else None, items, sorted(gaps)
@@ -0,0 +1,201 @@
1
+ """Bounded projection of known positive WebFlux predicate conjunctions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from ..models.common import AnalysisBudget
9
+
10
+ HTTP_METHODS = frozenset(
11
+ {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"}
12
+ )
13
+ _MAX_LENGTH = 4096
14
+ _MAX_DEPTH = 16
15
+ _MAX_CLAUSES = 32
16
+ _MAX_CONDITIONS = 16
17
+ _LEGACY_CONDITION = re.compile(
18
+ r"(?P<kind>consumes|produces|headers|params)\s*\[(?P<value>.*)\]\Z"
19
+ )
20
+ _LEGACY_KIND = {
21
+ "consumes": "consumes",
22
+ "produces": "produces",
23
+ "headers": "header",
24
+ "params": "param",
25
+ }
26
+ # Quoted parameters and custom header predicates remain unsupported. Keeping
27
+ # alternatives in one header row preserves their set rather than inventing ANDs.
28
+ _TOKEN = r"[A-Za-z0-9!#$%&'*+.^_`|~-]+"
29
+ _MEDIA = re.compile(rf"{_TOKEN}/{_TOKEN}(?:;{_TOKEN}={_TOKEN})*\Z")
30
+
31
+
32
+ class _UnsupportedPredicate(Exception):
33
+ pass
34
+
35
+
36
+ class _Conjunction:
37
+ def __init__(self, text: str) -> None:
38
+ self.text = text
39
+ self.position = 0
40
+ self.nodes = 0
41
+ self.leaves: list[str] = []
42
+ self.budget = AnalysisBudget()
43
+
44
+ def _space(self) -> None:
45
+ while self.position < len(self.text) and self.text[self.position] == " ":
46
+ self.position += 1
47
+
48
+ def expression(self, depth: int = 0) -> None:
49
+ self.atom(depth)
50
+ while True:
51
+ self._space()
52
+ if self.text.startswith("&&", self.position):
53
+ self.position += 2
54
+ elif self.text.startswith(",", self.position):
55
+ # Retain the existing flat request-mapping spelling as well.
56
+ self.position += 1
57
+ else:
58
+ return
59
+ self.atom(depth)
60
+
61
+ def atom(self, depth: int) -> None:
62
+ self.budget.consume()
63
+ self.nodes += 1
64
+ if depth > _MAX_DEPTH or self.nodes > 2 * _MAX_CLAUSES:
65
+ raise _UnsupportedPredicate
66
+ self._space()
67
+ if self.position >= len(self.text):
68
+ raise _UnsupportedPredicate
69
+ if self.text[self.position] == "(":
70
+ if depth >= _MAX_DEPTH:
71
+ raise _UnsupportedPredicate
72
+ self.position += 1
73
+ self.expression(depth + 1)
74
+ self._space()
75
+ if not self.text.startswith(")", self.position):
76
+ raise _UnsupportedPredicate
77
+ self.position += 1
78
+ return
79
+ start = self.position
80
+ delimiters: list[str] = []
81
+ while self.position < len(self.text):
82
+ char = self.text[self.position]
83
+ if not delimiters and (
84
+ char in "()," or self.text.startswith(("&&", "||"), self.position)
85
+ ):
86
+ break
87
+ if char in "[{" or (char == "(" and delimiters):
88
+ delimiters.append({"[": "]", "{": "}", "(": ")"}[char])
89
+ if depth + len(delimiters) > _MAX_DEPTH:
90
+ raise _UnsupportedPredicate
91
+ elif char in "]})":
92
+ if not delimiters or delimiters.pop() != char:
93
+ raise _UnsupportedPredicate
94
+ self.position += 1
95
+ leaf = self.text[start : self.position].strip()
96
+ if not leaf or delimiters or len(self.leaves) >= _MAX_CLAUSES:
97
+ raise _UnsupportedPredicate
98
+ self.leaves.append(leaf)
99
+
100
+ def parse(self) -> list[str]:
101
+ self.expression()
102
+ self._space()
103
+ if self.position != len(self.text):
104
+ raise _UnsupportedPredicate
105
+ return self.leaves
106
+
107
+
108
+ def _media_condition(leaf: str) -> dict[str, Any] | None:
109
+ name, separator, value = leaf.partition(":")
110
+ if name not in {"Accept", "Content-Type"} or not separator:
111
+ return None
112
+ value = value.strip()
113
+ if value.startswith("[") and value.endswith("]"):
114
+ value = value[1:-1]
115
+ media = [part.strip() for part in value.split(",")]
116
+ value = ",".join(media)
117
+ if (
118
+ not media
119
+ or len(value) > 256
120
+ or any(not _MEDIA.fullmatch(part) for part in media)
121
+ ):
122
+ raise _UnsupportedPredicate
123
+ return {"kind": "header", "name": name, "value": value, "negated": False}
124
+
125
+
126
+ def parse_predicate(
127
+ raw: Any,
128
+ ) -> tuple[list[str], list[str], list[dict[str, Any]], bool]:
129
+ """Return no asserted facts unless every positive AND clause is understood.
130
+
131
+ Different path predicates in an AND may describe intersections and cannot be
132
+ emitted as a list of alternative paths. OR/NOT/custom clauses likewise stay
133
+ unknown instead of turning branches into nonexistent method/path pairs.
134
+ """
135
+ if (
136
+ not isinstance(raw, str)
137
+ or not raw
138
+ or len(raw) > _MAX_LENGTH
139
+ or any(ord(char) < 32 or ord(char) == 127 for char in raw)
140
+ ):
141
+ return [], [], [], False
142
+ text = raw.strip()
143
+ if text.startswith("{") and text.endswith("}"):
144
+ text = text[1:-1]
145
+ paths: list[str] = []
146
+ methods: set[str] | None = None
147
+ conditions: list[dict[str, Any]] = []
148
+ try:
149
+ for leaf in _Conjunction(text).parse():
150
+ token, _, rest = leaf.partition(" ")
151
+ method_set: set[str] | None = None
152
+ if leaf.startswith("[") and leaf.endswith("]"):
153
+ values = [part.strip() for part in leaf[1:-1].split(",")]
154
+ if values and all(value in HTTP_METHODS for value in values):
155
+ method_set = set(values)
156
+ rest = ""
157
+ elif token.upper() in HTTP_METHODS:
158
+ method_set = {token.upper()}
159
+ rest = rest.strip()
160
+ if method_set is not None:
161
+ methods = method_set if methods is None else methods & method_set
162
+ if not methods:
163
+ raise _UnsupportedPredicate
164
+ if not rest:
165
+ continue
166
+ leaf = (
167
+ rest[1:-1].strip()
168
+ if rest.startswith("[") and rest.endswith("]")
169
+ else rest
170
+ )
171
+ if not leaf.startswith("/"):
172
+ raise _UnsupportedPredicate
173
+ if leaf.startswith("/"):
174
+ if len(leaf) > 1024 or any(char.isspace() for char in leaf):
175
+ raise _UnsupportedPredicate
176
+ if paths and leaf not in paths:
177
+ raise _UnsupportedPredicate
178
+ if not paths:
179
+ paths.append(leaf)
180
+ continue
181
+ condition = _media_condition(leaf)
182
+ if condition is None:
183
+ matched = _LEGACY_CONDITION.fullmatch(leaf)
184
+ if matched is None or len(matched.group("value")) > 256:
185
+ raise _UnsupportedPredicate
186
+ condition = {
187
+ "kind": _LEGACY_KIND[matched.group("kind")],
188
+ "name": None,
189
+ "value": matched.group("value").strip() or None,
190
+ "negated": False,
191
+ }
192
+ if len(conditions) >= _MAX_CONDITIONS:
193
+ raise _UnsupportedPredicate
194
+ conditions.append(condition)
195
+ except _UnsupportedPredicate:
196
+ return [], [], [], False
197
+ if not paths:
198
+ # A method/header-only predicate leaves the path unrestricted. The
199
+ # current route projection cannot express that for exact path queries.
200
+ return [], [], [], False
201
+ return paths, sorted(methods or ()), conditions, True
@@ -0,0 +1,294 @@
1
+ """Normalize MVC dispatcherServlets and WebFlux dispatcherHandlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..models.common import AnalysisBudget
8
+
9
+ from ..contracts.definitions import glob_matches
10
+
11
+ from .mapping_predicates import (
12
+ HTTP_METHODS as _METHODS,
13
+ parse_predicate as _parse_predicate,
14
+ )
15
+
16
+
17
+ def _servlet_may_cover(query: str | None, mapping: str) -> bool:
18
+ if query is None or mapping in {"/", "/*", "*"}:
19
+ return True
20
+ if not mapping.startswith("/"):
21
+ return False
22
+ mapping_prefix = mapping.split("*", 1)[0]
23
+ query_prefix = query.split("*", 1)[0]
24
+ if mapping_prefix.startswith(query_prefix) or query_prefix.startswith(
25
+ mapping_prefix
26
+ ):
27
+ return True
28
+ return glob_matches(mapping, query) or glob_matches(query, mapping)
29
+
30
+
31
+ def _non_dispatcher_routes_unobserved(
32
+ mappings: dict[str, Any], path_pattern: str | None
33
+ ) -> bool:
34
+ """Actuator mappings only enumerate DispatcherServlet routes.
35
+
36
+ A Jersey/other ServletContainer registered under a matching URL space means
37
+ the absence of a DispatcherServlet record is not evidence of absence.
38
+ """
39
+ servlets = mappings.get("servlets")
40
+ if servlets is None:
41
+ return False
42
+ if not isinstance(servlets, list):
43
+ return True
44
+ for servlet in servlets:
45
+ if not isinstance(servlet, dict):
46
+ continue
47
+ if (
48
+ servlet.get("className")
49
+ == "org.springframework.web.servlet.DispatcherServlet"
50
+ ):
51
+ continue
52
+ raw = servlet.get("mappings")
53
+ if not isinstance(raw, list):
54
+ return True
55
+ for mapping in raw:
56
+ if isinstance(mapping, str) and _servlet_may_cover(path_pattern, mapping):
57
+ return True
58
+ return False
59
+
60
+
61
+ def _conditions(raw: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
62
+ rows: list[dict[str, Any]] = []
63
+ complete = True
64
+ for kind, key in (
65
+ ("consumes", "consumes"),
66
+ ("produces", "produces"),
67
+ ("header", "headers"),
68
+ ("param", "params"),
69
+ ):
70
+ values = raw.get(key, [])
71
+ if not isinstance(values, list):
72
+ complete = False
73
+ continue
74
+ for value in values:
75
+ if len(rows) >= 16:
76
+ complete = False
77
+ break
78
+ if isinstance(value, dict):
79
+ media = value.get("mediaType", value.get("value"))
80
+ rows.append(
81
+ {
82
+ "kind": kind,
83
+ "name": value.get("name")
84
+ if isinstance(value.get("name"), str)
85
+ else None,
86
+ "value": media
87
+ if isinstance(media, str) and len(media) <= 256
88
+ else None,
89
+ "negated": bool(value.get("negated", False)),
90
+ }
91
+ )
92
+ elif isinstance(value, str) and len(value) <= 256:
93
+ rows.append(
94
+ {"kind": kind, "name": None, "value": value, "negated": False}
95
+ )
96
+ return rows, complete
97
+
98
+
99
+ def normalize_mappings(
100
+ body: Any,
101
+ *,
102
+ path_pattern: str | None,
103
+ http_method: str | None,
104
+ controller_pattern: str | None,
105
+ context_id: str | None,
106
+ ) -> tuple[list[dict[str, Any]], list[str]]:
107
+ if not isinstance(body, dict) or not isinstance(body.get("contexts"), dict):
108
+ raise ValueError("INVALID_MAPPINGS_SHAPE")
109
+ gaps: set[str] = set()
110
+ budget = AnalysisBudget()
111
+ found: list[dict[str, Any]] = []
112
+ for context_name in sorted(body["contexts"]):
113
+ if not isinstance(context_name, str):
114
+ gaps.add("INVALID_CONTEXT_NAME")
115
+ continue
116
+ if context_id and context_name != context_id:
117
+ continue
118
+ context = body["contexts"][context_name]
119
+ if not isinstance(context, dict):
120
+ gaps.add("INVALID_CONTEXT")
121
+ continue
122
+ parent = context.get("parentId")
123
+ mappings = context.get("mappings")
124
+ if not isinstance(mappings, dict):
125
+ gaps.add("MAPPINGS_UNOBSERVED")
126
+ continue
127
+ if _non_dispatcher_routes_unobserved(mappings, path_pattern):
128
+ gaps.add("NON_DISPATCHER_SERVLET_ROUTES_UNOBSERVED")
129
+ trees = (
130
+ ("mvc", "dispatcherServlets"),
131
+ ("webflux", "dispatcherHandlers"),
132
+ )
133
+ for stack, tree_name in trees:
134
+ tree = mappings.get(tree_name)
135
+ if tree is None:
136
+ continue
137
+ if not isinstance(tree, dict):
138
+ gaps.add("MAPPINGS_UNOBSERVED")
139
+ continue
140
+ for dispatcher in sorted(tree):
141
+ records = tree[dispatcher]
142
+ if not isinstance(records, list):
143
+ gaps.add("MAPPINGS_UNOBSERVED")
144
+ continue
145
+ for record in records:
146
+ budget.consume()
147
+ if not isinstance(record, dict):
148
+ gaps.add("MAPPING_RECORD_UNREADABLE")
149
+ continue
150
+ details = record.get("details")
151
+ method = (
152
+ details.get("handlerMethod")
153
+ if isinstance(details, dict)
154
+ else None
155
+ )
156
+ conditions_raw = (
157
+ details.get("requestMappingConditions")
158
+ if isinstance(details, dict)
159
+ else None
160
+ )
161
+ handler_function = (
162
+ details.get("handlerFunction")
163
+ if isinstance(details, dict)
164
+ else None
165
+ )
166
+ if not isinstance(handler_function, str):
167
+ nested = record.get("handlerFunction")
168
+ handler_function = nested if isinstance(nested, str) else None
169
+ class_name = (
170
+ method.get("className") if isinstance(method, dict) else None
171
+ )
172
+ method_name = (
173
+ method.get("name") if isinstance(method, dict) else None
174
+ )
175
+ descriptor = (
176
+ method.get("descriptor") if isinstance(method, dict) else None
177
+ )
178
+ handler = record.get("handler")
179
+ if (
180
+ not isinstance(class_name, str)
181
+ and isinstance(handler, str)
182
+ and "#" in handler
183
+ ):
184
+ class_name, _, method_name = handler.partition("#")
185
+ patterns = []
186
+ methods: list[str] = []
187
+ condition_rows: list[dict[str, Any]] = []
188
+ complete = isinstance(conditions_raw, dict)
189
+ methods_complete = isinstance(conditions_raw, dict)
190
+ predicate_parsed = False
191
+ if isinstance(conditions_raw, dict):
192
+ raw_patterns = conditions_raw.get("patterns")
193
+ raw_methods = conditions_raw.get("methods")
194
+ if isinstance(raw_patterns, list):
195
+ for item in raw_patterns:
196
+ if isinstance(item, str) and 0 < len(item) <= 1024:
197
+ patterns.append(item)
198
+ else:
199
+ complete = False
200
+ gaps.add("MAPPING_PATTERN_UNREADABLE")
201
+ if len(patterns) > 16:
202
+ patterns = patterns[:16]
203
+ complete = False
204
+ gaps.add("MAPPING_PATH_LIMIT")
205
+ else:
206
+ complete = False
207
+ gaps.add("MAPPING_PATTERN_UNREADABLE")
208
+ if isinstance(raw_methods, list):
209
+ for item in raw_methods:
210
+ if isinstance(item, str) and item in _METHODS:
211
+ methods.append(item)
212
+ else:
213
+ complete = False
214
+ methods_complete = False
215
+ gaps.add("MAPPING_METHOD_UNREADABLE")
216
+ if len(methods) > 9:
217
+ methods = methods[:9]
218
+ complete = False
219
+ gaps.add("MAPPING_METHOD_LIMIT")
220
+ else:
221
+ complete = False
222
+ methods_complete = False
223
+ gaps.add("MAPPING_METHOD_UNREADABLE")
224
+ condition_rows, condition_complete = _conditions(conditions_raw)
225
+ complete = complete and condition_complete
226
+ elif isinstance(record.get("predicate"), str):
227
+ (
228
+ patterns,
229
+ methods,
230
+ condition_rows,
231
+ predicate_parsed,
232
+ ) = _parse_predicate(record["predicate"])
233
+ if predicate_parsed:
234
+ complete = True
235
+ methods_complete = True
236
+ else:
237
+ patterns = []
238
+ methods = []
239
+ condition_rows = []
240
+ complete = False
241
+ methods_complete = False
242
+ gaps.add("MAPPING_PREDICATE_UNPARSED")
243
+ else:
244
+ complete = False
245
+ methods_complete = False
246
+ gaps.add("MAPPING_CONDITIONS_UNOBSERVED")
247
+ if path_pattern and not any(
248
+ glob_matches(path_pattern, pattern) for pattern in patterns
249
+ ):
250
+ continue
251
+ unrestricted = not methods and methods_complete
252
+ if http_method and not unrestricted and http_method not in methods:
253
+ continue
254
+ if controller_pattern and not (
255
+ isinstance(class_name, str)
256
+ and glob_matches(controller_pattern, class_name)
257
+ ):
258
+ continue
259
+ found.append(
260
+ {
261
+ "contextId": context_name,
262
+ "parentContextId": parent
263
+ if isinstance(parent, str)
264
+ else None,
265
+ "stack": stack,
266
+ "dispatcherName": dispatcher
267
+ if isinstance(dispatcher, str)
268
+ else None,
269
+ "paths": patterns,
270
+ "httpMethods": methods,
271
+ "methodsUnrestricted": unrestricted,
272
+ "controllerClass": class_name
273
+ if isinstance(class_name, str)
274
+ else None,
275
+ "handlerMethod": method_name
276
+ if isinstance(method_name, str)
277
+ else None,
278
+ "descriptor": descriptor
279
+ if isinstance(descriptor, str)
280
+ else None,
281
+ "handlerKind": "method"
282
+ if isinstance(method, dict)
283
+ else "function"
284
+ if predicate_parsed or handler_function
285
+ else "unknown",
286
+ "conditions": condition_rows,
287
+ "matchingComplete": complete,
288
+ }
289
+ )
290
+ items = []
291
+ for index, row in enumerate(found, start=1):
292
+ row["id"] = f"mapping_{index:06d}"
293
+ items.append(row)
294
+ return items, sorted(gaps)