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,273 @@
1
+ """Build into an isolated staging directory, then publish only owned artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any, Sequence
13
+
14
+ from . import __version__
15
+ from .cox_lib import (
16
+ CoxError,
17
+ fail,
18
+ normalize_compile_targets,
19
+ validate_project,
20
+ write_json,
21
+ )
22
+
23
+ MARKER = ".cox-build.json"
24
+
25
+
26
+ def digest(path: Path) -> str:
27
+ return hashlib.sha256(path.read_bytes()).hexdigest()
28
+
29
+
30
+ def build_location(summary, out_dir: Path) -> Path:
31
+ candidate = out_dir if out_dir.is_absolute() else summary.project_root / out_dir
32
+ if candidate.is_symlink():
33
+ fail("OUTPUT_SYMLINK", "Output directory must not be a symlink", str(candidate))
34
+ # Canonicalize parents (including macOS /var and /tmp aliases) before overlap
35
+ # checks. The chosen root and every managed path inside it reject symlinks.
36
+ root = candidate.resolve()
37
+ if summary.project_root.is_relative_to(root):
38
+ fail(
39
+ "OUTPUT_OVERLAP",
40
+ "Output cannot be the project root or an ancestor",
41
+ str(root),
42
+ )
43
+ for source in [summary.manifest_path, *summary.referenced_paths]:
44
+ if source.is_relative_to(root) or (
45
+ source.is_dir() and root.is_relative_to(source)
46
+ ):
47
+ fail("OUTPUT_OVERLAP", "Output overlaps a manifest input", str(source))
48
+ if root.exists() and not root.is_dir():
49
+ fail("OUTPUT_TYPE", "Output must be a directory", str(root))
50
+ return root
51
+
52
+
53
+ def artifact_paths(stage: Path) -> list[Path]:
54
+ return sorted(p for p in stage.rglob("*") if p.is_file())
55
+
56
+
57
+ def load_ownership(build_root: Path) -> dict[str, str]:
58
+ marker = build_root / MARKER
59
+ if marker.is_symlink():
60
+ fail("OUTPUT_SYMLINK", "Build marker must not be a symlink", str(marker))
61
+ if not marker.exists():
62
+ return {}
63
+ try:
64
+ data = json.loads(marker.read_text(encoding="utf-8"))
65
+ if data["format"] != "cox-build/1" or not isinstance(data["files"], dict):
66
+ raise ValueError("Unsupported build marker")
67
+ for raw, checksum in data["files"].items():
68
+ if (
69
+ not raw
70
+ or any(part in ("", ".", "..") for part in raw.split("/"))
71
+ or any(c in raw for c in '\\<>:"|?*')
72
+ or any(ord(c) < 32 for c in raw)
73
+ or raw == MARKER
74
+ ):
75
+ raise ValueError("Invalid owned output path")
76
+ if not isinstance(checksum, str) or not re.fullmatch(
77
+ "[0-9a-f]{64}", checksum
78
+ ):
79
+ raise ValueError("Invalid checksum in build marker")
80
+ return data["files"]
81
+ except (OSError, ValueError, KeyError, TypeError) as exc:
82
+ fail("OUTPUT_MARKER", f"Invalid build ownership marker: {exc}", str(marker))
83
+
84
+
85
+ def publish(stage: Path, build_root: Path) -> None:
86
+ outputs = {p.relative_to(stage).as_posix(): p for p in artifact_paths(stage)}
87
+ owned = load_ownership(build_root)
88
+ # A compiler owns only the exact generated bytes recorded on the previous run.
89
+ for rel in sorted(set(outputs) | set(owned)):
90
+ path = build_root / rel
91
+ for parent in (path, *path.parents):
92
+ if parent == build_root:
93
+ break
94
+ if parent.is_symlink():
95
+ fail(
96
+ "OUTPUT_SYMLINK",
97
+ "Refusing to replace an output through a symlink",
98
+ str(parent),
99
+ )
100
+ if parent != path and parent.exists() and not parent.is_dir():
101
+ fail("OUTPUT_COLLISION", "Output parent is a file", str(parent))
102
+ if path.exists():
103
+ if not path.is_file() or rel not in owned:
104
+ fail(
105
+ "OUTPUT_COLLISION",
106
+ "Existing file is not owned by COX; use a fresh --out directory",
107
+ str(path),
108
+ )
109
+ if digest(path) != owned[rel]:
110
+ fail(
111
+ "OUTPUT_MODIFIED",
112
+ "Generated file has local edits; preserve them and use a fresh --out directory",
113
+ str(path),
114
+ )
115
+ build_root.mkdir(parents=True, exist_ok=True)
116
+ with tempfile.TemporaryDirectory(
117
+ prefix=".cox-backup-", dir=build_root.parent
118
+ ) as backup_name:
119
+ backup = Path(backup_name)
120
+ previous = [rel for rel in owned if (build_root / rel).is_file()]
121
+ for rel in previous:
122
+ dest = backup / rel
123
+ dest.parent.mkdir(parents=True, exist_ok=True)
124
+ shutil.copy2(build_root / rel, dest)
125
+ marker = build_root / MARKER
126
+ old_marker = marker.read_bytes() if marker.exists() else None
127
+ installed = []
128
+ try:
129
+ hashes = {rel: digest(path) for rel, path in outputs.items()}
130
+ for rel, source in outputs.items():
131
+ dest = build_root / rel
132
+ dest.parent.mkdir(parents=True, exist_ok=True)
133
+ os.replace(source, dest)
134
+ installed.append(rel)
135
+ for rel in set(owned) - set(outputs):
136
+ (build_root / rel).unlink(missing_ok=True)
137
+ temporary_marker = backup / "new-marker.json"
138
+ write_json(temporary_marker, {"format": "cox-build/1", "files": hashes})
139
+ os.replace(temporary_marker, marker)
140
+ except OSError as exc:
141
+ for rel in installed:
142
+ (build_root / rel).unlink(missing_ok=True)
143
+ for rel in previous:
144
+ dest = build_root / rel
145
+ dest.parent.mkdir(parents=True, exist_ok=True)
146
+ shutil.copy2(backup / rel, dest)
147
+ if old_marker is None:
148
+ marker.unlink(missing_ok=True)
149
+ else:
150
+ marker.write_bytes(old_marker)
151
+ raise CoxError(
152
+ f"Could not publish build; previous generated files restored: {exc}"
153
+ ) from exc
154
+
155
+
156
+ def compile_project(
157
+ target: Path,
158
+ out_dir: Path,
159
+ targets: Sequence[str] | None = None,
160
+ strict: bool = False,
161
+ ) -> dict[str, Any]:
162
+ summary = validate_project(target)
163
+ if strict and summary.warnings:
164
+ fail("WARNINGS_AS_ERRORS", "\n".join(summary.warnings))
165
+ selected = normalize_compile_targets(targets)
166
+ build_root = build_location(summary, out_dir)
167
+ try:
168
+ # Stage on the destination filesystem so publication also works on another volume.
169
+ build_root.parent.mkdir(parents=True, exist_ok=True)
170
+ with tempfile.TemporaryDirectory(
171
+ prefix=".cox-stage-", dir=build_root.parent
172
+ ) as temporary:
173
+ stage = Path(temporary)
174
+ results = {}
175
+ if "json" in selected:
176
+ for source, payload in sorted(summary.documents.items()):
177
+ if source != summary.manifest_path:
178
+ write_json(
179
+ (
180
+ stage
181
+ / "normalized"
182
+ / source.relative_to(summary.project_root)
183
+ ).with_suffix(".json"),
184
+ payload,
185
+ )
186
+ (stage / "normalized").mkdir(exist_ok=True)
187
+ results["json"] = {
188
+ "output_root": stage / "normalized",
189
+ "document_count": len(summary.documents) - 1,
190
+ }
191
+ if "react" in selected:
192
+ from .react_target import emit_react_stubs
193
+
194
+ results["react"] = emit_react_stubs(summary, stage)
195
+ if "package" in selected:
196
+ from .package_target import package_filename
197
+
198
+ name = package_filename(summary)
199
+ results["package"] = {
200
+ "output_root": stage / "package",
201
+ "archive": stage / "package" / name,
202
+ "manifest": stage
203
+ / "package"
204
+ / (Path(name).stem + ".manifest.json"),
205
+ }
206
+ manifest_payload = {
207
+ "compiledWith": f"cox {__version__}",
208
+ "mimeType": "application/vnd.cox+yaml",
209
+ "targets": selected,
210
+ "manifest": summary.documents[summary.manifest_path],
211
+ "resolvedFiles": {
212
+ key: value
213
+ for key, value in summary.build_index.items()
214
+ if key not in ("referenced_paths", "cox_documents")
215
+ },
216
+ "targetOutputs": {
217
+ name: {
218
+ key: value.relative_to(stage).as_posix()
219
+ if isinstance(value, Path)
220
+ else value
221
+ for key, value in result.items()
222
+ }
223
+ for name, result in results.items()
224
+ },
225
+ "warnings": summary.warnings,
226
+ }
227
+ write_json(stage / "manifest.compiled.json", manifest_payload)
228
+ report = {
229
+ "ok": True,
230
+ "formatVersion": "1.0",
231
+ "compilerVersion": __version__,
232
+ "project": summary.documents[summary.manifest_path]["Slug"],
233
+ "validatedDocuments": len(summary.documents),
234
+ "referencedPathsChecked": len(summary.referenced_paths),
235
+ "targets": selected,
236
+ "warnings": summary.warnings,
237
+ }
238
+ write_json(stage / "reports" / "verification_report.json", report)
239
+ text = (
240
+ "COX verification report\n"
241
+ + "\n".join(
242
+ f"{key}: {json.dumps(value, ensure_ascii=True)}"
243
+ for key, value in report.items()
244
+ )
245
+ + "\n"
246
+ )
247
+ (stage / "reports" / "verification_report.txt").write_text(
248
+ text, encoding="utf-8"
249
+ )
250
+ if "package" in selected:
251
+ from .package_target import emit_package_archive
252
+
253
+ results["package"] = emit_package_archive(summary, stage, results)
254
+ final_results = {
255
+ name: {
256
+ key: build_root / value.relative_to(stage)
257
+ if isinstance(value, Path)
258
+ else value
259
+ for key, value in result.items()
260
+ }
261
+ for name, result in results.items()
262
+ }
263
+ publish(stage, build_root)
264
+ return {
265
+ "build_root": build_root,
266
+ "targets": selected,
267
+ "target_results": final_results,
268
+ "compiled_manifest": build_root / "manifest.compiled.json",
269
+ "report": build_root / "reports" / "verification_report.txt",
270
+ "warnings": summary.warnings,
271
+ }
272
+ except OSError as exc:
273
+ raise CoxError(f"Build I/O failed: {exc}") from exc
@@ -0,0 +1,146 @@
1
+ """Human-readable and machine-readable interfaces to the same COX contract."""
2
+
3
+ from __future__ import annotations
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from . import __version__
11
+ from .cox_lib import CoxError, compile_project, validate_project, fail
12
+ from .package_target import verify_archive
13
+ from .scaffold import init_project
14
+
15
+ app = typer.Typer(
16
+ add_completion=False,
17
+ no_args_is_help=True,
18
+ help="Create, validate, compile and verify COX design contracts.",
19
+ )
20
+ console = Console()
21
+
22
+
23
+ def serializable(value):
24
+ if isinstance(value, Path):
25
+ return str(value)
26
+ if isinstance(value, dict):
27
+ return {key: serializable(item) for key, item in value.items()}
28
+ if isinstance(value, list):
29
+ return [serializable(item) for item in value]
30
+ return value
31
+
32
+
33
+ def output(payload, machine: bool, title: str):
34
+ if machine:
35
+ typer.echo(json.dumps(serializable(payload), indent=2))
36
+ else:
37
+ console.print(title, style="bold green", markup=False)
38
+ for key, value in payload.items():
39
+ if key not in ("ok", "target_results"):
40
+ console.print(f"{key}: {serializable(value)}", markup=False)
41
+
42
+
43
+ def failure(exc: Exception, machine: bool):
44
+ if not isinstance(exc, CoxError):
45
+ exc = CoxError(f"I/O operation failed: {exc}")
46
+ if machine:
47
+ typer.echo(json.dumps(exc.as_dict(), indent=2))
48
+ else:
49
+ console.print("COX failed", style="bold red", markup=False)
50
+ console.print(str(exc), markup=False)
51
+ raise typer.Exit(1)
52
+
53
+
54
+ def version_callback(value: bool):
55
+ if value:
56
+ typer.echo(__version__)
57
+ raise typer.Exit()
58
+
59
+
60
+ @app.callback()
61
+ def main(
62
+ version: bool = typer.Option(
63
+ False,
64
+ "--version",
65
+ callback=version_callback,
66
+ is_eager=True,
67
+ help="Show the tool version.",
68
+ ),
69
+ ):
70
+ pass
71
+
72
+
73
+ @app.command("validate")
74
+ def validate_command(
75
+ project_path: Path = typer.Argument(Path(".")),
76
+ machine: bool = typer.Option(False, "--json", help="Emit JSON diagnostics."),
77
+ strict: bool = typer.Option(False, "--strict", help="Fail on warnings."),
78
+ ):
79
+ """Check canonical schemas, paths and page/component bindings."""
80
+ try:
81
+ summary = validate_project(project_path)
82
+ if strict and summary.warnings:
83
+ fail("WARNINGS_AS_ERRORS", "\n".join(summary.warnings))
84
+ output(
85
+ {
86
+ "ok": True,
87
+ "version": __version__,
88
+ "project": str(summary.project_root),
89
+ "documentCount": len(summary.documents),
90
+ "warnings": summary.warnings,
91
+ },
92
+ machine,
93
+ "COX validation passed",
94
+ )
95
+ except (CoxError, OSError) as exc:
96
+ failure(exc, machine)
97
+
98
+
99
+ @app.command("compile")
100
+ def compile_command(
101
+ project_path: Path = typer.Argument(Path(".")),
102
+ out_dir: Path = typer.Option(Path("build"), "--out", "-o"),
103
+ targets: list[str] = typer.Option(
104
+ [], "--target", "-t", help="json, react, package or all; repeat or use commas."
105
+ ),
106
+ machine: bool = typer.Option(False, "--json"),
107
+ strict: bool = typer.Option(False, "--strict"),
108
+ ):
109
+ """Compile validated contracts into deterministic JSON, React stubs and ZIPs."""
110
+ try:
111
+ result = compile_project(project_path, out_dir, targets=targets, strict=strict)
112
+ output({"ok": True, **result}, machine, "COX compilation passed")
113
+ except (CoxError, OSError) as exc:
114
+ failure(exc, machine)
115
+
116
+
117
+ @app.command("init")
118
+ def init_command(
119
+ destination: Path = typer.Argument(...),
120
+ name: Optional[str] = typer.Option(None, "--name"),
121
+ machine: bool = typer.Option(False, "--json"),
122
+ ):
123
+ """Create a complete example project in a new or empty directory."""
124
+ try:
125
+ output(
126
+ {"ok": True, "project": init_project(destination, name)},
127
+ machine,
128
+ "COX project created",
129
+ )
130
+ except (CoxError, OSError) as exc:
131
+ failure(exc, machine)
132
+
133
+
134
+ @app.command("verify-package")
135
+ def verify_command(
136
+ archive: Path = typer.Argument(...), machine: bool = typer.Option(False, "--json")
137
+ ):
138
+ """Verify ZIP contents against the embedded SHA-256 inventory (not a signature)."""
139
+ try:
140
+ output(verify_archive(archive), machine, "COX package verified")
141
+ except (CoxError, OSError) as exc:
142
+ failure(exc, machine)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ app()