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.
- spring_runtime_mcp/__init__.py +3 -0
- spring_runtime_mcp/adapters/__init__.py +1 -0
- spring_runtime_mcp/adapters/beans.py +159 -0
- spring_runtime_mcp/adapters/conditions.py +333 -0
- spring_runtime_mcp/adapters/config.py +295 -0
- spring_runtime_mcp/adapters/drift.py +790 -0
- spring_runtime_mcp/adapters/health.py +164 -0
- spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
- spring_runtime_mcp/adapters/mappings.py +294 -0
- spring_runtime_mcp/adapters/migrations.py +373 -0
- spring_runtime_mcp/adapters/openapi.py +683 -0
- spring_runtime_mcp/adapters/source_catalog.py +119 -0
- spring_runtime_mcp/adapters/startup.py +202 -0
- spring_runtime_mcp/analyzers/__init__.py +1 -0
- spring_runtime_mcp/analyzers/bean_graph.py +172 -0
- spring_runtime_mcp/analyzers/condition_failures.py +359 -0
- spring_runtime_mcp/cli.py +248 -0
- spring_runtime_mcp/contracts/__init__.py +1 -0
- spring_runtime_mcp/contracts/definitions.py +425 -0
- spring_runtime_mcp/diagnostics.py +254 -0
- spring_runtime_mcp/models/__init__.py +1 -0
- spring_runtime_mcp/models/bean_graph.py +77 -0
- spring_runtime_mcp/models/common.py +99 -0
- spring_runtime_mcp/models/conditions.py +56 -0
- spring_runtime_mcp/models/queries.py +140 -0
- spring_runtime_mcp/models/source_catalog.py +35 -0
- spring_runtime_mcp/models/timestamps.py +71 -0
- spring_runtime_mcp/policy/__init__.py +1 -0
- spring_runtime_mcp/policy/configuration.py +32 -0
- spring_runtime_mcp/policy/output_guard.py +117 -0
- spring_runtime_mcp/policy/sanitization.py +616 -0
- spring_runtime_mcp/py.typed +0 -0
- spring_runtime_mcp/replay.py +220 -0
- spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
- spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
- spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/conformance/README.md +41 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
- spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
- spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
- spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
- spring_runtime_mcp/schemas/tools-contract.json +1902 -0
- spring_runtime_mcp/server.py +322 -0
- spring_runtime_mcp/services.py +44 -0
- spring_runtime_mcp/snapshots.py +340 -0
- spring_runtime_mcp/tools/__init__.py +1 -0
- spring_runtime_mcp/tools/analyze_startup.py +79 -0
- spring_runtime_mcp/tools/check_migrations.py +99 -0
- spring_runtime_mcp/tools/detect_drift.py +323 -0
- spring_runtime_mcp/tools/diagnose_health.py +88 -0
- spring_runtime_mcp/tools/inspect_beans.py +479 -0
- spring_runtime_mcp/tools/inspect_config.py +102 -0
- spring_runtime_mcp/tools/registry.py +48 -0
- spring_runtime_mcp/tools/search_openapi.py +79 -0
- spring_runtime_mcp/tools/support.py +166 -0
- spring_runtime_mcp/tools/verify_mappings.py +62 -0
- spring_runtime_mcp/transport/__init__.py +1 -0
- spring_runtime_mcp/transport/actuator_http.py +459 -0
- spring_runtime_mcp/transport/bounded_json.py +144 -0
- spring_runtime_mcp/transport/replay_files.py +71 -0
- spring_runtime_mcp/transport/response_gate.py +186 -0
- spring_runtime_mcp/transport/responses.py +82 -0
- spring_runtime_mcp/transport/stdio.py +44 -0
- spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
- spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
- spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
- spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -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
|
+
)
|