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,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)))