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,87 @@
1
+ #!/usr/bin/env python3
2
+ """Check built artifacts themselves, including resource coverage and exclusions."""
3
+
4
+ from __future__ import annotations
5
+ import argparse
6
+ import json
7
+ import tarfile
8
+ import zipfile
9
+ from pathlib import Path
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+
13
+
14
+ def check(dist: Path, vsix: Path):
15
+ version = json.loads((ROOT / "package.json").read_text())["version"]
16
+ with zipfile.ZipFile(
17
+ dist / f"cox_protocol_package-{version}-py3-none-any.whl"
18
+ ) as archive:
19
+ wheel = archive.namelist()
20
+ assert "cox_protocol/__main__.py" in wheel
21
+ assert (
22
+ len([name for name in wheel if name.startswith("cox_protocol/schemas/")])
23
+ == 5
24
+ )
25
+ assert "cox_protocol/starter/page/action_button/reference.svg" in wheel
26
+ assert not any(name.startswith("tools/") for name in wheel)
27
+ for name in wheel:
28
+ if name.startswith("cox_protocol/"):
29
+ assert archive.read(name) == (ROOT / name).read_bytes(), name
30
+ with tarfile.open(dist / f"cox_protocol_package-{version}.tar.gz") as archive:
31
+ source = archive.getnames()
32
+ assert any(name.endswith("/tests/test_compiler.py") for name in source)
33
+ assert any(name.endswith("/scripts/release_check.py") for name in source)
34
+ with tarfile.open(dist / f"cox-protocol-package-{version}.tgz") as archive:
35
+ npm = archive.getnames()
36
+ assert "package/cox_protocol/starter/manifest.cox" in npm
37
+ assert "package/bin/cox-protocol.js" in npm
38
+ assert (
39
+ json.load(archive.extractfile("package/package.json"))["version"] == version
40
+ )
41
+ with zipfile.ZipFile(vsix) as archive:
42
+ extension = archive.namelist()
43
+ package = json.loads(archive.read("extension/package.json"))
44
+ assert package["version"] == version
45
+ assert package["contributes"]["languages"][0]["id"] == "cox"
46
+ for item in package["contributes"]["snippets"]:
47
+ assert "extension/" + item["path"].removeprefix("./") in extension
48
+ for label, names in [
49
+ ("wheel", wheel),
50
+ ("sdist", source),
51
+ ("npm", npm),
52
+ ("VSIX", extension),
53
+ ]:
54
+ for name in names:
55
+ parts = Path(name).parts
56
+ assert not set(parts) & {
57
+ "node_modules",
58
+ "__pycache__",
59
+ ".git",
60
+ ".venv",
61
+ "build",
62
+ }, (label, name)
63
+ assert not name.endswith((".pyc", ".pyo", ".cox-build.json")), (label, name)
64
+ print(
65
+ json.dumps(
66
+ {
67
+ "ok": True,
68
+ "version": version,
69
+ "artifacts": ["wheel", "sdist", "npm", "VSIX"],
70
+ "checks": [
71
+ "resources",
72
+ "source-byte-match",
73
+ "versions",
74
+ "snippets",
75
+ "no-transient-files",
76
+ ],
77
+ }
78
+ )
79
+ )
80
+
81
+
82
+ if __name__ == "__main__":
83
+ parser = argparse.ArgumentParser()
84
+ parser.add_argument("--dist", type=Path, required=True)
85
+ parser.add_argument("--vsix", type=Path, required=True)
86
+ args = parser.parse_args()
87
+ check(args.dist, args.vsix)
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Record a real COX binding failure, correction and deterministic build."""
3
+
4
+ from __future__ import annotations
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from cox_protocol.cox_lib import CoxError, compile_project, validate_project, write_json
9
+ from cox_protocol.package_target import verify_archive
10
+ from cox_protocol.scaffold import init_project
11
+
12
+
13
+ def demo(out: Path):
14
+ out = out.resolve()
15
+ if out.exists() and any(out.iterdir()):
16
+ raise SystemExit("Use a new or empty demo output directory.")
17
+ out.mkdir(parents=True, exist_ok=True)
18
+ project = init_project(out / "project", "Contract Demo")
19
+ component = project / "page/action_button/action_button.cox"
20
+ corrected = component.read_text()
21
+ invalid = corrected.replace("Slug: action_button", "Slug: action_buton")
22
+ component.write_text(invalid)
23
+ try:
24
+ validate_project(project)
25
+ except CoxError as exc:
26
+ diagnostic = exc.as_dict()
27
+ else:
28
+ raise RuntimeError("Expected the broken binding to fail")
29
+ finally:
30
+ component.write_text(corrected)
31
+ (out / "01-invalid-component.cox.txt").write_text(invalid)
32
+ write_json(out / "02-diagnostic.json", diagnostic)
33
+ (out / "03-corrected-component.cox.txt").write_text(corrected)
34
+ summary = validate_project(project)
35
+ build = compile_project(project, out / "build", ["all"], strict=True)
36
+ verified = verify_archive(build["target_results"]["package"]["archive"])
37
+ verified["archive"] = Path(verified["archive"]).relative_to(out).as_posix()
38
+ evidence = {
39
+ "ok": True,
40
+ "rule": "Each component Slug must match its manifest binding.",
41
+ "observedDiagnostic": diagnostic,
42
+ "correction": "action_buton → action_button",
43
+ "validatedDocuments": len(summary.documents),
44
+ "archive": verified,
45
+ "scope": "Contract validation and structural React scaffolding; no rendered UI or AST compliance evaluation.",
46
+ }
47
+ write_json(out / "04-result.json", evidence)
48
+ print(
49
+ json.dumps(
50
+ {
51
+ "ok": True,
52
+ "output": str(out),
53
+ "validatedDocuments": len(summary.documents),
54
+ }
55
+ )
56
+ )
57
+
58
+
59
+ if __name__ == "__main__":
60
+ parser = argparse.ArgumentParser()
61
+ parser.add_argument("--out", required=True, type=Path)
62
+ demo(parser.parse_args().out)
@@ -0,0 +1,12 @@
1
+ import { mkdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawnSync } from 'node:child_process';
5
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
+ const extension = path.join(root, 'editor-support/vscode');
7
+ const version = JSON.parse(readFileSync(path.join(extension, 'package.json'), 'utf8')).version;
8
+ const destination = process.argv[2] ? path.resolve(process.argv[2]) : path.join(root, 'build/vscode', `cox-language-support-${version}.vsix`);
9
+ mkdirSync(path.dirname(destination), { recursive: true });
10
+ const result = spawnSync(process.execPath, [path.join(root, 'node_modules/@vscode/vsce/vsce'), 'package', '--no-dependencies', '--out', destination], { cwd: extension, stdio: 'inherit' });
11
+ if (result.error) console.error(result.error.message);
12
+ process.exit(result.status ?? 1);
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise an installed CLI away from its checkout; no repository imports."""
3
+
4
+ from __future__ import annotations
5
+ import argparse
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+
14
+ def check(command, env=None):
15
+ with tempfile.TemporaryDirectory(prefix="cox-installed-") as raw:
16
+ root = Path(raw)
17
+
18
+ def run(*args, expected=0):
19
+ result = subprocess.run(
20
+ [*command, *args], cwd=root, env=env, capture_output=True, text=True
21
+ )
22
+ if result.returncode != expected:
23
+ raise RuntimeError(
24
+ f"{args}: exit {result.returncode}\n{result.stdout}\n{result.stderr}"
25
+ )
26
+ return result.stdout
27
+
28
+ version = run("--version").strip()
29
+ assert json.loads(run("init", "demo", "--json"))["ok"]
30
+ assert (
31
+ json.loads(run("validate", "demo", "--json", "--strict"))["documentCount"]
32
+ == 3
33
+ )
34
+ component = root / "demo/page/action_button/action_button.cox"
35
+ original = component.read_text()
36
+ component.write_text(
37
+ original.replace("Slug: action_button", "Slug: action_buton")
38
+ )
39
+ invalid = json.loads(run("validate", "demo", "--json", expected=1))
40
+ assert invalid["errors"][0]["code"] == "BINDING_MISMATCH"
41
+ component.write_text(original)
42
+ result = json.loads(
43
+ run(
44
+ "compile",
45
+ "demo",
46
+ "--target",
47
+ "all",
48
+ "--out",
49
+ str(root / "artifacts"),
50
+ "--json",
51
+ "--strict",
52
+ )
53
+ )
54
+ archive = Path(result["target_results"]["package"]["archive"])
55
+ assert json.loads(run("verify-package", str(archive), "--json"))["ok"]
56
+ print(
57
+ json.dumps(
58
+ {
59
+ "ok": True,
60
+ "version": version,
61
+ "command": command,
62
+ "checks": [
63
+ "init",
64
+ "strict validation",
65
+ "invalid binding diagnostic",
66
+ "corrected all-target compile",
67
+ "external output",
68
+ "archive verification",
69
+ ],
70
+ }
71
+ )
72
+ )
73
+
74
+
75
+ if __name__ == "__main__":
76
+ parser = argparse.ArgumentParser()
77
+ parser.add_argument("--python", default=sys.executable)
78
+ parser.add_argument("--node-cli", type=Path)
79
+ args = parser.parse_args()
80
+ if args.node_cli:
81
+ check(
82
+ ["node", str(args.node_cli.resolve())],
83
+ {**os.environ, "COX_PYTHON": args.python},
84
+ )
85
+ else:
86
+ check([args.python, "-m", "cox_protocol"])
@@ -0,0 +1,21 @@
1
+ import shutil
2
+ from pathlib import Path
3
+ import pytest
4
+ import yaml
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+
8
+
9
+ @pytest.fixture
10
+ def project(tmp_path):
11
+ folder = tmp_path / "source"
12
+ shutil.copytree(ROOT / "examples" / "quickstart", folder)
13
+ return folder
14
+
15
+
16
+ def change(project, rel, mutate):
17
+ path = project / rel
18
+ doc = yaml.safe_load(path.read_text())
19
+ mutate(doc)
20
+ path.write_text(yaml.safe_dump(doc, sort_keys=False))
21
+ return doc
@@ -0,0 +1,123 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import pytest
8
+ from typer.testing import CliRunner
9
+ from cox_protocol import __version__
10
+ from cox_protocol.cox_cli import app
11
+ from cox_protocol.cox_lib import compile_project, schema_issues
12
+ from conftest import ROOT, change
13
+
14
+
15
+ def test_version_and_snippets():
16
+ assert CliRunner().invoke(app, ["--version"]).output.strip() == __version__
17
+ assert json.loads((ROOT / "package.json").read_text())["version"] == __version__
18
+ assert (
19
+ json.loads((ROOT / "editor-support/vscode/package.json").read_text())["version"]
20
+ == __version__
21
+ )
22
+ assert f'version = "{__version__}"' in (ROOT / "pyproject.toml").read_text()
23
+ import yaml
24
+
25
+ for path in (ROOT / "editor-support/vscode/snippets").glob("cox-*.json"):
26
+ if path.name == "cox-common.json":
27
+ continue
28
+ for snippet in json.loads(path.read_text()).values():
29
+ body = "\n".join(snippet["body"])
30
+ body = re.sub(r"\$\{\d+:([^}]+)\}", r"\1", body)
31
+ body = re.sub(r"\$\{\d+\|([^,|]+)[^}]*\|\}", r"\1", body)
32
+ assert not schema_issues(yaml.safe_load(body), path), path
33
+
34
+
35
+ def test_node_wrapper_cwd_isolation_and_errors(project, tmp_path):
36
+ node = shutil.which("node")
37
+ if not node:
38
+ pytest.skip("Node integration runs in the release/CI environment")
39
+ for name in ("yaml.py", "cox_protocol.py", "jsonschema.py"):
40
+ (project / name).write_text(
41
+ 'raise RuntimeError("Caller module must not be imported")'
42
+ )
43
+ env = {**os.environ, "COX_PYTHON": sys.executable}
44
+ wrapper = ROOT / "bin/cox-protocol.js"
45
+ version = subprocess.run(
46
+ [node, str(wrapper), "--version"],
47
+ capture_output=True,
48
+ text=True,
49
+ env=env,
50
+ cwd=project,
51
+ )
52
+ assert version.returncode == 0 and version.stdout.strip() == __version__
53
+ result = subprocess.run(
54
+ [node, str(wrapper), "validate", ".", "--json"],
55
+ capture_output=True,
56
+ text=True,
57
+ env=env,
58
+ cwd=project,
59
+ )
60
+ assert result.returncode == 0, result.stderr
61
+ assert json.loads(result.stdout)["ok"] is True
62
+ change(project, "manifest.cox", lambda d: d.update(CoxVersion="unknown"))
63
+ bad = subprocess.run(
64
+ [node, str(wrapper), "validate", ".", "--json"],
65
+ capture_output=True,
66
+ text=True,
67
+ env=env,
68
+ cwd=project,
69
+ )
70
+ assert bad.returncode == 1
71
+ assert json.loads(bad.stdout)["errors"]
72
+ missing = subprocess.run(
73
+ [node, str(wrapper), "--help"],
74
+ capture_output=True,
75
+ text=True,
76
+ env={**env, "COX_PYTHON": str(tmp_path / "absent")},
77
+ )
78
+ assert missing.returncode == 1 and "COX needs Python" in missing.stderr
79
+
80
+
81
+ def test_generated_tsx_typechecks(project, tmp_path):
82
+ tsc = ROOT / "node_modules/typescript/bin/tsc"
83
+ if not tsc.exists():
84
+ if os.environ.get("COX_REQUIRE_TYPESCRIPT") == "1":
85
+ pytest.fail("npm ci is required before this gate")
86
+ pytest.skip("Run npm ci to enable the TypeScript release gate")
87
+ change(
88
+ project,
89
+ "page/action_button/action_button.cox",
90
+ lambda d: d.update(
91
+ Role='Quoted "role" </section> */\nnext line',
92
+ ImplementationHints=["*/\nexport const wrong = 1"],
93
+ ),
94
+ )
95
+ out = tmp_path / "consumer"
96
+ compile_project(project, out, ["react"])
97
+ (out / "node_modules").symlink_to(ROOT / "node_modules", target_is_directory=True)
98
+ (out / "tsconfig.json").write_text(
99
+ json.dumps(
100
+ {
101
+ "compilerOptions": {
102
+ "strict": True,
103
+ "noEmit": True,
104
+ "jsx": "react-jsx",
105
+ "target": "ES2020",
106
+ "module": "ESNext",
107
+ "moduleResolution": "Bundler",
108
+ "skipLibCheck": True,
109
+ },
110
+ "include": ["react/**/*.ts", "react/**/*.tsx"],
111
+ }
112
+ )
113
+ )
114
+ checked = subprocess.run(
115
+ ["node", str(tsc), "--project", str(out / "tsconfig.json")],
116
+ capture_output=True,
117
+ text=True,
118
+ )
119
+ assert checked.returncode == 0, checked.stdout + checked.stderr
120
+ assert (
121
+ "EvidenceSummary.Intended:"
122
+ in (out / "react/components/ActionButton/index.tsx").read_text()
123
+ )
@@ -0,0 +1,279 @@
1
+ import json
2
+ import shutil
3
+ import stat
4
+ import zipfile
5
+ import pytest
6
+ from cox_protocol.cox_lib import CoxError, compile_project, validate_project
7
+ from cox_protocol.compiler import MARKER
8
+ from cox_protocol.package_target import verify_archive, zip_write_bytes
9
+ from conftest import change
10
+
11
+
12
+ def tree(root):
13
+ return {
14
+ p.relative_to(root).as_posix(): p.read_bytes()
15
+ for p in root.rglob("*")
16
+ if p.is_file()
17
+ }
18
+
19
+
20
+ def test_deterministic_external_build_and_roundtrip(project, tmp_path):
21
+ first = tmp_path / "one"
22
+ second = tmp_path / "two"
23
+ compile_project(project, first, ["all"])
24
+ copy = tmp_path / "renamed-checkout"
25
+ shutil.copytree(project, copy)
26
+ compile_project(copy, second, ["package", "react,json"])
27
+ assert tree(first) == tree(second)
28
+ archive = next((first / "package").glob("*.zip"))
29
+ verified = verify_archive(archive)
30
+ with zipfile.ZipFile(archive) as z:
31
+ names = z.namelist()
32
+ assert names == sorted(names)
33
+ assert all(i.date_time == (1980, 1, 1, 0, 0, 0) for i in z.infolist())
34
+ assert any(n.endswith("/build/manifest.compiled.json") for n in names)
35
+ assert any(n.endswith("/build/reports/verification_report.json") for n in names)
36
+ assert not any(
37
+ str(tmp_path) in z.read(n).decode("utf-8", errors="ignore") for n in names
38
+ )
39
+ z.extractall(tmp_path / "unpacked")
40
+ assert verified["fileCount"] == len(names) - 1
41
+ assert len(validate_project(tmp_path / "unpacked/cox_example").documents) == 3
42
+
43
+
44
+ def test_recompile_ownership_and_removed_target(project, tmp_path):
45
+ out = tmp_path / "out"
46
+ compile_project(project, out, ["all"])
47
+ (out / "user-notes.txt").write_text("preserve me")
48
+ compile_project(project, out, ["json"])
49
+ assert not list((out / "react").rglob("*.tsx"))
50
+ assert not list((out / "package").glob("*.zip"))
51
+ assert (out / "user-notes.txt").read_text() == "preserve me"
52
+ owned = out / "normalized/page/page.json"
53
+ owned.write_text("edited by human")
54
+ before = tree(out)
55
+ with pytest.raises(CoxError) as exc:
56
+ compile_project(project, out, ["all"])
57
+ assert exc.value.issues[0].code == "OUTPUT_MODIFIED"
58
+ assert tree(out) == before
59
+
60
+
61
+ def test_unowned_collision_and_output_boundaries(project, tmp_path):
62
+ out = tmp_path / "collision"
63
+ out.mkdir()
64
+ (out / "manifest.compiled.json").write_text("keep")
65
+ with pytest.raises(CoxError):
66
+ compile_project(project, out, ["all"])
67
+ assert tree(out) == {"manifest.compiled.json": b"keep"}
68
+ for path in [project, project.parent, project / "page"]:
69
+ with pytest.raises(CoxError):
70
+ compile_project(project, path, ["json"])
71
+ (tmp_path / "link").symlink_to(out, target_is_directory=True)
72
+ with pytest.raises(CoxError):
73
+ compile_project(project, tmp_path / "link", ["json"])
74
+
75
+
76
+ @pytest.mark.parametrize(
77
+ "raw", ["../victim", "/tmp/victim", "./victim", "a//b", "C:/victim"]
78
+ )
79
+ def test_invalid_ownership_marker(project, tmp_path, raw):
80
+ out = tmp_path / "out"
81
+ out.mkdir()
82
+ (out / MARKER).write_text(
83
+ json.dumps({"format": "cox-build/1", "files": {raw: "0" * 64}})
84
+ )
85
+ with pytest.raises(CoxError):
86
+ compile_project(project, out, ["json"])
87
+
88
+
89
+ def test_publish_rolls_back_io_failure(project, tmp_path, monkeypatch):
90
+ import cox_protocol.compiler as compiler
91
+
92
+ out = tmp_path / "out"
93
+ compile_project(project, out, ["json"])
94
+ before = tree(out)
95
+ change(project, "page/page.cox", lambda d: d["Layout"].update(Desktop="Changed"))
96
+ original = compiler.os.replace
97
+ calls = 0
98
+
99
+ def replace(source, dest):
100
+ nonlocal calls
101
+ calls += 1
102
+ if calls == 2:
103
+ raise OSError("simulated full disk")
104
+ return original(source, dest)
105
+
106
+ monkeypatch.setattr(compiler.os, "replace", replace)
107
+ with pytest.raises(CoxError):
108
+ compile_project(project, out, ["all"])
109
+ assert tree(out) == before
110
+
111
+
112
+ def test_stale_files_not_packaged(project, tmp_path):
113
+ out = project / "build"
114
+ compile_project(project, out, ["all"])
115
+ (out / "stale.txt").write_text("never ship")
116
+ compile_project(project, out, ["package"])
117
+ with zipfile.ZipFile(next((out / "package").glob("*.zip"))) as z:
118
+ assert not any("stale.txt" in n or "/react/" in n for n in z.namelist())
119
+
120
+
121
+ def test_package_excludes_env_and_explicit_transient(project, tmp_path):
122
+ assets = project / "assets"
123
+ assets.mkdir()
124
+ (assets / "readme").write_text("asset")
125
+ (assets / ".env").write_text("not an input")
126
+ change(project, "manifest.cox", lambda d: d.update(ToolingFiles=["assets"]))
127
+ compile_project(project, tmp_path / "out", ["package"])
128
+ with zipfile.ZipFile(next((tmp_path / "out/package").glob("*.zip"))) as z:
129
+ assert not any(n.endswith(".env") for n in z.namelist())
130
+ change(project, "manifest.cox", lambda d: d.update(ToolingFiles=["assets/.env"]))
131
+ with pytest.raises(CoxError):
132
+ compile_project(project, tmp_path / "bad", ["package"])
133
+
134
+
135
+ @pytest.mark.parametrize(
136
+ "filename", ["invalid:name.txt", "back\\slash.txt", "CON.txt", "trailing."]
137
+ )
138
+ def test_package_rejects_nonportable_files_in_referenced_directory(
139
+ project, tmp_path, filename
140
+ ):
141
+ assets = project / "assets"
142
+ assets.mkdir()
143
+ (assets / filename).write_text("not portable")
144
+ change(project, "manifest.cox", lambda d: d.update(ToolingFiles=["assets"]))
145
+ out = tmp_path / "out"
146
+ with pytest.raises(CoxError) as exc:
147
+ compile_project(project, out, ["package"])
148
+ assert exc.value.issues[0].code == "PACKAGE_PATH"
149
+ assert not out.exists()
150
+
151
+
152
+ @pytest.mark.parametrize(
153
+ "mutation",
154
+ [
155
+ "tamper",
156
+ "extra",
157
+ "duplicate",
158
+ "traversal",
159
+ "manifest",
160
+ "inventory_symlink",
161
+ "payload_symlink",
162
+ ],
163
+ )
164
+ def test_archive_tampering_is_rejected(project, tmp_path, mutation):
165
+ compile_project(project, tmp_path / "out", ["all"])
166
+ original = next((tmp_path / "out/package").glob("*.zip"))
167
+ bad = tmp_path / "bad.zip"
168
+ with zipfile.ZipFile(original) as source, zipfile.ZipFile(bad, "w") as dest:
169
+ for index, info in enumerate(source.infolist()):
170
+ data = source.read(info.filename)
171
+ if mutation == "tamper" and index == 0:
172
+ data += b"tampered"
173
+ if mutation == "manifest" and info.filename.endswith(
174
+ "/package.manifest.json"
175
+ ):
176
+ data = b"{}"
177
+ if (
178
+ mutation == "inventory_symlink"
179
+ and info.filename.endswith("/package.manifest.json")
180
+ ) or (mutation == "payload_symlink" and index == 0):
181
+ info.external_attr = (stat.S_IFLNK | 0o644) << 16
182
+ dest.writestr(info, data)
183
+ if mutation == "extra":
184
+ zip_write_bytes(dest, "cox_example/extra", b"extra")
185
+ if mutation == "traversal":
186
+ zip_write_bytes(dest, "cox_example/../outside", b"outside")
187
+ if mutation == "duplicate":
188
+ with pytest.warns(UserWarning):
189
+ dest.writestr(source.infolist()[0], b"duplicate")
190
+ with pytest.raises(CoxError):
191
+ verify_archive(bad)
192
+
193
+
194
+ def test_react_adversarial_prose_and_numeric_slug(project, tmp_path):
195
+ change(
196
+ project,
197
+ "manifest.cox",
198
+ lambda d: d["Pages"][0]["Components"][0].update(Slug="123_button"),
199
+ )
200
+ change(
201
+ project,
202
+ "page/page.cox",
203
+ lambda d: d["Children"]["Components"][0].update(Slug="123_button"),
204
+ )
205
+ change(
206
+ project,
207
+ "page/action_button/action_button.cox",
208
+ lambda d: d.update(Slug="123_button", Role='a "quote" </section> */\nalert(1)'),
209
+ )
210
+ out = tmp_path / "out"
211
+ compile_project(project, out, ["react"])
212
+ result = (out / "react/components/Cox123Button/index.tsx").read_text()
213
+ assert "export function Cox123Button" in result
214
+ assert 'aria-label={"a \\"quote\\"' in result
215
+ assert "* /" in result
216
+
217
+
218
+ def test_react_collision_is_actionable(project, tmp_path):
219
+ component = project / "page/action_button"
220
+ shutil.copytree(component, project / "page/other")
221
+ change(
222
+ project, "page/other/action_button.cox", lambda d: d.update(Slug="example_page")
223
+ )
224
+ change(
225
+ project,
226
+ "manifest.cox",
227
+ lambda d: d["Pages"][0]["Components"].append(
228
+ {
229
+ "Slug": "example_page",
230
+ "Folder": "page/other",
231
+ "ComponentInstruction": "page/other/action_button.cox",
232
+ "ComponentScreenshotPlaceholder": "page/other/reference.svg",
233
+ }
234
+ ),
235
+ )
236
+ change(
237
+ project,
238
+ "page/page.cox",
239
+ lambda d: d["Children"]["Components"].append(
240
+ {"Slug": "example_page", "Folder": "other", "Role": "Other"}
241
+ ),
242
+ )
243
+ with pytest.raises(CoxError) as exc:
244
+ compile_project(project, tmp_path / "out", ["react"])
245
+ assert exc.value.issues[0].code == "REACT_COLLISION"
246
+
247
+
248
+ def test_modified_marker_or_nested_output_symlink_preserves_files(project, tmp_path):
249
+ out = tmp_path / "out"
250
+ compile_project(project, out, ["json"])
251
+ other = tmp_path / "elsewhere"
252
+ other.mkdir()
253
+ shutil.rmtree(out / "normalized")
254
+ (out / "normalized").symlink_to(other, target_is_directory=True)
255
+ with pytest.raises(CoxError):
256
+ compile_project(project, out, ["json"])
257
+ assert not list(other.iterdir())
258
+
259
+
260
+ def test_unknown_target_does_not_create_outputs(project, tmp_path):
261
+ out = tmp_path / "out"
262
+ with pytest.raises(CoxError) as exc:
263
+ compile_project(project, out, ["all,unknown"])
264
+ assert exc.value.issues[0].code == "TARGET_UNKNOWN"
265
+ assert not out.exists()
266
+
267
+
268
+ def test_output_parent_alias_is_canonicalized_before_overlap_check(project, tmp_path):
269
+ actual = tmp_path / "actual"
270
+ actual.mkdir()
271
+ alias = tmp_path / "alias"
272
+ alias.symlink_to(actual, target_is_directory=True)
273
+ result = compile_project(project, alias / "output", ["json"])
274
+ assert result["build_root"] == actual / "output"
275
+ assert (actual / "output/manifest.compiled.json").exists()
276
+ project_alias = tmp_path / "project-alias"
277
+ project_alias.symlink_to(project, target_is_directory=True)
278
+ with pytest.raises(CoxError):
279
+ compile_project(project, project_alias / "page", ["json"])