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.
- package/CHANGELOG.md +48 -1
- package/README.md +123 -17
- package/ROADMAP.md +8 -3
- package/SKILL.md +34 -3
- package/agent-card.json +48 -5
- package/agent-surfaces.json +8 -1
- package/artifact-adapter-registry.json +53 -0
- package/bin/motionloom.mjs +41 -4
- package/docs/AGENT-INTEGRATION.md +15 -2
- package/docs/CHECKLIST.md +21 -0
- package/docs/STATUS.md +2 -2
- package/docs/audits/data/deep-stress-latest.json +65 -65
- package/docs/releases/2.3.0.md +33 -0
- 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-generated-pilot/hero-male.json +10 -0
- package/examples/agent-consumer/ai-generated-pilot-provenance.json +55 -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 +65 -4
- package/references/agent-interoperability.md +11 -0
- package/references/browser-review-contract.md +7 -1
- package/references/intelligence-core.md +29 -2
- package/rig-adapter-registry.json +39 -0
- package/schemas/action-set.schema.json +42 -0
- package/schemas/agent-surfaces.schema.json +1 -1
- package/schemas/artifact-adapter-registry.schema.json +16 -0
- package/schemas/asset-identity.schema.json +117 -0
- package/schemas/asset-provenance.schema.json +183 -0
- 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 +19 -0
- package/scripts/artifact-intake.py +408 -0
- package/scripts/asset-consistency.py +517 -0
- package/scripts/asset-provenance.py +395 -0
- package/scripts/build-ai-pilot.py +504 -0
- package/scripts/docs-audit.py +14 -2
- package/scripts/isolate-alpha-background.py +147 -0
- package/scripts/pr.py +2 -0
- package/scripts/quality-gate.py +127 -3
- package/scripts/report.py +135 -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 +500 -0
- package/scripts/skill-doctor.py +2 -1
- package/src/output/browser-review-smoke/asset-provenance.json +77 -0
- package/src/output/browser-review-smoke/manifest.json +1 -0
- package/src/output/browser-review-smoke/visual-truth.json +3 -3
- 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 +131 -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,301 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MotionLoom control-to-runtime candidate bridge.
|
|
3
|
+
|
|
4
|
+
This contract links a provider-neutral Artifact Intake bundle to the existing
|
|
5
|
+
identity/action/frame/atlas/map contracts. It is deterministic, standard
|
|
6
|
+
library only, and intentionally reports runtime-test readiness rather than any
|
|
7
|
+
form of production approval or user review outcome.
|
|
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 datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
25
|
+
SAFE = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$)).+")
|
|
26
|
+
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
27
|
+
KINDS = {"asset_identity": "identity", "action_set": "action-set", "frame_geometry": "frame-geometry", "atlas": "atlas", "layered_map": "layered-map"}
|
|
28
|
+
PROFILE_REQUIREMENTS = {
|
|
29
|
+
"image": {"asset_identity"},
|
|
30
|
+
"frame_sequence": {"asset_identity", "action_set", "frame_geometry"},
|
|
31
|
+
"sprite_atlas": {"asset_identity", "action_set", "frame_geometry", "atlas"},
|
|
32
|
+
"layered_map": {"asset_identity", "layered_map"},
|
|
33
|
+
"video": {"asset_identity", "action_set"},
|
|
34
|
+
"rigged_2d": {"asset_identity", "action_set"},
|
|
35
|
+
"rigged_3d": {"asset_identity", "action_set"},
|
|
36
|
+
"runtime_scene": {"asset_identity", "action_set"},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Issue:
|
|
42
|
+
severity: str
|
|
43
|
+
code: str
|
|
44
|
+
message: str
|
|
45
|
+
path: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def issue(issues: list[Issue], code: str, message: str, path: str = "", severity: str = "error") -> None:
|
|
49
|
+
issues.append(Issue(severity, code, message, path))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def safe(value: Any) -> bool:
|
|
53
|
+
return isinstance(value, str) and "\\" not in value and bool(SAFE.fullmatch(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
|
+
path = raw.resolve()
|
|
62
|
+
path.relative_to(root.resolve())
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
return path if not raw.is_symlink() and path.is_file() else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_json(path: Path, issues: list[Issue], label: str) -> dict[str, Any] | None:
|
|
69
|
+
try:
|
|
70
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
71
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
72
|
+
issue(issues, "invalid_json", f"{label}: {exc}", str(path))
|
|
73
|
+
return None
|
|
74
|
+
if not isinstance(value, dict):
|
|
75
|
+
issue(issues, "invalid_document", f"{label} root must be an object", str(path))
|
|
76
|
+
return None
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def digest(path: Path) -> str:
|
|
81
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_module(name: str, path: Path) -> Any:
|
|
85
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
86
|
+
if spec is None or spec.loader is None:
|
|
87
|
+
raise RuntimeError(f"cannot load {path}")
|
|
88
|
+
module = importlib.util.module_from_spec(spec)
|
|
89
|
+
sys.modules[name] = module
|
|
90
|
+
spec.loader.exec_module(module)
|
|
91
|
+
return module
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def required(document: dict[str, Any], fields: tuple[str, ...], prefix: str, issues: list[Issue]) -> None:
|
|
95
|
+
for field in fields:
|
|
96
|
+
if field not in document:
|
|
97
|
+
issue(issues, "missing_field", f"{prefix}.{field} is required", f"{prefix}.{field}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def valid_datetime(value: Any) -> bool:
|
|
101
|
+
try:
|
|
102
|
+
datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
103
|
+
return True
|
|
104
|
+
except ValueError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_group(root: Path, document: dict[str, Any], key: str, fields: tuple[str, ...], issues: list[Issue]) -> dict[str, Path]:
|
|
109
|
+
group = document.get(key) if isinstance(document.get(key), dict) else {}
|
|
110
|
+
required(group, fields, key, issues)
|
|
111
|
+
resolved: dict[str, Path] = {}
|
|
112
|
+
for field in fields:
|
|
113
|
+
path = resolve(root, group.get(field))
|
|
114
|
+
if path is None:
|
|
115
|
+
issue(issues, "missing_or_unsafe_ref", f"{key}.{field} must be an existing safe relative file", f"{key}.{field}")
|
|
116
|
+
else:
|
|
117
|
+
resolved[field] = path
|
|
118
|
+
return resolved
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_runtime_evidence(path: Path, issues: list[Issue]) -> bool:
|
|
122
|
+
evidence = load_json(path, issues, "runtime evidence")
|
|
123
|
+
if evidence is None:
|
|
124
|
+
return False
|
|
125
|
+
if evidence.get("mode") != "runtime" or evidence.get("status") != "pass":
|
|
126
|
+
issue(issues, "runtime_evidence_not_pass", "runtime evidence must record mode=runtime and status=pass", "runtime.runtime_evidence_ref")
|
|
127
|
+
return False
|
|
128
|
+
frameworks = evidence.get("frameworks")
|
|
129
|
+
if not isinstance(frameworks, list) or not frameworks or any(item.get("status") != "pass" or item.get("ready") is not True for item in frameworks if isinstance(item, dict)):
|
|
130
|
+
issue(issues, "runtime_framework_not_ready", "runtime evidence requires at least one ready passing framework", "runtime.runtime_evidence_ref")
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def validate_candidate(args: argparse.Namespace) -> dict[str, Any]:
|
|
136
|
+
root = args.root.resolve()
|
|
137
|
+
issues: list[Issue] = []
|
|
138
|
+
candidate_path = args.input.resolve()
|
|
139
|
+
candidate = load_json(candidate_path, issues, "runtime candidate")
|
|
140
|
+
if candidate is None:
|
|
141
|
+
return result(None, issues, {}, None, False)
|
|
142
|
+
required(candidate, ("schema_version", "candidate_id", "asset_id", "created_at", "artifact_intake", "consistency", "runtime"), "candidate", issues)
|
|
143
|
+
if candidate.get("schema_version") != "0.1":
|
|
144
|
+
issue(issues, "schema_version", "candidate.schema_version must be 0.1", "candidate.schema_version")
|
|
145
|
+
if not isinstance(candidate.get("candidate_id"), str) or len(candidate["candidate_id"]) < 3:
|
|
146
|
+
issue(issues, "invalid_candidate_id", "candidate_id must be a non-empty stable ID", "candidate.candidate_id")
|
|
147
|
+
if not isinstance(candidate.get("asset_id"), str) or not candidate["asset_id"].strip():
|
|
148
|
+
issue(issues, "invalid_asset_id", "asset_id must be non-empty", "candidate.asset_id")
|
|
149
|
+
if not valid_datetime(candidate.get("created_at")):
|
|
150
|
+
issue(issues, "invalid_datetime", "created_at must be ISO-8601", "candidate.created_at")
|
|
151
|
+
intake_paths = resolve_group(root, candidate, "artifact_intake", ("registry", "receipt", "controls", "export_manifest"), issues)
|
|
152
|
+
consistency_paths = resolve_group(root, candidate, "consistency", ("asset_identity", "action_set"), issues)
|
|
153
|
+
consistency_group = candidate.get("consistency") if isinstance(candidate.get("consistency"), dict) else {}
|
|
154
|
+
for name in ("frame_geometry", "atlas", "layered_map"):
|
|
155
|
+
if name in consistency_group:
|
|
156
|
+
path = resolve(root, consistency_group.get(name))
|
|
157
|
+
if path is None:
|
|
158
|
+
issue(issues, "missing_or_unsafe_ref", f"consistency.{name} must be an existing safe relative file", f"consistency.{name}")
|
|
159
|
+
else:
|
|
160
|
+
consistency_paths[name] = path
|
|
161
|
+
runtime = candidate.get("runtime") if isinstance(candidate.get("runtime"), dict) else {}
|
|
162
|
+
if runtime.get("review_required") is not True:
|
|
163
|
+
issue(issues, "review_required", "runtime.review_required must be true; candidate evidence cannot replace human review", "runtime.review_required")
|
|
164
|
+
if runtime.get("target") not in {"sprite", "canvas", "lottie", "dotlottie", "rive", "gsap", "framer-motion", "spine", "three"}:
|
|
165
|
+
issue(issues, "invalid_runtime_target", "runtime.target is unsupported", "runtime.target")
|
|
166
|
+
|
|
167
|
+
intake_result: dict[str, Any] | None = None
|
|
168
|
+
controls: dict[str, Any] | None = None
|
|
169
|
+
export: dict[str, Any] | None = None
|
|
170
|
+
if len(intake_paths) == 4:
|
|
171
|
+
try:
|
|
172
|
+
intake = load_module("motionloom_artifact_intake_bridge", ROOT / "scripts" / "artifact-intake.py")
|
|
173
|
+
# Intake strictness evaluates provider adapter authority. Candidate strictness
|
|
174
|
+
# evaluates actual runtime evidence below; a static intake adapter must not be
|
|
175
|
+
# promoted, but it can feed a separately tested runtime candidate.
|
|
176
|
+
intake_result = intake.evaluate_bundle(argparse.Namespace(root=root, registry=intake_paths["registry"], receipt=intake_paths["receipt"], controls=intake_paths["controls"], export_manifest=intake_paths["export_manifest"], strict=False))
|
|
177
|
+
for entry in intake_result.get("errors", []):
|
|
178
|
+
issue(issues, f"intake_{entry.get('code', 'error')}", entry.get("message", "artifact intake error"), entry.get("path", "artifact_intake"))
|
|
179
|
+
controls = load_json(intake_paths["controls"], issues, "control track")
|
|
180
|
+
export = load_json(intake_paths["export_manifest"], issues, "export manifest")
|
|
181
|
+
receipt = load_json(intake_paths["receipt"], issues, "generation receipt")
|
|
182
|
+
for label, document in (("receipt", receipt), ("controls", controls), ("export", export)):
|
|
183
|
+
if document is not None and document.get("asset_id", document.get("asset", {}).get("id")) != candidate.get("asset_id"):
|
|
184
|
+
issue(issues, "asset_id_mismatch", f"candidate.asset_id must match {label} asset ID", f"{label}.asset_id")
|
|
185
|
+
except (OSError, ValueError, AttributeError, KeyError, TypeError, RuntimeError) as exc:
|
|
186
|
+
issue(issues, "artifact_intake_unavailable", str(exc), "artifact_intake")
|
|
187
|
+
|
|
188
|
+
consistency_results: dict[str, Any] = {}
|
|
189
|
+
try:
|
|
190
|
+
analyzer = load_module("motionloom_asset_consistency_bridge", ROOT / "scripts" / "asset-consistency.py")
|
|
191
|
+
for name, path in consistency_paths.items():
|
|
192
|
+
document = load_json(path, issues, f"consistency {name}")
|
|
193
|
+
if document is None:
|
|
194
|
+
continue
|
|
195
|
+
check = analyzer.run(KINDS[name], document, path.parent.resolve(), args.strict)
|
|
196
|
+
consistency_results[name] = check
|
|
197
|
+
for entry in check.get("errors", []):
|
|
198
|
+
issue(issues, f"consistency_{entry.get('code', 'error')}", entry.get("message", "consistency error"), f"consistency.{name}")
|
|
199
|
+
if name in {"asset_identity", "action_set", "frame_geometry"} and document.get("asset_id", document.get("asset_identity")) != candidate.get("asset_id"):
|
|
200
|
+
issue(issues, "consistency_asset_id_mismatch", f"candidate.asset_id must match consistency.{name}", f"consistency.{name}")
|
|
201
|
+
except (OSError, ValueError, AttributeError, KeyError, TypeError, RuntimeError) as exc:
|
|
202
|
+
issue(issues, "consistency_unavailable", str(exc), "consistency")
|
|
203
|
+
|
|
204
|
+
profile = ((controls or {}).get("output_profile") or {}).get("kind")
|
|
205
|
+
for name in PROFILE_REQUIREMENTS.get(str(profile), set()):
|
|
206
|
+
if name not in consistency_paths:
|
|
207
|
+
issue(issues, "missing_profile_contract", f"{profile} requires consistency.{name}", f"consistency.{name}")
|
|
208
|
+
if profile not in PROFILE_REQUIREMENTS:
|
|
209
|
+
issue(issues, "unknown_output_profile", "control output_profile.kind is unsupported for runtime bridging", "controls.output_profile.kind")
|
|
210
|
+
|
|
211
|
+
output_paths: dict[Path, dict[str, Any]] = {}
|
|
212
|
+
for entry in ((export or {}).get("outputs") or []):
|
|
213
|
+
if isinstance(entry, dict):
|
|
214
|
+
path = resolve(root, entry.get("path"))
|
|
215
|
+
if path is not None:
|
|
216
|
+
output_paths[path] = entry
|
|
217
|
+
action = consistency_results.get("action_set", {}).get("metrics", {}).get("actions", [])
|
|
218
|
+
action_doc = load_json(consistency_paths["action_set"], issues, "action set") if "action_set" in consistency_paths else None
|
|
219
|
+
action_frames: list[Path] = []
|
|
220
|
+
if isinstance(action_doc, dict):
|
|
221
|
+
for item in action_doc.get("actions", []):
|
|
222
|
+
if isinstance(item, dict):
|
|
223
|
+
action_frames.extend((consistency_paths["action_set"].parent / str(frame)).resolve() for frame in item.get("frames", []) if safe(frame))
|
|
224
|
+
profile_data = (controls or {}).get("output_profile") or {}
|
|
225
|
+
expected_count = profile_data.get("expected_frame_count")
|
|
226
|
+
if profile in {"frame_sequence", "sprite_atlas"} and isinstance(expected_count, int) and len(action_frames) != expected_count:
|
|
227
|
+
issue(issues, "frame_count_mismatch", "control expected_frame_count must match action-set frame count", "controls.output_profile.expected_frame_count")
|
|
228
|
+
if profile in {"frame_sequence", "sprite_atlas"}:
|
|
229
|
+
for frame in action_frames:
|
|
230
|
+
if frame not in output_paths:
|
|
231
|
+
issue(issues, "unexported_action_frame", f"action frame is not hash-bound in export manifest: {frame.name}", "export_manifest.outputs")
|
|
232
|
+
geometry_doc = load_json(consistency_paths["frame_geometry"], issues, "frame geometry") if "frame_geometry" in consistency_paths else None
|
|
233
|
+
if isinstance(geometry_doc, dict):
|
|
234
|
+
for frame in geometry_doc.get("frames", []):
|
|
235
|
+
if isinstance(frame, dict) and safe(frame.get("image")):
|
|
236
|
+
path = (consistency_paths["frame_geometry"].parent / str(frame["image"])).resolve()
|
|
237
|
+
export_entry = output_paths.get(path)
|
|
238
|
+
if export_entry is None:
|
|
239
|
+
issue(issues, "unexported_geometry_frame", f"geometry frame is not hash-bound in export manifest: {path.name}", "export_manifest.outputs")
|
|
240
|
+
elif not isinstance(frame.get("sha256"), str) or frame.get("sha256") != export_entry.get("sha256") or digest(path) != frame.get("sha256"):
|
|
241
|
+
issue(issues, "frame_hash_mismatch", f"geometry/export hash mismatch: {path.name}", "frame_geometry.frames")
|
|
242
|
+
|
|
243
|
+
runtime_verified = False
|
|
244
|
+
if "runtime_evidence_ref" in runtime:
|
|
245
|
+
evidence_path = resolve(root, runtime.get("runtime_evidence_ref"))
|
|
246
|
+
if evidence_path is None:
|
|
247
|
+
issue(issues, "missing_or_unsafe_runtime_evidence", "runtime.runtime_evidence_ref must be an existing safe relative file", "runtime.runtime_evidence_ref")
|
|
248
|
+
else:
|
|
249
|
+
runtime_verified = validate_runtime_evidence(evidence_path, issues)
|
|
250
|
+
if args.strict and not runtime_verified:
|
|
251
|
+
issue(issues, "runtime_evidence_required", "strict runtime candidate validation requires passing runtime evidence", "runtime.runtime_evidence_ref")
|
|
252
|
+
return result(candidate, issues, consistency_results, intake_result, runtime_verified)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def result(candidate: dict[str, Any] | None, issues: list[Issue], consistency: dict[str, Any], intake: dict[str, Any] | None, runtime_verified: bool) -> dict[str, Any]:
|
|
256
|
+
errors = [asdict(item) for item in issues if item.severity == "error"]
|
|
257
|
+
warnings = [asdict(item) for item in issues if item.severity == "warning"]
|
|
258
|
+
ready = not errors
|
|
259
|
+
return {
|
|
260
|
+
"contract": "runtime-candidate",
|
|
261
|
+
"status": "blocked" if errors else "review_required",
|
|
262
|
+
"runtime_test_ready": ready,
|
|
263
|
+
"runtime_verified": runtime_verified,
|
|
264
|
+
"review_required": True,
|
|
265
|
+
"production_eligible": False,
|
|
266
|
+
"production_approved": False,
|
|
267
|
+
"candidate_id": (candidate or {}).get("candidate_id"),
|
|
268
|
+
"asset_id": (candidate or {}).get("asset_id"),
|
|
269
|
+
"intake": {"status": (intake or {}).get("status"), "adapter": (intake or {}).get("adapter")},
|
|
270
|
+
"consistency": {name: {"status": value.get("status"), "ready": value.get("ready")} for name, value in consistency.items()},
|
|
271
|
+
"errors": errors,
|
|
272
|
+
"warnings": warnings,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main(argv: list[str] | None = None) -> int:
|
|
277
|
+
parser = argparse.ArgumentParser(description="Bind MotionLoom artifact intake and consistency evidence into a runtime-test candidate")
|
|
278
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
279
|
+
for command in ("validate", "report"):
|
|
280
|
+
item = sub.add_parser(command)
|
|
281
|
+
item.add_argument("--input", required=True, type=Path)
|
|
282
|
+
item.add_argument("--root", type=Path, default=Path("."))
|
|
283
|
+
item.add_argument("--strict", action="store_true")
|
|
284
|
+
item.add_argument("--output", type=Path)
|
|
285
|
+
item.add_argument("--json", action="store_true")
|
|
286
|
+
args = parser.parse_args(argv)
|
|
287
|
+
payload = validate_candidate(args)
|
|
288
|
+
if args.output:
|
|
289
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
291
|
+
if args.command == "validate" or args.json:
|
|
292
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
293
|
+
else:
|
|
294
|
+
print(f"runtime-candidate: {payload['status']} ({len(payload['errors'])} errors, {len(payload['warnings'])} warnings)")
|
|
295
|
+
for entry in payload["errors"] + payload["warnings"]:
|
|
296
|
+
print(f"- {entry['severity']}: {entry['code']}: {entry['message']}")
|
|
297
|
+
return 0 if payload["runtime_test_ready"] else 1
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
raise SystemExit(main())
|