设计系统的 AI 代码审查:自动检测违反设计规范的前端代码
设计系统的 AI 代码审查:自动检测违反设计规范的前端代码
一、"这个颜色不在色板里,但看起来差不多"——设计破产的起点
Code Review 时我看到 PR 中有这样一行 CSS:background: #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: Map<string, 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: Set<string>; // ['#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 Map<string, string>(); const spacings = new Set<number>(); const fontSizes = new Set<number>(); 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[]): Promise<DesignViolation[]> { 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 ): Promise<DesignViolation[]> { 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 和 1(border 宽度等) 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 改一行"。
