import { DesignRules, Violation, FigmaNode } from '../types'; import { FigmaClient } from '../figma-client'; export class RuleEngine { constructor( private rules: DesignRules, private figmaClient: FigmaClient ) {} checkNode(node: FigmaNode, page?: string, frame?: string): Violation[] { const violations: Violation[] = []; // Skip ignored nodes if (this.shouldIgnore(node, frame)) { return violations; } // Check colors if (this.rules.colors) { violations.push(...this.checkColors(node, page, frame)); } // Check typography if (this.rules.typography && node.type === 'TEXT') { violations.push(...this.checkTypography(node, page, frame)); } // Check spacing if (this.rules.spacing) { violations.push(...this.checkSpacing(node, page, frame)); } // Check border radius if (this.rules.borderRadius && node.cornerRadius !== undefined) { violations.push(...this.checkBorderRadius(node, page, frame)); } return violations; } private shouldIgnore(node: FigmaNode, frame?: string): boolean { if (!this.rules.ignore) return false; // Check frame patterns if (frame && this.rules.ignore.frames) { for (const pattern of this.rules.ignore.frames) { const regex = new RegExp(pattern.replace('*', '.*')); if (regex.test(frame)) return true; } } // Check component patterns if (node.name && this.rules.ignore.components) { for (const pattern of this.rules.ignore.components) { const regex = new RegExp(pattern.replace('*', '.*')); if (regex.test(node.name)) return true; } } return false; } private checkColors(node: FigmaNode, page?: string, frame?: string): Violation[] { const violations: Violation[] = []; const allowedColors = [ ...(this.rules.colors?.primary || []), ...(this.rules.colors?.secondary || []), ...(this.rules.colors?.neutral || []), ]; const checkFills = (fills: any[]) => { for (const fill of fills) { if (fill.type === 'SOLID' && fill.color) { const hex = this.figmaClient.rgbToHex( fill.color.r, fill.color.g, fill.color.b ); if (!allowedColors.includes(hex) && !this.rules.colors?.allowUnknown) { violations.push({ type: 'color', severity: this.getSeverity('colors'), message: `Unapproved color: ${hex}`, node: { id: node.id, name: node.name, type: node.type, }, expected: allowedColors, actual: hex, frame, page, }); } } } }; if (node.fills && Array.isArray(node.fills)) { checkFills(node.fills); } if (node.strokes && Array.isArray(node.strokes)) { checkFills(node.strokes); } return violations; } private checkTypography(node: FigmaNode, page?: string, frame?: string): Violation[] { const violations: Violation[] = []; const { families = [], sizes = [], weights = [] } = this.rules.typography || {}; if (node.style) { // Check font family if (families.length > 0 && node.style.fontFamily) { if (!families.includes(node.style.fontFamily)) { violations.push({ type: 'typography', severity: this.getSeverity('typography'), message: `Unapproved font family: ${node.style.fontFamily}`, node: { id: node.id, name: node.name, type: node.type }, expected: families, actual: node.style.fontFamily, frame, page, }); } } // Check font size if (sizes.length > 0 && node.style.fontSize) { if (!sizes.includes(node.style.fontSize)) { violations.push({ type: 'typography', severity: this.getSeverity('typography.size'), message: `Unapproved font size: ${node.style.fontSize}px`, node: { id: node.id, name: node.name, type: node.type }, expected: sizes, actual: node.style.fontSize, frame, page, }); } } // Check font weight if (weights.length > 0 && node.style.fontWeight) { if (!weights.includes(node.style.fontWeight)) { violations.push({ type: 'typography', severity: this.getSeverity('typography.weight'), message: `Unapproved font weight: ${node.style.fontWeight}`, node: { id: node.id, name: node.name, type: node.type }, expected: weights, actual: node.style.fontWeight, frame, page, }); } } } return violations; } private checkSpacing(node: FigmaNode, page?: string, frame?: string): Violation[] { const violations: Violation[] = []; const { values = [], tolerance = 0 } = this.rules.spacing || {}; if (values.length === 0) return violations; const checkValue = (actual: number, propertyName: string) => { const isValid = values.some(v => Math.abs(actual - v) <= tolerance); if (!isValid) { violations.push({ type: 'spacing', severity: this.getSeverity('spacing'), message: `Invalid ${propertyName}: ${actual}px`, node: { id: node.id, name: node.name, type: node.type }, expected: values, actual, frame, page, }); } }; // Check padding if (node.paddingLeft !== undefined) checkValue(node.paddingLeft, 'paddingLeft'); if (node.paddingRight !== undefined) checkValue(node.paddingRight, 'paddingRight'); if (node.paddingTop !== undefined) checkValue(node.paddingTop, 'paddingTop'); if (node.paddingBottom !== undefined) checkValue(node.paddingBottom, 'paddingBottom'); // Check item spacing (gap in auto-layout) if (node.itemSpacing !== undefined) checkValue(node.itemSpacing, 'itemSpacing'); return violations; } private checkBorderRadius(node: FigmaNode, page?: string, frame?: string): Violation[] { const violations: Violation[] = []; const { values = [], tolerance = 0 } = this.rules.borderRadius || {}; if (values.length === 0 || node.cornerRadius === undefined) { return violations; } const isValid = values.some(v => Math.abs(node.cornerRadius! - v) <= tolerance); if (!isValid) { violations.push({ type: 'borderRadius', severity: this.getSeverity('borderRadius'), message: `Invalid border radius: ${node.cornerRadius}px`, node: { id: node.id, name: node.name, type: node.type }, expected: values, actual: node.cornerRadius, frame, page, }); } return violations; } private getSeverity(path: string): 'critical' | 'warning' | 'info' { return this.rules.severity?.[path] || 'warning'; } }