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.
Files changed (74) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/COX_FILE_FORMAT_SPECIFICATION_v1.0.md +40 -200
  3. package/FINALIZATION.md +50 -0
  4. package/LocalAppManager/Dashboard/analytics_sidebar_panels/analytics_sidebar_panels.cox +56 -0
  5. package/LocalAppManager/Dashboard/analytics_sidebar_panels/analytics_sidebar_panels_screenshot.placeholder.txt +1 -0
  6. package/LocalAppManager/Dashboard/dashboard.cox +62 -0
  7. package/LocalAppManager/Dashboard/dashboard_screenshot.placeholder.txt +1 -0
  8. package/LocalAppManager/Dashboard/left_navigation_sidebar/left_navigation_sidebar.cox +56 -0
  9. package/LocalAppManager/Dashboard/left_navigation_sidebar/left_navigation_sidebar_screenshot.placeholder.txt +1 -0
  10. package/LocalAppManager/Dashboard/projects_grid_cards/projects_grid_cards.cox +56 -0
  11. package/LocalAppManager/Dashboard/projects_grid_cards/projects_grid_cards_screenshot.placeholder.txt +1 -0
  12. package/LocalAppManager/Dashboard/top_header_stats_bar/top_header_stats_bar.cox +56 -0
  13. package/LocalAppManager/Dashboard/top_header_stats_bar/top_header_stats_bar_screenshot.placeholder.txt +1 -0
  14. package/MANIFEST.in +20 -0
  15. package/README.md +67 -154
  16. package/bin/cox-protocol.js +23 -97
  17. package/cox_protocol/__init__.py +2 -0
  18. package/cox_protocol/__main__.py +3 -0
  19. package/cox_protocol/compiler.py +273 -0
  20. package/cox_protocol/cox_cli.py +146 -0
  21. package/cox_protocol/cox_lib.py +599 -0
  22. package/cox_protocol/package_target.py +262 -0
  23. package/cox_protocol/react_target.py +306 -0
  24. package/cox_protocol/scaffold.py +56 -0
  25. package/cox_protocol/schemas/cox.component.v1.schema.json +180 -0
  26. package/cox_protocol/schemas/cox.crop_metadata.v1.schema.json +83 -0
  27. package/cox_protocol/schemas/cox.manifest.v1.schema.json +172 -0
  28. package/cox_protocol/schemas/cox.page.v1.schema.json +242 -0
  29. package/cox_protocol/schemas/cox.protocol.v1.schema.json +38 -0
  30. package/cox_protocol/starter/README.md +7 -0
  31. package/cox_protocol/starter/manifest.cox +26 -0
  32. package/cox_protocol/starter/page/action_button/action_button.cox +34 -0
  33. package/cox_protocol/starter/page/action_button/reference.svg +1 -0
  34. package/cox_protocol/starter/page/page.cox +39 -0
  35. package/cox_protocol/starter/page/reference.svg +1 -0
  36. package/editor-support/vscode/CHANGELOG.md +6 -0
  37. package/editor-support/vscode/README.md +2 -1
  38. package/editor-support/vscode/package.json +3 -3
  39. package/editor-support/vscode/snippets/cox-common.json +1 -1
  40. package/editor-support/vscode/snippets/cox-component.json +2 -1
  41. package/editor-support/vscode/snippets/cox-crop-metadata.json +1 -1
  42. package/editor-support/vscode/snippets/cox-manifest.json +1 -1
  43. package/editor-support/vscode/snippets/cox-page.json +2 -1
  44. package/editor-support/vscode/snippets/cox-protocol.json +1 -1
  45. package/examples/quickstart/README.md +7 -0
  46. package/examples/quickstart/manifest.cox +26 -0
  47. package/examples/quickstart/page/action_button/action_button.cox +34 -0
  48. package/examples/quickstart/page/action_button/reference.svg +1 -0
  49. package/examples/quickstart/page/page.cox +39 -0
  50. package/examples/quickstart/page/reference.svg +1 -0
  51. package/guides/contract-demo.md +24 -0
  52. package/guides/npm-installation.md +16 -287
  53. package/manifest.cox +7 -4
  54. package/package.json +30 -14
  55. package/protocols/recursive_decomposition_protocol.cox +8 -8
  56. package/pyproject.toml +17 -6
  57. package/schemas/cox.component.v1.schema.json +17 -4
  58. package/schemas/cox.crop_metadata.v1.schema.json +1 -1
  59. package/schemas/cox.manifest.v1.schema.json +1 -1
  60. package/schemas/cox.page.v1.schema.json +17 -4
  61. package/schemas/cox.protocol.v1.schema.json +1 -1
  62. package/scripts/check_distribution.py +87 -0
  63. package/scripts/demo.py +62 -0
  64. package/scripts/package-vscode.mjs +12 -0
  65. package/scripts/release_check.py +86 -0
  66. package/tests/conftest.py +21 -0
  67. package/tests/test_cli_and_typescript.py +123 -0
  68. package/tests/test_compiler.py +279 -0
  69. package/tests/test_validation.py +215 -0
  70. package/tools/__init__.py +2 -2
  71. package/tools/cox_cli.py +2 -123
  72. package/tools/cox_lib.py +2 -558
  73. package/tools/package_target.py +2 -214
  74. 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)