/** * Brain v0.1 - Watch Architecture Rules * * 5 MVP rules that catch the most common mistakes. * Rules only warn - they never block operations. */ import type { ArchRule, WatchWarning } from "../brain/types"; import path from "path"; import fs from "fs/promises"; /** * The 5 MVP Architecture Rules * * These rules cover the most frequent mistakes in Mandu projects: * 1. Direct modification of generated files * 2. Slot files in wrong locations * 3. Slot file naming convention * 4. Contract file naming convention * 5. Forbidden imports in generated files */ export const MVP_RULES: ArchRule[] = [ { id: "GENERATED_DIRECT_EDIT", name: "Generated Direct Edit", description: "Generated 파일은 직접 수정하면 안 됩니다", pattern: "**/generated/**", action: "warn", message: "Generated 파일이 직접 수정되었습니다. 이 파일은 `mandu generate`로 재생성됩니다.", agentAction: "regenerate", agentCommand: "mandu_generate", }, { id: "WRONG_SLOT_LOCATION", name: "Wrong Slot Location", description: "Slot 파일은 spec/slots/ 디렉토리에 있어야 합니다", pattern: "**/*.slot.ts", action: "warn", message: "Slot 파일이 잘못된 위치에 있습니다. spec/slots/ 디렉토리로 이동하세요.", excludePattern: "spec/slots/**", agentAction: "move", agentCommand: "mandu_check_location", }, { id: "SLOT_NAMING", name: "Slot Naming Convention", description: "Slot 파일은 .slot.ts로 끝나야 합니다", pattern: "spec/slots/*.ts", action: "warn", message: "Slot 파일명이 .slot.ts로 끝나야 합니다.", mustEndWith: ".slot.ts", agentAction: "rename", agentCommand: "mandu_check_location", }, { id: "CONTRACT_NAMING", name: "Contract Naming Convention", description: "Contract 파일은 .contract.ts로 끝나야 합니다", pattern: "spec/contracts/*.ts", action: "warn", message: "Contract 파일명이 .contract.ts로 끝나야 합니다.", mustEndWith: ".contract.ts", agentAction: "rename", agentCommand: "mandu_check_location", }, { id: "FORBIDDEN_IMPORT", name: "Forbidden Import in Generated", description: "Generated 파일에서 금지된 모듈 import", pattern: "**/generated/**", action: "warn", message: "Generated 파일에서 금지된 모듈이 import되었습니다.", forbiddenImports: ["fs", "child_process", "cluster", "worker_threads"], agentAction: "remove_import", agentCommand: "mandu_guard_check", }, { id: "SLOT_MODIFIED", name: "Slot File Modified", description: "Slot 파일이 수정되었습니다", pattern: "spec/slots/*.slot.ts", action: "warn", message: "Slot 수정 감지. mandu_validate_slot 또는 mandu_guard_check로 검증하세요.", agentAction: "validate", agentCommand: "mandu_validate_slot", }, { id: "ISLAND_FIRST_MODIFIED", name: "Island-First ComponentModule Modified", description: "Island-First 방식으로 생성된 componentModule이 수동으로 변경되었습니다", pattern: ".mandu/generated/web/routes/**", action: "warn", message: "Island-First componentModule이 수동 수정되었습니다. mandu generate를 실행하세요.", agentAction: "regenerate", agentCommand: "mandu_generate", }, ]; /** * Get all rules as a map by ID */ export function getRulesMap(): Map { const map = new Map(); for (const rule of MVP_RULES) { map.set(rule.id, rule); } return map; } /** * Simple glob pattern matching * * Supports: * - ** for any path segment(s) * - * for any characters in a single segment */ export function matchGlob(pattern: string, filePath: string): boolean { // Normalize path separators const normalizedPath = filePath.replace(/\\/g, "/"); const normalizedPattern = pattern.replace(/\\/g, "/"); // Convert glob to regex const regexPattern = normalizedPattern .replace(/\*\*/g, "<>") .replace(/\*/g, "[^/]*") .replace(/<>/g, ".*") .replace(/\//g, "\\/"); const regex = new RegExp(`^${regexPattern}$`); return regex.test(normalizedPath); } /** * Check if a file path matches any rule */ export function matchRules(filePath: string): ArchRule[] { const matched: ArchRule[] = []; for (const rule of MVP_RULES) { if (matchGlob(rule.pattern, filePath)) { // Skip if excluded if (rule.excludePattern && matchGlob(rule.excludePattern, filePath)) { continue; } matched.push(rule); } } return matched; } /** * Check file naming convention */ export function checkNamingConvention( filePath: string, rule: ArchRule ): boolean { if (!rule.mustEndWith) return true; const fileName = path.basename(filePath); return fileName.endsWith(rule.mustEndWith); } /** * Check for forbidden imports in file content */ export async function checkForbiddenImports( filePath: string, content: string, rule: ArchRule ): Promise { if (!rule.forbiddenImports || rule.forbiddenImports.length === 0) { return []; } const found: string[] = []; for (const forbidden of rule.forbiddenImports) { const importRegex = new RegExp( `import\\s+.*from\\s+['"]${forbidden}['"]|require\\s*\\(\\s*['"]${forbidden}['"]\\s*\\)`, "g" ); if (importRegex.test(content)) { found.push(forbidden); } } return found; } /** * Validate a file against all applicable rules */ export async function validateFile( filePath: string, event: "create" | "modify" | "delete", rootDir: string ): Promise { const warnings: WatchWarning[] = []; // Get relative path from root const relativePath = path.relative(rootDir, filePath).replace(/\\/g, "/"); // Find matching rules const matchedRules = matchRules(relativePath); for (const rule of matchedRules) { // Skip delete events for most rules if (event === "delete" && rule.id !== "GENERATED_DIRECT_EDIT") { continue; } // Base warning fields reused across all branches const base = { ruleId: rule.id, file: relativePath, timestamp: new Date(), event, agentAction: rule.agentAction, agentCommand: rule.agentCommand, } as const; // Check naming convention if (rule.mustEndWith && !checkNamingConvention(relativePath, rule)) { warnings.push({ ...base, message: rule.message }); continue; } // Check forbidden imports (only for modify/create) if ( rule.forbiddenImports && rule.forbiddenImports.length > 0 && event !== "delete" ) { try { const content = await fs.readFile(filePath, "utf-8"); const forbidden = await checkForbiddenImports( relativePath, content, rule ); if (forbidden.length > 0) { warnings.push({ ...base, message: `${rule.message} (${forbidden.join(", ")})`, }); } } catch { // File might not exist or be readable } continue; } // Default: generate warning for pattern match if (rule.id === "GENERATED_DIRECT_EDIT" || rule.id === "WRONG_SLOT_LOCATION" || rule.id === "ISLAND_FIRST_MODIFIED") { warnings.push({ ...base, message: rule.message }); } // Slot modified: info level notification if (rule.id === "SLOT_MODIFIED" && event !== "delete") { warnings.push({ ...base, message: rule.message, level: "info" as const }); } } return warnings; } /** * Get rule by ID */ export function getRule(ruleId: string): ArchRule | undefined { return MVP_RULES.find((r) => r.id === ruleId); } /** * Get all rules */ export function getAllRules(): readonly ArchRule[] { return MVP_RULES; }