unaltraweb 0.5.1 → 0.7.0
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.
- checksums.yaml +4 -4
- data/Makefile +13 -11
- data/_plugins/retained_documents.rb +62 -0
- data/docs/_documentation/en/40-distribution.md +73 -19
- data/docs/_documentation/en/45-retained-documents.md +138 -0
- data/docs/_documentation/en/46-live-runtime.md +89 -0
- data/docs/agents/d0-runtime-identity.md +200 -0
- data/docs/agents/mcp-contract.md +45 -2
- data/docs/agents/owner-closeout-76.md +205 -0
- data/docs/agents/owner-closeout-80.md +185 -0
- data/docs/agents/retained-letter-import.md +235 -0
- data/lib/unaltraweb.rb +1 -0
- data/requirements.txt +2 -0
- data/scripts/manual/Dockerfile +7 -4
- data/scripts/manual/build_pdf.py +28 -0
- data/scripts/manual/templates/manual.tex +1 -0
- data/scripts/test_gem_build.py +27 -0
- data/scripts/test_wheel_install.py +7 -2
- data/scripts/unaltraweb-mcp-bootstrap.sh +90 -9
- data/scripts/unaltraweb-mcp-cleanup.sh +41 -9
- data/scripts/web_captures/render.py +8 -1
- data/src/unaltraweb_mcp/__init__.py +4 -0
- data/src/unaltraweb_mcp/artifact-handoff-v1.schema.json +102 -0
- data/src/unaltraweb_mcp/artifact_handoff_v1.py +435 -0
- data/src/unaltraweb_mcp/artifact_imports.py +368 -0
- data/src/unaltraweb_mcp/component-contract.json +27 -27
- data/src/unaltraweb_mcp/distribution.py +887 -0
- data/src/unaltraweb_mcp/letter_bundle.py +223 -0
- data/src/unaltraweb_mcp/pdf_probe.py +63 -0
- metadata +16 -2
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .processes import run_process
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CONTRACT_NAME = "component-contract.json"
|
|
14
|
+
CONTRACT_SCHEMA_NAME = "component-contract.schema.json"
|
|
15
|
+
DOCTOR_SCHEMA_VERSION = 1
|
|
16
|
+
MUTABLE_TAGS = {"edge", "latest", "main", "master", "nightly", "stable"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _strict_json(path: Path) -> Any:
|
|
20
|
+
def reject(value: str) -> None:
|
|
21
|
+
raise ValueError(f"non-finite JSON number is not allowed: {value}")
|
|
22
|
+
|
|
23
|
+
return json.loads(path.read_text(encoding="utf-8"), parse_constant=reject)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _schema_type_matches(value: Any, expected: str) -> bool:
|
|
27
|
+
return {
|
|
28
|
+
"object": isinstance(value, dict),
|
|
29
|
+
"array": isinstance(value, list),
|
|
30
|
+
"string": isinstance(value, str),
|
|
31
|
+
"boolean": isinstance(value, bool),
|
|
32
|
+
"integer": isinstance(value, int) and not isinstance(value, bool),
|
|
33
|
+
"number": isinstance(value, (int, float)) and not isinstance(value, bool),
|
|
34
|
+
"null": value is None,
|
|
35
|
+
}.get(expected, False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_schema_value(value: Any, rule: dict[str, Any], root: dict[str, Any], path: str) -> None:
|
|
39
|
+
if "$ref" in rule:
|
|
40
|
+
reference = str(rule["$ref"])
|
|
41
|
+
if not reference.startswith("#/"):
|
|
42
|
+
raise RuntimeError(f"Unsupported external schema reference at {path}: {reference}")
|
|
43
|
+
selected: Any = root
|
|
44
|
+
for part in reference[2:].split("/"):
|
|
45
|
+
selected = selected[part.replace("~1", "/").replace("~0", "~")]
|
|
46
|
+
_validate_schema_value(value, selected, root, path)
|
|
47
|
+
return
|
|
48
|
+
expected_type = rule.get("type")
|
|
49
|
+
if expected_type and not _schema_type_matches(value, str(expected_type)):
|
|
50
|
+
raise RuntimeError(f"Component contract {path} must have type {expected_type}.")
|
|
51
|
+
if "const" in rule and value != rule["const"]:
|
|
52
|
+
raise RuntimeError(f"Component contract {path} must equal {rule['const']!r}.")
|
|
53
|
+
if "enum" in rule and value not in rule["enum"]:
|
|
54
|
+
raise RuntimeError(f"Component contract {path} is not one of {rule['enum']!r}.")
|
|
55
|
+
if "pattern" in rule and isinstance(value, str) and re.search(str(rule["pattern"]), value) is None:
|
|
56
|
+
raise RuntimeError(f"Component contract {path} does not match {rule['pattern']!r}.")
|
|
57
|
+
if "minLength" in rule and isinstance(value, str) and len(value) < int(rule["minLength"]):
|
|
58
|
+
raise RuntimeError(f"Component contract {path} must contain at least {rule['minLength']} characters.")
|
|
59
|
+
if "maxLength" in rule and isinstance(value, str) and len(value) > int(rule["maxLength"]):
|
|
60
|
+
raise RuntimeError(f"Component contract {path} must contain at most {rule['maxLength']} characters.")
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
properties = rule.get("properties") if isinstance(rule.get("properties"), dict) else {}
|
|
63
|
+
required = rule.get("required") if isinstance(rule.get("required"), list) else []
|
|
64
|
+
missing = [key for key in required if key not in value]
|
|
65
|
+
if missing:
|
|
66
|
+
raise RuntimeError(f"Component contract {path} is missing required keys: {missing}.")
|
|
67
|
+
additional = rule.get("additionalProperties", True)
|
|
68
|
+
for key, item in value.items():
|
|
69
|
+
item_path = f"{path}.{key}"
|
|
70
|
+
if key in properties:
|
|
71
|
+
_validate_schema_value(item, properties[key], root, item_path)
|
|
72
|
+
elif additional is False:
|
|
73
|
+
raise RuntimeError(f"Component contract {item_path} is not allowed by the schema.")
|
|
74
|
+
elif isinstance(additional, dict):
|
|
75
|
+
_validate_schema_value(item, additional, root, item_path)
|
|
76
|
+
if isinstance(value, list):
|
|
77
|
+
if rule.get("uniqueItems") and len({json.dumps(item, sort_keys=True) for item in value}) != len(value):
|
|
78
|
+
raise RuntimeError(f"Component contract {path} must contain unique items.")
|
|
79
|
+
items = rule.get("items")
|
|
80
|
+
if isinstance(items, dict):
|
|
81
|
+
for index, item in enumerate(value):
|
|
82
|
+
_validate_schema_value(item, items, root, f"{path}[{index}]")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_component_contract(value: dict[str, Any], schema: dict[str, Any]) -> None:
|
|
86
|
+
_validate_schema_value(value, schema, schema, "$")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def component_contract_semantic_errors(value: dict[str, Any]) -> list[str]:
|
|
90
|
+
errors: list[str] = []
|
|
91
|
+
release = value["release"]
|
|
92
|
+
release_version = str(release["version"])
|
|
93
|
+
if release["tag"] != f"v{release_version}":
|
|
94
|
+
errors.append("release tag must equal v plus the release version")
|
|
95
|
+
for component_id, selected in value["components"].items():
|
|
96
|
+
version = str(selected["version"])
|
|
97
|
+
component_release = str(selected["release"])
|
|
98
|
+
reference = str(selected["reference"])
|
|
99
|
+
repository = str(selected["repository"])
|
|
100
|
+
image_repository = str(selected.get("image_repository") or "")
|
|
101
|
+
image_repository_valid = re.fullmatch(
|
|
102
|
+
r"ghcr\.io/dosquartsdedocs/[a-z0-9._-]+",
|
|
103
|
+
image_repository,
|
|
104
|
+
) is not None
|
|
105
|
+
digest_pinned = bool(image_repository_valid and re.fullmatch(
|
|
106
|
+
rf"{re.escape(image_repository)}@sha256:[0-9a-f]{{64}}",
|
|
107
|
+
reference,
|
|
108
|
+
))
|
|
109
|
+
if component_release != f"v{version}":
|
|
110
|
+
errors.append(f"{component_id} release must equal v plus its version")
|
|
111
|
+
kind = selected["kind"]
|
|
112
|
+
if component_id == "mcp" and selected["release_status"] == "released":
|
|
113
|
+
errors.append("mcp release status must remain ready because its publication digest is recorded externally")
|
|
114
|
+
if kind == "container" and not image_repository_valid:
|
|
115
|
+
errors.append(f"{component_id} container must declare a valid image repository")
|
|
116
|
+
elif kind != "container" and image_repository:
|
|
117
|
+
errors.append(f"{component_id} non-container must not declare an image repository")
|
|
118
|
+
if kind == "companion" and selected["release_status"] == "ready":
|
|
119
|
+
errors.append(f"{component_id} companion must be released before coordinated publication")
|
|
120
|
+
if kind == "gem" and reference != f"{selected['name']} (= {version})":
|
|
121
|
+
errors.append(f"{component_id} gem reference does not match its version")
|
|
122
|
+
elif kind == "python-wheel" and reference != f"{selected['name']}=={version}":
|
|
123
|
+
errors.append(f"{component_id} wheel reference does not match its version")
|
|
124
|
+
elif kind == "container" and not (
|
|
125
|
+
reference == f"{image_repository}:{version}"
|
|
126
|
+
or digest_pinned
|
|
127
|
+
):
|
|
128
|
+
errors.append(f"{component_id} container reference does not match its version")
|
|
129
|
+
elif kind == "container" and component_id != "mcp" and selected["release_status"] == "released" and not digest_pinned:
|
|
130
|
+
errors.append(f"{component_id} released container reference must use an immutable digest")
|
|
131
|
+
elif kind == "companion":
|
|
132
|
+
wheel_reference = re.fullmatch(
|
|
133
|
+
rf"{re.escape(repository)}/releases/download/{re.escape(component_release)}/"
|
|
134
|
+
rf"{re.escape(str(selected['name']))}-{re.escape(version)}-"
|
|
135
|
+
r"[A-Za-z0-9_.]+-[A-Za-z0-9_]+-[A-Za-z0-9_.]+\.whl#sha256=([0-9a-f]{64})",
|
|
136
|
+
reference,
|
|
137
|
+
)
|
|
138
|
+
if reference != f"{repository}.git@{component_release}" and not (
|
|
139
|
+
wheel_reference and wheel_reference.group(1) != "0" * 64
|
|
140
|
+
):
|
|
141
|
+
errors.append(f"{component_id} companion reference does not match its repository and release")
|
|
142
|
+
included = {name for name, item in value["components"].items() if item["included_in_wheel"]}
|
|
143
|
+
if included != {"wheel"}:
|
|
144
|
+
errors.append("only the wheel component may be included in the wheel")
|
|
145
|
+
expected_not_bundled = set(value["components"]) - {"wheel"}
|
|
146
|
+
if set(value["wheel_contract"]["not_bundled"]) != expected_not_bundled:
|
|
147
|
+
errors.append("wheel_contract.not_bundled must list every external component exactly")
|
|
148
|
+
integration = value["consumer_integration"]
|
|
149
|
+
expected_core_repository = f"{value['components']['gem']['repository']}.git"
|
|
150
|
+
if integration["core_repository"] != expected_core_repository:
|
|
151
|
+
errors.append("consumer integration core repository must match the gem provider")
|
|
152
|
+
core_sha = str(integration["core_sha"])
|
|
153
|
+
if re.fullmatch(r"[0-9a-f]{40}", core_sha) is None or core_sha == "0" * 40:
|
|
154
|
+
errors.append("consumer integration core SHA must be a nonzero full lowercase commit SHA")
|
|
155
|
+
expected_workflow = f"{expected_core_repository.removeprefix('https://github.com/').removesuffix('.git')}/.github/workflows/site-deploy.yml"
|
|
156
|
+
if integration["site_deploy_workflow"] != expected_workflow:
|
|
157
|
+
errors.append("consumer integration deploy workflow must belong to the core provider")
|
|
158
|
+
expected_pdf_prefix = f"{value['components']['manual_pdf']['image_repository']}@sha256:"
|
|
159
|
+
manual_pdf_image = str(integration["manual_pdf_image"])
|
|
160
|
+
if re.fullmatch(rf"{re.escape(expected_pdf_prefix)}[0-9a-f]{{64}}", manual_pdf_image) is None or manual_pdf_image.endswith("0" * 64):
|
|
161
|
+
errors.append("consumer integration manual PDF image must match the manual PDF provider")
|
|
162
|
+
vegavisuals_sha = str(integration["vegavisuals_sha"])
|
|
163
|
+
if re.fullmatch(r"[0-9a-f]{40}", vegavisuals_sha) is None or vegavisuals_sha == "0" * 40:
|
|
164
|
+
errors.append("consumer integration Vega revision must be a nonzero full lowercase commit SHA")
|
|
165
|
+
return errors
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@lru_cache(maxsize=1)
|
|
169
|
+
def _contract() -> dict[str, Any]:
|
|
170
|
+
path = Path(__file__).resolve().with_name(CONTRACT_NAME)
|
|
171
|
+
schema_path = path.with_name(CONTRACT_SCHEMA_NAME)
|
|
172
|
+
try:
|
|
173
|
+
value = _strict_json(path)
|
|
174
|
+
schema = _strict_json(schema_path)
|
|
175
|
+
if not isinstance(value, dict) or not isinstance(schema, dict):
|
|
176
|
+
raise RuntimeError("Component contract and schema roots must be JSON objects.")
|
|
177
|
+
validate_component_contract(value, schema)
|
|
178
|
+
semantic_errors = component_contract_semantic_errors(value)
|
|
179
|
+
if semantic_errors:
|
|
180
|
+
raise RuntimeError("; ".join(semantic_errors))
|
|
181
|
+
except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError, KeyError, RuntimeError) as exc:
|
|
182
|
+
raise RuntimeError(f"Unsupported or invalid package component contract: {path}: {exc}") from exc
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def distribution_contract() -> dict[str, Any]:
|
|
187
|
+
"""Return an isolated copy of the package-owned component BOM."""
|
|
188
|
+
return json.loads(json.dumps(_contract()))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def distribution_version() -> str:
|
|
192
|
+
return str(_contract()["release"]["version"])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def consumer_integration() -> dict[str, Any]:
|
|
196
|
+
"""Return an isolated copy of the reviewed consumer runtime tuple."""
|
|
197
|
+
return dict(_contract()["consumer_integration"])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def component(component_id: str) -> dict[str, Any]:
|
|
201
|
+
try:
|
|
202
|
+
value = _contract()["components"][component_id]
|
|
203
|
+
except KeyError as exc:
|
|
204
|
+
raise KeyError(f"Unknown unaltraweb component: {component_id}") from exc
|
|
205
|
+
return dict(value)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def component_reference(component_id: str) -> str:
|
|
209
|
+
return str(component(component_id)["reference"])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def companion_dependency_requirements(component_id: str) -> dict[str, Any]:
|
|
213
|
+
capabilities = {
|
|
214
|
+
"diavisuals": {
|
|
215
|
+
"tools": [
|
|
216
|
+
"compatibility_status",
|
|
217
|
+
"release_status",
|
|
218
|
+
"factory_manifest",
|
|
219
|
+
"project_check",
|
|
220
|
+
"render_diagram",
|
|
221
|
+
"render_diagram_text",
|
|
222
|
+
],
|
|
223
|
+
"resources": ["diavisuals://project/check", "diavisuals://factory-manifest"],
|
|
224
|
+
},
|
|
225
|
+
"vegavisuals": {
|
|
226
|
+
"tools": [
|
|
227
|
+
"initialize_project",
|
|
228
|
+
"validate_visualization",
|
|
229
|
+
"render_visualization",
|
|
230
|
+
"visualization_status",
|
|
231
|
+
"visualization_check",
|
|
232
|
+
"render_visualizations",
|
|
233
|
+
"compatibility_status",
|
|
234
|
+
"release_status",
|
|
235
|
+
"factory_manifest",
|
|
236
|
+
],
|
|
237
|
+
"resources": ["vegavisuals://project/check", "vegavisuals://factory-manifest"],
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
if component_id not in capabilities:
|
|
241
|
+
raise KeyError(f"Unknown unaltraweb companion: {component_id}")
|
|
242
|
+
selected = component(component_id)
|
|
243
|
+
reference = str(selected["reference"])
|
|
244
|
+
install_reference = f"git+{reference}" if ".git@" in reference else reference
|
|
245
|
+
return {
|
|
246
|
+
"lifecycle": {
|
|
247
|
+
"required": True,
|
|
248
|
+
"install": True,
|
|
249
|
+
"build": True,
|
|
250
|
+
"init": False,
|
|
251
|
+
"check": True,
|
|
252
|
+
"smoke": True,
|
|
253
|
+
"update": False,
|
|
254
|
+
},
|
|
255
|
+
"uv_spec": f"{component_id}[mcp] @ {install_reference}",
|
|
256
|
+
**capabilities[component_id],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_mutable_reference(reference: str) -> bool:
|
|
261
|
+
value = reference.strip().lower()
|
|
262
|
+
if not value:
|
|
263
|
+
return True
|
|
264
|
+
if re.search(r"@sha256:[0-9a-f]{64}$", value):
|
|
265
|
+
return False
|
|
266
|
+
if re.fullmatch(r"https://[^\s#]+\.whl#sha256=[0-9a-f]{64}", value):
|
|
267
|
+
return False
|
|
268
|
+
if value.startswith("http") and ".git@" in value:
|
|
269
|
+
selected = value.rsplit("@", 1)[-1]
|
|
270
|
+
return selected in MUTABLE_TAGS or selected.startswith("refs/heads/")
|
|
271
|
+
if "@" in value:
|
|
272
|
+
return True
|
|
273
|
+
image_name = value.rsplit("/", 1)[-1]
|
|
274
|
+
if ":" not in image_name:
|
|
275
|
+
return True
|
|
276
|
+
tag = image_name.rsplit(":", 1)[-1]
|
|
277
|
+
return not tag or tag in MUTABLE_TAGS
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _finding(
|
|
281
|
+
code: str,
|
|
282
|
+
severity: str,
|
|
283
|
+
expected: Any,
|
|
284
|
+
actual: Any,
|
|
285
|
+
remediation: str,
|
|
286
|
+
*,
|
|
287
|
+
component_id: str = "",
|
|
288
|
+
) -> dict[str, Any]:
|
|
289
|
+
finding = {
|
|
290
|
+
"code": code,
|
|
291
|
+
"severity": severity,
|
|
292
|
+
"expected": expected,
|
|
293
|
+
"actual": actual,
|
|
294
|
+
"remediation": remediation,
|
|
295
|
+
}
|
|
296
|
+
if component_id:
|
|
297
|
+
finding["component"] = component_id
|
|
298
|
+
return finding
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
302
|
+
from .site_tools import load_yaml_file
|
|
303
|
+
|
|
304
|
+
return load_yaml_file(path)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _make_value(path: Path, variable: str) -> str:
|
|
308
|
+
if not path.is_file():
|
|
309
|
+
return ""
|
|
310
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
311
|
+
match = re.search(rf"(?m)^{re.escape(variable)}\s*\?[:+]?=\s*([^\s#]+)", text)
|
|
312
|
+
return match.group(1).strip('"\'') if match else ""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _gemfile_declaration(text: str, name: str) -> str:
|
|
316
|
+
lines = text.splitlines()
|
|
317
|
+
declaration = re.compile(rf"^(?P<indent>[ \t]*)gem\s+['\"]{re.escape(name)}['\"](?:\s*,|\s*$)")
|
|
318
|
+
for index, line in enumerate(lines):
|
|
319
|
+
match = declaration.match(line)
|
|
320
|
+
if match is None:
|
|
321
|
+
continue
|
|
322
|
+
base_indent = len(match.group("indent").expandtabs())
|
|
323
|
+
stanza = [line]
|
|
324
|
+
for continuation in lines[index + 1 :]:
|
|
325
|
+
if not continuation.strip():
|
|
326
|
+
break
|
|
327
|
+
continuation_prefix = continuation[: len(continuation) - len(continuation.lstrip(" \t"))]
|
|
328
|
+
continuation_indent = len(continuation_prefix.expandtabs())
|
|
329
|
+
if continuation_indent <= base_indent:
|
|
330
|
+
break
|
|
331
|
+
stanza.append(continuation)
|
|
332
|
+
return "\n".join(stanza)
|
|
333
|
+
return ""
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _ruby_without_comments(text: str) -> str:
|
|
337
|
+
stripped: list[str] = []
|
|
338
|
+
for line in text.splitlines():
|
|
339
|
+
output: list[str] = []
|
|
340
|
+
quote = ""
|
|
341
|
+
escaped = False
|
|
342
|
+
for character in line:
|
|
343
|
+
if escaped:
|
|
344
|
+
output.append(character)
|
|
345
|
+
escaped = False
|
|
346
|
+
continue
|
|
347
|
+
if quote and character == "\\":
|
|
348
|
+
output.append(character)
|
|
349
|
+
escaped = True
|
|
350
|
+
continue
|
|
351
|
+
if quote:
|
|
352
|
+
output.append(character)
|
|
353
|
+
if character == quote:
|
|
354
|
+
quote = ""
|
|
355
|
+
continue
|
|
356
|
+
if character in {'"', "'"}:
|
|
357
|
+
quote = character
|
|
358
|
+
output.append(character)
|
|
359
|
+
elif character == "#":
|
|
360
|
+
break
|
|
361
|
+
else:
|
|
362
|
+
output.append(character)
|
|
363
|
+
stripped.append("".join(output))
|
|
364
|
+
return "\n".join(stripped)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _bundler_git_pin(text: str, name: str) -> tuple[str, str, str, str]:
|
|
368
|
+
for match in re.finditer(r"(?ms)^GIT[ \t]*\n(?P<body>.*?)(?=^[A-Z][A-Z ]*[ \t]*$|\Z)", text):
|
|
369
|
+
body = match.group("body")
|
|
370
|
+
version_match = re.search(rf"(?m)^\s{{4}}{re.escape(name)} \(([^)]+)\)\s*$", body)
|
|
371
|
+
if version_match is None:
|
|
372
|
+
continue
|
|
373
|
+
remote_match = re.search(r"(?m)^\s{2}remote:\s*(\S+)\s*$", body)
|
|
374
|
+
revision_match = re.search(r"(?m)^\s{2}revision:\s*([0-9a-f]+)\s*$", body)
|
|
375
|
+
ref_match = re.search(r"(?m)^\s{2}ref:\s*(\S+)\s*$", body)
|
|
376
|
+
return (
|
|
377
|
+
remote_match.group(1) if remote_match else "",
|
|
378
|
+
revision_match.group(1) if revision_match else "",
|
|
379
|
+
ref_match.group(1) if ref_match else "",
|
|
380
|
+
version_match.group(1),
|
|
381
|
+
)
|
|
382
|
+
return "", "", "", ""
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _factory_findings(factory: Path) -> list[dict[str, Any]]:
|
|
386
|
+
findings: list[dict[str, Any]] = []
|
|
387
|
+
contract = _contract()
|
|
388
|
+
expected_version = distribution_version()
|
|
389
|
+
manifest = _load_yaml(factory / "mcp-factory.yml")
|
|
390
|
+
actual_version = str(manifest.get("version") or "")
|
|
391
|
+
findings.append(
|
|
392
|
+
_finding(
|
|
393
|
+
"UW-DIST-FACTORY-VERSION",
|
|
394
|
+
"info" if actual_version == expected_version else "error",
|
|
395
|
+
expected_version,
|
|
396
|
+
actual_version or "unreadable",
|
|
397
|
+
"No action required." if actual_version == expected_version else "Use a factory checkout from the wheel's selected release.",
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
runtime = manifest.get("runtime") if isinstance(manifest.get("runtime"), dict) else {}
|
|
402
|
+
actual_mcp = str(runtime.get("image") or "")
|
|
403
|
+
expected_mcp = component_reference("mcp")
|
|
404
|
+
findings.append(
|
|
405
|
+
_finding(
|
|
406
|
+
"UW-DIST-FACTORY-MCP-PIN",
|
|
407
|
+
"info" if actual_mcp == expected_mcp else "error",
|
|
408
|
+
expected_mcp,
|
|
409
|
+
actual_mcp or "missing",
|
|
410
|
+
"No action required." if actual_mcp == expected_mcp else "Align runtime.image with the package component contract.",
|
|
411
|
+
component_id="mcp",
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
dependencies = manifest.get("mcp_dependencies") if isinstance(manifest.get("mcp_dependencies"), list) else []
|
|
416
|
+
dependency_map = {str(item.get("name")): item for item in dependencies if isinstance(item, dict)}
|
|
417
|
+
receipt_tools = {"diavisuals": "project_check", "vegavisuals": "visualization_check"}
|
|
418
|
+
for component_id in ["diavisuals", "vegavisuals"]:
|
|
419
|
+
expected = contract["components"][component_id]
|
|
420
|
+
dependency = dependency_map.get(component_id, {})
|
|
421
|
+
requirements = companion_dependency_requirements(component_id)
|
|
422
|
+
actual = {
|
|
423
|
+
"version": str(dependency.get("version") or ""),
|
|
424
|
+
"release": str(dependency.get("release") or ""),
|
|
425
|
+
"release_status": str(dependency.get("release_status") or ""),
|
|
426
|
+
"remote": str(dependency.get("remote") or ""),
|
|
427
|
+
}
|
|
428
|
+
selected = {
|
|
429
|
+
"version": expected["version"],
|
|
430
|
+
"release": expected["release"],
|
|
431
|
+
"release_status": expected["release_status"],
|
|
432
|
+
"remote": expected["repository"] + ".git",
|
|
433
|
+
}
|
|
434
|
+
findings.append(
|
|
435
|
+
_finding(
|
|
436
|
+
"UW-DIST-COMPANION-PIN",
|
|
437
|
+
"info" if actual == selected else "error",
|
|
438
|
+
selected,
|
|
439
|
+
actual,
|
|
440
|
+
"No action required." if actual == selected else f"Pin {component_id} to the selected HTTPS release metadata.",
|
|
441
|
+
component_id=component_id,
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
expected_lifecycle = requirements["lifecycle"]
|
|
445
|
+
actual_lifecycle = {key: dependency.get(key) for key in expected_lifecycle}
|
|
446
|
+
findings.append(
|
|
447
|
+
_finding(
|
|
448
|
+
"UW-DIST-COMPANION-LIFECYCLE",
|
|
449
|
+
"info" if actual_lifecycle == expected_lifecycle else "error",
|
|
450
|
+
expected_lifecycle,
|
|
451
|
+
actual_lifecycle,
|
|
452
|
+
(
|
|
453
|
+
"No action required."
|
|
454
|
+
if actual_lifecycle == expected_lifecycle
|
|
455
|
+
else f"Align {component_id} dependency lifecycle with the required gContExt contract."
|
|
456
|
+
),
|
|
457
|
+
component_id=component_id,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
actual_uv_spec = str(dependency.get("uv_spec") or "")
|
|
461
|
+
findings.append(
|
|
462
|
+
_finding(
|
|
463
|
+
"UW-DIST-COMPANION-INSTALL-SPEC",
|
|
464
|
+
"info" if actual_uv_spec == requirements["uv_spec"] else "error",
|
|
465
|
+
requirements["uv_spec"],
|
|
466
|
+
actual_uv_spec or "missing",
|
|
467
|
+
(
|
|
468
|
+
"No action required."
|
|
469
|
+
if actual_uv_spec == requirements["uv_spec"]
|
|
470
|
+
else f"Use the selected immutable {component_id} release in uv_spec."
|
|
471
|
+
),
|
|
472
|
+
component_id=component_id,
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
declared_tools = dependency.get("required_tools") if isinstance(dependency.get("required_tools"), list) else []
|
|
476
|
+
declared_resources = dependency.get("required_resources") if isinstance(dependency.get("required_resources"), list) else []
|
|
477
|
+
missing_capabilities = sorted(
|
|
478
|
+
(set(requirements["tools"]) - set(declared_tools))
|
|
479
|
+
| (set(requirements["resources"]) - set(declared_resources))
|
|
480
|
+
)
|
|
481
|
+
findings.append(
|
|
482
|
+
_finding(
|
|
483
|
+
"UW-DIST-COMPANION-CAPABILITIES",
|
|
484
|
+
"info" if not missing_capabilities else "error",
|
|
485
|
+
{"tools": requirements["tools"], "resources": requirements["resources"]},
|
|
486
|
+
{"tools": declared_tools, "resources": declared_resources},
|
|
487
|
+
(
|
|
488
|
+
"No action required."
|
|
489
|
+
if not missing_capabilities
|
|
490
|
+
else f"Require the missing {component_id} tools and resources: {', '.join(missing_capabilities)}."
|
|
491
|
+
),
|
|
492
|
+
component_id=component_id,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
receipt_tool = receipt_tools[component_id]
|
|
496
|
+
findings.append(
|
|
497
|
+
_finding(
|
|
498
|
+
"UW-DIST-COMPANION-RECEIPT-TOOL",
|
|
499
|
+
"info" if receipt_tool in declared_tools else "error",
|
|
500
|
+
receipt_tool,
|
|
501
|
+
declared_tools,
|
|
502
|
+
"No action required." if receipt_tool in declared_tools else f"Require {receipt_tool} from {component_id} before enforcing its provider receipt.",
|
|
503
|
+
component_id=component_id,
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
makefile = factory / "Makefile"
|
|
508
|
+
make_pins = {
|
|
509
|
+
"MCP_RUNTIME_IMAGE": "runtime",
|
|
510
|
+
"MCP_IMAGE": "mcp",
|
|
511
|
+
"WEB_CAPTURE_IMAGE": "web_capture",
|
|
512
|
+
"DOCKER_IMAGE": "runtime",
|
|
513
|
+
"MANUAL_PDF_IMAGE": "manual_pdf",
|
|
514
|
+
}
|
|
515
|
+
for variable, component_id in make_pins.items():
|
|
516
|
+
expected = component_reference(component_id)
|
|
517
|
+
actual = _make_value(makefile, variable)
|
|
518
|
+
findings.append(
|
|
519
|
+
_finding(
|
|
520
|
+
"UW-DIST-FACTORY-COMPONENT-PIN",
|
|
521
|
+
"info" if actual == expected else "error",
|
|
522
|
+
expected,
|
|
523
|
+
actual or f"{variable} missing",
|
|
524
|
+
"No action required." if actual == expected else f"Set {variable} to the selected component reference.",
|
|
525
|
+
component_id=component_id,
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
return findings
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _nested(mapping: dict[str, Any], *keys: str) -> Any:
|
|
532
|
+
value: Any = mapping
|
|
533
|
+
for key in keys:
|
|
534
|
+
if not isinstance(value, dict):
|
|
535
|
+
return None
|
|
536
|
+
value = value.get(key)
|
|
537
|
+
return value
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _project_image_references(config: dict[str, Any]) -> list[tuple[str, str]]:
|
|
541
|
+
references: list[tuple[str, str]] = []
|
|
542
|
+
engines = config.get("engines") if isinstance(config.get("engines"), dict) else {}
|
|
543
|
+
for engine in ["python", "r"]:
|
|
544
|
+
settings = engines.get(engine) if isinstance(engines.get(engine), dict) else {}
|
|
545
|
+
for key in ["image", "base_image"]:
|
|
546
|
+
value = str(settings.get(key) or "").strip()
|
|
547
|
+
if value:
|
|
548
|
+
references.append((f"{engine}.{key}", value))
|
|
549
|
+
environments = settings.get("environments") if isinstance(settings.get("environments"), dict) else {}
|
|
550
|
+
for name, value in environments.items():
|
|
551
|
+
if str(value).strip():
|
|
552
|
+
references.append((f"{engine}.environments.{name}", str(value).strip()))
|
|
553
|
+
return references
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _has_files(project: Path, patterns: list[str], roots: list[str]) -> bool:
|
|
557
|
+
for root_name in roots:
|
|
558
|
+
root = project / root_name
|
|
559
|
+
if not root.is_dir():
|
|
560
|
+
continue
|
|
561
|
+
for pattern in patterns:
|
|
562
|
+
if next(root.rglob(pattern), None) is not None:
|
|
563
|
+
return True
|
|
564
|
+
return False
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _project_findings(project: Path) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, str], list[str]]:
|
|
568
|
+
findings: list[dict[str, Any]] = []
|
|
569
|
+
images: dict[str, str] = {}
|
|
570
|
+
selected_components = ["wheel"]
|
|
571
|
+
if not project.exists() or not project.is_dir():
|
|
572
|
+
findings.append(_finding("UW-DIST-PROJECT", "error", "existing project directory", str(project), "Pass an existing consumer project directory."))
|
|
573
|
+
return findings, {"supplied": True, "path": str(project), "is_unaltraweb_site": False}, images, selected_components
|
|
574
|
+
|
|
575
|
+
from . import site_tools
|
|
576
|
+
|
|
577
|
+
detection = site_tools.detect_site(project)
|
|
578
|
+
config = site_tools.site_config(project)
|
|
579
|
+
is_site = bool(detection["is_unaltraweb_site"])
|
|
580
|
+
profile = site_tools.site_profile(config)
|
|
581
|
+
project_state: dict[str, Any] = {
|
|
582
|
+
"supplied": True,
|
|
583
|
+
"path": str(project),
|
|
584
|
+
"is_unaltraweb_site": is_site,
|
|
585
|
+
"profile": profile,
|
|
586
|
+
}
|
|
587
|
+
findings.append(
|
|
588
|
+
_finding(
|
|
589
|
+
"UW-DIST-PROJECT-CONFIG",
|
|
590
|
+
"info" if is_site else "warning",
|
|
591
|
+
"unaltraweb consumer markers in _config.yml and Gemfile",
|
|
592
|
+
"unaltraweb consumer site" if is_site else "no complete consumer markers",
|
|
593
|
+
"No action required." if is_site else "Pass an unaltraweb site or omit --project for distribution-only checks.",
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
if not is_site:
|
|
597
|
+
return findings, project_state, images, selected_components
|
|
598
|
+
|
|
599
|
+
selected_components.extend(["gem", "mcp"])
|
|
600
|
+
gem_version = component("gem")["version"]
|
|
601
|
+
integration = consumer_integration()
|
|
602
|
+
gemfile = (project / "Gemfile").read_text(encoding="utf-8", errors="ignore") if (project / "Gemfile").is_file() else ""
|
|
603
|
+
gem_declaration = _ruby_without_comments(_gemfile_declaration(gemfile, "unaltraweb"))
|
|
604
|
+
exact_version = bool(re.search(rf"(?m)^\s*gem\s+['\"]unaltraweb['\"]\s*,\s*['\"](?:=\s*)?{re.escape(gem_version)}['\"]", gem_declaration))
|
|
605
|
+
gem_repository_matches = re.findall(r"\bgit:\s*['\"]([^'\"]+)['\"]", gem_declaration)
|
|
606
|
+
gem_ref_matches = re.findall(r"\bref:\s*['\"]([^'\"]+)['\"]", gem_declaration)
|
|
607
|
+
gem_repository = gem_repository_matches[0] if len(gem_repository_matches) == 1 else ""
|
|
608
|
+
gem_ref = gem_ref_matches[0] if len(gem_ref_matches) == 1 else ""
|
|
609
|
+
lock_text = (project / "Gemfile.lock").read_text(encoding="utf-8", errors="ignore") if (project / "Gemfile.lock").is_file() else ""
|
|
610
|
+
lock_match = re.search(r"(?m)^\s{2}unaltraweb \(= ([^)]+)\)!?\s*$", lock_text)
|
|
611
|
+
locked_gem = lock_match.group(1) if lock_match else ""
|
|
612
|
+
lock_repository, lock_revision, lock_ref, lock_spec_version = _bundler_git_pin(lock_text, "unaltraweb")
|
|
613
|
+
gem_ok = (
|
|
614
|
+
exact_version
|
|
615
|
+
and gem_repository == integration["core_repository"]
|
|
616
|
+
and gem_ref == integration["core_sha"]
|
|
617
|
+
and locked_gem == gem_version
|
|
618
|
+
and lock_repository == integration["core_repository"]
|
|
619
|
+
and lock_revision == integration["core_sha"]
|
|
620
|
+
and lock_ref == integration["core_sha"]
|
|
621
|
+
and lock_spec_version == gem_version
|
|
622
|
+
)
|
|
623
|
+
findings.append(
|
|
624
|
+
_finding(
|
|
625
|
+
"UW-DIST-PROJECT-GEM-PIN",
|
|
626
|
+
"info" if gem_ok else "warning",
|
|
627
|
+
{
|
|
628
|
+
"version": gem_version,
|
|
629
|
+
"repository": integration["core_repository"],
|
|
630
|
+
"revision": integration["core_sha"],
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
"version_exact": exact_version,
|
|
634
|
+
"repository": "ambiguous" if len(gem_repository_matches) > 1 else (gem_repository or "missing"),
|
|
635
|
+
"gemfile_revision": "ambiguous" if len(gem_ref_matches) > 1 else (gem_ref or "missing"),
|
|
636
|
+
"lock_version": locked_gem or "missing",
|
|
637
|
+
"lock_repository": lock_repository or "missing",
|
|
638
|
+
"lock_revision": lock_revision or "missing",
|
|
639
|
+
"lock_ref": lock_ref or "missing",
|
|
640
|
+
"lock_spec_version": lock_spec_version or "missing",
|
|
641
|
+
},
|
|
642
|
+
"No action required." if gem_ok else "Use scaffold_sync to restore the reviewed consumer integration tuple.",
|
|
643
|
+
component_id="gem",
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
makefile = project / "Makefile"
|
|
648
|
+
expected_mcp = component_reference("mcp")
|
|
649
|
+
actual_mcp = _make_value(makefile, "MCP_IMAGE")
|
|
650
|
+
mcp_severity = "info" if actual_mcp == expected_mcp else ("warning" if not actual_mcp else "error")
|
|
651
|
+
findings.append(
|
|
652
|
+
_finding(
|
|
653
|
+
"UW-DIST-PROJECT-MCP-PIN",
|
|
654
|
+
mcp_severity,
|
|
655
|
+
expected_mcp,
|
|
656
|
+
actual_mcp or "MCP_IMAGE not declared",
|
|
657
|
+
"No action required." if actual_mcp == expected_mcp else "Use the selected MCP image, or document an intentional immutable override.",
|
|
658
|
+
component_id="mcp",
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
images["mcp"] = actual_mcp or expected_mcp
|
|
662
|
+
|
|
663
|
+
runtime_image = _make_value(makefile, "DOCKER_IMAGE")
|
|
664
|
+
if runtime_image:
|
|
665
|
+
selected_components.append("runtime")
|
|
666
|
+
images["runtime"] = runtime_image
|
|
667
|
+
|
|
668
|
+
computations_path = project / ".unaltraweb/computations.yml"
|
|
669
|
+
computation_sources = _has_files(project, ["*.qmd", "*.Rmd", "*.rmd", "*.R", "*.r", "*.py", "*.ipynb"], ["_chapters", "assets"])
|
|
670
|
+
computation_enabled = computations_path.is_file() or computation_sources
|
|
671
|
+
capture_enabled = _has_files(project, ["*.capture.yml", "*.capture.yaml"], ["assets", "_chapters"])
|
|
672
|
+
pdf_enabled = _nested(config, "unaltraweb", "manual", "pdf", "enabled") is True
|
|
673
|
+
vega_enabled = (project / ".vegavisuals.yml").is_file()
|
|
674
|
+
diagram_enabled = _has_files(project, ["*.mmd", "*.mermaid", "*.puml", "*.plantuml", "*.uml"], ["assets", "_chapters", "_documentation"])
|
|
675
|
+
features = {
|
|
676
|
+
"manual_computations": computation_enabled,
|
|
677
|
+
"web_captures": capture_enabled,
|
|
678
|
+
"manual_pdf": pdf_enabled,
|
|
679
|
+
"vegavisuals": vega_enabled,
|
|
680
|
+
"diavisuals": diagram_enabled,
|
|
681
|
+
}
|
|
682
|
+
project_state["features"] = features
|
|
683
|
+
|
|
684
|
+
if computation_enabled:
|
|
685
|
+
computation_config = _load_yaml(computations_path)
|
|
686
|
+
configured_engines = computation_config.get("engines") if isinstance(computation_config.get("engines"), dict) else {}
|
|
687
|
+
engine_ids = [engine for engine in ["python", "r"] if engine in configured_engines]
|
|
688
|
+
if not engine_ids:
|
|
689
|
+
engine_ids = ["python", "r"]
|
|
690
|
+
for engine in engine_ids:
|
|
691
|
+
component_id = f"compute_{engine}"
|
|
692
|
+
selected_components.append(component_id)
|
|
693
|
+
settings = configured_engines.get(engine) if isinstance(configured_engines.get(engine), dict) else {}
|
|
694
|
+
images[component_id] = str(settings.get("image") or component_reference(component_id))
|
|
695
|
+
for location, reference in _project_image_references(computation_config):
|
|
696
|
+
if is_mutable_reference(reference):
|
|
697
|
+
findings.append(
|
|
698
|
+
_finding(
|
|
699
|
+
"UW-DIST-PROJECT-MUTABLE-PIN",
|
|
700
|
+
"warning",
|
|
701
|
+
"version tag or digest",
|
|
702
|
+
f"{location}={reference}",
|
|
703
|
+
"Replace mutable worker tags with a release tag or digest.",
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
if capture_enabled:
|
|
708
|
+
selected_components.append("web_capture")
|
|
709
|
+
images["web_capture"] = _make_value(makefile, "WEB_CAPTURE_IMAGE") or component_reference("web_capture")
|
|
710
|
+
if pdf_enabled:
|
|
711
|
+
selected_components.append("manual_pdf")
|
|
712
|
+
images["manual_pdf"] = _make_value(makefile, "MANUAL_PDF_IMAGE") or component_reference("manual_pdf")
|
|
713
|
+
if vega_enabled:
|
|
714
|
+
selected_components.append("vegavisuals")
|
|
715
|
+
if diagram_enabled:
|
|
716
|
+
selected_components.append("diavisuals")
|
|
717
|
+
|
|
718
|
+
for component_id, reference in images.items():
|
|
719
|
+
if is_mutable_reference(reference):
|
|
720
|
+
findings.append(
|
|
721
|
+
_finding(
|
|
722
|
+
"UW-DIST-PROJECT-MUTABLE-PIN",
|
|
723
|
+
"warning",
|
|
724
|
+
"version tag or digest",
|
|
725
|
+
reference,
|
|
726
|
+
"Replace mutable runtime tags with a release tag or digest.",
|
|
727
|
+
component_id=component_id,
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
return findings, project_state, images, list(dict.fromkeys(selected_components))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _docker_findings(images: dict[str, str]) -> list[dict[str, Any]]:
|
|
734
|
+
if not shutil.which("docker"):
|
|
735
|
+
return [_finding("UW-DIST-DOCKER-AVAILABLE", "info", "optional Docker CLI", "not installed", "Install Docker only when container-backed features are needed.")]
|
|
736
|
+
try:
|
|
737
|
+
version = run_process(
|
|
738
|
+
["docker", "version", "--format", "{{.Server.Version}}"],
|
|
739
|
+
timeout_seconds=5,
|
|
740
|
+
)
|
|
741
|
+
except OSError as exc:
|
|
742
|
+
return [_finding("UW-DIST-DOCKER-AVAILABLE", "warning", "reachable optional Docker daemon", str(exc), "Start Docker or rerun doctor without --docker.")]
|
|
743
|
+
if version.returncode != 0:
|
|
744
|
+
return [_finding("UW-DIST-DOCKER-AVAILABLE", "warning", "reachable optional Docker daemon", version.stderr.strip() or "unavailable", "Start Docker or rerun doctor without --docker.")]
|
|
745
|
+
|
|
746
|
+
findings = [_finding("UW-DIST-DOCKER-AVAILABLE", "info", "reachable optional Docker daemon", version.stdout.strip() or "available", "No action required.")]
|
|
747
|
+
for component_id, reference in images.items():
|
|
748
|
+
try:
|
|
749
|
+
inspected = run_process(
|
|
750
|
+
["docker", "image", "inspect", reference, "--format", "{{.Id}}"],
|
|
751
|
+
timeout_seconds=5,
|
|
752
|
+
)
|
|
753
|
+
except OSError as exc:
|
|
754
|
+
findings.append(
|
|
755
|
+
_finding(
|
|
756
|
+
"UW-DIST-IMAGE-PRESENCE",
|
|
757
|
+
"warning",
|
|
758
|
+
f"optional local image {reference}",
|
|
759
|
+
str(exc),
|
|
760
|
+
"Retry local inspection or prepare the image explicitly; doctor never pulls images.",
|
|
761
|
+
component_id=component_id,
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
continue
|
|
765
|
+
present = inspected.returncode == 0
|
|
766
|
+
findings.append(
|
|
767
|
+
_finding(
|
|
768
|
+
"UW-DIST-IMAGE-PRESENCE",
|
|
769
|
+
"info",
|
|
770
|
+
f"optional local image {reference}",
|
|
771
|
+
inspected.stdout.strip() if present else "not present locally",
|
|
772
|
+
"No action required." if present else "Pull or build the image explicitly before using this feature; doctor never pulls images.",
|
|
773
|
+
component_id=component_id,
|
|
774
|
+
)
|
|
775
|
+
)
|
|
776
|
+
return findings
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def distribution_doctor(
|
|
780
|
+
*,
|
|
781
|
+
project: Path | None = None,
|
|
782
|
+
factory: Path | None = None,
|
|
783
|
+
check_docker: bool = False,
|
|
784
|
+
) -> dict[str, Any]:
|
|
785
|
+
contract = distribution_contract()
|
|
786
|
+
pending_components = sorted(
|
|
787
|
+
component_id for component_id, selected in contract["components"].items()
|
|
788
|
+
if selected["release_status"] == "pending"
|
|
789
|
+
)
|
|
790
|
+
unavailable_components = sorted(
|
|
791
|
+
component_id for component_id, selected in contract["components"].items()
|
|
792
|
+
if selected["release_status"] == "unavailable"
|
|
793
|
+
)
|
|
794
|
+
findings = [
|
|
795
|
+
_finding(
|
|
796
|
+
"UW-DIST-COMPONENT-CONTRACT",
|
|
797
|
+
"info",
|
|
798
|
+
1,
|
|
799
|
+
contract["schema_version"],
|
|
800
|
+
"No action required.",
|
|
801
|
+
)
|
|
802
|
+
]
|
|
803
|
+
for component_id in pending_components:
|
|
804
|
+
selected = contract["components"][component_id]
|
|
805
|
+
code = "UW-DIST-COMPANION-RELEASE-PENDING" if selected["kind"] == "companion" else "UW-DIST-RELEASE-PENDING"
|
|
806
|
+
findings.append(_finding(
|
|
807
|
+
code,
|
|
808
|
+
"warning",
|
|
809
|
+
f"published immutable release {selected['release']}",
|
|
810
|
+
"pending",
|
|
811
|
+
f"Use an explicitly selected local {component_id} candidate for development; do not publish the coordinated release until this component is available.",
|
|
812
|
+
component_id=component_id,
|
|
813
|
+
))
|
|
814
|
+
for component_id in unavailable_components:
|
|
815
|
+
selected = contract["components"][component_id]
|
|
816
|
+
code = "UW-DIST-COMPANION-RELEASE-UNAVAILABLE" if selected["kind"] == "companion" else "UW-DIST-RELEASE-UNAVAILABLE"
|
|
817
|
+
findings.append(_finding(
|
|
818
|
+
code,
|
|
819
|
+
"warning",
|
|
820
|
+
f"published immutable release {selected['release']}",
|
|
821
|
+
"unavailable",
|
|
822
|
+
f"Restore availability of the selected {component_id} release before publishing the modular release.",
|
|
823
|
+
component_id=component_id,
|
|
824
|
+
))
|
|
825
|
+
selected_components = ["wheel"]
|
|
826
|
+
images: dict[str, str] = {}
|
|
827
|
+
if factory is None:
|
|
828
|
+
findings.append(
|
|
829
|
+
_finding(
|
|
830
|
+
"UW-DIST-WHEEL-MODE",
|
|
831
|
+
"info",
|
|
832
|
+
"factory checkout optional for package-only commands",
|
|
833
|
+
"limited wheel mode",
|
|
834
|
+
"Set UNALTRAWEB_FACTORY_DIR only for factory-backed MCP, build, computation, capture, PDF, or bibliometrics commands.",
|
|
835
|
+
)
|
|
836
|
+
)
|
|
837
|
+
factory_state = {"available": False, "path": "", "mode": "wheel"}
|
|
838
|
+
else:
|
|
839
|
+
factory = factory.expanduser().resolve()
|
|
840
|
+
factory_state = {"available": (factory / "mcp-factory.yml").is_file(), "path": str(factory), "mode": "factory"}
|
|
841
|
+
if factory_state["available"]:
|
|
842
|
+
findings.extend(_factory_findings(factory))
|
|
843
|
+
if project is None:
|
|
844
|
+
selected_components = list(contract["components"])
|
|
845
|
+
images = {
|
|
846
|
+
component_id: str(value["reference"])
|
|
847
|
+
for component_id, value in contract["components"].items()
|
|
848
|
+
if value["kind"] == "container"
|
|
849
|
+
}
|
|
850
|
+
else:
|
|
851
|
+
findings.append(_finding("UW-DIST-FACTORY", "error", "mcp-factory.yml", str(factory), "Set UNALTRAWEB_FACTORY_DIR to a valid factory checkout."))
|
|
852
|
+
|
|
853
|
+
project_state: dict[str, Any] = {"supplied": False, "path": ""}
|
|
854
|
+
if project is not None:
|
|
855
|
+
project = project.expanduser().resolve()
|
|
856
|
+
project_findings, project_state, project_images, project_components = _project_findings(project)
|
|
857
|
+
findings.extend(project_findings)
|
|
858
|
+
images = project_images
|
|
859
|
+
selected_components = project_components
|
|
860
|
+
|
|
861
|
+
if check_docker:
|
|
862
|
+
findings.extend(_docker_findings(images))
|
|
863
|
+
else:
|
|
864
|
+
findings.append(_finding("UW-DIST-DOCKER-CHECK", "info", "optional local image inspection", "not requested", "Pass --docker to inspect selected local images without pulling."))
|
|
865
|
+
|
|
866
|
+
severities = {severity: sum(1 for item in findings if item["severity"] == severity) for severity in ["error", "warning", "info"]}
|
|
867
|
+
return {
|
|
868
|
+
"schema_version": DOCTOR_SCHEMA_VERSION,
|
|
869
|
+
"ok": severities["error"] == 0,
|
|
870
|
+
"release_ready": not pending_components and not unavailable_components and severities["error"] == 0,
|
|
871
|
+
"pending_releases": pending_components,
|
|
872
|
+
"unavailable_releases": unavailable_components,
|
|
873
|
+
"offline": True,
|
|
874
|
+
"mode": factory_state["mode"],
|
|
875
|
+
"limited": factory_state["mode"] == "wheel",
|
|
876
|
+
"release": contract["release"],
|
|
877
|
+
"consumer_integration": contract["consumer_integration"],
|
|
878
|
+
"receipt_contract": contract["receipt_contract"],
|
|
879
|
+
"wheel_contract": contract["wheel_contract"],
|
|
880
|
+
"components": contract["components"],
|
|
881
|
+
"selected_components": selected_components,
|
|
882
|
+
"factory": factory_state,
|
|
883
|
+
"project": project_state,
|
|
884
|
+
"docker": {"checked": check_docker, "images": images},
|
|
885
|
+
"summary": severities,
|
|
886
|
+
"findings": findings,
|
|
887
|
+
}
|