diff design_system/test/design_system_policy_test.js @ 254:2b6e732087ff

[ui] Add complete native component catalog Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Tue, 04 Aug 2026 15:12:09 -0700
parents
children 60a876c4587a
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/design_system/test/design_system_policy_test.js	Tue Aug 04 15:12:09 2026 -0700
@@ -0,0 +1,311 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const RUNFILES = process.env.JS_BINARY__RUNFILES;
+const WORKSPACE = process.env.JS_BINARY__WORKSPACE;
+assert.ok(RUNFILES);
+assert.ok(WORKSPACE);
+
+const sourceRoot = path.join(RUNFILES, WORKSPACE, 'design_system/src');
+const files = [];
+
+function walk(directory) {
+  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+    const entryPath = path.join(directory, entry.name);
+    if (entry.isDirectory()) walk(entryPath);
+    else if (/\.(?:css|html|js)$/.test(entry.name)) files.push(entryPath);
+  }
+}
+
+walk(sourceRoot);
+
+function validateCallableDocumentation(
+  relative,
+  source,
+  index,
+  name,
+  parameterSource,
+  failures,
+) {
+  const prefix = source.slice(0, index);
+  const comment = prefix.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
+  const body = comment?.[1] || '';
+  const parameters = parameterSource
+    .split(',')
+    .map(parameter => parameter.trim()
+      .replace(/^\.\.\./, '')
+      .split('=')[0].trim())
+    .filter(Boolean);
+  if (!/@return\s+\{[^}]+\}/.test(body)) {
+    failures.push(`${relative} exported callable ${name} lacks @return`);
+  }
+  const documented = [...body.matchAll(
+    /@param\s+\{[^}]+\}\s+([A-Za-z0-9_$]+)/g,
+  )].map(parameter => parameter[1]);
+  for (const parameter of parameters) {
+    if (!documented.includes(parameter)) {
+      failures.push(
+        `${relative} exported callable ${name} lacks @param for ${parameter}`,
+      );
+    }
+  }
+}
+
+const rawColor = new RegExp([
+  '#[0-9a-fA-F]{3,8}\\b',
+  '\\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\\s*\\(',
+].join('|'), 'gi');
+const forbiddenGlyph = /[×←→↑↓+◆◇✓✔✕✖★☆⚠]/gu;
+const pictographic = /\p{Extended_Pictographic}/gu;
+const failures = [];
+
+for (const file of files) {
+  const relative = path.relative(sourceRoot, file);
+  const source = fs.readFileSync(file, 'utf8');
+
+  if (relative !== 'styles/tokens.css') {
+    for (const match of source.matchAll(rawColor)) {
+      failures.push(
+        `${relative}:${source.slice(0, match.index).split('\n').length} ` +
+        `raw color ${JSON.stringify(match[0])}`,
+      );
+    }
+    if (relative.endsWith('.css')) {
+      const css = source.replace(/\/\*[\s\S]*?\*\//g, '');
+      const allowedWords = new Set([
+        'none',
+        'transparent',
+        'currentcolor',
+        'inherit',
+        'initial',
+        'unset',
+        'revert',
+        'solid',
+        'dashed',
+        'dotted',
+        'double',
+        'groove',
+        'ridge',
+        'inset',
+        'outset',
+        'canvas',
+        'canvastext',
+        'linktext',
+        'visitedtext',
+        'activetext',
+        'buttonface',
+        'buttontext',
+        'field',
+        'fieldtext',
+        'highlight',
+        'highlighttext',
+        'graytext',
+        'mark',
+        'marktext',
+        'selecteditem',
+        'selecteditemtext',
+        'accentcolor',
+        'accentcolortext',
+        'color-mix',
+        'in',
+        'srgb',
+        'linear-gradient',
+        'radial-gradient',
+        'conic-gradient',
+        'to',
+        'top',
+        'right',
+        'bottom',
+        'left',
+        'at',
+        'circle',
+        'ellipse',
+        'px',
+        'rem',
+        'em',
+        'vh',
+        'vw',
+        'vi',
+        'vb',
+        'vmin',
+        'vmax',
+        'deg',
+        'rad',
+        'turn',
+      ]);
+      for (const declaration of css.matchAll(
+        /(^|[;{])\s*([-\w]+)\s*:\s*([^;{}]+)/gm,
+      )) {
+        const property = declaration[2].toLowerCase();
+        const value = declaration[3];
+        const colorProperty = [
+          'accent-color',
+          'background',
+          'background-color',
+          'box-shadow',
+          'caret-color',
+          'color',
+          'fill',
+          'outline',
+          'outline-color',
+          'scrollbar-color',
+          'stroke',
+          'text-shadow',
+        ].includes(property) ||
+          (property.startsWith('border') &&
+           !/(?:collapse|image|radius|spacing|style|width)/.test(property));
+        if (!colorProperty) continue;
+        const withoutTokens = value.replace(
+          /var\(\s*--zen-[\w-]+\s*\)/g,
+          '',
+        );
+        const words = withoutTokens.toLowerCase()
+          .match(/[a-z][a-z-]*/g) || [];
+        if (words.some(word => !allowedWords.has(word)) &&
+            !/^\s*(?:0|none|transparent|currentColor|inherit)\s*$/i.test(
+              value,
+            )) {
+          failures.push(
+            `${relative}:${css.slice(0, declaration.index).split('\n').length} ` +
+            `${property} must use a --zen-* token`,
+          );
+        }
+      }
+    }
+  }
+
+  for (const pattern of [forbiddenGlyph, pictographic]) {
+    for (const match of source.matchAll(pattern)) {
+      failures.push(
+        `${relative}:${source.slice(0, match.index).split('\n').length} ` +
+        `icon glyph ${JSON.stringify(match[0])}; use <zen-icon>`,
+      );
+    }
+  }
+  if (relative !== 'components/icon.js' &&
+      /<svg\b|createElementNS\([^)]*["']svg["']/i.test(source)) {
+    failures.push(`${relative} defines SVG outside components/icon.js`);
+  }
+  if (relative.endsWith('.js')) {
+    if (/\bimport\s*\/[/*]/.test(source)) {
+      failures.push(`${relative} places comments inside import syntax`);
+    }
+    const importPattern =
+      /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+)["']([^"']+)["']/g;
+    for (const match of source.matchAll(importPattern)) {
+      if (!match[1].startsWith('.')) {
+        failures.push(
+          `${relative} imports external runtime ${JSON.stringify(match[1])}`,
+        );
+      }
+    }
+    if (/\bimport\s*\(/.test(source)) {
+      failures.push(`${relative} uses forbidden dynamic import()`);
+    }
+  }
+  if (relative.endsWith('.css') &&
+      /@font-face|(?:font|font-family)\s*:[^;]*(?:icon|awesome|material)/i.test(
+        source,
+      )) {
+    failures.push(`${relative} declares an icon font`);
+  }
+  if (relative.endsWith('.css')) {
+    for (const match of source.matchAll(
+      /@import\s+(?:url\(\s*)?["']?([^"')\s;]+)["']?\s*\)?/g,
+    )) {
+      if (!match[1].startsWith('.')) {
+        failures.push(
+          `${relative} imports external stylesheet ${JSON.stringify(match[1])}`,
+        );
+      }
+    }
+  }
+}
+
+for (const file of files.filter(file =>
+  file.endsWith('.js')
+)) {
+  const relative = path.relative(sourceRoot, file);
+  const source = fs.readFileSync(file, 'utf8');
+  const declaration =
+    /^export\s+(?:default\s+)?(?:async\s+)?(class|const|let|var|function)\s+([A-Za-z0-9_]+)/gm;
+  for (const match of source.matchAll(declaration)) {
+    const prefix = source.slice(0, match.index);
+    const comment = prefix.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
+    const line = prefix.split('\n').length;
+    if (!comment) {
+      failures.push(`${relative}:${line} ${match[2]} lacks Google-style JSDoc`);
+      continue;
+    }
+    const body = comment[1];
+    if (match[1] === 'class' && !/@extends\s+\{[^}]+\}/.test(body)) {
+      failures.push(`${relative}:${line} ${match[2]} lacks @extends`);
+    } else if (['const', 'let', 'var'].includes(match[1]) &&
+               !/@(?:const|type)\b/.test(body)) {
+      failures.push(`${relative}:${line} ${match[2]} lacks @const/@type`);
+    } else if (match[1] === 'function' &&
+               (!/@param\s+\{[^}]+\}/.test(body) ||
+                !/@return\s+\{[^}]+\}/.test(body))) {
+      failures.push(`${relative}:${line} ${match[2]} lacks @param/@return`);
+    } else if (match[1] === 'function') {
+      const signature = source.slice(match.index).match(
+        /^export\s+(?:default\s+)?(?:async\s+)?function\s+[A-Za-z0-9_]+\s*\(([^)]*)\)/,
+      );
+      const parameters = (signature?.[1] || '')
+        .split(',')
+        .map(parameter => parameter.trim()
+          .replace(/^\.\.\./, '')
+          .split('=')[0].trim())
+        .filter(Boolean);
+      const documented = [...body.matchAll(
+        /@param\s+\{[^}]+\}\s+([A-Za-z0-9_$]+)/g,
+      )].map(parameter => parameter[1]);
+      for (const parameter of parameters) {
+        if (!documented.includes(parameter)) {
+          failures.push(
+            `${relative}:${line} ${match[2]} lacks @param for ${parameter}`,
+          );
+        }
+      }
+    }
+  }
+  for (const match of source.matchAll(
+    /^export\s+const\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?(?:\(([^)]*)\)|([A-Za-z0-9_$]+))\s*=>/gm,
+  )) {
+    validateCallableDocumentation(
+      relative,
+      source,
+      match.index,
+      match[1],
+      match[2] || match[3] || '',
+      failures,
+    );
+  }
+  for (const match of source.matchAll(
+    /^export\s+const\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?function(?:\s+[A-Za-z0-9_]+)?\s*\(([^)]*)\)/gm,
+  )) {
+    validateCallableDocumentation(
+      relative,
+      source,
+      match.index,
+      match[1],
+      match[2],
+      failures,
+    );
+  }
+  for (const match of source.matchAll(
+    /export\s*\{[\s\S]*?\}\s*(?!from\b)(?:;|$)/g,
+  )) {
+    failures.push(
+      `${relative}:${source.slice(0, match.index).split('\n').length} ` +
+      "uses an undocumented local export list",
+    );
+  }
+}
+
+assert.deepEqual(
+  failures,
+  [],
+  `Design-system policy violations:\n${failures.join('\n')}`,
+);