/** * Rule Engine - Evaluates Guardian rules against local files */ import fs from 'fs/promises'; import path from 'path'; import chalk from 'chalk'; export interface EffectiveRule { id: string; rule_name: string; rule_type: string; value: Record; severity: 'critical' | 'warning' | 'info'; description: string; source: 'global' | 'project_override'; is_enabled: boolean; } export interface Violation { file: string; rule: string; ruleType: string; severity: 'critical' | 'warning' | 'info'; message: string; details?: string; line?: number; } export interface CheckResult { file: string; violations: Violation[]; passed: boolean; } /** * Check a single file against all rules */ export async function checkFile( filePath: string, rules: EffectiveRule[], rootPath: string ): Promise { const violations: Violation[] = []; const relativePath = path.relative(rootPath, filePath); try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); for (const rule of rules) { const ruleViolations = await evaluateRule(rule, content, lines, relativePath); violations.push(...ruleViolations); } } catch (error: any) { violations.push({ file: relativePath, rule: 'FILE_READ_ERROR', ruleType: 'SYSTEM', severity: 'warning', message: `Could not read file: ${error.message}` }); } return { file: relativePath, violations, passed: violations.length === 0 }; } /** * Evaluate a single rule against file content */ async function evaluateRule( rule: EffectiveRule, content: string, lines: string[], filePath: string ): Promise { const violations: Violation[] = []; switch (rule.rule_type) { case 'MAX_FILE_LINES': { const value = rule.value as { limit: number; warning_threshold?: number }; const lineCount = lines.length; if (lineCount > value.limit) { violations.push({ file: filePath, rule: rule.rule_name, ruleType: rule.rule_type, severity: 'critical', message: `File exceeds ${value.limit} lines`, details: `Current: ${lineCount} lines (limit: ${value.limit})` }); } else if (value.warning_threshold && lineCount > value.warning_threshold) { violations.push({ file: filePath, rule: rule.rule_name, ruleType: rule.rule_type, severity: 'warning', message: `File approaching line limit`, details: `Current: ${lineCount} lines (warning at: ${value.warning_threshold}, limit: ${value.limit})` }); } break; } case 'MAX_FUNCTION_LINES': { const value = rule.value as { limit: number }; const functionViolations = checkFunctionLines(content, lines, filePath, rule, value.limit); violations.push(...functionViolations); break; } case 'PATTERN_FORBIDDEN': { const value = rule.value as { pattern: string; message?: string }; const pattern = new RegExp(value.pattern, 'g'); let match; while ((match = pattern.exec(content)) !== null) { const lineNumber = content.substring(0, match.index).split('\n').length; violations.push({ file: filePath, rule: rule.rule_name, ruleType: rule.rule_type, severity: rule.severity, message: value.message || `Forbidden pattern found: ${value.pattern}`, line: lineNumber, details: `Found: "${match[0].substring(0, 50)}${match[0].length > 50 ? '...' : ''}"` }); } break; } case 'PATTERN_REQUIRED': { const value = rule.value as { pattern: string; context?: string }; const pattern = new RegExp(value.pattern); // Only check if context matches (e.g., only in certain file types) const shouldCheck = !value.context || filePath.includes(value.context); if (shouldCheck && !pattern.test(content)) { violations.push({ file: filePath, rule: rule.rule_name, ruleType: rule.rule_type, severity: rule.severity, message: `Required pattern not found: ${value.pattern}`, details: value.context ? `Expected in files matching: ${value.context}` : undefined }); } break; } case 'NAMING_CONVENTION': { const value = rule.value as { pattern: string; context: string }; const pattern = new RegExp(value.pattern); const fileName = path.basename(filePath); // Check if context matches (e.g., "components" for component files) if (filePath.includes(value.context) && !pattern.test(fileName)) { violations.push({ file: filePath, rule: rule.rule_name, ruleType: rule.rule_type, severity: rule.severity, message: `File name does not match naming convention`, details: `Expected pattern: ${value.pattern}` }); } break; } default: // Unknown rule type, skip break; } return violations; } /** * Check function line counts using simple heuristics (not full AST) * Works for TypeScript/JavaScript */ function checkFunctionLines( content: string, lines: string[], filePath: string, rule: EffectiveRule, limit: number ): Violation[] { const violations: Violation[] = []; // Simple regex-based detection for functions // Matches: function name(), async function name(), const name = () =>, const name = function() const functionPatterns = [ /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\([^)]*\)\s*\{/g, /(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>\s*\{/g, /(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?function\s*\([^)]*\)\s*\{/g, /(\w+)\s*\([^)]*\)\s*\{/g // Method in class/object ]; for (const pattern of functionPatterns) { let match; while ((match = pattern.exec(content)) !== null) { const functionName = match[1]; const startIndex = match.index + match[0].length - 1; // Position of opening brace // Find matching closing brace let braceCount = 1; let endIndex = startIndex + 1; while (braceCount > 0 && endIndex < content.length) { if (content[endIndex] === '{') braceCount++; else if (content[endIndex] === '}') braceCount--; endIndex++; } // Count lines in function const functionContent = content.substring(startIndex, endIndex); const functionLines = functionContent.split('\n').length; if (functionLines > limit) { const lineNumber = content.substring(0, match.index).split('\n').length; violations.push({ file: filePath, rule: rule.rule_name, ruleType: rule.rule_type, severity: rule.severity, message: `Function "${functionName}" exceeds ${limit} lines`, line: lineNumber, details: `Current: ${functionLines} lines (limit: ${limit})` }); } } } return violations; } /** * Format violations for console output */ export function formatViolations(violations: Violation[]): void { for (const v of violations) { const severityColor = v.severity === 'critical' ? chalk.red : v.severity === 'warning' ? chalk.yellow : chalk.blue; const lineInfo = v.line ? chalk.dim(`:${v.line}`) : ''; console.log(` ${severityColor(`[${v.severity.toUpperCase()}]`)} ${v.file}${lineInfo}`); console.log(` ${v.message}`); if (v.details) { console.log(` ${chalk.dim(v.details)}`); } } } /** * Summarize results */ export function summarizeResults(results: CheckResult[]): { totalFiles: number; totalViolations: number; criticalCount: number; warningCount: number; infoCount: number; } { let criticalCount = 0; let warningCount = 0; let infoCount = 0; for (const result of results) { for (const v of result.violations) { if (v.severity === 'critical') criticalCount++; else if (v.severity === 'warning') warningCount++; else infoCount++; } } return { totalFiles: results.length, totalViolations: criticalCount + warningCount + infoCount, criticalCount, warningCount, infoCount }; }