设计系统的 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 合规性"的自动化检查:
这三条规则覆盖了 80% 的设计规范违规场景。在 PR 阶段自动检查并评论建议修复方案,让设计规范的遵守成本从"人工 Code Review"降低到"看 PR Comment 改一行"。



