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,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)
|
package/scripts/demo.py
ADDED
|
@@ -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"])
|