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,119 @@
|
|
|
1
|
+
"""Validate bounded source evidence without reading or executing source files."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..models.common import AnalysisBudget, NormalizationError
|
|
11
|
+
from ..models.source_catalog import SourceCatalog, SourceProducer, TypeFact
|
|
12
|
+
from ..transport.bounded_json import validate_tree
|
|
13
|
+
|
|
14
|
+
_ID = re.compile(r"[A-Za-z_$][A-Za-z0-9_.$]*(?:#[A-Za-z_$][A-Za-z0-9_$]*)?\Z")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _text(value: Any) -> str:
|
|
18
|
+
if not isinstance(value, str) or len(value) > 256 or not _ID.fullmatch(value):
|
|
19
|
+
raise NormalizationError("INVALID_SOURCE_CATALOG")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _strings(value: Any) -> tuple[str, ...]:
|
|
24
|
+
if not isinstance(value, list) or len(value) > 1000:
|
|
25
|
+
raise NormalizationError("INVALID_SOURCE_CATALOG")
|
|
26
|
+
return tuple(_text(v) for v in value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_source_catalog(
|
|
30
|
+
payload: Mapping[str, Any], *, target_id: str, budget: AnalysisBudget
|
|
31
|
+
) -> SourceCatalog:
|
|
32
|
+
validate_tree(dict(payload), max_bytes=262144)
|
|
33
|
+
if (
|
|
34
|
+
set(payload) - {"targetId", "sourceRevision", "types", "producers"}
|
|
35
|
+
or payload.get("targetId") != target_id
|
|
36
|
+
):
|
|
37
|
+
raise NormalizationError("SOURCE_TARGET_MISMATCH")
|
|
38
|
+
if len(json.dumps(payload, ensure_ascii=False).encode()) > 262144:
|
|
39
|
+
raise NormalizationError("SOURCE_CATALOG_TOO_LARGE")
|
|
40
|
+
rows, producers = payload.get("types", []), payload.get("producers", [])
|
|
41
|
+
if (
|
|
42
|
+
not isinstance(rows, list)
|
|
43
|
+
or not isinstance(producers, list)
|
|
44
|
+
or len(rows) + len(producers) > 1000
|
|
45
|
+
):
|
|
46
|
+
raise NormalizationError("INVALID_SOURCE_CATALOG")
|
|
47
|
+
types: dict[str, TypeFact] = {}
|
|
48
|
+
for row in rows:
|
|
49
|
+
budget.consume()
|
|
50
|
+
if not isinstance(row, dict) or set(row) != {"name", "directSupertypes"}:
|
|
51
|
+
raise NormalizationError("INVALID_SOURCE_CATALOG")
|
|
52
|
+
name = _text(row["name"])
|
|
53
|
+
if name in types:
|
|
54
|
+
raise NormalizationError("DUPLICATE_SOURCE_TYPE")
|
|
55
|
+
types[name] = TypeFact(name, _strings(row["directSupertypes"]))
|
|
56
|
+
parsed = []
|
|
57
|
+
keys = {
|
|
58
|
+
"contextId",
|
|
59
|
+
"beanName",
|
|
60
|
+
"aliases",
|
|
61
|
+
"declaredType",
|
|
62
|
+
"implementationType",
|
|
63
|
+
"configurationClass",
|
|
64
|
+
"factoryMethod",
|
|
65
|
+
"conditionSources",
|
|
66
|
+
"sourceFile",
|
|
67
|
+
"sourceLine",
|
|
68
|
+
}
|
|
69
|
+
for i, row in enumerate(producers):
|
|
70
|
+
budget.consume()
|
|
71
|
+
if not isinstance(row, dict) or set(row) != keys:
|
|
72
|
+
raise NormalizationError("INVALID_SOURCE_CATALOG")
|
|
73
|
+
file = row["sourceFile"]
|
|
74
|
+
if (
|
|
75
|
+
not isinstance(file, str)
|
|
76
|
+
or file in ("", ".")
|
|
77
|
+
or len(file) > 256
|
|
78
|
+
or "\\" in file
|
|
79
|
+
or ":" in file
|
|
80
|
+
or any(ord(c) < 32 for c in file)
|
|
81
|
+
or PurePosixPath(file).is_absolute()
|
|
82
|
+
or ".." in PurePosixPath(file).parts
|
|
83
|
+
):
|
|
84
|
+
raise NormalizationError("INVALID_SOURCE_PATH")
|
|
85
|
+
line = row["sourceLine"]
|
|
86
|
+
if type(line) is not int or line < 1:
|
|
87
|
+
raise NormalizationError("INVALID_SOURCE_LINE")
|
|
88
|
+
|
|
89
|
+
current = row
|
|
90
|
+
|
|
91
|
+
def optional(key: str, record: dict[str, Any] = current) -> str | None:
|
|
92
|
+
return None if record[key] is None else _text(record[key])
|
|
93
|
+
|
|
94
|
+
parsed.append(
|
|
95
|
+
SourceProducer(
|
|
96
|
+
f"source_producer_{i}",
|
|
97
|
+
_text(row["contextId"]),
|
|
98
|
+
_text(row["beanName"]),
|
|
99
|
+
_strings(row["aliases"]),
|
|
100
|
+
_text(row["declaredType"]),
|
|
101
|
+
optional("implementationType"),
|
|
102
|
+
_text(row["configurationClass"]),
|
|
103
|
+
optional("factoryMethod"),
|
|
104
|
+
_strings(row["conditionSources"]),
|
|
105
|
+
file,
|
|
106
|
+
line,
|
|
107
|
+
(f"source_evidence_{i}",),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
revision = payload.get("sourceRevision")
|
|
111
|
+
if revision is not None and (
|
|
112
|
+
not isinstance(revision, str)
|
|
113
|
+
or len(revision) > 128
|
|
114
|
+
or any(ord(c) < 32 for c in revision)
|
|
115
|
+
):
|
|
116
|
+
raise NormalizationError("INVALID_SOURCE_REVISION")
|
|
117
|
+
return SourceCatalog(
|
|
118
|
+
target_id, revision, MappingProxyType(types), tuple(parsed), ()
|
|
119
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Normalize ApplicationStartup timelines without posting to the startup endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..models.common import AnalysisBudget
|
|
10
|
+
from ..models.timestamps import parse_rfc3339
|
|
11
|
+
|
|
12
|
+
from ..contracts.definitions import glob_matches
|
|
13
|
+
|
|
14
|
+
_DURATION = re.compile(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?\Z")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _nanos(text: str) -> int | None:
|
|
18
|
+
matched = _DURATION.fullmatch(text)
|
|
19
|
+
if matched is None or not any(matched.groups()):
|
|
20
|
+
return None
|
|
21
|
+
hours = Decimal(matched.group(1) or 0)
|
|
22
|
+
minutes = Decimal(matched.group(2) or 0)
|
|
23
|
+
seconds = Decimal(matched.group(3) or 0)
|
|
24
|
+
return int((hours * 3600 + minutes * 60 + seconds) * Decimal(1_000_000_000))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _timestamp(value: Any) -> str | None:
|
|
28
|
+
return (
|
|
29
|
+
value if isinstance(value, str) and parse_rfc3339(value) is not None else None
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _bottleneck(name: str) -> str:
|
|
34
|
+
if "beans.instantiate" in name:
|
|
35
|
+
return "BEAN_INSTANTIATION"
|
|
36
|
+
if "base-packages.scan" in name or "component-scan" in name:
|
|
37
|
+
return "PACKAGE_SCAN"
|
|
38
|
+
if "auto-config" in name or "autoconfigure" in name:
|
|
39
|
+
return "AUTO_CONFIGURATION"
|
|
40
|
+
return "OTHER" if name else "UNKNOWN"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_startup(
|
|
44
|
+
body: Any,
|
|
45
|
+
*,
|
|
46
|
+
step_name_pattern: str | None,
|
|
47
|
+
bean_name_pattern: str | None,
|
|
48
|
+
min_duration_ms: float,
|
|
49
|
+
max_depth: int,
|
|
50
|
+
) -> tuple[str | None, int | float | None, str, bool, list[dict[str, Any]], list[str]]:
|
|
51
|
+
if not isinstance(body, dict) or not isinstance(body.get("timeline"), dict):
|
|
52
|
+
raise ValueError("INVALID_STARTUP_SHAPE")
|
|
53
|
+
events = body["timeline"].get("events")
|
|
54
|
+
if not isinstance(events, list):
|
|
55
|
+
raise ValueError("INVALID_STARTUP_SHAPE")
|
|
56
|
+
version = body.get("springBootVersion")
|
|
57
|
+
version = version if isinstance(version, str) and len(version) <= 64 else None
|
|
58
|
+
gaps: set[str] = set()
|
|
59
|
+
budget = AnalysisBudget()
|
|
60
|
+
records: dict[str, dict[str, Any]] = {}
|
|
61
|
+
order: list[str] = []
|
|
62
|
+
for event in events:
|
|
63
|
+
budget.consume()
|
|
64
|
+
if len(order) >= 500:
|
|
65
|
+
gaps.add("STARTUP_NODE_LIMIT")
|
|
66
|
+
break
|
|
67
|
+
if not isinstance(event, dict) or not isinstance(
|
|
68
|
+
event.get("startupStep"), dict
|
|
69
|
+
):
|
|
70
|
+
gaps.add("STARTUP_EVENT_UNREADABLE")
|
|
71
|
+
continue
|
|
72
|
+
step = event["startupStep"]
|
|
73
|
+
raw_id = step.get("id")
|
|
74
|
+
if type(raw_id) is not int or raw_id < 0:
|
|
75
|
+
gaps.add("STARTUP_EVENT_UNREADABLE")
|
|
76
|
+
continue
|
|
77
|
+
step_id = str(raw_id)
|
|
78
|
+
if step_id in records:
|
|
79
|
+
gaps.add("STARTUP_DUPLICATE_STEP")
|
|
80
|
+
records[step_id]["relationState"] = "duplicate"
|
|
81
|
+
continue
|
|
82
|
+
nanos = (
|
|
83
|
+
_nanos(event["duration"])
|
|
84
|
+
if isinstance(event.get("duration"), str)
|
|
85
|
+
else None
|
|
86
|
+
)
|
|
87
|
+
if nanos is None:
|
|
88
|
+
gaps.add("STARTUP_DURATION_UNPARSED")
|
|
89
|
+
parent = step.get("parentId")
|
|
90
|
+
parent_id = str(parent) if type(parent) is int and parent >= 0 else None
|
|
91
|
+
tags = []
|
|
92
|
+
raw_tags = step.get("tags", [])
|
|
93
|
+
if isinstance(raw_tags, list):
|
|
94
|
+
if len(raw_tags) > 10:
|
|
95
|
+
gaps.add("STARTUP_TAG_LIMIT")
|
|
96
|
+
for tag in raw_tags[:10]:
|
|
97
|
+
if not isinstance(tag, dict) or not isinstance(tag.get("key"), str):
|
|
98
|
+
continue
|
|
99
|
+
value = tag.get("value")
|
|
100
|
+
tags.append(
|
|
101
|
+
{
|
|
102
|
+
"key": tag["key"][:128],
|
|
103
|
+
"value": value[:256] if isinstance(value, str) else None,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
name = step.get("name") if isinstance(step.get("name"), str) else ""
|
|
107
|
+
raw_started = event.get("startTime")
|
|
108
|
+
started_at = _timestamp(raw_started)
|
|
109
|
+
if isinstance(raw_started, str) and started_at is None:
|
|
110
|
+
gaps.add("STARTUP_TIMING_UNOBSERVED")
|
|
111
|
+
records[step_id] = {
|
|
112
|
+
"stepId": step_id,
|
|
113
|
+
"parentStepId": parent_id,
|
|
114
|
+
"name": name[:256],
|
|
115
|
+
"startedAt": started_at,
|
|
116
|
+
"durationNanos": None if nanos is None else str(nanos),
|
|
117
|
+
"durationMs": None if nanos is None else nanos / 1_000_000,
|
|
118
|
+
"tags": tags,
|
|
119
|
+
"bottleneckCode": _bottleneck(name),
|
|
120
|
+
"relationState": "resolved",
|
|
121
|
+
}
|
|
122
|
+
order.append(step_id)
|
|
123
|
+
for step_id, row in records.items():
|
|
124
|
+
parent = row["parentStepId"]
|
|
125
|
+
if parent is None:
|
|
126
|
+
continue
|
|
127
|
+
if parent not in records:
|
|
128
|
+
row["relationState"] = "orphan"
|
|
129
|
+
gaps.add("STARTUP_ORPHAN_STEP")
|
|
130
|
+
continue
|
|
131
|
+
seen = {step_id}
|
|
132
|
+
cursor = parent
|
|
133
|
+
while cursor is not None:
|
|
134
|
+
budget.consume()
|
|
135
|
+
if cursor in seen:
|
|
136
|
+
row["relationState"] = "cycle"
|
|
137
|
+
gaps.add("STARTUP_CYCLE")
|
|
138
|
+
break
|
|
139
|
+
seen.add(cursor)
|
|
140
|
+
cursor = records.get(cursor, {}).get("parentStepId")
|
|
141
|
+
items: list[dict[str, Any]] = []
|
|
142
|
+
for step_id in order:
|
|
143
|
+
row = records[step_id]
|
|
144
|
+
ancestors: list[str] = []
|
|
145
|
+
cursor = row["parentStepId"]
|
|
146
|
+
visited: set[str] = set()
|
|
147
|
+
while cursor and cursor not in visited and len(ancestors) < 16:
|
|
148
|
+
visited.add(cursor)
|
|
149
|
+
ancestors.append(cursor)
|
|
150
|
+
cursor = records.get(cursor, {}).get("parentStepId")
|
|
151
|
+
if len(ancestors) > max_depth:
|
|
152
|
+
gaps.add("STARTUP_DEPTH_LIMIT")
|
|
153
|
+
continue
|
|
154
|
+
if step_name_pattern and not glob_matches(step_name_pattern, row["name"]):
|
|
155
|
+
continue
|
|
156
|
+
if bean_name_pattern and not any(
|
|
157
|
+
tag["key"] == "beanName"
|
|
158
|
+
and isinstance(tag["value"], str)
|
|
159
|
+
and glob_matches(bean_name_pattern, tag["value"])
|
|
160
|
+
for tag in row["tags"]
|
|
161
|
+
):
|
|
162
|
+
continue
|
|
163
|
+
duration = row["durationMs"]
|
|
164
|
+
if duration is not None and duration < min_duration_ms:
|
|
165
|
+
continue
|
|
166
|
+
row["ancestorIds"] = ancestors
|
|
167
|
+
items.append(row)
|
|
168
|
+
items.sort(
|
|
169
|
+
key=lambda row: (
|
|
170
|
+
-(row["durationMs"] if isinstance(row["durationMs"], (int, float)) else -1),
|
|
171
|
+
row["stepId"],
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
total: int | float | None = None
|
|
175
|
+
try:
|
|
176
|
+
starts = [parse_rfc3339(event["startTime"]) for event in events]
|
|
177
|
+
ends = [parse_rfc3339(event["endTime"]) for event in events]
|
|
178
|
+
if not starts or any(date is None for date in [*starts, *ends]):
|
|
179
|
+
raise ValueError("STARTUP_TIMING_UNOBSERVED")
|
|
180
|
+
valid_starts = [date for date in starts if date is not None]
|
|
181
|
+
valid_ends = [date for date in ends if date is not None]
|
|
182
|
+
raw_start = body["timeline"].get("startTime")
|
|
183
|
+
start = (
|
|
184
|
+
parse_rfc3339(raw_start)
|
|
185
|
+
if "startTime" in body["timeline"]
|
|
186
|
+
else min(valid_starts)
|
|
187
|
+
)
|
|
188
|
+
if start is None:
|
|
189
|
+
raise ValueError("STARTUP_TIMING_UNOBSERVED")
|
|
190
|
+
if any(
|
|
191
|
+
end < begin or begin < start
|
|
192
|
+
for begin, end in zip(valid_starts, valid_ends, strict=True)
|
|
193
|
+
):
|
|
194
|
+
raise ValueError("STARTUP_TIMING_UNOBSERVED")
|
|
195
|
+
if any(date.leap for date in [start, *valid_starts, *valid_ends]):
|
|
196
|
+
raise ValueError("STARTUP_TIMING_UNOBSERVED")
|
|
197
|
+
total = max(valid_ends).milliseconds_since(start)
|
|
198
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
199
|
+
gaps.add("STARTUP_TIMING_UNOBSERVED")
|
|
200
|
+
complete = not gaps
|
|
201
|
+
basis = "timelineSpan" if total is not None else "unknown"
|
|
202
|
+
return version, total, basis, complete, items, sorted(gaps)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deterministic diagnostic algorithms, with no network access."""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Deterministic alias-aware dependency resolution and breadth-first slices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
|
|
7
|
+
from ..models.bean_graph import (
|
|
8
|
+
BeanGraph,
|
|
9
|
+
BeanKey,
|
|
10
|
+
ContextKey,
|
|
11
|
+
DependencyEdge,
|
|
12
|
+
GraphSlice,
|
|
13
|
+
Resolution,
|
|
14
|
+
)
|
|
15
|
+
from ..models.common import AnalysisBudget, AnalysisLimit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_bean_reference(
|
|
19
|
+
graph: BeanGraph, defining_context: ContextKey, requested_name: str
|
|
20
|
+
) -> Resolution:
|
|
21
|
+
current: ContextKey | None = defining_context
|
|
22
|
+
seen: set[ContextKey] = set()
|
|
23
|
+
while current is not None:
|
|
24
|
+
if current in seen:
|
|
25
|
+
return Resolution("unresolved", (), "CONTEXT_CYCLE")
|
|
26
|
+
seen.add(current)
|
|
27
|
+
context = graph.contexts.get(current)
|
|
28
|
+
if context is None:
|
|
29
|
+
return Resolution("unresolved", (), "PARENT_CONTEXT_UNOBSERVED")
|
|
30
|
+
if not context.complete:
|
|
31
|
+
return Resolution("unresolved", (), "BEAN_INDEX_INCOMPLETE")
|
|
32
|
+
candidates = graph.names.get(current, {}).get(requested_name, ())
|
|
33
|
+
if len(candidates) > 1:
|
|
34
|
+
return Resolution("ambiguous", candidates, "ALIAS_AMBIGUOUS")
|
|
35
|
+
if candidates:
|
|
36
|
+
return Resolution("resolved", candidates, None)
|
|
37
|
+
if context.parent.state != "known":
|
|
38
|
+
return Resolution("unresolved", (), "PARENT_CONTEXT_UNOBSERVED")
|
|
39
|
+
current = context.parent.value
|
|
40
|
+
return Resolution("unresolved", (), "NOT_FOUND_IN_COMPLETE_VISIBLE_HIERARCHY")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_dependency_graph(graph: BeanGraph, *, budget: AnalysisBudget) -> BeanGraph:
|
|
44
|
+
forward: dict[BeanKey, list[DependencyEdge]] = {key: [] for key in graph.beans}
|
|
45
|
+
reverse: dict[BeanKey, list[DependencyEdge]] = {key: [] for key in graph.beans}
|
|
46
|
+
gaps = set(graph.gaps)
|
|
47
|
+
try:
|
|
48
|
+
for key, bean in sorted(graph.beans.items()):
|
|
49
|
+
budget.consume()
|
|
50
|
+
for requested in bean.requested_dependencies:
|
|
51
|
+
budget.consume()
|
|
52
|
+
resolution = resolve_bean_reference(graph, key.context, requested)
|
|
53
|
+
edge = DependencyEdge(key, requested, resolution, bean.evidence_ids)
|
|
54
|
+
forward[key].append(edge)
|
|
55
|
+
if resolution.state == "resolved":
|
|
56
|
+
reverse[resolution.candidates[0]].append(edge)
|
|
57
|
+
else:
|
|
58
|
+
gaps.add(resolution.reason_code or "DEPENDENCY_UNRESOLVED")
|
|
59
|
+
except AnalysisLimit:
|
|
60
|
+
gaps.add("GRAPH_LIMIT_REACHED")
|
|
61
|
+
return BeanGraph(
|
|
62
|
+
graph.contexts,
|
|
63
|
+
graph.beans,
|
|
64
|
+
graph.names,
|
|
65
|
+
graph.canonical_to_names,
|
|
66
|
+
{key: tuple(edges) for key, edges in forward.items()},
|
|
67
|
+
{key: tuple(edges) for key, edges in reverse.items()},
|
|
68
|
+
"partial" if gaps else "complete",
|
|
69
|
+
tuple(sorted(gaps)),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def visible_beans(
|
|
74
|
+
graph: BeanGraph, start: ContextKey, *, budget: AnalysisBudget | None = None
|
|
75
|
+
) -> tuple[BeanKey, ...] | None:
|
|
76
|
+
"""List type-query candidates with the same shadowing rules as name lookup."""
|
|
77
|
+
budget = budget or AnalysisBudget()
|
|
78
|
+
current: ContextKey | None = start
|
|
79
|
+
seen: set[ContextKey] = set()
|
|
80
|
+
occupied: set[str] = set()
|
|
81
|
+
selected: list[BeanKey] = []
|
|
82
|
+
while current is not None:
|
|
83
|
+
budget.consume()
|
|
84
|
+
if current in seen:
|
|
85
|
+
return None
|
|
86
|
+
seen.add(current)
|
|
87
|
+
context = graph.contexts.get(current)
|
|
88
|
+
if context is None or not context.complete or context.parent.state != "known":
|
|
89
|
+
return None
|
|
90
|
+
for key in sorted(graph.beans):
|
|
91
|
+
if key.context == current:
|
|
92
|
+
budget.consume()
|
|
93
|
+
if key.canonical_name not in occupied:
|
|
94
|
+
selected.append(key)
|
|
95
|
+
# A local alias can also hide an ancestor's canonical name.
|
|
96
|
+
occupied.update(graph.names.get(current, {}))
|
|
97
|
+
current = context.parent.value
|
|
98
|
+
return tuple(selected)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def slice_dependencies(
|
|
102
|
+
graph: BeanGraph,
|
|
103
|
+
roots: tuple[BeanKey, ...],
|
|
104
|
+
*,
|
|
105
|
+
direction: str,
|
|
106
|
+
max_depth: int,
|
|
107
|
+
budget: AnalysisBudget,
|
|
108
|
+
) -> GraphSlice:
|
|
109
|
+
if direction not in (
|
|
110
|
+
"dependencies",
|
|
111
|
+
"dependents",
|
|
112
|
+
"both",
|
|
113
|
+
) or not 0 <= max_depth <= min(16, budget.max_depth):
|
|
114
|
+
raise ValueError("INVALID_GRAPH_QUERY")
|
|
115
|
+
gaps = set(graph.gaps)
|
|
116
|
+
selected: dict[tuple[BeanKey, str], tuple[DependencyEdge, int]] = {}
|
|
117
|
+
cut = False
|
|
118
|
+
# Separate traversals avoid interpreting a zig-zag in 'both' as a dependency chain.
|
|
119
|
+
directions = ("dependencies", "dependents") if direction == "both" else (direction,)
|
|
120
|
+
try:
|
|
121
|
+
for traversal in directions:
|
|
122
|
+
queue = deque((key, 0) for key in sorted(set(roots)))
|
|
123
|
+
seen = set(roots)
|
|
124
|
+
index = graph.forward if traversal == "dependencies" else graph.reverse
|
|
125
|
+
while queue:
|
|
126
|
+
key, depth = queue.popleft()
|
|
127
|
+
budget.consume()
|
|
128
|
+
edges = index.get(key, ())
|
|
129
|
+
if depth >= max_depth:
|
|
130
|
+
# Cycles already inside the visited set do not imply a hidden closure.
|
|
131
|
+
if any(
|
|
132
|
+
(
|
|
133
|
+
edge.resolution.candidates[0]
|
|
134
|
+
if traversal == "dependencies"
|
|
135
|
+
and edge.resolution.state == "resolved"
|
|
136
|
+
else edge.from_key
|
|
137
|
+
if traversal == "dependents"
|
|
138
|
+
else None
|
|
139
|
+
)
|
|
140
|
+
not in seen
|
|
141
|
+
for edge in edges
|
|
142
|
+
):
|
|
143
|
+
cut = True
|
|
144
|
+
continue
|
|
145
|
+
for edge in edges:
|
|
146
|
+
budget.consume()
|
|
147
|
+
identity = (edge.from_key, edge.requested_name)
|
|
148
|
+
previous = selected.get(identity)
|
|
149
|
+
if previous is None or depth + 1 < previous[1]:
|
|
150
|
+
selected[identity] = (edge, depth + 1)
|
|
151
|
+
next_key = (
|
|
152
|
+
edge.resolution.candidates[0]
|
|
153
|
+
if traversal == "dependencies"
|
|
154
|
+
and edge.resolution.state == "resolved"
|
|
155
|
+
else edge.from_key
|
|
156
|
+
if traversal == "dependents"
|
|
157
|
+
else None
|
|
158
|
+
)
|
|
159
|
+
if next_key is not None and next_key not in seen:
|
|
160
|
+
seen.add(next_key)
|
|
161
|
+
queue.append((next_key, depth + 1))
|
|
162
|
+
except AnalysisLimit:
|
|
163
|
+
cut = True
|
|
164
|
+
if cut:
|
|
165
|
+
gaps.add("GRAPH_LIMIT_REACHED")
|
|
166
|
+
edges_result = tuple(
|
|
167
|
+
sorted(
|
|
168
|
+
selected.values(),
|
|
169
|
+
key=lambda pair: (pair[1], pair[0].from_key, pair[0].requested_name),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return GraphSlice(tuple(sorted(set(roots))), edges_result, cut, tuple(sorted(gaps)))
|