@mandujs/core 0.1.0 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,12 +1,22 @@
1
1
  import type { RoutesManifest, RouteSpec } from "../spec/schema";
2
- import { generateApiHandler, generatePageComponent } from "./templates";
2
+ import { generateApiHandler, generatePageComponent, generateSlotLogic } from "./templates";
3
3
  import path from "path";
4
4
  import fs from "fs/promises";
5
5
 
6
+ async function fileExists(filePath: string): Promise<boolean> {
7
+ try {
8
+ await fs.access(filePath);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
6
15
  export interface GenerateResult {
7
16
  success: boolean;
8
17
  created: string[];
9
18
  deleted: string[];
19
+ skipped: string[];
10
20
  errors: string[];
11
21
  }
12
22
 
@@ -41,6 +51,7 @@ export async function generateRoutes(
41
51
  success: true,
42
52
  created: [],
43
53
  deleted: [],
54
+ skipped: [],
44
55
  errors: [],
45
56
  };
46
57
 
@@ -77,6 +88,24 @@ export async function generateRoutes(
77
88
  kind: route.kind,
78
89
  };
79
90
 
91
+ // Slot file (only if slotModule is specified)
92
+ if (route.slotModule) {
93
+ const slotFilePath = path.join(rootDir, route.slotModule);
94
+ const slotDir = path.dirname(slotFilePath);
95
+
96
+ await ensureDir(slotDir);
97
+
98
+ // slot 파일이 이미 존재하면 덮어쓰지 않음 (사용자 코드 보존)
99
+ const slotExists = await fileExists(slotFilePath);
100
+ if (!slotExists) {
101
+ const slotContent = generateSlotLogic(route);
102
+ await Bun.write(slotFilePath, slotContent);
103
+ result.created.push(slotFilePath);
104
+ } else {
105
+ result.skipped.push(slotFilePath);
106
+ }
107
+ }
108
+
80
109
  // Page component (only for page kind)
81
110
  if (route.kind === "page") {
82
111
  const webFileName = `${route.id}.route.tsx`;
@@ -1,6 +1,11 @@
1
1
  import type { RouteSpec } from "../spec/schema";
2
2
 
3
3
  export function generateApiHandler(route: RouteSpec): string {
4
+ // slotModule이 있으면 slot을 호출하는 버전 생성
5
+ if (route.slotModule) {
6
+ return generateApiHandlerWithSlot(route);
7
+ }
8
+
4
9
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
5
10
  // Route ID: ${route.id}
6
11
  // Pattern: ${route.pattern}
@@ -18,6 +23,113 @@ export default function handler(req: Request, params: Record<string, string>): R
18
23
  `;
19
24
  }
20
25
 
26
+ export function generateApiHandlerWithSlot(route: RouteSpec): string {
27
+ const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/server/generated/routes");
28
+ const logicFunctionName = `${route.id}Logic`;
29
+
30
+ return `// Generated by Mandu - DO NOT EDIT DIRECTLY
31
+ // Route ID: ${route.id}
32
+ // Pattern: ${route.pattern}
33
+ // Slot Module: ${route.slotModule}
34
+
35
+ import type { Request } from "bun";
36
+ import { ${logicFunctionName} } from "${slotImportPath}";
37
+
38
+ export default async function handler(
39
+ req: Request,
40
+ params: Record<string, string>
41
+ ): Promise<Response> {
42
+ try {
43
+ const result = await ${logicFunctionName}(req, params);
44
+ return Response.json(result);
45
+ } catch (error) {
46
+ console.error(\`[${route.id}] Handler error:\`, error);
47
+ return Response.json(
48
+ { status: "error", message: "Internal server error" },
49
+ { status: 500 }
50
+ );
51
+ }
52
+ }
53
+ `;
54
+ }
55
+
56
+ export function generateSlotLogic(route: RouteSpec): string {
57
+ const logicFunctionName = `${route.id}Logic`;
58
+ const resultTypeName = `${capitalize(route.id)}Result`;
59
+
60
+ return `// Slot logic for route: ${route.id}
61
+ // Pattern: ${route.pattern}
62
+ // 이 파일에서 비즈니스 로직을 구현하세요.
63
+
64
+ import type { Request } from "bun";
65
+
66
+ export interface ${resultTypeName} {
67
+ status: "ok" | "error";
68
+ data?: unknown;
69
+ message?: string;
70
+ }
71
+
72
+ export async function ${logicFunctionName}(
73
+ req: Request,
74
+ params: Record<string, string>
75
+ ): Promise<${resultTypeName}> {
76
+ const method = req.method;
77
+
78
+ // TODO: 비즈니스 로직 구현
79
+ switch (method) {
80
+ case "GET":
81
+ return {
82
+ status: "ok",
83
+ data: { routeId: "${route.id}", message: "GET 로직 미구현" },
84
+ };
85
+
86
+ case "POST":
87
+ return {
88
+ status: "ok",
89
+ data: { routeId: "${route.id}", message: "POST 로직 미구현" },
90
+ };
91
+
92
+ default:
93
+ return {
94
+ status: "error",
95
+ message: \`Method \${method} not allowed\`,
96
+ };
97
+ }
98
+ }
99
+ `;
100
+ }
101
+
102
+ function computeSlotImportPath(slotModule: string, fromDir: string): string {
103
+ // slotModule: "apps/server/slots/users.logic.ts"
104
+ // fromDir: "apps/server/generated/routes"
105
+ // result: "../../slots/users.logic"
106
+
107
+ const slotParts = slotModule.replace(/\\/g, "/").split("/");
108
+ const fromParts = fromDir.replace(/\\/g, "/").split("/");
109
+
110
+ // Find common prefix length
111
+ let commonLength = 0;
112
+ while (
113
+ commonLength < slotParts.length &&
114
+ commonLength < fromParts.length &&
115
+ slotParts[commonLength] === fromParts[commonLength]
116
+ ) {
117
+ commonLength++;
118
+ }
119
+
120
+ // Calculate relative path
121
+ const upCount = fromParts.length - commonLength;
122
+ const relativeParts = slotParts.slice(commonLength);
123
+ const ups = Array(upCount).fill("..");
124
+
125
+ let result = [...ups, ...relativeParts].join("/");
126
+
127
+ // Remove .ts extension
128
+ result = result.replace(/\.ts$/, "");
129
+
130
+ return result;
131
+ }
132
+
21
133
  export function generatePageComponent(route: RouteSpec): string {
22
134
  const pageName = capitalize(route.id);
23
135
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
@@ -0,0 +1,203 @@
1
+ import type { RoutesManifest } from "../spec/schema";
2
+ import type { GuardViolation } from "./rules";
3
+ import { GUARD_RULES } from "./rules";
4
+ import { runGuardCheck } from "./check";
5
+ import { writeLock } from "../spec/lock";
6
+ import { generateRoutes } from "../generator/generate";
7
+ import path from "path";
8
+
9
+ export interface AutoCorrectStep {
10
+ ruleId: string;
11
+ action: string;
12
+ success: boolean;
13
+ message: string;
14
+ }
15
+
16
+ export interface AutoCorrectResult {
17
+ fixed: boolean;
18
+ steps: AutoCorrectStep[];
19
+ remainingViolations: GuardViolation[];
20
+ retriedCount: number;
21
+ }
22
+
23
+ // 자동 수정 가능한 규칙들
24
+ const AUTO_CORRECTABLE_RULES = new Set([
25
+ GUARD_RULES.SPEC_HASH_MISMATCH.id,
26
+ GUARD_RULES.GENERATED_MANUAL_EDIT.id,
27
+ GUARD_RULES.SLOT_NOT_FOUND.id,
28
+ ]);
29
+
30
+ export function isAutoCorrectableViolation(violation: GuardViolation): boolean {
31
+ return AUTO_CORRECTABLE_RULES.has(violation.ruleId);
32
+ }
33
+
34
+ export async function runAutoCorrect(
35
+ violations: GuardViolation[],
36
+ manifest: RoutesManifest,
37
+ rootDir: string,
38
+ maxRetries: number = 3
39
+ ): Promise<AutoCorrectResult> {
40
+ const steps: AutoCorrectStep[] = [];
41
+ let currentViolations = violations;
42
+ let retriedCount = 0;
43
+
44
+ while (retriedCount < maxRetries) {
45
+ const autoCorrectableViolations = currentViolations.filter(isAutoCorrectableViolation);
46
+
47
+ if (autoCorrectableViolations.length === 0) {
48
+ break;
49
+ }
50
+
51
+ // 각 위반에 대해 수정 시도
52
+ let anyFixed = false;
53
+
54
+ for (const violation of autoCorrectableViolations) {
55
+ const step = await correctViolation(violation, manifest, rootDir);
56
+ steps.push(step);
57
+
58
+ if (step.success) {
59
+ anyFixed = true;
60
+ }
61
+ }
62
+
63
+ if (!anyFixed) {
64
+ // 아무것도 수정하지 못했으면 루프 종료
65
+ break;
66
+ }
67
+
68
+ // Guard 재검사
69
+ retriedCount++;
70
+ const recheckResult = await runGuardCheck(manifest, rootDir);
71
+ currentViolations = recheckResult.violations;
72
+
73
+ if (recheckResult.passed) {
74
+ return {
75
+ fixed: true,
76
+ steps,
77
+ remainingViolations: [],
78
+ retriedCount,
79
+ };
80
+ }
81
+ }
82
+
83
+ return {
84
+ fixed: currentViolations.length === 0,
85
+ steps,
86
+ remainingViolations: currentViolations,
87
+ retriedCount,
88
+ };
89
+ }
90
+
91
+ async function correctViolation(
92
+ violation: GuardViolation,
93
+ manifest: RoutesManifest,
94
+ rootDir: string
95
+ ): Promise<AutoCorrectStep> {
96
+ switch (violation.ruleId) {
97
+ case GUARD_RULES.SPEC_HASH_MISMATCH.id:
98
+ return await correctSpecHashMismatch(manifest, rootDir);
99
+
100
+ case GUARD_RULES.GENERATED_MANUAL_EDIT.id:
101
+ return await correctGeneratedManualEdit(manifest, rootDir);
102
+
103
+ case GUARD_RULES.SLOT_NOT_FOUND.id:
104
+ return await correctSlotNotFound(manifest, rootDir);
105
+
106
+ default:
107
+ return {
108
+ ruleId: violation.ruleId,
109
+ action: "skip",
110
+ success: false,
111
+ message: `자동 수정 불가능한 규칙: ${violation.ruleId}`,
112
+ };
113
+ }
114
+ }
115
+
116
+ async function correctSpecHashMismatch(
117
+ manifest: RoutesManifest,
118
+ rootDir: string
119
+ ): Promise<AutoCorrectStep> {
120
+ try {
121
+ const lockPath = path.join(rootDir, "spec/spec.lock.json");
122
+ await writeLock(lockPath, manifest);
123
+
124
+ return {
125
+ ruleId: GUARD_RULES.SPEC_HASH_MISMATCH.id,
126
+ action: "spec-upsert",
127
+ success: true,
128
+ message: "spec.lock.json 업데이트 완료",
129
+ };
130
+ } catch (error) {
131
+ return {
132
+ ruleId: GUARD_RULES.SPEC_HASH_MISMATCH.id,
133
+ action: "spec-upsert",
134
+ success: false,
135
+ message: `spec.lock.json 업데이트 실패: ${error instanceof Error ? error.message : String(error)}`,
136
+ };
137
+ }
138
+ }
139
+
140
+ async function correctGeneratedManualEdit(
141
+ manifest: RoutesManifest,
142
+ rootDir: string
143
+ ): Promise<AutoCorrectStep> {
144
+ try {
145
+ const result = await generateRoutes(manifest, rootDir);
146
+
147
+ if (result.success) {
148
+ return {
149
+ ruleId: GUARD_RULES.GENERATED_MANUAL_EDIT.id,
150
+ action: "generate",
151
+ success: true,
152
+ message: `코드 재생성 완료 (${result.created.length}개 파일)`,
153
+ };
154
+ } else {
155
+ return {
156
+ ruleId: GUARD_RULES.GENERATED_MANUAL_EDIT.id,
157
+ action: "generate",
158
+ success: false,
159
+ message: `코드 재생성 실패: ${result.errors.join(", ")}`,
160
+ };
161
+ }
162
+ } catch (error) {
163
+ return {
164
+ ruleId: GUARD_RULES.GENERATED_MANUAL_EDIT.id,
165
+ action: "generate",
166
+ success: false,
167
+ message: `코드 재생성 실패: ${error instanceof Error ? error.message : String(error)}`,
168
+ };
169
+ }
170
+ }
171
+
172
+ async function correctSlotNotFound(
173
+ manifest: RoutesManifest,
174
+ rootDir: string
175
+ ): Promise<AutoCorrectStep> {
176
+ try {
177
+ const result = await generateRoutes(manifest, rootDir);
178
+
179
+ if (result.success) {
180
+ const slotCount = result.created.filter((f) => f.includes("slots")).length;
181
+ return {
182
+ ruleId: GUARD_RULES.SLOT_NOT_FOUND.id,
183
+ action: "generate-slot",
184
+ success: true,
185
+ message: `Slot 파일 생성 완료 (${slotCount}개 파일)`,
186
+ };
187
+ } else {
188
+ return {
189
+ ruleId: GUARD_RULES.SLOT_NOT_FOUND.id,
190
+ action: "generate-slot",
191
+ success: false,
192
+ message: `Slot 파일 생성 실패: ${result.errors.join(", ")}`,
193
+ };
194
+ }
195
+ } catch (error) {
196
+ return {
197
+ ruleId: GUARD_RULES.SLOT_NOT_FOUND.id,
198
+ action: "generate-slot",
199
+ success: false,
200
+ message: `Slot 파일 생성 실패: ${error instanceof Error ? error.message : String(error)}`,
201
+ };
202
+ }
203
+ }
@@ -120,6 +120,32 @@ export async function checkInvalidGeneratedImport(
120
120
  return violations;
121
121
  }
122
122
 
123
+ // Rule 5: Slot file existence check
124
+ export async function checkSlotFileExists(
125
+ manifest: RoutesManifest,
126
+ rootDir: string
127
+ ): Promise<GuardViolation[]> {
128
+ const violations: GuardViolation[] = [];
129
+
130
+ for (const route of manifest.routes) {
131
+ if (route.slotModule) {
132
+ const slotPath = path.join(rootDir, route.slotModule);
133
+ const exists = await fileExists(slotPath);
134
+
135
+ if (!exists) {
136
+ violations.push({
137
+ ruleId: GUARD_RULES.SLOT_NOT_FOUND.id,
138
+ file: route.slotModule,
139
+ message: `Slot 파일을 찾을 수 없습니다 (routeId: ${route.id})`,
140
+ suggestion: "bunx mandu generate를 실행하여 slot 파일을 생성하세요",
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ return violations;
147
+ }
148
+
123
149
  // Rule 4: Forbidden imports in generated files
124
150
  export async function checkForbiddenImportsInGenerated(
125
151
  rootDir: string,
@@ -218,6 +244,10 @@ export async function runGuardCheck(
218
244
  const importViolations = await checkInvalidGeneratedImport(rootDir);
219
245
  violations.push(...importViolations);
220
246
 
247
+ // Rule 5: Slot file existence
248
+ const slotViolations = await checkSlotFileExists(manifest, rootDir);
249
+ violations.push(...slotViolations);
250
+
221
251
  return {
222
252
  passed: violations.length === 0,
223
253
  violations,
@@ -1,2 +1,3 @@
1
1
  export * from "./rules";
2
2
  export * from "./check";
3
+ export * from "./auto-correct";
@@ -32,6 +32,11 @@ export const GUARD_RULES: Record<string, GuardRule> = {
32
32
  name: "Forbidden Import in Generated",
33
33
  description: "generated 파일에서 금지된 모듈을 import 했습니다",
34
34
  },
35
+ SLOT_NOT_FOUND: {
36
+ id: "SLOT_NOT_FOUND",
37
+ name: "Slot File Not Found",
38
+ description: "spec에 명시된 slotModule 파일을 찾을 수 없습니다",
39
+ },
35
40
  };
36
41
 
37
42
  export const FORBIDDEN_IMPORTS = ["fs", "child_process", "cluster", "worker_threads"];
@@ -10,6 +10,7 @@ export const RouteSpec = z
10
10
  kind: RouteKind,
11
11
  module: z.string().min(1, "module 경로는 필수입니다"),
12
12
  componentModule: z.string().optional(),
13
+ slotModule: z.string().optional(),
13
14
  })
14
15
  .refine(
15
16
  (route) => {