@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 +1 -1
- package/src/generator/generate.ts +30 -1
- package/src/generator/templates.ts +112 -0
- package/src/guard/auto-correct.ts +203 -0
- package/src/guard/check.ts +30 -0
- package/src/guard/index.ts +1 -0
- package/src/guard/rules.ts +5 -0
- package/src/spec/schema.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/guard/check.ts
CHANGED
|
@@ -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,
|
package/src/guard/index.ts
CHANGED
package/src/guard/rules.ts
CHANGED
|
@@ -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"];
|