@mandujs/core 0.13.0 → 0.13.1
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.
- package/README.ko.md +4 -4
- package/README.md +653 -653
- package/package.json +1 -1
- package/src/bundler/build.ts +91 -91
- package/src/bundler/css.ts +302 -302
- package/src/client/Link.tsx +227 -227
- package/src/client/globals.ts +44 -44
- package/src/client/hooks.ts +267 -267
- package/src/client/index.ts +5 -5
- package/src/client/island.ts +8 -8
- package/src/client/router.ts +435 -435
- package/src/client/runtime.ts +23 -23
- package/src/client/serialize.ts +404 -404
- package/src/client/window-state.ts +101 -101
- package/src/config/mandu.ts +9 -0
- package/src/config/validate.ts +12 -0
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/content-layer.ts +314 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/data-store.ts +245 -245
- package/src/content/digest.ts +133 -133
- package/src/content/index.ts +164 -164
- package/src/content/loader-context.ts +172 -172
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +169 -169
- package/src/content/loaders/glob.ts +252 -252
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/client.ts +16 -16
- package/src/contract/define.ts +459 -459
- package/src/contract/handler.ts +10 -10
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +404 -404
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/registry.ts +568 -568
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/contract/validator.ts +32 -32
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +465 -465
- package/src/devtools/client/catchers/error-catcher.ts +327 -327
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/catchers/network-proxy.ts +363 -363
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/kitchen-root.tsx +362 -362
- package/src/devtools/client/components/mandu-character.tsx +241 -241
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
- package/src/devtools/client/components/panel/index.ts +32 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
- package/src/devtools/client/components/panel/network-panel.tsx +292 -292
- package/src/devtools/client/components/panel/panel-container.tsx +259 -259
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/client/state-manager.ts +478 -478
- package/src/devtools/design-tokens.ts +263 -263
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +266 -266
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +444 -444
- package/src/devtools/types.ts +319 -319
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +222 -222
- package/src/devtools/worker/worker-manager.ts +409 -409
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +46 -46
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +24 -1
- package/src/filling/deps.ts +238 -238
- package/src/filling/index.ts +2 -0
- package/src/filling/sse.test.ts +168 -0
- package/src/filling/sse.ts +162 -0
- package/src/generator/index.ts +3 -3
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/presets/index.ts +291 -291
- package/src/guard/reporter.ts +445 -445
- package/src/guard/rules.ts +12 -12
- package/src/guard/statistics.ts +578 -578
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/types.ts +348 -348
- package/src/guard/validator.ts +834 -834
- package/src/guard/watcher.ts +404 -404
- package/src/index.ts +6 -1
- package/src/intent/index.ts +310 -310
- package/src/island/index.ts +304 -304
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/plugins/index.ts +38 -38
- package/src/plugins/registry.ts +377 -377
- package/src/plugins/types.ts +363 -363
- package/src/report/index.ts +1 -1
- package/src/router/fs-patterns.ts +387 -387
- package/src/router/fs-scanner.ts +497 -497
- package/src/runtime/boundary.tsx +232 -232
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/escape.ts +44 -0
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/logger.ts +677 -677
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/router.ts +105 -105
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +257 -0
- package/src/runtime/session-key.ts +328 -328
- package/src/runtime/ssr.ts +16 -21
- package/src/runtime/streaming-ssr.ts +24 -33
- package/src/runtime/trace.ts +144 -144
- package/src/seo/index.ts +214 -214
- package/src/seo/integration/ssr.ts +307 -307
- package/src/seo/render/basic.ts +427 -427
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +191 -191
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +126 -126
- package/src/seo/resolve/index.ts +353 -353
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +97 -97
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +575 -575
- package/src/slot/validator.ts +39 -39
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
- package/src/utils/bun.ts +8 -8
- package/src/utils/lru-cache.ts +75 -75
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
package/src/guard/statistics.ts
CHANGED
|
@@ -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
|
+
}
|