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,3 @@
1
+ """Bounded, read-only Spring Boot diagnostic analysis."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1 @@
1
+ """Convert bounded Actuator observations into diagnostic facts."""
@@ -0,0 +1,159 @@
1
+ """Normalize Actuator beans without inventing absent container facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, TypeGuard
7
+
8
+ from ..models.bean_graph import Bean, BeanGraph, BeanKey, Context, ContextKey
9
+ from ..models.common import (
10
+ AnalysisBudget,
11
+ AnalysisLimit,
12
+ Fact,
13
+ NormalizationError,
14
+ ParsedObservation,
15
+ )
16
+
17
+
18
+ def _text(value: Any) -> TypeGuard[str]:
19
+ return isinstance(value, str) and 0 < len(value) <= 4096
20
+
21
+
22
+ def _fact(record: Mapping[str, Any], name: str) -> Fact[str]:
23
+ value = record.get(name)
24
+ if _text(value):
25
+ return Fact("known", value, None)
26
+ return Fact("notReported", None, "FIELD_NOT_REPORTED")
27
+
28
+
29
+ def normalize_beans(
30
+ observation: ParsedObservation, *, budget: AnalysisBudget
31
+ ) -> BeanGraph:
32
+ """Build all local name indices before resolving any dependency edges."""
33
+ body = observation.body
34
+ if not isinstance(body, Mapping) or not isinstance(body.get("contexts"), Mapping):
35
+ raise NormalizationError("INVALID_BEANS_STRUCTURE")
36
+ contexts: dict[ContextKey, Context] = {}
37
+ beans: dict[BeanKey, Bean] = {}
38
+ names: dict[ContextKey, dict[str, tuple[BeanKey, ...]]] = {}
39
+ canonical: dict[BeanKey, tuple[str, ...]] = {}
40
+ gaps: set[str] = set()
41
+ try:
42
+ for context_id, raw_context in sorted(
43
+ body["contexts"].items(), key=lambda item: str(item[0])
44
+ ):
45
+ budget.consume()
46
+ if not _text(context_id):
47
+ gaps.add("INVALID_CONTEXT_NAME")
48
+ continue
49
+ key = ContextKey(observation.target_id, observation.snapshot_id, context_id)
50
+ names[key] = {}
51
+ if not isinstance(raw_context, Mapping):
52
+ contexts[key] = Context(
53
+ key, Fact("unknown", None, "INVALID_CONTEXT"), False
54
+ )
55
+ gaps.add("INVALID_CONTEXT")
56
+ continue
57
+ parent_id = raw_context.get("parentId")
58
+ if "parentId" not in raw_context:
59
+ # Boot reports parentId only for child contexts; omitting it
60
+ # means this is a root context, not unobserved hierarchy.
61
+ parent: Fact[ContextKey | None] = Fact("known", None, None)
62
+ elif parent_id is None:
63
+ parent = Fact("known", None, None)
64
+ elif _text(parent_id):
65
+ parent = Fact(
66
+ "known", ContextKey(key.target_id, key.snapshot_id, parent_id), None
67
+ )
68
+ else:
69
+ parent = Fact("unknown", None, "INVALID_PARENT_CONTEXT")
70
+ gaps.add("INVALID_PARENT_CONTEXT")
71
+ raw_beans = raw_context.get("beans")
72
+ contexts[key] = Context(key, parent, isinstance(raw_beans, Mapping))
73
+ if not isinstance(raw_beans, Mapping):
74
+ gaps.add("BEAN_INDEX_INCOMPLETE")
75
+ continue
76
+ for name, raw in sorted(raw_beans.items(), key=lambda item: str(item[0])):
77
+ budget.consume()
78
+ if not _text(name) or not isinstance(raw, Mapping):
79
+ contexts[key] = Context(key, parent, False)
80
+ gaps.add("BEAN_INDEX_INCOMPLETE")
81
+ continue
82
+ bean_key = BeanKey(key, name)
83
+ aliases_raw = raw.get("aliases")
84
+ if not isinstance(aliases_raw, list) or not all(
85
+ _text(alias) for alias in aliases_raw
86
+ ):
87
+ aliases = ()
88
+ contexts[key] = Context(key, parent, False)
89
+ gaps.add("ALIASES_NOT_REPORTED")
90
+ else:
91
+ aliases = tuple(sorted(set(aliases_raw)))
92
+ dependencies_raw = raw.get("dependencies")
93
+ if not isinstance(dependencies_raw, list) or not all(
94
+ _text(dep) for dep in dependencies_raw
95
+ ):
96
+ dependencies = ()
97
+ gaps.add("DEPENDENCIES_NOT_REPORTED")
98
+ else:
99
+ dependencies = tuple(sorted(set(dependencies_raw)))
100
+ # Opaque ordinals never encode or hash untrusted labels.
101
+ identifier = f"bean_{len(beans) + 1:06d}"
102
+ beans[bean_key] = Bean(
103
+ bean_key,
104
+ identifier,
105
+ aliases,
106
+ _fact(raw, "type"),
107
+ _fact(raw, "scope"),
108
+ _fact(raw, "resource"),
109
+ dependencies,
110
+ (observation.observation_id,),
111
+ )
112
+ all_names = tuple(sorted({name, *aliases}))
113
+ canonical[bean_key] = all_names
114
+ for local_name in all_names:
115
+ budget.consume()
116
+ names[key][local_name] = tuple(
117
+ sorted((*names[key].get(local_name, ()), bean_key))
118
+ )
119
+ except AnalysisLimit:
120
+ gaps.add("GRAPH_LIMIT_REACHED")
121
+ # Any interrupted index may have omitted names; no ancestor fallback is safe.
122
+ contexts = {
123
+ key: Context(key, ctx.parent, False) for key, ctx in contexts.items()
124
+ }
125
+ for context in contexts.values():
126
+ if (
127
+ context.parent.state == "known"
128
+ and context.parent.value is not None
129
+ and context.parent.value not in contexts
130
+ ):
131
+ gaps.add("PARENT_CONTEXT_UNOBSERVED")
132
+ checked: set[ContextKey] = set()
133
+ try:
134
+ for start in contexts:
135
+ current: ContextKey | None = start
136
+ path: set[ContextKey] = set()
137
+ while current is not None and current not in checked:
138
+ budget.consume()
139
+ if current in path:
140
+ gaps.add("CONTEXT_CYCLE")
141
+ break
142
+ path.add(current)
143
+ ancestor = contexts.get(current)
144
+ if ancestor is None or ancestor.parent.state != "known":
145
+ break
146
+ current = ancestor.parent.value
147
+ checked.update(path)
148
+ except AnalysisLimit:
149
+ gaps.add("GRAPH_LIMIT_REACHED")
150
+ return BeanGraph(
151
+ contexts,
152
+ beans,
153
+ names,
154
+ canonical,
155
+ {},
156
+ {},
157
+ "partial" if gaps else "complete",
158
+ tuple(sorted(gaps)),
159
+ )
@@ -0,0 +1,333 @@
1
+ """Bounded, conservative adapters for known Actuator condition grammars."""
2
+
3
+ import re
4
+ from dataclasses import replace
5
+ from types import MappingProxyType
6
+ from typing import Any, Literal, cast
7
+
8
+ from ..models.bean_graph import ContextKey
9
+ from ..models.common import AnalysisBudget, Fact, NormalizationError, ParsedObservation
10
+ from ..models.conditions import Condition, ConditionReport, Outcome, Search
11
+
12
+ _IDENT = r"[A-Za-z_$][A-Za-z0-9_.$]*"
13
+ _SOURCE = re.compile(_IDENT + r"(?:#[A-Za-z_$][A-Za-z0-9_$]*)?\Z")
14
+ _NESTED = re.compile(
15
+ r"(AnyNestedCondition|AllNestedConditions|NoneNestedConditions) (\d{1,4}) matched (\d{1,4}) did not\Z"
16
+ )
17
+ _BEAN = re.compile(
18
+ r"@ConditionalOn(OnMissingBean|Bean)"
19
+ ) # dispatch uses explicit spelling below
20
+
21
+
22
+ def combine(operator: str, values: tuple[Outcome, ...], complete: bool) -> Outcome:
23
+ if operator == "any" and "matched" in values:
24
+ return "matched"
25
+ if (
26
+ operator in ("all", "none")
27
+ and ("notMatched" if operator == "all" else "matched") in values
28
+ ):
29
+ return "notMatched"
30
+ if not complete:
31
+ return "unknown"
32
+ if operator == "any":
33
+ return "notMatched" if all(v == "notMatched" for v in values) else "unknown"
34
+ if operator == "all":
35
+ return "matched" if all(v == "matched" for v in values) else "unknown"
36
+ if operator == "none":
37
+ return "matched" if all(v == "notMatched" for v in values) else "unknown"
38
+ return "unknown"
39
+
40
+
41
+ def _leaf(
42
+ message: str,
43
+ ) -> tuple[Search, tuple[str, ...], tuple[str, ...], Fact[str], str, bool, Outcome]:
44
+ unknown: Fact[str] = Fact("notReported", None, "NOT_REPORTED")
45
+ bean = re.fullmatch(
46
+ r"@ConditionalOn(Bean|MissingBean) \((types|names): ([A-Za-z0-9_.$, ]+); SearchStrategy: (all|current|parents)\) (.+)",
47
+ message,
48
+ )
49
+ if bean:
50
+ kind, selector, entries, strategy, suffix = bean.groups()
51
+ required = tuple(s.strip() for s in entries.split(","))
52
+ if not required or any(not _SOURCE.fullmatch(s) for s in required):
53
+ return (
54
+ "unknown",
55
+ (),
56
+ (),
57
+ unknown,
58
+ "UNKNOWN_CONDITION_GRAMMAR",
59
+ False,
60
+ "unknown",
61
+ )
62
+ missing = re.fullmatch(
63
+ r"did not find any beans(?: of type " + _IDENT + ")?", suffix
64
+ )
65
+ found = re.fullmatch(r"found bean '[A-Za-z0-9_.$]+'", suffix)
66
+ if missing or found:
67
+ outcome: Outcome = (
68
+ "matched"
69
+ if (bool(missing) == (kind == "MissingBean"))
70
+ else "notMatched"
71
+ )
72
+ return (
73
+ cast(Search, strategy.upper()),
74
+ required if selector == "types" else (),
75
+ required if selector == "names" else (),
76
+ unknown,
77
+ "REQUIRED_BEAN_MISSING"
78
+ if missing and kind == "Bean"
79
+ else "CONDITION_MATCH"
80
+ if outcome == "matched"
81
+ else "BEAN_PRESENT",
82
+ True,
83
+ outcome,
84
+ )
85
+ prop = re.fullmatch(
86
+ r"@ConditionalOnProperty \(([A-Za-z0-9_.-]+)=([A-Za-z0-9_.-]+)\) did not find property '([A-Za-z0-9_.-]+)'",
87
+ message,
88
+ )
89
+ if prop and (prop[1] == prop[3] or prop[1].endswith("." + prop[3])):
90
+ return (
91
+ "unknown",
92
+ (),
93
+ (),
94
+ Fact("known", prop[1], None),
95
+ "PROPERTY_NOT_FOUND",
96
+ True,
97
+ "notMatched",
98
+ )
99
+ mismatch = re.fullmatch(
100
+ r'@ConditionalOnProperty \(([A-Za-z0-9_.-]+)=([A-Za-z0-9_.-]+)\) found different value in property [\'"]([A-Za-z0-9_.-]+)[\'"]',
101
+ message,
102
+ )
103
+ if mismatch and (
104
+ mismatch[1] == mismatch[3] or mismatch[1].endswith("." + mismatch[3])
105
+ ):
106
+ return (
107
+ "unknown",
108
+ (),
109
+ (),
110
+ Fact("known", mismatch[1], None),
111
+ "PROPERTY_VALUE_MISMATCH",
112
+ True,
113
+ "notMatched",
114
+ )
115
+ return "unknown", (), (), unknown, "UNKNOWN_CONDITION_GRAMMAR", False, "unknown"
116
+
117
+
118
+ def normalize_conditions(
119
+ observation: ParsedObservation, *, budget: AnalysisBudget
120
+ ) -> ConditionReport:
121
+ body = observation.body
122
+ if not isinstance(body, dict) or not isinstance(body.get("contexts"), dict):
123
+ raise NormalizationError("INVALID_CONDITIONS_SHAPE")
124
+ nodes: dict[str, Condition] = {}
125
+ roots: dict[tuple[ContextKey, str], tuple[str, ...]] = {}
126
+ gaps: set[str] = set()
127
+
128
+ def parse(
129
+ context: ContextKey,
130
+ source: str,
131
+ condition_type: Any,
132
+ message: Any,
133
+ reported: Outcome,
134
+ depth: int = 0,
135
+ ) -> str:
136
+ budget.consume()
137
+ node_id = f"condition_{len(nodes)}"
138
+ base = Condition(
139
+ node_id,
140
+ context,
141
+ source,
142
+ condition_type
143
+ if isinstance(condition_type, str)
144
+ and len(condition_type) <= 256
145
+ and _SOURCE.fullmatch(condition_type)
146
+ else None,
147
+ "leaf",
148
+ reported,
149
+ "unknown",
150
+ "unknown",
151
+ (),
152
+ (),
153
+ Fact("notReported", None, "NOT_REPORTED"),
154
+ (),
155
+ False,
156
+ "UNKNOWN_CONDITION_GRAMMAR",
157
+ (observation.observation_id,),
158
+ )
159
+ nodes[node_id] = base
160
+ if not isinstance(message, str) or len(message.encode()) > 8192 or depth >= 16:
161
+ gaps.add("UNKNOWN_CONDITION_GRAMMAR")
162
+ return node_id
163
+ segments = re.split(r"; NestedCondition on ", message)
164
+ nested = _NESTED.fullmatch(segments[0])
165
+ if nested:
166
+ operator = {
167
+ "AnyNestedCondition": "any",
168
+ "AllNestedConditions": "all",
169
+ "NoneNestedConditions": "none",
170
+ }[nested[1]]
171
+ entries: list[tuple[str, str]] = []
172
+ valid = True
173
+ for segment in segments[1:]:
174
+ name, sep, text = segment.partition(" ")
175
+ if not sep or not _SOURCE.fullmatch(name):
176
+ valid = False
177
+ continue
178
+ entries.append((name, text))
179
+ # A qualified nested class is a parent only if explicitly present.
180
+ if len({name for name, _ in entries}) != len(entries):
181
+ valid = False
182
+ direct = [
183
+ (n, t)
184
+ for n, t in entries
185
+ if not any(n.startswith(p + ".") for p, _ in entries if p != n)
186
+ ]
187
+ children = []
188
+ for name, text in direct:
189
+ descendants = [(n, t) for n, t in entries if n.startswith(name + ".")]
190
+ child_message = text + "".join(
191
+ "; NestedCondition on " + n + " " + t for n, t in descendants
192
+ )
193
+ child_id = parse(
194
+ context, source, name, child_message, "unknown", depth + 1
195
+ )
196
+ child = nodes[child_id]
197
+ nodes[child_id] = replace(child, reported_outcome=child.derived_outcome)
198
+ children.append(child_id)
199
+ complete = (
200
+ valid
201
+ and len(children) == int(nested[2]) + int(nested[3])
202
+ and all(nodes[c].children_complete for c in children)
203
+ )
204
+ values = tuple(nodes[c].derived_outcome for c in children)
205
+ counts_match = values.count("matched") == int(nested[2]) and values.count(
206
+ "notMatched"
207
+ ) == int(nested[3])
208
+ complete = complete and counts_match
209
+ derived = combine(operator, values, complete)
210
+ reason = "NESTED_CONDITION" if complete else "UNKNOWN_CONDITION_GRAMMAR"
211
+ nodes[node_id] = replace(
212
+ base,
213
+ operator=cast(Literal["any", "all", "none"], operator),
214
+ derived_outcome=derived,
215
+ child_ids=tuple(children),
216
+ children_complete=complete,
217
+ reason_code=reason,
218
+ )
219
+ else:
220
+ search, types, names, prop, reason, complete, derived = _leaf(message)
221
+ # Annotation text alone cannot establish which Condition implementation ran.
222
+ expected = (
223
+ "OnBeanCondition"
224
+ if types or names
225
+ else "OnPropertyCondition"
226
+ if prop.state == "known"
227
+ else None
228
+ )
229
+ if (
230
+ expected
231
+ and depth == 0
232
+ and (
233
+ not isinstance(condition_type, str)
234
+ or len(condition_type) > 256
235
+ or not _SOURCE.fullmatch(condition_type)
236
+ or condition_type.rsplit(".", 1)[-1] != expected
237
+ )
238
+ ):
239
+ complete, derived, reason = (
240
+ False,
241
+ "unknown",
242
+ "UNKNOWN_CONDITION_GRAMMAR",
243
+ )
244
+ nodes[node_id] = replace(
245
+ base,
246
+ derived_outcome=derived,
247
+ search=search,
248
+ required_types=types,
249
+ required_names=names,
250
+ property_key=prop,
251
+ reason_code=reason,
252
+ children_complete=complete,
253
+ )
254
+ node = nodes[node_id]
255
+ if (
256
+ reported != "unknown"
257
+ and node.derived_outcome != "unknown"
258
+ and reported != node.derived_outcome
259
+ ):
260
+ nodes[node_id] = replace(
261
+ node, children_complete=False, reason_code="CONDITION_PARSE_CONFLICT"
262
+ )
263
+ gaps.add("CONDITION_PARSE_CONFLICT")
264
+ elif not node.children_complete:
265
+ gaps.add("UNKNOWN_CONDITION_GRAMMAR")
266
+ return node_id
267
+
268
+ for context_id, value in sorted(
269
+ body["contexts"].items(), key=lambda item: str(item[0])
270
+ ):
271
+ budget.consume()
272
+ if (
273
+ not isinstance(context_id, str)
274
+ or len(context_id) > 256
275
+ or not isinstance(value, dict)
276
+ ):
277
+ gaps.add("INVALID_CONDITIONS_CONTEXT")
278
+ continue
279
+ context = ContextKey(observation.target_id, observation.snapshot_id, context_id)
280
+ for branch in ("positiveMatches", "negativeMatches"):
281
+ entries = value.get(branch)
282
+ if not isinstance(entries, dict):
283
+ gaps.add("CONDITIONS_BRANCH_UNOBSERVED")
284
+ continue
285
+ for source, matches in sorted(
286
+ entries.items(), key=lambda item: str(item[0])
287
+ ):
288
+ if (
289
+ not isinstance(source, str)
290
+ or len(source) > 256
291
+ or not _SOURCE.fullmatch(source)
292
+ ):
293
+ gaps.add("INVALID_CONDITION_SOURCE")
294
+ continue
295
+ groups = (
296
+ [("matched", matches)]
297
+ if branch == "positiveMatches"
298
+ else list(matches.items())
299
+ if isinstance(matches, dict)
300
+ else []
301
+ )
302
+ if branch == "negativeMatches" and (
303
+ not isinstance(matches, dict)
304
+ or set(matches) != {"matched", "notMatched"}
305
+ ):
306
+ gaps.add("CONDITIONS_BRANCH_UNOBSERVED")
307
+ for outcome, rows in groups:
308
+ if outcome not in ("matched", "notMatched") or not isinstance(
309
+ rows, list
310
+ ):
311
+ gaps.add("INVALID_CONDITION_ENTRY")
312
+ continue
313
+ ids = list(roots.get((context, source), ()))
314
+ for row in rows:
315
+ if not isinstance(row, dict):
316
+ gaps.add("INVALID_CONDITION_ENTRY")
317
+ continue
318
+ ids.append(
319
+ parse(
320
+ context,
321
+ source,
322
+ row.get("condition"),
323
+ row.get("message"),
324
+ cast(Outcome, outcome),
325
+ )
326
+ )
327
+ roots[context, source] = tuple(ids)
328
+ return ConditionReport(
329
+ MappingProxyType(nodes),
330
+ MappingProxyType(roots),
331
+ "partial" if gaps else "complete",
332
+ tuple(sorted(gaps)),
333
+ )