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,790 @@
1
+ """Compare caller expectations with one runtime snapshot.
2
+
3
+ Omitted dimensions are not read and not compared. An empty expectation is an
4
+ explicit empty scope. Unexpected runtime objects are reported only when the
5
+ caller marks that scope complete and the corresponding observation is complete.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+ from ..adapters.beans import normalize_beans
14
+ from ..adapters.mappings import normalize_mappings
15
+ from ..adapters.migrations import normalize_migrations
16
+ from ..analyzers.bean_graph import (
17
+ build_dependency_graph,
18
+ resolve_bean_reference,
19
+ slice_dependencies,
20
+ visible_beans,
21
+ )
22
+ from ..analyzers.condition_failures import is_assignable
23
+ from ..models.bean_graph import Bean, BeanGraph, BeanKey, ContextKey
24
+ from ..models.common import AnalysisBudget, AnalysisLimit, ParsedObservation
25
+ from ..models.queries import (
26
+ DriftQuery,
27
+ ExpectedBean,
28
+ ExpectedRoute,
29
+ MigrationQuery,
30
+ )
31
+ from ..models.source_catalog import SourceCatalog
32
+
33
+ _IDENTITY = re.compile(r"[A-Za-z0-9_$][A-Za-z0-9_.$#/@:+-]*\Z")
34
+ _FACT = re.compile(r"[A-Za-z0-9_.$#/@:+-]{1,256}\Z")
35
+ _PROFILE = re.compile(r"[A-Za-z0-9_.-]{1,128}\Z")
36
+ _BRACE = re.compile(r"\{([A-Za-z0-9_$]+)\}")
37
+ _DIM = {"beans": 0, "routes": 1, "migrations": 2}
38
+ _FLYWAY_GAPS = frozenset(
39
+ {
40
+ "FLYWAY_EVIDENCE_INVALID",
41
+ "FLYWAY_BEANS_UNOBSERVED",
42
+ "FLYWAY_MIGRATIONS_UNOBSERVED",
43
+ "MIGRATION_RECORD_UNREADABLE",
44
+ }
45
+ )
46
+
47
+
48
+ def _public_identity(value: str) -> str | None:
49
+ folded = _BRACE.sub(r":\1", value)
50
+ if len(folded) > 256 or _IDENTITY.fullmatch(folded) is None:
51
+ return None
52
+ return folded
53
+
54
+
55
+ def _fact(value: str) -> str | None:
56
+ if _FACT.fullmatch(value) is None:
57
+ return None
58
+ return value
59
+
60
+
61
+ def _record(
62
+ dimension: str,
63
+ identity: str,
64
+ context_id: str | None,
65
+ expected: str,
66
+ actual: str,
67
+ evidence: str,
68
+ affected: list[str],
69
+ ) -> dict[str, Any] | None:
70
+ public = _public_identity(identity)
71
+ safe_expected = _fact(expected)
72
+ safe_actual = _fact(actual)
73
+ if public is None or safe_expected is None or safe_actual is None:
74
+ return None
75
+ if evidence not in ("observed", "inferred", "unknown"):
76
+ return None
77
+ return {
78
+ "dimension": dimension,
79
+ "identity": public,
80
+ "contextId": context_id,
81
+ "expected": safe_expected,
82
+ "actual": safe_actual,
83
+ "evidenceLevel": evidence,
84
+ "affectedDependencyIds": affected[:16],
85
+ }
86
+
87
+
88
+ def _sort(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
89
+ return sorted(
90
+ rows,
91
+ key=lambda row: (
92
+ _DIM[row["dimension"]],
93
+ row["contextId"] or "",
94
+ row["identity"],
95
+ ),
96
+ )
97
+
98
+
99
+ def _affected(
100
+ graph: BeanGraph, key: BeanKey, max_depth: int, budget: AnalysisBudget
101
+ ) -> tuple[list[str], bool]:
102
+ sliced = slice_dependencies(
103
+ graph, (key,), direction="dependents", max_depth=max_depth, budget=budget
104
+ )
105
+ found: list[str] = []
106
+ for edge, _depth in sliced.edges:
107
+ bean = graph.beans.get(edge.from_key)
108
+ if bean is None or bean.id in found:
109
+ continue
110
+ found.append(bean.id)
111
+ found.sort()
112
+ return found[:16], sliced.truncated or len(found) > 16
113
+
114
+
115
+ def _type_relation(
116
+ bean: Bean, expected: str, catalog: SourceCatalog, budget: AnalysisBudget
117
+ ) -> str:
118
+ reported = bean.reported_type
119
+ if reported.state != "known" or not isinstance(reported.value, str):
120
+ return "unreported"
121
+ if reported.value == expected:
122
+ return "match"
123
+ try:
124
+ fact = is_assignable(reported.value, expected, catalog, budget=budget)
125
+ except AnalysisLimit:
126
+ return "unknown"
127
+ if fact.state != "known" or not isinstance(fact.value, bool):
128
+ return "unknown"
129
+ return "match" if fact.value else "mismatch"
130
+
131
+
132
+ def _aliases_match(bean: Bean, aliases: tuple[str, ...]) -> bool:
133
+ present = set(bean.aliases)
134
+ return all(alias in present for alias in aliases)
135
+
136
+
137
+ def _bean_records(
138
+ query: DriftQuery,
139
+ observation: ParsedObservation,
140
+ catalog: SourceCatalog,
141
+ gaps: list[str],
142
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]:
143
+ budget = AnalysisBudget()
144
+ graph = build_dependency_graph(
145
+ normalize_beans(observation, budget=budget), budget=budget
146
+ )
147
+ incomplete = graph.coverage != "complete"
148
+ if incomplete:
149
+ gaps.extend(graph.gaps)
150
+ expectations = query.expected_beans or ()
151
+ missing: list[dict[str, Any]] = []
152
+ covered: set[BeanKey] = set()
153
+ blocked = False
154
+ for expected in expectations:
155
+ outcome, keys, blocked_one = _one_bean(
156
+ graph, observation, expected, catalog, budget, gaps, query.max_depth
157
+ )
158
+ blocked = blocked or blocked_one or expected.context_id is None
159
+ covered.update(keys)
160
+ if outcome is not None:
161
+ missing.append(outcome)
162
+ unexpected: list[dict[str, Any]] = []
163
+ scope_closed = (
164
+ query.expected_scope_complete
165
+ and not blocked
166
+ and graph.coverage == "complete"
167
+ and expectations is not None
168
+ )
169
+ if scope_closed:
170
+ for key, bean in sorted(graph.beans.items()):
171
+ if key in covered:
172
+ continue
173
+ identity = f"{key.context.context_id}:{key.canonical_name}"
174
+ row = _record(
175
+ "beans",
176
+ identity,
177
+ key.context.context_id,
178
+ "absent",
179
+ "present",
180
+ "observed",
181
+ [],
182
+ )
183
+ if row is None:
184
+ gaps.append("IDENTITY_NOT_SAFE")
185
+ incomplete = True
186
+ continue
187
+ unexpected.append(row)
188
+ return missing, unexpected, incomplete
189
+
190
+
191
+ def _one_bean(
192
+ graph: BeanGraph,
193
+ observation: ParsedObservation,
194
+ expected: ExpectedBean,
195
+ catalog: SourceCatalog,
196
+ budget: AnalysisBudget,
197
+ gaps: list[str],
198
+ max_depth: int,
199
+ ) -> tuple[dict[str, Any] | None, set[BeanKey], bool]:
200
+ """Return a diff, the runtime keys that satisfy the expectation, and whether
201
+ unexpected enumeration must be suppressed.
202
+ """
203
+ keys = _candidate_beans(graph, observation, expected)
204
+ if keys is None:
205
+ gaps.append("BEAN_INDEX_INCOMPLETE")
206
+ return None, set(), True
207
+ if len(keys) > 1 and expected.name is not None:
208
+ satisfying = [
209
+ key
210
+ for key in keys
211
+ if _bean_satisfies(graph, key, expected, catalog, budget, gaps) == "match"
212
+ ]
213
+ if satisfying:
214
+ return None, set(satisfying), False
215
+ gaps.append("BEAN_SCOPE_AMBIGUOUS")
216
+ return None, set(), True
217
+ if expected.name is None:
218
+ return _type_only(
219
+ graph, observation, expected, keys, catalog, budget, gaps, max_depth
220
+ )
221
+ if not keys:
222
+ row = _absent_bean(graph, observation, expected, gaps)
223
+ return row, set(), row is None
224
+ key = keys[0]
225
+ relation = _bean_satisfies(graph, key, expected, catalog, budget, gaps)
226
+ if relation == "match":
227
+ return None, {key}, False
228
+ if relation != "mismatch":
229
+ return None, set(), True
230
+ row, cut = _mismatch_record(graph, key, expected, catalog, budget, gaps, max_depth)
231
+ return row, {key}, cut
232
+
233
+
234
+ def _bean_satisfies(
235
+ graph: BeanGraph,
236
+ key: BeanKey,
237
+ expected: ExpectedBean,
238
+ catalog: SourceCatalog,
239
+ budget: AnalysisBudget,
240
+ gaps: list[str],
241
+ ) -> str:
242
+ bean = graph.beans[key]
243
+ if expected.type_name:
244
+ relation = _type_relation(bean, expected.type_name, catalog, budget)
245
+ if relation == "unreported":
246
+ gaps.append("TYPE_NOT_REPORTED")
247
+ return "unknown"
248
+ if relation == "unknown":
249
+ gaps.append("SOURCE_TYPE_UNOBSERVED")
250
+ return "unknown"
251
+ if relation == "mismatch":
252
+ return "mismatch"
253
+ if not _aliases_match(bean, expected.aliases):
254
+ return "mismatch"
255
+ return "match"
256
+
257
+
258
+ def _candidate_beans(
259
+ graph: BeanGraph, observation: ParsedObservation, expected: ExpectedBean
260
+ ) -> list[BeanKey] | None:
261
+ """None means the searched hierarchy is not fully visible."""
262
+ if expected.name is None:
263
+ return _beans_by_type(graph, observation, expected)
264
+ starts = _start_contexts(graph, observation, expected.context_id)
265
+ if starts is None:
266
+ return None
267
+ found: list[BeanKey] = []
268
+ for start in starts:
269
+ resolution = resolve_bean_reference(graph, start, expected.name)
270
+ if resolution.state == "ambiguous":
271
+ return None
272
+ if resolution.state == "resolved":
273
+ found.append(resolution.candidates[0])
274
+ continue
275
+ if resolution.reason_code != "NOT_FOUND_IN_COMPLETE_VISIBLE_HIERARCHY":
276
+ return None
277
+ unique = sorted(set(found))
278
+ return unique
279
+
280
+
281
+ def _beans_by_type(
282
+ graph: BeanGraph, observation: ParsedObservation, expected: ExpectedBean
283
+ ) -> list[BeanKey] | None:
284
+ if expected.type_name is None:
285
+ return []
286
+ if expected.context_id is None:
287
+ if graph.coverage != "complete":
288
+ return None
289
+ return sorted(graph.beans)
290
+ start = ContextKey(
291
+ observation.target_id, observation.snapshot_id, expected.context_id
292
+ )
293
+ if start not in graph.contexts:
294
+ return [] if graph.coverage == "complete" else None
295
+ visible = visible_beans(graph, start)
296
+ return None if visible is None else sorted(visible)
297
+
298
+
299
+ def _start_contexts(
300
+ graph: BeanGraph, observation: ParsedObservation, context_id: str | None
301
+ ) -> list[ContextKey] | None:
302
+ if context_id is None:
303
+ if graph.coverage != "complete":
304
+ return None
305
+ return sorted(graph.contexts)
306
+ key = ContextKey(observation.target_id, observation.snapshot_id, context_id)
307
+ if key not in graph.contexts:
308
+ return [] if graph.coverage == "complete" else None
309
+ return [key]
310
+
311
+
312
+ def _absent_bean(
313
+ graph: BeanGraph,
314
+ observation: ParsedObservation,
315
+ expected: ExpectedBean,
316
+ gaps: list[str],
317
+ ) -> dict[str, Any] | None:
318
+ if not _absence_is_observed(graph, observation, expected):
319
+ gaps.append("BEAN_INDEX_INCOMPLETE")
320
+ return None
321
+ identity = (
322
+ f"{expected.context_id}:{expected.name}"
323
+ if expected.context_id and expected.name
324
+ else f"unscoped:{expected.name or expected.type_name or 'bean'}"
325
+ )
326
+ row = _record(
327
+ "beans",
328
+ identity,
329
+ expected.context_id,
330
+ expected.type_name or "present",
331
+ "absent",
332
+ "observed",
333
+ [],
334
+ )
335
+ if row is None:
336
+ gaps.append("IDENTITY_NOT_SAFE")
337
+ return row
338
+
339
+
340
+ def _type_only(
341
+ graph: BeanGraph,
342
+ observation: ParsedObservation,
343
+ expected: ExpectedBean,
344
+ keys: list[BeanKey],
345
+ catalog: SourceCatalog,
346
+ budget: AnalysisBudget,
347
+ gaps: list[str],
348
+ max_depth: int,
349
+ ) -> tuple[dict[str, Any] | None, set[BeanKey], bool]:
350
+ if expected.type_name is None:
351
+ gaps.append("BEAN_INDEX_INCOMPLETE")
352
+ return None, set(), True
353
+ matched: set[BeanKey] = set()
354
+ mismatches: list[BeanKey] = []
355
+ unknown = False
356
+ for key in keys:
357
+ relation = _type_relation(graph.beans[key], expected.type_name, catalog, budget)
358
+ if relation == "match" and _aliases_match(graph.beans[key], expected.aliases):
359
+ matched.add(key)
360
+ elif relation == "match":
361
+ mismatches.append(key)
362
+ elif relation in ("unknown", "unreported"):
363
+ unknown = True
364
+ if matched:
365
+ return None, matched, False
366
+ if len(mismatches) == 1 and not unknown:
367
+ row, cut = _mismatch_record(
368
+ graph, mismatches[0], expected, catalog, budget, gaps, max_depth
369
+ )
370
+ return row, {mismatches[0]}, cut or row is None
371
+ if mismatches or unknown:
372
+ gaps.append("BEAN_SCOPE_AMBIGUOUS" if mismatches else "SOURCE_TYPE_UNOBSERVED")
373
+ return None, set(), True
374
+ if not _absence_is_observed(graph, observation, expected):
375
+ gaps.append("BEAN_INDEX_INCOMPLETE")
376
+ return None, set(), True
377
+ row = _absent_bean(graph, observation, expected, gaps)
378
+ return row, set(), row is None
379
+
380
+
381
+ def _mismatch_record(
382
+ graph: BeanGraph,
383
+ key: BeanKey,
384
+ expected: ExpectedBean,
385
+ catalog: SourceCatalog,
386
+ budget: AnalysisBudget,
387
+ gaps: list[str],
388
+ max_depth: int,
389
+ ) -> tuple[dict[str, Any] | None, bool]:
390
+ bean = graph.beans[key]
391
+ affected, cut = _affected(graph, key, max_depth, budget)
392
+ if cut:
393
+ gaps.append("AFFECTED_DEPENDENCY_LIMIT")
394
+ if (
395
+ expected.type_name
396
+ and _type_relation(bean, expected.type_name, catalog, budget) == "mismatch"
397
+ ):
398
+ expected_text = expected.type_name
399
+ actual_text = bean.reported_type.value or "unknown"
400
+ else:
401
+ expected_text = next(
402
+ (alias for alias in expected.aliases if alias not in bean.aliases), "alias"
403
+ )
404
+ actual_text = "alias-absent"
405
+ row = _record(
406
+ "beans",
407
+ f"{key.context.context_id}:{key.canonical_name}",
408
+ key.context.context_id,
409
+ expected_text,
410
+ actual_text,
411
+ "observed",
412
+ affected,
413
+ )
414
+ if row is None:
415
+ gaps.append("IDENTITY_NOT_SAFE")
416
+ return None, True
417
+ return row, cut
418
+
419
+
420
+ def _absence_is_observed(
421
+ graph: BeanGraph, observation: ParsedObservation, expected: ExpectedBean
422
+ ) -> bool:
423
+ if expected.context_id is None:
424
+ return graph.coverage == "complete"
425
+ key = ContextKey(
426
+ observation.target_id, observation.snapshot_id, expected.context_id
427
+ )
428
+ if key not in graph.contexts:
429
+ return graph.coverage == "complete"
430
+ if expected.name is None:
431
+ return True
432
+ resolution = resolve_bean_reference(graph, key, expected.name)
433
+ return resolution.reason_code == "NOT_FOUND_IN_COMPLETE_VISIBLE_HIERARCHY"
434
+
435
+
436
+ def _route_records(
437
+ query: DriftQuery, body: Any, gaps: list[str]
438
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]:
439
+ observed, parsed_gaps = normalize_mappings(
440
+ body,
441
+ path_pattern=None,
442
+ http_method=None,
443
+ controller_pattern=None,
444
+ context_id=None,
445
+ )
446
+ gaps.extend(parsed_gaps)
447
+ incomplete = bool(parsed_gaps)
448
+ routes = _explode_routes(observed)
449
+ expectations = query.expected_routes or ()
450
+ missing: list[dict[str, Any]] = []
451
+ covered: set[tuple[str, str, str]] = set()
452
+ for expected in expectations:
453
+ match = next(
454
+ (route for route in routes if _route_matches(route, expected)), None
455
+ )
456
+ if match is not None:
457
+ covered.add(match["key"])
458
+ continue
459
+ if incomplete:
460
+ continue
461
+ identity = (
462
+ f"{expected.context_id}:{expected.http_method}:{expected.path}"
463
+ if expected.context_id
464
+ else f"unscoped:{expected.http_method}:{expected.path}"
465
+ )
466
+ row = _record(
467
+ "routes",
468
+ identity,
469
+ expected.context_id,
470
+ expected.controller_class or "present",
471
+ "absent",
472
+ "observed",
473
+ [],
474
+ )
475
+ if row is None:
476
+ gaps.append("IDENTITY_NOT_SAFE")
477
+ incomplete = True
478
+ continue
479
+ missing.append(row)
480
+ unexpected: list[dict[str, Any]] = []
481
+ closed = (
482
+ query.expected_scope_complete
483
+ and not incomplete
484
+ and all(item.context_id is not None for item in expectations)
485
+ )
486
+ if closed:
487
+ seen: set[tuple[str, str, str]] = set()
488
+ for route in routes:
489
+ if route["key"] in covered or route["key"] in seen:
490
+ continue
491
+ seen.add(route["key"])
492
+ row = _record(
493
+ "routes",
494
+ f"{route['contextId']}:{route['method']}:{route['path']}",
495
+ route["contextId"],
496
+ "absent",
497
+ "present",
498
+ "observed",
499
+ [],
500
+ )
501
+ if row is None:
502
+ gaps.append("IDENTITY_NOT_SAFE")
503
+ incomplete = True
504
+ continue
505
+ unexpected.append(row)
506
+ return missing, unexpected, incomplete
507
+
508
+
509
+ def _explode_routes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
510
+ routes: list[dict[str, Any]] = []
511
+ for row in rows:
512
+ methods = row["httpMethods"] or ["ANY"]
513
+ for path in row["paths"]:
514
+ if not isinstance(path, str):
515
+ continue
516
+ for method in methods:
517
+ routes.append(
518
+ {
519
+ "key": (row["contextId"], method, path),
520
+ "contextId": row["contextId"],
521
+ "method": method,
522
+ "path": path,
523
+ "controller": row["controllerClass"],
524
+ }
525
+ )
526
+ return routes
527
+
528
+
529
+ def _route_matches(route: dict[str, Any], expected: ExpectedRoute) -> bool:
530
+ if expected.context_id is not None and route["contextId"] != expected.context_id:
531
+ return False
532
+ if route["path"] != expected.path:
533
+ return False
534
+ if route["method"] not in ("ANY", expected.http_method):
535
+ return False
536
+ return not (
537
+ expected.controller_class and route["controller"] != expected.controller_class
538
+ )
539
+
540
+
541
+ def _migration_record(
542
+ query: DriftQuery, body: Any | None, gaps: list[str]
543
+ ) -> tuple[list[dict[str, Any]], bool]:
544
+ expected = query.expected_migration
545
+ if expected is None:
546
+ return [], False
547
+ if expected.engine == "liquibase":
548
+ gaps.append("MIGRATION_VERSION_UNSUPPORTED")
549
+ if body is None:
550
+ gaps.append("MIGRATION_ENDPOINT_UNOBSERVED")
551
+ return [], True
552
+ if body is None:
553
+ gaps.append("MIGRATION_ENDPOINT_UNOBSERVED")
554
+ return [], True
555
+ rows, parsed_gaps, _visibility = normalize_migrations(
556
+ body, None, MigrationQuery(engine="flyway")
557
+ )
558
+ gaps.extend(parsed_gaps)
559
+ selected = [
560
+ row
561
+ for row in rows
562
+ if row["engine"] == "flyway"
563
+ and (expected.context_id is None or row["contextId"] == expected.context_id)
564
+ and (
565
+ expected.migration_bean is None
566
+ or row["migrationBean"] == expected.migration_bean
567
+ )
568
+ ]
569
+ groups: dict[tuple[str | None, str], list[dict[str, Any]]] = {}
570
+ for row in selected:
571
+ groups.setdefault((row["contextId"], row["migrationBean"]), []).append(row)
572
+ obscured = any(gap in _FLYWAY_GAPS for gap in parsed_gaps)
573
+ if len(groups) > 1:
574
+ gaps.append("MIGRATION_SCOPE_AMBIGUOUS")
575
+ return [], True
576
+ if not groups:
577
+ if obscured:
578
+ gaps.append("MIGRATION_VERSION_UNOBSERVED")
579
+ return [], True
580
+ identity = (
581
+ f"{expected.context_id or 'unscoped'}:{expected.migration_bean or 'flyway'}"
582
+ )
583
+ absent = _record(
584
+ "migrations",
585
+ identity,
586
+ expected.context_id,
587
+ expected.version,
588
+ "absent",
589
+ "observed",
590
+ [],
591
+ )
592
+ if absent is None:
593
+ gaps.append("IDENTITY_NOT_SAFE")
594
+ return [], True
595
+ return [absent], False
596
+ context_id, bean_name = next(iter(groups))
597
+ installed, install_state = _installed_version(groups[(context_id, bean_name)])
598
+ if install_state == "unknown" or (install_state == "ok" and installed is None):
599
+ gaps.append("MIGRATION_ORDER_UNKNOWN")
600
+ return [], True
601
+ actual = installed if install_state == "ok" and installed is not None else "absent"
602
+ if actual == expected.version:
603
+ return [], obscured
604
+ diff = _record(
605
+ "migrations",
606
+ f"{context_id or 'unscoped'}:{bean_name}",
607
+ context_id if isinstance(context_id, str) else None,
608
+ expected.version,
609
+ actual,
610
+ "observed",
611
+ [],
612
+ )
613
+ if diff is None:
614
+ gaps.append("IDENTITY_NOT_SAFE")
615
+ return [], True
616
+ return [diff], obscured
617
+
618
+
619
+ def _installed_version(rows: list[dict[str, Any]]) -> tuple[str | None, str]:
620
+ ranked: list[tuple[int, str]] = []
621
+ saw_unranked = False
622
+ for row in rows:
623
+ if row.get("rawState") not in {
624
+ "SUCCESS",
625
+ "OUT_OF_ORDER",
626
+ "MISSING_SUCCESS",
627
+ "FUTURE_SUCCESS",
628
+ } or not isinstance(row.get("version"), str):
629
+ continue
630
+ rank = row.get("orderExecuted")
631
+ if type(rank) is not int:
632
+ saw_unranked = True
633
+ continue
634
+ ranked.append((rank, row["version"]))
635
+ if saw_unranked:
636
+ return None, "unknown"
637
+ if not ranked:
638
+ return None, "absent"
639
+ top = max(rank for rank, _version in ranked)
640
+ winners = [version for rank, version in ranked if rank == top]
641
+ if len(set(winners)) != 1:
642
+ return None, "unknown"
643
+ return winners[0], "ok"
644
+
645
+
646
+ def _profiles(
647
+ expected: tuple[str, ...], body: Any | None, gaps: list[str]
648
+ ) -> tuple[dict[str, Any], bool, bool]:
649
+ safe_expected, expected_unsafe = _profile_tokens(expected)
650
+ if body is None:
651
+ gaps.append("PROFILE_UNOBSERVED")
652
+ return (
653
+ {"expected": safe_expected, "actual": None, "state": "unknown"},
654
+ True,
655
+ False,
656
+ )
657
+ if not isinstance(body, dict) or not isinstance(body.get("activeProfiles"), list):
658
+ gaps.append("ENV_PROFILES_UNOBSERVED")
659
+ return (
660
+ {"expected": safe_expected, "actual": None, "state": "unknown"},
661
+ True,
662
+ False,
663
+ )
664
+ actual, actual_unsafe = _profile_tokens(tuple(body["activeProfiles"]))
665
+ override = _environment_override(body)
666
+ if expected_unsafe or actual_unsafe:
667
+ gaps.append("PROFILE_TOKEN_UNSAFE")
668
+ return (
669
+ {"expected": safe_expected, "actual": None, "state": "unknown"},
670
+ True,
671
+ False,
672
+ )
673
+ state = "match" if set(safe_expected) == set(actual) else "mismatch"
674
+ return (
675
+ {"expected": safe_expected, "actual": actual, "state": state},
676
+ False,
677
+ override and state == "mismatch",
678
+ )
679
+
680
+
681
+ def _profile_tokens(values: tuple[Any, ...]) -> tuple[list[str], bool]:
682
+ safe: list[str] = []
683
+ unsafe = False
684
+ for value in values:
685
+ if isinstance(value, str) and _PROFILE.fullmatch(value):
686
+ safe.append(value)
687
+ else:
688
+ unsafe = True
689
+ return sorted(set(safe)), unsafe
690
+
691
+
692
+ def _environment_override(body: dict[str, Any]) -> bool:
693
+ single = body.get("property")
694
+ if (
695
+ isinstance(single, dict)
696
+ and single.get("source") == "systemEnvironment"
697
+ and _visible_scalar(single.get("value"))
698
+ ):
699
+ return True
700
+ sources = body.get("propertySources")
701
+ if not isinstance(sources, list):
702
+ return False
703
+ for source in sources:
704
+ if not isinstance(source, dict):
705
+ continue
706
+ properties = source.get("properties")
707
+ if (
708
+ not isinstance(properties, dict)
709
+ or "spring.profiles.active" not in properties
710
+ ):
711
+ continue
712
+ raw_name = source.get("name")
713
+ name = raw_name if isinstance(raw_name, str) else ""
714
+ winning = "systemenvironment" in name.casefold().replace("_", "")
715
+ spec = properties["spring.profiles.active"]
716
+ raw = spec.get("value") if isinstance(spec, dict) else spec
717
+ return winning and _visible_scalar(raw)
718
+ return False
719
+
720
+
721
+ def _visible_scalar(value: Any) -> bool:
722
+ if isinstance(value, str):
723
+ return (
724
+ bool(value)
725
+ and not any(char.isspace() for char in value)
726
+ and set(value) != {"*"}
727
+ )
728
+ return type(value) in (int, bool)
729
+
730
+
731
+ def compare_drift(
732
+ query: DriftQuery,
733
+ *,
734
+ beans: ParsedObservation | None,
735
+ mappings_body: Any | None,
736
+ flyway_body: Any | None,
737
+ migration_observed: bool,
738
+ env_body: Any | None,
739
+ catalog: SourceCatalog,
740
+ ) -> dict[str, Any]:
741
+ gaps: list[str] = []
742
+ missing: list[dict[str, Any]] = []
743
+ unexpected: list[dict[str, Any]] = []
744
+ incomplete = False
745
+ if query.expected_beans is not None:
746
+ if beans is None:
747
+ gaps.append("BEANS_UNOBSERVED")
748
+ incomplete = True
749
+ else:
750
+ bean_missing, bean_unexpected, bean_incomplete = _bean_records(
751
+ query, beans, catalog, gaps
752
+ )
753
+ missing.extend(bean_missing)
754
+ unexpected.extend(bean_unexpected)
755
+ incomplete = incomplete or bean_incomplete
756
+ if query.expected_routes is not None:
757
+ if mappings_body is None:
758
+ gaps.append("MAPPINGS_UNOBSERVED")
759
+ incomplete = True
760
+ else:
761
+ route_missing, route_unexpected, route_incomplete = _route_records(
762
+ query, mappings_body, gaps
763
+ )
764
+ missing.extend(route_missing)
765
+ unexpected.extend(route_unexpected)
766
+ incomplete = incomplete or route_incomplete
767
+ override = False
768
+ profile = None
769
+ if query.expected_migration is not None:
770
+ body = (
771
+ flyway_body
772
+ if query.expected_migration.engine == "flyway"
773
+ else ({} if migration_observed else None)
774
+ )
775
+ migration_rows, migration_incomplete = _migration_record(query, body, gaps)
776
+ missing.extend(migration_rows)
777
+ incomplete = incomplete or migration_incomplete
778
+ if query.expected_profiles is not None:
779
+ profile, profile_incomplete, override = _profiles(
780
+ query.expected_profiles, env_body, gaps
781
+ )
782
+ incomplete = incomplete or profile_incomplete
783
+ return {
784
+ "missing": _sort(missing),
785
+ "unexpected": _sort(unexpected),
786
+ "profile": profile,
787
+ "gaps": sorted(set(gaps)),
788
+ "incomplete": incomplete or bool(gaps),
789
+ "environment_override": override,
790
+ }