/** * Brain v0.2 - Architecture Analyzer * * 프로젝트 아키텍처 규칙을 분석하고 위반을 감지 */ import type { ArchitectureConfig, ArchitectureViolation, CheckLocationRequest, CheckLocationResult, CheckImportRequest, CheckImportResult, ProjectStructure, FolderInfo, } from "./types"; import { getBrain } from "../brain"; import path from "path"; import fs from "fs/promises"; /** * Mandu 기본 아키텍처 규칙 */ export const DEFAULT_ARCHITECTURE_CONFIG: ArchitectureConfig = { folders: { "spec/": { pattern: "spec/**", description: "스펙 정의 전용. 구현 코드 금지", allowedFiles: ["*.ts", "*.json"], readonly: false, }, "spec/slots/": { pattern: "spec/slots/**", description: "Slot 파일 전용", allowedFiles: ["*.slot.ts", "*.slot.tsx", "*.client.ts", "*.client.tsx"], }, "spec/contracts/": { pattern: "spec/contracts/**", description: "Contract 파일 전용", allowedFiles: ["*.contract.ts"], }, "generated/": { pattern: "**/generated/**", description: "자동 생성 파일. 직접 수정 금지", readonly: true, }, ".mandu/generated/server/": { pattern: ".mandu/generated/server/**", description: "서버 생성 파일", allowedFiles: ["*.ts"], readonly: true, }, ".mandu/generated/web/": { pattern: ".mandu/generated/web/**", description: "웹 생성 파일", allowedFiles: ["*.ts", "*.tsx"], readonly: true, }, }, imports: [ { source: ".mandu/generated/web/**", forbid: ["fs", "child_process", "path", "crypto"], reason: "프론트엔드 생성 파일에서 Node.js 내장 모듈 사용 금지", }, { source: "**/generated/**", forbid: ["fs", "child_process"], reason: "Generated 파일에서 시스템 모듈 사용 금지", }, { source: "spec/**", forbid: ["react", "react-dom"], reason: "Spec 파일에서 React 사용 금지", }, ], layers: [ { name: "spec", folders: ["spec/**"], canDependOn: [], }, { name: "generated", folders: ["**/generated/**"], canDependOn: ["spec"], }, { name: "app", folders: ["app/**", "src/**"], canDependOn: ["spec", "generated"], }, ], naming: [ { folder: "spec/slots/", filePattern: "^[a-z][a-z0-9-]*\\.(slot|client)\\.tsx?$", description: "Slot 파일은 kebab-case.slot.ts(x) 또는 kebab-case.client.ts(x)", examples: ["users-list.slot.ts", "counter.client.tsx"], }, { folder: "spec/contracts/", filePattern: "^[a-z][a-z0-9-]*\\.contract\\.ts$", description: "Contract 파일은 kebab-case.contract.ts", examples: ["users.contract.ts", "auth.contract.ts"], }, ], }; /** * 글로브 패턴을 정규식으로 변환 */ function globToRegex(glob: string): RegExp { const escaped = glob .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*\*/g, "§§") .replace(/\*/g, "[^/]*") .replace(/§§/g, ".*") .replace(/\?/g, "."); return new RegExp(`^${escaped}$`); } /** * 경로가 패턴과 매칭되는지 확인 */ function matchesPattern(filePath: string, pattern: string): boolean { const normalizedPath = filePath.replace(/\\/g, "/"); const regex = globToRegex(pattern); return regex.test(normalizedPath); } /** * Architecture Analyzer 클래스 */ export class ArchitectureAnalyzer { private config: ArchitectureConfig; private rootDir: string; private projectStructure: ProjectStructure | null = null; constructor(rootDir: string, config?: Partial) { this.rootDir = rootDir; this.config = { ...DEFAULT_ARCHITECTURE_CONFIG, ...config, folders: { ...DEFAULT_ARCHITECTURE_CONFIG.folders, ...config?.folders, }, imports: [ ...(DEFAULT_ARCHITECTURE_CONFIG.imports || []), ...(config?.imports || []), ], }; } /** * 설정 업데이트 */ updateConfig(config: Partial): void { this.config = { ...this.config, ...config, }; } /** * 현재 설정 반환 */ getConfig(): ArchitectureConfig { return this.config; } /** * 파일 위치 검증 */ async checkLocation(request: CheckLocationRequest): Promise { const violations: ArchitectureViolation[] = []; const normalizedPath = this.toRelativePath(request.path); // 1. readonly 폴더 검사 for (const [key, rule] of Object.entries(this.config.folders || {})) { const folderRule = typeof rule === "string" ? { pattern: key, description: rule } : rule; if (folderRule.readonly && matchesPattern(normalizedPath, folderRule.pattern)) { violations.push({ ruleId: "READONLY_FOLDER", ruleType: "folder", file: request.path, message: `이 폴더는 수정 금지입니다: ${folderRule.description}`, suggestion: "이 파일은 자동 생성됩니다. bunx mandu generate를 사용하세요.", severity: "error", }); } } // 2. 네이밍 규칙 검사 for (const rule of this.config.naming || []) { if (normalizedPath.startsWith(rule.folder.replace(/\\/g, "/"))) { const fileName = path.basename(normalizedPath); const regex = new RegExp(rule.filePattern); if (!regex.test(fileName)) { violations.push({ ruleId: "NAMING_CONVENTION", ruleType: "naming", file: request.path, message: `네이밍 규칙 위반: ${rule.description}`, suggestion: `예시: ${rule.examples?.join(", ") || "N/A"}`, severity: "error", }); } } } // 3. 허용된 파일 타입 검사 for (const [key, rule] of Object.entries(this.config.folders || {})) { const folderRule = typeof rule === "string" ? { pattern: key, description: rule } : rule; if (matchesPattern(normalizedPath, folderRule.pattern)) { if (folderRule.allowedFiles && folderRule.allowedFiles.length > 0) { const fileName = path.basename(normalizedPath); const isAllowed = folderRule.allowedFiles.some((pattern) => { const regex = globToRegex(pattern); return regex.test(fileName); }); if (!isAllowed) { violations.push({ ruleId: "DISALLOWED_FILE_TYPE", ruleType: "folder", file: request.path, message: `이 폴더에서 허용되지 않는 파일 타입입니다`, suggestion: `허용: ${folderRule.allowedFiles.join(", ")}`, severity: "warning", }); } } } } // 4. 내용 기반 검사 (content가 제공된 경우) if (request.content) { const importViolations = await this.checkImports({ sourceFile: request.path, imports: this.extractImports(request.content), }); for (const v of importViolations.violations) { violations.push({ ruleId: "FORBIDDEN_IMPORT", ruleType: "import", file: request.path, message: v.reason, suggestion: v.suggestion, severity: "error", }); } } // 5. LLM 제안 (위반이 있는 경우) let suggestion: string | undefined; let recommendedPath: string | undefined; if (violations.length > 0) { const brain = getBrain(); if (await brain.isLLMAvailable()) { const llmSuggestion = await this.getLLMSuggestion(request, violations); suggestion = llmSuggestion.suggestion; recommendedPath = llmSuggestion.recommendedPath; } else { // 템플릿 기반 제안 recommendedPath = this.getTemplateRecommendedPath(request.path); } } return { allowed: violations.length === 0, violations, suggestion, recommendedPath, }; } /** * Import 검증 */ async checkImports(request: CheckImportRequest): Promise { const violations: Array<{ import: string; reason: string; suggestion?: string; }> = []; const normalizedSource = this.toRelativePath(request.sourceFile); for (const importPath of request.imports) { for (const rule of this.config.imports || []) { if (matchesPattern(normalizedSource, rule.source)) { // 금지된 import 검사 if (rule.forbid) { for (const forbidden of rule.forbid) { if ( importPath === forbidden || importPath.startsWith(`${forbidden}/`) ) { violations.push({ import: importPath, reason: rule.reason || `'${importPath}' import 금지`, suggestion: this.getImportSuggestion(importPath, normalizedSource), }); } } } // 허용된 import만 검사 (allow가 정의된 경우) if (rule.allow && rule.allow.length > 0) { const isAllowed = rule.allow.some((allowed) => matchesPattern(importPath, allowed) ); if (!isAllowed) { violations.push({ import: importPath, reason: `'${importPath}'는 허용되지 않은 import입니다`, suggestion: `허용된 패턴: ${rule.allow.join(", ")}`, }); } } } } } return { allowed: violations.length === 0, violations, }; } /** * 프로젝트 구조 인덱싱 */ async indexProject(): Promise { const folders = await this.scanFolders(this.rootDir, 0, 3); this.projectStructure = { rootDir: this.rootDir, folders, config: this.config, indexedAt: new Date().toISOString(), }; return this.projectStructure; } /** * 프로젝트 구조 반환 */ async getProjectStructure(): Promise { if (!this.projectStructure) { return this.indexProject(); } return this.projectStructure; } /** * 코드에서 import 문 추출 */ private extractImports(content: string): string[] { const imports: string[] = []; // ES6 import const importRegex = /import\s+(?:.*\s+from\s+)?['"]([^'"]+)['"]/g; let match; while ((match = importRegex.exec(content)) !== null) { imports.push(match[1]); } // require const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g; while ((match = requireRegex.exec(content)) !== null) { imports.push(match[1]); } return imports; } private toRelativePath(filePath: string): string { const normalized = filePath.replace(/\\/g, "/"); if (path.isAbsolute(normalized)) { return path.relative(this.rootDir, normalized).replace(/\\/g, "/"); } return normalized; } /** * 폴더 스캔 */ private async scanFolders( dir: string, depth: number, maxDepth: number ): Promise { if (depth >= maxDepth) return []; const folders: FolderInfo[] = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name === "node_modules" || entry.name === ".git") continue; const fullPath = path.join(dir, entry.name); const relativePath = path.relative(this.rootDir, fullPath).replace(/\\/g, "/"); // 폴더 설명 찾기 let description: string | undefined; for (const [key, rule] of Object.entries(this.config.folders || {})) { const folderRule = typeof rule === "string" ? { pattern: key, description: rule } : rule; if (matchesPattern(relativePath + "/", folderRule.pattern)) { description = folderRule.description; break; } } // 파일 수 계산 const files = entries.filter((e) => e.isFile()); folders.push({ path: relativePath, description, fileCount: files.length, children: await this.scanFolders(fullPath, depth + 1, maxDepth), }); } } catch { // 권한 없는 폴더 무시 } return folders; } /** * Import에 대한 템플릿 제안 */ private getImportSuggestion(importPath: string, sourceFile: string): string { if (importPath === "fs") { if (sourceFile.includes(".mandu/generated/web") || sourceFile.includes("client")) { return "프론트엔드에서는 fetch API를 사용하세요"; } return "Bun.file() 또는 Bun.write()를 사용하세요"; } if (importPath === "child_process") { return "Bun.spawn() 또는 Bun.$를 사용하세요"; } if (importPath === "path") { return "import.meta.dir 또는 Bun.pathToFileURL을 사용하세요"; } return "다른 모듈을 사용하세요"; } /** * 템플릿 기반 권장 경로 */ private getTemplateRecommendedPath(filePath: string): string | undefined { const normalized = filePath.replace(/\\/g, "/"); const fileName = path.basename(normalized); if (fileName.endsWith(".slot.ts")) { return `spec/slots/${fileName}`; } if (fileName.endsWith(".contract.ts")) { return `spec/contracts/${fileName}`; } if (normalized.includes("generated/")) { return undefined; // generated는 이동 불가 } return undefined; } /** * LLM 기반 제안 */ private async getLLMSuggestion( request: CheckLocationRequest, violations: ArchitectureViolation[] ): Promise<{ suggestion?: string; recommendedPath?: string }> { const brain = getBrain(); const prompt = `Mandu Framework 아키텍처 분석: 파일: ${request.path} 위반 사항: ${violations.map((v) => `- ${v.message}`).join("\n")} 프로젝트 구조 규칙: ${JSON.stringify(this.config.folders, null, 2)} 질문: 1. 이 파일의 올바른 위치는 어디인가요? 2. 어떻게 수정해야 하나요? 짧고 명확하게 답변하세요 (3줄 이내).`; try { const result = await brain.generate(prompt); // 응답에서 경로 추출 시도 const pathMatch = result.match(/(?:spec\/|\.mandu\/|app\/|src\/|packages\/)[^\s,)]+/); return { suggestion: result, recommendedPath: pathMatch?.[0], }; } catch { return {}; } } } /** * 글로벌 analyzer 인스턴스 */ let globalAnalyzer: ArchitectureAnalyzer | null = null; /** * Architecture Analyzer 초기화 */ export function initializeArchitectureAnalyzer( rootDir: string, config?: Partial ): ArchitectureAnalyzer { globalAnalyzer = new ArchitectureAnalyzer(rootDir, config); return globalAnalyzer; } /** * 글로벌 analyzer 반환 */ export function getArchitectureAnalyzer(): ArchitectureAnalyzer | null { return globalAnalyzer; }