comparison 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
comparison
equal deleted inserted replaced
253:fdf3816959cb 254:2b6e732087ff
1 const assert = require('node:assert/strict');
2 const fs = require('node:fs');
3 const path = require('node:path');
4
5 const RUNFILES = process.env.JS_BINARY__RUNFILES;
6 const WORKSPACE = process.env.JS_BINARY__WORKSPACE;
7 assert.ok(RUNFILES);
8 assert.ok(WORKSPACE);
9
10 const sourceRoot = path.join(RUNFILES, WORKSPACE, 'design_system/src');
11 const files = [];
12
13 function walk(directory) {
14 for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
15 const entryPath = path.join(directory, entry.name);
16 if (entry.isDirectory()) walk(entryPath);
17 else if (/\.(?:css|html|js)$/.test(entry.name)) files.push(entryPath);
18 }
19 }
20
21 walk(sourceRoot);
22
23 function validateCallableDocumentation(
24 relative,
25 source,
26 index,
27 name,
28 parameterSource,
29 failures,
30 ) {
31 const prefix = source.slice(0, index);
32 const comment = prefix.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
33 const body = comment?.[1] || '';
34 const parameters = parameterSource
35 .split(',')
36 .map(parameter => parameter.trim()
37 .replace(/^\.\.\./, '')
38 .split('=')[0].trim())
39 .filter(Boolean);
40 if (!/@return\s+\{[^}]+\}/.test(body)) {
41 failures.push(`${relative} exported callable ${name} lacks @return`);
42 }
43 const documented = [...body.matchAll(
44 /@param\s+\{[^}]+\}\s+([A-Za-z0-9_$]+)/g,
45 )].map(parameter => parameter[1]);
46 for (const parameter of parameters) {
47 if (!documented.includes(parameter)) {
48 failures.push(
49 `${relative} exported callable ${name} lacks @param for ${parameter}`,
50 );
51 }
52 }
53 }
54
55 const rawColor = new RegExp([
56 '#[0-9a-fA-F]{3,8}\\b',
57 '\\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\\s*\\(',
58 ].join('|'), 'gi');
59 const forbiddenGlyph = /[×←→↑↓+◆◇✓✔✕✖★☆⚠]/gu;
60 const pictographic = /\p{Extended_Pictographic}/gu;
61 const failures = [];
62
63 for (const file of files) {
64 const relative = path.relative(sourceRoot, file);
65 const source = fs.readFileSync(file, 'utf8');
66
67 if (relative !== 'styles/tokens.css') {
68 for (const match of source.matchAll(rawColor)) {
69 failures.push(
70 `${relative}:${source.slice(0, match.index).split('\n').length} ` +
71 `raw color ${JSON.stringify(match[0])}`,
72 );
73 }
74 if (relative.endsWith('.css')) {
75 const css = source.replace(/\/\*[\s\S]*?\*\//g, '');
76 const allowedWords = new Set([
77 'none',
78 'transparent',
79 'currentcolor',
80 'inherit',
81 'initial',
82 'unset',
83 'revert',
84 'solid',
85 'dashed',
86 'dotted',
87 'double',
88 'groove',
89 'ridge',
90 'inset',
91 'outset',
92 'canvas',
93 'canvastext',
94 'linktext',
95 'visitedtext',
96 'activetext',
97 'buttonface',
98 'buttontext',
99 'field',
100 'fieldtext',
101 'highlight',
102 'highlighttext',
103 'graytext',
104 'mark',
105 'marktext',
106 'selecteditem',
107 'selecteditemtext',
108 'accentcolor',
109 'accentcolortext',
110 'color-mix',
111 'in',
112 'srgb',
113 'linear-gradient',
114 'radial-gradient',
115 'conic-gradient',
116 'to',
117 'top',
118 'right',
119 'bottom',
120 'left',
121 'at',
122 'circle',
123 'ellipse',
124 'px',
125 'rem',
126 'em',
127 'vh',
128 'vw',
129 'vi',
130 'vb',
131 'vmin',
132 'vmax',
133 'deg',
134 'rad',
135 'turn',
136 ]);
137 for (const declaration of css.matchAll(
138 /(^|[;{])\s*([-\w]+)\s*:\s*([^;{}]+)/gm,
139 )) {
140 const property = declaration[2].toLowerCase();
141 const value = declaration[3];
142 const colorProperty = [
143 'accent-color',
144 'background',
145 'background-color',
146 'box-shadow',
147 'caret-color',
148 'color',
149 'fill',
150 'outline',
151 'outline-color',
152 'scrollbar-color',
153 'stroke',
154 'text-shadow',
155 ].includes(property) ||
156 (property.startsWith('border') &&
157 !/(?:collapse|image|radius|spacing|style|width)/.test(property));
158 if (!colorProperty) continue;
159 const withoutTokens = value.replace(
160 /var\(\s*--zen-[\w-]+\s*\)/g,
161 '',
162 );
163 const words = withoutTokens.toLowerCase()
164 .match(/[a-z][a-z-]*/g) || [];
165 if (words.some(word => !allowedWords.has(word)) &&
166 !/^\s*(?:0|none|transparent|currentColor|inherit)\s*$/i.test(
167 value,
168 )) {
169 failures.push(
170 `${relative}:${css.slice(0, declaration.index).split('\n').length} ` +
171 `${property} must use a --zen-* token`,
172 );
173 }
174 }
175 }
176 }
177
178 for (const pattern of [forbiddenGlyph, pictographic]) {
179 for (const match of source.matchAll(pattern)) {
180 failures.push(
181 `${relative}:${source.slice(0, match.index).split('\n').length} ` +
182 `icon glyph ${JSON.stringify(match[0])}; use <zen-icon>`,
183 );
184 }
185 }
186 if (relative !== 'components/icon.js' &&
187 /<svg\b|createElementNS\([^)]*["']svg["']/i.test(source)) {
188 failures.push(`${relative} defines SVG outside components/icon.js`);
189 }
190 if (relative.endsWith('.js')) {
191 if (/\bimport\s*\/[/*]/.test(source)) {
192 failures.push(`${relative} places comments inside import syntax`);
193 }
194 const importPattern =
195 /(?:import\s+(?:[^'"]+\s+from\s+)?|export\s+[^'"]+\s+from\s+)["']([^"']+)["']/g;
196 for (const match of source.matchAll(importPattern)) {
197 if (!match[1].startsWith('.')) {
198 failures.push(
199 `${relative} imports external runtime ${JSON.stringify(match[1])}`,
200 );
201 }
202 }
203 if (/\bimport\s*\(/.test(source)) {
204 failures.push(`${relative} uses forbidden dynamic import()`);
205 }
206 }
207 if (relative.endsWith('.css') &&
208 /@font-face|(?:font|font-family)\s*:[^;]*(?:icon|awesome|material)/i.test(
209 source,
210 )) {
211 failures.push(`${relative} declares an icon font`);
212 }
213 if (relative.endsWith('.css')) {
214 for (const match of source.matchAll(
215 /@import\s+(?:url\(\s*)?["']?([^"')\s;]+)["']?\s*\)?/g,
216 )) {
217 if (!match[1].startsWith('.')) {
218 failures.push(
219 `${relative} imports external stylesheet ${JSON.stringify(match[1])}`,
220 );
221 }
222 }
223 }
224 }
225
226 for (const file of files.filter(file =>
227 file.endsWith('.js')
228 )) {
229 const relative = path.relative(sourceRoot, file);
230 const source = fs.readFileSync(file, 'utf8');
231 const declaration =
232 /^export\s+(?:default\s+)?(?:async\s+)?(class|const|let|var|function)\s+([A-Za-z0-9_]+)/gm;
233 for (const match of source.matchAll(declaration)) {
234 const prefix = source.slice(0, match.index);
235 const comment = prefix.match(/\/\*\*([\s\S]*?)\*\/\s*$/);
236 const line = prefix.split('\n').length;
237 if (!comment) {
238 failures.push(`${relative}:${line} ${match[2]} lacks Google-style JSDoc`);
239 continue;
240 }
241 const body = comment[1];
242 if (match[1] === 'class' && !/@extends\s+\{[^}]+\}/.test(body)) {
243 failures.push(`${relative}:${line} ${match[2]} lacks @extends`);
244 } else if (['const', 'let', 'var'].includes(match[1]) &&
245 !/@(?:const|type)\b/.test(body)) {
246 failures.push(`${relative}:${line} ${match[2]} lacks @const/@type`);
247 } else if (match[1] === 'function' &&
248 (!/@param\s+\{[^}]+\}/.test(body) ||
249 !/@return\s+\{[^}]+\}/.test(body))) {
250 failures.push(`${relative}:${line} ${match[2]} lacks @param/@return`);
251 } else if (match[1] === 'function') {
252 const signature = source.slice(match.index).match(
253 /^export\s+(?:default\s+)?(?:async\s+)?function\s+[A-Za-z0-9_]+\s*\(([^)]*)\)/,
254 );
255 const parameters = (signature?.[1] || '')
256 .split(',')
257 .map(parameter => parameter.trim()
258 .replace(/^\.\.\./, '')
259 .split('=')[0].trim())
260 .filter(Boolean);
261 const documented = [...body.matchAll(
262 /@param\s+\{[^}]+\}\s+([A-Za-z0-9_$]+)/g,
263 )].map(parameter => parameter[1]);
264 for (const parameter of parameters) {
265 if (!documented.includes(parameter)) {
266 failures.push(
267 `${relative}:${line} ${match[2]} lacks @param for ${parameter}`,
268 );
269 }
270 }
271 }
272 }
273 for (const match of source.matchAll(
274 /^export\s+const\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?(?:\(([^)]*)\)|([A-Za-z0-9_$]+))\s*=>/gm,
275 )) {
276 validateCallableDocumentation(
277 relative,
278 source,
279 match.index,
280 match[1],
281 match[2] || match[3] || '',
282 failures,
283 );
284 }
285 for (const match of source.matchAll(
286 /^export\s+const\s+([A-Za-z0-9_]+)\s*=\s*(?:async\s*)?function(?:\s+[A-Za-z0-9_]+)?\s*\(([^)]*)\)/gm,
287 )) {
288 validateCallableDocumentation(
289 relative,
290 source,
291 match.index,
292 match[1],
293 match[2],
294 failures,
295 );
296 }
297 for (const match of source.matchAll(
298 /export\s*\{[\s\S]*?\}\s*(?!from\b)(?:;|$)/g,
299 )) {
300 failures.push(
301 `${relative}:${source.slice(0, match.index).split('\n').length} ` +
302 "uses an undocumented local export list",
303 );
304 }
305 }
306
307 assert.deepEqual(
308 failures,
309 [],
310 `Design-system policy violations:\n${failures.join('\n')}`,
311 );