From f0570786877d78b3daee53bbafd0ddb9efe5153f Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 10 Aug 2026 07:33:06 +0000 Subject: [PATCH] fix(runner): restore browser token verification Co-Authored-By: Paperclip --- packages/paperclip-runner/.gitignore | 2 + .../scripts/check-browser-tokens.mjs | 130 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 packages/paperclip-runner/scripts/check-browser-tokens.mjs diff --git a/packages/paperclip-runner/.gitignore b/packages/paperclip-runner/.gitignore index 692fe0227ff..f1c6dfea396 100644 --- a/packages/paperclip-runner/.gitignore +++ b/packages/paperclip-runner/.gitignore @@ -2,5 +2,7 @@ /dist-phase5/ /playwright-report/ /test-results/ +/runner/target/ +!scripts/check-browser-tokens.mjs !scripts/check-forbidden-imports.mjs !scripts/check-forbidden-imports.test.mjs diff --git a/packages/paperclip-runner/scripts/check-browser-tokens.mjs b/packages/paperclip-runner/scripts/check-browser-tokens.mjs new file mode 100644 index 00000000000..8a20e244568 --- /dev/null +++ b/packages/paperclip-runner/scripts/check-browser-tokens.mjs @@ -0,0 +1,130 @@ +import { readFile, readdir } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoots = [ + resolve(packageRoot, "devtools/browser/src"), + resolve(packageRoot, "src/react"), + resolve(packageRoot, "examples"), +]; + +async function sourceFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map((entry) => { + const path = resolve(directory, entry.name); + return entry.isDirectory() ? sourceFiles(path) : [path]; + }), + ); + return nested.flat(); +} + +const componentRules = [ + ["hex color", /#[0-9a-f]{3,8}\b/i], + ["raw pixel value", /\b\d+(?:\.\d+)?px\b/], + ["inline style", /\bstyle\s*=\s*\{/], + ["raw font size", /\bfont(?:-size|Size)\s*[:=]/], + ["arbitrary Tailwind value", /\b[a-z-]+-\[[^\]]+\]/], +]; +const violations = []; + +for (const path of (await Promise.all(sourceRoots.map(sourceFiles))).flat()) { + if (![".ts", ".tsx"].includes(extname(path))) { + continue; + } + const source = await readFile(path, "utf8"); + for (const [label, pattern] of componentRules) { + if (pattern.test(source)) { + violations.push(`${path.slice(packageRoot.length + 1)}: ${label}`); + } + } +} + +for (const path of await sourceFiles(resolve(packageRoot, "src/react"))) { + if (![".ts", ".tsx"].includes(extname(path))) continue; + if (/\bui-[a-z-]+/.test(await readFile(path, "utf8"))) { + violations.push(`${path.slice(packageRoot.length + 1)}: legacy ui- class name`); + } +} + +const styles = await readFile(resolve(packageRoot, "devtools/browser/src/styles.css"), "utf8"); +for (const token of [ + "--background", + "--foreground", + "--card", + "--primary", + "--muted", + "--border", + "--ring", + "--font-sans", + "--font-mono", + "--space-1", + "--radius-sm", + "--motion-fast", +]) { + if (!styles.includes(`${token}:`)) { + violations.push(`devtools/browser/src/styles.css: missing ${token}`); + } +} + +const sdkStyles = await readFile(resolve(packageRoot, "styles.css"), "utf8"); +for (const token of [ + "--pcr-background", + "--pcr-foreground", + "--pcr-card", + "--pcr-primary", + "--pcr-muted", + "--pcr-border", + "--pcr-ring", + "--pcr-font-sans", + "--pcr-font-mono", + "--pcr-space-1", + "--pcr-radius-sm", + "--pcr-motion-fast", + "--pcr-touch-target", +]) { + if (!sdkStyles.includes(`${token}:`)) { + violations.push(`styles.css: missing ${token}`); + } +} +if (!sdkStyles.startsWith(".pcr-root {")) { + violations.push("styles.css: token sheet must be scoped to .pcr-root"); +} +if (sdkStyles.includes(":root")) { + violations.push("styles.css: SDK tokens may not leak onto :root"); +} +if (sdkStyles.includes("\\n")) { + violations.push("styles.css: contains a literal \\n escape sequence"); +} +if (/--pcr---|\.pcr-[a-z-]+--pcr-/i.test(sdkStyles)) { + violations.push("styles.css: malformed repeated pcr prefix"); +} +const sdkTokenNames = new Set([ + ...[...sdkStyles.matchAll(/--([a-z][a-z0-9-]*)\s*:/gi)].map((match) => match[1]), + ...[...sdkStyles.matchAll(/var\(\s*--([a-z][a-z0-9-]*)/gi)].map( + (match) => match[1], + ), +]); +for (const tokenName of sdkTokenNames) { + if (!tokenName.startsWith("pcr-")) { + violations.push(`styles.css: unprefixed SDK token --${tokenName}`); + } +} +const cssWithoutComments = sdkStyles.replaceAll(/\/\*[\s\S]*?\*\//g, ""); +const openBraces = [...cssWithoutComments].filter((character) => character === "{").length; +const closeBraces = [...cssWithoutComments].filter((character) => character === "}").length; +if (openBraces !== closeBraces) { + violations.push( + `styles.css: unbalanced braces (${openBraces} open, ${closeBraces} close)`, + ); +} + +if (violations.length > 0) { + process.stderr.write(`Standalone browser token check failed:\n- ${violations.join("\n- ")}\n`); + process.exitCode = 1; +} else { + process.stdout.write( + "Standalone browser token check passed; component visual values stay in styles.css.\n", + ); +}