open-design/scripts/guard.ts
Tom Huang 2df8b775ec
feat(skills): add 32 zhangzara HTML deck templates (#704)
* feat(skills): add 32 zhangzara HTML deck templates

Vendored from upstream MIT-licensed
zarazhangrui/beautiful-html-templates — one Open Design skill per template
(name prefix `html-ppt-zhangzara-`) so each template surfaces as its own
entry in the Examples panel and renders its own preview.

Each skill ships:
- SKILL.md (frontmatter + workflow), description, triggers, and
  od.upstream pointing at the source folder
- example.html (the self-contained deck; daemon's preview route looks
  for <skillDir>/example.html)
- template.json (upstream metadata snapshot, with `slug` re-prefixed to
  `zhangzara-<base>` and a `source` URL)
- assets/deck-stage.js / assets/styles.css for the 8 templates that
  ship a runtime; HTML refs rewritten so the daemon's iframe URL
  rewriter resolves them through /api/skills/<id>/assets/

scripts/guard.ts allowlist updated with the `html-ppt-zhangzara-` prefix
so the vendored upstream JS runtimes pass the residual-JS check.

* fix(skills, i18n): address PR #704 review feedback

- Add the 32 new html-ppt-zhangzara-* skill ids to the de/ru/fr
  SKILL_IDS_WITH_EN_FALLBACK arrays so the localized-content
  coverage e2e test passes. The vendored upstream templates are
  English-only; falling back to the upstream English description
  is the right semantic for this batch.
- Also add the pre-existing social-media-dashboard skill and
  totality-festival design system to the same fallback arrays
  (introduced in #678 without i18n coverage). Tagged with TODOs
  so localized copy can land in a follow-up.
- Ship the upstream MIT LICENSE file in each
  skills/html-ppt-zhangzara-*/ folder so the copyright/permission
  notice travels with the vendored copy, as MIT requires for
  redistributing substantial portions. Update each SKILL.md's
  Source section to reference the bundled LICENSE.
- For the 8 runtime-backed templates (creative-mode,
  editorial-tri-tone, neo-grid-bold, peoples-platform,
  pin-and-paper, pink-script, soft-editorial, stencil-tablet),
  expand the workflow's clone step to instruct the agent to copy
  the assets/ folder alongside example.html — the skill HTML
  references assets/deck-stage.js (and assets/styles.css for
  pin-and-paper) as project-local paths, so cloning the HTML
  alone produces an artifact whose runtime 404s.

Verified locally:
- pnpm guard passes.
- pnpm --filter @open-design/web typecheck passes.
- pnpm --filter @open-design/web test passes (309/309).
- pnpm --filter @open-design/e2e test passes (6/6 active,
  including localized-content coverage for de/ru/fr).

* fix(i18n): drop duplicate totality-festival fallback after merge with main

Main already added 'totality-festival' to the design-system EN-fallback
lists; the TODO entry from this branch became a duplicate after merge.

* fix(skills, guard): address PR #704 follow-up review

- Pin Chart.js CDN to 4.4.7 in coral and cartesian example.html so
  vendored decks no longer track the latest jsDelivr major.
- Narrow scripts/guard.ts zhangzara allowlist to a regex that only
  permits skills/html-ppt-zhangzara-*/assets/deck-stage.js, restoring
  the TypeScript-first guard for any other JS under those skill dirs.
- Reconcile slide_count and 'Slides in demo' with actual <section
  class="slide"> counts: broadside 20 -> 16, monochrome 18 -> 16,
  neo-grid-bold 13 -> 12.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): keep resolveDataDir return path stable, canonicalize at compare site

The realpathSync wrapper inside resolveDataDir was rewriting every
/var/... result to /private/var/... on macOS, which broke 11 hermetic
assertions in tests/resolve-data-dir.test.ts (absolute paths, relative
paths, and \$HOME / \${HOME} / ~ expansions whose mkdtempSync roots live
under /var/folders/...). It also changed the public OD_DATA_DIR
resolution contract for any downstream caller that compared against the
expanded user-supplied path.

Restore resolveDataDir to return the expanded resolved path unchanged,
and introduce RUNTIME_DATA_DIR_CANONICAL — a one-shot realpath of
RUNTIME_DATA_DIR — used only at the narrow folder-import comparison
site that needs to match against a user-supplied realpath() result. The
import-path symlink protection from #624 still works (a /var-rooted
data dir now compares against its /private/var canonical form), while
resolveDataDir keeps its stable, user-shaped contract.

