@mandujs/core 0.1.0 → 0.2.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.md ADDED
@@ -0,0 +1,189 @@
1
+ # @mandujs/core
2
+
3
+ Mandu Framework Core - Spec, Generator, Guard, Runtime
4
+
5
+ ## 설치
6
+
7
+ ```bash
8
+ bun add @mandujs/core
9
+ ```
10
+
11
+ > 일반적으로 `@mandujs/cli`를 통해 사용합니다. 직접 사용은 고급 사용 사례입니다.
12
+
13
+ ## 모듈 구조
14
+
15
+ ```
16
+ @mandujs/core
17
+ ├── spec/ # Spec 스키마 및 로딩
18
+ ├── generator/ # 코드 생성
19
+ ├── guard/ # 아키텍처 검사 및 자동 수정
20
+ ├── runtime/ # 서버 및 라우터
21
+ └── report/ # Guard 리포트 생성
22
+ ```
23
+
24
+ ## Spec 모듈
25
+
26
+ 라우트 manifest 스키마 정의 및 로딩.
27
+
28
+ ```typescript
29
+ import { loadManifest, RoutesManifest, RouteSpec } from "@mandujs/core";
30
+
31
+ // manifest 로드 및 검증
32
+ const result = await loadManifest("spec/routes.manifest.json");
33
+
34
+ if (result.success && result.data) {
35
+ const manifest: RoutesManifest = result.data;
36
+ manifest.routes.forEach((route: RouteSpec) => {
37
+ console.log(route.id, route.pattern, route.kind);
38
+ });
39
+ }
40
+ ```
41
+
42
+ ### Lock 파일
43
+
44
+ ```typescript
45
+ import { writeLock, readLock } from "@mandujs/core";
46
+
47
+ // lock 파일 쓰기
48
+ const lock = await writeLock("spec/spec.lock.json", manifest);
49
+ console.log(lock.routesHash);
50
+
51
+ // lock 파일 읽기
52
+ const existing = await readLock("spec/spec.lock.json");
53
+ ```
54
+
55
+ ## Generator 모듈
56
+
57
+ Spec 기반 코드 생성.
58
+
59
+ ```typescript
60
+ import { generateRoutes, GenerateResult } from "@mandujs/core";
61
+
62
+ const result: GenerateResult = await generateRoutes(manifest, "./");
63
+
64
+ console.log("생성됨:", result.created);
65
+ console.log("건너뜀:", result.skipped); // 이미 존재하는 slot 파일
66
+ ```
67
+
68
+ ### 템플릿 함수
69
+
70
+ ```typescript
71
+ import {
72
+ generateApiHandler,
73
+ generateApiHandlerWithSlot,
74
+ generateSlotLogic,
75
+ generatePageComponent
76
+ } from "@mandujs/core";
77
+
78
+ // API 핸들러 생성
79
+ const code = generateApiHandler(route);
80
+
81
+ // Slot이 있는 API 핸들러
82
+ const codeWithSlot = generateApiHandlerWithSlot(route);
83
+
84
+ // Slot 로직 파일
85
+ const slotCode = generateSlotLogic(route);
86
+ ```
87
+
88
+ ## Guard 모듈
89
+
90
+ 아키텍처 규칙 검사 및 자동 수정.
91
+
92
+ ```typescript
93
+ import {
94
+ runGuardCheck,
95
+ runAutoCorrect,
96
+ GuardResult,
97
+ GuardViolation
98
+ } from "@mandujs/core";
99
+
100
+ // 검사 실행
101
+ const result: GuardResult = await runGuardCheck(manifest, "./");
102
+
103
+ if (!result.passed) {
104
+ result.violations.forEach((v: GuardViolation) => {
105
+ console.log(`${v.rule}: ${v.message}`);
106
+ });
107
+
108
+ // 자동 수정 실행
109
+ const corrected = await runAutoCorrect(result.violations, manifest, "./");
110
+ console.log("수정됨:", corrected.steps);
111
+ console.log("남은 위반:", corrected.remainingViolations);
112
+ }
113
+ ```
114
+
115
+ ### Guard 규칙
116
+
117
+ | 규칙 ID | 설명 | 자동 수정 |
118
+ |---------|------|----------|
119
+ | `SPEC_HASH_MISMATCH` | spec과 lock 해시 불일치 | ✅ |
120
+ | `GENERATED_MANUAL_EDIT` | generated 파일 수동 수정 | ✅ |
121
+ | `HANDLER_NOT_FOUND` | 핸들러 파일 없음 | ❌ |
122
+ | `COMPONENT_NOT_FOUND` | 컴포넌트 파일 없음 | ❌ |
123
+ | `SLOT_NOT_FOUND` | slot 파일 없음 | ✅ |
124
+
125
+ ## Runtime 모듈
126
+
127
+ 서버 시작 및 라우팅.
128
+
129
+ ```typescript
130
+ import {
131
+ startServer,
132
+ registerApiHandler,
133
+ registerPageLoader
134
+ } from "@mandujs/core";
135
+
136
+ // API 핸들러 등록
137
+ registerApiHandler("getUsers", async (req) => {
138
+ return { users: [] };
139
+ });
140
+
141
+ // 페이지 로더 등록
142
+ registerPageLoader("homePage", () => import("./pages/Home"));
143
+
144
+ // 서버 시작
145
+ const server = startServer(manifest, { port: 3000 });
146
+
147
+ // 종료
148
+ server.stop();
149
+ ```
150
+
151
+ ## Report 모듈
152
+
153
+ Guard 결과 리포트 생성.
154
+
155
+ ```typescript
156
+ import { buildGuardReport } from "@mandujs/core";
157
+
158
+ const report = buildGuardReport(guardResult, lockPath);
159
+ console.log(report); // 포맷된 텍스트 리포트
160
+ ```
161
+
162
+ ## 타입
163
+
164
+ ```typescript
165
+ import type {
166
+ RoutesManifest,
167
+ RouteSpec,
168
+ RouteKind,
169
+ SpecLock,
170
+ GuardResult,
171
+ GuardViolation,
172
+ GenerateResult,
173
+ AutoCorrectResult,
174
+ } from "@mandujs/core";
175
+ ```
176
+
177
+ ## 요구 사항
178
+
179
+ - Bun >= 1.0.0
180
+ - React >= 18.0.0
181
+ - Zod >= 3.0.0
182
+
183
+ ## 관련 패키지
184
+
185
+ - [@mandujs/cli](https://www.npmjs.com/package/@mandujs/cli) - CLI 도구
186
+
187
+ ## 라이선스
188
+
189
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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) => {