/** * Mandu Guard Statistics * * 위반 통계 및 트렌드 분석 */ import { writeFile, readFile, mkdir } from "fs/promises"; import { dirname, join } from "path"; import type { Violation, ViolationReport, ViolationType, Severity, GuardPreset, } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // Types // ═══════════════════════════════════════════════════════════════════════════ /** * 단일 스캔 기록 */ export interface ScanRecord { /** 스캔 ID */ id: string; /** 스캔 시간 */ timestamp: number; /** 프리셋 */ preset?: GuardPreset; /** 분석된 파일 수 */ filesAnalyzed: number; /** 총 위반 수 */ totalViolations: number; /** 심각도별 카운트 */ bySeverity: Record; /** 타입별 카운트 */ byType: Record; /** 레이어별 카운트 */ byLayer: Record; /** 가장 많은 위반 파일 */ hotspots: Array<{ file: string; count: number }>; } /** * 통계 저장소 */ export interface StatisticsStore { /** 버전 */ version: number; /** 프로젝트 이름 */ projectName?: string; /** 스캔 기록 */ records: ScanRecord[]; /** 마지막 업데이트 */ lastUpdated: number; } /** * 트렌드 분석 */ export interface TrendAnalysis { /** 분석 기간 */ period: { start: number; end: number; days: number; }; /** 위반 변화량 */ violationDelta: number; /** 위반 변화율 (%) */ violationChangePercent: number; /** 개선/악화 */ trend: "improving" | "stable" | "degrading"; /** 레이어별 트렌드 */ byLayer: Record; /** 권장 사항 */ recommendations: string[]; } /** * 레이어별 통계 */ export interface LayerStatistics { /** 레이어 이름 */ name: string; /** 총 위반 수 */ totalViolations: number; /** 위반 원인 레이어 수 */ asSource: number; /** 위반 대상 레이어 수 */ asTarget: number; /** 가장 많이 위반한 타겟 */ topTargets: Array<{ layer: string; count: number }>; /** 건강도 점수 (0-100) */ healthScore: number; } // ═══════════════════════════════════════════════════════════════════════════ // Statistics Generation // ═══════════════════════════════════════════════════════════════════════════ /** * 리포트에서 스캔 기록 생성 */ export function createScanRecord( report: ViolationReport, preset?: GuardPreset ): ScanRecord { // 레이어별 카운트 const byLayer: Record = {}; for (const v of report.violations) { byLayer[v.fromLayer] = (byLayer[v.fromLayer] || 0) + 1; } // 핫스팟 (가장 많은 위반 파일) const fileCounts: Record = {}; for (const v of report.violations) { fileCounts[v.filePath] = (fileCounts[v.filePath] || 0) + 1; } const hotspots = Object.entries(fileCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([file, count]) => ({ file, count })); return { id: generateId(), timestamp: Date.now(), preset, filesAnalyzed: report.filesAnalyzed, totalViolations: report.totalViolations, bySeverity: report.bySeverity, byType: report.byType, byLayer, hotspots, }; } /** * 레이어별 통계 계산 */ export function calculateLayerStatistics( violations: Violation[], layers: string[] ): LayerStatistics[] { const stats: Map = new Map(); // 초기화 for (const layer of layers) { stats.set(layer, { name: layer, totalViolations: 0, asSource: 0, asTarget: 0, topTargets: [], healthScore: 100, }); } // 위반 집계 const targetCounts: Map> = new Map(); for (const v of violations) { // Source 카운트 const sourceStat = stats.get(v.fromLayer); if (sourceStat) { sourceStat.asSource++; sourceStat.totalViolations++; // 타겟 카운트 if (!targetCounts.has(v.fromLayer)) { targetCounts.set(v.fromLayer, new Map()); } const targets = targetCounts.get(v.fromLayer)!; targets.set(v.toLayer, (targets.get(v.toLayer) || 0) + 1); } // Target 카운트 const targetStat = stats.get(v.toLayer); if (targetStat) { targetStat.asTarget++; } } // 건강도 점수 및 Top targets 계산 for (const [layer, stat] of stats) { // 건강도: 위반이 많을수록 낮음 const maxViolations = 20; // 20개 이상이면 0점 stat.healthScore = Math.max(0, Math.round(100 - (stat.asSource / maxViolations) * 100)); // Top targets const targets = targetCounts.get(layer); if (targets) { stat.topTargets = Array.from(targets.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([layer, count]) => ({ layer, count })); } } return Array.from(stats.values()); } /** * 트렌드 분석 */ export function analyzeTrend( records: ScanRecord[], days: number = 7 ): TrendAnalysis | null { if (records.length < 2) { return null; } const now = Date.now(); const periodStart = now - days * 24 * 60 * 60 * 1000; // 기간 내 기록 필터링 const periodRecords = records.filter((r) => r.timestamp >= periodStart); if (periodRecords.length < 2) { return null; } // 가장 오래된 것과 가장 최근 것 비교 const oldest = periodRecords[0]; const newest = periodRecords[periodRecords.length - 1]; const violationDelta = newest.totalViolations - oldest.totalViolations; const violationChangePercent = oldest.totalViolations === 0 ? 0 : Math.round((violationDelta / oldest.totalViolations) * 100); const trend: TrendAnalysis["trend"] = violationDelta < -2 ? "improving" : violationDelta > 2 ? "degrading" : "stable"; // 레이어별 트렌드 const allLayers = new Set([ ...Object.keys(oldest.byLayer || {}), ...Object.keys(newest.byLayer || {}), ]); const byLayer: TrendAnalysis["byLayer"] = {}; for (const layer of allLayers) { const oldCount = oldest.byLayer?.[layer] || 0; const newCount = newest.byLayer?.[layer] || 0; const delta = newCount - oldCount; byLayer[layer] = { delta, trend: delta < -1 ? "improving" : delta > 1 ? "degrading" : "stable", }; } // 권장 사항 생성 const recommendations: string[] = []; if (trend === "degrading") { recommendations.push("위반이 증가하고 있습니다. 새 코드 리뷰를 강화하세요."); } const degradingLayers = Object.entries(byLayer) .filter(([_, v]) => v.trend === "degrading") .map(([k, _]) => k); if (degradingLayers.length > 0) { recommendations.push(`주의 필요 레이어: ${degradingLayers.join(", ")}`); } const hotspot = newest.hotspots?.[0]; if (hotspot && hotspot.count > 5) { recommendations.push(`핫스팟: ${hotspot.file} (${hotspot.count}개 위반) - 리팩토링 고려`); } if (recommendations.length === 0 && trend === "improving") { recommendations.push("잘하고 있습니다! 아키텍처 품질이 개선되고 있습니다."); } return { period: { start: oldest.timestamp, end: newest.timestamp, days, }, violationDelta, violationChangePercent, trend, byLayer, recommendations, }; } // ═══════════════════════════════════════════════════════════════════════════ // Statistics Storage // ═══════════════════════════════════════════════════════════════════════════ const STATS_FILE = ".mandu/guard-stats.json"; const MAX_RECORDS = 100; /** * 통계 저장소 로드 */ export async function loadStatistics(rootDir: string): Promise { const filePath = join(rootDir, STATS_FILE); try { const content = await readFile(filePath, "utf-8"); return JSON.parse(content); } catch { return { version: 1, records: [], lastUpdated: Date.now(), }; } } /** * 통계 저장소 저장 */ export async function saveStatistics( rootDir: string, store: StatisticsStore ): Promise { const filePath = join(rootDir, STATS_FILE); // 디렉토리 생성 await mkdir(dirname(filePath), { recursive: true }); // 레코드 수 제한 if (store.records.length > MAX_RECORDS) { store.records = store.records.slice(-MAX_RECORDS); } store.lastUpdated = Date.now(); await writeFile(filePath, JSON.stringify(store, null, 2)); } /** * 스캔 기록 추가 */ export async function addScanRecord( rootDir: string, record: ScanRecord ): Promise { const store = await loadStatistics(rootDir); store.records.push(record); await saveStatistics(rootDir, store); } // ═══════════════════════════════════════════════════════════════════════════ // Report Generation // ═══════════════════════════════════════════════════════════════════════════ /** * 마크다운 리포트 생성 (Guard Report) */ export function generateGuardMarkdownReport( report: ViolationReport, trend?: TrendAnalysis | null, layerStats?: LayerStatistics[] ): string { const lines: string[] = []; lines.push("# 🛡️ Mandu Guard Report"); lines.push(""); lines.push(`*Generated: ${new Date().toISOString()}*`); lines.push(""); // 요약 lines.push("## 📊 Summary"); lines.push(""); lines.push(`| Metric | Value |`); lines.push(`|--------|-------|`); lines.push(`| Files Analyzed | ${report.filesAnalyzed} |`); lines.push(`| Total Violations | ${report.totalViolations} |`); lines.push(`| Errors | ${report.bySeverity.error} |`); lines.push(`| Warnings | ${report.bySeverity.warn} |`); lines.push(`| Info | ${report.bySeverity.info} |`); lines.push(`| Analysis Time | ${report.analysisTime}ms |`); lines.push(""); // 트렌드 if (trend) { lines.push("## 📈 Trend Analysis"); lines.push(""); const trendEmoji = trend.trend === "improving" ? "📉" : trend.trend === "degrading" ? "📈" : "➡️"; lines.push(`**Status:** ${trendEmoji} ${trend.trend.toUpperCase()}`); lines.push(""); lines.push(`- Violation change: ${trend.violationDelta >= 0 ? "+" : ""}${trend.violationDelta}`); lines.push(`- Change rate: ${trend.violationChangePercent >= 0 ? "+" : ""}${trend.violationChangePercent}%`); lines.push(`- Period: ${trend.period.days} days`); lines.push(""); if (trend.recommendations.length > 0) { lines.push("### 💡 Recommendations"); lines.push(""); for (const rec of trend.recommendations) { lines.push(`- ${rec}`); } lines.push(""); } } // 레이어 통계 if (layerStats && layerStats.length > 0) { lines.push("## 🏗️ Layer Health"); lines.push(""); lines.push(`| Layer | Health | Violations | Top Target |`); lines.push(`|-------|--------|------------|------------|`); for (const stat of layerStats) { const healthEmoji = stat.healthScore >= 80 ? "🟢" : stat.healthScore >= 50 ? "🟡" : "🔴"; const topTarget = stat.topTargets[0]; const topTargetStr = topTarget ? `${topTarget.layer} (${topTarget.count})` : "-"; lines.push( `| ${stat.name} | ${healthEmoji} ${stat.healthScore}% | ${stat.asSource} | ${topTargetStr} |` ); } lines.push(""); } // 위반 상세 if (report.violations.length > 0) { lines.push("## ❌ Violations"); lines.push(""); // 타입별 그룹화 const byType = new Map(); for (const v of report.violations) { if (!byType.has(v.type)) { byType.set(v.type, []); } byType.get(v.type)!.push(v); } for (const [type, violations] of byType) { lines.push(`### ${getTypeTitle(type)} (${violations.length})`); lines.push(""); for (const v of violations.slice(0, 10)) { lines.push(`- **${v.filePath}:${v.line}**`); lines.push(` - \`${v.fromLayer}\` → \`${v.toLayer}\``); lines.push(` - ${v.importStatement}`); if (v.suggestions.length > 0) { lines.push(` - 💡 ${v.suggestions[0]}`); } lines.push(""); } if (violations.length > 10) { lines.push(`*... and ${violations.length - 10} more*`); lines.push(""); } } } // 결론 lines.push("---"); lines.push(""); if (report.totalViolations === 0) { lines.push("✅ **All clear!** No architecture violations detected."); } else if (report.bySeverity.error > 0) { lines.push(`❌ **Action required:** ${report.bySeverity.error} error(s) must be fixed.`); } else { lines.push(`⚠️ **Review needed:** ${report.totalViolations} issue(s) found.`); } return lines.join("\n"); } /** * HTML 리포트 생성 */ export function generateHTMLReport( report: ViolationReport, trend?: TrendAnalysis | null, layerStats?: LayerStatistics[] ): string { const markdown = generateGuardMarkdownReport(report, trend, layerStats); // 간단한 마크다운 → HTML 변환 let html = markdown .replace(/^# (.+)$/gm, "

$1

") .replace(/^## (.+)$/gm, "

$1

") .replace(/^### (.+)$/gm, "

$1

") .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\*(.+?)\*/g, "$1") .replace(/`(.+?)`/g, "$1") .replace(/^- (.+)$/gm, "
  • $1
  • ") .replace(/\n\n/g, "

    ") .replace(/\|(.+)\|/g, (match) => { const cells = match .split("|") .filter((c) => c.trim()) .map((c) => `${c.trim()}`) .join(""); return `${cells}`; }); return ` Mandu Guard Report

    ${html}
    `; } // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════ function generateId(): string { return `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } function getTypeTitle(type: ViolationType): string { switch (type) { case "layer-violation": return "Layer Violations"; case "circular-dependency": return "Circular Dependencies"; case "cross-slice": return "Cross-Slice Dependencies"; case "deep-nesting": return "Deep Nesting"; case "file-type": return "File Type Violations"; case "invalid-shared-segment": return "Shared Segment Violations"; default: return "Violations"; } }