motionloom 2.2.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +123 -17
  3. package/ROADMAP.md +8 -3
  4. package/SKILL.md +34 -3
  5. package/agent-card.json +48 -5
  6. package/agent-surfaces.json +8 -1
  7. package/artifact-adapter-registry.json +53 -0
  8. package/bin/motionloom.mjs +41 -4
  9. package/docs/AGENT-INTEGRATION.md +15 -2
  10. package/docs/CHECKLIST.md +21 -0
  11. package/docs/STATUS.md +2 -2
  12. package/docs/audits/data/deep-stress-latest.json +65 -65
  13. package/docs/releases/2.3.0.md +33 -0
  14. package/docs/releases/2.4.0.md +31 -0
  15. package/docs/releases/npm-publish-from-workstation.md +19 -8
  16. package/docs/research/agent-skill-ecosystem-notes.md +47 -0
  17. package/docs/research/ai-animation-tools-2026-notes.md +81 -0
  18. package/docs/research/ai-animation-tools-2026-report.md +168 -0
  19. package/docs/research/ai-pilot-ingest-notes.md +29 -0
  20. package/examples/agent-consumer/ai-generated-pilot/hero-male.json +10 -0
  21. package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -0
  22. package/examples/agent-consumer/ai-pilot-scout/CHATGPT-HANDOFF.md +70 -0
  23. package/examples/agent-consumer/ai-pilot-scout/CODEX-HANDOFF.md +80 -0
  24. package/examples/agent-consumer/ai-pilot-scout/README.md +39 -0
  25. package/examples/agent-consumer/ai-pilot-scout/chatgpt-geometry-correction-prompt.md +55 -0
  26. package/examples/agent-consumer/ai-pilot-scout/chatgpt-pose-generation-prompt.md +78 -0
  27. package/examples/agent-consumer/ai-pilot-scout/partial-handoff.json +73 -0
  28. package/examples/agent-consumer/artifact-intake/hero-motion-controls.json +15 -0
  29. package/examples/agent-consumer/artifact-intake/hero-motion-export.json +14 -0
  30. package/examples/agent-consumer/artifact-intake/hero-motion-provenance.json +20 -0
  31. package/examples/agent-consumer/artifact-intake/hero-motion-receipt.json +16 -0
  32. package/examples/agent-consumer/asset-consistency/assets/forest-back.png +0 -0
  33. package/examples/agent-consumer/asset-consistency/assets/forest-front.png +0 -0
  34. package/examples/agent-consumer/asset-consistency/assets/forest-mid.png +0 -0
  35. package/examples/agent-consumer/asset-consistency/assets/hero-atlas.png +0 -0
  36. package/examples/agent-consumer/asset-consistency/assets/hero-frame-00.png +0 -0
  37. package/examples/agent-consumer/asset-consistency/assets/hero-frame-01.png +0 -0
  38. package/examples/agent-consumer/asset-consistency/assets/hero-frame-02.png +0 -0
  39. package/examples/agent-consumer/asset-consistency/assets/hero-frame-03.png +0 -0
  40. package/examples/agent-consumer/asset-consistency/forest-layered-map.json +53 -0
  41. package/examples/agent-consumer/asset-consistency/hero-atlas-contract.json +24 -0
  42. package/examples/agent-consumer/asset-consistency/hero-identity.json +51 -0
  43. package/examples/agent-consumer/asset-consistency/hero-walk-action-set.json +27 -0
  44. package/examples/agent-consumer/asset-consistency/hero-walk-frame-geometry.json +63 -0
  45. package/examples/agent-consumer/rig-compatibility/hero-walk-fixture-rig.json +13 -0
  46. package/examples/agent-consumer/rive-package-gate/README.md +20 -0
  47. package/examples/agent-consumer/runtime-candidate/hero-walk-candidate.json +18 -0
  48. package/examples/agent-consumer/runtime-pilot/action-set.json +19 -0
  49. package/examples/agent-consumer/runtime-pilot/asset-identity.json +44 -0
  50. package/examples/agent-consumer/runtime-pilot/candidate.json +21 -0
  51. package/examples/agent-consumer/runtime-pilot/controls.json +35 -0
  52. package/examples/agent-consumer/runtime-pilot/export.json +17 -0
  53. package/examples/agent-consumer/runtime-pilot/provenance.json +38 -0
  54. package/examples/agent-consumer/runtime-pilot/receipt.json +30 -0
  55. package/package.json +65 -4
  56. package/references/agent-interoperability.md +11 -0
  57. package/references/browser-review-contract.md +7 -1
  58. package/references/intelligence-core.md +29 -2
  59. package/rig-adapter-registry.json +39 -0
  60. package/schemas/action-set.schema.json +42 -0
  61. package/schemas/agent-surfaces.schema.json +1 -1
  62. package/schemas/artifact-adapter-registry.schema.json +16 -0
  63. package/schemas/asset-identity.schema.json +117 -0
  64. package/schemas/asset-provenance.schema.json +183 -0
  65. package/schemas/atlas-contract.schema.json +48 -0
  66. package/schemas/control-track.schema.json +18 -0
  67. package/schemas/export-manifest.schema.json +19 -0
  68. package/schemas/frame-geometry.schema.json +79 -0
  69. package/schemas/generation-receipt.schema.json +20 -0
  70. package/schemas/layered-map.schema.json +75 -0
  71. package/schemas/rig-adapter-registry.schema.json +14 -0
  72. package/schemas/rig-compatibility.schema.json +53 -0
  73. package/schemas/rive-package-manifest.schema.json +43 -0
  74. package/schemas/runtime-candidate.schema.json +48 -0
  75. package/schemas/scene-manifest.schema.json +19 -0
  76. package/scripts/artifact-intake.py +408 -0
  77. package/scripts/asset-consistency.py +517 -0
  78. package/scripts/asset-provenance.py +395 -0
  79. package/scripts/build-ai-pilot.py +504 -0
  80. package/scripts/docs-audit.py +14 -2
  81. package/scripts/isolate-alpha-background.py +147 -0
  82. package/scripts/pr.py +2 -0
  83. package/scripts/quality-gate.py +127 -3
  84. package/scripts/report.py +135 -0
  85. package/scripts/resolve-task-bundle.py +84 -0
  86. package/scripts/review-hook.py +26 -4
  87. package/scripts/rig-compatibility.py +251 -0
  88. package/scripts/rive-package-gate.py +245 -0
  89. package/scripts/runtime-candidate.py +301 -0
  90. package/scripts/setup.mjs +500 -0
  91. package/scripts/skill-doctor.py +2 -1
  92. package/src/output/browser-review-smoke/asset-provenance.json +77 -0
  93. package/src/output/browser-review-smoke/manifest.json +1 -0
  94. package/src/output/browser-review-smoke/visual-truth.json +3 -3
  95. package/src/output/runtime-pilot-framer/asset-provenance.json +38 -0
  96. package/src/output/runtime-pilot-framer/browser-review.json +20 -0
  97. package/src/output/runtime-pilot-framer/framer-motion/runtime-telemetry.json +103 -0
  98. package/src/output/runtime-pilot-framer/manifest.json +31 -0
  99. package/src/output/runtime-pilot-framer/motion-spec.json +25 -0
  100. package/src/output/runtime-pilot-framer/runtime-evidence.json +96 -0
  101. package/src/output/runtime-pilot-framer/runtime-telemetry.json +100 -0
  102. package/src/output/runtime-pilot-framer/scene.jsx +52 -0
  103. package/src/output/runtime-pilot-framer/snapshot/.render-meta.json +9 -0
  104. package/src/output/runtime-pilot-framer/snapshot/frame-00.png +0 -0
  105. package/src/output/runtime-pilot-framer/snapshot/frame-100.png +0 -0
  106. package/src/output/runtime-pilot-framer/snapshot/frame-50.png +0 -0
  107. package/tests/scripts/run_tests.py +131 -0
  108. package/tests/scripts/test_ai_pilot_builder.py +128 -0
  109. package/tests/scripts/test_alpha_isolation.py +60 -0
  110. package/tests/scripts/test_artifact_intake.py +146 -0
  111. package/tests/scripts/test_asset_consistency.py +199 -0
  112. package/tests/scripts/test_rig_compatibility.py +105 -0
  113. package/tests/scripts/test_rive_package_gate.py +101 -0
  114. 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())