fix(plugin-previews): keep Community gallery previews in sync with shipped plugins (#5262)

* fix(plugin-previews): validate baked preview metadata against shipped plugins

The Home Community gallery renders whatever data/plugin-previews/manifest.json
names, and nothing kept that metadata in sync with reality: #4815 deleted 19
example plugins but left all 19 manifest entries behind, #4040 shipped a card
showing another plugin's imagery, a failed re-bake silently leaves the old
clip serving outdated content, and durationMs recorded the intended walk time
rather than the encoded file (23 entries carried holdMs > real duration, so
the card's idle loop pointed past the end of the clip).

- add scripts/check-plugin-preview-manifest.ts to pnpm guard: every manifest
  entry must name a shipped plugin, own its clip keys (<id>/<hash>/... or the
  legacy <id>.<hash>....), carry a coherent fingerprint, and never share a
  clip with another entry; prune the 19 orphaned entries it caught
- bake validation (BAKE_VERSION 5): reject blank clips (whole-clip luma range
  via ffprobe signalstats), record durationMs from the encoded file, clamp
  holdMs to it, and write a bake-report.json naming skipped/stale/blank
  plugins instead of swallowing the failure
- strict mode (--strict / PREVIEW_STRICT=1) fails the run when a bake left
  broken metadata behind; the pre-merge validation workflow now runs strict
  while the post-merge/nightly publishers stay lenient

* fix(plugins): make the hyperframes example self-contained

The example loaded cdn.tailwindcss.com (a render-blocking head script the
page never used — every style is inline) plus Google Fonts. Inside the
sandboxed gallery iframe or on a slow network the stalled script showed a
long blank instead of the composition, and the CI bake raced the webfonts.
Drop both: the page now renders from local system font stacks with zero
external requests, so the live preview, the detail iframe, and the baked
clip all show the same content.

* fix(plugin-previews): address review — fail fast on probe errors, widen strict trigger paths

- probeClipMs/clipLumaRange now throw instead of returning null: ffprobe is
  required validation infrastructure, and a swallowed probe error silently
  disabled exactly the checks this PR adds. A thrown error becomes an
  'error …' skip, is recorded in bake-report.json under 'errors', and fails
  strict mode — while the routine skips every sweep has (non-html plugins
  404ing the preview route) still never trip strict.
- bake-plugin-previews-pr.yml now also triggers on plugins/community/**,
  .github/actions/bake-previews/**, and itself, so community-plugin preview
  changes and bake-recipe changes cannot merge without the strict pre-merge
  validation.

* fix(plugin-previews): bake static pages as a held still instead of skipping forever

The strict pre-merge bake on this PR caught five manifest entries that could
NEVER refresh: example-dating-web, example-html-ppt-zhangzara-8-bit-orbit,
example-frame-logo-outro, od-new-generation, example-orbit-notion. All five
are (near-)static pages — the CDP screencast only delivers frames when
something repaints, so they produce 1-4 frames and the old 'frames < 5' guard
skipped them on every bake, nightly included, leaving their committed entries
permanently stale (their old clips are the 103-210ms degenerate files in the
manifest today).

Encode <5-frame captures as a still instead: hold the last frame for the
idle-loop span (half before the trailing repeated concat entry, half
inherited by it, summing to ~HOLD_MS). Only a zero-frame capture is a real
failure now. The blank-luma check still rejects stills of nothing.

* fix(plugin-previews): never persist a manifest entry without a fingerprint

When the preview fingerprint fetch failed, the loop still rendered and — on
an otherwise successful bake — persisted an entry with hash: null and
un-fingerprinted keys: metadata the content-hash reuse skip can never match
again and the new manifest guard rejects. Treat a missing fingerprint as an
infrastructure error instead: skip the plugin, record it under errors in
bake-report.json (strict mode exits non-zero), and leave any committed entry
untouched for the next sweep.

---------

Co-authored-by: Tuola Ge <gexingli@refly.ai>
This commit is contained in:
XingliGe
2026-07-08 03:44:05 +00:00
committed by GitHub
co-authored by Tuola Ge
parent 9a7ff9bf8f
commit 390fcf88f3
9 changed files with 475 additions and 147 deletions
+142 -4
View File
@@ -52,7 +52,15 @@ import path from 'node:path';
// misread tall pages with a horizontal marquee as decks); pan via REAL
// wheel events so scroll-hijack landing pages (custom/transform scroll)
// actually move.
const BAKE_VERSION = 4;
// v5: validate the encoded output — reject blank (never-rendered) clips,
// record durationMs from the FILE (not the intended walk time), and
// clamp holdMs to the encoded duration so the gallery's idle loop never
// points past the end of the clip; encode static pages (screencast
// delivers <5 frames because nothing repaints) as a held still instead
// of skipping them forever. Bumped so every entry's metadata is
// corrected in one sweep (23 entries carried holdMs > real duration,
// 5 static-page entries could never refresh at all).
const BAKE_VERSION = 5;
// ---- config ---------------------------------------------------------------
const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:17579';
@@ -99,6 +107,14 @@ function argList(name) {
const OUT = path.resolve(arg('out', '.tmp/plugin-previews'));
const LIMIT = Number(arg('limit', '0')) || 0;
const ONLY = argList('id');
// Strict mode (--strict or PREVIEW_STRICT=1): exit non-zero when any bake left
// broken metadata behind — a stale entry (source changed but the re-bake
// failed, so the manifest keeps serving the OLD clip), a blank clip, or an
// unexpected error (thrown exception, e.g. ffprobe unavailable). The
// pre-merge validation workflow runs strict so a PR that breaks its own
// preview fails visibly; the post-merge/nightly path stays lenient and only
// reports, so one broken plugin never blocks publishing the rest.
const STRICT = process.argv.includes('--strict') || process.env.PREVIEW_STRICT === '1';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -113,6 +129,58 @@ function resolveChrome() {
throw new Error('No Chrome found. Set CHROME=/path/to/chrome.');
}
// ---- baked-output validation ----------------------------------------------
// The gallery faithfully renders whatever the manifest names, so a broken bake
// must be rejected HERE — once an entry lands, the card shows the broken clip
// until the next content change (see the "preview shows wrong content" class
// of reports: stale clips, blank cards).
// Real duration of the encoded clip. `durMs` upstream is only the *intended*
// wall-clock walk; the VFR concat + fps resample can land somewhere else
// entirely (observed 1155ms recorded for a 3667ms clip), and holdMs — the span
// the gallery loops while idle — must never exceed what the file actually has.
//
// Both probes THROW on failure instead of degrading: ffprobe is required
// validation infrastructure (the workflow installs ffmpeg explicitly), and a
// swallowed probe error would silently disable exactly the checks this
// pipeline exists for. A thrown error becomes an `error …` skip for that
// plugin, lands in bake-report.json, and fails strict mode.
function probeClipMs(file) {
const out = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration',
'-of', 'csv=p=0', file], { encoding: 'utf8' }).trim();
const s = Number(out);
if (!Number.isFinite(s) || s <= 0) throw new Error(`ffprobe returned no duration for ${file}: "${out}"`);
return Math.round(s * 1000);
}
// Aggregate luma range across every frame of the clip. If no frame ever gets
// brighter than BLANK_MAX_Y (~9% grey), nothing rendered — the page 404'd into
// a dark shell, a CDN dependency never loaded, fonts raced, … — and the mirror
// check catches an all-white nothing. Thresholds are deliberately extreme so a
// legitimately dark (or light) design never trips them: any visible text or
// artwork moves YMAX/YMIN far past these bounds.
const BLANK_MAX_Y = 24;
const BLANK_MIN_Y = 232;
function clipLumaRange(file) {
const esc = file.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\:');
// nk=0 keeps the tag names in the output: ffprobe emits the tags in
// signalstats' own order (YMIN before YMAX), not the -show_entries order,
// so parse by key instead of by position.
const out = execFileSync('ffprobe', ['-v', 'error', '-f', 'lavfi',
'-i', `movie='${esc}',signalstats`,
'-show_entries', 'frame_tags=lavfi.signalstats.YMAX,lavfi.signalstats.YMIN',
'-of', 'csv=p=0:nk=0'], { encoding: 'utf8' });
let maxY = 0, minY = 255, frames = 0;
for (const match of out.matchAll(/signalstats\.(YMAX|YMIN)=([0-9.]+)/g)) {
const value = Number(match[2]);
if (!Number.isFinite(value)) continue;
if (match[1] === 'YMAX') { frames += 1; if (value > maxY) maxY = value; }
else if (value < minY) minY = value;
}
if (frames === 0) throw new Error(`ffprobe signalstats returned no frames for ${file}`);
return { maxY, minY };
}
// ---- discover the html-preview plugins ------------------------------------
async function discoverIds() {
if (ONLY.length) return ONLY;
@@ -410,7 +478,7 @@ async function bakeOne(browser, id, hash, motion) {
await client.send('Page.stopScreencast');
await page.close();
if (frames.length < 5) { rmSync(frameDir, { recursive: true, force: true }); return { id, skipped: `frames ${frames.length}` }; }
if (frames.length === 0) { rmSync(frameDir, { recursive: true, force: true }); return { id, skipped: 'frames 0' }; }
// VFR concat list (real per-frame durations) -> correct real-time speed.
const lines = [];
@@ -420,6 +488,15 @@ async function bakeOne(browser, id, hash, motion) {
if (i > 0) lines.push(`duration ${(frames[i].ts - frames[i - 1].ts).toFixed(4)}`);
lines.push(`file '${fp}'`);
}
// A static page repaints nothing after its first paint, so the screencast
// delivers only a frame or two. That is a still, not a failure: hold the
// last frame for the idle-loop span so the encode yields a real clip.
// (The old `frames < 5` guard skipped these pages on EVERY bake, so their
// manifest entries could never refresh — five entries on main were
// permanently stale because of it.) Half the hold here because the trailing
// repeated `file` below inherits the last `duration` directive, so the two
// sum to ~HOLD_MS.
if (frames.length < 5) lines.push(`duration ${(HOLD_MS / 2000).toFixed(4)}`);
lines.push(`file '${path.join(frameDir, `f-${String(frames.length - 1).padStart(4, '0')}.jpg`)}'`);
const listPath = path.join(frameDir, 'list.txt');
writeFileSync(listPath, lines.join('\n'));
@@ -453,7 +530,21 @@ async function bakeOne(browser, id, hash, motion) {
'-q:v', '5', '-frames:v', '1', poster]);
rmSync(frameDir, { recursive: true, force: true });
return { id, durationMs: durMs, holdMs: HOLD_MS, video: videoKey, poster: posterKey,
// Reject a clip that never rendered anything — an entry for it would show a
// blank card until the plugin's next content change. Deleting the outputs
// keeps the broken clip out of the R2 upload (the CI step copies OUT
// recursively).
const luma = clipLumaRange(video);
if (luma.maxY < BLANK_MAX_Y || luma.minY > BLANK_MIN_Y) {
rmSync(path.dirname(video), { recursive: true, force: true });
return { id, skipped: `blank clip (luma ${luma.minY}..${luma.maxY})` };
}
// Manifest metadata must describe the FILE, not the intent: durationMs is
// the encoded duration, and holdMs (the span the gallery loops while idle)
// is clamped to it so the idle loop never points past the end of the clip.
const encodedMs = probeClipMs(video);
return { id, durationMs: encodedMs, holdMs: Math.min(HOLD_MS, encodedMs), video: videoKey, poster: posterKey,
bytes: statSync(video).size, posterBytes: statSync(poster).size };
}
@@ -473,6 +564,14 @@ const manifestPath = path.join(OUT, 'manifest.json');
const previews = existsSync(manifestPath)
? (JSON.parse(readFileSync(manifestPath, 'utf8')).previews || {}) : {};
let ok = 0, skip = 0, reused = 0;
// Machine-readable outcome for the workflows: which plugins were skipped and
// why, and — the metadata-sync failure this pipeline used to swallow — which
// committed entries are now STALE: their source changed but the re-bake
// failed, so the manifest keeps serving a clip of the OLD content. `errors`
// separates unexpected failures (thrown exceptions: ffprobe missing, encoder
// crash, …) from the routine skips every sweep has (non-html plugins 404 the
// preview route); strict mode fails on the former, never on the latter.
const report = { skipped: [], stale: [], blank: [], errors: [] };
for (const id of ids) {
const t0 = Date.now();
// Content-hash skip: a plugin whose preview HTML (and the bake recipe) is
@@ -483,6 +582,19 @@ for (const id of ids) {
const html = await (await fetch(`${BASE_URL}/api/plugins/${encodeURIComponent(id)}/preview`)).text();
hash = createHash('sha256').update(html).update(` ${BAKE_VERSION} ${motionMap[id] || ''}`).digest('hex').slice(0, 16);
} catch {}
// No fingerprint -> no bake. Rendering anyway used to persist an entry with
// `hash: null` and un-fingerprinted keys — metadata the reuse skip can never
// match again (and the manifest guard rejects). A failed fingerprint fetch
// is an infrastructure error: report it, fail strict mode, and leave any
// committed entry untouched for the next sweep to refresh.
if (!hash) {
skip += 1;
const reason = 'error preview fingerprint fetch failed';
console.log(` ~ ${id}: skip (${reason})`);
report.skipped.push({ id, reason });
report.errors.push(id);
continue;
}
const prev = previews[id];
// In CI the unchanged clips already live on R2 (not on disk), so PREVIEW_REMOTE
// trusts the manifest hash without a local-file check; locally we also confirm
@@ -496,11 +608,37 @@ for (const id of ids) {
}
let r;
try { r = await bakeOne(browser, id, hash, motionMap[id]); } catch (e) { r = { id, skipped: `error ${e.message}` }; }
if (r.skipped) { skip += 1; console.log(` ~ ${id}: skip (${r.skipped})`); continue; }
if (r.skipped) {
skip += 1;
console.log(` ~ ${id}: skip (${r.skipped})`);
report.skipped.push({ id, reason: r.skipped });
if (r.skipped.startsWith('blank clip')) report.blank.push(id);
if (r.skipped.startsWith('error ')) report.errors.push(id);
if (prev && hash && prev.hash !== hash) report.stale.push(id);
continue;
}
previews[id] = { video: r.video, poster: r.poster, durationMs: r.durationMs, holdMs: r.holdMs, hash };
ok += 1;
console.log(` + ${id}: ${(r.bytes / 1024).toFixed(0)}KB mp4, ${(r.posterBytes / 1024).toFixed(0)}KB poster (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
writeFileSync(manifestPath, JSON.stringify({ generatedAt: null, previews }, null, 2));
}
await browser.close();
// Written next to the manifest (the R2 upload step excludes it, same as
// manifest.json) so both the strict pre-merge job and a human reading a
// nightly run can see exactly which entries are broken or lagging.
writeFileSync(path.join(OUT, 'bake-report.json'),
JSON.stringify({ baked: ok, reused, ...report }, null, 2));
console.log(`done: ${ok} baked, ${reused} reused (unchanged), ${skip} skipped -> ${manifestPath}`);
if (report.blank.length) {
console.error(`BLANK bakes rejected (nothing ever rendered; entry NOT written): ${report.blank.join(', ')}`);
}
if (report.stale.length) {
console.error(`STALE manifest entries (source changed but the re-bake failed — the gallery keeps showing the OLD clip until a bake succeeds): ${report.stale.join(', ')}`);
}
if (report.errors.length) {
console.error(`ERRORED bakes (unexpected exception — validation infrastructure problem or renderer crash, see the skip reasons above): ${report.errors.join(', ')}`);
}
if (STRICT && (report.blank.length || report.stale.length || report.errors.length)) {
console.error('strict mode: failing on blank/stale/errored previews');
process.exit(1);
}
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validatePreviewManifest } from "./check-plugin-preview-manifest.ts";
const HASH_A = "a2e7f0664e7ceed4";
const HASH_B = "0123456789abcdef";
function manifestWith(previews: Record<string, unknown>): unknown {
return { generatedAt: null, previews };
}
function dirEntry(id: string, hash: string = HASH_A): Record<string, unknown> {
return {
video: `${id}/${hash}/preview.mp4`,
poster: `${id}/${hash}/poster.jpg`,
durationMs: 3667,
holdMs: 2500,
hash,
};
}
function flatEntry(id: string, hash: string = HASH_A): Record<string, unknown> {
return {
video: `${id}.${hash}.mp4`,
poster: `${id}.${hash}.poster.jpg`,
durationMs: 3667,
holdMs: 2500,
hash,
};
}
test("accepts directory-layout and legacy flat-layout entries", () => {
const manifest = manifestWith({
"example-video-hyperframes": dirEntry("example-video-hyperframes"),
"example-invoice": flatEntry("example-invoice", HASH_B),
});
const ids = new Set(["example-video-hyperframes", "example-invoice"]);
assert.deepEqual(validatePreviewManifest(manifest, ids), []);
});
test("flags an orphan entry whose plugin no longer ships", () => {
const manifest = manifestWith({ "example-luxury-botanical": flatEntry("example-luxury-botanical") });
const violations = validatePreviewManifest(manifest, new Set(["example-invoice"]));
assert.equal(violations.length, 1);
assert.match(violations[0]!, /example-luxury-botanical: no such plugin ships/);
});
test("flags clip keys that belong to a different plugin id", () => {
const manifest = manifestWith({
"example-video-hyperframes": {
...dirEntry("example-video-hyperframes"),
video: `example-figma/${HASH_A}/preview.mp4`,
},
});
const violations = validatePreviewManifest(manifest, new Set(["example-video-hyperframes"]));
assert.equal(violations.length, 1);
assert.match(violations[0]!, /video key "example-figma\/.*" does not belong to this plugin id/);
});
test("flags a key whose embedded fingerprint disagrees with the recorded hash", () => {
const manifest = manifestWith({
"example-invoice": { ...flatEntry("example-invoice", HASH_A), hash: HASH_B },
});
const violations = validatePreviewManifest(manifest, new Set(["example-invoice"]));
assert.equal(violations.length, 2); // video + poster both carry the old hash
assert.match(violations[0]!, /does not carry the recorded hash/);
});
test("flags two entries claiming the same clip", () => {
const shared = dirEntry("example-a");
const manifest = manifestWith({
"example-a": shared,
"example-b": { ...shared },
});
const violations = validatePreviewManifest(manifest, new Set(["example-a", "example-b"]));
// example-b's keys belong to example-a (2 ownership violations) and both
// keys are double-claimed (2 duplicate violations).
assert.equal(violations.filter((v) => /does not belong/.test(v)).length, 2);
assert.equal(violations.filter((v) => /claimed by 2 entries/.test(v)).length, 2);
});
test("flags malformed hash, missing keys, and non-positive timings", () => {
const manifest = manifestWith({
"example-a": { poster: "example-a/x/poster.jpg", hash: "NOT-HEX", durationMs: 0, holdMs: -5 },
});
const violations = validatePreviewManifest(manifest, new Set(["example-a"]));
assert.ok(violations.some((v) => /video must be a non-empty string/.test(v)));
assert.ok(violations.some((v) => /hash must be 16 lowercase hex chars/.test(v)));
assert.ok(violations.some((v) => /durationMs must be a positive finite number/.test(v)));
assert.ok(violations.some((v) => /holdMs must be a positive finite number/.test(v)));
});
test("rejects a manifest without a previews object", () => {
const violations = validatePreviewManifest({ previews: [] }, new Set());
assert.equal(violations.length, 1);
assert.match(violations[0]!, /expected a \{ previews: \{ <id>: … \} \} object/);
});
+190
View File
@@ -0,0 +1,190 @@
/* ─────────────────────────────────────────────────────────────────────────
* scripts/check-plugin-preview-manifest.ts
*
* Guard for the baked plugin-preview manifest (data/plugin-previews/
* manifest.json) — the metadata the Home "Community" gallery trusts to pair
* every plugin card with its pre-rendered clip. The gallery faithfully shows
* whatever this file names, so drifted metadata is directly user-visible:
* a deleted plugin's entry lingers forever (#4815 removed 19 example plugins
* and left all 19 manifest entries behind), or an entry resolves to another
* plugin's clip and the card renders the wrong content entirely (#4040, the
* shamoni card showing luxury-botanical's imagery).
*
* Checks:
* 1. no orphans — every entry names a plugin that still ships under
* plugins/_official or plugins/community;
* 2. clip ownership — an entry's video/poster keys belong to its own plugin
* id, either `<id>/<hash>/…` (directory layout) or `<id>.<hash>.…`
* (legacy flat layout);
* 3. hash coherence — the fingerprint embedded in the keys matches the
* entry's recorded `hash`;
* 4. no two entries share a clip (the #4040 failure shape);
* 5. durationMs / holdMs, when present, are positive finite numbers.
*
* Run standalone: `pnpm exec tsx scripts/check-plugin-preview-manifest.ts`
* Or as part of `pnpm guard` (registered in scripts/guard.ts).
* ─────────────────────────────────────────────────────────────────────── */
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(import.meta.dirname, "..");
const MANIFEST_REPO_PATH = "data/plugin-previews/manifest.json";
// The id universe the manifest may reference: every bundled plugin the CI
// daemon can serve a preview for. Community is included so a community
// plugin's bake never reads as an orphan.
const PLUGIN_ROOTS = ["plugins/_official", "plugins/community"];
const HASH_PATTERN = /^[0-9a-f]{16}$/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Every plugin id (`name` in open-design.json) shipped under the given roots. */
export async function collectPluginIds(
roots: readonly string[] = PLUGIN_ROOTS,
): Promise<Set<string>> {
const ids = new Set<string>();
const pending = roots.map((root) => path.join(repoRoot, root));
while (pending.length > 0) {
const dir = pending.pop()!;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isDirectory()) {
pending.push(path.join(dir, entry.name));
continue;
}
if (entry.name !== "open-design.json") continue;
try {
const manifest: unknown = JSON.parse(await readFile(path.join(dir, entry.name), "utf8"));
if (isRecord(manifest) && typeof manifest.name === "string" && manifest.name.length > 0) {
ids.add(manifest.name);
}
} catch {
// A malformed plugin manifest is plugin-spec territory, not this
// guard's; the plugin simply contributes no id.
}
}
}
return ids;
}
function keyBelongsToPlugin(key: string, id: string): boolean {
return key.startsWith(`${id}/`) || key.startsWith(`${id}.`);
}
function keyCarriesHash(key: string, id: string, hash: string): boolean {
return key.startsWith(`${id}/${hash}/`) || key.startsWith(`${id}.${hash}.`);
}
/** Pure validation over a parsed manifest; returns human-readable violations. */
export function validatePreviewManifest(
manifest: unknown,
pluginIds: ReadonlySet<string>,
): string[] {
const violations: string[] = [];
if (!isRecord(manifest) || !isRecord(manifest.previews)) {
return [`${MANIFEST_REPO_PATH}: expected a { previews: { <id>: … } } object`];
}
const claimedKeys = new Map<string, string[]>();
for (const [id, entry] of Object.entries(manifest.previews)) {
if (!isRecord(entry)) {
violations.push(`${MANIFEST_REPO_PATH}: ${id}: entry must be an object`);
continue;
}
const { video, poster, hash, durationMs, holdMs } = entry;
for (const [field, value] of [["video", video], ["poster", poster]] as const) {
if (typeof value !== "string" || value.length === 0) {
violations.push(`${MANIFEST_REPO_PATH}: ${id}: ${field} must be a non-empty string`);
continue;
}
if (!keyBelongsToPlugin(value, id)) {
violations.push(
`${MANIFEST_REPO_PATH}: ${id}: ${field} key "${value}" does not belong to this plugin id — the card would render another plugin's clip`,
);
}
const owners = claimedKeys.get(value) ?? [];
owners.push(id);
claimedKeys.set(value, owners);
}
if (hash !== undefined) {
if (typeof hash !== "string" || !HASH_PATTERN.test(hash)) {
violations.push(`${MANIFEST_REPO_PATH}: ${id}: hash must be 16 lowercase hex chars`);
} else {
for (const [field, value] of [["video", video], ["poster", poster]] as const) {
if (typeof value === "string" && keyBelongsToPlugin(value, id) && !keyCarriesHash(value, id, hash)) {
violations.push(
`${MANIFEST_REPO_PATH}: ${id}: ${field} key "${value}" does not carry the recorded hash ${hash} — the entry points at a clip baked from different content`,
);
}
}
}
}
for (const [field, value] of [["durationMs", durationMs], ["holdMs", holdMs]] as const) {
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
violations.push(`${MANIFEST_REPO_PATH}: ${id}: ${field} must be a positive finite number`);
}
}
if (!pluginIds.has(id)) {
violations.push(
`${MANIFEST_REPO_PATH}: ${id}: no such plugin ships in ${PLUGIN_ROOTS.join(" or ")} — remove the stale entry (deleting a plugin must also delete its baked-preview entry)`,
);
}
}
for (const [key, owners] of claimedKeys) {
if (owners.length > 1) {
violations.push(
`${MANIFEST_REPO_PATH}: clip key "${key}" is claimed by ${owners.length} entries (${owners.join(", ")}) — cards would show duplicate imagery`,
);
}
}
return violations;
}
export async function checkPluginPreviewManifest(): Promise<boolean> {
let manifest: unknown;
try {
manifest = JSON.parse(await readFile(path.join(repoRoot, MANIFEST_REPO_PATH), "utf8"));
} catch (error) {
console.error(
`Plugin preview manifest check failed: ${MANIFEST_REPO_PATH} could not be parsed: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false;
}
const pluginIds = await collectPluginIds();
const violations = validatePreviewManifest(manifest, pluginIds);
if (violations.length > 0) {
console.error("Plugin preview manifest violations:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
const count = isRecord(manifest) && isRecord(manifest.previews)
? Object.keys(manifest.previews).length
: 0;
console.log(
`Plugin preview manifest check passed: ${count} entr${count === 1 ? "y" : "ies"} consistent with ${pluginIds.size} shipped plugins.`,
);
return true;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const ok = await checkPluginPreviewManifest();
if (!ok) process.exitCode = 1;
}
+2
View File
@@ -9,6 +9,7 @@ import { checkDesignSystemPackageQuality } from "./check-design-system-package-q
import { checkDesignSystemComponentFixtureReport } from "./check-components-fixtures.ts";
import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts";
import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts";
import { checkPluginPreviewManifest } from "./check-plugin-preview-manifest.ts";
import { validatePlaywrightSuiteTopology } from "../e2e/lib/playwright/suites.ts";
import {
checkDesignSystemA1RequiredTokens,
@@ -1306,6 +1307,7 @@ const checks: GuardCheck[] = [
{ name: "style policy", run: checkStylePolicy },
{ name: "CI topology", run: checkCiTopology },
{ name: "craft references", run: checkCraftReferences },
{ name: "plugin preview manifest", run: checkPluginPreviewManifest },
{ name: "design system manifests", run: checkDesignSystemManifests },
{ name: "design system package quality", run: checkDesignSystemPackageQuality },
{ name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport },