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
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")
|
|
@@ -126,6 +126,16 @@
|
|
|
126
126
|
"id": "p2-attestation-unknown-signer",
|
|
127
127
|
"class": "negative",
|
|
128
128
|
"assertion": "A signature whose key id is absent from the configured trust policy is rejected."
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "project-corpus-first-party-pass",
|
|
132
|
+
"class": "positive",
|
|
133
|
+
"assertion": "The analyzer recognizes the first-party MotionLoom project through the labeled corpus harness."
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"id": "project-corpus-insufficient-external-explicit",
|
|
137
|
+
"class": "negative",
|
|
138
|
+
"assertion": "Missing external project checkouts are reported as insufficient evidence instead of being treated as product-value proof."
|
|
129
139
|
}
|
|
130
140
|
]
|
|
131
141
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": "1.0",
|
|
3
|
+
"corpus_id": "motionloom-project-corpus-v1",
|
|
4
|
+
"description": "Provenance-labeled analyzer corpus. External checkouts are opt-in and are never fetched by MotionLoom CI.",
|
|
5
|
+
"required_external_projects": 3,
|
|
6
|
+
"projects": [
|
|
7
|
+
{
|
|
8
|
+
"id": "motionloom-first-party",
|
|
9
|
+
"class": "first-party",
|
|
10
|
+
"external": false,
|
|
11
|
+
"scope": "repository",
|
|
12
|
+
"local_path": ".",
|
|
13
|
+
"source": "https://github.com/lenhonbp/MotionLoom",
|
|
14
|
+
"expected": {
|
|
15
|
+
"name": "motionloom"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "motion-one-external",
|
|
20
|
+
"class": "external",
|
|
21
|
+
"external": true,
|
|
22
|
+
"local_path": "external/framer-motion",
|
|
23
|
+
"source": "https://github.com/motiondivision/motion",
|
|
24
|
+
"expected": {
|
|
25
|
+
"name": "motion-one",
|
|
26
|
+
"framework": "motion"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "gsap-external",
|
|
31
|
+
"class": "external",
|
|
32
|
+
"external": true,
|
|
33
|
+
"local_path": "external/gsap",
|
|
34
|
+
"source": "https://github.com/greensock/GSAP",
|
|
35
|
+
"expected": {
|
|
36
|
+
"framework": "gsap"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "rive-external",
|
|
41
|
+
"class": "external",
|
|
42
|
+
"external": true,
|
|
43
|
+
"local_path": "external/rive",
|
|
44
|
+
"source": "https://github.com/rive-app/rive-react",
|
|
45
|
+
"expected": {
|
|
46
|
+
"name": "rive-react",
|
|
47
|
+
"framework": "rive"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
@@ -49,6 +49,45 @@ def test_analyzer_on_fixture():
|
|
|
49
49
|
check("analyzer extracts brand primary", ctx.get("brand", {}).get("primary") == "#2563EB")
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def test_analyzer_scan_budget_and_ignore_rules():
|
|
53
|
+
with tempfile.TemporaryDirectory() as td:
|
|
54
|
+
root = Path(td)
|
|
55
|
+
(root / "package.json").write_text(json.dumps({"name": "bounded-fixture", "dependencies": {"gsap": "^3.0.0"}}))
|
|
56
|
+
(root / "ignored-dir").mkdir()
|
|
57
|
+
(root / "ignored-dir" / "ignored.ts").write_text("duration: 999s")
|
|
58
|
+
(root / "src").mkdir()
|
|
59
|
+
for index in range(5):
|
|
60
|
+
(root / "src" / f"scene-{index}.tsx").write_text("const easing = 'ease-out';")
|
|
61
|
+
context = root / "context.json"
|
|
62
|
+
result = subprocess.run([
|
|
63
|
+
sys.executable, str(ROOT / "scripts/analyze.py"), str(root),
|
|
64
|
+
"--output", str(context), "--max-files", "2", "--max-bytes", "100000",
|
|
65
|
+
"--max-seconds", "10", "--ignore-dir", "ignored-dir",
|
|
66
|
+
], capture_output=True, text=True)
|
|
67
|
+
data = json.loads(context.read_text())
|
|
68
|
+
scan = data.get("scan", {})
|
|
69
|
+
check("bounded analyzer exits cleanly", result.returncode == 0, result.stderr)
|
|
70
|
+
check("bounded analyzer emits scan metadata", scan.get("files_scanned") == 2)
|
|
71
|
+
check("bounded analyzer exposes truncation", data.get("scan_truncated") is True and scan.get("scan_truncated") is True)
|
|
72
|
+
check("bounded analyzer records file limit", "max_files" in scan.get("truncation_reasons", []))
|
|
73
|
+
check("bounded analyzer records ignored directory", "ignored-dir" in scan.get("ignored_directories", []))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_project_corpus_harness_is_explicit_about_evidence():
|
|
77
|
+
result = subprocess.run([
|
|
78
|
+
sys.executable, str(ROOT / "scripts/eval-projects.py"),
|
|
79
|
+
"--workspace", str(ROOT), "--require-external", "0", "--allow-insufficient",
|
|
80
|
+
], capture_output=True, text=True)
|
|
81
|
+
report = json.loads(result.stdout)
|
|
82
|
+
check("project corpus first-party evaluation passes", result.returncode == 0 and report.get("status") == "pass")
|
|
83
|
+
strict = subprocess.run([
|
|
84
|
+
sys.executable, str(ROOT / "scripts/eval-projects.py"),
|
|
85
|
+
"--workspace", str(ROOT), "--allow-insufficient",
|
|
86
|
+
], capture_output=True, text=True)
|
|
87
|
+
strict_report = json.loads(strict.stdout)
|
|
88
|
+
check("project corpus missing external evidence is explicit", strict.returncode == 0 and strict_report.get("status") == "insufficient_evidence")
|
|
89
|
+
|
|
90
|
+
|
|
52
91
|
def test_spec_generate_and_validate():
|
|
53
92
|
with tempfile.TemporaryDirectory() as td:
|
|
54
93
|
ctx = Path(td) / "project-context.json"
|
|
@@ -678,7 +717,7 @@ def test_p1_semantic_continuity_fix_plan():
|
|
|
678
717
|
task.update({"task_id": task_id, "scene": scene, "scene_order": order, "project_name": "p1-continuity"})
|
|
679
718
|
(path / "task.json").write_text(json.dumps(task, indent=2) + "\n")
|
|
680
719
|
ir = json.loads((path / "motion-ir.json").read_text())
|
|
681
|
-
ir.update({"task_id": task_id, "scene": scene, "context_hash": task.get("context_hash")})
|
|
720
|
+
ir.update({"task_id": task_id, "scene": scene, "context_hash": task.get("context_hash") or ir.get("context_hash")})
|
|
682
721
|
(path / "motion-ir.json").write_text(json.dumps(ir, indent=2) + "\n")
|
|
683
722
|
|
|
684
723
|
continuity = subprocess.run([
|
|
@@ -815,10 +854,22 @@ def test_observability_contract():
|
|
|
815
854
|
attestation_tests.returncode == 0 and "attestation contract tests: PASS" in attestation_tests.stdout,
|
|
816
855
|
)
|
|
817
856
|
|
|
857
|
+
memory_tests = subprocess.run(
|
|
858
|
+
[sys.executable, str(ROOT / "tests/scripts/test_project_memory.py")],
|
|
859
|
+
capture_output=True,
|
|
860
|
+
text=True,
|
|
861
|
+
)
|
|
862
|
+
check(
|
|
863
|
+
"project memory recovery and cross-platform contract passes",
|
|
864
|
+
memory_tests.returncode == 0 and "project memory contract tests: PASS" in memory_tests.stdout,
|
|
865
|
+
)
|
|
866
|
+
|
|
818
867
|
|
|
819
868
|
if __name__ == "__main__":
|
|
820
869
|
print("== MotionLoom engine tests ==")
|
|
821
870
|
test_analyzer_on_fixture()
|
|
871
|
+
test_analyzer_scan_budget_and_ignore_rules()
|
|
872
|
+
test_project_corpus_harness_is_explicit_about_evidence()
|
|
822
873
|
test_spec_generate_and_validate()
|
|
823
874
|
test_rig_build_and_pose()
|
|
824
875
|
test_lottie_scaffold_valid()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Project Memory recovery and cross-platform path contract tests."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
16
|
+
CLI = ROOT / "scripts" / "project-memory.py"
|
|
17
|
+
NODE_CLI = ROOT / "bin" / "motionloom.mjs"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_memory(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
[sys.executable, str(CLI), *args, "--project-root", str(root)],
|
|
23
|
+
cwd=ROOT,
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
check=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_memory_recovery_and_relocation() -> None:
|
|
31
|
+
with tempfile.TemporaryDirectory(prefix="motionloom memory ") as td:
|
|
32
|
+
root = Path(td) / "Dự án MotionLoom"
|
|
33
|
+
root.mkdir(parents=True)
|
|
34
|
+
(root / "package.json").write_text(json.dumps({"name": "memory-fixture"}), encoding="utf-8")
|
|
35
|
+
(root / "project-context.json").write_text(json.dumps({
|
|
36
|
+
"schema_version": "2.0",
|
|
37
|
+
"name": "memory-fixture",
|
|
38
|
+
"generated_at": "2026-08-13T00:00:00Z",
|
|
39
|
+
}), encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
created = run_memory(root, "init", "--context-path", "project-context.json", "--json")
|
|
42
|
+
assert created.returncode == 0, created.stderr
|
|
43
|
+
memory_path = root / ".motionloom" / "project-memory.json"
|
|
44
|
+
assert memory_path.is_file()
|
|
45
|
+
|
|
46
|
+
decision = run_memory(root, "record-decision", "--id", "ease-ui", "--status", "accepted", "--summary", "Use ease-out for UI entry", "--user-confirmed", "--json")
|
|
47
|
+
assert decision.returncode == 0, decision.stderr
|
|
48
|
+
|
|
49
|
+
rejected = run_memory(root, "record-decision", "--id", "linear-ui", "--status", "rejected", "--summary", "Do not use linear easing for UI", "--rationale", "Perceptually mechanical", "--json")
|
|
50
|
+
assert rejected.returncode == 0, rejected.stderr
|
|
51
|
+
|
|
52
|
+
blocked_outcome = run_memory(root, "record-outcome", "--id", "fix-1", "--issue-id", "issue-1", "--summary", "Unreviewed fix", "--result", "pass")
|
|
53
|
+
assert blocked_outcome.returncode == 2
|
|
54
|
+
assert "user-confirmed" in blocked_outcome.stderr
|
|
55
|
+
|
|
56
|
+
outcome = run_memory(root, "record-outcome", "--id", "fix-1", "--issue-id", "issue-1", "--summary", "Reduce hand-track duration", "--root-cause", "timing", "--resolution", "duration 420ms", "--result", "pass", "--correction-count", "1", "--rerun-scope", "scene:onboarding-wave", "--user-confirmed", "--json")
|
|
57
|
+
assert outcome.returncode == 0, outcome.stderr
|
|
58
|
+
|
|
59
|
+
recovered = run_memory(root, "recover", "--limit", "10")
|
|
60
|
+
assert recovered.returncode == 0, recovered.stderr
|
|
61
|
+
payload = json.loads(recovered.stdout)
|
|
62
|
+
assert payload["project"]["project_id"].startswith("local:")
|
|
63
|
+
assert payload["decisions"][-1]["id"] == "linear-ui"
|
|
64
|
+
assert payload["rejected_patterns"][-1]["id"] == "linear-ui"
|
|
65
|
+
assert payload["remediation"][-1]["user_confirmed"] is True
|
|
66
|
+
assert payload["instructions"][0].endswith("user approval.")
|
|
67
|
+
assert payload["instructions"][-1].endswith("artifacts.")
|
|
68
|
+
|
|
69
|
+
moved = Path(td) / "relocated" / "MotionLoom copy"
|
|
70
|
+
moved.parent.mkdir()
|
|
71
|
+
shutil.copytree(root, moved)
|
|
72
|
+
relocated = run_memory(moved, "recover", "--limit", "10")
|
|
73
|
+
assert relocated.returncode == 0, relocated.stderr
|
|
74
|
+
relocated_payload = json.loads(relocated.stdout)
|
|
75
|
+
assert relocated_payload["project"]["root_path"] == str(moved.resolve())
|
|
76
|
+
relocated_validation = run_memory(moved, "validate", "--json")
|
|
77
|
+
assert relocated_validation.returncode == 0, relocated_validation.stderr
|
|
78
|
+
|
|
79
|
+
# Only the volatile checkout path may be rebound without changing the
|
|
80
|
+
# durable project payload; meaningful direct edits remain integrity failures.
|
|
81
|
+
rebound = json.loads((moved / ".motionloom" / "project-memory.json").read_text(encoding="utf-8"))
|
|
82
|
+
rebound["project"]["root_path"] = str(moved.parent / "another-location")
|
|
83
|
+
(moved / ".motionloom" / "project-memory.json").write_text(json.dumps(rebound), encoding="utf-8")
|
|
84
|
+
path_only = run_memory(moved, "validate", "--json")
|
|
85
|
+
assert path_only.returncode == 0, path_only.stdout + path_only.stderr
|
|
86
|
+
|
|
87
|
+
context = json.loads((moved / "project-context.json").read_text(encoding="utf-8"))
|
|
88
|
+
context["changed_by_other_task"] = True
|
|
89
|
+
(moved / "project-context.json").write_text(json.dumps(context), encoding="utf-8")
|
|
90
|
+
stale = run_memory(moved, "refresh", "--json")
|
|
91
|
+
assert stale.returncode == 10, stale.stdout + stale.stderr
|
|
92
|
+
stale_payload = json.loads(stale.stdout)
|
|
93
|
+
assert stale_payload["freshness"]["status"] == "stale"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_cross_project_recovery_is_fail_closed() -> None:
|
|
97
|
+
with tempfile.TemporaryDirectory(prefix="motionloom-project-a ") as td:
|
|
98
|
+
source = Path(td) / "source"
|
|
99
|
+
foreign = Path(td) / "foreign"
|
|
100
|
+
source.mkdir(); foreign.mkdir()
|
|
101
|
+
for root, name in ((source, "project-a"), (foreign, "project-b")):
|
|
102
|
+
(root / "package.json").write_text(json.dumps({"name": name}), encoding="utf-8")
|
|
103
|
+
(root / "project-context.json").write_text(json.dumps({"schema_version": "2.0", "name": name}), encoding="utf-8")
|
|
104
|
+
assert run_memory(source, "init").returncode == 0
|
|
105
|
+
foreign_memory = foreign / ".motionloom"
|
|
106
|
+
foreign_memory.mkdir()
|
|
107
|
+
shutil.copy2(source / ".motionloom" / "project-memory.json", foreign_memory / "project-memory.json")
|
|
108
|
+
result = run_memory(foreign, "recover")
|
|
109
|
+
assert result.returncode == 11
|
|
110
|
+
assert "identity mismatch" in result.stderr
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_node_cli_routes_memory_without_shell() -> None:
|
|
114
|
+
with tempfile.TemporaryDirectory(prefix="motionloom node cli ") as td:
|
|
115
|
+
root = Path(td)
|
|
116
|
+
(root / "package.json").write_text(json.dumps({"name": "node-cli-fixture"}), encoding="utf-8")
|
|
117
|
+
(root / "project-context.json").write_text(json.dumps({"schema_version": "2.0", "name": "node-cli-fixture"}), encoding="utf-8")
|
|
118
|
+
result = subprocess.run(["node", str(NODE_CLI), "memory", "init", "--project-root", str(root), "--json"], cwd=ROOT, capture_output=True, text=True, check=False)
|
|
119
|
+
assert result.returncode == 0, result.stderr
|
|
120
|
+
recover = subprocess.run(["node", str(NODE_CLI), "memory", "recover", "--project-root", str(root)], cwd=ROOT, capture_output=True, text=True, check=False)
|
|
121
|
+
assert recover.returncode == 0, recover.stderr
|
|
122
|
+
assert json.loads(recover.stdout)["project"]["name"] == "node-cli-fixture"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
test_memory_recovery_and_relocation()
|
|
127
|
+
test_cross_project_recovery_is_fail_closed()
|
|
128
|
+
test_node_cli_routes_memory_without_shell()
|
|
129
|
+
print("project memory contract tests: PASS")
|