motionloom 2.0.0 → 2.1.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 +41 -0
- package/CODE_OF_CONDUCT.md +19 -0
- package/CONTRIBUTING.md +65 -0
- package/README.md +187 -134
- package/ROADMAP.md +32 -0
- package/SECURITY.md +27 -0
- package/SKILL.md +33 -8
- package/SUPPORT.md +23 -0
- package/agent-card.json +21 -6
- package/bin/motionloom.mjs +23 -5
- package/docs/STATUS.md +33 -0
- package/docs/audits/2.1.0-deep-stress-evaluation.md +97 -0
- package/docs/audits/data/2.1.0-deep-stress-6900.json +329 -0
- package/docs/audits/data/deep-stress-latest.json +329 -0
- package/docs/audits/external-project-corpus-2026-08-13.md +26 -0
- package/docs/releases/2.1.0.md +23 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/package.json +52 -26
- package/references/intelligence-core.md +1 -1
- package/schemas/project-memory.schema.json +180 -0
- package/scripts/analyze.py +56 -0
- package/scripts/capture-runtime-telemetry.py +119 -0
- package/scripts/devlab.py +126 -0
- package/scripts/docs-audit.py +96 -0
- package/scripts/eval-intelligence.py +23 -0
- package/scripts/eval-projects.py +156 -0
- package/scripts/intelligence.py +106 -6
- package/scripts/pr.py +150 -0
- package/scripts/prepack-clean.mjs +37 -0
- package/scripts/project-memory.py +483 -0
- package/scripts/project_memory_loader.py +31 -0
- package/scripts/release-verify.py +52 -0
- package/scripts/render.py +65 -0
- package/scripts/report.py +44 -2
- package/scripts/review-hook.py +13 -2
- package/scripts/skill-doctor.py +12 -2
- package/scripts/to-dotlottie.mjs +26 -20
- package/src/core/analyzer.py +174 -25
- package/tests/evals/intelligence-cases.json +10 -0
- package/tests/evals/project-corpus.json +51 -0
- package/tests/scripts/run_tests.py +52 -1
- package/tests/scripts/test_project_memory.py +129 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Small import-safe bridge for the cross-platform analyzer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _module():
|
|
10
|
+
path = Path(__file__).with_name("project-memory.py")
|
|
11
|
+
spec = importlib.util.spec_from_file_location("motionloom_project_memory", path)
|
|
12
|
+
if spec is None or spec.loader is None:
|
|
13
|
+
raise RuntimeError(f"Cannot load Project Memory module: {path}")
|
|
14
|
+
module = importlib.util.module_from_spec(spec)
|
|
15
|
+
spec.loader.exec_module(module)
|
|
16
|
+
return module
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def refresh_if_present(root: Path, context_path: Path, initialize: bool = False):
|
|
20
|
+
module = _module()
|
|
21
|
+
memory_path = (root / ".motionloom" / "project-memory.json").resolve()
|
|
22
|
+
if not memory_path.exists():
|
|
23
|
+
if not initialize:
|
|
24
|
+
return None
|
|
25
|
+
memory = module.base_memory(root, str(context_path))
|
|
26
|
+
module.write_atomic(memory_path, memory)
|
|
27
|
+
return {"status": "initialized", "memory_path": str(memory_path), "freshness": memory["freshness"]}
|
|
28
|
+
memory = module.load_or_fail(memory_path)
|
|
29
|
+
refreshed = module.refresh_memory(memory, root, str(context_path))
|
|
30
|
+
module.write_atomic(memory_path, refreshed)
|
|
31
|
+
return {"status": refreshed["freshness"]["status"], "memory_path": str(memory_path), "freshness": refreshed["freshness"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify the immutable metadata chain before an npm/GitHub release."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
parser = argparse.ArgumentParser(description="Verify package version, changelog and release note alignment.")
|
|
18
|
+
parser.add_argument("--expected-version", default="")
|
|
19
|
+
parser.add_argument("--package", default=str(ROOT / "package.json"))
|
|
20
|
+
parser.add_argument("--changelog", default=str(ROOT / "CHANGELOG.md"))
|
|
21
|
+
parser.add_argument("--release-note", default="")
|
|
22
|
+
parser.add_argument("--tag", default="", help="Optional Git tag; accepts v<version> or <version>")
|
|
23
|
+
args = parser.parse_args()
|
|
24
|
+
|
|
25
|
+
errors: list[str] = []
|
|
26
|
+
package_path = Path(args.package).resolve()
|
|
27
|
+
package = json.loads(package_path.read_text(encoding="utf-8"))
|
|
28
|
+
actual = str(package.get("version", ""))
|
|
29
|
+
expected = str(args.expected_version or actual).removeprefix("v")
|
|
30
|
+
if actual != expected:
|
|
31
|
+
errors.append(f"package version {actual!r} does not match expected {expected!r}")
|
|
32
|
+
|
|
33
|
+
changelog = Path(args.changelog).resolve()
|
|
34
|
+
if not re.search(rf"^## \[{re.escape(expected)}\](?:\s|$)", changelog.read_text(encoding="utf-8"), re.MULTILINE):
|
|
35
|
+
errors.append(f"CHANGELOG.md has no heading for [{expected}]")
|
|
36
|
+
|
|
37
|
+
note = Path(args.release_note) if args.release_note else ROOT / "docs/releases" / f"{expected}.md"
|
|
38
|
+
if not note.is_absolute():
|
|
39
|
+
note = ROOT / note
|
|
40
|
+
if not note.is_file():
|
|
41
|
+
errors.append(f"release note is missing: {note.relative_to(ROOT) if note.is_relative_to(ROOT) else note}")
|
|
42
|
+
|
|
43
|
+
if args.tag and args.tag.removeprefix("v") != expected:
|
|
44
|
+
errors.append(f"tag {args.tag!r} does not match version {expected!r}")
|
|
45
|
+
|
|
46
|
+
report = {"status": "fail" if errors else "pass", "version": actual, "expected_version": expected, "errors": errors}
|
|
47
|
+
print(json.dumps(report, indent=2))
|
|
48
|
+
return 1 if errors else 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MotionLoom render entrypoint.
|
|
4
|
+
|
|
5
|
+
Style contract: evidence-first, explicit runtime mode and no hidden approval
|
|
6
|
+
side effects. This is the cross-platform equivalent of render.sh and delegates
|
|
7
|
+
to the canonical Python snapshot renderer without requiring Bash.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SCENE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(description="Render MotionLoom runtime snapshots")
|
|
24
|
+
parser.add_argument("scene")
|
|
25
|
+
parser.add_argument("--repo", default=None, help="Repository root; defaults to the script parent")
|
|
26
|
+
parser.add_argument("--progress", default="0,50,100")
|
|
27
|
+
parser.add_argument("--allow-placeholder", action="store_true")
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
if not SCENE_RE.fullmatch(args.scene):
|
|
31
|
+
parser.error("scene id contains unsafe path characters")
|
|
32
|
+
|
|
33
|
+
repo = Path(args.repo).expanduser().resolve() if args.repo else Path(__file__).resolve().parents[1]
|
|
34
|
+
scene_dir = repo / "src" / "output" / args.scene
|
|
35
|
+
if not scene_dir.is_dir():
|
|
36
|
+
print(f"error: scene directory not found: {scene_dir}", file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
progress = [int(value) for value in args.progress.split(",") if value.strip()]
|
|
41
|
+
except ValueError:
|
|
42
|
+
print("error: progress must be a comma-separated list of integers", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
if not progress or any(value < 0 or value > 100 for value in progress):
|
|
46
|
+
print("error: progress values must be between 0 and 100", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
# Import by path-independent package layout. The repository root is added
|
|
50
|
+
# only for this process; no cwd or shell-specific import assumptions.
|
|
51
|
+
sys.path.insert(0, str(repo))
|
|
52
|
+
from src.core.snapshot import render_snapshots # pylint: disable=import-outside-toplevel
|
|
53
|
+
|
|
54
|
+
result = render_snapshots(
|
|
55
|
+
args.scene,
|
|
56
|
+
scene_dir,
|
|
57
|
+
progress,
|
|
58
|
+
allow_placeholder=args.allow_placeholder or os.environ.get("ALLOW_PLACEHOLDER") == "1",
|
|
59
|
+
)
|
|
60
|
+
print(__import__("json").dumps(result, indent=2))
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main())
|
package/scripts/report.py
CHANGED
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
import argparse
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
|
+
import shutil
|
|
9
10
|
import sys
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from pathlib import Path
|
|
@@ -49,6 +50,39 @@ def read_json(path: Path, default: dict | list | None = None):
|
|
|
49
50
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
50
51
|
|
|
51
52
|
|
|
53
|
+
def project_memory_path() -> Path:
|
|
54
|
+
return ROOT / ".motionloom" / "project-memory.json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def memory_summary() -> dict | None:
|
|
58
|
+
path = project_memory_path()
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
memory = read_json(path)
|
|
63
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
64
|
+
except (OSError, json.JSONDecodeError):
|
|
65
|
+
return {"path": ".motionloom/project-memory.json", "status": "invalid"}
|
|
66
|
+
freshness = memory.get("freshness") if isinstance(memory, dict) else {}
|
|
67
|
+
return {
|
|
68
|
+
"path": ".motionloom/project-memory.json",
|
|
69
|
+
"snapshot_path": "project-memory.json",
|
|
70
|
+
"memory_id": memory.get("memory_id"),
|
|
71
|
+
"status": freshness.get("status", "invalid"),
|
|
72
|
+
"sha256": digest,
|
|
73
|
+
"updated_at": memory.get("updated_at"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sync_memory_snapshot(task_dir: Path) -> dict | None:
|
|
78
|
+
source = project_memory_path()
|
|
79
|
+
if not source.is_file():
|
|
80
|
+
return None
|
|
81
|
+
destination = task_dir / "project-memory.json"
|
|
82
|
+
shutil.copy2(source, destination)
|
|
83
|
+
return memory_summary()
|
|
84
|
+
|
|
85
|
+
|
|
52
86
|
def has_symlink_component(path: Path) -> bool:
|
|
53
87
|
current = path
|
|
54
88
|
while True:
|
|
@@ -78,6 +112,7 @@ def candidate_is_current(candidate: dict) -> bool:
|
|
|
78
112
|
def init_task(args: argparse.Namespace) -> int:
|
|
79
113
|
task_dir = Path(args.output or ROOT / "artifacts" / args.task_id).resolve()
|
|
80
114
|
timestamp = now()
|
|
115
|
+
memory = memory_summary()
|
|
81
116
|
task = {
|
|
82
117
|
"schema_version": "1.0",
|
|
83
118
|
"task_id": args.task_id,
|
|
@@ -92,6 +127,8 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
92
127
|
"created_at": timestamp,
|
|
93
128
|
"updated_at": timestamp,
|
|
94
129
|
}
|
|
130
|
+
if memory:
|
|
131
|
+
task["project_memory"] = memory
|
|
95
132
|
report = {
|
|
96
133
|
"report_version": "1.0",
|
|
97
134
|
"task_id": args.task_id,
|
|
@@ -103,7 +140,7 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
103
140
|
"problems": [],
|
|
104
141
|
"structure_review": {"missing_files": [], "broken_references": [], "untracked_artifacts": []},
|
|
105
142
|
"browser_review": [],
|
|
106
|
-
"next_agent": [{"agent": args.agent, "action": "
|
|
143
|
+
"next_agent": [{"agent": args.agent, "action": "Recover Project Memory and run project analysis before generation.", "evidence_needed": ["project-memory.json", "project-context.json"]}],
|
|
107
144
|
"generated_at": timestamp,
|
|
108
145
|
}
|
|
109
146
|
handoff = {
|
|
@@ -114,7 +151,7 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
114
151
|
"state": "created",
|
|
115
152
|
"summary": "New animation task initialized.",
|
|
116
153
|
"next_actions": [{"action": "Analyze host project context", "skill": "motionloom", "evidence_needed": ["project-context.json"]}],
|
|
117
|
-
"required_artifacts": ["task.json", "execution-report.json", *EVIDENCE_ARTIFACTS],
|
|
154
|
+
"required_artifacts": ["task.json", "execution-report.json", *EVIDENCE_ARTIFACTS, *( ["project-memory.json"] if memory else [] )],
|
|
118
155
|
"blockers": [],
|
|
119
156
|
}
|
|
120
157
|
write_json(task_dir / "task.json", task)
|
|
@@ -122,6 +159,8 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
122
159
|
write_json(task_dir / "issue-register.json", {"version": "1.0", "task_id": args.task_id, "issues": []})
|
|
123
160
|
write_json(task_dir / "handoff.json", handoff)
|
|
124
161
|
write_json(task_dir / "artifact-manifest.json", {"manifest_version": "1.0", "task_id": args.task_id, "generated_at": timestamp, "artifacts": []})
|
|
162
|
+
if memory:
|
|
163
|
+
sync_memory_snapshot(task_dir)
|
|
125
164
|
(task_dir / "decision-log.jsonl").write_text("", encoding="utf-8")
|
|
126
165
|
print(json.dumps({"status": "created", "task_id": args.task_id, "task_dir": str(task_dir)}, ensure_ascii=False))
|
|
127
166
|
return 0
|
|
@@ -130,6 +169,7 @@ def init_task(args: argparse.Namespace) -> int:
|
|
|
130
169
|
def collect(args: argparse.Namespace) -> int:
|
|
131
170
|
task_dir = Path(args.task_dir).resolve()
|
|
132
171
|
task = read_json(task_dir / "task.json")
|
|
172
|
+
sync_memory_snapshot(task_dir)
|
|
133
173
|
excluded = {"artifact-manifest.json", "execution-report.json", "decision-log.jsonl"}
|
|
134
174
|
artifacts = []
|
|
135
175
|
for path in sorted(task_dir.rglob("*")):
|
|
@@ -146,6 +186,8 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
146
186
|
handoff = read_json(handoff_path)
|
|
147
187
|
required = set(handoff.get("required_artifacts", []))
|
|
148
188
|
required.update(name for name in EVIDENCE_ARTIFACTS if (task_dir / name).is_file())
|
|
189
|
+
if (task_dir / "project-memory.json").is_file():
|
|
190
|
+
required.add("project-memory.json")
|
|
149
191
|
handoff["required_artifacts"] = sorted(required)
|
|
150
192
|
write_json(handoff_path, handoff)
|
|
151
193
|
print(json.dumps({"status": "collected", "task_id": manifest["task_id"], "artifact_count": len(artifacts)}, ensure_ascii=False))
|
package/scripts/review-hook.py
CHANGED
|
@@ -7,6 +7,7 @@ import argparse
|
|
|
7
7
|
import hashlib
|
|
8
8
|
import json
|
|
9
9
|
import re
|
|
10
|
+
import shutil
|
|
10
11
|
import subprocess
|
|
11
12
|
import sys
|
|
12
13
|
from datetime import datetime, timedelta, timezone
|
|
@@ -68,6 +69,11 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
68
69
|
if not scene:
|
|
69
70
|
raise ValueError("task.json requires scene")
|
|
70
71
|
scene_dir, source_path, render_meta, context_path = paths(task_dir, task)
|
|
72
|
+
memory_path = ROOT / ".motionloom" / "project-memory.json"
|
|
73
|
+
memory = read_json(memory_path, {}) if memory_path.is_file() else {}
|
|
74
|
+
memory_status = (memory.get("freshness") or {}).get("status")
|
|
75
|
+
if memory_path.is_file() and memory_status not in {"fresh", None}:
|
|
76
|
+
raise ValueError(f"project memory is {memory_status}; run motionloom memory refresh/analyze before browser review")
|
|
71
77
|
spec = read_json(scene_dir / "motion-spec.json")
|
|
72
78
|
context_hash = (spec.get("context_binding") or {}).get("context_sha256") or sha256(context_path)
|
|
73
79
|
source_hash = sha256(source_path)
|
|
@@ -92,6 +98,9 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
92
98
|
"prepared_at": now(),
|
|
93
99
|
"expires_at": (datetime.now(timezone.utc) + CANDIDATE_TTL).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
94
100
|
}
|
|
101
|
+
if memory_path.is_file():
|
|
102
|
+
candidate["project_memory_sha256"] = sha256(memory_path)
|
|
103
|
+
candidate["project_memory_id"] = memory.get("memory_id")
|
|
95
104
|
(scene_dir / "browser-review.json").write_text(json.dumps(candidate, indent=2) + "\n", encoding="utf-8")
|
|
96
105
|
task["state"] = "review_required"
|
|
97
106
|
task["updated_at"] = now()
|
|
@@ -109,9 +118,11 @@ def prepare(args: argparse.Namespace) -> int:
|
|
|
109
118
|
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
110
119
|
handoff_path = task_dir / "handoff.json"
|
|
111
120
|
handoff = read_json(handoff_path)
|
|
112
|
-
|
|
121
|
+
if memory_path.is_file():
|
|
122
|
+
shutil.copy2(memory_path, task_dir / "project-memory.json")
|
|
123
|
+
handoff.update({"state": "review_required", "to_agent": "browser-review-agent", "summary": "Open the exact rendered candidate in the internal Dev Lab and obtain user approval before PR.", "next_actions": [{"action": "Open internal Dev Lab candidate", "kind": "browser_review", "agent": "browser-review-agent", "skill": "browser-review", "url": url, "candidate_id": cid, "requires_user_approval": True, "evidence_needed": ["review.json"], "output_artifacts": ["review.json"]}], "required_artifacts": sorted(set(handoff.get("required_artifacts", []) + ["browser-review.json", "review.json", "semantic-lint-benchmark.json", "evidence-verifier-report.json", "runtime-adapters/runtime-evidence.json"] + (["project-memory.json"] if memory_path.is_file() else [])))})
|
|
113
124
|
handoff_path.write_text(json.dumps(handoff, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
114
|
-
subprocess.run([
|
|
125
|
+
subprocess.run([sys.executable, str(ROOT / "scripts/devlab.py"), scene, "--prepare-only", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
|
|
115
126
|
subprocess.run([sys.executable, str(ROOT / "scripts/report.py"), "collect", "--task-dir", str(task_dir)], check=True, capture_output=True, text=True)
|
|
116
127
|
print(json.dumps({"status": "review_required", "task_id": task["task_id"], "candidate_id": cid, "url": url, "agent": "browser-review-agent", "action": "Open this exact URL in the internal browser; ask the user to inspect frames 0/50/100 and approve or request changes.", "requires_user_approval": True, "output_artifacts": ["review.json"]}, ensure_ascii=False))
|
|
117
128
|
return 0
|
package/scripts/skill-doctor.py
CHANGED
|
@@ -13,7 +13,16 @@ from pathlib import Path
|
|
|
13
13
|
ROOT = Path(__file__).resolve().parents[1]
|
|
14
14
|
REQUIRED_FILES = ["SKILL.md", "agent-card.json", "package.json"]
|
|
15
15
|
REQUIRED_DIRS = ["scripts", "templates", "references", "schemas"]
|
|
16
|
-
REQUIRED_SCRIPT_FILES = [
|
|
16
|
+
REQUIRED_SCRIPT_FILES = [
|
|
17
|
+
"scripts/report-contract.py",
|
|
18
|
+
"scripts/review-hook.py",
|
|
19
|
+
"scripts/quality-gate.py",
|
|
20
|
+
"scripts/runtime-adapters.mjs",
|
|
21
|
+
"scripts/project-memory.py",
|
|
22
|
+
"scripts/project_memory_loader.py",
|
|
23
|
+
"scripts/analyze.py",
|
|
24
|
+
"scripts/devlab.py",
|
|
25
|
+
]
|
|
17
26
|
REQUIRED_SCHEMAS = [
|
|
18
27
|
"task.schema.json",
|
|
19
28
|
"execution-report.schema.json",
|
|
@@ -26,6 +35,7 @@ REQUIRED_SCHEMAS = [
|
|
|
26
35
|
"provenance.schema.json",
|
|
27
36
|
"capability-registry.schema.json",
|
|
28
37
|
"motion-ir.schema.json",
|
|
38
|
+
"project-memory.schema.json",
|
|
29
39
|
]
|
|
30
40
|
|
|
31
41
|
|
|
@@ -121,7 +131,7 @@ def run() -> int:
|
|
|
121
131
|
package_path = ROOT / "package.json"
|
|
122
132
|
try:
|
|
123
133
|
package = json.loads(package_path.read_text(encoding="utf-8"))
|
|
124
|
-
for script in ("test", "validate", "doctor", "report", "report:check", "review"):
|
|
134
|
+
for script in ("test", "validate", "doctor", "report", "report:check", "review", "memory:bootstrap", "memory:recover", "memory:validate", "devlab", "pack:dotlottie"):
|
|
125
135
|
if script not in package.get("scripts", {}):
|
|
126
136
|
warnings.append({"code": "missing_package_script", "message": f"package.json has no {script} script."})
|
|
127
137
|
except (FileNotFoundError, json.JSONDecodeError) as exc:
|
package/scripts/to-dotlottie.mjs
CHANGED
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import crypto from "node:crypto";
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
-
import os from "node:os";
|
|
10
9
|
import path from "node:path";
|
|
11
|
-
import {
|
|
10
|
+
import { strFromU8, zipSync, unzipSync } from "fflate";
|
|
12
11
|
|
|
13
12
|
function arg(name, fallback = undefined) {
|
|
14
13
|
const index = process.argv.indexOf(name);
|
|
@@ -43,17 +42,14 @@ if (!/^[a-zA-Z0-9._ -]+$/.test(animationId)) {
|
|
|
43
42
|
}
|
|
44
43
|
readJson(sourcePath);
|
|
45
44
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
fs.copyFileSync(sourcePath, path.join(archiveRoot, "a", `${animationId}.json`));
|
|
45
|
+
const archive = {
|
|
46
|
+
[`a/${animationId}.json`]: fs.readFileSync(sourcePath),
|
|
47
|
+
};
|
|
50
48
|
|
|
51
49
|
const optionalDirectories = ["i", "t", "s", "f"];
|
|
52
50
|
for (const directory of optionalDirectories) {
|
|
53
51
|
const candidate = path.join(sceneDir, "dotlottie", directory);
|
|
54
|
-
if (fs.existsSync(candidate))
|
|
55
|
-
fs.cpSync(candidate, path.join(archiveRoot, directory), { recursive: true });
|
|
56
|
-
}
|
|
52
|
+
if (fs.existsSync(candidate)) addTree(candidate, directory);
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
const dotManifest = {
|
|
@@ -62,20 +58,13 @@ const dotManifest = {
|
|
|
62
58
|
initial: { animation: animationId },
|
|
63
59
|
animations: [{ id: animationId }],
|
|
64
60
|
};
|
|
65
|
-
|
|
66
|
-
path.join(archiveRoot, "manifest.json"),
|
|
67
|
-
`${JSON.stringify(dotManifest, null, 2)}\n`,
|
|
68
|
-
"utf8",
|
|
69
|
-
);
|
|
61
|
+
archive["manifest.json"] = Buffer.from(`${JSON.stringify(dotManifest, null, 2)}\n`, "utf8");
|
|
70
62
|
|
|
71
63
|
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
72
64
|
if (fs.existsSync(output)) fs.rmSync(output, { force: true });
|
|
73
|
-
|
|
65
|
+
fs.writeFileSync(output, zipSync(archive, { level: 6 }));
|
|
74
66
|
|
|
75
|
-
const entries =
|
|
76
|
-
.trim()
|
|
77
|
-
.split(/\r?\n/)
|
|
78
|
-
.filter(Boolean);
|
|
67
|
+
const entries = Object.keys(unzipSync(fs.readFileSync(output))).sort();
|
|
79
68
|
if (!entries.includes("manifest.json")) throw new Error("archive missing manifest.json");
|
|
80
69
|
if (!entries.includes(`a/${animationId}.json`)) {
|
|
81
70
|
throw new Error("archive missing initial animation payload");
|
|
@@ -95,5 +84,22 @@ console.log(JSON.stringify({
|
|
|
95
84
|
}, null, 2));
|
|
96
85
|
|
|
97
86
|
function readJsonFromZip(file, entry) {
|
|
98
|
-
|
|
87
|
+
const payload = unzipSync(fs.readFileSync(file))[entry];
|
|
88
|
+
if (!payload) throw new Error(`archive entry is missing: ${entry}`);
|
|
89
|
+
return JSON.parse(strFromU8(payload));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function addTree(directory, archivePrefix) {
|
|
93
|
+
const entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const sourceEntry = path.join(directory, entry.name);
|
|
96
|
+
const archiveEntry = `${archivePrefix}/${entry.name.replaceAll("\\", "/")}`;
|
|
97
|
+
const stat = fs.lstatSync(sourceEntry);
|
|
98
|
+
if (stat.isSymbolicLink()) continue;
|
|
99
|
+
if (stat.isDirectory()) {
|
|
100
|
+
addTree(sourceEntry, archiveEntry);
|
|
101
|
+
} else if (stat.isFile()) {
|
|
102
|
+
archive[archiveEntry] = fs.readFileSync(sourceEntry);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
99
105
|
}
|