cox-protocol-package 1.0.2 → 1.2.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.
- package/CHANGELOG.md +22 -0
- package/COX_FILE_FORMAT_SPECIFICATION_v1.0.md +40 -200
- package/FINALIZATION.md +50 -0
- package/LocalAppManager/Dashboard/analytics_sidebar_panels/analytics_sidebar_panels.cox +56 -0
- package/LocalAppManager/Dashboard/analytics_sidebar_panels/analytics_sidebar_panels_screenshot.placeholder.txt +1 -0
- package/LocalAppManager/Dashboard/dashboard.cox +62 -0
- package/LocalAppManager/Dashboard/dashboard_screenshot.placeholder.txt +1 -0
- package/LocalAppManager/Dashboard/left_navigation_sidebar/left_navigation_sidebar.cox +56 -0
- package/LocalAppManager/Dashboard/left_navigation_sidebar/left_navigation_sidebar_screenshot.placeholder.txt +1 -0
- package/LocalAppManager/Dashboard/projects_grid_cards/projects_grid_cards.cox +56 -0
- package/LocalAppManager/Dashboard/projects_grid_cards/projects_grid_cards_screenshot.placeholder.txt +1 -0
- package/LocalAppManager/Dashboard/top_header_stats_bar/top_header_stats_bar.cox +56 -0
- package/LocalAppManager/Dashboard/top_header_stats_bar/top_header_stats_bar_screenshot.placeholder.txt +1 -0
- package/MANIFEST.in +20 -0
- package/README.md +67 -154
- package/bin/cox-protocol.js +23 -97
- package/cox_protocol/__init__.py +2 -0
- package/cox_protocol/__main__.py +3 -0
- package/cox_protocol/compiler.py +273 -0
- package/cox_protocol/cox_cli.py +146 -0
- package/cox_protocol/cox_lib.py +599 -0
- package/cox_protocol/package_target.py +262 -0
- package/cox_protocol/react_target.py +306 -0
- package/cox_protocol/scaffold.py +56 -0
- package/cox_protocol/schemas/cox.component.v1.schema.json +180 -0
- package/cox_protocol/schemas/cox.crop_metadata.v1.schema.json +83 -0
- package/cox_protocol/schemas/cox.manifest.v1.schema.json +172 -0
- package/cox_protocol/schemas/cox.page.v1.schema.json +242 -0
- package/cox_protocol/schemas/cox.protocol.v1.schema.json +38 -0
- package/cox_protocol/starter/README.md +7 -0
- package/cox_protocol/starter/manifest.cox +26 -0
- package/cox_protocol/starter/page/action_button/action_button.cox +34 -0
- package/cox_protocol/starter/page/action_button/reference.svg +1 -0
- package/cox_protocol/starter/page/page.cox +39 -0
- package/cox_protocol/starter/page/reference.svg +1 -0
- package/editor-support/vscode/CHANGELOG.md +6 -0
- package/editor-support/vscode/README.md +2 -1
- package/editor-support/vscode/package.json +3 -3
- package/editor-support/vscode/snippets/cox-common.json +1 -1
- package/editor-support/vscode/snippets/cox-component.json +2 -1
- package/editor-support/vscode/snippets/cox-crop-metadata.json +1 -1
- package/editor-support/vscode/snippets/cox-manifest.json +1 -1
- package/editor-support/vscode/snippets/cox-page.json +2 -1
- package/editor-support/vscode/snippets/cox-protocol.json +1 -1
- package/examples/quickstart/README.md +7 -0
- package/examples/quickstart/manifest.cox +26 -0
- package/examples/quickstart/page/action_button/action_button.cox +34 -0
- package/examples/quickstart/page/action_button/reference.svg +1 -0
- package/examples/quickstart/page/page.cox +39 -0
- package/examples/quickstart/page/reference.svg +1 -0
- package/guides/contract-demo.md +24 -0
- package/guides/npm-installation.md +16 -287
- package/manifest.cox +7 -4
- package/package.json +30 -14
- package/protocols/recursive_decomposition_protocol.cox +8 -8
- package/pyproject.toml +17 -6
- package/schemas/cox.component.v1.schema.json +17 -4
- package/schemas/cox.crop_metadata.v1.schema.json +1 -1
- package/schemas/cox.manifest.v1.schema.json +1 -1
- package/schemas/cox.page.v1.schema.json +17 -4
- package/schemas/cox.protocol.v1.schema.json +1 -1
- package/scripts/check_distribution.py +87 -0
- package/scripts/demo.py +62 -0
- package/scripts/package-vscode.mjs +12 -0
- package/scripts/release_check.py +86 -0
- package/tests/conftest.py +21 -0
- package/tests/test_cli_and_typescript.py +123 -0
- package/tests/test_compiler.py +279 -0
- package/tests/test_validation.py +215 -0
- package/tools/__init__.py +2 -2
- package/tools/cox_cli.py +2 -123
- package/tools/cox_lib.py +2 -558
- package/tools/package_target.py +2 -214
- package/tools/react_target.py +2 -278
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""COX v1 parsing, canonical validation and project reference resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from jsonschema import Draft202012Validator
|
|
14
|
+
|
|
15
|
+
SCHEMA_FILENAMES = {
|
|
16
|
+
"cox.manifest.v1": "cox.manifest.v1.schema.json",
|
|
17
|
+
"cox.page.v1": "cox.page.v1.schema.json",
|
|
18
|
+
"cox.component.v1": "cox.component.v1.schema.json",
|
|
19
|
+
"cox.protocol.v1": "cox.protocol.v1.schema.json",
|
|
20
|
+
"cox.crop_metadata.v1": "cox.crop_metadata.v1.schema.json",
|
|
21
|
+
}
|
|
22
|
+
SUPPORTED_COMPILE_TARGETS = ("json", "react", "package")
|
|
23
|
+
TRANSIENT_DIRS = {
|
|
24
|
+
".git",
|
|
25
|
+
".venv",
|
|
26
|
+
"venv",
|
|
27
|
+
".mypy_cache",
|
|
28
|
+
".pytest_cache",
|
|
29
|
+
".ruff_cache",
|
|
30
|
+
"__pycache__",
|
|
31
|
+
"node_modules",
|
|
32
|
+
"dist",
|
|
33
|
+
"build",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def portable_segment(value: str) -> bool:
|
|
38
|
+
reserved = {"con", "prn", "aux", "nul"} | {
|
|
39
|
+
f"{prefix}{i}" for prefix in ("com", "lpt") for i in range(1, 10)
|
|
40
|
+
}
|
|
41
|
+
return (
|
|
42
|
+
bool(value.strip())
|
|
43
|
+
and not value.endswith((" ", "."))
|
|
44
|
+
and value.split(".")[0].casefold() not in reserved
|
|
45
|
+
and not any(c in value for c in '/\\<>:"|?*')
|
|
46
|
+
and not any(ord(c) < 32 for c in value)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Diagnostic:
|
|
52
|
+
code: str
|
|
53
|
+
message: str
|
|
54
|
+
path: str = ""
|
|
55
|
+
location: str = ""
|
|
56
|
+
|
|
57
|
+
def __str__(self) -> str:
|
|
58
|
+
label = ": ".join(part for part in (self.path, self.location) if part)
|
|
59
|
+
return f"{label}: {self.message}" if label else self.message
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CoxError(RuntimeError):
|
|
63
|
+
def __init__(self, issues: str | list[Diagnostic]):
|
|
64
|
+
self.issues = (
|
|
65
|
+
[Diagnostic("COX_ERROR", issues)] if isinstance(issues, str) else issues
|
|
66
|
+
)
|
|
67
|
+
super().__init__("\n".join(str(issue) for issue in self.issues))
|
|
68
|
+
|
|
69
|
+
def as_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {"ok": False, "errors": [asdict(issue) for issue in self.issues]}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fail(code: str, message: str, path: str = "", location: str = "") -> None:
|
|
74
|
+
raise CoxError([Diagnostic(code, message, path, location)])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ValidationSummary:
|
|
79
|
+
project_root: Path
|
|
80
|
+
manifest_path: Path
|
|
81
|
+
documents: dict[Path, dict[str, Any]]
|
|
82
|
+
referenced_paths: list[Path]
|
|
83
|
+
warnings: list[str]
|
|
84
|
+
build_index: dict[str, Any]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def relative_path(root: Path, path: Path) -> str:
|
|
88
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_report_path(root: Path, path: Path) -> str:
|
|
92
|
+
try:
|
|
93
|
+
return relative_path(root, path)
|
|
94
|
+
except ValueError:
|
|
95
|
+
return path.resolve().as_posix()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_project_root(target: Path) -> tuple[Path, Path]:
|
|
99
|
+
target = target.resolve()
|
|
100
|
+
if not target.exists():
|
|
101
|
+
fail("PROJECT_MISSING", "Project path does not exist", str(target))
|
|
102
|
+
folder = target if target.is_dir() else target.parent
|
|
103
|
+
for candidate in (folder, *folder.parents):
|
|
104
|
+
manifest = candidate / "manifest.cox"
|
|
105
|
+
if manifest.is_file():
|
|
106
|
+
if manifest.is_symlink():
|
|
107
|
+
fail(
|
|
108
|
+
"PATH_SYMLINK", "manifest.cox must be a regular file", str(manifest)
|
|
109
|
+
)
|
|
110
|
+
return candidate, manifest
|
|
111
|
+
fail("MANIFEST_MISSING", "Could not locate manifest.cox", str(target))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CoxLoader(yaml.SafeLoader):
|
|
115
|
+
"""Reject ambiguous keys/aliases rather than silently changing the contract."""
|
|
116
|
+
|
|
117
|
+
def compose_node(self, parent, index):
|
|
118
|
+
if self.check_event(yaml.AliasEvent):
|
|
119
|
+
raise yaml.YAMLError("YAML aliases are not supported; use explicit values")
|
|
120
|
+
return super().compose_node(parent, index)
|
|
121
|
+
|
|
122
|
+
def construct_mapping(self, node, deep=False):
|
|
123
|
+
mapping = {}
|
|
124
|
+
for key_node, value_node in node.value:
|
|
125
|
+
key = self.construct_object(key_node, deep=deep)
|
|
126
|
+
if not isinstance(key, str):
|
|
127
|
+
raise yaml.YAMLError("Mapping keys must be strings")
|
|
128
|
+
if key in mapping:
|
|
129
|
+
raise yaml.YAMLError(
|
|
130
|
+
f"Duplicate mapping key {key!r} at line {key_node.start_mark.line + 1}"
|
|
131
|
+
)
|
|
132
|
+
mapping[key] = self.construct_object(value_node, deep=deep)
|
|
133
|
+
return mapping
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def check_json_value(value: Any) -> None:
|
|
137
|
+
if isinstance(value, dict):
|
|
138
|
+
for key, child in value.items():
|
|
139
|
+
if not isinstance(key, str):
|
|
140
|
+
raise ValueError("Mapping keys must be strings")
|
|
141
|
+
check_json_value(child)
|
|
142
|
+
elif isinstance(value, list):
|
|
143
|
+
for child in value:
|
|
144
|
+
check_json_value(child)
|
|
145
|
+
elif isinstance(value, float) and not math.isfinite(value):
|
|
146
|
+
raise ValueError("Non-finite numbers are not supported")
|
|
147
|
+
elif value is not None and not isinstance(value, (str, int, float, bool)):
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"{type(value).__name__} is not a JSON-compatible value; quote dates and other text"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def read_yaml_document(path: Path) -> dict[str, Any]:
|
|
154
|
+
try:
|
|
155
|
+
content = path.read_text(encoding="utf-8")
|
|
156
|
+
document = yaml.load(content, Loader=CoxLoader)
|
|
157
|
+
if not isinstance(document, dict):
|
|
158
|
+
fail("DOCUMENT_TYPE", "Top-level COX document must be a mapping", str(path))
|
|
159
|
+
check_json_value(document)
|
|
160
|
+
return document
|
|
161
|
+
except UnicodeDecodeError as exc:
|
|
162
|
+
raise CoxError(
|
|
163
|
+
[Diagnostic("UTF8_INVALID", "File is not valid UTF-8", str(path))]
|
|
164
|
+
) from exc
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
raise CoxError([Diagnostic("FILE_UNREADABLE", str(exc), str(path))]) from exc
|
|
167
|
+
except (yaml.YAMLError, ValueError, RecursionError) as exc:
|
|
168
|
+
raise CoxError([Diagnostic("YAML_INVALID", str(exc), str(path))]) from exc
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def read_json_document(path: Path) -> dict[str, Any]:
|
|
172
|
+
try:
|
|
173
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
174
|
+
if not isinstance(value, dict):
|
|
175
|
+
raise ValueError("Expected a JSON object")
|
|
176
|
+
check_json_value(value)
|
|
177
|
+
return value
|
|
178
|
+
except (OSError, ValueError) as exc:
|
|
179
|
+
raise CoxError([Diagnostic("JSON_INVALID", str(exc), str(path))]) from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def load_schema(schema_dir: Path | None, schema_name: str) -> dict[str, Any]:
|
|
183
|
+
"""Canonical schemas are part of the compiler; a project cannot redefine v1."""
|
|
184
|
+
filename = SCHEMA_FILENAMES.get(schema_name)
|
|
185
|
+
if filename is None:
|
|
186
|
+
fail("SCHEMA_UNKNOWN", f"Unknown COX schema id: {schema_name!r}")
|
|
187
|
+
schema = read_json_document(Path(__file__).parent / "schemas" / filename)
|
|
188
|
+
Draft202012Validator.check_schema(schema)
|
|
189
|
+
return schema
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def schema_issues(document: dict[str, Any], path: Path) -> list[Diagnostic]:
|
|
193
|
+
schema_name = document.get("Schema")
|
|
194
|
+
if not isinstance(schema_name, str) or schema_name not in SCHEMA_FILENAMES:
|
|
195
|
+
return [
|
|
196
|
+
Diagnostic(
|
|
197
|
+
"SCHEMA_UNKNOWN",
|
|
198
|
+
f"Unknown or missing Schema: {schema_name!r}",
|
|
199
|
+
str(path),
|
|
200
|
+
"Schema",
|
|
201
|
+
)
|
|
202
|
+
]
|
|
203
|
+
errors = Draft202012Validator(load_schema(None, schema_name)).iter_errors(document)
|
|
204
|
+
return [
|
|
205
|
+
Diagnostic(
|
|
206
|
+
"SCHEMA_INVALID",
|
|
207
|
+
error.message,
|
|
208
|
+
str(path),
|
|
209
|
+
".".join(map(str, error.absolute_path)),
|
|
210
|
+
)
|
|
211
|
+
for error in sorted(
|
|
212
|
+
errors, key=lambda error: tuple(map(str, error.absolute_path))
|
|
213
|
+
)
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def validate_against_schema(document, path, schema_dir=None) -> list[str]:
|
|
218
|
+
return [str(issue) for issue in schema_issues(document, path)]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def safe_project_path(
|
|
222
|
+
root: Path,
|
|
223
|
+
raw: str,
|
|
224
|
+
label: str,
|
|
225
|
+
kind: str | None = None,
|
|
226
|
+
*,
|
|
227
|
+
allow_parent: bool = False,
|
|
228
|
+
) -> Path:
|
|
229
|
+
if (
|
|
230
|
+
not raw
|
|
231
|
+
or "\\" in raw
|
|
232
|
+
or any(c in raw for c in '\x00<>:"|?*')
|
|
233
|
+
or any(ord(c) < 32 for c in raw)
|
|
234
|
+
or raw.startswith("/")
|
|
235
|
+
or (not allow_parent and ".." in raw.split("/"))
|
|
236
|
+
or raw.rstrip("/") in ("", ".")
|
|
237
|
+
):
|
|
238
|
+
fail(
|
|
239
|
+
"PATH_INVALID",
|
|
240
|
+
"Use a non-empty project-relative POSIX path without parent traversal",
|
|
241
|
+
label,
|
|
242
|
+
)
|
|
243
|
+
if any(
|
|
244
|
+
not portable_segment(part)
|
|
245
|
+
for part in raw.split("/")
|
|
246
|
+
if part not in ("", ".", "..")
|
|
247
|
+
):
|
|
248
|
+
fail("PATH_INVALID", "Reference contains a non-portable filename", label)
|
|
249
|
+
path = root / PurePosixPath(raw)
|
|
250
|
+
for ancestor in (path, *path.parents):
|
|
251
|
+
if ancestor == root:
|
|
252
|
+
break
|
|
253
|
+
if ancestor.is_symlink():
|
|
254
|
+
fail(
|
|
255
|
+
"PATH_SYMLINK", "Symlink references are not portable COX inputs", label
|
|
256
|
+
)
|
|
257
|
+
try:
|
|
258
|
+
path.resolve().relative_to(root)
|
|
259
|
+
except ValueError:
|
|
260
|
+
fail("PATH_OUTSIDE_PROJECT", "Reference leaves the project root", label)
|
|
261
|
+
if not path.exists():
|
|
262
|
+
fail("REFERENCE_MISSING", f"Referenced path does not exist: {raw}", label)
|
|
263
|
+
if kind == "file" and not path.is_file():
|
|
264
|
+
fail("REFERENCE_TYPE", f"Expected a file: {raw}", label)
|
|
265
|
+
if kind == "directory" and not path.is_dir():
|
|
266
|
+
fail("REFERENCE_TYPE", f"Expected a directory: {raw}", label)
|
|
267
|
+
return path.resolve()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def unique_paths(paths: list[Path]) -> list[Path]:
|
|
271
|
+
return sorted(set(path.resolve() for path in paths))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def walk_files(root: Path, skip_nested_projects: bool = False):
|
|
275
|
+
"""Stable traversal, without caches, symlinks, or nested independent projects."""
|
|
276
|
+
for folder, dirs, files in os.walk(root, followlinks=False):
|
|
277
|
+
base = Path(folder)
|
|
278
|
+
dirs[:] = sorted(
|
|
279
|
+
d
|
|
280
|
+
for d in dirs
|
|
281
|
+
if d not in TRANSIENT_DIRS
|
|
282
|
+
and not d.endswith(".egg-info")
|
|
283
|
+
and not (base / d).is_symlink()
|
|
284
|
+
and not (skip_nested_projects and (base / d / "manifest.cox").is_file())
|
|
285
|
+
)
|
|
286
|
+
if base != root and (base / ".cox-build.json").exists():
|
|
287
|
+
dirs[:] = []
|
|
288
|
+
continue
|
|
289
|
+
for name in sorted(files):
|
|
290
|
+
yield base / name
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def build_manifest_index(
|
|
294
|
+
manifest: dict[str, Any], project_root: Path
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
index: dict[str, Any] = {}
|
|
297
|
+
refs, docs = [], [project_root / "manifest.cox"]
|
|
298
|
+
for key, field in [
|
|
299
|
+
("reference_docs", "ReferenceDocs"),
|
|
300
|
+
("guide_docs", "GuideDocs"),
|
|
301
|
+
("protocol_docs", "ProtocolDocs"),
|
|
302
|
+
("skill_bundles", "SkillBundles"),
|
|
303
|
+
("schema_files", "Schemas"),
|
|
304
|
+
("tooling_files", "ToolingFiles"),
|
|
305
|
+
]:
|
|
306
|
+
paths = [
|
|
307
|
+
safe_project_path(project_root, raw, f"manifest.cox: {field}[{i}]")
|
|
308
|
+
for i, raw in enumerate(manifest.get(field, []))
|
|
309
|
+
]
|
|
310
|
+
index[key] = [relative_path(project_root, path) for path in paths]
|
|
311
|
+
refs.extend(paths)
|
|
312
|
+
if field in ("ProtocolDocs", "GuideDocs", "SkillBundles"):
|
|
313
|
+
for path in paths:
|
|
314
|
+
candidates = walk_files(path) if path.is_dir() else [path]
|
|
315
|
+
docs.extend(
|
|
316
|
+
safe_project_path(
|
|
317
|
+
project_root,
|
|
318
|
+
p.relative_to(project_root).as_posix(),
|
|
319
|
+
field,
|
|
320
|
+
"file",
|
|
321
|
+
)
|
|
322
|
+
for p in candidates
|
|
323
|
+
if p.suffix == ".cox"
|
|
324
|
+
)
|
|
325
|
+
if field == "ProtocolDocs" and any(
|
|
326
|
+
p.suffix != ".cox" or not p.is_file() for p in paths
|
|
327
|
+
):
|
|
328
|
+
fail(
|
|
329
|
+
"REFERENCE_TYPE",
|
|
330
|
+
"ProtocolDocs entries must be .cox files",
|
|
331
|
+
"manifest.cox",
|
|
332
|
+
)
|
|
333
|
+
index["pages"] = []
|
|
334
|
+
for i, page in enumerate(manifest["Pages"]):
|
|
335
|
+
label = f"manifest.cox: Pages[{i}]"
|
|
336
|
+
folder = safe_project_path(
|
|
337
|
+
project_root, page["Folder"], label + ".Folder", "directory"
|
|
338
|
+
)
|
|
339
|
+
instruction = safe_project_path(
|
|
340
|
+
project_root, page["PageInstruction"], label + ".PageInstruction", "file"
|
|
341
|
+
)
|
|
342
|
+
shot = safe_project_path(
|
|
343
|
+
project_root,
|
|
344
|
+
page["PageScreenshotPlaceholder"],
|
|
345
|
+
label + ".PageScreenshotPlaceholder",
|
|
346
|
+
"file",
|
|
347
|
+
)
|
|
348
|
+
if not instruction.is_relative_to(folder) or not shot.is_relative_to(folder):
|
|
349
|
+
fail(
|
|
350
|
+
"BINDING_FOLDER",
|
|
351
|
+
"Page instruction and evidence must be within its Folder",
|
|
352
|
+
label,
|
|
353
|
+
)
|
|
354
|
+
refs.extend([instruction, shot])
|
|
355
|
+
docs.append(instruction)
|
|
356
|
+
entry = {
|
|
357
|
+
"pageName": page["PageName"],
|
|
358
|
+
"slug": page["Slug"],
|
|
359
|
+
"folder": relative_path(project_root, folder),
|
|
360
|
+
"instruction": relative_path(project_root, instruction),
|
|
361
|
+
"screenshot": relative_path(project_root, shot),
|
|
362
|
+
"components": [],
|
|
363
|
+
}
|
|
364
|
+
for j, component in enumerate(page["Components"]):
|
|
365
|
+
clabel = f"{label}.Components[{j}]"
|
|
366
|
+
cfolder = safe_project_path(
|
|
367
|
+
project_root, component["Folder"], clabel + ".Folder", "directory"
|
|
368
|
+
)
|
|
369
|
+
cinstruction = safe_project_path(
|
|
370
|
+
project_root,
|
|
371
|
+
component["ComponentInstruction"],
|
|
372
|
+
clabel + ".ComponentInstruction",
|
|
373
|
+
"file",
|
|
374
|
+
)
|
|
375
|
+
cshot = safe_project_path(
|
|
376
|
+
project_root,
|
|
377
|
+
component["ComponentScreenshotPlaceholder"],
|
|
378
|
+
clabel + ".ComponentScreenshotPlaceholder",
|
|
379
|
+
"file",
|
|
380
|
+
)
|
|
381
|
+
if not cinstruction.is_relative_to(cfolder) or not cshot.is_relative_to(
|
|
382
|
+
cfolder
|
|
383
|
+
):
|
|
384
|
+
fail(
|
|
385
|
+
"BINDING_FOLDER",
|
|
386
|
+
"Component instruction and evidence must be within its Folder",
|
|
387
|
+
clabel,
|
|
388
|
+
)
|
|
389
|
+
refs.extend([cinstruction, cshot])
|
|
390
|
+
docs.append(cinstruction)
|
|
391
|
+
entry["components"].append(
|
|
392
|
+
{
|
|
393
|
+
"slug": component["Slug"],
|
|
394
|
+
"folder": relative_path(project_root, cfolder),
|
|
395
|
+
"instruction": relative_path(project_root, cinstruction),
|
|
396
|
+
"screenshot": relative_path(project_root, cshot),
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
index["pages"].append(entry)
|
|
400
|
+
index["referenced_paths"] = unique_paths(refs)
|
|
401
|
+
index["cox_documents"] = unique_paths(docs)
|
|
402
|
+
return index
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def validate_bindings(manifest, index, documents, root):
|
|
406
|
+
errors = []
|
|
407
|
+
page_slugs, component_slugs = set(), {}
|
|
408
|
+
for page in index["pages"]:
|
|
409
|
+
doc = documents[root / page["instruction"]]
|
|
410
|
+
expected = {
|
|
411
|
+
"FileType": "page_spec",
|
|
412
|
+
"Slug": page["slug"],
|
|
413
|
+
"PageName": page["pageName"],
|
|
414
|
+
"ProjectName": manifest["ProjectName"],
|
|
415
|
+
}
|
|
416
|
+
for key, value in expected.items():
|
|
417
|
+
if doc.get(key) != value:
|
|
418
|
+
errors.append(
|
|
419
|
+
Diagnostic(
|
|
420
|
+
"BINDING_MISMATCH",
|
|
421
|
+
f"Expected {value!r}, got {doc.get(key)!r}",
|
|
422
|
+
page["instruction"],
|
|
423
|
+
key,
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
if page["slug"] in page_slugs:
|
|
427
|
+
errors.append(
|
|
428
|
+
Diagnostic(
|
|
429
|
+
"SLUG_DUPLICATE",
|
|
430
|
+
f"Duplicate page slug {page['slug']}",
|
|
431
|
+
"manifest.cox",
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
page_slugs.add(page["slug"])
|
|
435
|
+
children = doc.get("Children", {}).get("Components", [])
|
|
436
|
+
bound = {c["slug"]: c for c in page["components"]}
|
|
437
|
+
if len(bound) != len(page["components"]) or len(
|
|
438
|
+
{c["Slug"] for c in children}
|
|
439
|
+
) != len(children):
|
|
440
|
+
errors.append(
|
|
441
|
+
Diagnostic(
|
|
442
|
+
"SLUG_DUPLICATE",
|
|
443
|
+
"Duplicate component binding within page",
|
|
444
|
+
page["instruction"],
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
if {c["Slug"] for c in children} != set(bound):
|
|
448
|
+
errors.append(
|
|
449
|
+
Diagnostic(
|
|
450
|
+
"BINDING_MISMATCH",
|
|
451
|
+
"Children.Components must match the manifest component slugs",
|
|
452
|
+
page["instruction"],
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
for child in children:
|
|
456
|
+
if child["Slug"] in bound:
|
|
457
|
+
child_path = Path(page["instruction"]).parent / child["Folder"]
|
|
458
|
+
# Child folders are relative to the page document, not the manifest.
|
|
459
|
+
try:
|
|
460
|
+
resolved = safe_project_path(
|
|
461
|
+
root,
|
|
462
|
+
child_path.as_posix(),
|
|
463
|
+
page["instruction"] + ": Children.Folder",
|
|
464
|
+
"directory",
|
|
465
|
+
allow_parent=True,
|
|
466
|
+
)
|
|
467
|
+
if relative_path(root, resolved) != bound[child["Slug"]]["folder"]:
|
|
468
|
+
errors.append(
|
|
469
|
+
Diagnostic(
|
|
470
|
+
"BINDING_FOLDER",
|
|
471
|
+
"Child folder differs from manifest binding",
|
|
472
|
+
page["instruction"],
|
|
473
|
+
child["Slug"],
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
except CoxError as exc:
|
|
477
|
+
errors.extend(exc.issues)
|
|
478
|
+
for component in page["components"]:
|
|
479
|
+
path = root / component["instruction"]
|
|
480
|
+
payload = documents[path]
|
|
481
|
+
for key, value in {
|
|
482
|
+
"FileType": "component_spec",
|
|
483
|
+
"Slug": component["slug"],
|
|
484
|
+
}.items():
|
|
485
|
+
if payload.get(key) != value:
|
|
486
|
+
errors.append(
|
|
487
|
+
Diagnostic(
|
|
488
|
+
"BINDING_MISMATCH",
|
|
489
|
+
f"Expected {value!r}, got {payload.get(key)!r}",
|
|
490
|
+
component["instruction"],
|
|
491
|
+
key,
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
prior = component_slugs.setdefault(component["slug"], path)
|
|
495
|
+
if prior != path:
|
|
496
|
+
errors.append(
|
|
497
|
+
Diagnostic(
|
|
498
|
+
"SLUG_DUPLICATE",
|
|
499
|
+
"Same component slug refers to different source files",
|
|
500
|
+
component["instruction"],
|
|
501
|
+
component["slug"],
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
if errors:
|
|
505
|
+
raise CoxError(errors)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def validate_project(target: Path) -> ValidationSummary:
|
|
509
|
+
root, manifest_path = resolve_project_root(target)
|
|
510
|
+
manifest = read_yaml_document(manifest_path)
|
|
511
|
+
issues = schema_issues(manifest, manifest_path)
|
|
512
|
+
if manifest.get("Schema") != "cox.manifest.v1":
|
|
513
|
+
issues.append(
|
|
514
|
+
Diagnostic(
|
|
515
|
+
"MANIFEST_TYPE",
|
|
516
|
+
"manifest.cox must use cox.manifest.v1",
|
|
517
|
+
str(manifest_path),
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
if issues:
|
|
521
|
+
raise CoxError(issues)
|
|
522
|
+
index = build_manifest_index(manifest, root)
|
|
523
|
+
documents = {manifest_path: manifest}
|
|
524
|
+
for path in index["cox_documents"]:
|
|
525
|
+
if path == manifest_path:
|
|
526
|
+
continue
|
|
527
|
+
try:
|
|
528
|
+
if path.suffix != ".cox":
|
|
529
|
+
fail(
|
|
530
|
+
"DOCUMENT_EXTENSION",
|
|
531
|
+
"COX instructions must use the .cox extension",
|
|
532
|
+
str(path),
|
|
533
|
+
)
|
|
534
|
+
doc = read_yaml_document(path)
|
|
535
|
+
documents[path] = doc
|
|
536
|
+
issues.extend(schema_issues(doc, path))
|
|
537
|
+
except CoxError as exc:
|
|
538
|
+
issues.extend(exc.issues)
|
|
539
|
+
if issues:
|
|
540
|
+
raise CoxError(issues)
|
|
541
|
+
validate_bindings(manifest, index, documents, root)
|
|
542
|
+
for raw in index["protocol_docs"]:
|
|
543
|
+
if documents[root / raw].get("FileType") != "protocol_doc":
|
|
544
|
+
fail(
|
|
545
|
+
"BINDING_MISMATCH",
|
|
546
|
+
"ProtocolDocs must reference protocol_doc documents",
|
|
547
|
+
raw,
|
|
548
|
+
)
|
|
549
|
+
unreferenced = [
|
|
550
|
+
relative_path(root, p)
|
|
551
|
+
for p in walk_files(root, skip_nested_projects=True)
|
|
552
|
+
if p.suffix == ".cox" and p.resolve() not in documents and not p.is_symlink()
|
|
553
|
+
]
|
|
554
|
+
warnings = (
|
|
555
|
+
["Unreferenced COX files: " + ", ".join(sorted(unreferenced))]
|
|
556
|
+
if unreferenced
|
|
557
|
+
else []
|
|
558
|
+
)
|
|
559
|
+
return ValidationSummary(
|
|
560
|
+
root, manifest_path, documents, index["referenced_paths"], warnings, index
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def write_json(path: Path, payload: Any) -> None:
|
|
565
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
566
|
+
path.write_text(
|
|
567
|
+
json.dumps(payload, indent=2, ensure_ascii=True, allow_nan=False) + "\n",
|
|
568
|
+
encoding="utf-8",
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def normalize_compile_targets(targets: Sequence[str] | None) -> list[str]:
|
|
573
|
+
expanded = []
|
|
574
|
+
for raw in targets or ["json"]:
|
|
575
|
+
expanded.extend(part.strip().lower() for part in raw.split(",") if part.strip())
|
|
576
|
+
if "all" in expanded:
|
|
577
|
+
expanded = [t for t in expanded if t != "all"] + list(SUPPORTED_COMPILE_TARGETS)
|
|
578
|
+
unknown = set(expanded) - set(SUPPORTED_COMPILE_TARGETS)
|
|
579
|
+
if unknown:
|
|
580
|
+
fail(
|
|
581
|
+
"TARGET_UNKNOWN",
|
|
582
|
+
"Unsupported compile target(s): " + ", ".join(sorted(unknown)),
|
|
583
|
+
)
|
|
584
|
+
return [
|
|
585
|
+
target
|
|
586
|
+
for target in SUPPORTED_COMPILE_TARGETS
|
|
587
|
+
if target in (expanded or ["json"])
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def compile_project(
|
|
592
|
+
target: Path,
|
|
593
|
+
out_dir: Path,
|
|
594
|
+
targets: Sequence[str] | None = None,
|
|
595
|
+
strict: bool = False,
|
|
596
|
+
):
|
|
597
|
+
from .compiler import compile_project as compile_impl
|
|
598
|
+
|
|
599
|
+
return compile_impl(target, out_dir, targets, strict)
|