设计系统的 AI 代码审查:自动检测违反设计规范的前端代码 设计系统的 AI 代码审查自动检测违反设计规范的前端代码一、这个颜色不在色板里但看起来差不多——设计破产的起点Code Review 时我看到 PR 中有这样一行 CSSbackground: #f5a623;。查了设计 Token 表最近的颜色是--color-warning: #fa8c16。写这段代码的同事说我就是想给标签加个背景色试了几个 Token 都不合适就随便取了个颜色。这不是态度问题。当组件库的 Token 覆盖率达到 90% 但 10% 的场景没有合适的 Token 时开发者会本能地自己凑一个。而这个凑出来的颜色就成了设计规范的裂缝——今天是#f5a623下个月是#f0a500三个月后你的产品里有 7 种不同的橙黄色。AI 代码审查要做的不是发现语法错误ESLint 已经做了而是发现违反设计规范的代码——硬编码了 Token 表中不存在的颜色值、使用了不在间距体系里的间距值、突破了设计规范定义的字号范围。二、设计规范审查引擎架构flowchart TD A[PR 代码变更] -- B[AST 解析] B -- C{CSS/SCSS 声明检测} C -- D1[颜色值检测] D1 -- D2{值在 Token 表中?} D2 --|是| D3[通过] D2 --|否| D4[检查是否变量引用] D4 --|是变量| D5[检查变量名是否合法 Token] D4 --|硬编码值| D6[标记违规] D5 --|不在 Token 表中| D6 C -- E1[间距值检测] E1 -- E2{值是 4 的倍数?} E2 --|否| E6[标记违规] E2 --|是| E3{在间距体系中?} E3 --|否| E4[警告: 非标准间距] C -- F1[字号检测] F1 -- F2{值在字号体系中?} F2 --|否| F6[标记违规] D6 -- G[生成审查评论] E6 -- G E4 -- G F6 -- G G -- H[PR 评论 建议修复方案]三、设计规范 AI 审查器实现// design-review/design-lint.ts // AI 驱动的设计规范审查器 import fs from fs; import path from path; import * as postcss from postcss; import { simpleGit } from simple-git; interface TokenSpec { /** 颜色 Token 允许的所有值 */ colors: Mapstring, string; // hex值 → Token名 /** 间距体系4px 倍数 */ spacings: number[]; // [0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64] /** 字号体系 */ fontSizes: number[]; // [12, 14, 16, 18, 20, 24, 28, 32, 40, 48] /** 允许的第三方颜色如白色、黑色、透明 */ allowedRawColors: Setstring; // [#fff, #ffffff, #000, #000000, transparent] } interface DesignViolation { file: string; line: number; severity: error | warning; category: color | spacing | typography | radius | shadow; currentValue: string; suggestion: string; reasoning: string; } /** * 设计规范审查器 * * 在 PR 阶段自动运行检测代码是否违反设计规范 * 核心原则只做确定性检测不做模糊推断 */ class DesignLinter { private tokenSpec: TokenSpec; constructor(tokenJsonPath: string) { this.tokenSpec this.loadTokenSpec(tokenJsonPath); } /** * 从 tokens.json 加载设计规范 */ private loadTokenSpec(tokenPath: string): TokenSpec { const tokens JSON.parse(fs.readFileSync(tokenPath, utf-8)); // 展平 Token 树提取颜色、间距、字号 const colors new Mapstring, string(); const spacings new Setnumber(); const fontSizes new Setnumber(); function walk(obj: any, prefix: string ) { for (const [key, val] of Object.entries(obj)) { if (val typeof val object value in val) { const tokenName prefix ? ${prefix}-${key} : key; if (val.type color) { colors.set(val.value.toLowerCase(), tokenName); } else if (val.type spacing) { spacings.add(parseFloat(val.value)); } else if (val.type fontSize) { fontSizes.add(parseFloat(val.value)); } } else if (val typeof val object) { walk(val, prefix ? ${prefix}-${key} : key); } } } walk(tokens); return { colors, spacings: Array.from(spacings).sort((a, b) a - b), fontSizes: Array.from(fontSizes).sort((a, b) a - b), allowedRawColors: new Set([ #fff, #ffffff, #000, #000000, transparent, currentcolor, inherit, white, black ]) }; } /** * 审查 PR 中的 CSS/SCSS 变更 * * param changedFiles - PR 中变更的文件列表 * returns 违反设计规范的列表 */ async review(changedFiles: string[]): PromiseDesignViolation[] { const violations: DesignViolation[] []; const stylePattern /\.(css|scss|less|styl)$/; for (const file of changedFiles) { if (!stylePattern.test(file) !file.endsWith(.tsx) !file.endsWith(.jsx)) { continue; } const content fs.readFileSync(file, utf-8); // 如果是 TSX/JSX提取其中的 style 对象和 styled-components if (file.endsWith(.tsx) || file.endsWith(.jsx)) { violations.push(...this.reviewJSXStyles(file, content)); } // 如果是 CSS/SCSS用 PostCSS 解析 if (file.endsWith(.css) || file.endsWith(.scss) || file.endsWith(.less)) { violations.push(...await this.reviewStyleFile(file, content)); } } return violations; } /** * 审查样式文件中的声明 * * 使用 PostCSS 解析 AST遍历每个 Declaration 节点 */ private async reviewStyleFile( file: string, content: string ): PromiseDesignViolation[] { const violations: DesignViolation[] []; try { const root postcss.parse(content); root.walkDecls((decl) { // ---- 颜色检测 ---- if (this.isColorProperty(decl.prop)) { const colorMatch decl.value.match( /(?:#(?:[0-9a-fA-F]{3}){1,2}\b|rgba?\([^)]\)|hsla?\([^)]\))/ ); if (colorMatch) { const colorValue colorMatch[0]; // 跳过 var(--xxx) 和变量引用 if (decl.value.includes(var()) return; // 跳过允许的原始颜色 if (this.tokenSpec.allowedRawColors.has(colorValue.toLowerCase())) { return; } // 检查是否在 Token 表中 const normalizedColor colorValue.toLowerCase().replace(/\s/g, ); if (!this.tokenSpec.colors.has(normalizedColor)) { // 查找最接近的 Token 颜色作为建议 const suggestion this.findClosestColor(normalizedColor); violations.push({ file, line: decl.source?.start?.line || 0, severity: error, category: color, currentValue: colorValue, suggestion: suggestion ? var(--color-${suggestion.tokenName}) : 使用设计 Token 替代, reasoning: suggestion ? 硬编码颜色 ${colorValue} 不在设计 Token 中最接近的 Token 为 ${suggestion.tokenName} (${suggestion.hex}) : 硬编码颜色 ${colorValue} 不在设计 Token 中请在 tokens.json 中增补或使用已有 Token }); } } } // ---- 间距检测 ---- if (this.isSpacingProperty(decl.prop)) { const pxMatch decl.value.match(/(\d)px/g); if (pxMatch) { for (const pxValue of pxMatch) { const numValue parseInt(pxValue); // 跳过 0 和 1border 宽度等 if (numValue 1) continue; // 不是 4 的倍数 if (numValue % 4 ! 0) { const nearest Math.round(numValue / 4) * 4; violations.push({ file, line: decl.source?.start?.line || 0, severity: warning, category: spacing, currentValue: ${numValue}px, suggestion: ${nearest}px 或使用 Token var(--space-${nearest/4}), reasoning: 间距 ${numValue}px 不是 4px 的倍数建议使用 ${nearest}px }); } } } } // ---- 字号检测 ---- if (decl.prop font-size) { const pxMatch decl.value.match(/(\d)px/); if (pxMatch) { const fontSize parseInt(pxMatch[1]); const validSizes this.tokenSpec.fontSizes; // 允许行高line-height等非标准值的自由使用 if (!validSizes.includes(fontSize)) { const nearest validSizes.reduce((prev, curr) Math.abs(curr - fontSize) Math.abs(prev - fontSize) ? curr : prev ); violations.push({ file, line: decl.source?.start?.line || 0, severity: warning, category: typography, currentValue: ${fontSize}px, suggestion: var(--text-${this.fontSizeToTokenScale(nearest)}) 或 ${nearest}px, reasoning: 字号 ${fontSize}px 不在设计规范字段体系内最接近的标准字号为 ${nearest}px }); } } } }); } catch (e) { console.error(解析 ${file} 失败:, e); } return violations; } /** * 审查 JSX 中的内联样式和 styled-components */ private reviewJSXStyles(file: string, content: string): DesignViolation[] { const violations: DesignViolation[] []; // 匹配 style{{ backgroundColor: #xxx }} 模式 const inlineStylePattern /style\{\{[\s\S]*?\}\}/g; // 简化实现——实际工程中需要解析 JSX AST // 此处略 return violations; } /** 判断是否为颜色相关属性 */ private isColorProperty(prop: string): boolean { return /color|background|border.*color|fill|stroke|outline-color/i.test(prop); } /** 判断是否为间距相关属性 */ private isSpacingProperty(prop: string): boolean { return /^(margin|padding|gap|inset)/i.test(prop); } /** * 查找最接近的设计 Token 颜色 * * 使用 RGB 距离算法找到视觉上最近的颜色 */ private findClosestColor(hex: string): { tokenName: string; hex: string } | null { if (!this.tokenSpec.colors.size) return null; const target this.hexToRgb(hex); if (!target) return null; let minDistance Infinity; let closestToken: { tokenName: string; hex: string } | null null; for (const [tokenHex, tokenName] of this.tokenSpec.colors) { const tokenRgb this.hexToRgb(tokenHex); if (!tokenRgb) continue; // RGB 欧几里得距离也有更精确的 Delta E 公式但 RGB 距离对审查已足够 const dr target.r - tokenRgb.r; const dg target.g - tokenRgb.g; const db target.b - tokenRgb.b; const distance dr * dr dg * dg db * db; if (distance minDistance) { minDistance distance; closestToken { tokenName, hex: tokenHex }; } } // 距离超过一定阈值 → 差异太大不建议自动匹配 if (closestToken minDistance 10000) { return null; } return closestToken; } private hexToRgb(hex: string): { r: number; g: number; b: number } | null { hex hex.replace(#, ); if (hex.length 3) { hex hex[0] hex[0] hex[1] hex[1] hex[2] hex[2]; } if (hex.length ! 6) return null; return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16) }; } private fontSizeToTokenScale(size: number): string { // 将 px 字号映射到 Token 名称中的语义标识 switch (size) { case 12: return xs; case 14: return sm; case 16: return md; case 18: return lg; case 20: return xl; case 24: return 2xl; default: return custom-${size}; } } /** * 生成 Markdown 格式的审查报告 */ generateReport(violations: DesignViolation[]): string { if (violations.length 0) { return 设计规范审查通过。未发现违规。; } const errors violations.filter(v v.severity error); const warnings violations.filter(v v.severity warning); let report ## 设计规范审查报告\n\n; if (errors.length 0) { report ### 错误 (${errors.length})\n\n; report | 文件 | 行号 | 类别 | 当前值 | 建议 |\n; report |------|------|------|--------|------|\n; for (const v of errors) { report | ${v.file} | ${v.line} | ${v.category} | \${v.currentValue}\ | ${v.suggestion} |\n; } } if (warnings.length 0) { report \n### 警告 (${warnings.length})\n\n; report | 文件 | 行号 | 类别 | 当前值 | 建议 |\n; report |------|------|------|--------|------|\n; for (const v of warnings) { report | ${v.file} | ${v.line} | ${v.category} | \${v.currentValue}\ | ${v.suggestion} |\n; } } return report; } }四、审查器的能力边界命名无法检测。审查器能发现#f5a623不在 Token 表中但不能发现.card-bg { background: var(--color-error) }是语义错误卡片背景色不应该用错误色。语义检测需要人工 Review。复合值和 calc() 表达式的检测有限。calc(var(--space-4) 1px)这种表达式中的1px难以被检测到。建议在 Token 体系中明确禁止对 Token 值做非 Token 的数学运算。第三方库的样式不可控。如果项目中使用了 Ant Design 等第三方组件库其内部样式可能使用组件库自己的 Token 体系或硬编码值。审查器应跳过node_modules中的文件。五、总结设计系统的 AI 代码审查核心是Design Token 合规性的自动化检查颜色表校验——所有 hex/rgb 值必须在 Token 表中间距体系校验——所有 margin/padding/gap 值必须是 4 的倍数字号体系校验——所有 font-size 值必须在设计规范的字号集合中这三条规则覆盖了 80% 的设计规范违规场景。在 PR 阶段自动检查并评论建议修复方案让设计规范的遵守成本从人工 Code Review降低到看 PR Comment 改一行。