@mandujs/core 0.9.30 → 0.9.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.9.30",
3
+ "version": "0.9.37",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,6 +49,8 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "chokidar": "^5.0.0",
52
+ "glob": "^13.0.0",
53
+ "minimatch": "^10.1.1",
52
54
  "ollama": "^0.6.3"
53
55
  }
54
56
  }
@@ -173,9 +173,9 @@ export class ArchitectureAnalyzer {
173
173
  /**
174
174
  * 파일 위치 검증
175
175
  */
176
- async checkLocation(request: CheckLocationRequest): Promise<CheckLocationResult> {
177
- const violations: ArchitectureViolation[] = [];
178
- const normalizedPath = request.path.replace(/\\/g, "/");
176
+ async checkLocation(request: CheckLocationRequest): Promise<CheckLocationResult> {
177
+ const violations: ArchitectureViolation[] = [];
178
+ const normalizedPath = this.toRelativePath(request.path);
179
179
 
180
180
  // 1. readonly 폴더 검사
181
181
  for (const [key, rule] of Object.entries(this.config.folders || {})) {
@@ -288,14 +288,14 @@ export class ArchitectureAnalyzer {
288
288
  /**
289
289
  * Import 검증
290
290
  */
291
- async checkImports(request: CheckImportRequest): Promise<CheckImportResult> {
291
+ async checkImports(request: CheckImportRequest): Promise<CheckImportResult> {
292
292
  const violations: Array<{
293
293
  import: string;
294
294
  reason: string;
295
295
  suggestion?: string;
296
296
  }> = [];
297
297
 
298
- const normalizedSource = request.sourceFile.replace(/\\/g, "/");
298
+ const normalizedSource = this.toRelativePath(request.sourceFile);
299
299
 
300
300
  for (const importPath of request.imports) {
301
301
  for (const rule of this.config.imports || []) {
@@ -368,7 +368,7 @@ export class ArchitectureAnalyzer {
368
368
  /**
369
369
  * 코드에서 import 문 추출
370
370
  */
371
- private extractImports(content: string): string[] {
371
+ private extractImports(content: string): string[] {
372
372
  const imports: string[] = [];
373
373
 
374
374
  // ES6 import
@@ -385,7 +385,15 @@ export class ArchitectureAnalyzer {
385
385
  }
386
386
 
387
387
  return imports;
388
- }
388
+ }
389
+
390
+ private toRelativePath(filePath: string): string {
391
+ const normalized = filePath.replace(/\\/g, "/");
392
+ if (path.isAbsolute(normalized)) {
393
+ return path.relative(this.rootDir, normalized).replace(/\\/g, "/");
394
+ }
395
+ return normalized;
396
+ }
389
397
 
390
398
  /**
391
399
  * 폴더 스캔
@@ -21,7 +21,7 @@ export interface DevBundlerOptions {
21
21
  /**
22
22
  * 추가 watch 디렉토리 (공통 컴포넌트 등)
23
23
  * 상대 경로 또는 절대 경로 모두 지원
24
- * 기본값: ["src/components", "components", "src/shared", "shared", "lib"]
24
+ * 기본값: ["src/components", "components", "src/shared", "shared", "src/lib", "lib", "src/hooks", "hooks", "src/utils", "utils"]
25
25
  */
26
26
  watchDirs?: string[];
27
27
  /**
@@ -117,15 +117,21 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
117
117
  ? customWatchDirs
118
118
  : [...DEFAULT_COMMON_DIRS, ...customWatchDirs];
119
119
 
120
- for (const dir of commonDirsToCheck) {
121
- const absDir = path.isAbsolute(dir) ? dir : path.join(rootDir, dir);
120
+ const addCommonDir = async (dir: string): Promise<void> => {
121
+ const absPath = path.isAbsolute(dir) ? dir : path.join(rootDir, dir);
122
122
  try {
123
- await fs.promises.access(absDir);
124
- commonWatchDirs.add(absDir);
125
- watchDirs.add(absDir);
123
+ const stat = await fs.promises.stat(absPath);
124
+ const watchPath = stat.isDirectory() ? absPath : path.dirname(absPath);
125
+ await fs.promises.access(watchPath);
126
+ commonWatchDirs.add(watchPath);
127
+ watchDirs.add(watchPath);
126
128
  } catch {
127
129
  // 디렉토리 없으면 무시
128
130
  }
131
+ };
132
+
133
+ for (const dir of commonDirsToCheck) {
134
+ await addCommonDir(dir);
129
135
  }
130
136
 
131
137
  // 파일 감시 설정
@@ -306,9 +312,10 @@ export interface HMRServer {
306
312
  }
307
313
 
308
314
  export interface HMRMessage {
309
- type: "connected" | "reload" | "island-update" | "error" | "ping";
315
+ type: "connected" | "reload" | "island-update" | "layout-update" | "error" | "ping";
310
316
  data?: {
311
317
  routeId?: string;
318
+ layoutPath?: string;
312
319
  message?: string;
313
320
  timestamp?: number;
314
321
  };
@@ -476,6 +483,13 @@ export function generateHMRClientScript(port: number): string {
476
483
  }
477
484
  break;
478
485
 
486
+ case 'layout-update':
487
+ const layoutPath = message.data?.layoutPath;
488
+ console.log('[Mandu HMR] Layout updated:', layoutPath);
489
+ // Layout 변경은 항상 전체 리로드
490
+ location.reload();
491
+ break;
492
+
479
493
  case 'error':
480
494
  console.error('[Mandu HMR] Build error:', message.data?.message);
481
495
  showErrorOverlay(message.data?.message);
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Mandu Guard Analyzer
3
+ *
4
+ * 파일 분석 및 Import 추출
5
+ */
6
+
7
+ import { readFile } from "fs/promises";
8
+ import { dirname, isAbsolute, relative, resolve } from "path";
9
+ import { minimatch } from "minimatch";
10
+ import type {
11
+ ImportInfo,
12
+ FileAnalysis,
13
+ LayerDefinition,
14
+ GuardConfig,
15
+ } from "./types";
16
+ import { WATCH_EXTENSIONS } from "./types";
17
+
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+ // Import Extraction
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+
22
+ /**
23
+ * Static import 패턴
24
+ *
25
+ * Examples:
26
+ * - import { X } from 'module'
27
+ * - import X from 'module'
28
+ * - import * as X from 'module'
29
+ * - import 'module'
30
+ */
31
+ const STATIC_IMPORT_PATTERN = /^import\s+(?:(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+))*)\s+from\s+)?['"]([^'"]+)['"]/gm;
32
+
33
+ /**
34
+ * Dynamic import 패턴
35
+ *
36
+ * Examples:
37
+ * - import('module')
38
+ * - await import('module')
39
+ */
40
+ const DYNAMIC_IMPORT_PATTERN = /(?:await\s+)?import\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
41
+
42
+ /**
43
+ * CommonJS require 패턴
44
+ *
45
+ * Examples:
46
+ * - require('module')
47
+ * - const X = require('module')
48
+ */
49
+ const REQUIRE_PATTERN = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
50
+
51
+ /**
52
+ * Named import 추출 패턴
53
+ */
54
+ const NAMED_IMPORT_PATTERN = /\{\s*([^}]+)\s*\}/;
55
+
56
+ /**
57
+ * Default import 추출 패턴
58
+ */
59
+ const DEFAULT_IMPORT_PATTERN = /^import\s+(\w+)/;
60
+
61
+ /**
62
+ * 파일에서 import 문 추출
63
+ */
64
+ export function extractImports(content: string): ImportInfo[] {
65
+ const imports: ImportInfo[] = [];
66
+ const lines = content.split("\n");
67
+
68
+ // Static imports
69
+ let match: RegExpExecArray | null;
70
+ const staticPattern = new RegExp(STATIC_IMPORT_PATTERN.source, "gm");
71
+
72
+ while ((match = staticPattern.exec(content)) !== null) {
73
+ const statement = match[0];
74
+ const path = match[1];
75
+ const position = getLineAndColumn(content, match.index);
76
+
77
+ // Named imports 추출
78
+ const namedMatch = statement.match(NAMED_IMPORT_PATTERN);
79
+ const namedImports = namedMatch
80
+ ? namedMatch[1].split(",").map((s) => s.trim().split(" as ")[0].trim())
81
+ : undefined;
82
+
83
+ // Default import 추출
84
+ const defaultMatch = statement.match(DEFAULT_IMPORT_PATTERN);
85
+ const defaultImport =
86
+ defaultMatch && !statement.includes("{") && !statement.includes("*")
87
+ ? defaultMatch[1]
88
+ : undefined;
89
+
90
+ imports.push({
91
+ statement,
92
+ path,
93
+ line: position.line,
94
+ column: position.column,
95
+ type: "static",
96
+ namedImports,
97
+ defaultImport,
98
+ });
99
+ }
100
+
101
+ // Dynamic imports
102
+ const dynamicPattern = new RegExp(DYNAMIC_IMPORT_PATTERN.source, "gm");
103
+
104
+ while ((match = dynamicPattern.exec(content)) !== null) {
105
+ const position = getLineAndColumn(content, match.index);
106
+
107
+ imports.push({
108
+ statement: match[0],
109
+ path: match[1],
110
+ line: position.line,
111
+ column: position.column,
112
+ type: "dynamic",
113
+ });
114
+ }
115
+
116
+ // CommonJS requires
117
+ const requirePattern = new RegExp(REQUIRE_PATTERN.source, "gm");
118
+
119
+ while ((match = requirePattern.exec(content)) !== null) {
120
+ const position = getLineAndColumn(content, match.index);
121
+
122
+ imports.push({
123
+ statement: match[0],
124
+ path: match[1],
125
+ line: position.line,
126
+ column: position.column,
127
+ type: "require",
128
+ });
129
+ }
130
+
131
+ return imports;
132
+ }
133
+
134
+ /**
135
+ * 인덱스에서 라인과 컬럼 위치 계산
136
+ */
137
+ function getLineAndColumn(content: string, index: number): { line: number; column: number } {
138
+ const lines = content.slice(0, index).split("\n");
139
+ return {
140
+ line: lines.length,
141
+ column: lines[lines.length - 1].length + 1,
142
+ };
143
+ }
144
+
145
+ // ═══════════════════════════════════════════════════════════════════════════
146
+ // Layer Resolution
147
+ // ═══════════════════════════════════════════════════════════════════════════
148
+
149
+ /**
150
+ * 파일 경로에서 레이어 결정
151
+ */
152
+ export function resolveFileLayer(
153
+ filePath: string,
154
+ layers: LayerDefinition[],
155
+ rootDir: string
156
+ ): string | null {
157
+ const relativePath = relative(rootDir, filePath).replace(/\\/g, "/");
158
+
159
+ for (const layer of layers) {
160
+ if (minimatch(relativePath, layer.pattern)) {
161
+ return layer.name;
162
+ }
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Import 경로에서 타겟 레이어 결정
170
+ */
171
+ export function resolveImportLayer(
172
+ importPath: string,
173
+ layers: LayerDefinition[],
174
+ srcDir: string,
175
+ fromFile?: string,
176
+ rootDir?: string
177
+ ): string | null {
178
+ const normalizedImportPath = importPath.replace(/\\/g, "/");
179
+ const normalizedSrcDir = srcDir.replace(/\\/g, "/").replace(/\/$/, "");
180
+
181
+ const isAlias = normalizedImportPath.startsWith("@/") || normalizedImportPath.startsWith("~/");
182
+ const isRelative = normalizedImportPath.startsWith(".");
183
+ const isSrcAbsolute = normalizedSrcDir.length > 0 && normalizedSrcDir !== "." &&
184
+ (normalizedImportPath === normalizedSrcDir || normalizedImportPath.startsWith(`${normalizedSrcDir}/`));
185
+
186
+ // 상대/alias/src 경로가 아닌 경우 (node_modules 등)
187
+ if (!isAlias && !isRelative && !isSrcAbsolute) {
188
+ return null;
189
+ }
190
+
191
+ const candidates: string[] = [];
192
+
193
+ if (isAlias) {
194
+ const aliasPath = normalizedImportPath.slice(2);
195
+ const withSrc = normalizedSrcDir.length > 0 ? `${normalizedSrcDir}/${aliasPath}` : aliasPath;
196
+ candidates.push(withSrc, aliasPath);
197
+ } else if (isSrcAbsolute) {
198
+ const trimmed = normalizedSrcDir.length > 0 && normalizedImportPath.startsWith(`${normalizedSrcDir}/`)
199
+ ? normalizedImportPath.slice(normalizedSrcDir.length + 1)
200
+ : normalizedImportPath;
201
+ candidates.push(normalizedImportPath, trimmed);
202
+ } else if (isRelative) {
203
+ if (!fromFile || !rootDir) {
204
+ return null;
205
+ }
206
+
207
+ const absoluteFromFile = isAbsolute(fromFile) ? fromFile : resolve(rootDir, fromFile);
208
+ const resolvedPath = resolve(dirname(absoluteFromFile), normalizedImportPath);
209
+ const relativeToRoot = relative(rootDir, resolvedPath).replace(/\\/g, "/");
210
+
211
+ // 루트 밖이면 무시
212
+ if (relativeToRoot.startsWith("..") || relativeToRoot.startsWith("../")) {
213
+ return null;
214
+ }
215
+
216
+ candidates.push(relativeToRoot);
217
+
218
+ if (normalizedSrcDir.length > 0 && relativeToRoot.startsWith(`${normalizedSrcDir}/`)) {
219
+ candidates.push(relativeToRoot.slice(normalizedSrcDir.length + 1));
220
+ }
221
+ }
222
+
223
+ for (const candidate of candidates) {
224
+ const normalizedCandidate = candidate.replace(/\\/g, "/");
225
+
226
+ for (const layer of layers) {
227
+ if (minimatch(normalizedCandidate, layer.pattern)) {
228
+ return layer.name;
229
+ }
230
+ }
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * FSD 슬라이스 이름 추출
238
+ */
239
+ export function extractSlice(filePath: string, layer: string): string | undefined {
240
+ const relativePath = filePath.replace(/\\/g, "/");
241
+
242
+ // layer/slice/... 형식에서 slice 추출
243
+ const pattern = new RegExp(`${layer}/([^/]+)`);
244
+ const match = relativePath.match(pattern);
245
+
246
+ return match?.[1];
247
+ }
248
+
249
+ // ═══════════════════════════════════════════════════════════════════════════
250
+ // File Analysis
251
+ // ═══════════════════════════════════════════════════════════════════════════
252
+
253
+ /**
254
+ * 파일 분석
255
+ */
256
+ export async function analyzeFile(
257
+ filePath: string,
258
+ layers: LayerDefinition[],
259
+ rootDir: string
260
+ ): Promise<FileAnalysis> {
261
+ const content = await readFile(filePath, "utf-8");
262
+ const imports = extractImports(content);
263
+ const layer = resolveFileLayer(filePath, layers, rootDir);
264
+ const slice = layer ? extractSlice(filePath, layer) : undefined;
265
+
266
+ return {
267
+ filePath,
268
+ rootDir,
269
+ layer,
270
+ slice,
271
+ imports,
272
+ analyzedAt: Date.now(),
273
+ };
274
+ }
275
+
276
+ /**
277
+ * 파일이 분석 대상인지 확인
278
+ */
279
+ export function shouldAnalyzeFile(
280
+ filePath: string,
281
+ config: GuardConfig,
282
+ rootDir?: string
283
+ ): boolean {
284
+ const ext = filePath.slice(filePath.lastIndexOf("."));
285
+
286
+ // 확장자 체크
287
+ if (!WATCH_EXTENSIONS.includes(ext)) {
288
+ return false;
289
+ }
290
+
291
+ // 제외 패턴 체크
292
+ if (config.exclude) {
293
+ const normalizedPath = filePath.replace(/\\/g, "/");
294
+ const candidates = new Set<string>([normalizedPath]);
295
+
296
+ if (rootDir) {
297
+ const relativeToRoot = relative(rootDir, filePath).replace(/\\/g, "/");
298
+ candidates.add(relativeToRoot);
299
+
300
+ const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, "");
301
+ if (srcDir && relativeToRoot.startsWith(`${srcDir}/`)) {
302
+ candidates.add(relativeToRoot.slice(srcDir.length + 1));
303
+ }
304
+ }
305
+
306
+ for (const pattern of config.exclude) {
307
+ for (const candidate of candidates) {
308
+ if (minimatch(candidate, pattern)) {
309
+ return false;
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * Import가 무시 대상인지 확인
320
+ */
321
+ export function shouldIgnoreImport(
322
+ importPath: string,
323
+ config: GuardConfig
324
+ ): boolean {
325
+ const normalizedImportPath = importPath.replace(/\\/g, "/");
326
+ const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, "");
327
+ const isSrcAbsolute = srcDir.length > 0 && srcDir !== "." &&
328
+ (normalizedImportPath === srcDir || normalizedImportPath.startsWith(`${srcDir}/`));
329
+
330
+ // 외부 모듈 (node_modules)
331
+ if (
332
+ !normalizedImportPath.startsWith(".") &&
333
+ !normalizedImportPath.startsWith("@/") &&
334
+ !normalizedImportPath.startsWith("~/") &&
335
+ !isSrcAbsolute
336
+ ) {
337
+ return true;
338
+ }
339
+
340
+ // 무시 패턴 체크
341
+ if (config.ignoreImports) {
342
+ for (const pattern of config.ignoreImports) {
343
+ if (minimatch(normalizedImportPath, pattern)) {
344
+ return true;
345
+ }
346
+ }
347
+ }
348
+
349
+ return false;
350
+ }