motionloom 2.0.0 → 2.2.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/.agents/skills/motionloom/SKILL.md +14 -0
- package/.claude/skills/motionloom.md +5 -0
- package/.codex/skills/motionloom.md +11 -0
- package/AGENTS.md +17 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +19 -0
- package/CONTRIBUTING.md +65 -0
- package/README.md +193 -134
- package/ROADMAP.md +36 -0
- package/SECURITY.md +28 -0
- package/SKILL.md +57 -9
- package/SUPPORT.md +23 -0
- package/agent-card.json +42 -6
- package/agent-surfaces.json +79 -0
- package/bin/motionloom.mjs +33 -5
- package/docs/AGENT-INTEGRATION.md +47 -0
- package/docs/CHECKLIST.md +2 -1
- package/docs/STATUS.md +33 -0
- package/docs/audits/2.1.0-deep-stress-evaluation.md +97 -0
- package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -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/2.2.0.md +35 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/examples/agent-consumer/README.md +18 -0
- package/examples/agent-consumer/fixture-manifest.json +82 -0
- package/package.json +69 -28
- package/references/agent-interoperability.md +29 -0
- package/references/intelligence-core.md +5 -1
- package/schemas/agent-surfaces.schema.json +78 -0
- package/schemas/project-memory.schema.json +180 -0
- package/schemas/remediation-history.schema.json +23 -0
- package/schemas/scene-manifest.schema.json +1 -0
- package/schemas/visual-truth.schema.json +80 -0
- package/scripts/analyze.py +56 -0
- package/scripts/capture-runtime-telemetry.py +119 -0
- package/scripts/devlab.py +126 -0
- package/scripts/discovery.py +257 -0
- package/scripts/docs-audit.py +112 -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 +151 -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/quality-gate.py +43 -3
- package/scripts/release-verify.py +52 -0
- package/scripts/remediation-learning.py +326 -0
- package/scripts/render.py +65 -0
- package/scripts/report.py +60 -2
- package/scripts/review-hook.py +13 -2
- package/scripts/skill-doctor.py +12 -2
- package/scripts/to-dotlottie.mjs +26 -20
- package/scripts/visual-truth.py +310 -0
- package/src/core/analyzer.py +174 -25
- package/src/output/browser-review-smoke/manifest.json +1 -0
- package/src/output/browser-review-smoke/visual-truth.json +68 -0
- package/tests/evals/intelligence-cases.json +10 -0
- package/tests/evals/project-corpus.json +51 -0
- package/tests/scripts/run_tests.py +111 -1
- package/tests/scripts/test_project_memory.py +129 -0
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
|
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build and validate MotionLoom's provenance-bound visual truth contract.
|
|
3
|
+
|
|
4
|
+
The contract is deliberately a review aid, not an approval engine. It compares
|
|
5
|
+
real rendered PNG frames, records image dimensions and SHA-256 digests, emits a
|
|
6
|
+
small deterministic perceptual summary, and keeps user approval false until a
|
|
7
|
+
separate browser review artifact records a human decision.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import struct
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = "1.0"
|
|
22
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
23
|
+
SHA256_RE = 64
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sha256(path: Path) -> str:
|
|
27
|
+
digest = hashlib.sha256()
|
|
28
|
+
with path.open("rb") as handle:
|
|
29
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
30
|
+
digest.update(chunk)
|
|
31
|
+
return digest.hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def json_sha256(path: Path) -> str:
|
|
35
|
+
return sha256(path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_json(path: Path) -> dict:
|
|
39
|
+
try:
|
|
40
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
42
|
+
raise ValueError(f"invalid JSON {path}: {exc}") from exc
|
|
43
|
+
if not isinstance(value, dict):
|
|
44
|
+
raise ValueError(f"expected JSON object: {path}")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def png_dimensions(path: Path) -> tuple[int, int]:
|
|
49
|
+
"""Read PNG dimensions without requiring Pillow or a system image tool."""
|
|
50
|
+
with path.open("rb") as handle:
|
|
51
|
+
if handle.read(8) != PNG_SIGNATURE:
|
|
52
|
+
raise ValueError(f"not a PNG file: {path}")
|
|
53
|
+
length = struct.unpack(">I", handle.read(4))[0]
|
|
54
|
+
chunk = handle.read(4)
|
|
55
|
+
if chunk != b"IHDR" or length < 8:
|
|
56
|
+
raise ValueError(f"PNG has no IHDR: {path}")
|
|
57
|
+
width, height = struct.unpack(">II", handle.read(8))
|
|
58
|
+
if width <= 0 or height <= 0:
|
|
59
|
+
raise ValueError(f"PNG dimensions are invalid: {path}")
|
|
60
|
+
return width, height
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def relpath(path: Path, root: Path) -> str:
|
|
64
|
+
resolved = path.resolve()
|
|
65
|
+
try:
|
|
66
|
+
return resolved.relative_to(root.resolve()).as_posix()
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise ValueError(f"path must be inside repository root: {path}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def display_path(path: Path, root: Path) -> str:
|
|
72
|
+
"""Render an output path without weakening repository-bound evidence paths."""
|
|
73
|
+
try:
|
|
74
|
+
return relpath(path, root)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return path.resolve().as_posix()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def frame_record(path: Path, root: Path, role: str, percent: int) -> dict:
|
|
80
|
+
if not path.is_file() or path.stat().st_size == 0:
|
|
81
|
+
raise ValueError(f"{role} frame is missing or empty: {path}")
|
|
82
|
+
width, height = png_dimensions(path)
|
|
83
|
+
return {
|
|
84
|
+
"role": role,
|
|
85
|
+
"percent": percent,
|
|
86
|
+
"path": relpath(path, root),
|
|
87
|
+
"sha256": sha256(path),
|
|
88
|
+
"bytes": path.stat().st_size,
|
|
89
|
+
"width": width,
|
|
90
|
+
"height": height,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def perceptual_summary(baseline: dict, candidate: dict) -> dict:
|
|
95
|
+
"""Return a conservative perceptual signal based on byte identity.
|
|
96
|
+
|
|
97
|
+
A byte-identical PNG is a strong deterministic equality signal. A changed
|
|
98
|
+
PNG is intentionally reported as `review_required`, not as a failed quality
|
|
99
|
+
judgment, because visual acceptability is a user/runtime review decision.
|
|
100
|
+
"""
|
|
101
|
+
same_dimensions = (baseline["width"], baseline["height"]) == (candidate["width"], candidate["height"])
|
|
102
|
+
identical = baseline["sha256"] == candidate["sha256"]
|
|
103
|
+
return {
|
|
104
|
+
"metric": "sha256-image-identity",
|
|
105
|
+
"method": "PNG metadata plus byte identity; no visual approval inferred",
|
|
106
|
+
"same_dimensions": same_dimensions,
|
|
107
|
+
"byte_identical": identical,
|
|
108
|
+
"changed": not identical or not same_dimensions,
|
|
109
|
+
"distance": 0.0 if identical and same_dimensions else 1.0,
|
|
110
|
+
"threshold": 0.0,
|
|
111
|
+
"interpretation": "equal" if identical and same_dimensions else "review_required",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build(args: argparse.Namespace) -> int:
|
|
116
|
+
root = Path(args.root).resolve()
|
|
117
|
+
output = Path(args.output)
|
|
118
|
+
if not output.is_absolute():
|
|
119
|
+
output = (root / output).resolve()
|
|
120
|
+
scene = str(args.scene)
|
|
121
|
+
if not scene or scene in {".", ".."} or "/" in scene or "\\" in scene:
|
|
122
|
+
print("visual-truth: unsafe scene identifier", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
try:
|
|
125
|
+
baseline = Path(args.baseline)
|
|
126
|
+
candidate = Path(args.candidate)
|
|
127
|
+
if not baseline.is_absolute():
|
|
128
|
+
baseline = (root / baseline).resolve()
|
|
129
|
+
if not candidate.is_absolute():
|
|
130
|
+
candidate = (root / candidate).resolve()
|
|
131
|
+
source = Path(args.source)
|
|
132
|
+
manifest = Path(args.manifest)
|
|
133
|
+
runtime_evidence = Path(args.runtime_evidence) if args.runtime_evidence else None
|
|
134
|
+
motion_ir = Path(args.motion_ir) if args.motion_ir else None
|
|
135
|
+
for value in (source, manifest, runtime_evidence, motion_ir):
|
|
136
|
+
if value is not None and not value.is_absolute():
|
|
137
|
+
value = (root / value).resolve()
|
|
138
|
+
# Rebind optional paths after the loop because Path is immutable.
|
|
139
|
+
if args.runtime_evidence:
|
|
140
|
+
runtime_evidence = (root / args.runtime_evidence).resolve() if not Path(args.runtime_evidence).is_absolute() else Path(args.runtime_evidence).resolve()
|
|
141
|
+
if args.motion_ir:
|
|
142
|
+
motion_ir = (root / args.motion_ir).resolve() if not Path(args.motion_ir).is_absolute() else Path(args.motion_ir).resolve()
|
|
143
|
+
source = (root / args.source).resolve() if not Path(args.source).is_absolute() else Path(args.source).resolve()
|
|
144
|
+
manifest = (root / args.manifest).resolve() if not Path(args.manifest).is_absolute() else Path(args.manifest).resolve()
|
|
145
|
+
baseline_record = frame_record(baseline, root, "baseline", args.percent)
|
|
146
|
+
candidate_record = frame_record(candidate, root, "candidate", args.percent)
|
|
147
|
+
manifest_data = load_json(manifest)
|
|
148
|
+
if not source.is_file() or not manifest.is_file():
|
|
149
|
+
raise ValueError("source and manifest must exist")
|
|
150
|
+
runtime_data = load_json(runtime_evidence) if runtime_evidence else None
|
|
151
|
+
motion_data = load_json(motion_ir) if motion_ir else None
|
|
152
|
+
source_hash = sha256(source)
|
|
153
|
+
manifest_hash = sha256(manifest)
|
|
154
|
+
motion_hash = sha256(motion_ir) if motion_ir else None
|
|
155
|
+
runtime_hash = sha256(runtime_evidence) if runtime_evidence else None
|
|
156
|
+
if manifest_data.get("file") and not str(manifest_data["file"]).strip():
|
|
157
|
+
raise ValueError("manifest.file must be non-empty")
|
|
158
|
+
if runtime_data is not None:
|
|
159
|
+
if runtime_data.get("status") != "pass":
|
|
160
|
+
raise ValueError("runtime evidence status must be pass")
|
|
161
|
+
if args.task_id and runtime_data.get("task_id") not in {None, args.task_id}:
|
|
162
|
+
raise ValueError("runtime evidence task_id does not match requested task")
|
|
163
|
+
if runtime_data.get("scene") not in {None, scene}:
|
|
164
|
+
raise ValueError("runtime evidence scene does not match requested scene")
|
|
165
|
+
comparison = perceptual_summary(baseline_record, candidate_record)
|
|
166
|
+
changed_regions = []
|
|
167
|
+
if comparison["changed"]:
|
|
168
|
+
changed_regions.append({
|
|
169
|
+
"id": "full-frame",
|
|
170
|
+
"label": "Full rendered frame",
|
|
171
|
+
"reason": "Baseline and candidate PNG identities differ; inspect the exact candidate in Dev Lab.",
|
|
172
|
+
"severity": "review",
|
|
173
|
+
"evidence": [baseline_record["path"], candidate_record["path"]],
|
|
174
|
+
})
|
|
175
|
+
report = {
|
|
176
|
+
"schema_version": SCHEMA_VERSION,
|
|
177
|
+
"contract": "motionloom-visual-truth",
|
|
178
|
+
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
179
|
+
"status": "review_required" if comparison["changed"] else "pass",
|
|
180
|
+
"scene": scene,
|
|
181
|
+
"task_id": args.task_id or None,
|
|
182
|
+
"frames": {
|
|
183
|
+
"baseline": baseline_record,
|
|
184
|
+
"candidate": candidate_record,
|
|
185
|
+
},
|
|
186
|
+
"comparison": comparison,
|
|
187
|
+
"regions": changed_regions,
|
|
188
|
+
"provenance": {
|
|
189
|
+
"source_path": relpath(source, root),
|
|
190
|
+
"source_sha256": source_hash,
|
|
191
|
+
"manifest_path": relpath(manifest, root),
|
|
192
|
+
"manifest_sha256": manifest_hash,
|
|
193
|
+
"runtime_evidence_path": relpath(runtime_evidence, root) if runtime_evidence else None,
|
|
194
|
+
"runtime_evidence_sha256": runtime_hash,
|
|
195
|
+
"motion_ir_path": relpath(motion_ir, root) if motion_ir else None,
|
|
196
|
+
"motion_ir_sha256": motion_hash,
|
|
197
|
+
"runtime_status": runtime_data.get("status") if runtime_data else None,
|
|
198
|
+
"motion_ir_schema_version": motion_data.get("schema_version") if motion_data else None,
|
|
199
|
+
},
|
|
200
|
+
"review_boundary": {
|
|
201
|
+
"approval": False,
|
|
202
|
+
"user_review_required": True,
|
|
203
|
+
"decision": "pending",
|
|
204
|
+
"pr_side_effects": "explicit-confirmation",
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
209
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
210
|
+
return 0
|
|
211
|
+
except (OSError, ValueError) as exc:
|
|
212
|
+
print(f"visual-truth: {exc}", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def validate_report(path: Path, root: Path | None = None, expected_scene: str | None = None, expected_task_id: str | None = None, expected_source_sha256: str | None = None, expected_manifest_sha256: str | None = None, expected_motion_ir_sha256: str | None = None) -> list[str]:
|
|
217
|
+
root = (root or path.parent).resolve()
|
|
218
|
+
errors: list[str] = []
|
|
219
|
+
try:
|
|
220
|
+
data = load_json(path)
|
|
221
|
+
except ValueError as exc:
|
|
222
|
+
return [str(exc)]
|
|
223
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
224
|
+
errors.append("visual truth schema_version must be 1.0")
|
|
225
|
+
if data.get("contract") != "motionloom-visual-truth":
|
|
226
|
+
errors.append("visual truth contract identifier is invalid")
|
|
227
|
+
if data.get("status") not in {"pass", "review_required"}:
|
|
228
|
+
errors.append("visual truth status must be pass or review_required")
|
|
229
|
+
if expected_scene and data.get("scene") != expected_scene:
|
|
230
|
+
errors.append("visual truth scene does not match requested scene")
|
|
231
|
+
if expected_task_id and data.get("task_id") != expected_task_id:
|
|
232
|
+
errors.append("visual truth task_id does not match task bundle")
|
|
233
|
+
boundary = data.get("review_boundary")
|
|
234
|
+
if not isinstance(boundary, dict) or boundary.get("approval") is not False or boundary.get("user_review_required") is not True:
|
|
235
|
+
errors.append("visual truth must preserve approval=false and user_review_required=true")
|
|
236
|
+
frames = data.get("frames")
|
|
237
|
+
if not isinstance(frames, dict) or not isinstance(frames.get("baseline"), dict) or not isinstance(frames.get("candidate"), dict):
|
|
238
|
+
errors.append("visual truth must contain baseline and candidate frame records")
|
|
239
|
+
else:
|
|
240
|
+
for role in ("baseline", "candidate"):
|
|
241
|
+
frame = frames[role]
|
|
242
|
+
if frame.get("role") != role or not isinstance(frame.get("sha256"), str) or len(frame.get("sha256", "")) != SHA256_RE:
|
|
243
|
+
errors.append(f"visual truth {role} frame hash is invalid")
|
|
244
|
+
frame_path = root / str(frame.get("path", ""))
|
|
245
|
+
try:
|
|
246
|
+
if not frame_path.is_file() or sha256(frame_path) != frame.get("sha256"):
|
|
247
|
+
errors.append(f"visual truth {role} frame hash/path binding is stale")
|
|
248
|
+
elif png_dimensions(frame_path) != (frame.get("width"), frame.get("height")):
|
|
249
|
+
errors.append(f"visual truth {role} frame dimensions are stale")
|
|
250
|
+
except (OSError, ValueError):
|
|
251
|
+
errors.append(f"visual truth {role} frame is not a readable PNG")
|
|
252
|
+
provenance = data.get("provenance")
|
|
253
|
+
if not isinstance(provenance, dict):
|
|
254
|
+
errors.append("visual truth provenance is required")
|
|
255
|
+
else:
|
|
256
|
+
for label, expected in (("source_sha256", expected_source_sha256), ("manifest_sha256", expected_manifest_sha256), ("motion_ir_sha256", expected_motion_ir_sha256)):
|
|
257
|
+
if expected and provenance.get(label) != expected:
|
|
258
|
+
errors.append(f"visual truth {label} does not match current artifact")
|
|
259
|
+
for label in ("source_path", "manifest_path"):
|
|
260
|
+
path_value = provenance.get(label)
|
|
261
|
+
if not isinstance(path_value, str) or Path(path_value).is_absolute() or ".." in Path(path_value).parts:
|
|
262
|
+
errors.append(f"visual truth {label} must be a safe repository-relative path")
|
|
263
|
+
comparison = data.get("comparison")
|
|
264
|
+
if not isinstance(comparison, dict) or comparison.get("interpretation") not in {"equal", "review_required"}:
|
|
265
|
+
errors.append("visual truth comparison interpretation is invalid")
|
|
266
|
+
return errors
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def validate(args: argparse.Namespace) -> int:
|
|
270
|
+
root = Path(args.root).resolve()
|
|
271
|
+
path = Path(args.input)
|
|
272
|
+
if not path.is_absolute():
|
|
273
|
+
path = (root / path).resolve()
|
|
274
|
+
errors = validate_report(path, root, args.scene, args.task_id, args.source_sha256, args.manifest_sha256, args.motion_ir_sha256)
|
|
275
|
+
result = {"status": "pass" if not errors else "fail", "path": display_path(path, root), "errors": errors}
|
|
276
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
277
|
+
return 0 if not errors else 1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main() -> int:
|
|
281
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
282
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
283
|
+
build_parser = sub.add_parser("build")
|
|
284
|
+
build_parser.add_argument("--root", default=".")
|
|
285
|
+
build_parser.add_argument("--scene", required=True)
|
|
286
|
+
build_parser.add_argument("--baseline", required=True)
|
|
287
|
+
build_parser.add_argument("--candidate", required=True)
|
|
288
|
+
build_parser.add_argument("--percent", type=int, default=100)
|
|
289
|
+
build_parser.add_argument("--source", required=True)
|
|
290
|
+
build_parser.add_argument("--manifest", required=True)
|
|
291
|
+
build_parser.add_argument("--runtime-evidence")
|
|
292
|
+
build_parser.add_argument("--motion-ir")
|
|
293
|
+
build_parser.add_argument("--task-id")
|
|
294
|
+
build_parser.add_argument("--output", required=True)
|
|
295
|
+
build_parser.set_defaults(func=build)
|
|
296
|
+
validate_parser = sub.add_parser("validate")
|
|
297
|
+
validate_parser.add_argument("--root", default=".")
|
|
298
|
+
validate_parser.add_argument("--input", required=True)
|
|
299
|
+
validate_parser.add_argument("--scene")
|
|
300
|
+
validate_parser.add_argument("--task-id")
|
|
301
|
+
validate_parser.add_argument("--source-sha256")
|
|
302
|
+
validate_parser.add_argument("--manifest-sha256")
|
|
303
|
+
validate_parser.add_argument("--motion-ir-sha256")
|
|
304
|
+
validate_parser.set_defaults(func=validate)
|
|
305
|
+
args = parser.parse_args()
|
|
306
|
+
return args.func(args)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
raise SystemExit(main())
|
package/src/core/analyzer.py
CHANGED
|
@@ -10,9 +10,12 @@ in this context, never in assumptions.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
|
+
import fnmatch
|
|
13
14
|
import json
|
|
15
|
+
import os
|
|
14
16
|
import re
|
|
15
17
|
import sys
|
|
18
|
+
import time
|
|
16
19
|
from datetime import datetime, timezone
|
|
17
20
|
from pathlib import Path
|
|
18
21
|
|
|
@@ -80,6 +83,112 @@ CATEGORIES = {
|
|
|
80
83
|
},
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
DEFAULT_IGNORE_DIRS = {
|
|
87
|
+
".git",
|
|
88
|
+
".hg",
|
|
89
|
+
".svn",
|
|
90
|
+
"node_modules",
|
|
91
|
+
".venv",
|
|
92
|
+
"venv",
|
|
93
|
+
"__pycache__",
|
|
94
|
+
".motionloom",
|
|
95
|
+
"target",
|
|
96
|
+
"dist",
|
|
97
|
+
"build",
|
|
98
|
+
".next",
|
|
99
|
+
".turbo",
|
|
100
|
+
"coverage",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ScanBudget:
|
|
105
|
+
"""Bound repository traversal without hiding that the scan was partial."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
root: Path,
|
|
110
|
+
*,
|
|
111
|
+
max_files: int | None = 2500,
|
|
112
|
+
max_bytes: int | None = 25_000_000,
|
|
113
|
+
max_seconds: float | None = 10.0,
|
|
114
|
+
ignore_dirs: set[str] | None = None,
|
|
115
|
+
ignore_globs: list[str] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.root = root
|
|
118
|
+
self.max_files = max_files
|
|
119
|
+
self.max_bytes = max_bytes
|
|
120
|
+
self.max_seconds = max_seconds
|
|
121
|
+
self.ignore_dirs = set(DEFAULT_IGNORE_DIRS) | set(ignore_dirs or ())
|
|
122
|
+
self.ignore_globs = list(ignore_globs or [])
|
|
123
|
+
self.started = time.monotonic()
|
|
124
|
+
self.files_scanned = 0
|
|
125
|
+
self.bytes_scanned = 0
|
|
126
|
+
self.truncated = False
|
|
127
|
+
self.truncation_reasons: list[str] = []
|
|
128
|
+
|
|
129
|
+
def _mark(self, reason: str) -> None:
|
|
130
|
+
self.truncated = True
|
|
131
|
+
if reason not in self.truncation_reasons:
|
|
132
|
+
self.truncation_reasons.append(reason)
|
|
133
|
+
|
|
134
|
+
def _relative(self, path: Path) -> str:
|
|
135
|
+
return path.relative_to(self.root).as_posix()
|
|
136
|
+
|
|
137
|
+
def _ignored(self, path: Path) -> bool:
|
|
138
|
+
relative = self._relative(path)
|
|
139
|
+
if any(part in self.ignore_dirs for part in path.relative_to(self.root).parts):
|
|
140
|
+
return True
|
|
141
|
+
return any(fnmatch.fnmatch(relative, pattern) for pattern in self.ignore_globs)
|
|
142
|
+
|
|
143
|
+
def _limited(self, next_size: int = 0) -> bool:
|
|
144
|
+
if self.max_files is not None and self.files_scanned >= self.max_files:
|
|
145
|
+
self._mark("max_files")
|
|
146
|
+
return True
|
|
147
|
+
if self.max_bytes is not None and self.bytes_scanned + next_size > self.max_bytes:
|
|
148
|
+
self._mark("max_bytes")
|
|
149
|
+
return True
|
|
150
|
+
if self.max_seconds is not None and time.monotonic() - self.started >= self.max_seconds:
|
|
151
|
+
self._mark("max_seconds")
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def files(self, suffixes: set[str] | None = None):
|
|
156
|
+
"""Yield readable candidate files in stable order until a budget is hit."""
|
|
157
|
+
for directory, dirnames, filenames in os.walk(self.root, topdown=True):
|
|
158
|
+
directory_path = Path(directory)
|
|
159
|
+
dirnames[:] = sorted(
|
|
160
|
+
name for name in dirnames
|
|
161
|
+
if not self._ignored(directory_path / name)
|
|
162
|
+
)
|
|
163
|
+
for filename in sorted(filenames):
|
|
164
|
+
path = directory_path / filename
|
|
165
|
+
if self._ignored(path):
|
|
166
|
+
continue
|
|
167
|
+
if suffixes and path.suffix.lower() not in suffixes:
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
size = path.stat().st_size
|
|
171
|
+
except OSError:
|
|
172
|
+
continue
|
|
173
|
+
if self._limited(size):
|
|
174
|
+
return
|
|
175
|
+
self.files_scanned += 1
|
|
176
|
+
self.bytes_scanned += size
|
|
177
|
+
yield path
|
|
178
|
+
|
|
179
|
+
def summary(self) -> dict:
|
|
180
|
+
return {
|
|
181
|
+
"max_files": self.max_files,
|
|
182
|
+
"max_bytes": self.max_bytes,
|
|
183
|
+
"max_seconds": self.max_seconds,
|
|
184
|
+
"files_scanned": self.files_scanned,
|
|
185
|
+
"bytes_scanned": self.bytes_scanned,
|
|
186
|
+
"ignored_directories": sorted(self.ignore_dirs),
|
|
187
|
+
"ignore_globs": self.ignore_globs,
|
|
188
|
+
"scan_truncated": self.truncated,
|
|
189
|
+
"truncation_reasons": self.truncation_reasons,
|
|
190
|
+
}
|
|
191
|
+
|
|
83
192
|
|
|
84
193
|
def read_json(path: Path):
|
|
85
194
|
try:
|
|
@@ -91,15 +200,25 @@ def read_json(path: Path):
|
|
|
91
200
|
def detect_stack(project_root: Path, pkg: dict | None) -> dict:
|
|
92
201
|
stack = {"framework": None, "react": False, "vue": False, "react_native": False, "native_web": False}
|
|
93
202
|
deps = set()
|
|
203
|
+
package_name = ""
|
|
94
204
|
if pkg:
|
|
205
|
+
package_name = str(pkg.get("name", "")).lower()
|
|
95
206
|
deps.update(pkg.get("dependencies", {}).keys())
|
|
96
207
|
deps.update(pkg.get("devDependencies", {}).keys())
|
|
97
208
|
stack["react"] = bool({"react", "next", "framer-motion"} & deps)
|
|
98
209
|
stack["vue"] = bool({"vue", "nuxt"} & deps)
|
|
99
210
|
stack["react_native"] = bool({"react-native"} & deps)
|
|
100
211
|
stack["native_web"] = bool({"lottie-web", "dotlottie-web", "gsap", "animejs"} & deps)
|
|
101
|
-
|
|
212
|
+
# Prefer the package's own identity over a transitive/dev dependency in a
|
|
213
|
+
# monorepo. This prevents Motion One from being labeled GSAP merely
|
|
214
|
+
# because its root workspace uses GSAP for tests or tooling, and lets
|
|
215
|
+
# Rive packages expose a distinct runtime signal.
|
|
216
|
+
if package_name in {"motion", "motion-one"}:
|
|
217
|
+
stack["framework"] = "motion"
|
|
218
|
+
elif package_name == "framer-motion" or "framer-motion" in deps:
|
|
102
219
|
stack["framework"] = "framer-motion"
|
|
220
|
+
elif package_name.startswith("rive") or any("rive" in dependency.lower() for dependency in deps):
|
|
221
|
+
stack["framework"] = "rive"
|
|
103
222
|
elif "gsap" in deps:
|
|
104
223
|
stack["framework"] = "gsap"
|
|
105
224
|
elif "dotlottie-web" in deps or "lottie-web" in deps or "@lottiefiles/react-lottie-player" in deps:
|
|
@@ -113,7 +232,7 @@ def detect_stack(project_root: Path, pkg: dict | None) -> dict:
|
|
|
113
232
|
return stack
|
|
114
233
|
|
|
115
234
|
|
|
116
|
-
def extract_brand_tokens(project_root: Path) -> dict:
|
|
235
|
+
def extract_brand_tokens(project_root: Path, scanner: ScanBudget | None = None) -> dict:
|
|
117
236
|
tokens = {"primary": None, "accent": None, "palette": [], "fonts": []}
|
|
118
237
|
# Tailwind config
|
|
119
238
|
for candidate in ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs"]:
|
|
@@ -126,7 +245,7 @@ def extract_brand_tokens(project_root: Path) -> dict:
|
|
|
126
245
|
tokens["accent"] = m.group(1).upper()
|
|
127
246
|
# package.json theme / CSS variables
|
|
128
247
|
css_vars = {}
|
|
129
|
-
css_files = list(project_root.rglob("*.css")) + list(project_root.rglob("*.scss"))
|
|
248
|
+
css_files = list(scanner.files({".css", ".scss"})) if scanner else list(project_root.rglob("*.css")) + list(project_root.rglob("*.scss"))
|
|
130
249
|
for f in css_files[:20]:
|
|
131
250
|
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
132
251
|
for m in re.finditer(r"--([a-z0-9-]+):\s*(#[0-9a-fA-F]{3,8})", text):
|
|
@@ -137,14 +256,13 @@ def extract_brand_tokens(project_root: Path) -> dict:
|
|
|
137
256
|
return tokens
|
|
138
257
|
|
|
139
258
|
|
|
140
|
-
def detect_motion_language(project_root: Path) -> dict:
|
|
259
|
+
def detect_motion_language(project_root: Path, scanner: ScanBudget | None = None) -> dict:
|
|
141
260
|
"""Gather existing easing/duration conventions from the project."""
|
|
142
261
|
easings = set()
|
|
143
262
|
durations = set()
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
continue
|
|
263
|
+
suffixes = {".ts", ".tsx", ".js", ".jsx", ".css"}
|
|
264
|
+
files = scanner.files(suffixes) if scanner else (f for suffix in suffixes for f in project_root.rglob(f"*{suffix}"))
|
|
265
|
+
for f in files:
|
|
148
266
|
try:
|
|
149
267
|
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
150
268
|
except OSError:
|
|
@@ -160,30 +278,47 @@ def detect_motion_language(project_root: Path) -> dict:
|
|
|
160
278
|
}
|
|
161
279
|
|
|
162
280
|
|
|
163
|
-
def find_existing_animations(project_root: Path) -> list:
|
|
281
|
+
def find_existing_animations(project_root: Path, scanner: ScanBudget | None = None) -> list:
|
|
164
282
|
found = []
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
283
|
+
suffixes = {".lottie", ".json", ".riv"}
|
|
284
|
+
files = scanner.files(suffixes) if scanner else (
|
|
285
|
+
f for suffix in (".lottie", ".json", ".riv") for f in project_root.rglob(f"*{suffix}")
|
|
286
|
+
)
|
|
287
|
+
for f in files:
|
|
288
|
+
rel = f.relative_to(project_root).as_posix()
|
|
289
|
+
if f.suffix.lower() == ".json":
|
|
290
|
+
text = f.read_text(encoding="utf-8", errors="ignore")[:200]
|
|
291
|
+
if '"v"' not in text and "anim" not in text.lower():
|
|
169
292
|
continue
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
continue
|
|
174
|
-
found.append(rel)
|
|
175
|
-
if len(found) >= 15:
|
|
176
|
-
return found
|
|
293
|
+
found.append(rel)
|
|
294
|
+
if len(found) >= 15:
|
|
295
|
+
return found
|
|
177
296
|
return found
|
|
178
297
|
|
|
179
298
|
|
|
180
|
-
def analyze(
|
|
299
|
+
def analyze(
|
|
300
|
+
project_root: str,
|
|
301
|
+
*,
|
|
302
|
+
max_files: int | None = 2500,
|
|
303
|
+
max_bytes: int | None = 25_000_000,
|
|
304
|
+
max_seconds: float | None = 10.0,
|
|
305
|
+
ignore_dirs: list[str] | None = None,
|
|
306
|
+
ignore_globs: list[str] | None = None,
|
|
307
|
+
) -> dict:
|
|
181
308
|
root = Path(project_root).resolve()
|
|
309
|
+
scanner = ScanBudget(
|
|
310
|
+
root,
|
|
311
|
+
max_files=max_files,
|
|
312
|
+
max_bytes=max_bytes,
|
|
313
|
+
max_seconds=max_seconds,
|
|
314
|
+
ignore_dirs=ignore_dirs,
|
|
315
|
+
ignore_globs=ignore_globs,
|
|
316
|
+
)
|
|
182
317
|
pkg = read_json(root / "package.json")
|
|
183
318
|
manifest = read_json(root / "project-manifest.json")
|
|
184
319
|
readme = (root / "README.md").read_text(encoding="utf-8", errors="ignore")[:3000] if (root / "README.md").exists() else ""
|
|
185
320
|
|
|
186
|
-
brand = extract_brand_tokens(root)
|
|
321
|
+
brand = extract_brand_tokens(root, scanner)
|
|
187
322
|
# project-manifest.json is the explicit project contract and therefore
|
|
188
323
|
# overrides inferred values from Tailwind/CSS when both are present.
|
|
189
324
|
manifest_brand = (manifest or {}).get("brand") or {}
|
|
@@ -198,8 +333,10 @@ def analyze(project_root: str) -> dict:
|
|
|
198
333
|
"description": (manifest or {}).get("description") or (pkg or {}).get("description") or "",
|
|
199
334
|
"stack": detect_stack(root, pkg),
|
|
200
335
|
"brand": brand,
|
|
201
|
-
"motion_language": detect_motion_language(root),
|
|
202
|
-
"existing_animations": find_existing_animations(root),
|
|
336
|
+
"motion_language": detect_motion_language(root, scanner),
|
|
337
|
+
"existing_animations": find_existing_animations(root, scanner),
|
|
338
|
+
"scan": scanner.summary(),
|
|
339
|
+
"scan_truncated": scanner.truncated,
|
|
203
340
|
"manifest_overrides": manifest or {},
|
|
204
341
|
"source_authority": "project-manifest.json then assets/library/, never invented geometry",
|
|
205
342
|
}
|
|
@@ -210,11 +347,23 @@ def main():
|
|
|
210
347
|
parser = argparse.ArgumentParser(description="Analyze a host project and emit its binding context.")
|
|
211
348
|
parser.add_argument("project_root", nargs="?", default=".")
|
|
212
349
|
parser.add_argument("--output", help="Context path; defaults to <project_root>/project-context.json")
|
|
350
|
+
parser.add_argument("--max-files", type=int, default=2500)
|
|
351
|
+
parser.add_argument("--max-bytes", type=int, default=25_000_000)
|
|
352
|
+
parser.add_argument("--max-seconds", type=float, default=10.0)
|
|
353
|
+
parser.add_argument("--ignore-dir", action="append", default=[])
|
|
354
|
+
parser.add_argument("--ignore-glob", action="append", default=[])
|
|
213
355
|
args = parser.parse_args()
|
|
214
356
|
root = Path(args.project_root).resolve()
|
|
215
357
|
if not root.is_dir():
|
|
216
358
|
parser.error(f"project root is not a directory: {root}")
|
|
217
|
-
ctx = analyze(
|
|
359
|
+
ctx = analyze(
|
|
360
|
+
str(root),
|
|
361
|
+
max_files=args.max_files,
|
|
362
|
+
max_bytes=args.max_bytes,
|
|
363
|
+
max_seconds=args.max_seconds,
|
|
364
|
+
ignore_dirs=args.ignore_dir or None,
|
|
365
|
+
ignore_globs=args.ignore_glob or None,
|
|
366
|
+
)
|
|
218
367
|
out = Path(args.output).resolve() if args.output else root / "project-context.json"
|
|
219
368
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
220
369
|
out.write_text(json.dumps(ctx, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|