Verified locally: pnpm --filter @open-design/daemon test (1083/1083),
including all 12 resolve-data-dir.test.ts cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 12:02:59 +08:00

422 lines
14 KiB
TypeScript

import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
const repoRoot = path.resolve(import.meta.dirname, "..");
type GuardCheck = {
name: string;
run: () => Promise<boolean>;
};
function toRepositoryPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
const residualExtensions = new Set([".js", ".mjs", ".cjs"]);
const residualSkippedDirectories = new Set([
".agents",
".astro",
".claude",
".claude-sessions",
".codex",
".cursor",
".git",
".od",
".od-e2e",
".opencode",
".task",
".tmp",
".vite",
"dist",
"node_modules",
"out",
]);
const residualAllowedExactPaths = new Set([
// esbuild config entrypoints are executed directly by Node before package
// dist output exists.
"packages/contracts/esbuild.config.mjs",
"packages/platform/esbuild.config.mjs",
"packages/sidecar/esbuild.config.mjs",
"packages/sidecar-proto/esbuild.config.mjs",
// Maintainer utility scripts ported from the media branch. They are
// executed directly by Node and are not loaded by the app runtime.
"scripts/import-prompt-templates.mjs",
"scripts/postinstall.mjs",
"apps/packaged/esbuild.config.mjs",
// Browser service workers must be served as JavaScript files.
"apps/web/public/od-notifications-sw.js",
"scripts/bake-html-ppt-examples.mjs",
"scripts/scaffold-html-ppt-skills.mjs",
"scripts/sync-hyperframes-skill.mjs",
"scripts/verify-media-models.mjs",
"tools/dev/bin/tools-dev.mjs",
"tools/dev/esbuild.config.mjs",
"tools/pack/bin/tools-pack.mjs",
"tools/pack/esbuild.config.mjs",
"tools/pack/resources/mac/notarize.cjs",
// electron-builder hook path; CJS compatibility entry used by tools-pack desktop builds.
"tools/pack/resources/web-standalone-after-pack.cjs",
]);
const residualAllowedPathPrefixes = [
"apps/daemon/dist/",
"apps/web/.next/",
"apps/web/out/",
"generated/",
"e2e/playwright-report/",
"e2e/reports/html/",
"e2e/reports/playwright-html-report/",
"e2e/reports/test-results/",
"e2e/ui/.od-data/",
"e2e/ui/reports/playwright-html-report/",
"e2e/ui/reports/test-results/",
"e2e/ui/test-results/",
// Vendored upstream HyperFrames skill helper scripts.
"skills/hyperframes/scripts/",
// Vendored upstream Last30Days runtime helper used by the skill engine.
"skills/last30days/scripts/lib/vendor/",
// Vendored upstream html-ppt skill runtime assets (lewislulu/html-ppt-skill).
"skills/html-ppt/assets/",
"test-results/",
"vendor/",
];
const residualAllowedPathPatterns: RegExp[] = [
// Vendored upstream Zara template runtimes — one skill per template, name prefix
// `html-ppt-zhangzara-` (zarazhangrui/beautiful-html-templates). Only the
// vendored deck-stage runtime asset is allowlisted; any other JavaScript under
// these skill directories must still be converted to TypeScript or explicitly
// listed in `residualAllowedExactPaths`.
/^skills\/html-ppt-zhangzara-[^/]+\/assets\/deck-stage\.js$/,
];
function isResidualAllowedPath(repositoryPath: string): boolean {
if (residualAllowedExactPaths.has(repositoryPath)) return true;
if (residualAllowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix))) return true;
return residualAllowedPathPatterns.some((pattern) => pattern.test(repositoryPath));
}
function isResidualSkippedDirectoryName(directoryName: string): boolean {
return (
residualSkippedDirectories.has(directoryName) || directoryName === ".next" || directoryName.startsWith(".next-")
);
}
async function collectResidualJavaScript(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const residualFiles: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const repositoryPath = toRepositoryPath(fullPath);
if (entry.isDirectory()) {
if (isResidualSkippedDirectoryName(entry.name) || isResidualAllowedPath(`${repositoryPath}/`)) {
continue;
}
residualFiles.push(...(await collectResidualJavaScript(fullPath)));
continue;
}
if (!entry.isFile() || !residualExtensions.has(path.extname(entry.name))) {
continue;
}
if (isResidualAllowedPath(repositoryPath)) {
continue;
}
residualFiles.push(repositoryPath);
}
return residualFiles;
}
async function checkResidualJavaScript(): Promise<boolean> {
const residualFiles = await collectResidualJavaScript(repoRoot);
if (residualFiles.length > 0) {
console.error("Residual project-owned JavaScript files found:");
for (const filePath of residualFiles) {
console.error(`- ${filePath}`);
}
console.error("Convert these files to TypeScript or add a documented generated/vendor/output allowlist entry.");
return false;
}
console.log("Residual JavaScript check passed: project-owned code is TypeScript-only.");
return true;
}
const testLayoutScopedDirectories = ["apps", "packages", "tools"];
const testLayoutSkippedDirectories = new Set([".next", ".od-data", "dist", "node_modules", "out", "reports", "test-results"]);
function isTestFile(fileName: string): boolean {
return /\.test\.tsx?$/.test(fileName);
}
function expectedTestPath(repositoryPath: string): string {
const [scope, project, ...relativeParts] = repositoryPath.split("/");
if (!testLayoutScopedDirectories.includes(scope ?? "") || project == null || relativeParts.length === 0) {
return repositoryPath;
}
const normalizedRelativeParts = relativeParts[0] === "src" ? relativeParts.slice(1) : relativeParts;
return [scope, project, "tests", ...normalizedRelativeParts].join("/");
}
function isAllowedScopedTestPath(repositoryPath: string): boolean {
const [scope, project, directory] = repositoryPath.split("/");
return testLayoutScopedDirectories.includes(scope ?? "") && project != null && directory === "tests";
}
async function collectTestLayoutViolations(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const violations: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (testLayoutSkippedDirectories.has(entry.name)) {
continue;
}
violations.push(...(await collectTestLayoutViolations(fullPath)));
continue;
}
if (!entry.isFile() || !isTestFile(entry.name)) {
continue;
}
const repositoryPath = toRepositoryPath(fullPath);
if (!isAllowedScopedTestPath(repositoryPath)) {
violations.push(repositoryPath);
}
}
return violations;
}
async function checkTestLayout(): Promise<boolean> {
const violations = (
await Promise.all(
testLayoutScopedDirectories.map((directory) => collectTestLayoutViolations(path.join(repoRoot, directory))),
)
).flat();
if (violations.length > 0) {
console.error("Test files under apps/, packages/, and tools/ must live in tests/ sibling to src/:");
for (const violation of violations) {
console.error(`- ${violation} -> ${expectedTestPath(violation)}`);
}
return false;
}
console.log("Test layout check passed: apps/packages/tools tests live in sibling tests directories.");
return true;
}
const e2ePackageJsonPath = path.join(repoRoot, "e2e", "package.json");
const e2eSkippedDirectories = new Set([".od-data", "node_modules", "reports", "test-results"]);
const e2eAllowedScripts = ["test", "typecheck"];
async function collectRepositoryFiles(directory: string, skippedDirectoryNames = new Set<string>()): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (skippedDirectoryNames.has(entry.name)) continue;
files.push(...(await collectRepositoryFiles(fullPath, skippedDirectoryNames)));
continue;
}
if (entry.isFile()) files.push(toRepositoryPath(fullPath));
}
return files;
}
async function checkE2eLayout(): Promise<boolean> {
const violations: string[] = [];
const packageJson = JSON.parse(await readFile(e2ePackageJsonPath, "utf8")) as {
scripts?: Record<string, unknown>;
};
const scriptNames = Object.keys(packageJson.scripts ?? {}).sort();
if (scriptNames.join("\0") !== e2eAllowedScripts.join("\0")) {
violations.push(
`e2e/package.json scripts must be exactly ${e2eAllowedScripts.join(", ")} (found: ${scriptNames.join(", ")})`,
);
}
const e2eRoot = path.join(repoRoot, "e2e");
for (const repositoryPath of await collectRepositoryFiles(e2eRoot, e2eSkippedDirectories)) {
if (
repositoryPath === "e2e/package.json" ||
repositoryPath === "e2e/tsconfig.json" ||
repositoryPath === "e2e/vitest.config.ts" ||
repositoryPath === "e2e/playwright.config.ts" ||
repositoryPath === "e2e/AGENTS.md"
) {
continue;
}
if (repositoryPath.startsWith("e2e/specs/")) {
if (!/\.spec\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e specs must be *.spec.ts`);
}
continue;
}
if (repositoryPath.startsWith("e2e/tests/")) {
if (!/\.test\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e tests must be *.test.ts`);
}
continue;
}
if (repositoryPath.startsWith("e2e/ui/")) {
const relativePath = repositoryPath.slice("e2e/ui/".length);
if (relativePath.includes("/") || !/\.test\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e UI files must be flat Playwright *.test.ts files under ui/`);
}
continue;
}
if (repositoryPath.startsWith("e2e/resources/")) {
const relativePath = repositoryPath.slice("e2e/resources/".length);
if (relativePath.includes("/") || !/\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e resources must be flat TypeScript files under resources/`);
}
continue;
}
if (repositoryPath.startsWith("e2e/lib/")) {
if (!/\.ts$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> e2e lib files must be TypeScript`);
}
continue;
}
if (repositoryPath.startsWith("e2e/scripts/")) {
if (repositoryPath !== "e2e/scripts/playwright.ts") {
violations.push(`${repositoryPath} -> e2e scripts currently allow only scripts/playwright.ts`);
}
continue;
}
violations.push(`${repositoryPath} -> e2e source files must live in specs/, tests/, ui/, resources/, lib/, or scripts/playwright.ts`);
}
if (violations.length > 0) {
console.error("E2E package layout violations found:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log("E2E layout check passed: Vitest, Playwright UI, resources, lib, and scripts stay in their lanes.");
return true;
}
const webTestSkippedDirectories = new Set([".od-data", "reports", "test-results"]);
async function checkWebTestLayout(): Promise<boolean> {
const violations: string[] = [];
const webTestsRoot = path.join(repoRoot, "apps", "web", "tests");
for (const repositoryPath of await collectRepositoryFiles(webTestsRoot, webTestSkippedDirectories)) {
if (repositoryPath.startsWith("apps/web/tests/vitest/") || repositoryPath.startsWith("apps/web/tests/playwright/")) {
violations.push(`${repositoryPath} -> web tests should stay lightweight under apps/web/tests/ without vitest/playwright nesting`);
continue;
}
if (/\.(spec|test)\.tsx?$/.test(repositoryPath) && !/\.test\.tsx?$/.test(repositoryPath)) {
violations.push(`${repositoryPath} -> web Vitest test files must be *.test.ts or *.test.tsx`);
}
}
if (violations.length > 0) {
console.error("Web test layout violations found:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log("Web test layout check passed: web tests stay lightweight and Vitest-only.");
return true;
}
const toolsRootAllowlist = new Map<string, "directory" | "file">([
// Keep top-level tools intentionally small. `tools/launcher` was an incoming
// Windows shim experiment from PR #683 and is not an active repo boundary.
["AGENTS.md", "file"],
["dev", "directory"],
["pack", "directory"],
]);
async function checkToolsLayout(): Promise<boolean> {
const toolsRoot = path.join(repoRoot, "tools");
const entries = await readdir(toolsRoot, { withFileTypes: true });
const seen = new Set<string>();
const violations: string[] = [];
for (const entry of entries) {
const expected = toolsRootAllowlist.get(entry.name);
const repositoryPath = `tools/${entry.name}${entry.isDirectory() ? "/" : ""}`;
if (expected == null) {
violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, and pack/`);
continue;
}
seen.add(entry.name);
if (expected === "directory" && !entry.isDirectory()) {
violations.push(`${repositoryPath} -> expected tools/${entry.name}/ to be a directory`);
}
if (expected === "file" && !entry.isFile()) {
violations.push(`${repositoryPath} -> expected tools/${entry.name} to be a file`);
}
}
for (const [entryName, expected] of toolsRootAllowlist) {
if (!seen.has(entryName)) {
violations.push(`tools/${entryName}${expected === "directory" ? "/" : ""} -> required tools boundary is missing`);
}
}
if (violations.length > 0) {
console.error("Tools layout violations found:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log("Tools layout check passed: tools/ top-level entries match the active boundary allowlist.");
return true;
}
const checks: GuardCheck[] = [
{ name: "residual JavaScript", run: checkResidualJavaScript },
{ name: "test layout", run: checkTestLayout },
{ name: "e2e layout", run: checkE2eLayout },
{ name: "web test layout", run: checkWebTestLayout },
{ name: "tools layout", run: checkToolsLayout },
];
const results: boolean[] = [];
for (const check of checks) {
try {
results.push(await check.run());
} catch (error) {
console.error(`Guard check failed unexpectedly: ${check.name}`);
console.error(error);
results.push(false);
}
}
if (results.some((passed) => !passed)) {
process.exitCode = 1;
}