macrostates-cli 0.3.1__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.
- macrostates/__init__.py +23 -0
- macrostates/__main__.py +4 -0
- macrostates/_build_version.py +56 -0
- macrostates/_cli.py +158 -0
- macrostates/_formats/__init__.py +42 -0
- macrostates/_formats/common.py +102 -0
- macrostates/_formats/v0.py +11 -0
- macrostates/_formats/v1.py +44 -0
- macrostates/_integrity.py +144 -0
- macrostates/_io.py +118 -0
- macrostates/_models.py +71 -0
- macrostates/_mutations.py +53 -0
- macrostates/_project.py +527 -0
- macrostates/_scopes.py +34 -0
- macrostates/_sources.py +153 -0
- macrostates/_staged.py +56 -0
- macrostates/_validation.py +274 -0
- macrostates/py.typed +0 -0
- macrostates_cli-0.3.1.dist-info/METADATA +259 -0
- macrostates_cli-0.3.1.dist-info/RECORD +24 -0
- macrostates_cli-0.3.1.dist-info/WHEEL +5 -0
- macrostates_cli-0.3.1.dist-info/entry_points.txt +2 -0
- macrostates_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
- macrostates_cli-0.3.1.dist-info/top_level.txt +1 -0
macrostates/_sources.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""GitHub transport. Tokens never enter manifests, locks, URLs or diagnostics."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
11
|
+
from urllib.parse import quote, urlparse
|
|
12
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
13
|
+
|
|
14
|
+
from ._integrity import MAX_ARCHIVE
|
|
15
|
+
from ._models import MacrostatesError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SourceProvider(Protocol):
|
|
19
|
+
"""Injectable source boundary used by install and lock."""
|
|
20
|
+
|
|
21
|
+
def resolve(self, repository: str, tag: str) -> str:
|
|
22
|
+
"""Return the full commit ID selected by a release tag."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def download(self, repository: str, commit: str) -> bytes:
|
|
26
|
+
"""Return a tar archive of that exact commit with a single root directory."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def github_repository(value: str) -> str:
|
|
31
|
+
patterns = (
|
|
32
|
+
r"https://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?/?",
|
|
33
|
+
r"git@github\.com:([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?",
|
|
34
|
+
)
|
|
35
|
+
for pattern in patterns:
|
|
36
|
+
match = re.fullmatch(pattern, value)
|
|
37
|
+
if match:
|
|
38
|
+
if any(part in (".", "..") for part in match[1].split("/")):
|
|
39
|
+
break
|
|
40
|
+
return match[1]
|
|
41
|
+
raise MacrostatesError("Sources must use a credential-free github.com repository URL")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SafeRedirect(HTTPRedirectHandler):
|
|
45
|
+
def redirect_request(
|
|
46
|
+
self, req: Request, fp: Any, code: int, msg: str, headers: Any, newurl: str
|
|
47
|
+
) -> Request | None:
|
|
48
|
+
parsed = urlparse(newurl)
|
|
49
|
+
if parsed.scheme != "https" or parsed.hostname not in (
|
|
50
|
+
"github.com",
|
|
51
|
+
"api.github.com",
|
|
52
|
+
"codeload.github.com",
|
|
53
|
+
):
|
|
54
|
+
raise MacrostatesError("GitHub archive redirected to an unsupported host")
|
|
55
|
+
redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
56
|
+
if redirected and parsed.hostname != urlparse(req.full_url).hostname:
|
|
57
|
+
redirected.remove_header("Authorization")
|
|
58
|
+
return redirected
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GitHubSource:
|
|
62
|
+
"""Download GitHub snapshots using an explicit token, environment, or gh login.
|
|
63
|
+
|
|
64
|
+
Creating this object performs no I/O. Credential lookup occurs only when a
|
|
65
|
+
request is made. Passing token explicitly takes precedence over environment.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, *, token: str | None = None, timeout: float = 30) -> None:
|
|
69
|
+
self._explicit_token = token
|
|
70
|
+
self.timeout = timeout
|
|
71
|
+
|
|
72
|
+
def _token(self) -> str | None:
|
|
73
|
+
if self._explicit_token is not None:
|
|
74
|
+
return self._explicit_token
|
|
75
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
76
|
+
if token:
|
|
77
|
+
return token
|
|
78
|
+
if shutil.which("gh"):
|
|
79
|
+
try:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
["gh", "auth", "token", "--hostname", "github.com"],
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
timeout=self.timeout,
|
|
85
|
+
check=False,
|
|
86
|
+
)
|
|
87
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
88
|
+
raise MacrostatesError("GitHub CLI credential lookup failed") from exc
|
|
89
|
+
if result.returncode == 0:
|
|
90
|
+
return result.stdout.strip()
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def _request(self, endpoint: str, *, archive: bool = False) -> bytes:
|
|
94
|
+
try:
|
|
95
|
+
user_agent = "macrostates-cli/" + version("macrostates-cli")
|
|
96
|
+
except PackageNotFoundError:
|
|
97
|
+
user_agent = "macrostates-cli"
|
|
98
|
+
headers = {
|
|
99
|
+
"Accept": "application/vnd.github+json",
|
|
100
|
+
"User-Agent": user_agent,
|
|
101
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
102
|
+
}
|
|
103
|
+
token = self._token()
|
|
104
|
+
if token:
|
|
105
|
+
headers["Authorization"] = "Bearer " + token
|
|
106
|
+
request = Request("https://api.github.com/" + endpoint, headers=headers)
|
|
107
|
+
limit = MAX_ARCHIVE if archive else 2 * 1024 * 1024
|
|
108
|
+
try:
|
|
109
|
+
with build_opener(SafeRedirect()).open(request, timeout=self.timeout) as response:
|
|
110
|
+
data = response.read(limit + 1)
|
|
111
|
+
except HTTPError as exc:
|
|
112
|
+
raise MacrostatesError(
|
|
113
|
+
f"GitHub request failed (HTTP {exc.code}); check access and the release tag"
|
|
114
|
+
) from None
|
|
115
|
+
except (URLError, OSError, ValueError) as exc:
|
|
116
|
+
raise MacrostatesError(
|
|
117
|
+
"GitHub request failed; check connectivity and authentication"
|
|
118
|
+
) from exc
|
|
119
|
+
if len(data) > limit:
|
|
120
|
+
raise MacrostatesError("GitHub response exceeds the download limit")
|
|
121
|
+
return data
|
|
122
|
+
|
|
123
|
+
def _json(self, endpoint: str) -> dict[str, Any]:
|
|
124
|
+
try:
|
|
125
|
+
result = json.loads(self._request(endpoint))
|
|
126
|
+
except (ValueError, UnicodeError) as exc:
|
|
127
|
+
raise MacrostatesError("Invalid GitHub API response") from exc
|
|
128
|
+
if not isinstance(result, dict):
|
|
129
|
+
raise MacrostatesError("Invalid GitHub API response")
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def resolve(self, repository: str, tag: str) -> str:
|
|
133
|
+
repository_id = github_repository(repository)
|
|
134
|
+
result = self._json(f"repos/{repository_id}/git/ref/tags/{quote(tag, safe='')}")
|
|
135
|
+
for _ in range(8):
|
|
136
|
+
obj = result.get("object", {})
|
|
137
|
+
if not isinstance(obj, dict):
|
|
138
|
+
raise MacrostatesError("GitHub returned an invalid tag object")
|
|
139
|
+
sha = obj.get("sha")
|
|
140
|
+
if not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha):
|
|
141
|
+
raise MacrostatesError("GitHub returned an invalid object ID")
|
|
142
|
+
if obj.get("type") == "commit":
|
|
143
|
+
return sha
|
|
144
|
+
if obj.get("type") != "tag":
|
|
145
|
+
raise MacrostatesError("Release tag does not select a commit")
|
|
146
|
+
result = self._json(f"repos/{repository_id}/git/tags/{sha}")
|
|
147
|
+
raise MacrostatesError("Release tag nesting exceeds the supported depth")
|
|
148
|
+
|
|
149
|
+
def download(self, repository: str, commit: str) -> bytes:
|
|
150
|
+
repository_id = github_repository(repository)
|
|
151
|
+
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", commit):
|
|
152
|
+
raise MacrostatesError("Archive download requires a full commit ID")
|
|
153
|
+
return self._request(f"repos/{repository_id}/tarball/{commit}", archive=True)
|
macrostates/_staged.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Read a Git index without executing hooks or changing the checkout."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ._integrity import MAX_EXTRACTED
|
|
8
|
+
from ._io import relative_path, safe_path
|
|
9
|
+
from ._models import MacrostatesError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def git(root: Path, *arguments: str) -> bytes:
|
|
13
|
+
result = subprocess.run(["git", "-C", str(root), *arguments], capture_output=True, check=False)
|
|
14
|
+
if result.returncode:
|
|
15
|
+
raise MacrostatesError(
|
|
16
|
+
"Cannot inspect Git index; ensure the project is inside a Git repository"
|
|
17
|
+
)
|
|
18
|
+
return result.stdout
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def export_index(project_root: Path, target: Path) -> Path:
|
|
22
|
+
git_root = Path(
|
|
23
|
+
os.fsdecode(git(project_root, "rev-parse", "--show-toplevel")).strip()
|
|
24
|
+
).resolve()
|
|
25
|
+
relative_project = project_root.resolve().relative_to(git_root)
|
|
26
|
+
entries = git(git_root, "ls-files", "--stage", "-z").split(b"\0")
|
|
27
|
+
links: list[tuple[Path, str]] = []
|
|
28
|
+
total = 0
|
|
29
|
+
for entry in entries:
|
|
30
|
+
if not entry:
|
|
31
|
+
continue
|
|
32
|
+
header, encoded_path = entry.split(b"\t", 1)
|
|
33
|
+
mode, sha, stage = header.decode("ascii").split()
|
|
34
|
+
if stage != "0":
|
|
35
|
+
raise MacrostatesError("Git index has unresolved merge conflicts")
|
|
36
|
+
if mode == "160000":
|
|
37
|
+
# Submodule contents are outside this repository's staged snapshot.
|
|
38
|
+
continue
|
|
39
|
+
if mode not in ("100644", "100755", "120000"):
|
|
40
|
+
raise MacrostatesError("Git index contains an unsupported file mode")
|
|
41
|
+
name = relative_path(os.fsdecode(encoded_path), "staged path")
|
|
42
|
+
destination = safe_path(target, name)
|
|
43
|
+
contents = git(git_root, "cat-file", "blob", sha)
|
|
44
|
+
total += len(contents)
|
|
45
|
+
if total > MAX_EXTRACTED:
|
|
46
|
+
raise MacrostatesError("Staged snapshot exceeds 128 MiB")
|
|
47
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
if mode == "120000":
|
|
49
|
+
links.append((destination, os.fsdecode(contents)))
|
|
50
|
+
else:
|
|
51
|
+
destination.write_bytes(contents)
|
|
52
|
+
destination.chmod(0o755 if mode == "100755" else 0o644)
|
|
53
|
+
# Creating links last prevents a staged link from directing writes elsewhere.
|
|
54
|
+
for destination, contents in links:
|
|
55
|
+
destination.symlink_to(contents)
|
|
56
|
+
return target / relative_project
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Structural validation and dependency checks over normalized metadata."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import unquote, urlsplit
|
|
7
|
+
|
|
8
|
+
from ._formats import read_metadata
|
|
9
|
+
from ._formats.common import dependencies
|
|
10
|
+
from ._io import read_yaml, safe_path, version
|
|
11
|
+
from ._models import Composition, MacrostatesError, Metadata, Report
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_packages(
|
|
15
|
+
composition: Composition, roots: dict[str, Path]
|
|
16
|
+
) -> tuple[Report, dict[str, Metadata]]:
|
|
17
|
+
report = Report()
|
|
18
|
+
metadata: dict[str, Metadata] = {}
|
|
19
|
+
selected = {package.name: package for package in composition.packages}
|
|
20
|
+
for package in composition.packages:
|
|
21
|
+
root = roots[package.name]
|
|
22
|
+
try:
|
|
23
|
+
item = read_metadata(read_yaml(safe_path(root, "package.yaml")))
|
|
24
|
+
if (item.name, item.version, item.entrypoint) != (
|
|
25
|
+
package.name,
|
|
26
|
+
package.version,
|
|
27
|
+
package.entrypoint,
|
|
28
|
+
):
|
|
29
|
+
report.add(
|
|
30
|
+
"package.mismatch",
|
|
31
|
+
f"Metadata does not match selection for {package.name}",
|
|
32
|
+
package.path,
|
|
33
|
+
)
|
|
34
|
+
if not safe_path(root, item.entrypoint).is_file():
|
|
35
|
+
report.add(
|
|
36
|
+
"entrypoint.missing", f"Missing entrypoint for {package.name}", package.path
|
|
37
|
+
)
|
|
38
|
+
for key, raw in package.legacy_dependencies.items():
|
|
39
|
+
expected = (
|
|
40
|
+
item.dependencies if key == "dependencies" else item.optional_dependencies
|
|
41
|
+
)
|
|
42
|
+
if dependencies(raw, key) != expected:
|
|
43
|
+
report.add(
|
|
44
|
+
"dependency.legacy_mismatch",
|
|
45
|
+
f"Legacy dependency copies disagree for {package.name}",
|
|
46
|
+
package.path,
|
|
47
|
+
)
|
|
48
|
+
metadata[package.name] = item
|
|
49
|
+
except (MacrostatesError, OSError) as exc:
|
|
50
|
+
report.add("package.invalid", f"{package.name}: {exc}", package.path)
|
|
51
|
+
graph: dict[str, list[str]] = {}
|
|
52
|
+
for name, item in metadata.items():
|
|
53
|
+
graph[name] = []
|
|
54
|
+
for dependency in item.dependencies + item.optional_dependencies:
|
|
55
|
+
if dependency.name not in selected:
|
|
56
|
+
if dependency in item.dependencies:
|
|
57
|
+
report.add("dependency.missing", f"{name} requires {dependency.name}")
|
|
58
|
+
continue
|
|
59
|
+
actual = version(selected[dependency.name].version)
|
|
60
|
+
required = version(dependency.version)
|
|
61
|
+
satisfied = (
|
|
62
|
+
actual == required if dependency.constraint == "exact" else actual >= required
|
|
63
|
+
)
|
|
64
|
+
if dependency.constraint == "compatible":
|
|
65
|
+
satisfied = satisfied and actual[0] == required[0]
|
|
66
|
+
if not satisfied:
|
|
67
|
+
report.add(
|
|
68
|
+
"dependency.incompatible",
|
|
69
|
+
f"{name} requires {dependency.name} {dependency.constraint} {dependency.version}; selected {selected[dependency.name].version}",
|
|
70
|
+
)
|
|
71
|
+
graph[name].append(dependency.name)
|
|
72
|
+
|
|
73
|
+
visiting: set[str] = set()
|
|
74
|
+
visited: set[str] = set()
|
|
75
|
+
|
|
76
|
+
def visit(name: str) -> None:
|
|
77
|
+
if name in visiting:
|
|
78
|
+
report.add("dependency.cycle", f"Dependency cycle contains {name}")
|
|
79
|
+
return
|
|
80
|
+
if name in visited:
|
|
81
|
+
return
|
|
82
|
+
visiting.add(name)
|
|
83
|
+
for target in graph.get(name, []):
|
|
84
|
+
visit(target)
|
|
85
|
+
visiting.remove(name)
|
|
86
|
+
visited.add(name)
|
|
87
|
+
|
|
88
|
+
for name in graph:
|
|
89
|
+
visit(name)
|
|
90
|
+
return report, metadata
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_links(root: Path, boundary: Path, report: Report) -> None:
|
|
94
|
+
# This is a conservative local-link checker, not a Markdown semantic parser.
|
|
95
|
+
for path in sorted(root.rglob("*.md")):
|
|
96
|
+
if path.is_symlink():
|
|
97
|
+
report.add(
|
|
98
|
+
"path.symlink",
|
|
99
|
+
"Specification documents must not be symlinks",
|
|
100
|
+
path.relative_to(boundary).as_posix(),
|
|
101
|
+
)
|
|
102
|
+
continue
|
|
103
|
+
text = path.read_text(encoding="utf-8")
|
|
104
|
+
text = re.sub(r"```.*?```|~~~.*?~~~", "", text, flags=re.S)
|
|
105
|
+
targets = re.findall(r"!?\[[^\]\n]*\]\(([^\n)]*)\)", text)
|
|
106
|
+
targets += re.findall(r"^\s*\[[^\]]+\]:\s*(\S+)", text, flags=re.M)
|
|
107
|
+
for raw in targets:
|
|
108
|
+
target = raw.strip().split(' "', 1)[0].strip("<>")
|
|
109
|
+
parsed = urlsplit(target)
|
|
110
|
+
if not target or parsed.scheme or parsed.netloc or not parsed.path:
|
|
111
|
+
continue
|
|
112
|
+
destination = path.parent / unquote(parsed.path)
|
|
113
|
+
if not destination.resolve().is_relative_to(boundary.resolve()):
|
|
114
|
+
report.add(
|
|
115
|
+
"link.escape",
|
|
116
|
+
"Local specification link escapes the project",
|
|
117
|
+
path.relative_to(boundary).as_posix(),
|
|
118
|
+
)
|
|
119
|
+
elif not destination.exists():
|
|
120
|
+
report.add(
|
|
121
|
+
"link.missing",
|
|
122
|
+
f"Missing local link target: {parsed.path}",
|
|
123
|
+
path.relative_to(boundary).as_posix(),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Process23Policy:
|
|
128
|
+
"""Process 2.3.x declaration policy; earlier releases retain their own rules."""
|
|
129
|
+
|
|
130
|
+
label = "Process 2.3"
|
|
131
|
+
requires_modern_layout = False
|
|
132
|
+
|
|
133
|
+
def check(self, composition: Composition, implementation: Path, report: Report) -> None:
|
|
134
|
+
declared = composition.raw["project"].get("version")
|
|
135
|
+
if not isinstance(declared, str) or not declared.startswith("spec-"):
|
|
136
|
+
report.add(
|
|
137
|
+
"process.spec_version",
|
|
138
|
+
f"{self.label} requires project.version: spec-MAJOR.MINOR.REVISION",
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
try:
|
|
142
|
+
spec = version(declared.removeprefix("spec-"))
|
|
143
|
+
release = safe_path(implementation, "release.yaml")
|
|
144
|
+
if not release.exists():
|
|
145
|
+
overview = safe_path(implementation, "main.md")
|
|
146
|
+
if overview.is_file() and re.search(
|
|
147
|
+
r"(?im)^\s*project phase:\s*active\b", overview.read_text()
|
|
148
|
+
):
|
|
149
|
+
report.add(
|
|
150
|
+
"process.release_missing", "Active implementation has no release.yaml"
|
|
151
|
+
)
|
|
152
|
+
return
|
|
153
|
+
data = read_yaml(release)
|
|
154
|
+
current = version(data.get("version"))
|
|
155
|
+
baseline_text = data.get("specification")
|
|
156
|
+
if not isinstance(baseline_text, str) or not baseline_text.startswith("spec-"):
|
|
157
|
+
raise MacrostatesError("release.specification must be spec-MAJOR.MINOR.REVISION")
|
|
158
|
+
baseline = version(baseline_text.removeprefix("spec-"))
|
|
159
|
+
stamp = data.get("release_date")
|
|
160
|
+
if not isinstance(stamp, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", stamp):
|
|
161
|
+
raise MacrostatesError("release_date must be YYYY-MM-DD")
|
|
162
|
+
date.fromisoformat(stamp)
|
|
163
|
+
if current[:2] != baseline[:2] or current[:2] != spec[:2]:
|
|
164
|
+
report.add(
|
|
165
|
+
"process.version_alignment",
|
|
166
|
+
"Composition, implementation and baseline Major.Minor differ",
|
|
167
|
+
)
|
|
168
|
+
if baseline[:2] == spec[:2] and baseline[2] > spec[2]:
|
|
169
|
+
report.add(
|
|
170
|
+
"process.future_baseline",
|
|
171
|
+
"Implementation references a later specification revision",
|
|
172
|
+
)
|
|
173
|
+
except (MacrostatesError, ValueError, OSError) as exc:
|
|
174
|
+
report.add("process.release_invalid", str(exc))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Process24Policy(Process23Policy):
|
|
178
|
+
"""2.4 adds optional verification guidance without changing declarations."""
|
|
179
|
+
|
|
180
|
+
label = "Process 2.4"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Process30Policy(Process23Policy):
|
|
184
|
+
"""3.0 retains contract versions and adopts Meta 2 artifact locations."""
|
|
185
|
+
|
|
186
|
+
label = "Process 3.0"
|
|
187
|
+
requires_modern_layout = True
|
|
188
|
+
|
|
189
|
+
def check(self, composition: Composition, implementation: Path, report: Report) -> None:
|
|
190
|
+
if implementation.parent.name != ".macrostates":
|
|
191
|
+
report.add("process.layout", f"{self.label} requires .macrostates/implementation/")
|
|
192
|
+
super().check(composition, implementation, report)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Process40Policy(Process30Policy):
|
|
196
|
+
"""4.0 retains declarations/layout; branch and PR compliance needs manual review."""
|
|
197
|
+
|
|
198
|
+
label = "Process 4.0"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Process41Policy(Process40Policy):
|
|
202
|
+
"""4.1 expands dependency constraints without changing declaration checks."""
|
|
203
|
+
|
|
204
|
+
label = "Process 4.1"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
PROCESS_POLICIES = {
|
|
208
|
+
(2, 3): Process23Policy(),
|
|
209
|
+
(2, 4): Process24Policy(),
|
|
210
|
+
(3, 0): Process30Policy(),
|
|
211
|
+
(4, 0): Process40Policy(),
|
|
212
|
+
(4, 1): Process41Policy(),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def has_contract_version(process_version: str) -> bool:
|
|
217
|
+
"""Only known release policies opt new projects into contract versions."""
|
|
218
|
+
return version(process_version)[:2] in PROCESS_POLICIES
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def process_requires_modern_layout(process_version: str) -> bool:
|
|
222
|
+
policy = PROCESS_POLICIES.get(version(process_version)[:2])
|
|
223
|
+
return policy is not None and policy.requires_modern_layout
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class Meta20Policy:
|
|
227
|
+
"""Meta 2.0 layout and explicit composition-format checks."""
|
|
228
|
+
|
|
229
|
+
label = "Meta 2.0"
|
|
230
|
+
|
|
231
|
+
def check(self, composition: Composition, specs: Path, root: Path, report: Report) -> None:
|
|
232
|
+
if specs != root / ".macrostates" / "specs":
|
|
233
|
+
report.add("meta.layout", f"{self.label} requires .macrostates/specs/")
|
|
234
|
+
if composition.schema_version != 1:
|
|
235
|
+
report.add(
|
|
236
|
+
"meta.composition_format", f"{self.label} requires composition schema_version: 1"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Meta21Policy(Meta20Policy):
|
|
241
|
+
"""2.1 changes authoring guidance; explicit dependency constraints remain required."""
|
|
242
|
+
|
|
243
|
+
label = "Meta 2.1"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
META_POLICIES = {(2, 0): Meta20Policy(), (2, 1): Meta21Policy()}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def check_meta(composition: Composition, specs: Path, root: Path, report: Report) -> None:
|
|
250
|
+
"""Release-selected layout policy; legacy projects keep their own conventions."""
|
|
251
|
+
meta = next((item for item in composition.packages if item.name == "meta"), None)
|
|
252
|
+
if meta is None or version(meta.version)[0] < 2:
|
|
253
|
+
return
|
|
254
|
+
policy = META_POLICIES.get(version(meta.version)[:2])
|
|
255
|
+
if policy is None:
|
|
256
|
+
report.add("meta.unsupported_policy", f"No layout-policy adapter for Meta {meta.version}")
|
|
257
|
+
return
|
|
258
|
+
policy.check(composition, specs, root, report)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def check_process(composition: Composition, implementation: Path, report: Report) -> None:
|
|
262
|
+
process = next((package for package in composition.packages if package.name == "process"), None)
|
|
263
|
+
if process is None:
|
|
264
|
+
return
|
|
265
|
+
selected = version(process.version)
|
|
266
|
+
if selected[0] == 1 or (selected[0] == 2 and selected[1] < 3):
|
|
267
|
+
return
|
|
268
|
+
policy = PROCESS_POLICIES.get(selected[:2])
|
|
269
|
+
if policy is not None:
|
|
270
|
+
policy.check(composition, implementation, report)
|
|
271
|
+
else:
|
|
272
|
+
report.add(
|
|
273
|
+
"process.unsupported_policy", f"No process-policy adapter for Process {process.version}"
|
|
274
|
+
)
|
macrostates/py.typed
ADDED
|
File without changes
|