@mandujs/core 0.8.2 → 0.9.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.
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * Slot Content Validator
3
3
  * 슬롯 파일 내용을 작성 전에 검증하고 문제를 식별합니다.
4
+ *
5
+ * 검증 항목:
6
+ * 1. 필수 import/export 패턴
7
+ * 2. Mandu.filling() 사용 여부
8
+ * 3. export default Mandu.filling() 형태 검증
9
+ * 4. 핸들러 반환 타입 검증 (ctx.ok(), ctx.json() 등)
10
+ * 5. 금지된 모듈 import 검사
4
11
  */
5
12
 
6
13
  export interface SlotValidationIssue {
@@ -29,13 +36,103 @@ const FORBIDDEN_IMPORTS = [
29
36
  "node:worker_threads",
30
37
  ];
31
38
 
32
- // 필수 패턴들
39
+ // 필수 패턴들 (더 엄격한 검사)
33
40
  const REQUIRED_PATTERNS = {
34
41
  manduImport: /import\s+.*\bMandu\b.*from\s+['"]@mandujs\/core['"]/,
35
42
  fillingPattern: /Mandu\s*\.\s*filling\s*\(\s*\)/,
36
43
  defaultExport: /export\s+default\b/,
44
+ // export default Mandu.filling() 또는 export default 변수명
45
+ exportDefaultFilling: /export\s+default\s+(Mandu\s*\.\s*filling\s*\(\s*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)/,
37
46
  };
38
47
 
48
+ /**
49
+ * 주석을 제거한 코드 반환
50
+ * 한줄 주석(//)과 블록 주석 제거
51
+ */
52
+ function stripComments(content: string): string {
53
+ // 문자열 내부는 보존하면서 주석만 제거
54
+ let result = "";
55
+ let i = 0;
56
+ let inString: string | null = null;
57
+ let inComment: "line" | "block" | null = null;
58
+
59
+ while (i < content.length) {
60
+ const char = content[i];
61
+ const nextChar = content[i + 1];
62
+
63
+ // 문자열 처리
64
+ if (!inComment) {
65
+ if (!inString && (char === '"' || char === "'" || char === "`")) {
66
+ inString = char;
67
+ result += char;
68
+ i++;
69
+ continue;
70
+ }
71
+ if (inString && char === inString && content[i - 1] !== "\\") {
72
+ inString = null;
73
+ result += char;
74
+ i++;
75
+ continue;
76
+ }
77
+ if (inString) {
78
+ result += char;
79
+ i++;
80
+ continue;
81
+ }
82
+ }
83
+
84
+ // 주석 시작 감지
85
+ if (!inString && !inComment) {
86
+ if (char === "/" && nextChar === "/") {
87
+ inComment = "line";
88
+ i += 2;
89
+ continue;
90
+ }
91
+ if (char === "/" && nextChar === "*") {
92
+ inComment = "block";
93
+ i += 2;
94
+ continue;
95
+ }
96
+ }
97
+
98
+ // 주석 종료 감지
99
+ if (inComment === "line" && char === "\n") {
100
+ inComment = null;
101
+ result += char; // 줄바꿈은 유지
102
+ i++;
103
+ continue;
104
+ }
105
+ if (inComment === "block" && char === "*" && nextChar === "/") {
106
+ inComment = null;
107
+ i += 2;
108
+ continue;
109
+ }
110
+
111
+ // 주석 내부면 스킵
112
+ if (inComment) {
113
+ i++;
114
+ continue;
115
+ }
116
+
117
+ result += char;
118
+ i++;
119
+ }
120
+
121
+ return result;
122
+ }
123
+
124
+ // 올바른 응답 패턴 (ctx 메서드 호출)
125
+ const VALID_RESPONSE_PATTERNS = [
126
+ /return\s+ctx\s*\.\s*(ok|json|created|noContent|notFound|badRequest|error|html|redirect|stream)\s*\(/,
127
+ /return\s+new\s+Response\s*\(/,
128
+ /return\s+Response\s*\.\s*(json|redirect)\s*\(/,
129
+ ];
130
+
131
+ // 잘못된 반환 패턴 (일반 객체 직접 반환)
132
+ const INVALID_RETURN_PATTERNS = [
133
+ /return\s+\{\s*[^}]*\}\s*;?\s*$/m, // return { ... }; (Response가 아닌 객체)
134
+ ];
135
+
39
136
  /**
40
137
  * 슬롯 내용을 검증합니다.
41
138
  */
@@ -43,6 +140,9 @@ export function validateSlotContent(content: string): SlotValidationResult {
43
140
  const issues: SlotValidationIssue[] = [];
44
141
  const lines = content.split("\n");
45
142
 
143
+ // 주석 제거된 코드 (export default 등 패턴 검사용)
144
+ const codeWithoutComments = stripComments(content);
145
+
46
146
  // 1. 금지된 import 검사
47
147
  for (let i = 0; i < lines.length; i++) {
48
148
  const line = lines[i];
@@ -90,25 +190,54 @@ export function validateSlotContent(content: string): SlotValidationResult {
90
190
  });
91
191
  }
92
192
 
93
- // 4. default export 검사
94
- if (!REQUIRED_PATTERNS.defaultExport.test(content)) {
193
+ // 4. default export 검사 (강화됨) - 주석 제거된 코드에서 검사
194
+ const hasDefaultExport = REQUIRED_PATTERNS.defaultExport.test(codeWithoutComments);
195
+ const hasExportDefaultFilling = REQUIRED_PATTERNS.exportDefaultFilling.test(codeWithoutComments);
196
+
197
+ if (!hasDefaultExport) {
198
+ // Mandu.filling()이 있는데 export default가 없는 경우 - 변수에 할당만 함
199
+ const fillingVarMatch = codeWithoutComments.match(/(?:const|let|var)\s+(\w+)\s*=\s*Mandu\s*\.\s*filling\s*\(\s*\)/);
200
+ if (fillingVarMatch) {
201
+ const varName = fillingVarMatch[1];
202
+ issues.push({
203
+ code: "MISSING_DEFAULT_EXPORT",
204
+ severity: "error",
205
+ message: `default export가 없습니다. '${varName}'가 export 되지 않았습니다`,
206
+ suggestion: `'export default ${varName};' 를 파일 끝에 추가하거나, 'export default Mandu.filling()...' 형태로 작성하세요`,
207
+ autoFixable: true,
208
+ });
209
+ } else {
210
+ issues.push({
211
+ code: "MISSING_DEFAULT_EXPORT",
212
+ severity: "error",
213
+ message: "default export가 없습니다",
214
+ suggestion: "export default Mandu.filling()... 형태로 작성하세요",
215
+ autoFixable: true,
216
+ });
217
+ }
218
+ } else if (hasDefaultExport && REQUIRED_PATTERNS.fillingPattern.test(codeWithoutComments) && !hasExportDefaultFilling) {
219
+ // export default는 있지만 Mandu.filling()을 export하지 않는 경우
95
220
  issues.push({
96
- code: "MISSING_DEFAULT_EXPORT",
97
- severity: "error",
98
- message: "default export 없습니다",
99
- suggestion: "export default Mandu.filling()... 형태로 작성하세요",
100
- autoFixable: true,
221
+ code: "INVALID_DEFAULT_EXPORT",
222
+ severity: "warning",
223
+ message: "export default Mandu.filling()을 직접 export하지 않습니다",
224
+ suggestion: "export default Mandu.filling()... 형태로 작성하거나, 변수명을 export default로 내보내세요",
225
+ autoFixable: false,
101
226
  });
102
227
  }
103
228
 
104
- // 5. 기본 문법 검사 (간단한 체크)
105
- const syntaxIssues = checkBasicSyntax(content, lines);
229
+ // 5. 기본 문법 검사 (간단한 체크) - 주석 제거된 코드로 검사
230
+ const syntaxIssues = checkBasicSyntax(codeWithoutComments, codeWithoutComments.split("\n"));
106
231
  issues.push(...syntaxIssues);
107
232
 
108
- // 6. HTTP 메서드 핸들러 검사
109
- const methodIssues = checkHttpMethods(content);
233
+ // 6. HTTP 메서드 핸들러 검사 (강화됨)
234
+ const methodIssues = checkHttpMethods(content, lines);
110
235
  issues.push(...methodIssues);
111
236
 
237
+ // 7. 핸들러 반환 타입 검사 (신규)
238
+ const returnIssues = checkHandlerReturns(content, lines);
239
+ issues.push(...returnIssues);
240
+
112
241
  return {
113
242
  valid: issues.filter((i) => i.severity === "error").length === 0,
114
243
  issues,
@@ -185,7 +314,7 @@ function checkBasicSyntax(
185
314
  /**
186
315
  * HTTP 메서드 핸들러 검사
187
316
  */
188
- function checkHttpMethods(content: string): SlotValidationIssue[] {
317
+ function checkHttpMethods(content: string, lines: string[]): SlotValidationIssue[] {
189
318
  const issues: SlotValidationIssue[] = [];
190
319
 
191
320
  // .get(), .post() 등의 핸들러가 있는지 확인
@@ -204,14 +333,14 @@ function checkHttpMethods(content: string): SlotValidationIssue[] {
204
333
  }
205
334
 
206
335
  // ctx.ok(), ctx.json() 등 응답 패턴 확인
207
- const responsePattern = /ctx\s*\.\s*(ok|json|created|noContent|error|html)\s*\(/;
336
+ const responsePattern = /ctx\s*\.\s*(ok|json|created|noContent|notFound|badRequest|error|html|redirect|stream)\s*\(/;
208
337
  if (hasMethod && !responsePattern.test(content)) {
209
338
  issues.push({
210
339
  code: "NO_RESPONSE_PATTERN",
211
- severity: "warning",
212
- message: "응답 패턴이 없습니다",
340
+ severity: "error", // 에러로 승격 (warning → error)
341
+ message: "ctx 응답 메서드가 없습니다 (ctx.ok(), ctx.json() 등)",
213
342
  suggestion:
214
- "핸들러에서 ctx.ok(), ctx.json() 등으로 응답을 반환하세요",
343
+ "핸들러에서 ctx.ok(), ctx.json(), ctx.created() 등으로 응답을 반환하세요. 일반 객체 { ... }를 직접 반환하면 안 됩니다.",
215
344
  autoFixable: false,
216
345
  });
217
346
  }
@@ -219,6 +348,96 @@ function checkHttpMethods(content: string): SlotValidationIssue[] {
219
348
  return issues;
220
349
  }
221
350
 
351
+ /**
352
+ * 핸들러 반환 타입 검사 (신규)
353
+ * 핸들러가 올바른 Response 객체를 반환하는지 검사
354
+ */
355
+ function checkHandlerReturns(content: string, lines: string[]): SlotValidationIssue[] {
356
+ const issues: SlotValidationIssue[] = [];
357
+
358
+ // 핸들러 내부에서 일반 객체를 직접 반환하는 패턴 감지
359
+ // 예: return { data: [], status: "ok" };
360
+ const handlerBlockPattern = /\.(get|post|put|patch|delete)\s*\(\s*(?:async\s*)?\(?(?:ctx)?\)?\s*=>\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/gs;
361
+
362
+ let match;
363
+ while ((match = handlerBlockPattern.exec(content)) !== null) {
364
+ const method = match[1].toUpperCase();
365
+ const handlerBody = match[2];
366
+
367
+ // 핸들러 본문에서 return 문 찾기
368
+ const returnStatements = handlerBody.match(/return\s+[^;]+;?/g) || [];
369
+
370
+ for (const returnStmt of returnStatements) {
371
+ // ctx.* 또는 new Response 또는 Response.* 패턴 확인
372
+ const isValidReturn =
373
+ /return\s+ctx\s*\./.test(returnStmt) ||
374
+ /return\s+new\s+Response/.test(returnStmt) ||
375
+ /return\s+Response\s*\./.test(returnStmt);
376
+
377
+ // 일반 객체 직접 반환 감지: return { ... }
378
+ const isObjectReturn = /return\s+\{/.test(returnStmt) && !isValidReturn;
379
+
380
+ // 문자열 직접 반환 감지: return "string" 또는 return 'string'
381
+ const isStringReturn = /return\s+['"`]/.test(returnStmt) && !isValidReturn;
382
+
383
+ // throw 문자열 감지 (Error 객체가 아닌 문자열)
384
+ const throwStringPattern = /throw\s+['"`][^'"`]+['"`]/;
385
+
386
+ if (isObjectReturn) {
387
+ // return 문이 있는 라인 번호 찾기
388
+ const lineNum = findLineNumber(lines, returnStmt.trim().substring(0, 30));
389
+ issues.push({
390
+ code: "INVALID_HANDLER_RETURN",
391
+ severity: "error",
392
+ message: `${method} 핸들러가 일반 객체를 직접 반환합니다`,
393
+ line: lineNum,
394
+ suggestion: "return { ... } 대신 return ctx.ok({ ... }) 또는 return ctx.json({ ... }) 을 사용하세요",
395
+ autoFixable: false,
396
+ });
397
+ }
398
+
399
+ if (isStringReturn) {
400
+ const lineNum = findLineNumber(lines, returnStmt.trim().substring(0, 30));
401
+ issues.push({
402
+ code: "INVALID_HANDLER_RETURN",
403
+ severity: "error",
404
+ message: `${method} 핸들러가 문자열을 직접 반환합니다`,
405
+ line: lineNum,
406
+ suggestion: "return 'text' 대신 return ctx.html('text') 또는 return ctx.ok({ message: 'text' }) 를 사용하세요",
407
+ autoFixable: false,
408
+ });
409
+ }
410
+ }
411
+
412
+ // throw 문자열 검사
413
+ if (/throw\s+['"`][^'"`]+['"`]/.test(handlerBody)) {
414
+ const lineNum = findLineNumber(lines, "throw");
415
+ issues.push({
416
+ code: "INVALID_THROW_PATTERN",
417
+ severity: "warning",
418
+ message: `${method} 핸들러에서 문자열을 직접 throw합니다`,
419
+ line: lineNum,
420
+ suggestion: "throw 'message' 대신 throw new Error('message') 를 사용하세요",
421
+ autoFixable: false,
422
+ });
423
+ }
424
+ }
425
+
426
+ return issues;
427
+ }
428
+
429
+ /**
430
+ * 특정 텍스트가 있는 라인 번호 찾기
431
+ */
432
+ function findLineNumber(lines: string[], searchText: string): number | undefined {
433
+ for (let i = 0; i < lines.length; i++) {
434
+ if (lines[i].includes(searchText)) {
435
+ return i + 1;
436
+ }
437
+ }
438
+ return undefined;
439
+ }
440
+
222
441
  /**
223
442
  * 에러 요약 생성
224
443
  */
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Brain v0.1 - Watcher Module
3
+ *
4
+ * Watch handles error prevention:
5
+ * - File change detection
6
+ * - Architecture rule warnings
7
+ * - No blocking - warnings only
8
+ */
9
+
10
+ // Rules
11
+ export {
12
+ MVP_RULES,
13
+ getRulesMap,
14
+ matchGlob,
15
+ matchRules,
16
+ checkNamingConvention,
17
+ checkForbiddenImports,
18
+ validateFile,
19
+ getRule,
20
+ getAllRules,
21
+ } from "./rules";
22
+
23
+ // Watcher
24
+ export {
25
+ FileWatcher,
26
+ createWatcher,
27
+ getWatcher,
28
+ startWatcher,
29
+ stopWatcher,
30
+ type WatcherConfig,
31
+ } from "./watcher";
32
+
33
+ // Reporter
34
+ export {
35
+ formatWarning,
36
+ printWarning,
37
+ formatStatus,
38
+ printStatus,
39
+ printWatchStart,
40
+ printWatchStop,
41
+ generateJsonStatus,
42
+ createConsoleHandler,
43
+ createCollectorHandler,
44
+ } from "./reporter";
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Brain v0.1 - Watch Reporter
3
+ *
4
+ * Formats and outputs watch warnings to the console.
5
+ * Warnings only - never blocks operations.
6
+ */
7
+
8
+ import type { WatchWarning, WatchStatus, ArchRule } from "../brain/types";
9
+ import { getRule } from "./rules";
10
+
11
+ /**
12
+ * ANSI color codes for terminal output
13
+ */
14
+ const colors = {
15
+ reset: "\x1b[0m",
16
+ bright: "\x1b[1m",
17
+ dim: "\x1b[2m",
18
+ red: "\x1b[31m",
19
+ green: "\x1b[32m",
20
+ yellow: "\x1b[33m",
21
+ blue: "\x1b[34m",
22
+ magenta: "\x1b[35m",
23
+ cyan: "\x1b[36m",
24
+ };
25
+
26
+ /**
27
+ * Check if colors should be used
28
+ */
29
+ function useColors(): boolean {
30
+ if (process.env.NO_COLOR) return false;
31
+ if (typeof process?.stdout?.isTTY === "boolean") {
32
+ return process.stdout.isTTY;
33
+ }
34
+ return true;
35
+ }
36
+
37
+ /**
38
+ * Apply color if enabled
39
+ */
40
+ function color(text: string, colorCode: string): string {
41
+ if (!useColors()) return text;
42
+ return `${colorCode}${text}${colors.reset}`;
43
+ }
44
+
45
+ /**
46
+ * Format a warning for terminal output
47
+ */
48
+ export function formatWarning(warning: WatchWarning): string {
49
+ const rule = getRule(warning.ruleId);
50
+
51
+ // Event icons
52
+ const eventIcons: Record<string, string> = {
53
+ create: "📝",
54
+ modify: "✏️",
55
+ delete: "🗑️",
56
+ };
57
+
58
+ const eventIcon = eventIcons[warning.event] || "📄";
59
+ const ruleIcon = rule?.action === "error" ? "❌" : "⚠️";
60
+ const ruleColor = rule?.action === "error" ? colors.red : colors.yellow;
61
+
62
+ const lines: string[] = [];
63
+
64
+ lines.push(
65
+ `${eventIcon} ${ruleIcon} ${color(`[${warning.ruleId}]`, ruleColor)} ${warning.file}`
66
+ );
67
+ lines.push(` ${warning.message}`);
68
+
69
+ if (rule?.description) {
70
+ lines.push(` ${color("💡", colors.cyan)} ${rule.description}`);
71
+ }
72
+
73
+ return lines.join("\n");
74
+ }
75
+
76
+ /**
77
+ * Print a warning to console
78
+ */
79
+ export function printWarning(warning: WatchWarning): void {
80
+ console.log(formatWarning(warning));
81
+ console.log();
82
+ }
83
+
84
+ /**
85
+ * Format watch status for terminal output
86
+ */
87
+ export function formatStatus(status: WatchStatus): string {
88
+ const lines: string[] = [];
89
+
90
+ lines.push(color("👁️ Mandu Watch Status", colors.bright + colors.blue));
91
+ lines.push(color("─".repeat(40), colors.dim));
92
+ lines.push();
93
+
94
+ const statusIcon = status.active ? "🟢" : "🔴";
95
+ const statusText = status.active ? "Active" : "Inactive";
96
+
97
+ lines.push(`${statusIcon} Status: ${color(statusText, status.active ? colors.green : colors.red)}`);
98
+
99
+ if (status.rootDir) {
100
+ lines.push(`📁 Root: ${color(status.rootDir, colors.cyan)}`);
101
+ }
102
+
103
+ lines.push(`📊 Files: ${status.fileCount}`);
104
+
105
+ if (status.startedAt) {
106
+ const duration = Math.floor(
107
+ (Date.now() - status.startedAt.getTime()) / 1000
108
+ );
109
+ const minutes = Math.floor(duration / 60);
110
+ const seconds = duration % 60;
111
+ lines.push(
112
+ `⏱️ Uptime: ${minutes > 0 ? `${minutes}m ` : ""}${seconds}s`
113
+ );
114
+ }
115
+
116
+ if (status.recentWarnings.length > 0) {
117
+ lines.push();
118
+ lines.push(
119
+ color(
120
+ `⚠️ Recent Warnings (${status.recentWarnings.length})`,
121
+ colors.yellow
122
+ )
123
+ );
124
+
125
+ // Show last 5 warnings
126
+ const recent = status.recentWarnings.slice(-5);
127
+ for (const warning of recent) {
128
+ const time = warning.timestamp.toLocaleTimeString();
129
+ lines.push(
130
+ ` ${color(time, colors.dim)} [${warning.ruleId}] ${warning.file}`
131
+ );
132
+ }
133
+ } else {
134
+ lines.push();
135
+ lines.push(color("✅ No recent warnings", colors.green));
136
+ }
137
+
138
+ return lines.join("\n");
139
+ }
140
+
141
+ /**
142
+ * Print watch status to console
143
+ */
144
+ export function printStatus(status: WatchStatus): void {
145
+ console.log(formatStatus(status));
146
+ console.log();
147
+ }
148
+
149
+ /**
150
+ * Print watch startup message
151
+ */
152
+ export function printWatchStart(rootDir: string): void {
153
+ console.log();
154
+ console.log(color("👁️ Mandu Watch", colors.bright + colors.blue));
155
+ console.log(color("─".repeat(40), colors.dim));
156
+ console.log(`📁 Watching: ${color(rootDir, colors.cyan)}`);
157
+ console.log();
158
+ console.log(color("Rules active:", colors.dim));
159
+ console.log(" • GENERATED_DIRECT_EDIT - Generated 파일 직접 수정 감지");
160
+ console.log(" • WRONG_SLOT_LOCATION - 잘못된 위치의 Slot 파일 감지");
161
+ console.log(" • SLOT_NAMING - Slot 파일 네이밍 규칙");
162
+ console.log(" • CONTRACT_NAMING - Contract 파일 네이밍 규칙");
163
+ console.log(" • FORBIDDEN_IMPORT - Generated 파일의 금지된 import 감지");
164
+ console.log();
165
+ console.log(
166
+ color("ℹ️ Watch는 경고만 출력합니다. 작업을 차단하지 않습니다.", colors.dim)
167
+ );
168
+ console.log(color(" Press Ctrl+C to stop", colors.dim));
169
+ console.log();
170
+ }
171
+
172
+ /**
173
+ * Print watch stop message
174
+ */
175
+ export function printWatchStop(): void {
176
+ console.log();
177
+ console.log(color("👁️ Watch stopped", colors.dim));
178
+ console.log();
179
+ }
180
+
181
+ /**
182
+ * Generate JSON status for MCP/API consumption
183
+ */
184
+ export function generateJsonStatus(status: WatchStatus): string {
185
+ return JSON.stringify(
186
+ {
187
+ active: status.active,
188
+ rootDir: status.rootDir,
189
+ fileCount: status.fileCount,
190
+ startedAt: status.startedAt?.toISOString() || null,
191
+ recentWarnings: status.recentWarnings.map((w) => ({
192
+ ruleId: w.ruleId,
193
+ file: w.file,
194
+ message: w.message,
195
+ event: w.event,
196
+ timestamp: w.timestamp.toISOString(),
197
+ })),
198
+ },
199
+ null,
200
+ 2
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Create a warning handler that prints to console
206
+ */
207
+ export function createConsoleHandler(): (warning: WatchWarning) => void {
208
+ return (warning) => {
209
+ printWarning(warning);
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Create a warning handler that collects warnings
215
+ */
216
+ export function createCollectorHandler(): {
217
+ handler: (warning: WatchWarning) => void;
218
+ getWarnings: () => WatchWarning[];
219
+ clear: () => void;
220
+ } {
221
+ const warnings: WatchWarning[] = [];
222
+
223
+ return {
224
+ handler: (warning) => {
225
+ warnings.push(warning);
226
+ },
227
+ getWarnings: () => [...warnings],
228
+ clear: () => {
229
+ warnings.length = 0;
230
+ },
231
+ };
232
+ }