@mandujs/core 0.41.2 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,578 +1,578 @@
1
- /**
2
- * Mandu Guard Statistics
3
- *
4
- * 위반 통계 및 트렌드 분석
5
- */
6
-
7
- import { writeFile, readFile, mkdir } from "fs/promises";
8
- import { dirname, join } from "path";
9
- import type {
10
- Violation,
11
- ViolationReport,
12
- ViolationType,
13
- Severity,
14
- GuardPreset,
15
- } from "./types";
16
-
17
- // ═══════════════════════════════════════════════════════════════════════════
18
- // Types
19
- // ═══════════════════════════════════════════════════════════════════════════
20
-
21
- /**
22
- * 단일 스캔 기록
23
- */
24
- export interface ScanRecord {
25
- /** 스캔 ID */
26
- id: string;
27
- /** 스캔 시간 */
28
- timestamp: number;
29
- /** 프리셋 */
30
- preset?: GuardPreset;
31
- /** 분석된 파일 수 */
32
- filesAnalyzed: number;
33
- /** 총 위반 수 */
34
- totalViolations: number;
35
- /** 심각도별 카운트 */
36
- bySeverity: Record<Severity, number>;
37
- /** 타입별 카운트 */
38
- byType: Record<ViolationType, number>;
39
- /** 레이어별 카운트 */
40
- byLayer: Record<string, number>;
41
- /** 가장 많은 위반 파일 */
42
- hotspots: Array<{ file: string; count: number }>;
43
- }
44
-
45
- /**
46
- * 통계 저장소
47
- */
48
- export interface StatisticsStore {
49
- /** 버전 */
50
- version: number;
51
- /** 프로젝트 이름 */
52
- projectName?: string;
53
- /** 스캔 기록 */
54
- records: ScanRecord[];
55
- /** 마지막 업데이트 */
56
- lastUpdated: number;
57
- }
58
-
59
- /**
60
- * 트렌드 분석
61
- */
62
- export interface TrendAnalysis {
63
- /** 분석 기간 */
64
- period: {
65
- start: number;
66
- end: number;
67
- days: number;
68
- };
69
- /** 위반 변화량 */
70
- violationDelta: number;
71
- /** 위반 변화율 (%) */
72
- violationChangePercent: number;
73
- /** 개선/악화 */
74
- trend: "improving" | "stable" | "degrading";
75
- /** 레이어별 트렌드 */
76
- byLayer: Record<string, { delta: number; trend: "improving" | "stable" | "degrading" }>;
77
- /** 권장 사항 */
78
- recommendations: string[];
79
- }
80
-
81
- /**
82
- * 레이어별 통계
83
- */
84
- export interface LayerStatistics {
85
- /** 레이어 이름 */
86
- name: string;
87
- /** 총 위반 수 */
88
- totalViolations: number;
89
- /** 위반 원인 레이어 수 */
90
- asSource: number;
91
- /** 위반 대상 레이어 수 */
92
- asTarget: number;
93
- /** 가장 많이 위반한 타겟 */
94
- topTargets: Array<{ layer: string; count: number }>;
95
- /** 건강도 점수 (0-100) */
96
- healthScore: number;
97
- }
98
-
99
- // ═══════════════════════════════════════════════════════════════════════════
100
- // Statistics Generation
101
- // ═══════════════════════════════════════════════════════════════════════════
102
-
103
- /**
104
- * 리포트에서 스캔 기록 생성
105
- */
106
- export function createScanRecord(
107
- report: ViolationReport,
108
- preset?: GuardPreset
109
- ): ScanRecord {
110
- // 레이어별 카운트
111
- const byLayer: Record<string, number> = {};
112
- for (const v of report.violations) {
113
- byLayer[v.fromLayer] = (byLayer[v.fromLayer] || 0) + 1;
114
- }
115
-
116
- // 핫스팟 (가장 많은 위반 파일)
117
- const fileCounts: Record<string, number> = {};
118
- for (const v of report.violations) {
119
- fileCounts[v.filePath] = (fileCounts[v.filePath] || 0) + 1;
120
- }
121
-
122
- const hotspots = Object.entries(fileCounts)
123
- .sort((a, b) => b[1] - a[1])
124
- .slice(0, 5)
125
- .map(([file, count]) => ({ file, count }));
126
-
127
- return {
128
- id: generateId(),
129
- timestamp: Date.now(),
130
- preset,
131
- filesAnalyzed: report.filesAnalyzed,
132
- totalViolations: report.totalViolations,
133
- bySeverity: report.bySeverity,
134
- byType: report.byType,
135
- byLayer,
136
- hotspots,
137
- };
138
- }
139
-
140
- /**
141
- * 레이어별 통계 계산
142
- */
143
- export function calculateLayerStatistics(
144
- violations: Violation[],
145
- layers: string[]
146
- ): LayerStatistics[] {
147
- const stats: Map<string, LayerStatistics> = new Map();
148
-
149
- // 초기화
150
- for (const layer of layers) {
151
- stats.set(layer, {
152
- name: layer,
153
- totalViolations: 0,
154
- asSource: 0,
155
- asTarget: 0,
156
- topTargets: [],
157
- healthScore: 100,
158
- });
159
- }
160
-
161
- // 위반 집계
162
- const targetCounts: Map<string, Map<string, number>> = new Map();
163
-
164
- for (const v of violations) {
165
- // Source 카운트
166
- const sourceStat = stats.get(v.fromLayer);
167
- if (sourceStat) {
168
- sourceStat.asSource++;
169
- sourceStat.totalViolations++;
170
-
171
- // 타겟 카운트
172
- if (!targetCounts.has(v.fromLayer)) {
173
- targetCounts.set(v.fromLayer, new Map());
174
- }
175
- const targets = targetCounts.get(v.fromLayer)!;
176
- targets.set(v.toLayer, (targets.get(v.toLayer) || 0) + 1);
177
- }
178
-
179
- // Target 카운트
180
- const targetStat = stats.get(v.toLayer);
181
- if (targetStat) {
182
- targetStat.asTarget++;
183
- }
184
- }
185
-
186
- // 건강도 점수 및 Top targets 계산
187
- for (const [layer, stat] of stats) {
188
- // 건강도: 위반이 많을수록 낮음
189
- const maxViolations = 20; // 20개 이상이면 0점
190
- stat.healthScore = Math.max(0, Math.round(100 - (stat.asSource / maxViolations) * 100));
191
-
192
- // Top targets
193
- const targets = targetCounts.get(layer);
194
- if (targets) {
195
- stat.topTargets = Array.from(targets.entries())
196
- .sort((a, b) => b[1] - a[1])
197
- .slice(0, 3)
198
- .map(([layer, count]) => ({ layer, count }));
199
- }
200
- }
201
-
202
- return Array.from(stats.values());
203
- }
204
-
205
- /**
206
- * 트렌드 분석
207
- */
208
- export function analyzeTrend(
209
- records: ScanRecord[],
210
- days: number = 7
211
- ): TrendAnalysis | null {
212
- if (records.length < 2) {
213
- return null;
214
- }
215
-
216
- const now = Date.now();
217
- const periodStart = now - days * 24 * 60 * 60 * 1000;
218
-
219
- // 기간 내 기록 필터링
220
- const periodRecords = records.filter((r) => r.timestamp >= periodStart);
221
-
222
- if (periodRecords.length < 2) {
223
- return null;
224
- }
225
-
226
- // 가장 오래된 것과 가장 최근 것 비교
227
- const oldest = periodRecords[0];
228
- const newest = periodRecords[periodRecords.length - 1];
229
-
230
- const violationDelta = newest.totalViolations - oldest.totalViolations;
231
- const violationChangePercent =
232
- oldest.totalViolations === 0
233
- ? 0
234
- : Math.round((violationDelta / oldest.totalViolations) * 100);
235
-
236
- const trend: TrendAnalysis["trend"] =
237
- violationDelta < -2
238
- ? "improving"
239
- : violationDelta > 2
240
- ? "degrading"
241
- : "stable";
242
-
243
- // 레이어별 트렌드
244
- const allLayers = new Set([
245
- ...Object.keys(oldest.byLayer || {}),
246
- ...Object.keys(newest.byLayer || {}),
247
- ]);
248
-
249
- const byLayer: TrendAnalysis["byLayer"] = {};
250
- for (const layer of allLayers) {
251
- const oldCount = oldest.byLayer?.[layer] || 0;
252
- const newCount = newest.byLayer?.[layer] || 0;
253
- const delta = newCount - oldCount;
254
-
255
- byLayer[layer] = {
256
- delta,
257
- trend: delta < -1 ? "improving" : delta > 1 ? "degrading" : "stable",
258
- };
259
- }
260
-
261
- // 권장 사항 생성
262
- const recommendations: string[] = [];
263
-
264
- if (trend === "degrading") {
265
- recommendations.push("위반이 증가하고 있습니다. 새 코드 리뷰를 강화하세요.");
266
- }
267
-
268
- const degradingLayers = Object.entries(byLayer)
269
- .filter(([_, v]) => v.trend === "degrading")
270
- .map(([k, _]) => k);
271
-
272
- if (degradingLayers.length > 0) {
273
- recommendations.push(`주의 필요 레이어: ${degradingLayers.join(", ")}`);
274
- }
275
-
276
- const hotspot = newest.hotspots?.[0];
277
- if (hotspot && hotspot.count > 5) {
278
- recommendations.push(`핫스팟: ${hotspot.file} (${hotspot.count}개 위반) - 리팩토링 고려`);
279
- }
280
-
281
- if (recommendations.length === 0 && trend === "improving") {
282
- recommendations.push("잘하고 있습니다! 아키텍처 품질이 개선되고 있습니다.");
283
- }
284
-
285
- return {
286
- period: {
287
- start: oldest.timestamp,
288
- end: newest.timestamp,
289
- days,
290
- },
291
- violationDelta,
292
- violationChangePercent,
293
- trend,
294
- byLayer,
295
- recommendations,
296
- };
297
- }
298
-
299
- // ═══════════════════════════════════════════════════════════════════════════
300
- // Statistics Storage
301
- // ═══════════════════════════════════════════════════════════════════════════
302
-
303
- const STATS_FILE = ".mandu/guard-stats.json";
304
- const MAX_RECORDS = 100;
305
-
306
- /**
307
- * 통계 저장소 로드
308
- */
309
- export async function loadStatistics(rootDir: string): Promise<StatisticsStore> {
310
- const filePath = join(rootDir, STATS_FILE);
311
-
312
- try {
313
- const content = await readFile(filePath, "utf-8");
314
- return JSON.parse(content);
315
- } catch {
316
- return {
317
- version: 1,
318
- records: [],
319
- lastUpdated: Date.now(),
320
- };
321
- }
322
- }
323
-
324
- /**
325
- * 통계 저장소 저장
326
- */
327
- export async function saveStatistics(
328
- rootDir: string,
329
- store: StatisticsStore
330
- ): Promise<void> {
331
- const filePath = join(rootDir, STATS_FILE);
332
-
333
- // 디렉토리 생성
334
- await mkdir(dirname(filePath), { recursive: true });
335
-
336
- // 레코드 수 제한
337
- if (store.records.length > MAX_RECORDS) {
338
- store.records = store.records.slice(-MAX_RECORDS);
339
- }
340
-
341
- store.lastUpdated = Date.now();
342
-
343
- await writeFile(filePath, JSON.stringify(store, null, 2));
344
- }
345
-
346
- /**
347
- * 스캔 기록 추가
348
- */
349
- export async function addScanRecord(
350
- rootDir: string,
351
- record: ScanRecord
352
- ): Promise<void> {
353
- const store = await loadStatistics(rootDir);
354
- store.records.push(record);
355
- await saveStatistics(rootDir, store);
356
- }
357
-
358
- // ═══════════════════════════════════════════════════════════════════════════
359
- // Report Generation
360
- // ═══════════════════════════════════════════════════════════════════════════
361
-
362
- /**
363
- * 마크다운 리포트 생성 (Guard Report)
364
- */
365
- export function generateGuardMarkdownReport(
366
- report: ViolationReport,
367
- trend?: TrendAnalysis | null,
368
- layerStats?: LayerStatistics[]
369
- ): string {
370
- const lines: string[] = [];
371
-
372
- lines.push("# 🛡️ Mandu Guard Report");
373
- lines.push("");
374
- lines.push(`*Generated: ${new Date().toISOString()}*`);
375
- lines.push("");
376
-
377
- // 요약
378
- lines.push("## 📊 Summary");
379
- lines.push("");
380
- lines.push(`| Metric | Value |`);
381
- lines.push(`|--------|-------|`);
382
- lines.push(`| Files Analyzed | ${report.filesAnalyzed} |`);
383
- lines.push(`| Total Violations | ${report.totalViolations} |`);
384
- lines.push(`| Errors | ${report.bySeverity.error} |`);
385
- lines.push(`| Warnings | ${report.bySeverity.warn} |`);
386
- lines.push(`| Info | ${report.bySeverity.info} |`);
387
- lines.push(`| Analysis Time | ${report.analysisTime}ms |`);
388
- lines.push("");
389
-
390
- // 트렌드
391
- if (trend) {
392
- lines.push("## 📈 Trend Analysis");
393
- lines.push("");
394
-
395
- const trendEmoji =
396
- trend.trend === "improving"
397
- ? "📉"
398
- : trend.trend === "degrading"
399
- ? "📈"
400
- : "➡️";
401
-
402
- lines.push(`**Status:** ${trendEmoji} ${trend.trend.toUpperCase()}`);
403
- lines.push("");
404
- lines.push(`- Violation change: ${trend.violationDelta >= 0 ? "+" : ""}${trend.violationDelta}`);
405
- lines.push(`- Change rate: ${trend.violationChangePercent >= 0 ? "+" : ""}${trend.violationChangePercent}%`);
406
- lines.push(`- Period: ${trend.period.days} days`);
407
- lines.push("");
408
-
409
- if (trend.recommendations.length > 0) {
410
- lines.push("### 💡 Recommendations");
411
- lines.push("");
412
- for (const rec of trend.recommendations) {
413
- lines.push(`- ${rec}`);
414
- }
415
- lines.push("");
416
- }
417
- }
418
-
419
- // 레이어 통계
420
- if (layerStats && layerStats.length > 0) {
421
- lines.push("## 🏗️ Layer Health");
422
- lines.push("");
423
- lines.push(`| Layer | Health | Violations | Top Target |`);
424
- lines.push(`|-------|--------|------------|------------|`);
425
-
426
- for (const stat of layerStats) {
427
- const healthEmoji =
428
- stat.healthScore >= 80
429
- ? "🟢"
430
- : stat.healthScore >= 50
431
- ? "🟡"
432
- : "🔴";
433
-
434
- const topTarget = stat.topTargets[0];
435
- const topTargetStr = topTarget ? `${topTarget.layer} (${topTarget.count})` : "-";
436
-
437
- lines.push(
438
- `| ${stat.name} | ${healthEmoji} ${stat.healthScore}% | ${stat.asSource} | ${topTargetStr} |`
439
- );
440
- }
441
- lines.push("");
442
- }
443
-
444
- // 위반 상세
445
- if (report.violations.length > 0) {
446
- lines.push("## ❌ Violations");
447
- lines.push("");
448
-
449
- // 타입별 그룹화
450
- const byType = new Map<ViolationType, Violation[]>();
451
- for (const v of report.violations) {
452
- if (!byType.has(v.type)) {
453
- byType.set(v.type, []);
454
- }
455
- byType.get(v.type)!.push(v);
456
- }
457
-
458
- for (const [type, violations] of byType) {
459
- lines.push(`### ${getTypeTitle(type)} (${violations.length})`);
460
- lines.push("");
461
-
462
- for (const v of violations.slice(0, 10)) {
463
- lines.push(`- **${v.filePath}:${v.line}**`);
464
- lines.push(` - \`${v.fromLayer}\` → \`${v.toLayer}\``);
465
- lines.push(` - ${v.importStatement}`);
466
- if (v.suggestions.length > 0) {
467
- lines.push(` - 💡 ${v.suggestions[0]}`);
468
- }
469
- lines.push("");
470
- }
471
-
472
- if (violations.length > 10) {
473
- lines.push(`*... and ${violations.length - 10} more*`);
474
- lines.push("");
475
- }
476
- }
477
- }
478
-
479
- // 결론
480
- lines.push("---");
481
- lines.push("");
482
-
483
- if (report.totalViolations === 0) {
484
- lines.push("✅ **All clear!** No architecture violations detected.");
485
- } else if (report.bySeverity.error > 0) {
486
- lines.push(`❌ **Action required:** ${report.bySeverity.error} error(s) must be fixed.`);
487
- } else {
488
- lines.push(`⚠️ **Review needed:** ${report.totalViolations} issue(s) found.`);
489
- }
490
-
491
- return lines.join("\n");
492
- }
493
-
494
- /**
495
- * HTML 리포트 생성
496
- */
497
- export function generateHTMLReport(
498
- report: ViolationReport,
499
- trend?: TrendAnalysis | null,
500
- layerStats?: LayerStatistics[]
501
- ): string {
502
- const markdown = generateGuardMarkdownReport(report, trend, layerStats);
503
-
504
- // 간단한 마크다운 → HTML 변환
505
- let html = markdown
506
- .replace(/^# (.+)$/gm, "<h1>$1</h1>")
507
- .replace(/^## (.+)$/gm, "<h2>$1</h2>")
508
- .replace(/^### (.+)$/gm, "<h3>$1</h3>")
509
- .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
510
- .replace(/\*(.+?)\*/g, "<em>$1</em>")
511
- .replace(/`(.+?)`/g, "<code>$1</code>")
512
- .replace(/^- (.+)$/gm, "<li>$1</li>")
513
- .replace(/\n\n/g, "</p><p>")
514
- .replace(/\|(.+)\|/g, (match) => {
515
- const cells = match
516
- .split("|")
517
- .filter((c) => c.trim())
518
- .map((c) => `<td>${c.trim()}</td>`)
519
- .join("");
520
- return `<tr>${cells}</tr>`;
521
- });
522
-
523
- return `<!DOCTYPE html>
524
- <html>
525
- <head>
526
- <meta charset="UTF-8">
527
- <title>Mandu Guard Report</title>
528
- <style>
529
- body {
530
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
531
- max-width: 900px;
532
- margin: 0 auto;
533
- padding: 2rem;
534
- background: #f5f5f5;
535
- }
536
- h1 { color: #333; border-bottom: 2px solid #0066cc; }
537
- h2 { color: #0066cc; margin-top: 2rem; }
538
- table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
539
- th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #ddd; }
540
- code { background: #f0f0f0; padding: 0.2rem 0.4rem; border-radius: 3px; }
541
- li { margin: 0.5rem 0; }
542
- .summary { background: white; padding: 1rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
543
- </style>
544
- </head>
545
- <body>
546
- <div class="summary">
547
- ${html}
548
- </div>
549
- </body>
550
- </html>`;
551
- }
552
-
553
- // ═══════════════════════════════════════════════════════════════════════════
554
- // Helpers
555
- // ═══════════════════════════════════════════════════════════════════════════
556
-
557
- function generateId(): string {
558
- return `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
559
- }
560
-
561
- function getTypeTitle(type: ViolationType): string {
562
- switch (type) {
563
- case "layer-violation":
564
- return "Layer Violations";
565
- case "circular-dependency":
566
- return "Circular Dependencies";
567
- case "cross-slice":
568
- return "Cross-Slice Dependencies";
569
- case "deep-nesting":
570
- return "Deep Nesting";
571
- case "file-type":
572
- return "File Type Violations";
573
- case "invalid-shared-segment":
574
- return "Shared Segment Violations";
575
- default:
576
- return "Violations";
577
- }
578
- }
1
+ /**
2
+ * Mandu Guard Statistics
3
+ *
4
+ * 위반 통계 및 트렌드 분석
5
+ */
6
+
7
+ import { writeFile, readFile, mkdir } from "fs/promises";
8
+ import { dirname, join } from "path";
9
+ import type {
10
+ Violation,
11
+ ViolationReport,
12
+ ViolationType,
13
+ Severity,
14
+ GuardPreset,
15
+ } from "./types";
16
+
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+ // Types
19
+ // ═══════════════════════════════════════════════════════════════════════════
20
+
21
+ /**
22
+ * 단일 스캔 기록
23
+ */
24
+ export interface ScanRecord {
25
+ /** 스캔 ID */
26
+ id: string;
27
+ /** 스캔 시간 */
28
+ timestamp: number;
29
+ /** 프리셋 */
30
+ preset?: GuardPreset;
31
+ /** 분석된 파일 수 */
32
+ filesAnalyzed: number;
33
+ /** 총 위반 수 */
34
+ totalViolations: number;
35
+ /** 심각도별 카운트 */
36
+ bySeverity: Record<Severity, number>;
37
+ /** 타입별 카운트 */
38
+ byType: Record<ViolationType, number>;
39
+ /** 레이어별 카운트 */
40
+ byLayer: Record<string, number>;
41
+ /** 가장 많은 위반 파일 */
42
+ hotspots: Array<{ file: string; count: number }>;
43
+ }
44
+
45
+ /**
46
+ * 통계 저장소
47
+ */
48
+ export interface StatisticsStore {
49
+ /** 버전 */
50
+ version: number;
51
+ /** 프로젝트 이름 */
52
+ projectName?: string;
53
+ /** 스캔 기록 */
54
+ records: ScanRecord[];
55
+ /** 마지막 업데이트 */
56
+ lastUpdated: number;
57
+ }
58
+
59
+ /**
60
+ * 트렌드 분석
61
+ */
62
+ export interface TrendAnalysis {
63
+ /** 분석 기간 */
64
+ period: {
65
+ start: number;
66
+ end: number;
67
+ days: number;
68
+ };
69
+ /** 위반 변화량 */
70
+ violationDelta: number;
71
+ /** 위반 변화율 (%) */
72
+ violationChangePercent: number;
73
+ /** 개선/악화 */
74
+ trend: "improving" | "stable" | "degrading";
75
+ /** 레이어별 트렌드 */
76
+ byLayer: Record<string, { delta: number; trend: "improving" | "stable" | "degrading" }>;
77
+ /** 권장 사항 */
78
+ recommendations: string[];
79
+ }
80
+
81
+ /**
82
+ * 레이어별 통계
83
+ */
84
+ export interface LayerStatistics {
85
+ /** 레이어 이름 */
86
+ name: string;
87
+ /** 총 위반 수 */
88
+ totalViolations: number;
89
+ /** 위반 원인 레이어 수 */
90
+ asSource: number;
91
+ /** 위반 대상 레이어 수 */
92
+ asTarget: number;
93
+ /** 가장 많이 위반한 타겟 */
94
+ topTargets: Array<{ layer: string; count: number }>;
95
+ /** 건강도 점수 (0-100) */
96
+ healthScore: number;
97
+ }
98
+
99
+ // ═══════════════════════════════════════════════════════════════════════════
100
+ // Statistics Generation
101
+ // ═══════════════════════════════════════════════════════════════════════════
102
+
103
+ /**
104
+ * 리포트에서 스캔 기록 생성
105
+ */
106
+ export function createScanRecord(
107
+ report: ViolationReport,
108
+ preset?: GuardPreset
109
+ ): ScanRecord {
110
+ // 레이어별 카운트
111
+ const byLayer: Record<string, number> = {};
112
+ for (const v of report.violations) {
113
+ byLayer[v.fromLayer] = (byLayer[v.fromLayer] || 0) + 1;
114
+ }
115
+
116
+ // 핫스팟 (가장 많은 위반 파일)
117
+ const fileCounts: Record<string, number> = {};
118
+ for (const v of report.violations) {
119
+ fileCounts[v.filePath] = (fileCounts[v.filePath] || 0) + 1;
120
+ }
121
+
122
+ const hotspots = Object.entries(fileCounts)
123
+ .sort((a, b) => b[1] - a[1])
124
+ .slice(0, 5)
125
+ .map(([file, count]) => ({ file, count }));
126
+
127
+ return {
128
+ id: generateId(),
129
+ timestamp: Date.now(),
130
+ preset,
131
+ filesAnalyzed: report.filesAnalyzed,
132
+ totalViolations: report.totalViolations,
133
+ bySeverity: report.bySeverity,
134
+ byType: report.byType,
135
+ byLayer,
136
+ hotspots,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * 레이어별 통계 계산
142
+ */
143
+ export function calculateLayerStatistics(
144
+ violations: Violation[],
145
+ layers: string[]
146
+ ): LayerStatistics[] {
147
+ const stats: Map<string, LayerStatistics> = new Map();
148
+
149
+ // 초기화
150
+ for (const layer of layers) {
151
+ stats.set(layer, {
152
+ name: layer,
153
+ totalViolations: 0,
154
+ asSource: 0,
155
+ asTarget: 0,
156
+ topTargets: [],
157
+ healthScore: 100,
158
+ });
159
+ }
160
+
161
+ // 위반 집계
162
+ const targetCounts: Map<string, Map<string, number>> = new Map();
163
+
164
+ for (const v of violations) {
165
+ // Source 카운트
166
+ const sourceStat = stats.get(v.fromLayer);
167
+ if (sourceStat) {
168
+ sourceStat.asSource++;
169
+ sourceStat.totalViolations++;
170
+
171
+ // 타겟 카운트
172
+ if (!targetCounts.has(v.fromLayer)) {
173
+ targetCounts.set(v.fromLayer, new Map());
174
+ }
175
+ const targets = targetCounts.get(v.fromLayer)!;
176
+ targets.set(v.toLayer, (targets.get(v.toLayer) || 0) + 1);
177
+ }
178
+
179
+ // Target 카운트
180
+ const targetStat = stats.get(v.toLayer);
181
+ if (targetStat) {
182
+ targetStat.asTarget++;
183
+ }
184
+ }
185
+
186
+ // 건강도 점수 및 Top targets 계산
187
+ for (const [layer, stat] of stats) {
188
+ // 건강도: 위반이 많을수록 낮음
189
+ const maxViolations = 20; // 20개 이상이면 0점
190
+ stat.healthScore = Math.max(0, Math.round(100 - (stat.asSource / maxViolations) * 100));
191
+
192
+ // Top targets
193
+ const targets = targetCounts.get(layer);
194
+ if (targets) {
195
+ stat.topTargets = Array.from(targets.entries())
196
+ .sort((a, b) => b[1] - a[1])
197
+ .slice(0, 3)
198
+ .map(([layer, count]) => ({ layer, count }));
199
+ }
200
+ }
201
+
202
+ return Array.from(stats.values());
203
+ }
204
+
205
+ /**
206
+ * 트렌드 분석
207
+ */
208
+ export function analyzeTrend(
209
+ records: ScanRecord[],
210
+ days: number = 7
211
+ ): TrendAnalysis | null {
212
+ if (records.length < 2) {
213
+ return null;
214
+ }
215
+
216
+ const now = Date.now();
217
+ const periodStart = now - days * 24 * 60 * 60 * 1000;
218
+
219
+ // 기간 내 기록 필터링
220
+ const periodRecords = records.filter((r) => r.timestamp >= periodStart);
221
+
222
+ if (periodRecords.length < 2) {
223
+ return null;
224
+ }
225
+
226
+ // 가장 오래된 것과 가장 최근 것 비교
227
+ const oldest = periodRecords[0];
228
+ const newest = periodRecords[periodRecords.length - 1];
229
+
230
+ const violationDelta = newest.totalViolations - oldest.totalViolations;
231
+ const violationChangePercent =
232
+ oldest.totalViolations === 0
233
+ ? 0
234
+ : Math.round((violationDelta / oldest.totalViolations) * 100);
235
+
236
+ const trend: TrendAnalysis["trend"] =
237
+ violationDelta < -2
238
+ ? "improving"
239
+ : violationDelta > 2
240
+ ? "degrading"
241
+ : "stable";
242
+
243
+ // 레이어별 트렌드
244
+ const allLayers = new Set([
245
+ ...Object.keys(oldest.byLayer || {}),
246
+ ...Object.keys(newest.byLayer || {}),
247
+ ]);
248
+
249
+ const byLayer: TrendAnalysis["byLayer"] = {};
250
+ for (const layer of allLayers) {
251
+ const oldCount = oldest.byLayer?.[layer] || 0;
252
+ const newCount = newest.byLayer?.[layer] || 0;
253
+ const delta = newCount - oldCount;
254
+
255
+ byLayer[layer] = {
256
+ delta,
257
+ trend: delta < -1 ? "improving" : delta > 1 ? "degrading" : "stable",
258
+ };
259
+ }
260
+
261
+ // 권장 사항 생성
262
+ const recommendations: string[] = [];
263
+
264
+ if (trend === "degrading") {
265
+ recommendations.push("위반이 증가하고 있습니다. 새 코드 리뷰를 강화하세요.");
266
+ }
267
+
268
+ const degradingLayers = Object.entries(byLayer)
269
+ .filter(([_, v]) => v.trend === "degrading")
270
+ .map(([k, _]) => k);
271
+
272
+ if (degradingLayers.length > 0) {
273
+ recommendations.push(`주의 필요 레이어: ${degradingLayers.join(", ")}`);
274
+ }
275
+
276
+ const hotspot = newest.hotspots?.[0];
277
+ if (hotspot && hotspot.count > 5) {
278
+ recommendations.push(`핫스팟: ${hotspot.file} (${hotspot.count}개 위반) - 리팩토링 고려`);
279
+ }
280
+
281
+ if (recommendations.length === 0 && trend === "improving") {
282
+ recommendations.push("잘하고 있습니다! 아키텍처 품질이 개선되고 있습니다.");
283
+ }
284
+
285
+ return {
286
+ period: {
287
+ start: oldest.timestamp,
288
+ end: newest.timestamp,
289
+ days,
290
+ },
291
+ violationDelta,
292
+ violationChangePercent,
293
+ trend,
294
+ byLayer,
295
+ recommendations,
296
+ };
297
+ }
298
+
299
+ // ═══════════════════════════════════════════════════════════════════════════
300
+ // Statistics Storage
301
+ // ═══════════════════════════════════════════════════════════════════════════
302
+
303
+ const STATS_FILE = ".mandu/guard-stats.json";
304
+ const MAX_RECORDS = 100;
305
+
306
+ /**
307
+ * 통계 저장소 로드
308
+ */
309
+ export async function loadStatistics(rootDir: string): Promise<StatisticsStore> {
310
+ const filePath = join(rootDir, STATS_FILE);
311
+
312
+ try {
313
+ const content = await readFile(filePath, "utf-8");
314
+ return JSON.parse(content);
315
+ } catch {
316
+ return {
317
+ version: 1,
318
+ records: [],
319
+ lastUpdated: Date.now(),
320
+ };
321
+ }
322
+ }
323
+
324
+ /**
325
+ * 통계 저장소 저장
326
+ */
327
+ export async function saveStatistics(
328
+ rootDir: string,
329
+ store: StatisticsStore
330
+ ): Promise<void> {
331
+ const filePath = join(rootDir, STATS_FILE);
332
+
333
+ // 디렉토리 생성
334
+ await mkdir(dirname(filePath), { recursive: true });
335
+
336
+ // 레코드 수 제한
337
+ if (store.records.length > MAX_RECORDS) {
338
+ store.records = store.records.slice(-MAX_RECORDS);
339
+ }
340
+
341
+ store.lastUpdated = Date.now();
342
+
343
+ await writeFile(filePath, JSON.stringify(store, null, 2));
344
+ }
345
+
346
+ /**
347
+ * 스캔 기록 추가
348
+ */
349
+ export async function addScanRecord(
350
+ rootDir: string,
351
+ record: ScanRecord
352
+ ): Promise<void> {
353
+ const store = await loadStatistics(rootDir);
354
+ store.records.push(record);
355
+ await saveStatistics(rootDir, store);
356
+ }
357
+
358
+ // ═══════════════════════════════════════════════════════════════════════════
359
+ // Report Generation
360
+ // ═══════════════════════════════════════════════════════════════════════════
361
+
362
+ /**
363
+ * 마크다운 리포트 생성 (Guard Report)
364
+ */
365
+ export function generateGuardMarkdownReport(
366
+ report: ViolationReport,
367
+ trend?: TrendAnalysis | null,
368
+ layerStats?: LayerStatistics[]
369
+ ): string {
370
+ const lines: string[] = [];
371
+
372
+ lines.push("# 🛡️ Mandu Guard Report");
373
+ lines.push("");
374
+ lines.push(`*Generated: ${new Date().toISOString()}*`);
375
+ lines.push("");
376
+
377
+ // 요약
378
+ lines.push("## 📊 Summary");
379
+ lines.push("");
380
+ lines.push(`| Metric | Value |`);
381
+ lines.push(`|--------|-------|`);
382
+ lines.push(`| Files Analyzed | ${report.filesAnalyzed} |`);
383
+ lines.push(`| Total Violations | ${report.totalViolations} |`);
384
+ lines.push(`| Errors | ${report.bySeverity.error} |`);
385
+ lines.push(`| Warnings | ${report.bySeverity.warn} |`);
386
+ lines.push(`| Info | ${report.bySeverity.info} |`);
387
+ lines.push(`| Analysis Time | ${report.analysisTime}ms |`);
388
+ lines.push("");
389
+
390
+ // 트렌드
391
+ if (trend) {
392
+ lines.push("## 📈 Trend Analysis");
393
+ lines.push("");
394
+
395
+ const trendEmoji =
396
+ trend.trend === "improving"
397
+ ? "📉"
398
+ : trend.trend === "degrading"
399
+ ? "📈"
400
+ : "➡️";
401
+
402
+ lines.push(`**Status:** ${trendEmoji} ${trend.trend.toUpperCase()}`);
403
+ lines.push("");
404
+ lines.push(`- Violation change: ${trend.violationDelta >= 0 ? "+" : ""}${trend.violationDelta}`);
405
+ lines.push(`- Change rate: ${trend.violationChangePercent >= 0 ? "+" : ""}${trend.violationChangePercent}%`);
406
+ lines.push(`- Period: ${trend.period.days} days`);
407
+ lines.push("");
408
+
409
+ if (trend.recommendations.length > 0) {
410
+ lines.push("### 💡 Recommendations");
411
+ lines.push("");
412
+ for (const rec of trend.recommendations) {
413
+ lines.push(`- ${rec}`);
414
+ }
415
+ lines.push("");
416
+ }
417
+ }
418
+
419
+ // 레이어 통계
420
+ if (layerStats && layerStats.length > 0) {
421
+ lines.push("## 🏗️ Layer Health");
422
+ lines.push("");
423
+ lines.push(`| Layer | Health | Violations | Top Target |`);
424
+ lines.push(`|-------|--------|------------|------------|`);
425
+
426
+ for (const stat of layerStats) {
427
+ const healthEmoji =
428
+ stat.healthScore >= 80
429
+ ? "🟢"
430
+ : stat.healthScore >= 50
431
+ ? "🟡"
432
+ : "🔴";
433
+
434
+ const topTarget = stat.topTargets[0];
435
+ const topTargetStr = topTarget ? `${topTarget.layer} (${topTarget.count})` : "-";
436
+
437
+ lines.push(
438
+ `| ${stat.name} | ${healthEmoji} ${stat.healthScore}% | ${stat.asSource} | ${topTargetStr} |`
439
+ );
440
+ }
441
+ lines.push("");
442
+ }
443
+
444
+ // 위반 상세
445
+ if (report.violations.length > 0) {
446
+ lines.push("## ❌ Violations");
447
+ lines.push("");
448
+
449
+ // 타입별 그룹화
450
+ const byType = new Map<ViolationType, Violation[]>();
451
+ for (const v of report.violations) {
452
+ if (!byType.has(v.type)) {
453
+ byType.set(v.type, []);
454
+ }
455
+ byType.get(v.type)!.push(v);
456
+ }
457
+
458
+ for (const [type, violations] of byType) {
459
+ lines.push(`### ${getTypeTitle(type)} (${violations.length})`);
460
+ lines.push("");
461
+
462
+ for (const v of violations.slice(0, 10)) {
463
+ lines.push(`- **${v.filePath}:${v.line}**`);
464
+ lines.push(` - \`${v.fromLayer}\` → \`${v.toLayer}\``);
465
+ lines.push(` - ${v.importStatement}`);
466
+ if (v.suggestions.length > 0) {
467
+ lines.push(` - 💡 ${v.suggestions[0]}`);
468
+ }
469
+ lines.push("");
470
+ }
471
+
472
+ if (violations.length > 10) {
473
+ lines.push(`*... and ${violations.length - 10} more*`);
474
+ lines.push("");
475
+ }
476
+ }
477
+ }
478
+
479
+ // 결론
480
+ lines.push("---");
481
+ lines.push("");
482
+
483
+ if (report.totalViolations === 0) {
484
+ lines.push("✅ **All clear!** No architecture violations detected.");
485
+ } else if (report.bySeverity.error > 0) {
486
+ lines.push(`❌ **Action required:** ${report.bySeverity.error} error(s) must be fixed.`);
487
+ } else {
488
+ lines.push(`⚠️ **Review needed:** ${report.totalViolations} issue(s) found.`);
489
+ }
490
+
491
+ return lines.join("\n");
492
+ }
493
+
494
+ /**
495
+ * HTML 리포트 생성
496
+ */
497
+ export function generateHTMLReport(
498
+ report: ViolationReport,
499
+ trend?: TrendAnalysis | null,
500
+ layerStats?: LayerStatistics[]
501
+ ): string {
502
+ const markdown = generateGuardMarkdownReport(report, trend, layerStats);
503
+
504
+ // 간단한 마크다운 → HTML 변환
505
+ let html = markdown
506
+ .replace(/^# (.+)$/gm, "<h1>$1</h1>")
507
+ .replace(/^## (.+)$/gm, "<h2>$1</h2>")
508
+ .replace(/^### (.+)$/gm, "<h3>$1</h3>")
509
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
510
+ .replace(/\*(.+?)\*/g, "<em>$1</em>")
511
+ .replace(/`(.+?)`/g, "<code>$1</code>")
512
+ .replace(/^- (.+)$/gm, "<li>$1</li>")
513
+ .replace(/\n\n/g, "</p><p>")
514
+ .replace(/\|(.+)\|/g, (match) => {
515
+ const cells = match
516
+ .split("|")
517
+ .filter((c) => c.trim())
518
+ .map((c) => `<td>${c.trim()}</td>`)
519
+ .join("");
520
+ return `<tr>${cells}</tr>`;
521
+ });
522
+
523
+ return `<!DOCTYPE html>
524
+ <html>
525
+ <head>
526
+ <meta charset="UTF-8">
527
+ <title>Mandu Guard Report</title>
528
+ <style>
529
+ body {
530
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
531
+ max-width: 900px;
532
+ margin: 0 auto;
533
+ padding: 2rem;
534
+ background: #f5f5f5;
535
+ }
536
+ h1 { color: #333; border-bottom: 2px solid #0066cc; }
537
+ h2 { color: #0066cc; margin-top: 2rem; }
538
+ table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
539
+ th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid #ddd; }
540
+ code { background: #f0f0f0; padding: 0.2rem 0.4rem; border-radius: 3px; }
541
+ li { margin: 0.5rem 0; }
542
+ .summary { background: white; padding: 1rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
543
+ </style>
544
+ </head>
545
+ <body>
546
+ <div class="summary">
547
+ ${html}
548
+ </div>
549
+ </body>
550
+ </html>`;
551
+ }
552
+
553
+ // ═══════════════════════════════════════════════════════════════════════════
554
+ // Helpers
555
+ // ═══════════════════════════════════════════════════════════════════════════
556
+
557
+ function generateId(): string {
558
+ return `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
559
+ }
560
+
561
+ function getTypeTitle(type: ViolationType): string {
562
+ switch (type) {
563
+ case "layer-violation":
564
+ return "Layer Violations";
565
+ case "circular-dependency":
566
+ return "Circular Dependencies";
567
+ case "cross-slice":
568
+ return "Cross-Slice Dependencies";
569
+ case "deep-nesting":
570
+ return "Deep Nesting";
571
+ case "file-type":
572
+ return "File Type Violations";
573
+ case "invalid-shared-segment":
574
+ return "Shared Segment Violations";
575
+ default:
576
+ return "Violations";
577
+ }
578
+ }