yuho 5.0.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.
- yuho/__init__.py +16 -0
- yuho/ast/__init__.py +196 -0
- yuho/ast/builder.py +926 -0
- yuho/ast/constant_folder.py +280 -0
- yuho/ast/dead_code.py +199 -0
- yuho/ast/exhaustiveness.py +503 -0
- yuho/ast/nodes.py +907 -0
- yuho/ast/overlap.py +291 -0
- yuho/ast/reachability.py +293 -0
- yuho/ast/scope_analysis.py +490 -0
- yuho/ast/transformer.py +490 -0
- yuho/ast/type_check.py +471 -0
- yuho/ast/type_inference.py +425 -0
- yuho/ast/visitor.py +239 -0
- yuho/cli/__init__.py +14 -0
- yuho/cli/commands/__init__.py +1 -0
- yuho/cli/commands/api.py +431 -0
- yuho/cli/commands/ast_viz.py +334 -0
- yuho/cli/commands/check.py +218 -0
- yuho/cli/commands/config.py +311 -0
- yuho/cli/commands/contribute.py +122 -0
- yuho/cli/commands/diff.py +487 -0
- yuho/cli/commands/explain.py +240 -0
- yuho/cli/commands/fmt.py +253 -0
- yuho/cli/commands/generate.py +316 -0
- yuho/cli/commands/graph.py +410 -0
- yuho/cli/commands/init.py +120 -0
- yuho/cli/commands/library.py +656 -0
- yuho/cli/commands/lint.py +503 -0
- yuho/cli/commands/lsp.py +36 -0
- yuho/cli/commands/preview.py +377 -0
- yuho/cli/commands/repl.py +444 -0
- yuho/cli/commands/serve.py +44 -0
- yuho/cli/commands/test.py +528 -0
- yuho/cli/commands/transpile.py +121 -0
- yuho/cli/commands/wizard.py +370 -0
- yuho/cli/completions.py +182 -0
- yuho/cli/error_formatter.py +193 -0
- yuho/cli/main.py +1064 -0
- yuho/config/__init__.py +46 -0
- yuho/config/loader.py +235 -0
- yuho/config/mask.py +194 -0
- yuho/config/schema.py +147 -0
- yuho/library/__init__.py +84 -0
- yuho/library/index.py +328 -0
- yuho/library/install.py +699 -0
- yuho/library/lockfile.py +330 -0
- yuho/library/package.py +421 -0
- yuho/library/resolver.py +791 -0
- yuho/library/signature.py +335 -0
- yuho/llm/__init__.py +45 -0
- yuho/llm/config.py +75 -0
- yuho/llm/factory.py +123 -0
- yuho/llm/prompts.py +146 -0
- yuho/llm/providers.py +383 -0
- yuho/llm/utils.py +470 -0
- yuho/lsp/__init__.py +14 -0
- yuho/lsp/code_action_handler.py +518 -0
- yuho/lsp/completion_handler.py +85 -0
- yuho/lsp/diagnostics.py +100 -0
- yuho/lsp/hover_handler.py +130 -0
- yuho/lsp/server.py +1425 -0
- yuho/mcp/__init__.py +10 -0
- yuho/mcp/server.py +1452 -0
- yuho/parser/__init__.py +8 -0
- yuho/parser/source_location.py +108 -0
- yuho/parser/wrapper.py +311 -0
- yuho/testing/__init__.py +48 -0
- yuho/testing/coverage.py +274 -0
- yuho/testing/fixtures.py +263 -0
- yuho/transpile/__init__.py +52 -0
- yuho/transpile/alloy_transpiler.py +546 -0
- yuho/transpile/base.py +100 -0
- yuho/transpile/blocks_transpiler.py +338 -0
- yuho/transpile/english_transpiler.py +470 -0
- yuho/transpile/graphql_transpiler.py +404 -0
- yuho/transpile/json_transpiler.py +217 -0
- yuho/transpile/jsonld_transpiler.py +250 -0
- yuho/transpile/latex_preamble.py +161 -0
- yuho/transpile/latex_transpiler.py +406 -0
- yuho/transpile/latex_utils.py +206 -0
- yuho/transpile/mermaid_transpiler.py +357 -0
- yuho/transpile/registry.py +275 -0
- yuho/verify/__init__.py +43 -0
- yuho/verify/alloy.py +352 -0
- yuho/verify/combined.py +218 -0
- yuho/verify/z3_solver.py +1155 -0
- yuho-5.0.0.dist-info/METADATA +186 -0
- yuho-5.0.0.dist-info/RECORD +91 -0
- yuho-5.0.0.dist-info/WHEEL +4 -0
- yuho-5.0.0.dist-info/entry_points.txt +2 -0
yuho/library/resolver.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency resolution with version constraint solving for Yuho packages.
|
|
3
|
+
|
|
4
|
+
Implements a SAT-based resolver for package dependencies with support for
|
|
5
|
+
semver version constraints like ^1.0.0, ~1.2.0, >=1.0.0, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Dict, Optional, Tuple, Set
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
import re
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Regex pattern for strict semver validation (based on semver.org spec)
|
|
18
|
+
SEMVER_PATTERN = re.compile(
|
|
19
|
+
r"^v?" # Optional 'v' prefix
|
|
20
|
+
r"(?P<major>0|[1-9]\d*)"
|
|
21
|
+
r"\.(?P<minor>0|[1-9]\d*)"
|
|
22
|
+
r"\.(?P<patch>0|[1-9]\d*)"
|
|
23
|
+
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
|
|
24
|
+
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
|
25
|
+
r"(?:\+(?P<build>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SemverValidationError(Exception):
|
|
30
|
+
"""Raised when a version string is not valid semver."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SemverValidation:
|
|
36
|
+
"""Result of semver validation."""
|
|
37
|
+
valid: bool
|
|
38
|
+
errors: List[str] = field(default_factory=list)
|
|
39
|
+
warnings: List[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_semver(version_str: str, strict: bool = True) -> SemverValidation:
|
|
43
|
+
"""
|
|
44
|
+
Validate a version string against semver specification.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
version_str: Version string to validate
|
|
48
|
+
strict: If True, requires full semver compliance. If False, allows
|
|
49
|
+
partial versions like "1.0" or "1".
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
SemverValidation result with validity and any errors/warnings
|
|
53
|
+
"""
|
|
54
|
+
errors = []
|
|
55
|
+
warnings = []
|
|
56
|
+
|
|
57
|
+
if not version_str:
|
|
58
|
+
return SemverValidation(False, ["Version string is empty"])
|
|
59
|
+
|
|
60
|
+
version_str = version_str.strip()
|
|
61
|
+
|
|
62
|
+
# Check for strict semver compliance
|
|
63
|
+
match = SEMVER_PATTERN.match(version_str)
|
|
64
|
+
|
|
65
|
+
if match:
|
|
66
|
+
# Valid semver
|
|
67
|
+
if version_str.startswith("v"):
|
|
68
|
+
warnings.append("Version has 'v' prefix (valid but not recommended)")
|
|
69
|
+
return SemverValidation(True, [], warnings)
|
|
70
|
+
|
|
71
|
+
if strict:
|
|
72
|
+
errors.append(f"'{version_str}' is not valid semver format (expected X.Y.Z)")
|
|
73
|
+
|
|
74
|
+
# Provide helpful hints
|
|
75
|
+
parts = version_str.lstrip("v").split(".")
|
|
76
|
+
if len(parts) < 3:
|
|
77
|
+
errors.append(f"Missing components: semver requires MAJOR.MINOR.PATCH")
|
|
78
|
+
elif len(parts) > 3 and "-" not in version_str and "+" not in version_str:
|
|
79
|
+
errors.append(f"Too many version components")
|
|
80
|
+
|
|
81
|
+
# Check for invalid characters
|
|
82
|
+
if re.search(r"[^0-9a-zA-Z.\-+]", version_str.lstrip("v")):
|
|
83
|
+
errors.append("Contains invalid characters")
|
|
84
|
+
|
|
85
|
+
return SemverValidation(False, errors)
|
|
86
|
+
|
|
87
|
+
# Non-strict mode: allow partial versions
|
|
88
|
+
partial_pattern = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?")
|
|
89
|
+
if partial_pattern.match(version_str):
|
|
90
|
+
warnings.append(f"'{version_str}' is a partial version (not strict semver)")
|
|
91
|
+
return SemverValidation(True, [], warnings)
|
|
92
|
+
|
|
93
|
+
return SemverValidation(False, [f"'{version_str}' cannot be parsed as a version"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ConstraintOp(Enum):
|
|
97
|
+
"""Version constraint operators."""
|
|
98
|
+
EQ = "=" # Exact version
|
|
99
|
+
GTE = ">=" # Greater than or equal
|
|
100
|
+
LTE = "<=" # Less than or equal
|
|
101
|
+
GT = ">" # Greater than
|
|
102
|
+
LT = "<" # Less than
|
|
103
|
+
CARET = "^" # Compatible with (major version locked)
|
|
104
|
+
TILDE = "~" # Approximately (minor version locked)
|
|
105
|
+
ANY = "*" # Any version
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Version:
|
|
110
|
+
"""Semantic version representation with full semver 2.0 support."""
|
|
111
|
+
major: int
|
|
112
|
+
minor: int
|
|
113
|
+
patch: int
|
|
114
|
+
prerelease: str = ""
|
|
115
|
+
build: str = "" # Build metadata (ignored in comparisons per semver spec)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def parse(cls, version_str: str, validate: bool = False) -> "Version":
|
|
119
|
+
"""
|
|
120
|
+
Parse version string like '1.2.3' or 'v1.2.3-alpha+build.123'.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
version_str: Version string to parse
|
|
124
|
+
validate: If True, raises SemverValidationError for invalid versions
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Parsed Version object
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
SemverValidationError: If validate=True and version is invalid
|
|
131
|
+
"""
|
|
132
|
+
if validate:
|
|
133
|
+
result = validate_semver(version_str, strict=True)
|
|
134
|
+
if not result.valid:
|
|
135
|
+
raise SemverValidationError("; ".join(result.errors))
|
|
136
|
+
|
|
137
|
+
v = version_str.strip().lstrip("v")
|
|
138
|
+
prerelease = ""
|
|
139
|
+
build = ""
|
|
140
|
+
|
|
141
|
+
# Extract build metadata (after +)
|
|
142
|
+
if "+" in v:
|
|
143
|
+
v, build = v.split("+", 1)
|
|
144
|
+
|
|
145
|
+
# Extract prerelease (after -)
|
|
146
|
+
if "-" in v:
|
|
147
|
+
v, prerelease = v.split("-", 1)
|
|
148
|
+
|
|
149
|
+
parts = v.split(".")
|
|
150
|
+
major = int(parts[0]) if len(parts) > 0 and parts[0].isdigit() else 0
|
|
151
|
+
minor = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
|
|
152
|
+
patch = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0
|
|
153
|
+
|
|
154
|
+
return cls(major, minor, patch, prerelease, build)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def is_valid(cls, version_str: str, strict: bool = True) -> bool:
|
|
158
|
+
"""
|
|
159
|
+
Check if a version string is valid semver.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
version_str: Version string to check
|
|
163
|
+
strict: If True, requires full semver compliance
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
True if valid semver
|
|
167
|
+
"""
|
|
168
|
+
return validate_semver(version_str, strict).valid
|
|
169
|
+
|
|
170
|
+
def __str__(self) -> str:
|
|
171
|
+
base = f"{self.major}.{self.minor}.{self.patch}"
|
|
172
|
+
if self.prerelease:
|
|
173
|
+
base = f"{base}-{self.prerelease}"
|
|
174
|
+
if self.build:
|
|
175
|
+
base = f"{base}+{self.build}"
|
|
176
|
+
return base
|
|
177
|
+
|
|
178
|
+
def to_tuple(self) -> Tuple[int, int, int, str]:
|
|
179
|
+
"""Return version as comparable tuple (excludes build metadata)."""
|
|
180
|
+
return (self.major, self.minor, self.patch, self.prerelease)
|
|
181
|
+
|
|
182
|
+
def _compare_prerelease(self, other: "Version") -> int:
|
|
183
|
+
"""
|
|
184
|
+
Compare prerelease versions per semver spec.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
-1 if self < other, 0 if equal, 1 if self > other
|
|
188
|
+
"""
|
|
189
|
+
# No prerelease > prerelease (1.0.0 > 1.0.0-alpha)
|
|
190
|
+
if not self.prerelease and other.prerelease:
|
|
191
|
+
return 1
|
|
192
|
+
if self.prerelease and not other.prerelease:
|
|
193
|
+
return -1
|
|
194
|
+
if self.prerelease == other.prerelease:
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
# Compare prerelease identifiers
|
|
198
|
+
self_parts = self.prerelease.split(".")
|
|
199
|
+
other_parts = other.prerelease.split(".")
|
|
200
|
+
|
|
201
|
+
for s, o in zip(self_parts, other_parts):
|
|
202
|
+
# Numeric identifiers < alphanumeric
|
|
203
|
+
s_num = s.isdigit()
|
|
204
|
+
o_num = o.isdigit()
|
|
205
|
+
|
|
206
|
+
if s_num and o_num:
|
|
207
|
+
if int(s) < int(o):
|
|
208
|
+
return -1
|
|
209
|
+
if int(s) > int(o):
|
|
210
|
+
return 1
|
|
211
|
+
elif s_num:
|
|
212
|
+
return -1 # numeric < alphanumeric
|
|
213
|
+
elif o_num:
|
|
214
|
+
return 1
|
|
215
|
+
else:
|
|
216
|
+
if s < o:
|
|
217
|
+
return -1
|
|
218
|
+
if s > o:
|
|
219
|
+
return 1
|
|
220
|
+
|
|
221
|
+
# Longer prerelease > shorter (1.0.0-alpha.1 > 1.0.0-alpha)
|
|
222
|
+
if len(self_parts) < len(other_parts):
|
|
223
|
+
return -1
|
|
224
|
+
if len(self_parts) > len(other_parts):
|
|
225
|
+
return 1
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
def __lt__(self, other: "Version") -> bool:
|
|
229
|
+
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
|
|
230
|
+
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
|
|
231
|
+
return self._compare_prerelease(other) < 0
|
|
232
|
+
|
|
233
|
+
def __le__(self, other: "Version") -> bool:
|
|
234
|
+
return self == other or self < other
|
|
235
|
+
|
|
236
|
+
def __gt__(self, other: "Version") -> bool:
|
|
237
|
+
return other < self
|
|
238
|
+
|
|
239
|
+
def __ge__(self, other: "Version") -> bool:
|
|
240
|
+
return self == other or self > other
|
|
241
|
+
|
|
242
|
+
def __eq__(self, other: object) -> bool:
|
|
243
|
+
if not isinstance(other, Version):
|
|
244
|
+
return False
|
|
245
|
+
# Build metadata is ignored in equality per semver spec
|
|
246
|
+
return (self.major, self.minor, self.patch, self.prerelease) == \
|
|
247
|
+
(other.major, other.minor, other.patch, other.prerelease)
|
|
248
|
+
|
|
249
|
+
def __hash__(self) -> int:
|
|
250
|
+
return hash((self.major, self.minor, self.patch, self.prerelease))
|
|
251
|
+
|
|
252
|
+
def is_compatible_with(self, other: "Version") -> bool:
|
|
253
|
+
"""
|
|
254
|
+
Check if this version is backwards-compatible with another version.
|
|
255
|
+
|
|
256
|
+
Per semver rules:
|
|
257
|
+
- Same major version (>0) = compatible
|
|
258
|
+
- Major version 0 = unstable, only same minor is compatible
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
other: Version to check compatibility with
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
True if self is backwards-compatible with other
|
|
265
|
+
"""
|
|
266
|
+
if self.major == 0 and other.major == 0:
|
|
267
|
+
# 0.x.y versions: only same minor is compatible
|
|
268
|
+
return self.minor == other.minor
|
|
269
|
+
return self.major == other.major
|
|
270
|
+
|
|
271
|
+
def is_breaking_change_from(self, other: "Version") -> bool:
|
|
272
|
+
"""
|
|
273
|
+
Check if upgrading from other to self is a breaking change.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
other: Previous version
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
True if this version introduces breaking changes
|
|
280
|
+
"""
|
|
281
|
+
if self <= other:
|
|
282
|
+
return False # Downgrade or same version
|
|
283
|
+
if self.major == 0 and other.major == 0:
|
|
284
|
+
# 0.x.y: minor bumps can be breaking
|
|
285
|
+
return self.minor > other.minor
|
|
286
|
+
return self.major > other.major
|
|
287
|
+
|
|
288
|
+
def next_major(self) -> "Version":
|
|
289
|
+
"""Return next major version."""
|
|
290
|
+
return Version(self.major + 1, 0, 0)
|
|
291
|
+
|
|
292
|
+
def next_minor(self) -> "Version":
|
|
293
|
+
"""Return next minor version."""
|
|
294
|
+
return Version(self.major, self.minor + 1, 0)
|
|
295
|
+
|
|
296
|
+
def next_patch(self) -> "Version":
|
|
297
|
+
"""Return next patch version."""
|
|
298
|
+
return Version(self.major, self.minor, self.patch + 1)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class VersionConstraint:
|
|
303
|
+
"""A version constraint like '^1.0.0' or '>=1.2.0'."""
|
|
304
|
+
op: ConstraintOp
|
|
305
|
+
version: Optional[Version]
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def parse(cls, constraint_str: str) -> "VersionConstraint":
|
|
309
|
+
"""Parse constraint string."""
|
|
310
|
+
constraint_str = constraint_str.strip()
|
|
311
|
+
|
|
312
|
+
if constraint_str == "*" or not constraint_str:
|
|
313
|
+
return cls(ConstraintOp.ANY, None)
|
|
314
|
+
|
|
315
|
+
# Check for operators
|
|
316
|
+
for op_str, op in [
|
|
317
|
+
(">=", ConstraintOp.GTE),
|
|
318
|
+
("<=", ConstraintOp.LTE),
|
|
319
|
+
(">", ConstraintOp.GT),
|
|
320
|
+
("<", ConstraintOp.LT),
|
|
321
|
+
("^", ConstraintOp.CARET),
|
|
322
|
+
("~", ConstraintOp.TILDE),
|
|
323
|
+
("=", ConstraintOp.EQ),
|
|
324
|
+
]:
|
|
325
|
+
if constraint_str.startswith(op_str):
|
|
326
|
+
version = Version.parse(constraint_str[len(op_str):])
|
|
327
|
+
return cls(op, version)
|
|
328
|
+
|
|
329
|
+
# Default to exact match
|
|
330
|
+
return cls(ConstraintOp.EQ, Version.parse(constraint_str))
|
|
331
|
+
|
|
332
|
+
def satisfies(self, version: Version) -> bool:
|
|
333
|
+
"""Check if a version satisfies this constraint."""
|
|
334
|
+
if self.op == ConstraintOp.ANY:
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
if self.version is None:
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
v = self.version
|
|
341
|
+
|
|
342
|
+
if self.op == ConstraintOp.EQ:
|
|
343
|
+
return version == v
|
|
344
|
+
elif self.op == ConstraintOp.GTE:
|
|
345
|
+
return version >= v
|
|
346
|
+
elif self.op == ConstraintOp.LTE:
|
|
347
|
+
return version <= v
|
|
348
|
+
elif self.op == ConstraintOp.GT:
|
|
349
|
+
return version > v
|
|
350
|
+
elif self.op == ConstraintOp.LT:
|
|
351
|
+
return version < v
|
|
352
|
+
elif self.op == ConstraintOp.CARET:
|
|
353
|
+
# ^1.2.3 means >=1.2.3 <2.0.0 (major locked)
|
|
354
|
+
if version < v:
|
|
355
|
+
return False
|
|
356
|
+
if v.major == 0:
|
|
357
|
+
# ^0.x.y means >=0.x.y <0.(x+1).0
|
|
358
|
+
return version.major == 0 and version.minor == v.minor
|
|
359
|
+
return version.major == v.major
|
|
360
|
+
elif self.op == ConstraintOp.TILDE:
|
|
361
|
+
# ~1.2.3 means >=1.2.3 <1.3.0 (minor locked)
|
|
362
|
+
if version < v:
|
|
363
|
+
return False
|
|
364
|
+
return version.major == v.major and version.minor == v.minor
|
|
365
|
+
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
def __str__(self) -> str:
|
|
369
|
+
if self.op == ConstraintOp.ANY:
|
|
370
|
+
return "*"
|
|
371
|
+
|
|
372
|
+
op_str = {
|
|
373
|
+
ConstraintOp.EQ: "=",
|
|
374
|
+
ConstraintOp.GTE: ">=",
|
|
375
|
+
ConstraintOp.LTE: "<=",
|
|
376
|
+
ConstraintOp.GT: ">",
|
|
377
|
+
ConstraintOp.LT: "<",
|
|
378
|
+
ConstraintOp.CARET: "^",
|
|
379
|
+
ConstraintOp.TILDE: "~",
|
|
380
|
+
}[self.op]
|
|
381
|
+
|
|
382
|
+
return f"{op_str}{self.version}"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@dataclass
|
|
386
|
+
class Dependency:
|
|
387
|
+
"""A package dependency with version constraint."""
|
|
388
|
+
package: str # Section number
|
|
389
|
+
constraint: VersionConstraint
|
|
390
|
+
|
|
391
|
+
@classmethod
|
|
392
|
+
def parse(cls, dep_str: str) -> "Dependency":
|
|
393
|
+
"""Parse dependency string like 'S403@^1.0.0' or 'S403>=1.0.0'."""
|
|
394
|
+
# Check for @ separator
|
|
395
|
+
if "@" in dep_str:
|
|
396
|
+
package, version = dep_str.split("@", 1)
|
|
397
|
+
return cls(package.strip(), VersionConstraint.parse(version))
|
|
398
|
+
|
|
399
|
+
# Check for version operators
|
|
400
|
+
for op in [">=", "<=", ">", "<", "^", "~", "="]:
|
|
401
|
+
if op in dep_str:
|
|
402
|
+
idx = dep_str.index(op)
|
|
403
|
+
package = dep_str[:idx].strip()
|
|
404
|
+
constraint = VersionConstraint.parse(dep_str[idx:])
|
|
405
|
+
return cls(package, constraint)
|
|
406
|
+
|
|
407
|
+
# No version constraint - any version
|
|
408
|
+
return cls(dep_str.strip(), VersionConstraint(ConstraintOp.ANY, None))
|
|
409
|
+
|
|
410
|
+
def __str__(self) -> str:
|
|
411
|
+
return f"{self.package}@{self.constraint}"
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@dataclass
|
|
415
|
+
class PackageInfo:
|
|
416
|
+
"""Information about a package for resolution."""
|
|
417
|
+
section_number: str
|
|
418
|
+
version: Version
|
|
419
|
+
dependencies: List[Dependency] = field(default_factory=list)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@dataclass
|
|
423
|
+
class Resolution:
|
|
424
|
+
"""Result of dependency resolution."""
|
|
425
|
+
success: bool
|
|
426
|
+
packages: Dict[str, Version] # section_number -> resolved version
|
|
427
|
+
errors: List[str] = field(default_factory=list)
|
|
428
|
+
install_order: List[str] = field(default_factory=list)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class DependencyResolver:
|
|
432
|
+
"""
|
|
433
|
+
Resolves package dependencies with version constraints.
|
|
434
|
+
|
|
435
|
+
Uses a backtracking algorithm to find a consistent set of
|
|
436
|
+
package versions that satisfy all constraints.
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
def __init__(self):
|
|
440
|
+
"""Initialize resolver."""
|
|
441
|
+
self._available: Dict[str, List[PackageInfo]] = {} # package -> versions
|
|
442
|
+
self._constraints: Dict[str, List[VersionConstraint]] = {} # package -> constraints
|
|
443
|
+
|
|
444
|
+
def add_available_package(self, package: PackageInfo) -> None:
|
|
445
|
+
"""Add an available package version."""
|
|
446
|
+
if package.section_number not in self._available:
|
|
447
|
+
self._available[package.section_number] = []
|
|
448
|
+
self._available[package.section_number].append(package)
|
|
449
|
+
# Keep sorted by version (newest first)
|
|
450
|
+
self._available[package.section_number].sort(
|
|
451
|
+
key=lambda p: p.version, reverse=True
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
def load_from_index(self, index) -> None:
|
|
455
|
+
"""Load available packages from library index."""
|
|
456
|
+
for entry in index.list_all():
|
|
457
|
+
pkg = PackageInfo(
|
|
458
|
+
section_number=entry.section_number,
|
|
459
|
+
version=Version.parse(entry.version),
|
|
460
|
+
dependencies=[], # Would need to load from package
|
|
461
|
+
)
|
|
462
|
+
self.add_available_package(pkg)
|
|
463
|
+
|
|
464
|
+
def resolve(
|
|
465
|
+
self,
|
|
466
|
+
root_dependencies: List[Dependency],
|
|
467
|
+
installed: Optional[Dict[str, Version]] = None,
|
|
468
|
+
) -> Resolution:
|
|
469
|
+
"""
|
|
470
|
+
Resolve dependencies starting from root dependencies.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
root_dependencies: Initial dependencies to resolve
|
|
474
|
+
installed: Already installed packages (locked versions)
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Resolution result
|
|
478
|
+
"""
|
|
479
|
+
installed = installed or {}
|
|
480
|
+
self._constraints = {}
|
|
481
|
+
|
|
482
|
+
# Add root constraints
|
|
483
|
+
for dep in root_dependencies:
|
|
484
|
+
self._add_constraint(dep.package, dep.constraint)
|
|
485
|
+
|
|
486
|
+
# Try to resolve
|
|
487
|
+
resolved: Dict[str, Version] = dict(installed)
|
|
488
|
+
to_resolve = [dep.package for dep in root_dependencies]
|
|
489
|
+
errors = []
|
|
490
|
+
|
|
491
|
+
while to_resolve:
|
|
492
|
+
package = to_resolve.pop(0)
|
|
493
|
+
|
|
494
|
+
if package in resolved:
|
|
495
|
+
# Already resolved, check constraint
|
|
496
|
+
if not self._check_constraints(package, resolved[package]):
|
|
497
|
+
errors.append(
|
|
498
|
+
f"Conflict: {package}@{resolved[package]} violates constraints"
|
|
499
|
+
)
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
# Find best matching version
|
|
503
|
+
version = self._find_best_version(package)
|
|
504
|
+
|
|
505
|
+
if version is None:
|
|
506
|
+
errors.append(f"No version of {package} satisfies constraints")
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
resolved[package] = version
|
|
510
|
+
|
|
511
|
+
# Add transitive dependencies
|
|
512
|
+
pkg_info = self._get_package(package, version)
|
|
513
|
+
if pkg_info:
|
|
514
|
+
for dep in pkg_info.dependencies:
|
|
515
|
+
self._add_constraint(dep.package, dep.constraint)
|
|
516
|
+
if dep.package not in resolved:
|
|
517
|
+
to_resolve.append(dep.package)
|
|
518
|
+
|
|
519
|
+
if errors:
|
|
520
|
+
return Resolution(
|
|
521
|
+
success=False,
|
|
522
|
+
packages={},
|
|
523
|
+
errors=errors,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Compute install order (topological sort)
|
|
527
|
+
install_order = self._topological_sort(resolved)
|
|
528
|
+
|
|
529
|
+
return Resolution(
|
|
530
|
+
success=True,
|
|
531
|
+
packages=resolved,
|
|
532
|
+
install_order=install_order,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
def _add_constraint(self, package: str, constraint: VersionConstraint) -> None:
|
|
536
|
+
"""Add a constraint for a package."""
|
|
537
|
+
if package not in self._constraints:
|
|
538
|
+
self._constraints[package] = []
|
|
539
|
+
self._constraints[package].append(constraint)
|
|
540
|
+
|
|
541
|
+
def _check_constraints(self, package: str, version: Version) -> bool:
|
|
542
|
+
"""Check if version satisfies all constraints for package."""
|
|
543
|
+
if package not in self._constraints:
|
|
544
|
+
return True
|
|
545
|
+
return all(c.satisfies(version) for c in self._constraints[package])
|
|
546
|
+
|
|
547
|
+
def _find_best_version(self, package: str) -> Optional[Version]:
|
|
548
|
+
"""Find the best (newest) version satisfying constraints."""
|
|
549
|
+
if package not in self._available:
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
for pkg_info in self._available[package]:
|
|
553
|
+
if self._check_constraints(package, pkg_info.version):
|
|
554
|
+
return pkg_info.version
|
|
555
|
+
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
def _get_package(self, package: str, version: Version) -> Optional[PackageInfo]:
|
|
559
|
+
"""Get package info for a specific version."""
|
|
560
|
+
if package not in self._available:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
for pkg_info in self._available[package]:
|
|
564
|
+
if pkg_info.version == version:
|
|
565
|
+
return pkg_info
|
|
566
|
+
|
|
567
|
+
return None
|
|
568
|
+
|
|
569
|
+
def _topological_sort(self, packages: Dict[str, Version]) -> List[str]:
|
|
570
|
+
"""Sort packages by dependency order (dependencies first)."""
|
|
571
|
+
# Build dependency graph
|
|
572
|
+
graph: Dict[str, Set[str]] = {pkg: set() for pkg in packages}
|
|
573
|
+
|
|
574
|
+
for pkg, version in packages.items():
|
|
575
|
+
pkg_info = self._get_package(pkg, version)
|
|
576
|
+
if pkg_info:
|
|
577
|
+
for dep in pkg_info.dependencies:
|
|
578
|
+
if dep.package in packages:
|
|
579
|
+
graph[pkg].add(dep.package)
|
|
580
|
+
|
|
581
|
+
# Kahn's algorithm
|
|
582
|
+
in_degree = {pkg: 0 for pkg in packages}
|
|
583
|
+
for pkg, deps in graph.items():
|
|
584
|
+
for dep in deps:
|
|
585
|
+
in_degree[pkg] += 1
|
|
586
|
+
|
|
587
|
+
queue = [pkg for pkg, degree in in_degree.items() if degree == 0]
|
|
588
|
+
result = []
|
|
589
|
+
|
|
590
|
+
while queue:
|
|
591
|
+
pkg = queue.pop(0)
|
|
592
|
+
result.append(pkg)
|
|
593
|
+
|
|
594
|
+
for other, deps in graph.items():
|
|
595
|
+
if pkg in deps:
|
|
596
|
+
in_degree[other] -= 1
|
|
597
|
+
if in_degree[other] == 0:
|
|
598
|
+
queue.append(other)
|
|
599
|
+
|
|
600
|
+
return result
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@dataclass
|
|
604
|
+
class CompatibilityResult:
|
|
605
|
+
"""Result of compatibility check between package versions."""
|
|
606
|
+
compatible: bool
|
|
607
|
+
breaking_changes: List[str] = field(default_factory=list)
|
|
608
|
+
warnings: List[str] = field(default_factory=list)
|
|
609
|
+
upgrade_type: str = "" # "major", "minor", "patch", "prerelease", "downgrade"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class CompatibilityChecker:
|
|
613
|
+
"""
|
|
614
|
+
Checks version compatibility between packages.
|
|
615
|
+
|
|
616
|
+
Enforces semver compatibility rules and detects breaking changes.
|
|
617
|
+
"""
|
|
618
|
+
|
|
619
|
+
@staticmethod
|
|
620
|
+
def check_upgrade(
|
|
621
|
+
from_version: Version,
|
|
622
|
+
to_version: Version,
|
|
623
|
+
allow_major: bool = False,
|
|
624
|
+
) -> CompatibilityResult:
|
|
625
|
+
"""
|
|
626
|
+
Check if upgrading between versions is compatible.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
from_version: Current version
|
|
630
|
+
to_version: Target version
|
|
631
|
+
allow_major: If True, allow major version upgrades
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
CompatibilityResult with compatibility status
|
|
635
|
+
"""
|
|
636
|
+
breaking = []
|
|
637
|
+
warnings = []
|
|
638
|
+
|
|
639
|
+
# Determine upgrade type
|
|
640
|
+
if to_version < from_version:
|
|
641
|
+
upgrade_type = "downgrade"
|
|
642
|
+
warnings.append(f"Downgrade from {from_version} to {to_version}")
|
|
643
|
+
elif to_version.major > from_version.major:
|
|
644
|
+
upgrade_type = "major"
|
|
645
|
+
elif to_version.minor > from_version.minor:
|
|
646
|
+
upgrade_type = "minor"
|
|
647
|
+
elif to_version.patch > from_version.patch:
|
|
648
|
+
upgrade_type = "patch"
|
|
649
|
+
elif to_version.prerelease != from_version.prerelease:
|
|
650
|
+
upgrade_type = "prerelease"
|
|
651
|
+
else:
|
|
652
|
+
upgrade_type = "none"
|
|
653
|
+
|
|
654
|
+
# Check for breaking changes
|
|
655
|
+
is_breaking = to_version.is_breaking_change_from(from_version)
|
|
656
|
+
if is_breaking:
|
|
657
|
+
if from_version.major == 0:
|
|
658
|
+
breaking.append(
|
|
659
|
+
f"Minor version bump in 0.x series ({from_version} -> {to_version}) "
|
|
660
|
+
"may contain breaking changes"
|
|
661
|
+
)
|
|
662
|
+
else:
|
|
663
|
+
breaking.append(
|
|
664
|
+
f"Major version bump ({from_version} -> {to_version}) "
|
|
665
|
+
"indicates breaking API changes"
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
compatible = not is_breaking or allow_major
|
|
669
|
+
|
|
670
|
+
return CompatibilityResult(
|
|
671
|
+
compatible=compatible,
|
|
672
|
+
breaking_changes=breaking,
|
|
673
|
+
warnings=warnings,
|
|
674
|
+
upgrade_type=upgrade_type,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
@staticmethod
|
|
678
|
+
def check_constraint_compatibility(
|
|
679
|
+
constraint1: VersionConstraint,
|
|
680
|
+
constraint2: VersionConstraint,
|
|
681
|
+
) -> Tuple[bool, Optional[VersionConstraint]]:
|
|
682
|
+
"""
|
|
683
|
+
Check if two version constraints can be satisfied simultaneously.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
constraint1: First constraint
|
|
687
|
+
constraint2: Second constraint
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
Tuple of (compatible, merged_constraint or None if incompatible)
|
|
691
|
+
"""
|
|
692
|
+
# ANY matches everything
|
|
693
|
+
if constraint1.op == ConstraintOp.ANY:
|
|
694
|
+
return (True, constraint2)
|
|
695
|
+
if constraint2.op == ConstraintOp.ANY:
|
|
696
|
+
return (True, constraint1)
|
|
697
|
+
|
|
698
|
+
# For exact matches, check if they're the same
|
|
699
|
+
if constraint1.op == ConstraintOp.EQ and constraint2.op == ConstraintOp.EQ:
|
|
700
|
+
if constraint1.version == constraint2.version:
|
|
701
|
+
return (True, constraint1)
|
|
702
|
+
return (False, None)
|
|
703
|
+
|
|
704
|
+
# For one exact and one range, check if exact satisfies range
|
|
705
|
+
if constraint1.op == ConstraintOp.EQ:
|
|
706
|
+
if constraint2.satisfies(constraint1.version):
|
|
707
|
+
return (True, constraint1)
|
|
708
|
+
return (False, None)
|
|
709
|
+
if constraint2.op == ConstraintOp.EQ:
|
|
710
|
+
if constraint1.satisfies(constraint2.version):
|
|
711
|
+
return (True, constraint2)
|
|
712
|
+
return (False, None)
|
|
713
|
+
|
|
714
|
+
# For two ranges, find intersection
|
|
715
|
+
# This is a simplified check - returns the more restrictive constraint
|
|
716
|
+
# A full implementation would compute the actual intersection
|
|
717
|
+
v1, v2 = constraint1.version, constraint2.version
|
|
718
|
+
|
|
719
|
+
# Check if there's any overlap by testing boundary versions
|
|
720
|
+
test_versions = [v1, v2]
|
|
721
|
+
if v1:
|
|
722
|
+
test_versions.extend([v1.next_patch(), v1.next_minor(), v1.next_major()])
|
|
723
|
+
if v2:
|
|
724
|
+
test_versions.extend([v2.next_patch(), v2.next_minor(), v2.next_major()])
|
|
725
|
+
|
|
726
|
+
for v in test_versions:
|
|
727
|
+
if v and constraint1.satisfies(v) and constraint2.satisfies(v):
|
|
728
|
+
# There's at least one satisfying version
|
|
729
|
+
# Return the constraint with higher minimum
|
|
730
|
+
if constraint1.version and constraint2.version:
|
|
731
|
+
if constraint1.version >= constraint2.version:
|
|
732
|
+
return (True, constraint1)
|
|
733
|
+
return (True, constraint2)
|
|
734
|
+
return (True, constraint1)
|
|
735
|
+
|
|
736
|
+
return (False, None)
|
|
737
|
+
|
|
738
|
+
@staticmethod
|
|
739
|
+
def find_compatible_versions(
|
|
740
|
+
available: List[Version],
|
|
741
|
+
constraints: List[VersionConstraint],
|
|
742
|
+
) -> List[Version]:
|
|
743
|
+
"""
|
|
744
|
+
Find all versions that satisfy all constraints.
|
|
745
|
+
|
|
746
|
+
Args:
|
|
747
|
+
available: List of available versions
|
|
748
|
+
constraints: List of constraints to satisfy
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
List of compatible versions (sorted newest first)
|
|
752
|
+
"""
|
|
753
|
+
compatible = []
|
|
754
|
+
for v in available:
|
|
755
|
+
if all(c.satisfies(v) for c in constraints):
|
|
756
|
+
compatible.append(v)
|
|
757
|
+
return sorted(compatible, reverse=True)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def resolve_dependencies(
|
|
761
|
+
dependencies: List[str],
|
|
762
|
+
installed: Optional[Dict[str, str]] = None,
|
|
763
|
+
) -> Resolution:
|
|
764
|
+
"""
|
|
765
|
+
Convenience function to resolve dependencies.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
dependencies: List of dependency strings like 'S403@^1.0.0'
|
|
769
|
+
installed: Dict of installed packages (section -> version string)
|
|
770
|
+
|
|
771
|
+
Returns:
|
|
772
|
+
Resolution result
|
|
773
|
+
"""
|
|
774
|
+
from yuho.library.index import LibraryIndex
|
|
775
|
+
|
|
776
|
+
resolver = DependencyResolver()
|
|
777
|
+
|
|
778
|
+
# Load available packages from index
|
|
779
|
+
index = LibraryIndex()
|
|
780
|
+
resolver.load_from_index(index)
|
|
781
|
+
|
|
782
|
+
# Parse dependencies
|
|
783
|
+
parsed_deps = [Dependency.parse(d) for d in dependencies]
|
|
784
|
+
|
|
785
|
+
# Parse installed versions
|
|
786
|
+
parsed_installed = {}
|
|
787
|
+
if installed:
|
|
788
|
+
for pkg, ver in installed.items():
|
|
789
|
+
parsed_installed[pkg] = Version.parse(ver)
|
|
790
|
+
|
|
791
|
+
return resolver.resolve(parsed_deps, parsed_installed)
|