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.
Files changed (64) hide show
  1. package/.agents/skills/motionloom/SKILL.md +14 -0
  2. package/.claude/skills/motionloom.md +5 -0
  3. package/.codex/skills/motionloom.md +11 -0
  4. package/AGENTS.md +17 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +19 -0
  7. package/CONTRIBUTING.md +65 -0
  8. package/README.md +193 -134
  9. package/ROADMAP.md +36 -0
  10. package/SECURITY.md +28 -0
  11. package/SKILL.md +57 -9
  12. package/SUPPORT.md +23 -0
  13. package/agent-card.json +42 -6
  14. package/agent-surfaces.json +79 -0
  15. package/bin/motionloom.mjs +33 -5
  16. package/docs/AGENT-INTEGRATION.md +47 -0
  17. package/docs/CHECKLIST.md +2 -1
  18. package/docs/STATUS.md +33 -0
  19. package/docs/audits/2.1.0-deep-stress-evaluation.md +97 -0
  20. package/docs/audits/ci-replay-remediation-2026-08-13.md +33 -0
  21. package/docs/audits/data/2.1.0-deep-stress-6900.json +329 -0
  22. package/docs/audits/data/deep-stress-latest.json +329 -0
  23. package/docs/audits/external-project-corpus-2026-08-13.md +26 -0
  24. package/docs/releases/2.1.0.md +23 -0
  25. package/docs/releases/2.2.0.md +35 -0
  26. package/docs/releases/npm-publish-from-workstation.md +6 -6
  27. package/examples/agent-consumer/README.md +18 -0
  28. package/examples/agent-consumer/fixture-manifest.json +82 -0
  29. package/package.json +69 -28
  30. package/references/agent-interoperability.md +29 -0
  31. package/references/intelligence-core.md +5 -1
  32. package/schemas/agent-surfaces.schema.json +78 -0
  33. package/schemas/project-memory.schema.json +180 -0
  34. package/schemas/remediation-history.schema.json +23 -0
  35. package/schemas/scene-manifest.schema.json +1 -0
  36. package/schemas/visual-truth.schema.json +80 -0
  37. package/scripts/analyze.py +56 -0
  38. package/scripts/capture-runtime-telemetry.py +119 -0
  39. package/scripts/devlab.py +126 -0
  40. package/scripts/discovery.py +257 -0
  41. package/scripts/docs-audit.py +112 -0
  42. package/scripts/eval-intelligence.py +23 -0
  43. package/scripts/eval-projects.py +156 -0
  44. package/scripts/intelligence.py +106 -6
  45. package/scripts/pr.py +151 -0
  46. package/scripts/prepack-clean.mjs +37 -0
  47. package/scripts/project-memory.py +483 -0
  48. package/scripts/project_memory_loader.py +31 -0
  49. package/scripts/quality-gate.py +43 -3
  50. package/scripts/release-verify.py +52 -0
  51. package/scripts/remediation-learning.py +326 -0
  52. package/scripts/render.py +65 -0
  53. package/scripts/report.py +60 -2
  54. package/scripts/review-hook.py +13 -2
  55. package/scripts/skill-doctor.py +12 -2
  56. package/scripts/to-dotlottie.mjs +26 -20
  57. package/scripts/visual-truth.py +310 -0
  58. package/src/core/analyzer.py +174 -25
  59. package/src/output/browser-review-smoke/manifest.json +1 -0
  60. package/src/output/browser-review-smoke/visual-truth.json +68 -0
  61. package/tests/evals/intelligence-cases.json +10 -0
  62. package/tests/evals/project-corpus.json +51 -0
  63. package/tests/scripts/run_tests.py +111 -1
  64. package/tests/scripts/test_project_memory.py +129 -0
@@ -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 { execFileSync } from "node:child_process";
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 tmp = fs.mkdtempSync(path.join(os.tmpdir(), "animation-skill-dotlottie-"));
47
- const archiveRoot = path.join(tmp, "archive");
48
- fs.mkdirSync(path.join(archiveRoot, "a"), { recursive: true });
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
- fs.writeFileSync(
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
- execFileSync("zip", ["-X", "-q", "-r", output, "."], { cwd: archiveRoot });
65
+ fs.writeFileSync(output, zipSync(archive, { level: 6 }));
74
66
 
75
- const entries = execFileSync("unzip", ["-Z1", output], { encoding: "utf8" })
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
- return JSON.parse(execFileSync("unzip", ["-p", file, entry], { encoding: "utf8" }));
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())
@@ -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
- if "framer-motion" in deps:
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
- for pattern in ("*.ts", "*.tsx", "*.js", "*.jsx", "*.css"):
145
- for f in project_root.rglob(pattern):
146
- if "node_modules" in f.parts:
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
- for ext in ("*.lottie", "*.json", "*.riv"):
166
- for f in project_root.rglob(ext):
167
- rel = str(f.relative_to(project_root))
168
- if "node_modules" in rel or ".git" in rel:
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
- if ext == "*.json":
171
- text = f.read_text(encoding="utf-8", errors="ignore")[:200]
172
- if '"v"' not in text and "anim" not in text.lower():
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(project_root: str) -> dict:
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(str(root))
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")