@mandujs/cli 0.9.12 → 0.9.18

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.
@@ -1,28 +1,44 @@
1
1
  import {
2
- loadManifest,
3
2
  startServer,
4
3
  registerApiHandler,
5
4
  registerPageLoader,
6
5
  registerPageHandler,
6
+ registerLayoutLoader,
7
7
  startDevBundler,
8
8
  createHMRServer,
9
9
  needsHydration,
10
10
  loadEnv,
11
+ generateManifest,
12
+ watchFSRoutes,
13
+ clearDefaultRegistry,
14
+ createGuardWatcher,
15
+ getPreset,
16
+ type RoutesManifest,
17
+ type GuardConfig,
18
+ type GuardPreset,
11
19
  } from "@mandujs/core";
12
- import { resolveFromCwd } from "../util/fs";
20
+ import { isDirectory, resolveFromCwd } from "../util/fs";
21
+ import { resolveOutputFormat, type OutputFormat } from "../util/output";
13
22
  import path from "path";
14
23
 
15
- export interface DevOptions {
16
- port?: number;
17
- /** HMR 비활성화 */
18
- noHmr?: boolean;
19
- }
24
+ export interface DevOptions {
25
+ port?: number;
26
+ /** HMR 비활성화 */
27
+ noHmr?: boolean;
28
+ /** FS Routes 비활성화 (레거시 모드) */
29
+ legacy?: boolean;
30
+ /** Architecture Guard 활성화 */
31
+ guard?: boolean;
32
+ /** Guard 프리셋 */
33
+ guardPreset?: GuardPreset;
34
+ /** Guard 출력 형식 */
35
+ guardFormat?: OutputFormat;
36
+ }
20
37
 
