motionloom 2.0.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/LICENSE +21 -0
- package/README.md +179 -0
- package/SKILL.md +122 -0
- package/agent-card.json +161 -0
- package/assets/library/ATTRIBUTION.md +6 -0
- package/assets/library/README.md +20 -0
- package/assets/library/avatar-base.svg +19 -0
- package/assets/library/error-alert.json +1 -0
- package/assets/library/rive/ATTRIBUTION.md +12 -0
- package/assets/library/rive/state-machine-test.riv +0 -0
- package/assets/library/success-check.json +1 -0
- package/bin/motionloom.mjs +81 -0
- package/docs/BROWSER-REVIEW-E2E.md +78 -0
- package/docs/CATEGORIES.md +16 -0
- package/docs/CHECKLIST.md +31 -0
- package/docs/DEEP-AUDIT-WORKING-NOTES.md +22 -0
- package/docs/FRAMEWORK-SELECTION.md +26 -0
- package/docs/PROJECT-MANIFEST.md +37 -0
- package/docs/ROADMAP-INTELLIGENCE.md +224 -0
- package/docs/audits/1.10.0-attestation-research-notes.md +19 -0
- package/docs/audits/1.8.0-trust-boundary-hardening.md +56 -0
- package/docs/audits/1.9.0-evidence-interoperability-threat-model.md +37 -0
- package/docs/audits/2.0.0-attestation-acceptance.md +30 -0
- package/docs/releases/1.5.0.md +25 -0
- package/docs/releases/1.6.0.md +27 -0
- package/docs/releases/1.7.0.md +23 -0
- package/docs/releases/1.8.0.md +23 -0
- package/docs/releases/1.9.0.md +21 -0
- package/docs/releases/2.0.0.md +21 -0
- package/docs/releases/npm-publish-from-workstation.md +88 -0
- package/docs/research/AGENT-PROTOCOL-FINDINGS.md +43 -0
- package/examples/report-demo/REPORT.md +50 -0
- package/examples/report-demo/artifact-manifest.json +25 -0
- package/examples/report-demo/decision-log.jsonl +0 -0
- package/examples/report-demo/execution-report.json +70 -0
- package/examples/report-demo/handoff.json +22 -0
- package/examples/report-demo/issue-register.json +5 -0
- package/examples/report-demo/task.json +13 -0
- package/package.json +95 -0
- package/project-context.example.json +26 -0
- package/references/browser-review-contract.md +32 -0
- package/references/dotlottie-source-notes.md +21 -0
- package/references/intelligence-core.md +98 -0
- package/references/reporting-contract.md +38 -0
- package/references/runtime-capability.md +12 -0
- package/references/signed-attestation.md +31 -0
- package/schemas/artifact-manifest.schema.json +22 -0
- package/schemas/browser-review-candidate.schema.json +24 -0
- package/schemas/capability-registry.schema.json +61 -0
- package/schemas/continuity-report.schema.json +52 -0
- package/schemas/evidence-verifier-report.schema.json +35 -0
- package/schemas/execution-report.schema.json +35 -0
- package/schemas/fix-plan.schema.json +44 -0
- package/schemas/handoff.schema.json +19 -0
- package/schemas/motion-ir.schema.json +74 -0
- package/schemas/project-graph.schema.json +70 -0
- package/schemas/provenance.schema.json +76 -0
- package/schemas/runtime-evidence.schema.json +48 -0
- package/schemas/runtime-telemetry.schema.json +50 -0
- package/schemas/scene-manifest.schema.json +45 -0
- package/schemas/semantic-benchmark.schema.json +26 -0
- package/schemas/semantic-lint-report.schema.json +48 -0
- package/schemas/signed-attestation.schema.json +95 -0
- package/schemas/task.schema.json +39 -0
- package/schemas/trust-policy.schema.json +53 -0
- package/scripts/analyze.sh +13 -0
- package/scripts/attestation-keygen.py +63 -0
- package/scripts/attestation-verifier.py +178 -0
- package/scripts/attestation.py +288 -0
- package/scripts/capture-runtime-telemetry.sh +37 -0
- package/scripts/devlab.sh +77 -0
- package/scripts/eval-intelligence.py +377 -0
- package/scripts/evidence-verifier.py +222 -0
- package/scripts/fetch-library.sh +57 -0
- package/scripts/intelligence.py +1543 -0
- package/scripts/manifest.py +61 -0
- package/scripts/pr.sh +103 -0
- package/scripts/quality-gate.py +378 -0
- package/scripts/render-node.mjs +53 -0
- package/scripts/render.sh +37 -0
- package/scripts/report-contract.py +181 -0
- package/scripts/report.py +588 -0
- package/scripts/review-hook.py +199 -0
- package/scripts/runtime-adapters.mjs +187 -0
- package/scripts/skill-doctor.py +150 -0
- package/scripts/to-dotlottie.mjs +99 -0
- package/scripts/to-dotlottie.sh +25 -0
- package/scripts/validate-lottie.py +102 -0
- package/src/core/analyzer.py +226 -0
- package/src/core/snapshot.py +124 -0
- package/src/core/spec.py +240 -0
- package/src/output/browser-review-smoke/animation.json +57 -0
- package/src/output/browser-review-smoke/browser-review.json +21 -0
- package/src/output/browser-review-smoke/manifest.json +22 -0
- package/src/output/browser-review-smoke/motion-spec.json +28 -0
- package/src/output/browser-review-smoke/snapshot/.render-meta.json +10 -0
- package/src/output/browser-review-smoke/snapshot/frame-00.png +0 -0
- package/src/output/browser-review-smoke/snapshot/frame-100.png +0 -0
- package/src/output/browser-review-smoke/snapshot/frame-50.png +0 -0
- package/src/rig/README.md +35 -0
- package/src/rig/cutout_rig.py +211 -0
- package/templates/framer-motion/ui-micro.tsx +50 -0
- package/templates/gsap/scroll-scene.js +54 -0
- package/templates/lottie/README.md +21 -0
- package/templates/lottie/react-component.tsx +82 -0
- package/templates/lottie/scaffold/animation.json +57 -0
- package/templates/lottie/scaffold/character-rig.svg +19 -0
- package/templates/lottie/vanilla.js +68 -0
- package/templates/rive/README.md +36 -0
- package/tests/evals/intelligence-cases.json +131 -0
- package/tests/scripts/run_tests.py +843 -0
- package/tests/scripts/test_attestation.py +172 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create or refresh the required source_binding in a scene manifest."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
KINDS = {"project", "library", "generated", "fixture", "remote"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
parser = argparse.ArgumentParser()
|
|
18
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
bind = sub.add_parser("bind-source")
|
|
20
|
+
bind.add_argument("--scene", required=True)
|
|
21
|
+
bind.add_argument("--source", required=True)
|
|
22
|
+
bind.add_argument("--kind", required=True, choices=sorted(KINDS))
|
|
23
|
+
bind.add_argument("--authority", required=True)
|
|
24
|
+
bind.add_argument("--license", required=True)
|
|
25
|
+
bind.add_argument("--source-url")
|
|
26
|
+
bind.add_argument("--attribution", default="")
|
|
27
|
+
bind.add_argument("--manifest", default="manifest.json")
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
scene_dir = (ROOT / "src" / "output" / args.scene).resolve()
|
|
31
|
+
manifest_path = scene_dir / args.manifest
|
|
32
|
+
source_path = (scene_dir / args.source).resolve()
|
|
33
|
+
if not scene_dir.is_dir():
|
|
34
|
+
raise SystemExit(f"scene directory not found: {scene_dir}")
|
|
35
|
+
if not manifest_path.is_file():
|
|
36
|
+
raise SystemExit(f"manifest not found: {manifest_path}")
|
|
37
|
+
if not source_path.is_file() or scene_dir not in source_path.parents:
|
|
38
|
+
raise SystemExit("source must be an existing file inside the scene directory")
|
|
39
|
+
|
|
40
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
41
|
+
relative_source = source_path.relative_to(scene_dir).as_posix()
|
|
42
|
+
manifest["file"] = relative_source
|
|
43
|
+
binding = {
|
|
44
|
+
"kind": args.kind,
|
|
45
|
+
"source_path": relative_source,
|
|
46
|
+
"authority": args.authority,
|
|
47
|
+
"license": args.license,
|
|
48
|
+
"sha256": hashlib.sha256(source_path.read_bytes()).hexdigest(),
|
|
49
|
+
}
|
|
50
|
+
if args.source_url:
|
|
51
|
+
binding["source_url"] = args.source_url
|
|
52
|
+
if args.attribution:
|
|
53
|
+
binding["attribution"] = args.attribution
|
|
54
|
+
manifest["source_binding"] = binding
|
|
55
|
+
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
56
|
+
print(json.dumps({"manifest": str(manifest_path), "source_binding": binding}, indent=2, ensure_ascii=False))
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
raise SystemExit(main())
|
package/scripts/pr.sh
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pr.sh — Step 6 of the pipeline: confirm-into-PR.
|
|
3
|
+
# Commits the rendered scene (animation file, manifest, snapshots, signed
|
|
4
|
+
# spec) to a fix/<scene> branch and opens the pull request via the gh CLI.
|
|
5
|
+
#
|
|
6
|
+
# Usage: bash scripts/pr.sh <scene> [title]
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
REPO="$(dirname "$SCRIPT_DIR")"
|
|
10
|
+
SCENE="${1:?usage: bash scripts/pr.sh <scene> [title]}"
|
|
11
|
+
TITLE="${2:-"animation: scene '$SCENE' (verified in Dev Lab)"}"
|
|
12
|
+
SCENE_DIR="$REPO/src/output/$SCENE"
|
|
13
|
+
CONTEXT_PATH="${CONTEXT_PATH:-$REPO/project-context.json}"
|
|
14
|
+
TASK_DIR="${TASK_DIR:-}"
|
|
15
|
+
|
|
16
|
+
cd "$REPO"
|
|
17
|
+
|
|
18
|
+
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
19
|
+
echo "error: scripts/pr.sh must run inside a Git clone" >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
if [ ! -d "$SCENE_DIR" ]; then
|
|
24
|
+
echo "error: scene directory not found: $SCENE_DIR"
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if [[ ! "$SCENE" =~ ^[A-Za-z0-9._-]+$ || "$SCENE" == "." || "$SCENE" == ".." ]]; then
|
|
29
|
+
echo "error: scene id contains unsafe branch/path characters: $SCENE" >&2
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
echo "== running context-bound quality gate =="
|
|
34
|
+
QUALITY_ARGS=(--scene "$SCENE" --context "$CONTEXT_PATH" --require-browser-review)
|
|
35
|
+
if [ -n "$TASK_DIR" ]; then
|
|
36
|
+
QUALITY_ARGS+=(--task-dir "$TASK_DIR")
|
|
37
|
+
else
|
|
38
|
+
echo "error: TASK_DIR is required; browser Agent must persist review.json before PR" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
TASK_DIR_ABS="$(cd "$TASK_DIR" && pwd -P)"
|
|
43
|
+
case "$TASK_DIR_ABS" in
|
|
44
|
+
"$REPO"/*) ;;
|
|
45
|
+
*) echo "error: TASK_DIR must be inside the repository so evidence can be staged" >&2; exit 1 ;;
|
|
46
|
+
esac
|
|
47
|
+
TASK_SCENE="$(python3 - "$TASK_DIR_ABS/task.json" <<'PY'
|
|
48
|
+
import json, sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
|
51
|
+
print(data.get("scene", ""))
|
|
52
|
+
PY
|
|
53
|
+
)"
|
|
54
|
+
if [ "$TASK_SCENE" != "$SCENE" ]; then
|
|
55
|
+
echo "error: TASK_DIR task.scene does not match requested scene" >&2
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
python3 "$REPO/scripts/quality-gate.py" "${QUALITY_ARGS[@]}"
|
|
59
|
+
python3 "$REPO/scripts/review-hook.py" validate --task-dir "$TASK_DIR" --require-approved
|
|
60
|
+
python3 "$REPO/scripts/report.py" check --task-dir "$TASK_DIR"
|
|
61
|
+
|
|
62
|
+
BRANCH="fix/$SCENE"
|
|
63
|
+
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
|
|
64
|
+
git add "src/output/$SCENE"
|
|
65
|
+
TASK_REL="${TASK_DIR_ABS#"$REPO/"}"
|
|
66
|
+
git add "$TASK_REL"
|
|
67
|
+
if git diff --cached --quiet; then
|
|
68
|
+
echo "error: no staged scene changes to commit" >&2
|
|
69
|
+
exit 1
|
|
70
|
+
fi
|
|
71
|
+
git commit -m "feat(animation): scene '$SCENE' — proven in Dev Lab
|
|
72
|
+
|
|
73
|
+
- motion-spec signed (see src/output/$SCENE/motion-spec.json)
|
|
74
|
+
- snapshot frames: 0/50/100% in src/output/$SCENE/snapshot/
|
|
75
|
+
- context-bound quality gate: passed
|
|
76
|
+
- brand tokens bound from $CONTEXT_PATH"
|
|
77
|
+
|
|
78
|
+
if [ "${OPEN_PR:-0}" != "1" ]; then
|
|
79
|
+
echo "== committed to $BRANCH — OPEN_PR=0, push/open PR manually =="
|
|
80
|
+
exit 0
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
if ! command -v gh &>/dev/null; then
|
|
84
|
+
echo "== committed to $BRANCH — install gh CLI to open the PR =="
|
|
85
|
+
echo " git push origin $BRANCH"
|
|
86
|
+
exit 0
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
git push -u origin "$BRANCH" 2>/dev/null
|
|
90
|
+
gh pr create \
|
|
91
|
+
--title "$TITLE" \
|
|
92
|
+
--body "## Scene: $SCENE
|
|
93
|
+
|
|
94
|
+
Verified in the Dev Lab (checklist + snapshot diffs attached).
|
|
95
|
+
Framework, duration, easing, reduced-motion policy and theme tokens per the signed motion spec.
|
|
96
|
+
|
|
97
|
+
### Snapshots
|
|
98
|
+
| 0% | 50% | 100% |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| \`snapshot/frame-00.png\` | \`snapshot/frame-50.png\` | \`snapshot/frame-100.png\` |
|
|
101
|
+
|
|
102
|
+
Ready to review — comment fixes in the Dev Lab or approve to merge." \
|
|
103
|
+
&& echo "== PR opened for scene: $SCENE =="
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Acceptance gate for a scene in src/output.
|
|
3
|
+
|
|
4
|
+
This gate is intentionally stricter than the exploratory Dev Lab: it requires
|
|
5
|
+
one project context, a JSON motion spec bound to that context, a manifest,
|
|
6
|
+
runtime snapshots (not placeholders), and a completed checklist.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import hashlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
SAFE_SCENE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
20
|
+
sys.path.insert(0, str(ROOT / "scripts"))
|
|
21
|
+
from intelligence import validate_task_benchmark, validate_task_intelligence, validate_task_p1 # noqa: E402
|
|
22
|
+
sys.path.insert(0, str(ROOT / "src"))
|
|
23
|
+
from core.spec import validate_spec # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_validator():
|
|
27
|
+
path = ROOT / "scripts" / "validate-lottie.py"
|
|
28
|
+
loader = importlib.util.spec_from_file_location("validate_lottie", path)
|
|
29
|
+
module = importlib.util.module_from_spec(loader)
|
|
30
|
+
loader.loader.exec_module(module)
|
|
31
|
+
return module
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_evidence_verifier():
|
|
35
|
+
path = ROOT / "scripts" / "evidence-verifier.py"
|
|
36
|
+
loader = importlib.util.spec_from_file_location("evidence_verifier", path)
|
|
37
|
+
module = importlib.util.module_from_spec(loader)
|
|
38
|
+
loader.loader.exec_module(module)
|
|
39
|
+
return module
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_attestation_verifier():
|
|
43
|
+
path = ROOT / "scripts" / "attestation-verifier.py"
|
|
44
|
+
loader = importlib.util.spec_from_file_location("attestation_verifier", path)
|
|
45
|
+
module = importlib.util.module_from_spec(loader)
|
|
46
|
+
loader.loader.exec_module(module)
|
|
47
|
+
return module
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _json(path: Path):
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
53
|
+
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
|
54
|
+
raise ValueError(f"{path}: {exc}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sha256_file(path: Path) -> str:
|
|
58
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _telemetry_bundle_sha256(task_dir: Path) -> str:
|
|
62
|
+
entries = []
|
|
63
|
+
for path in sorted(task_dir.glob("runtime-adapters/**/runtime-telemetry.json")):
|
|
64
|
+
if path.is_file() and task_dir.resolve() in path.resolve().parents:
|
|
65
|
+
entries.append({"path": path.resolve().relative_to(task_dir.resolve()).as_posix(), "sha256": _sha256_file(path)})
|
|
66
|
+
return hashlib.sha256(json.dumps(entries, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_scene(scene_dir: Path, context_path: Path, require_review: bool = False, task_dir: Path | None = None, require_intelligence: bool = False, require_p1: bool = False, require_benchmark: bool = False, require_telemetry: bool = False, require_attestation: bool = False, attestation_path: Path | None = None, trust_policy_path: Path | None = None) -> list[str]:
|
|
70
|
+
issues = []
|
|
71
|
+
manifest_path = scene_dir / "manifest.json"
|
|
72
|
+
spec_path = scene_dir / "motion-spec.json"
|
|
73
|
+
if not manifest_path.exists():
|
|
74
|
+
issues.append("missing manifest.json")
|
|
75
|
+
return issues
|
|
76
|
+
if not spec_path.exists():
|
|
77
|
+
issues.append("missing motion-spec.json; markdown specs cannot prove context binding")
|
|
78
|
+
return issues
|
|
79
|
+
if not context_path.exists():
|
|
80
|
+
issues.append(f"missing project context: {context_path}")
|
|
81
|
+
return issues
|
|
82
|
+
try:
|
|
83
|
+
manifest = _json(manifest_path)
|
|
84
|
+
spec = _json(spec_path)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
return [str(exc)]
|
|
87
|
+
|
|
88
|
+
issues.extend(validate_spec(str(spec_path), str(context_path)))
|
|
89
|
+
context = _json(context_path)
|
|
90
|
+
if spec.get("context_binding", {}).get("name") != context.get("name"):
|
|
91
|
+
issues.append("spec context name does not match project-context.json")
|
|
92
|
+
if spec.get("theme", {}).get("primary") != (context.get("brand") or {}).get("primary"):
|
|
93
|
+
issues.append("spec primary color does not match project-context.json")
|
|
94
|
+
if manifest.get("framework") and manifest["framework"] != spec.get("framework"):
|
|
95
|
+
issues.append("manifest.framework does not match motion-spec.framework")
|
|
96
|
+
if manifest.get("category") and manifest["category"] != spec.get("category"):
|
|
97
|
+
issues.append("manifest.category does not match motion-spec.category")
|
|
98
|
+
if not spec.get("source_binding"):
|
|
99
|
+
issues.append("motion spec has no source_binding")
|
|
100
|
+
if not (spec.get("accessibility") or {}).get("reduced_motion"):
|
|
101
|
+
issues.append("motion spec has no reduced-motion policy")
|
|
102
|
+
|
|
103
|
+
checks = manifest.get("checks")
|
|
104
|
+
if not isinstance(checks, list) or not checks:
|
|
105
|
+
issues.append("manifest.checks must contain the Dev Lab quality checklist")
|
|
106
|
+
else:
|
|
107
|
+
failed = [c.get("id", "unnamed") for c in checks if c.get("pass") is not True]
|
|
108
|
+
if failed:
|
|
109
|
+
issues.append(f"Dev Lab checklist has failing/unconfirmed checks: {', '.join(failed)}")
|
|
110
|
+
|
|
111
|
+
snapshot_dir = scene_dir / "snapshot"
|
|
112
|
+
required = [snapshot_dir / f"frame-{p:02d}.png" for p in (0, 50, 100)]
|
|
113
|
+
for frame in required:
|
|
114
|
+
if not frame.is_file() or frame.stat().st_size == 0:
|
|
115
|
+
issues.append(f"missing snapshot frame: {frame.name}")
|
|
116
|
+
meta_path = snapshot_dir / ".render-meta.json"
|
|
117
|
+
if not meta_path.exists():
|
|
118
|
+
issues.append("missing snapshot/.render-meta.json")
|
|
119
|
+
else:
|
|
120
|
+
try:
|
|
121
|
+
meta = _json(meta_path)
|
|
122
|
+
if meta.get("mode") != "runtime":
|
|
123
|
+
issues.append("snapshot evidence is not runtime-rendered (placeholder is rejected)")
|
|
124
|
+
if meta.get("scene") != scene_dir.name:
|
|
125
|
+
issues.append("snapshot metadata scene mismatch")
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
issues.append(str(exc))
|
|
128
|
+
|
|
129
|
+
source_name = manifest.get("file")
|
|
130
|
+
source_path_for_evidence = (scene_dir / source_name).resolve() if source_name else scene_dir / "__missing-source__"
|
|
131
|
+
source_sha_for_evidence = ""
|
|
132
|
+
if source_path_for_evidence.is_file() and scene_dir.resolve() in source_path_for_evidence.parents:
|
|
133
|
+
source_sha_for_evidence = hashlib.sha256(source_path_for_evidence.read_bytes()).hexdigest()
|
|
134
|
+
manifest_sha_for_evidence = _sha256_file(manifest_path)
|
|
135
|
+
|
|
136
|
+
if spec.get("framework") in {"rive", "gsap", "framer-motion"}:
|
|
137
|
+
evidence_name = manifest.get("runtime_evidence")
|
|
138
|
+
if not evidence_name:
|
|
139
|
+
issues.append(f"manifest.runtime_evidence is required for {spec.get('framework')} scenes")
|
|
140
|
+
else:
|
|
141
|
+
evidence_path = (scene_dir / evidence_name).resolve()
|
|
142
|
+
if not evidence_path.is_file() or scene_dir.resolve() not in evidence_path.parents:
|
|
143
|
+
issues.append("manifest.runtime_evidence must point to an existing file inside the scene directory")
|
|
144
|
+
else:
|
|
145
|
+
try:
|
|
146
|
+
evidence = _json(evidence_path)
|
|
147
|
+
if evidence.get("mode") != "runtime":
|
|
148
|
+
issues.append("runtime evidence mode must be runtime")
|
|
149
|
+
if not evidence.get("run_id"):
|
|
150
|
+
issues.append("runtime evidence run_id is required")
|
|
151
|
+
if evidence.get("status") != "pass":
|
|
152
|
+
issues.append("runtime evidence top-level status must be pass")
|
|
153
|
+
if evidence.get("framework") and evidence.get("framework") != spec.get("framework"):
|
|
154
|
+
issues.append("runtime evidence framework does not match motion-spec.framework")
|
|
155
|
+
if evidence.get("scene") != scene_dir.name:
|
|
156
|
+
issues.append("runtime evidence scene does not match scene directory")
|
|
157
|
+
if evidence.get("source_sha256") != source_sha_for_evidence:
|
|
158
|
+
issues.append("runtime evidence source_sha256 does not match manifest.file")
|
|
159
|
+
if evidence.get("manifest_sha256") != manifest_sha_for_evidence:
|
|
160
|
+
issues.append("runtime evidence manifest_sha256 does not match manifest.json")
|
|
161
|
+
if evidence.get("frameworks"):
|
|
162
|
+
match = next((item for item in evidence["frameworks"] if item.get("framework") == spec.get("framework")), None)
|
|
163
|
+
if not match or match.get("status") != "pass" or match.get("ready") is not True:
|
|
164
|
+
issues.append("runtime evidence does not contain a passing adapter result")
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
issues.append(str(exc))
|
|
167
|
+
|
|
168
|
+
source_binding = manifest.get("source_binding")
|
|
169
|
+
if not isinstance(source_binding, dict):
|
|
170
|
+
issues.append("manifest.source_binding is required for production scenes")
|
|
171
|
+
else:
|
|
172
|
+
for field in ("kind", "source_path", "authority", "license", "sha256"):
|
|
173
|
+
if not str(source_binding.get(field, "")).strip():
|
|
174
|
+
issues.append(f"manifest.source_binding.{field} is required")
|
|
175
|
+
if source_binding.get("kind") not in {"project", "library", "generated", "fixture", "remote"}:
|
|
176
|
+
issues.append("manifest.source_binding.kind is invalid")
|
|
177
|
+
|
|
178
|
+
source = manifest.get("file")
|
|
179
|
+
if not source:
|
|
180
|
+
issues.append("manifest.file is required")
|
|
181
|
+
else:
|
|
182
|
+
source_path = (scene_dir / source).resolve()
|
|
183
|
+
if not source_path.is_file() or scene_dir.resolve() not in source_path.parents:
|
|
184
|
+
issues.append("manifest.file must point to an existing file inside the scene directory")
|
|
185
|
+
else:
|
|
186
|
+
if isinstance(source_binding, dict):
|
|
187
|
+
if source_binding.get("source_path") != source:
|
|
188
|
+
issues.append("manifest.source_binding.source_path must match manifest.file")
|
|
189
|
+
source_sha = hashlib.sha256(source_path.read_bytes()).hexdigest()
|
|
190
|
+
if source_binding.get("sha256") != source_sha:
|
|
191
|
+
issues.append("manifest.source_binding.sha256 does not match manifest.file")
|
|
192
|
+
if spec.get("framework") in ("lottie", "dotlottie") and source_path.suffix in (".json", ".lottie"):
|
|
193
|
+
validator = _load_validator()
|
|
194
|
+
issues.extend(validator.validate(source_path, spec, int(spec.get("performance", {}).get("max_layers", 80))))
|
|
195
|
+
|
|
196
|
+
candidate_path = (task_dir / "browser-review.json") if task_dir else (scene_dir / "browser-review.json")
|
|
197
|
+
if not candidate_path.is_file():
|
|
198
|
+
issues.append("missing browser-review.json; runtime render must hand off to the internal browser Agent")
|
|
199
|
+
else:
|
|
200
|
+
try:
|
|
201
|
+
candidate = _json(candidate_path)
|
|
202
|
+
source_sha = hashlib.sha256(source_path.read_bytes()).hexdigest()
|
|
203
|
+
context_sha = hashlib.sha256(context_path.read_bytes()).hexdigest()
|
|
204
|
+
if candidate.get("scene") != scene_dir.name:
|
|
205
|
+
issues.append("browser review candidate scene mismatch")
|
|
206
|
+
if task_dir:
|
|
207
|
+
task = _json(task_dir / "task.json")
|
|
208
|
+
if candidate.get("task_id") != task.get("task_id"):
|
|
209
|
+
issues.append("browser review candidate task_id mismatch")
|
|
210
|
+
if candidate.get("source_sha256") != source_sha:
|
|
211
|
+
issues.append("browser review candidate source_sha256 is stale")
|
|
212
|
+
if candidate.get("context_sha256") not in {context_sha, spec.get("context_binding", {}).get("context_sha256")}:
|
|
213
|
+
issues.append("browser review candidate context_sha256 is stale")
|
|
214
|
+
if candidate.get("status") not in {"prepared", "opened", "reviewed", "approved"}:
|
|
215
|
+
issues.append(f"browser review candidate status is not reviewable: {candidate.get('status')}")
|
|
216
|
+
expires_at = candidate.get("expires_at")
|
|
217
|
+
if not expires_at:
|
|
218
|
+
issues.append("browser review candidate expiry is required")
|
|
219
|
+
else:
|
|
220
|
+
try:
|
|
221
|
+
if datetime.now(timezone.utc) > datetime.fromisoformat(expires_at.replace("Z", "+00:00")):
|
|
222
|
+
issues.append("browser review candidate has expired")
|
|
223
|
+
except (TypeError, ValueError):
|
|
224
|
+
issues.append("browser review candidate expiry is invalid")
|
|
225
|
+
if require_review:
|
|
226
|
+
review_path = (task_dir / "review.json") if task_dir else (scene_dir / "review.json")
|
|
227
|
+
if not review_path.is_file():
|
|
228
|
+
issues.append("PR gate requires review.json captured by the browser Agent")
|
|
229
|
+
else:
|
|
230
|
+
review = _json(review_path)
|
|
231
|
+
if review.get("decision") != "approved":
|
|
232
|
+
issues.append("PR gate requires browser review decision=approved")
|
|
233
|
+
if review.get("candidate_id") != candidate.get("candidate_id"):
|
|
234
|
+
issues.append("browser review approves a different candidate")
|
|
235
|
+
if task_dir:
|
|
236
|
+
task = _json(task_dir / "task.json")
|
|
237
|
+
if review.get("task_id") != task.get("task_id"):
|
|
238
|
+
issues.append("browser review task_id mismatch")
|
|
239
|
+
if not str(review.get("reviewer") or "").strip():
|
|
240
|
+
issues.append("browser review reviewer is required")
|
|
241
|
+
reviewed_at = review.get("reviewed_at")
|
|
242
|
+
if reviewed_at:
|
|
243
|
+
try:
|
|
244
|
+
if datetime.fromisoformat(reviewed_at.replace("Z", "+00:00")) > datetime.fromisoformat(expires_at.replace("Z", "+00:00")):
|
|
245
|
+
issues.append("browser review was recorded after candidate expiry")
|
|
246
|
+
except (TypeError, ValueError):
|
|
247
|
+
issues.append("browser review reviewed_at is invalid")
|
|
248
|
+
if candidate.get("status") != "approved":
|
|
249
|
+
issues.append("PR gate requires browser-review.json status=approved")
|
|
250
|
+
except (ValueError, OSError) as exc:
|
|
251
|
+
issues.append(str(exc))
|
|
252
|
+
if require_intelligence:
|
|
253
|
+
if not task_dir:
|
|
254
|
+
issues.append("Intelligence Core gate requires --task-dir")
|
|
255
|
+
else:
|
|
256
|
+
issues.extend(validate_task_intelligence(task_dir, scene_dir.name))
|
|
257
|
+
if require_p1:
|
|
258
|
+
if not task_dir:
|
|
259
|
+
issues.append("P1 gate requires --task-dir")
|
|
260
|
+
else:
|
|
261
|
+
p1_issues = validate_task_p1(task_dir, scene_dir.name)
|
|
262
|
+
if not all((task_dir / name).is_file() for name in ("semantic-lint-report.json", "continuity-report.json", "fix-plan.json")):
|
|
263
|
+
p1_issues.append("P1 gate requires semantic-lint-report.json, continuity-report.json and fix-plan.json")
|
|
264
|
+
issues.extend(p1_issues)
|
|
265
|
+
if require_benchmark:
|
|
266
|
+
if not task_dir:
|
|
267
|
+
issues.append("benchmark gate requires --task-dir")
|
|
268
|
+
else:
|
|
269
|
+
issues.extend(validate_task_benchmark(task_dir, scene_dir.name))
|
|
270
|
+
if require_telemetry:
|
|
271
|
+
if not task_dir:
|
|
272
|
+
issues.append("telemetry gate requires --task-dir")
|
|
273
|
+
else:
|
|
274
|
+
verifier = _load_evidence_verifier()
|
|
275
|
+
verification = verifier.verify(scene_dir, task_dir, "runtime-adapters/runtime-evidence.json", None)
|
|
276
|
+
if not verification.get("verified"):
|
|
277
|
+
issues.extend(f"external evidence verifier: {issue}" for issue in verification.get("issues", []))
|
|
278
|
+
if verification.get("approval") is not False:
|
|
279
|
+
issues.append("external evidence verifier must never grant approval")
|
|
280
|
+
if require_attestation:
|
|
281
|
+
if not task_dir:
|
|
282
|
+
issues.append("attestation gate requires --task-dir")
|
|
283
|
+
else:
|
|
284
|
+
task_path = task_dir / "task.json"
|
|
285
|
+
if not task_path.is_file():
|
|
286
|
+
issues.append("attestation gate requires task.json")
|
|
287
|
+
else:
|
|
288
|
+
try:
|
|
289
|
+
task = _json(task_path)
|
|
290
|
+
resolved_attestation = attestation_path or (task_dir / "attestation.json")
|
|
291
|
+
resolved_policy = trust_policy_path or (task_dir / "trust-policy.json")
|
|
292
|
+
verifier = _load_attestation_verifier()
|
|
293
|
+
verification, exit_code = verifier.verify_attestation(
|
|
294
|
+
str(resolved_attestation),
|
|
295
|
+
str(resolved_policy),
|
|
296
|
+
str(task.get("task_id") or ""),
|
|
297
|
+
scene_dir.name,
|
|
298
|
+
)
|
|
299
|
+
if exit_code != 0 or not verification.get("verified"):
|
|
300
|
+
issues.extend(f"signed attestation verifier: {issue}" for issue in verification.get("issues", []))
|
|
301
|
+
if not verification.get("issues"):
|
|
302
|
+
issues.append(f"signed attestation verifier failed with exit code {exit_code}")
|
|
303
|
+
if verification.get("approval") is not False:
|
|
304
|
+
issues.append("signed attestation must never grant approval")
|
|
305
|
+
statement = _json(resolved_attestation).get("statement", {})
|
|
306
|
+
predicate = statement.get("predicate", {}) if isinstance(statement, dict) else {}
|
|
307
|
+
expected_motion_ir = task_dir / "motion-ir.json"
|
|
308
|
+
expected_verifier_report = task_dir / "evidence-verifier-report.json"
|
|
309
|
+
expected_telemetry_hash = _telemetry_bundle_sha256(task_dir)
|
|
310
|
+
expected_bindings = {
|
|
311
|
+
"task_id": task.get("task_id"),
|
|
312
|
+
"scene": scene_dir.name,
|
|
313
|
+
"context_hash": _sha256_file(context_path),
|
|
314
|
+
"source_sha256": source_sha_for_evidence,
|
|
315
|
+
"manifest_sha256": manifest_sha_for_evidence,
|
|
316
|
+
"motion_ir_sha256": _sha256_file(expected_motion_ir) if expected_motion_ir.is_file() else "",
|
|
317
|
+
"evidence.runtime_evidence_sha256": _sha256_file(task_dir / "runtime-adapters" / "runtime-evidence.json"),
|
|
318
|
+
"evidence.runtime_telemetry_sha256": expected_telemetry_hash,
|
|
319
|
+
"evidence.verifier_report_sha256": _sha256_file(expected_verifier_report) if expected_verifier_report.is_file() else "",
|
|
320
|
+
}
|
|
321
|
+
for field, expected in expected_bindings.items():
|
|
322
|
+
actual = predicate.get(field) if "." not in field else (predicate.get("evidence") or {}).get(field.split(".", 1)[1])
|
|
323
|
+
if expected and actual != expected:
|
|
324
|
+
issues.append(f"signed attestation {field} binding mismatch")
|
|
325
|
+
elif not expected:
|
|
326
|
+
issues.append(f"signed attestation required binding source is missing: {field}")
|
|
327
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
328
|
+
issues.append(f"signed attestation contract: {exc}")
|
|
329
|
+
return issues
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def main() -> int:
|
|
333
|
+
parser = argparse.ArgumentParser()
|
|
334
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
335
|
+
group.add_argument("--scene")
|
|
336
|
+
group.add_argument("--all", action="store_true")
|
|
337
|
+
parser.add_argument("--root", default=str(ROOT))
|
|
338
|
+
parser.add_argument("--context", default="project-context.json")
|
|
339
|
+
parser.add_argument("--task-dir")
|
|
340
|
+
parser.add_argument("--require-browser-review", action="store_true")
|
|
341
|
+
parser.add_argument("--require-intelligence", action="store_true")
|
|
342
|
+
parser.add_argument("--require-p1", action="store_true")
|
|
343
|
+
parser.add_argument("--require-benchmark", action="store_true")
|
|
344
|
+
parser.add_argument("--require-telemetry", action="store_true")
|
|
345
|
+
parser.add_argument("--require-attestation", action="store_true")
|
|
346
|
+
parser.add_argument("--attestation")
|
|
347
|
+
parser.add_argument("--trust-policy")
|
|
348
|
+
args = parser.parse_args()
|
|
349
|
+
if args.scene and (args.scene in {".", ".."} or not SAFE_SCENE.fullmatch(args.scene)):
|
|
350
|
+
print("QUALITY GATE: unsafe scene identifier")
|
|
351
|
+
return 1
|
|
352
|
+
root = Path(args.root).resolve()
|
|
353
|
+
context = Path(args.context)
|
|
354
|
+
if not context.is_absolute():
|
|
355
|
+
context = root / context
|
|
356
|
+
output_root = root / "src" / "output"
|
|
357
|
+
task_dir = Path(args.task_dir).resolve() if args.task_dir else None
|
|
358
|
+
attestation_path = Path(args.attestation).resolve() if args.attestation else None
|
|
359
|
+
trust_policy_path = Path(args.trust_policy).resolve() if args.trust_policy else None
|
|
360
|
+
scenes = [root / "src" / "output" / args.scene] if args.scene else sorted(p for p in output_root.iterdir() if p.is_dir()) if output_root.exists() else []
|
|
361
|
+
if not scenes:
|
|
362
|
+
print("QUALITY GATE: no scene outputs found")
|
|
363
|
+
return 0
|
|
364
|
+
failed = False
|
|
365
|
+
for scene_dir in scenes:
|
|
366
|
+
issues = validate_scene(scene_dir, context, args.require_browser_review, task_dir, args.require_intelligence, args.require_p1, args.require_benchmark, args.require_telemetry, args.require_attestation, attestation_path, trust_policy_path)
|
|
367
|
+
if issues:
|
|
368
|
+
failed = True
|
|
369
|
+
print(f"REJECTED {scene_dir.name}:")
|
|
370
|
+
for issue in issues:
|
|
371
|
+
print(f" - {issue}")
|
|
372
|
+
else:
|
|
373
|
+
print(f"ACCEPTED {scene_dir.name}: context + spec + runtime snapshots + browser-review candidate + checklist")
|
|
374
|
+
return 1 if failed else 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-node.mjs — Deterministic single-frame Lottie rendering in Node,
|
|
3
|
+
* using the official dotlottie runtime + @napi-rs/canvas. Reads SRC (file),
|
|
4
|
+
* FRAME_PCT (0-100) and writes a PNG to OUT. Bit-exact across machines,
|
|
5
|
+
* which makes it the source of truth for visual regression tests.
|
|
6
|
+
*
|
|
7
|
+
* Env: SRC=<animation.lottie|.json> FRAME_PCT=50 OUT=frame-50.png
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { DotLottie } from "@lottiefiles/dotlottie-web";
|
|
12
|
+
import { createCanvas } from "@napi-rs/canvas";
|
|
13
|
+
|
|
14
|
+
const src = process.env.SRC;
|
|
15
|
+
const pct = Number(process.env.FRAME_PCT || 0);
|
|
16
|
+
const out = process.env.OUT || "frame.png";
|
|
17
|
+
const SIZE = Number(process.env.RENDER_SIZE || 512);
|
|
18
|
+
|
|
19
|
+
if (!src) {
|
|
20
|
+
console.error("usage: SRC=<file> FRAME_PCT=<0-100> OUT=<png> node render-node.mjs");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const canvas = createCanvas(SIZE, SIZE);
|
|
25
|
+
const sourcePath = path.resolve(src);
|
|
26
|
+
if (!fs.existsSync(sourcePath)) {
|
|
27
|
+
console.error(`source file not found: ${sourcePath}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const bytes = fs.readFileSync(sourcePath);
|
|
31
|
+
const data = sourcePath.endsWith(".json")
|
|
32
|
+
? JSON.parse(bytes.toString("utf8"))
|
|
33
|
+
: new Uint8Array(bytes);
|
|
34
|
+
|
|
35
|
+
const dotLottie = new DotLottie({
|
|
36
|
+
canvas,
|
|
37
|
+
data,
|
|
38
|
+
autoplay: false,
|
|
39
|
+
renderConfig: { devicePixelRatio: 1 },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
dotLottie.addEventListener("load", async () => {
|
|
43
|
+
const target = Math.round((pct / 100) * (dotLottie.totalFrames - 1));
|
|
44
|
+
dotLottie.setFrame(target);
|
|
45
|
+
fs.writeFileSync(out, await canvas.encode("png"));
|
|
46
|
+
dotLottie.destroy();
|
|
47
|
+
process.exit(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
dotLottie.addEventListener("loadError", (e) => {
|
|
51
|
+
console.error("loadError:", e);
|
|
52
|
+
process.exit(2);
|
|
53
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# render.sh — Step 5 of the pipeline: render deterministic snapshot frames
|
|
3
|
+
# (0%, 50%, 100%) of a scene for visual verification and PR attachments.
|
|
4
|
+
#
|
|
5
|
+
# Usage: bash scripts/render.sh <scene>
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
REPO="$(dirname "$SCRIPT_DIR")"
|
|
9
|
+
SCENE="${1:?usage: bash scripts/render.sh <scene>}"
|
|
10
|
+
SCENE_DIR="$REPO/src/output/$SCENE"
|
|
11
|
+
|
|
12
|
+
if [[ ! "$SCENE" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
13
|
+
echo "error: scene id contains unsafe path characters: $SCENE" >&2
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
if [ ! -d "$SCENE_DIR" ]; then
|
|
18
|
+
echo "error: scene directory not found: $SCENE_DIR"
|
|
19
|
+
exit 1
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
mkdir -p "$SCENE_DIR/snapshot"
|
|
23
|
+
|
|
24
|
+
EXTRA_ARGS=()
|
|
25
|
+
if [ "${ALLOW_PLACEHOLDER:-0}" = "1" ]; then
|
|
26
|
+
EXTRA_ARGS+=(--allow-placeholder)
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
python3 "$REPO/src/core/snapshot.py" render "$SCENE" \
|
|
30
|
+
--scene-dir "$SCENE_DIR" \
|
|
31
|
+
--progress 0,50,100 "${EXTRA_ARGS[@]}"
|
|
32
|
+
|
|
33
|
+
if [ ! -f "$SCENE_DIR/snapshot/.render-meta.json" ]; then
|
|
34
|
+
echo "error: renderer did not write snapshot metadata" >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
echo "== runtime snapshots written to $SCENE_DIR/snapshot =="
|