Teach motion gate about reveal-replay; auto-provision missing harness deps

- motionGate: a captured CSS entrance animation the clone replays through
  a DittoMotion reveal (re-hide on mount, restart @keyframes on scroll)
  is graded by the scroll-reveal check, not the static-CSS expectation —
  previously every such cid failed emitted=false. Also drive every
  captured reveal instead of the first 16 (pages with more reveals could
  never pass; this was the spurious "reveals 16/24").
- render/harness: the validator copies app sources over the preinstalled
  harness without installing deps, so a Lottie clone's injected
  lottie-web crashed webpack ("build failed" with no real signal).
  Missing app deps are now installed into the harness pinned to the
  app's exact version (--no-save, idempotent), with a clear gate issue
  on install failure.

Cotton motion gate: 33 static + 10 replayed + 24/24 reveals -> pass.
Cropin home build gate: pass (was webpack Module not found).
116/116 compiler tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 20:22:42 -07:00
co-authored by Claude Fable 5
parent 7f99b76f66
commit f3d433c024
3 changed files with 219 additions and 7 deletions
+45
View File
@@ -21,9 +21,54 @@ export type BuildResult = {
durationMs: number;
};
/** Dependencies the app's package.json declares that are absent from the harness's
* preinstalled node_modules. The harness copies app SOURCE over its own tree but keeps its
* preinstalled node_modules/package.json (for speed + determinism), so a dep the generator
* injects for this clone but that the harness was never provisioned with (e.g. `lottie-web`,
* added by injectLottieDep only for clones with Lottie content) would be missing at build time
* → "Module not found" webpack error. Returns each such dep pinned to the app's exact version. */
export function missingHarnessDeps(appDir: string, harnessDir: string): Array<{ name: string; version: string }> {
const pkgPath = join(appDir, "package.json");
if (!existsSync(pkgPath)) return [];
let deps: Record<string, string> = {};
try { deps = (JSON.parse(readFileSync(pkgPath, "utf8")).dependencies ?? {}) as Record<string, string>; }
catch { return []; }
const out: Array<{ name: string; version: string }> = [];
for (const [name, version] of Object.entries(deps)) {
// A dep is present iff its package.json resolves under the harness node_modules.
if (!existsSync(join(harnessDir, "node_modules", name, "package.json"))) {
out.push({ name, version: String(version).replace(/^[\^~]/, "") });
}
}
return out;
}
/** Install any app-declared dependency missing from the harness node_modules, pinned to the
* app's exact version. --no-save keeps the harness package.json/lockfile untouched (the install
* is per-clone and idempotent); returns an error string if the install failed (surfaced as a
* build-gate issue) or null on success/no-op. */
function ensureHarnessDeps(appDir: string, harnessDir: string): string | null {
const missing = missingHarnessDeps(appDir, harnessDir);
if (!missing.length) return null;
const specs = missing.map((d) => `${d.name}@${d.version}`);
const res = spawnSync("npm", ["install", "--no-save", "--no-audit", "--no-fund", ...specs], {
cwd: harnessDir,
encoding: "utf8",
env: { ...process.env },
maxBuffer: 64 * 1024 * 1024,
timeout: 180000,
});
if (res.status !== 0) {
return `harness dep install failed for ${specs.join(", ")}: ${(res.stderr || res.stdout || "").split("\n").filter(Boolean).slice(-3).join(" | ")}`;
}
return null;
}
/** Build the generated app inside the shared harness (deps preinstalled). */
export function buildApp(appDir: string, harnessDir: string): BuildResult {
const t0 = Date.now();
const depErr = ensureHarnessDeps(appDir, harnessDir);
if (depErr) return { ok: false, outDir: null, stderr: depErr, durationMs: Date.now() - t0 };
const isVite = existsSync(join(appDir, "vite.config.ts")) || existsSync(join(appDir, "index.html"));
if (isVite) {
for (const entry of readdirSync(harnessDir, { withFileTypes: true })) {