#!/usr/bin/env node // check-compositions.mjs — Step 7 finalize preflight harness // // Runs after all Step 6 workers return and before Step 7 finalize starts assembling index.html. // Catches historical worker bugs (finalize used to spend 13 minutes on average // in edit-and-retry debugging): // // 1. Wrapper-ancestor selector: CSS / JS selector written as `.-root .foo` // / `.-root #foo`. Preview / snapshot works because the bundler keeps // the wrapper, but `hyperframes render` uses the producer pipeline, which strips // that wrapper, so every selector misses and the scene renders black or as raw DOM. // Correct form: plain `.s-foo` / `#s-foo`; the runtime scoper adds host scope. // 2. Self data-composition-id selector: CSS written as // `[data-composition-id=""] { ... }` triggers the newer CLI // `composition_self_attribute_selector` warning. Root styles should use `#root`. // 3. Scene-root id selector: `#-root` is not a runtime contract. // Root may only use `#root`; scene-internal elements use `#s-foo`. // 4. Missing root contract: no `id="root"`, no `class="-root"`, no // `data-composition-id`, no `data-duration`, or no `window.__timelines[...]`. // 5. Asset references a file absent from /public/: the worker invented // or misspelled the basename. // // Usage: // node check-compositions.mjs --hyperframes . --group-spec ./group_spec.json // // Exit codes: // 0 = all compositions pass. stdout prints the summary. // 1 = one or more fatal violations. stderr lists per-scene, per-rule failures; the // orchestrator should re-dispatch affected workers instead of patching in finalize. import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; const argv = process.argv.slice(2); const flag = (name, def) => { const i = argv.indexOf(`--${name}`); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def; }; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const hyperframesDir = resolve(flag("hyperframes", ".")); const groupSpecPath = resolve(flag("group-spec", "./group_spec.json")); const compositionsDir = join(hyperframesDir, "compositions"); if (!existsSync(groupSpecPath)) { console.error(`✗ group_spec.json not found at ${groupSpecPath}`); process.exit(1); } if (!existsSync(compositionsDir)) { console.error(`✗ compositions dir not found at ${compositionsDir}`); process.exit(1); } const groupSpec = JSON.parse(readFileSync(groupSpecPath, "utf8")); const sceneIds = (groupSpec.groups || []).flatMap((g) => g.scene_ids || []); if (sceneIds.length === 0) { console.error(`✗ no scene_ids found in group_spec.json`); process.exit(1); } // scene_id → group entry (for component-meta lookup) const sceneEntries = new Map(); for (const g of groupSpec.groups || []) { for (const sid of g.scene_ids || []) { sceneEntries.set(sid, g.scenes?.[sid] || {}); } } const visualTargets = Array.isArray(groupSpec.visual_clips) && groupSpec.visual_clips.length > 0 ? groupSpec.visual_clips.map((v) => ({ id: String(v.id || ""), file: String(v.file || `compositions/${v.id}.html`), kind: v.kind || "scene", sceneIds: Array.isArray(v.scene_ids) ? v.scene_ids : [], })) : sceneIds.map((sid) => ({ id: sid, file: `compositions/${sid}.html`, kind: "scene", sceneIds: [sid], })); if (visualTargets.length === 0) { console.error(`✗ no visual clips found in group_spec.json`); process.exit(1); } // Component metadata lookup — chunks/index.json carries rank / forbidden_with / // trigger_signals / visual_role per component (written by emit-chunks.mjs). // Keyed by component id, which equals the html file's basename. Empty when the // preset hasn't migrated to the new schema; rank checks then silently skip. const componentMeta = new Map(); { // Resolve chunks/index.json off the first scene's design_chunks.tokens_file — // tokens_file is required by prep.mjs so it's always present when chunks were // emitted. Walk up from chunks/tokens.css → chunks/ → chunks/index.json. const anyScene = [...sceneEntries.values()].find((e) => e.design_chunks?.tokens_file); const tokensPath = anyScene?.design_chunks?.tokens_file; if (tokensPath) { const chunksDir = dirname(tokensPath); const indexPath = join(chunksDir, "index.json"); if (existsSync(indexPath)) { try { const index = JSON.parse(readFileSync(indexPath, "utf8")); for (const c of index.components || []) { componentMeta.set(c.id, { rank: c.rank ?? null, trigger_signals: c.trigger_signals || [], forbidden_with: c.forbidden_with || [], visual_role: c.visual_role || null, }); } } catch { // Malformed index.json — skip metadata-driven checks; let other gates flag the data issue } } } } // Given an absolute path like "/.../chunks/components/hero.html", return the // component id "hero". Returns null if path doesn't fit the expected shape so // callers can skip safely. function componentIdFromPath(absPath) { if (typeof absPath !== "string") return null; const m = absPath.match(/\/chunks\/components\/([a-z0-9-]+)\.html$/); return m ? m[1] : null; } const errors = []; // fatal: { sceneId, rule, detail } const anomalies = []; // non-fatal: { sceneId, rule, detail } for (const target of visualTargets) { const sceneId = target.id; const compRel = target.file || `compositions/${sceneId}.html`; const filePath = join(hyperframesDir, compRel); // Rule 0: file exists and is non-empty if (!existsSync(filePath) || statSync(filePath).size === 0) { errors.push({ sceneId, rule: "file", detail: `${compRel} missing or empty`, }); continue; // Skip remaining rules for this scene. } const html = readFileSync(filePath, "utf8"); // Rule 1: root div contract // There must be exactly one root div with both id="root" and class="-root". const rootDivRe = new RegExp( `]*\\bid=["']root["'][^>]*\\bclass=["'][^"']*\\b${sceneId}-root\\b[^"']*["']`, "i", ); const rootDivAltRe = new RegExp( `]*\\bclass=["'][^"']*\\b${sceneId}-root\\b[^"']*["'][^>]*\\bid=["']root["']`, "i", ); if (!rootDivRe.test(html) && !rootDivAltRe.test(html)) { errors.push({ sceneId, rule: "root-contract", detail: `no
found — both attributes must be on the same div`, }); } // Rule 1b: data-composition-id and data-duration on root const hostIdRe = new RegExp(`data-composition-id=["']${sceneId}["']`); if (!hostIdRe.test(html)) { errors.push({ sceneId, rule: "data-composition-id", detail: `no data-composition-id="${sceneId}" found`, }); } if (!/data-duration=["'][\d.]+["']/.test(html)) { errors.push({ sceneId, rule: "data-duration", detail: `no data-duration="" found on root`, }); } // Rule 1c: window.__timelines[""] registration const tlKeyRe = new RegExp(`window\\.__timelines\\s*\\[\\s*["']${sceneId}["']\\s*\\]\\s*=`); if (!tlKeyRe.test(html)) { errors.push({ sceneId, rule: "timeline-registration", detail: `no window.__timelines["${sceneId}"] = ... line found (scene id must match verbatim)`, }); } // Recommended namespace prefix: scene_1 -> s1-; group_w2 -> g2- for shared nodes. // Used for fix hints and namespace health checks. const groupM = sceneId.match(/^group_w(\d+)$/); const m = sceneId.match(/(\d+)/); const sN = groupM ? `g${groupM[1]}-` : m ? `s${m[1]}-` : `s-`; const sceneLocalHints = groupM && target.sceneIds.length ? `; logical-scene-only nodes may use ${target.sceneIds .map((sid) => `.${sid.replace(/^scene_/, "s")}-foo`) .join(" / ")}` : ""; const wrapperAncestor = `.${sceneId}-root`; // literal bug shape const fixHint = `plain .${sN}foo / #${sN}foo (no ancestor selector)${sceneLocalHints}`; // Rule 2: CSS —