motionloom 2.3.0 → 2.4.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 +24 -1
- package/README.md +78 -11
- package/ROADMAP.md +8 -5
- package/SKILL.md +24 -5
- package/agent-card.json +31 -5
- package/agent-surfaces.json +1 -1
- package/artifact-adapter-registry.json +53 -0
- package/bin/motionloom.mjs +29 -5
- package/docs/CHECKLIST.md +16 -0
- package/docs/STATUS.md +2 -2
- package/docs/audits/data/deep-stress-latest.json +65 -65
- package/docs/releases/2.4.0.md +31 -0
- package/docs/releases/npm-publish-from-workstation.md +19 -8
- package/docs/research/agent-skill-ecosystem-notes.md +47 -0
- package/docs/research/ai-animation-tools-2026-notes.md +81 -0
- package/docs/research/ai-animation-tools-2026-report.md +168 -0
- package/docs/research/ai-pilot-ingest-notes.md +29 -0
- package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
- package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
- package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
- package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
- package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
- package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
- package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
- package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
- package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
- package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
- package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
- package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
- package/examples/agent-consumer/rive-package-gate/README.md +20 -0
- package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
- package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
- package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
- package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
- package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
- package/examples/agent-consumer/runtime-pilot/export.json +17 -0
- package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
- package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
- package/package.json +56 -4
- package/references/browser-review-contract.md +7 -1
- package/references/intelligence-core.md +22 -1
- package/rig-adapter-registry.json +39 -0
- package/schemas/action-set.schema.json +42 -0
- package/schemas/artifact-adapter-registry.schema.json +16 -0
- package/schemas/asset-identity.schema.json +117 -0
- package/schemas/asset-provenance.schema.json +1 -1
- package/schemas/atlas-contract.schema.json +48 -0
- package/schemas/control-track.schema.json +18 -0
- package/schemas/export-manifest.schema.json +19 -0
- package/schemas/frame-geometry.schema.json +79 -0
- package/schemas/generation-receipt.schema.json +20 -0
- package/schemas/layered-map.schema.json +75 -0
- package/schemas/rig-adapter-registry.schema.json +14 -0
- package/schemas/rig-compatibility.schema.json +53 -0
- package/schemas/rive-package-manifest.schema.json +43 -0
- package/schemas/runtime-candidate.schema.json +48 -0
- package/schemas/scene-manifest.schema.json +18 -0
- package/scripts/artifact-intake.py +408 -0
- package/scripts/asset-consistency.py +517 -0
- package/scripts/asset-provenance.py +8 -3
- package/scripts/build-ai-pilot.py +504 -0
- package/scripts/isolate-alpha-background.py +147 -0
- package/scripts/quality-gate.py +88 -2
- package/scripts/report.py +85 -0
- package/scripts/resolve-task-bundle.py +84 -0
- package/scripts/review-hook.py +26 -4
- package/scripts/rig-compatibility.py +251 -0
- package/scripts/rive-package-gate.py +245 -0
- package/scripts/runtime-candidate.py +301 -0
- package/scripts/setup.mjs +54 -26
- package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
- package/src/output/runtime-pilot-framer/browser-review.json +20 -0
- package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
- package/src/output/runtime-pilot-framer/manifest.json +31 -0
- package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
- package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
- package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
- package/src/output/runtime-pilot-framer/scene.jsx +52 -0
- package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
- package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
- package/tests/scripts/run_tests.py +107 -0
- package/tests/scripts/test_ai_pilot_builder.py +128 -0
- package/tests/scripts/test_alpha_isolation.py +60 -0
- package/tests/scripts/test_artifact_intake.py +146 -0
- package/tests/scripts/test_asset_consistency.py +199 -0
- package/tests/scripts/test_rig_compatibility.py +105 -0
- package/tests/scripts/test_rive_package_gate.py +101 -0
- package/tests/scripts/test_runtime_candidate.py +95 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MotionLoom rig compatibility validator.
|
|
3
|
+
|
|
4
|
+
This checks an explicit supplied rig contract and its runtime bridge. It does
|
|
5
|
+
not generate a rig, invent missing bones, or upgrade AI-generated material to
|
|
6
|
+
artist authority, production eligibility, production approval or a PR.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import asdict, dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
SAFE = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$)).+")
|
|
25
|
+
SHA = re.compile(r"^[a-f0-9]{64}$")
|
|
26
|
+
STATUSES = {"verified", "project_integrated", "static_validated", "scaffold_only", "disabled"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Issue:
|
|
31
|
+
severity: str
|
|
32
|
+
code: str
|
|
33
|
+
message: str
|
|
34
|
+
path: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add(issues: list[Issue], code: str, message: str, path: str = "", severity: str = "error") -> None:
|
|
38
|
+
issues.append(Issue(severity, code, message, path))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def safe(value: Any) -> bool:
|
|
42
|
+
return isinstance(value, str) and "\\" not in value and bool(SAFE.fullmatch(value))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve(root: Path, value: Any) -> Path | None:
|
|
46
|
+
if not safe(value):
|
|
47
|
+
return None
|
|
48
|
+
raw = root / str(value)
|
|
49
|
+
try:
|
|
50
|
+
path = raw.resolve()
|
|
51
|
+
path.relative_to(root.resolve())
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None
|
|
54
|
+
return path if not raw.is_symlink() and path.is_file() else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def digest(path: Path) -> str:
|
|
58
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_json(path: Path, issues: list[Issue], label: str) -> dict[str, Any] | None:
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
65
|
+
add(issues, "invalid_json", f"{label}: {exc}", str(path))
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
add(issues, "invalid_document", f"{label} must be an object", str(path))
|
|
69
|
+
return None
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_module(name: str, path: Path) -> Any:
|
|
74
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
75
|
+
if spec is None or spec.loader is None:
|
|
76
|
+
raise RuntimeError(f"cannot load {path}")
|
|
77
|
+
module = importlib.util.module_from_spec(spec)
|
|
78
|
+
sys.modules[name] = module
|
|
79
|
+
spec.loader.exec_module(module)
|
|
80
|
+
return module
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def hash_ref(root: Path, section: dict[str, Any], label: str, issues: list[Issue]) -> Path | None:
|
|
84
|
+
path = resolve(root, section.get("path"))
|
|
85
|
+
if path is None:
|
|
86
|
+
add(issues, "missing_or_unsafe_ref", f"{label}.path must be a safe existing relative file", f"{label}.path")
|
|
87
|
+
return None
|
|
88
|
+
expected = section.get("sha256")
|
|
89
|
+
if not isinstance(expected, str) or not SHA.fullmatch(expected):
|
|
90
|
+
add(issues, "invalid_sha256", f"{label}.sha256 must be lowercase SHA-256", f"{label}.sha256")
|
|
91
|
+
elif digest(path) != expected:
|
|
92
|
+
add(issues, "sha256_mismatch", f"{label} SHA-256 mismatch", f"{label}.sha256")
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def valid_evidence(path: Path, framework: str, issues: list[Issue]) -> bool:
|
|
97
|
+
evidence = load_json(path, issues, "runtime evidence")
|
|
98
|
+
if evidence is None:
|
|
99
|
+
return False
|
|
100
|
+
if evidence.get("mode") != "runtime" or evidence.get("status") != "pass":
|
|
101
|
+
add(issues, "runtime_evidence_not_pass", "runtime evidence must have mode=runtime and status=pass", "runtime.runtime_evidence_ref")
|
|
102
|
+
return False
|
|
103
|
+
frameworks = evidence.get("frameworks")
|
|
104
|
+
if not isinstance(frameworks, list) or not any(isinstance(item, dict) and item.get("framework") == framework and item.get("status") == "pass" and item.get("ready") is True for item in frameworks):
|
|
105
|
+
add(issues, "runtime_framework_mismatch", f"runtime evidence must have a ready passing {framework} framework", "runtime.runtime_evidence_ref")
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate(args: argparse.Namespace) -> dict[str, Any]:
|
|
111
|
+
root = args.root.resolve()
|
|
112
|
+
issues: list[Issue] = []
|
|
113
|
+
rig = load_json(args.input.resolve(), issues, "rig compatibility")
|
|
114
|
+
registry = load_json(args.registry.resolve(), issues, "rig adapter registry")
|
|
115
|
+
if rig is None or registry is None:
|
|
116
|
+
return result(rig, issues, None, False)
|
|
117
|
+
for field in ("schema_version", "rig_id", "asset_id", "authority", "source", "candidate", "bones", "sockets", "actions", "runtime"):
|
|
118
|
+
if field not in rig:
|
|
119
|
+
add(issues, "missing_field", f"rig.{field} is required", f"rig.{field}")
|
|
120
|
+
if rig.get("schema_version") != "0.1":
|
|
121
|
+
add(issues, "schema_version", "rig.schema_version must be 0.1", "rig.schema_version")
|
|
122
|
+
if rig.get("authority") not in {"ai_generated", "ai_assisted", "ai_assisted_human_reviewed", "artist_authored", "unknown"}:
|
|
123
|
+
add(issues, "invalid_authority", "rig.authority is unsupported", "rig.authority")
|
|
124
|
+
source = rig.get("source") if isinstance(rig.get("source"), dict) else {}
|
|
125
|
+
candidate_ref = rig.get("candidate") if isinstance(rig.get("candidate"), dict) else {}
|
|
126
|
+
source_path = hash_ref(root, source, "source", issues)
|
|
127
|
+
candidate_path = hash_ref(root, candidate_ref, "candidate", issues)
|
|
128
|
+
|
|
129
|
+
adapters: dict[str, dict[str, Any]] = {}
|
|
130
|
+
if registry.get("schema_version") != "0.1":
|
|
131
|
+
add(issues, "registry_schema_version", "registry.schema_version must be 0.1", "registry.schema_version")
|
|
132
|
+
for index, adapter in enumerate(registry.get("adapters", []) if isinstance(registry.get("adapters"), list) else []):
|
|
133
|
+
if not isinstance(adapter, dict) or not isinstance(adapter.get("adapter_id"), str):
|
|
134
|
+
add(issues, "invalid_adapter", "registry adapter requires adapter_id", f"registry.adapters[{index}]")
|
|
135
|
+
continue
|
|
136
|
+
adapters[adapter["adapter_id"]] = adapter
|
|
137
|
+
if adapter.get("status") not in STATUSES:
|
|
138
|
+
add(issues, "invalid_adapter_status", "adapter status is unsupported", f"registry.adapters[{index}].status")
|
|
139
|
+
for evidence in adapter.get("evidence", []) if isinstance(adapter.get("evidence"), list) else []:
|
|
140
|
+
if isinstance(evidence, dict):
|
|
141
|
+
hash_ref(root, evidence, f"registry.adapters[{index}].evidence", issues)
|
|
142
|
+
|
|
143
|
+
runtime = rig.get("runtime") if isinstance(rig.get("runtime"), dict) else {}
|
|
144
|
+
adapter = adapters.get(str(runtime.get("adapter_id")))
|
|
145
|
+
if adapter is None:
|
|
146
|
+
add(issues, "unknown_adapter", "runtime.adapter_id is not in rig adapter registry", "runtime.adapter_id")
|
|
147
|
+
else:
|
|
148
|
+
if adapter.get("status") == "disabled":
|
|
149
|
+
add(issues, "adapter_disabled", "runtime adapter is disabled", "runtime.adapter_id")
|
|
150
|
+
if adapter.get("target") != runtime.get("target"):
|
|
151
|
+
add(issues, "runtime_target_mismatch", "rig runtime.target must match adapter target", "runtime.target")
|
|
152
|
+
if runtime.get("review_required") is not True:
|
|
153
|
+
add(issues, "review_required", "rig compatibility cannot suppress human review", "runtime.review_required")
|
|
154
|
+
|
|
155
|
+
bones = rig.get("bones") if isinstance(rig.get("bones"), list) else []
|
|
156
|
+
bone_ids: set[str] = set()
|
|
157
|
+
for index, bone in enumerate(bones):
|
|
158
|
+
if not isinstance(bone, dict) or not isinstance(bone.get("id"), str) or not bone["id"].strip() or bone["id"] in bone_ids:
|
|
159
|
+
add(issues, "invalid_bone", "bone IDs must be unique and non-empty", f"bones[{index}]")
|
|
160
|
+
continue
|
|
161
|
+
bone_ids.add(bone["id"])
|
|
162
|
+
for index, bone in enumerate(bones):
|
|
163
|
+
if isinstance(bone, dict) and bone.get("parent") and bone.get("parent") not in bone_ids:
|
|
164
|
+
add(issues, "missing_parent_bone", f"bone parent is missing: {bone.get('parent')}", f"bones[{index}].parent")
|
|
165
|
+
sockets = rig.get("sockets") if isinstance(rig.get("sockets"), list) else []
|
|
166
|
+
socket_ids: set[str] = set()
|
|
167
|
+
for index, socket in enumerate(sockets):
|
|
168
|
+
if not isinstance(socket, dict) or not isinstance(socket.get("id"), str) or not socket["id"].strip() or socket["id"] in socket_ids:
|
|
169
|
+
add(issues, "invalid_socket", "socket IDs must be unique and non-empty", f"sockets[{index}]")
|
|
170
|
+
continue
|
|
171
|
+
socket_ids.add(socket["id"])
|
|
172
|
+
if socket.get("bone") not in bone_ids:
|
|
173
|
+
add(issues, "socket_bone_missing", f"socket bone is missing: {socket.get('bone')}", f"sockets[{index}].bone")
|
|
174
|
+
actions = rig.get("actions") if isinstance(rig.get("actions"), list) else []
|
|
175
|
+
action_ids: set[str] = set()
|
|
176
|
+
action_events: set[str] = set()
|
|
177
|
+
for index, action in enumerate(actions):
|
|
178
|
+
if not isinstance(action, dict) or not isinstance(action.get("id"), str) or not action["id"].strip() or action["id"] in action_ids:
|
|
179
|
+
add(issues, "invalid_action", "action IDs must be unique and non-empty", f"actions[{index}]")
|
|
180
|
+
continue
|
|
181
|
+
action_ids.add(action["id"])
|
|
182
|
+
action_events.update(event for event in action.get("events", []) if isinstance(event, str))
|
|
183
|
+
requirements = rig.get("requirements") if isinstance(rig.get("requirements"), dict) else {}
|
|
184
|
+
for socket in requirements.get("sockets", []) if isinstance(requirements.get("sockets"), list) else []:
|
|
185
|
+
if socket not in socket_ids:
|
|
186
|
+
add(issues, "required_socket_missing", f"required socket is absent: {socket}", "requirements.sockets")
|
|
187
|
+
for action in requirements.get("actions", []) if isinstance(requirements.get("actions"), list) else []:
|
|
188
|
+
if action not in action_ids:
|
|
189
|
+
add(issues, "required_action_missing", f"required action is absent: {action}", "requirements.actions")
|
|
190
|
+
for event in requirements.get("events", []) if isinstance(requirements.get("events"), list) else []:
|
|
191
|
+
if event not in action_events:
|
|
192
|
+
add(issues, "required_event_missing", f"required event is absent: {event}", "requirements.events")
|
|
193
|
+
|
|
194
|
+
candidate_result: dict[str, Any] | None = None
|
|
195
|
+
if candidate_path is not None:
|
|
196
|
+
try:
|
|
197
|
+
bridge = load_module("motionloom_runtime_candidate_rig", ROOT / "scripts" / "runtime-candidate.py")
|
|
198
|
+
candidate_result = bridge.validate_candidate(argparse.Namespace(root=root, input=candidate_path, strict=False))
|
|
199
|
+
if not candidate_result.get("runtime_test_ready"):
|
|
200
|
+
add(issues, "candidate_not_ready", "referenced runtime candidate is blocked", "candidate.path")
|
|
201
|
+
if candidate_result.get("asset_id") != rig.get("asset_id"):
|
|
202
|
+
add(issues, "candidate_asset_mismatch", "rig.asset_id must match runtime candidate", "asset_id")
|
|
203
|
+
candidate_doc = load_json(candidate_path, issues, "runtime candidate")
|
|
204
|
+
if candidate_doc is not None and candidate_doc.get("runtime", {}).get("target") != runtime.get("target"):
|
|
205
|
+
add(issues, "candidate_target_mismatch", "rig runtime target must match candidate runtime target", "runtime.target")
|
|
206
|
+
except (OSError, RuntimeError, ValueError, AttributeError, KeyError, TypeError) as exc:
|
|
207
|
+
add(issues, "candidate_unavailable", str(exc), "candidate.path")
|
|
208
|
+
runtime_verified = False
|
|
209
|
+
if "runtime_evidence_ref" in runtime:
|
|
210
|
+
evidence_path = resolve(root, runtime.get("runtime_evidence_ref"))
|
|
211
|
+
if evidence_path is None:
|
|
212
|
+
add(issues, "missing_or_unsafe_runtime_evidence", "runtime evidence reference must be a safe existing file", "runtime.runtime_evidence_ref")
|
|
213
|
+
elif adapter is not None:
|
|
214
|
+
runtime_verified = valid_evidence(evidence_path, str(adapter.get("framework")), issues)
|
|
215
|
+
if args.strict and not runtime_verified:
|
|
216
|
+
add(issues, "runtime_evidence_required", "strict rig validation requires passing matching runtime evidence", "runtime.runtime_evidence_ref")
|
|
217
|
+
return result(rig, issues, adapter, runtime_verified, candidate_result)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def result(rig: dict[str, Any] | None, issues: list[Issue], adapter: dict[str, Any] | None, runtime_verified: bool, candidate: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
221
|
+
errors = [asdict(item) for item in issues if item.severity == "error"]
|
|
222
|
+
warnings = [asdict(item) for item in issues if item.severity == "warning"]
|
|
223
|
+
ready = not errors
|
|
224
|
+
return {"contract": "rig-compatibility", "status": "blocked" if errors else "review_required", "ready": ready, "runtime_verified": runtime_verified, "review_required": True, "production_eligible": False, "production_approved": False, "rig_id": (rig or {}).get("rig_id"), "asset_id": (rig or {}).get("asset_id"), "adapter": {"adapter_id": (adapter or {}).get("adapter_id"), "status": (adapter or {}).get("status")}, "candidate": {"status": (candidate or {}).get("status"), "runtime_test_ready": (candidate or {}).get("runtime_test_ready")}, "errors": errors, "warnings": warnings}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main(argv: list[str] | None = None) -> int:
|
|
228
|
+
parser = argparse.ArgumentParser(description="Validate MotionLoom rig compatibility and runtime adapter evidence")
|
|
229
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
230
|
+
for command in ("validate", "report"):
|
|
231
|
+
item = sub.add_parser(command)
|
|
232
|
+
item.add_argument("--input", required=True, type=Path)
|
|
233
|
+
item.add_argument("--registry", type=Path, default=Path("rig-adapter-registry.json"))
|
|
234
|
+
item.add_argument("--root", type=Path, default=Path("."))
|
|
235
|
+
item.add_argument("--strict", action="store_true")
|
|
236
|
+
item.add_argument("--output", type=Path)
|
|
237
|
+
item.add_argument("--json", action="store_true")
|
|
238
|
+
args = parser.parse_args(argv)
|
|
239
|
+
payload = validate(args)
|
|
240
|
+
if args.output:
|
|
241
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
242
|
+
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
243
|
+
if args.command == "validate" or args.json:
|
|
244
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
245
|
+
else:
|
|
246
|
+
print(f"rig-compatibility: {payload['status']} ({len(payload['errors'])} errors, {len(payload['warnings'])} warnings)")
|
|
247
|
+
return 0 if payload["ready"] else 1
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed Rive package gate for MotionLoom.
|
|
3
|
+
|
|
4
|
+
The gate binds a concrete `.riv` file to provenance, a project-integrated Rive
|
|
5
|
+
runtime adapter and (optionally/strictly) a captured runtime proof. It does not
|
|
6
|
+
parse a proprietary Rive graph, invent missing state-machine data, create a
|
|
7
|
+
placeholder production package, or grant approval of any kind.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
SAFE = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$)).+")
|
|
25
|
+
SHA = re.compile(r"^[a-f0-9]{64}$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Issue:
|
|
30
|
+
severity: str
|
|
31
|
+
code: str
|
|
32
|
+
message: str
|
|
33
|
+
path: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def add(issues: list[Issue], code: str, message: str, path: str = "", severity: str = "error") -> None:
|
|
37
|
+
issues.append(Issue(severity, code, message, path))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def safe(value: Any) -> bool:
|
|
41
|
+
return isinstance(value, str) and "\\" not in value and bool(SAFE.fullmatch(value))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_json(path: Path, issues: list[Issue], label: str) -> dict[str, Any] | None:
|
|
45
|
+
try:
|
|
46
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
47
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
48
|
+
add(issues, "invalid_json", f"{label}: {exc}", str(path))
|
|
49
|
+
return None
|
|
50
|
+
if not isinstance(value, dict):
|
|
51
|
+
add(issues, "invalid_document", f"{label} must be a JSON object", str(path))
|
|
52
|
+
return None
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve(root: Path, value: Any) -> Path | None:
|
|
57
|
+
if not safe(value):
|
|
58
|
+
return None
|
|
59
|
+
raw = root / str(value)
|
|
60
|
+
try:
|
|
61
|
+
result = raw.resolve()
|
|
62
|
+
result.relative_to(root.resolve())
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
return result if not raw.is_symlink() and result.is_file() else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def digest(path: Path) -> str:
|
|
69
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_module(name: str, path: Path) -> Any:
|
|
73
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
74
|
+
if spec is None or spec.loader is None:
|
|
75
|
+
raise RuntimeError(f"cannot load {path}")
|
|
76
|
+
module = importlib.util.module_from_spec(spec)
|
|
77
|
+
sys.modules[name] = module
|
|
78
|
+
spec.loader.exec_module(module)
|
|
79
|
+
return module
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_registry(root: Path, registry_path: Path, adapter_id: Any, issues: list[Issue]) -> bool:
|
|
83
|
+
registry = load_json(registry_path, issues, "rig adapter registry")
|
|
84
|
+
if registry is None:
|
|
85
|
+
return False
|
|
86
|
+
adapters = registry.get("adapters")
|
|
87
|
+
if registry.get("schema_version") != "0.1" or not isinstance(adapters, list):
|
|
88
|
+
add(issues, "invalid_registry", "rig adapter registry schema_version/adapters are invalid", "registry")
|
|
89
|
+
return False
|
|
90
|
+
adapter = next((item for item in adapters if isinstance(item, dict) and item.get("adapter_id") == adapter_id), None)
|
|
91
|
+
if adapter is None:
|
|
92
|
+
add(issues, "unknown_adapter", "runtime.adapter_id is not registered", "runtime.adapter_id")
|
|
93
|
+
return False
|
|
94
|
+
if adapter.get("target") != "rive" or adapter.get("framework") != "rive":
|
|
95
|
+
add(issues, "adapter_target_mismatch", "registered adapter must target the Rive runtime", "runtime.adapter_id")
|
|
96
|
+
if adapter.get("status") not in {"project_integrated", "verified"}:
|
|
97
|
+
add(issues, "adapter_not_integrated", "Rive adapter must be project_integrated or verified", "runtime.adapter_id")
|
|
98
|
+
evidence = adapter.get("evidence")
|
|
99
|
+
if not isinstance(evidence, list) or not evidence:
|
|
100
|
+
add(issues, "adapter_evidence_missing", "registered Rive adapter requires hash-bound evidence", "registry.adapters.evidence")
|
|
101
|
+
else:
|
|
102
|
+
for index, item in enumerate(evidence):
|
|
103
|
+
path = resolve(root, item.get("path") if isinstance(item, dict) else None)
|
|
104
|
+
expected = item.get("sha256") if isinstance(item, dict) else None
|
|
105
|
+
if path is None or not isinstance(expected, str) or not SHA.fullmatch(expected):
|
|
106
|
+
add(issues, "adapter_evidence_invalid", "adapter evidence needs a safe existing path and SHA-256", f"registry.adapters.evidence[{index}]")
|
|
107
|
+
elif digest(path) != expected:
|
|
108
|
+
add(issues, "adapter_evidence_tampered", "adapter evidence SHA-256 mismatch", f"registry.adapters.evidence[{index}]")
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_runtime_evidence(root: Path, runtime: dict[str, Any], source_hash: str, strict: bool, issues: list[Issue]) -> bool:
|
|
113
|
+
ref = runtime.get("runtime_evidence_ref")
|
|
114
|
+
expected = runtime.get("runtime_evidence_sha256")
|
|
115
|
+
if ref is None and expected is None:
|
|
116
|
+
if strict:
|
|
117
|
+
add(issues, "runtime_evidence_required", "strict mode requires runtime.runtime_evidence_ref and SHA-256", "runtime")
|
|
118
|
+
return False
|
|
119
|
+
path = resolve(root, ref)
|
|
120
|
+
if path is None or not isinstance(expected, str) or not SHA.fullmatch(expected):
|
|
121
|
+
add(issues, "runtime_evidence_invalid_ref", "runtime evidence needs a safe existing JSON path and SHA-256", "runtime")
|
|
122
|
+
return False
|
|
123
|
+
if digest(path) != expected:
|
|
124
|
+
add(issues, "runtime_evidence_tampered", "runtime evidence SHA-256 mismatch", "runtime.runtime_evidence_sha256")
|
|
125
|
+
return False
|
|
126
|
+
evidence = load_json(path, issues, "runtime evidence")
|
|
127
|
+
if evidence is None:
|
|
128
|
+
return False
|
|
129
|
+
valid = True
|
|
130
|
+
if evidence.get("mode") != "runtime" or evidence.get("status") != "pass":
|
|
131
|
+
add(issues, "runtime_evidence_not_pass", "runtime evidence must have mode=runtime and status=pass", "runtime.runtime_evidence_ref")
|
|
132
|
+
valid = False
|
|
133
|
+
if evidence.get("source_sha256") != source_hash:
|
|
134
|
+
add(issues, "runtime_source_unbound", "runtime evidence source_sha256 must bind this exact .riv file", "runtime.runtime_evidence_ref")
|
|
135
|
+
valid = False
|
|
136
|
+
frameworks = evidence.get("frameworks")
|
|
137
|
+
if not isinstance(frameworks, list) or not any(isinstance(item, dict) and item.get("framework") == "rive" and item.get("status") == "pass" and item.get("ready") is True for item in frameworks):
|
|
138
|
+
add(issues, "runtime_framework_mismatch", "runtime evidence needs a ready passing Rive framework result", "runtime.runtime_evidence_ref")
|
|
139
|
+
valid = False
|
|
140
|
+
return valid
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def validate(args: argparse.Namespace) -> dict[str, Any]:
|
|
144
|
+
root = args.root.resolve()
|
|
145
|
+
issues: list[Issue] = []
|
|
146
|
+
manifest = load_json(args.input.resolve(), issues, "Rive package manifest")
|
|
147
|
+
if manifest is None:
|
|
148
|
+
return result(None, issues, False)
|
|
149
|
+
for field in ("schema_version", "package_id", "package_class", "asset", "provenance_ref", "runtime", "actions", "review_required"):
|
|
150
|
+
if field not in manifest:
|
|
151
|
+
add(issues, "missing_field", f"{field} is required", field)
|
|
152
|
+
if manifest.get("schema_version") != "0.1":
|
|
153
|
+
add(issues, "schema_version", "schema_version must be 0.1", "schema_version")
|
|
154
|
+
if manifest.get("package_class") not in {"ai_generated_pilot", "production_candidate"}:
|
|
155
|
+
add(issues, "invalid_package_class", "package_class must be ai_generated_pilot or production_candidate", "package_class")
|
|
156
|
+
if manifest.get("review_required") is not True:
|
|
157
|
+
add(issues, "review_required", "Rive package gate cannot suppress human review", "review_required")
|
|
158
|
+
|
|
159
|
+
asset = manifest.get("asset") if isinstance(manifest.get("asset"), dict) else {}
|
|
160
|
+
source = resolve(root, asset.get("path"))
|
|
161
|
+
source_hash = asset.get("sha256")
|
|
162
|
+
if source is None or source.suffix.lower() != ".riv":
|
|
163
|
+
add(issues, "missing_or_unsafe_riv", "asset.path must be a safe existing .riv file", "asset.path")
|
|
164
|
+
elif source.read_bytes()[:4] != b"RIVE":
|
|
165
|
+
add(issues, "rive_magic_invalid", "asset.path is not a Rive binary (missing RIVE header)", "asset.path")
|
|
166
|
+
if not isinstance(source_hash, str) or not SHA.fullmatch(source_hash):
|
|
167
|
+
add(issues, "invalid_asset_sha256", "asset.sha256 must be lowercase SHA-256", "asset.sha256")
|
|
168
|
+
elif source is not None and digest(source) != source_hash:
|
|
169
|
+
add(issues, "asset_sha256_mismatch", "asset.sha256 does not bind the .riv bytes", "asset.sha256")
|
|
170
|
+
if not isinstance(asset.get("bytes"), int) or asset.get("bytes") < 5:
|
|
171
|
+
add(issues, "invalid_asset_bytes", "asset.bytes must be the actual non-zero .riv byte count", "asset.bytes")
|
|
172
|
+
elif source is not None and source.stat().st_size != asset.get("bytes"):
|
|
173
|
+
add(issues, "asset_bytes_mismatch", "asset.bytes does not bind the .riv byte count", "asset.bytes")
|
|
174
|
+
|
|
175
|
+
provenance_ok = False
|
|
176
|
+
provenance_path = resolve(root, manifest.get("provenance_ref"))
|
|
177
|
+
if provenance_path is None:
|
|
178
|
+
add(issues, "missing_or_unsafe_provenance", "provenance_ref must be a safe existing JSON file", "provenance_ref")
|
|
179
|
+
else:
|
|
180
|
+
provenance = load_json(provenance_path, issues, "asset provenance")
|
|
181
|
+
try:
|
|
182
|
+
module = load_module("motionloom_asset_provenance", ROOT / "scripts" / "asset-provenance.py")
|
|
183
|
+
provenance_errors = module.validate_document(provenance or {}) + module.validate_files(provenance or {}, root)
|
|
184
|
+
except Exception as exc: # defensive boundary: never accept if the checker fails
|
|
185
|
+
provenance_errors = [f"provenance validator failed: {exc}"]
|
|
186
|
+
for message in provenance_errors:
|
|
187
|
+
add(issues, "invalid_provenance", message, "provenance_ref")
|
|
188
|
+
if not provenance_errors and provenance is not None:
|
|
189
|
+
files = provenance.get("files") if isinstance(provenance.get("files"), list) else []
|
|
190
|
+
file_match = any(isinstance(item, dict) and item.get("path") == asset.get("path") and item.get("sha256") == source_hash for item in files)
|
|
191
|
+
if not file_match:
|
|
192
|
+
add(issues, "provenance_file_unbound", "provenance must list this exact .riv path and SHA-256", "provenance_ref")
|
|
193
|
+
elif provenance.get("asset", {}).get("id") != asset.get("id"):
|
|
194
|
+
add(issues, "provenance_asset_mismatch", "provenance asset.id must match package asset.id", "provenance_ref")
|
|
195
|
+
elif provenance.get("authority") == "ai_generated" and manifest.get("package_class") != "ai_generated_pilot":
|
|
196
|
+
add(issues, "ai_class_mismatch", "ai_generated provenance requires package_class=ai_generated_pilot", "package_class")
|
|
197
|
+
elif provenance.get("authority") == "ai_generated" and provenance.get("readiness") != "runtime_ready":
|
|
198
|
+
add(issues, "ai_readiness_invalid", "ai_generated Rive packages must remain runtime_ready", "provenance_ref")
|
|
199
|
+
else:
|
|
200
|
+
provenance_ok = True
|
|
201
|
+
|
|
202
|
+
runtime = manifest.get("runtime") if isinstance(manifest.get("runtime"), dict) else {}
|
|
203
|
+
if runtime.get("target") != "rive" or runtime.get("review_required") is not True:
|
|
204
|
+
add(issues, "runtime_contract_invalid", "runtime target must be rive and review_required must be true", "runtime")
|
|
205
|
+
for name in ("required_state_machines", "required_inputs", "required_events"):
|
|
206
|
+
value = runtime.get(name)
|
|
207
|
+
if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
|
|
208
|
+
add(issues, "runtime_controls_invalid", f"runtime.{name} must be an array of non-empty strings", f"runtime.{name}")
|
|
209
|
+
actions = manifest.get("actions")
|
|
210
|
+
if not isinstance(actions, list) or not actions or len(set(actions)) != len(actions) or any(not isinstance(item, str) or not item.strip() for item in actions):
|
|
211
|
+
add(issues, "actions_invalid", "actions must be a non-empty list of unique non-empty strings", "actions")
|
|
212
|
+
validate_registry(root, args.registry.resolve(), runtime.get("adapter_id"), issues)
|
|
213
|
+
runtime_ok = validate_runtime_evidence(root, runtime, source_hash if isinstance(source_hash, str) else "", args.strict, issues)
|
|
214
|
+
return result(manifest, issues, provenance_ok and runtime_ok)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def result(manifest: dict[str, Any] | None, issues: list[Issue], runtime_evidence_valid: bool) -> dict[str, Any]:
|
|
218
|
+
errors = [issue for issue in issues if issue.severity == "error"]
|
|
219
|
+
return {
|
|
220
|
+
"contract": "rive-package-gate",
|
|
221
|
+
"package_id": manifest.get("package_id") if isinstance(manifest, dict) else None,
|
|
222
|
+
"status": "blocked" if errors else "pass",
|
|
223
|
+
"runtime_test_ready": not errors and runtime_evidence_valid,
|
|
224
|
+
"review_required": True,
|
|
225
|
+
"production_approved": False,
|
|
226
|
+
"issues": [asdict(issue) for issue in issues],
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main() -> int:
|
|
231
|
+
parser = argparse.ArgumentParser(description="Validate a real Rive package without granting approval")
|
|
232
|
+
parser.add_argument("validate", nargs="?", default="validate")
|
|
233
|
+
parser.add_argument("--input", required=True, type=Path)
|
|
234
|
+
parser.add_argument("--root", type=Path, default=ROOT)
|
|
235
|
+
parser.add_argument("--registry", type=Path, default=ROOT / "rig-adapter-registry.json")
|
|
236
|
+
parser.add_argument("--strict", action="store_true", help="Require source-bound runtime evidence")
|
|
237
|
+
parser.add_argument("--json", action="store_true")
|
|
238
|
+
args = parser.parse_args()
|
|
239
|
+
payload = validate(args)
|
|
240
|
+
print(json.dumps(payload, indent=2))
|
|
241
|
+
return 0 if payload["status"] == "pass" else 1
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
raise SystemExit(main())
|