21
38
  export async function dev(options: DevOptions = {}): Promise<void> {
22
- const specPath = resolveFromCwd("spec/routes.manifest.json");
23
39
  const rootDir = resolveFromCwd(".");
24
40
 
25
- console.log(`🥟 Mandu Dev Server`);
41
+ console.log(`🥟 Mandu Dev Server (FS Routes)`);
26
42
 
27
43
  // .env 파일 로드
28
44
  const envResult = await loadEnv({
@@ -34,50 +50,93 @@ export async function dev(options: DevOptions = {}): Promise<void> {
34
50
  console.log(`🔐 환경 변수 로드: ${envResult.loaded.join(", ")}`);
35
51
  }
36
52
 
37
- console.log(`📄 Spec 파일: ${specPath}\n`);
53
+ // FS Routes 스캔
54
+ console.log(`📂 app/ 폴더 스캔 중...`);
38
55
 
39
- const result = await loadManifest(specPath);
56
+ const result = await generateManifest(rootDir, {
57
+ skipLegacy: true,
58
+ });
40
59
 
41
- if (!result.success || !result.data) {
42
- console.error("❌ Spec 로드 실패:");
43
- result.errors?.forEach((e) => console.error(` - ${e}`));
60
+ if (result.manifest.routes.length === 0) {
61
+ console.log("");
62
+ console.log("📭 라우트가 없습니다.");
63
+ console.log("");
64
+ console.log("💡 app/ 폴더에 page.tsx 파일을 생성하세요:");
65
+ console.log("");
66
+ console.log(" app/page.tsx → /");
67
+ console.log(" app/blog/page.tsx → /blog");
68
+ console.log(" app/api/users/route.ts → /api/users");
69
+ console.log("");
44
70
  process.exit(1);
45
71
  }
46
72
 
47
- const manifest = result.data;
48
- console.log(`✅ Spec 로드 완료: ${manifest.routes.length}개 라우트`);
49
-
50
- // 핸들러 등록
51
- for (const route of manifest.routes) {
52
- if (route.kind === "api") {
53
- const modulePath = path.resolve(rootDir, route.module);
54
- try {
55
- const module = await import(modulePath);
56
- registerApiHandler(route.id, module.default || module.handler);
57
- console.log(` 📡 API: ${route.pattern} -> ${route.id}`);
58
- } catch (error) {
59
- console.error(` ❌ API 핸들러 로드 실패: ${route.id}`, error);
60
- }
61
- } else if (route.kind === "page" && route.componentModule) {
62
- const componentPath = path.resolve(rootDir, route.componentModule);
63
- const isIsland = needsHydration(route);
64
-
65
- // slotModule이 있으면 PageHandler 사용 (filling.loader 지원)
66
- if (route.slotModule) {
67
- registerPageHandler(route.id, async () => {
68
- const module = await import(componentPath);
69
- // module.default = { component, filling }
70
- return module.default;
71
- });
72
- console.log(` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}`);
73
- } else {
74
- // slotModule이 없으면 기존 PageLoader 사용
75
- registerPageLoader(route.id, () => import(componentPath));
76
- console.log(` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}`);
73
+ let manifest = result.manifest;
74
+ console.log(`✅ ${manifest.routes.length}개 라우트 발견\n`);
75
+
76
+ // Layout 경로 추적 (중복 등록 방지)
77
+ const registeredLayouts = new Set<string>();
78
+
79
+ // 핸들러 등록 함수
80
+ const registerHandlers = async (manifest: RoutesManifest, isReload = false) => {
81
+ // 리로드 레이아웃 캐시 클리어
82
+ if (isReload) {
83
+ registeredLayouts.clear();
84
+ }
85
+
86
+ for (const route of manifest.routes) {
87
+ if (route.kind === "api") {
88
+ const modulePath = path.resolve(rootDir, route.module);
89
+ try {
90
+ // 캐시 무효화 (HMR용)
91
+ delete require.cache[modulePath];
92
+ const module = await import(modulePath);
93
+ registerApiHandler(route.id, module.default || module.handler || module);
94
+ console.log(` 📡 API: ${route.pattern} -> ${route.id}`);
95
+ } catch (error) {
96
+ console.error(` ❌ API 핸들러 로드 실패: ${route.id}`, error);
97
+ }
98
+ } else if (route.kind === "page" && route.componentModule) {
99
+ const componentPath = path.resolve(rootDir, route.componentModule);
100
+ const isIsland = needsHydration(route);
101
+ const hasLayout = route.layoutChain && route.layoutChain.length > 0;
102
+
103
+ // Layout 로더 등록
104
+ if (route.layoutChain) {
105
+ for (const layoutPath of route.layoutChain) {
106
+ if (!registeredLayouts.has(layoutPath)) {
107
+ const absLayoutPath = path.resolve(rootDir, layoutPath);
108
+ registerLayoutLoader(layoutPath, async () => {
109
+ // 캐시 무효화 (HMR용)
110
+ delete require.cache[absLayoutPath];
111
+ return import(absLayoutPath);
112
+ });
113
+ registeredLayouts.add(layoutPath);
114
+ console.log(` 🎨 Layout: ${layoutPath}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ // slotModule이 있으면 PageHandler 사용 (filling.loader 지원)
120
+ if (route.slotModule) {
121
+ registerPageHandler(route.id, async () => {
122
+ delete require.cache[componentPath];
123
+ const module = await import(componentPath);
124
+ return module.default;
125
+ });
126
+ console.log(` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`);
127
+ } else {
128
+ registerPageLoader(route.id, () => {
129
+ delete require.cache[componentPath];
130
+ return import(componentPath);
131
+ });
132
+ console.log(` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`);
133
+ }
77
134
  }
78
135
  }
79
- }
136
+ };
80
137
 
138
+ // 초기 핸들러 등록
139
+ await registerHandlers(manifest);
81
140
  console.log("");
82
141
 
83
142
  const port = options.port || Number(process.env.PORT) || 3000;
@@ -98,28 +157,28 @@ export async function dev(options: DevOptions = {}): Promise<void> {
98
157
  devBundler = await startDevBundler({
99
158
  rootDir,
100
159
  manifest,
101
- onRebuild: (result) => {
102
- if (result.success) {
103
- if (result.routeId === "*") {
104
- hmrServer?.broadcast({
105
- type: "reload",
106
- data: {
107
- timestamp: Date.now(),
108
- },
109
- });
110
- } else {
111
- hmrServer?.broadcast({
112
- type: "island-update",
113
- data: {
114
- routeId: result.routeId,
115
- timestamp: Date.now(),
116
- },
117
- });
118
- }
119
- } else {
120
- hmrServer?.broadcast({
121
- type: "error",
122
- data: {
160
+ onRebuild: (result) => {
161
+ if (result.success) {
162
+ if (result.routeId === "*") {
163
+ hmrServer?.broadcast({
164
+ type: "reload",
165
+ data: {
166
+ timestamp: Date.now(),
167
+ },
168
+ });
169
+ } else {
170
+ hmrServer?.broadcast({
171
+ type: "island-update",
172
+ data: {
173
+ routeId: result.routeId,
174
+ timestamp: Date.now(),
175
+ },
176
+ });
177
+ }
178
+ } else {
179
+ hmrServer?.broadcast({
180
+ type: "error",
181
+ data: {
123
182
  routeId: result.routeId,
124
183
  message: result.error,
125
184
  },
@@ -147,12 +206,90 @@ export async function dev(options: DevOptions = {}): Promise<void> {
147
206
  bundleManifest: devBundler?.initialBuild.manifest,
148
207
  });
149
208
 
209
+ // Architecture Guard 실시간 감시 (선택적)
210
+ let archGuardWatcher: ReturnType<typeof createGuardWatcher> | null = null;
211
+
212
+ if (options.guard !== false) {
213
+ const guardPreset = options.guardPreset || "mandu";
214
+ const guardFormat = resolveOutputFormat(options.guardFormat);
215
+ const enableFsRoutes = !options.legacy && await isDirectory(path.resolve(rootDir, "app"));
216
+ const guardConfig: GuardConfig = {
217
+ preset: guardPreset,
218
+ srcDir: "src",
219
+ realtime: true,
220
+ realtimeOutput: guardFormat,
221
+ fsRoutes: enableFsRoutes
222
+ ? {
223
+ noPageToPage: true,
224
+ pageCanImport: ["widgets", "features", "entities", "shared"],
225
+ layoutCanImport: ["widgets", "shared"],
226
+ }
227
+ : undefined,
228
+ };
229
+
230
+ console.log(`🛡️ Architecture Guard 활성화 (${guardPreset})`);
231
+
232
+ archGuardWatcher = createGuardWatcher({
233
+ config: guardConfig,
234
+ rootDir,
235
+ onViolation: (violation) => {
236
+ // 실시간 경고는 watcher 내부에서 처리
237
+ },
238
+ onFileAnalyzed: (analysis, violations) => {
239
+ if (violations.length > 0) {
240
+ // HMR 에러로 브로드캐스트
241
+ hmrServer?.broadcast({
242
+ type: "guard-violation",
243
+ data: {
244
+ file: analysis.filePath,
245
+ violations: violations.map((v) => ({
246
+ line: v.line,
247
+ message: `${v.fromLayer} → ${v.toLayer}: ${v.ruleDescription}`,
248
+ })),
249
+ },
250
+ });
251
+ }
252
+ },
253
+ });
254
+
255
+ archGuardWatcher.start();
256
+ }
257
+
258
+ // FS Routes 실시간 감시
259
+ const routesWatcher = await watchFSRoutes(rootDir, {
260
+ skipLegacy: true,
261
+ onChange: async (result) => {
262
+ const timestamp = new Date().toLocaleTimeString();
263
+ console.log(`\n🔄 [${timestamp}] 라우트 변경 감지`);
264
+
265
+ // 레지스트리 클리어 (layout 캐시 포함)
266
+ clearDefaultRegistry();
267
+
268
+ // 새 매니페스트로 서버 업데이트
269
+ manifest = result.manifest;
270
+ console.log(` 📋 라우트: ${manifest.routes.length}개`);
271
+
272
+ // 라우트 재등록 (isReload = true)
273
+ await registerHandlers(manifest, true);
274
+
275
+ // HMR 브로드캐스트 (전체 리로드)
276
+ if (hmrServer) {
277
+ hmrServer.broadcast({
278
+ type: "reload",
279
+ data: { timestamp: Date.now() },
280
+ });
281
+ }
282
+ },
283
+ });
284
+
150
285
  // 정리 함수
151
286
  const cleanup = () => {
152
287
  console.log("\n🛑 서버 종료 중...");
153
288
  server.stop();
154
289
  devBundler?.close();
155
290
  hmrServer?.close();
291
+ routesWatcher.close();
292
+ archGuardWatcher?.close();
156
293
  process.exit(0);
157
294
  };
158
295
 
@@ -10,7 +10,7 @@ import {
10
10
  runGuardCheck,
11
11
  analyzeViolations,
12
12
  printDoctorReport,
13
- generateMarkdownReport,
13
+ generateDoctorMarkdownReport,
14
14
  initializeBrain,
15
15
  getBrain,
16
16
  } from "../../../core/src/index";
@@ -108,7 +108,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<boolean> {
108
108
  }
109
109
 
110
110
  case "markdown": {
111
- const md = generateMarkdownReport(analysis);
111
+ const md = generateDoctorMarkdownReport(analysis);
112
112
 
113
113
  if (output) {
114
114
  await fs.writeFile(output, md, "utf-8");
@@ -0,0 +1,256 @@
1
+ /**
2
+ * mandu guard arch - Architecture Guard Command
3
+ *
4
+ * 실시간 아키텍처 감시 및 일회성 검사
5
+ */
6
+
7
+ import {
8
+ createGuardWatcher,
9
+ checkDirectory,
10
+ printReport,
11
+ formatReportForAgent,
12
+ formatReportAsAgentJSON,
13
+ getPreset,
14
+ listPresets,
15
+ createScanRecord,
16
+ addScanRecord,
17
+ loadStatistics,
18
+ analyzeTrend,
19
+ calculateLayerStatistics,
20
+ generateGuardMarkdownReport,
21
+ generateHTMLReport,
22
+ type GuardConfig,
23
+ type GuardPreset,
24
+ } from "@mandujs/core";
25
+ import { writeFile } from "fs/promises";
26
+ import { isDirectory, resolveFromCwd } from "../util/fs";
27
+ import { resolveOutputFormat, type OutputFormat } from "../util/output";
28
+ import path from "path";
29
+
30
+ export interface GuardArchOptions {
31
+ /** 프리셋 이름 */
32
+ preset?: GuardPreset;
33
+ /** 실시간 감시 모드 */
34
+ watch?: boolean;
35
+ /** CI 모드 (에러 시 exit 1) */
36
+ ci?: boolean;
37
+ /** 출력 형식: console, agent, json */
38
+ format?: OutputFormat;
39
+ /** 조용히 (요약만 출력) */
40
+ quiet?: boolean;
41
+ /** 소스 디렉토리 */
42
+ srcDir?: string;
43
+ /** 프리셋 목록 출력 */
44
+ listPresets?: boolean;
45
+ /** 리포트 파일 출력 */
46
+ output?: string;
47
+ /** 리포트 형식: json, markdown, html */
48
+ reportFormat?: "json" | "markdown" | "html";
49
+ /** 통계 저장 (트렌드 분석용) */
50
+ saveStats?: boolean;
51
+ /** 트렌드 분석 표시 */
52
+ showTrend?: boolean;
53
+ }
54
+
55
+ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean> {
56
+ const {
57
+ preset = "mandu",
58
+ watch = false,
59
+ ci = false,
60
+ format,
61
+ quiet = false,
62
+ srcDir = "src",
63
+ listPresets: showPresets = false,
64
+ output,
65
+ reportFormat = "markdown",
66
+ saveStats = false,
67
+ showTrend = false,
68
+ } = options;
69
+
70
+ const rootDir = resolveFromCwd(".");
71
+ const resolvedFormat = resolveOutputFormat(format);
72
+ const enableFsRoutes = await isDirectory(path.resolve(rootDir, "app"));
73
+
74
+ // 프리셋 목록 출력
75
+ if (showPresets) {
76
+ console.log("");
77
+ console.log("🛡️ Mandu Guard - Available Presets");
78
+ console.log("");
79
+
80
+ const presets = listPresets();
81
+ for (const p of presets) {
82
+ const presetDef = getPreset(p.name);
83
+ console.log(` ${p.name === "fsd" ? "✨ " : " "}${p.name}`);
84
+ console.log(` ${p.description}`);
85
+ console.log(` Layers: ${presetDef.hierarchy.join(" → ")}`);
86
+ console.log("");
87
+ }
88
+
89
+ console.log("Usage: bunx mandu guard arch --preset <name>");
90
+ return true;
91
+ }
92
+
93
+ if (resolvedFormat === "console") {
94
+ console.log("");
95
+ console.log("🛡️ Mandu Guard - Architecture Checker");
96
+ console.log("");
97
+ console.log(`📋 Preset: ${preset}`);
98
+ console.log(`📂 Source: ${srcDir}/`);
99
+ console.log(`🔧 Mode: ${watch ? "Watch" : "Check"}`);
100
+ console.log("");
101
+ }
102
+
103
+ // Guard 설정
104
+ const config: GuardConfig = {
105
+ preset,
106
+ srcDir,
107
+ realtime: watch,
108
+ realtimeOutput: resolvedFormat,
109
+ fsRoutes: enableFsRoutes
110
+ ? {
111
+ noPageToPage: true,
112
+ pageCanImport: ["widgets", "features", "entities", "shared"],
113
+ layoutCanImport: ["widgets", "shared"],
114
+ }
115
+ : undefined,
116
+ };
117
+
118
+ // 실시간 감시 모드
119
+ if (watch) {
120
+ if (resolvedFormat === "console") {
121
+ console.log("👁️ Watching for architecture violations...");
122
+ console.log(" Press Ctrl+C to stop\n");
123
+ }
124
+
125
+ const watcher = createGuardWatcher({
126
+ config,
127
+ rootDir,
128
+ onViolation: (violation) => {
129
+ // 실시간 위반 출력은 watcher 내부에서 처리됨
130
+ },
131
+ onFileAnalyzed: (analysis, violations) => {
132
+ if (resolvedFormat === "console" && violations.length > 0 && !quiet) {
133
+ const timestamp = new Date().toLocaleTimeString();
134
+ console.log(`[${timestamp}] ${analysis.filePath}: ${violations.length} violation(s)`);
135
+ }
136
+ },
137
+ });
138
+
139
+ watcher.start();
140
+
141
+ // Ctrl+C 핸들링
142
+ process.on("SIGINT", () => {
143
+ console.log("\n🛑 Guard stopped");
144
+ watcher.close();
145
+ process.exit(0);
146
+ });
147
+
148
+ // 계속 실행
149
+ return new Promise(() => {});
150
+ }
151
+
152
+ // 일회성 검사 모드
153
+ if (resolvedFormat === "console" && !quiet) {
154
+ console.log("🔍 Scanning for architecture violations...\n");
155
+ }
156
+
157
+ const report = await checkDirectory(config, rootDir);
158
+ const presetDef = getPreset(preset);
159
+
160
+ // 출력 형식에 따른 리포트 출력
161
+ switch (resolvedFormat) {
162
+ case "json":
163
+ console.log(formatReportAsAgentJSON(report, preset));
164
+ break;
165
+
166
+ case "agent":
167
+ console.log(formatReportForAgent(report, preset));
168
+ break;
169
+
170
+ case "console":
171
+ default:
172
+ if (quiet) {
173
+ // 요약만 출력
174
+ console.log(`Files analyzed: ${report.filesAnalyzed}`);
175
+ console.log(`Violations: ${report.totalViolations}`);
176
+ console.log(` Errors: ${report.bySeverity.error}`);
177
+ console.log(` Warnings: ${report.bySeverity.warn}`);
178
+ console.log(` Info: ${report.bySeverity.info}`);
179
+ } else {
180
+ printReport(report, presetDef.hierarchy);
181
+ }
182
+ break;
183
+ }
184
+
185
+ // 통계 저장
186
+ if (saveStats) {
187
+ const scanRecord = createScanRecord(report, preset);
188
+ await addScanRecord(rootDir, scanRecord);
189
+ console.log("📊 Statistics saved to .mandu/guard-stats.json");
190
+ }
191
+
192
+ // 트렌드 분석
193
+ let trend = null;
194
+ let layerStats = null;
195
+
196
+ if (showTrend) {
197
+ const store = await loadStatistics(rootDir);
198
+ trend = analyzeTrend(store.records, 7);
199
+ layerStats = calculateLayerStatistics(report.violations, presetDef.hierarchy);
200
+
201
+ if (trend) {
202
+ console.log("");
203
+ console.log("📈 Trend Analysis (7 days):");
204
+ const trendEmoji = trend.trend === "improving" ? "📉" : trend.trend === "degrading" ? "📈" : "➡️";
205
+ console.log(` Status: ${trendEmoji} ${trend.trend.toUpperCase()}`);
206
+ console.log(` Change: ${trend.violationDelta >= 0 ? "+" : ""}${trend.violationDelta} (${trend.violationChangePercent >= 0 ? "+" : ""}${trend.violationChangePercent}%)`);
207
+
208
+ if (trend.recommendations.length > 0) {
209
+ console.log(" 💡 Recommendations:");
210
+ for (const rec of trend.recommendations) {
211
+ console.log(` - ${rec}`);
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // 리포트 파일 출력
218
+ if (output) {
219
+ let reportContent: string;
220
+
221
+ switch (reportFormat) {
222
+ case "json":
223
+ reportContent = formatReportAsAgentJSON(report, preset);
224
+ break;
225
+ case "html":
226
+ reportContent = generateHTMLReport(report, trend, layerStats ?? undefined);
227
+ break;
228
+ case "markdown":
229
+ default:
230
+ reportContent = generateGuardMarkdownReport(report, trend, layerStats ?? undefined);
231
+ break;
232
+ }
233
+
234
+ await writeFile(output, reportContent);
235
+ console.log(`\n📄 Report saved to ${output}`);
236
+ }
237
+
238
+ // CI 모드에서 에러가 있으면 실패
239
+ if (ci && report.bySeverity.error > 0) {
240
+ console.log("\n❌ Architecture check failed");
241
+ return false;
242
+ }
243
+
244
+ if (report.totalViolations === 0) {
245
+ console.log("\n✅ Architecture check passed");
246
+ return true;
247
+ }
248
+
249
+ if (report.bySeverity.error > 0) {
250
+ console.log(`\n⚠️ ${report.bySeverity.error} error(s) found - please fix before continuing`);
251
+ return !ci;
252
+ }
253
+
254
+ console.log(`\n⚠️ ${report.totalViolations} issue(s) found`);
255
+ return true;
256
+ }