@mandujs/core 0.22.0 → 0.23.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,290 +1,357 @@
1
- import { z, ZodError, ZodIssueCode } from "zod";
2
- import path from "path";
3
- import { pathToFileURL } from "url";
4
- import { CONFIG_FILES, coerceConfig } from "./mandu";
5
- import { readJsonFile } from "../utils/bun";
6
- import type { ManduAdapter } from "../runtime/adapter";
7
- import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
8
-
9
- /**
10
- * DNA-003: Strict mode schema helper
11
- *
12
- * Creates a schema that warns about unknown keys instead of failing
13
- * This provides the benefits of .strict() while maintaining compatibility
14
- */
15
- function strictWithWarnings<T extends z.ZodRawShape>(
16
- schema: z.ZodObject<T>,
17
- schemaName: string
18
- ): z.ZodEffects<z.ZodObject<T>> {
19
- return schema.superRefine((data, ctx) => {
20
- if (typeof data !== "object" || data === null) return;
21
-
22
- const knownKeys = new Set(Object.keys(schema.shape));
23
- const unknownKeys = Object.keys(data).filter((key) => !knownKeys.has(key));
24
-
25
- if (unknownKeys.length > 0 && process.env.MANDU_STRICT !== "0") {
26
- // In strict mode (default), add warnings to issues
27
- for (const key of unknownKeys) {
28
- ctx.addIssue({
29
- code: ZodIssueCode.unrecognized_keys,
30
- keys: [key],
31
- message: `Unknown key '${key}' in ${schemaName}. Did you mean one of: ${[...knownKeys].join(", ")}?`,
32
- });
33
- }
34
- }
35
- });
36
- }
37
-
38
- /**
39
- * Server 설정 스키마 (strict)
40
- */
41
- const ServerConfigSchema = z
42
- .object({
43
- port: z.number().min(1).max(65535).default(3000),
44
- hostname: z.string().default("localhost"),
45
- cors: z
46
- .union([
47
- z.boolean(),
48
- z.object({
49
- origin: z.union([z.string(), z.array(z.string())]).optional(),
50
- methods: z.array(z.string()).optional(),
51
- credentials: z.boolean().optional(),
52
- }).strict(),
53
- ])
54
- .default(false),
55
- streaming: z.boolean().default(false),
56
- rateLimit: z
57
- .union([
58
- z.boolean(),
59
- z.object({
60
- windowMs: z.number().int().positive().optional(),
61
- max: z.number().int().positive().optional(),
62
- message: z.string().min(1).optional(),
63
- statusCode: z.number().int().min(400).max(599).optional(),
64
- headers: z.boolean().optional(),
65
- }).strict(),
66
- ])
67
- .default(false),
68
- })
69
- .strict();
70
-
71
- /**
72
- * Guard 설정 스키마 (strict)
73
- */
74
- const GuardConfigSchema = z
75
- .object({
76
- preset: z.enum(["mandu", "fsd", "clean", "hexagonal", "atomic", "cqrs"]).default("mandu"),
77
- srcDir: z.string().default("src"),
78
- exclude: z.array(z.string()).default([]),
79
- realtime: z.boolean().default(true),
80
- rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
81
- })
82
- .strict();
83
-
84
- /**
85
- * Build 설정 스키마 (strict)
86
- */
87
- const BuildConfigSchema = z
88
- .object({
89
- outDir: z.string().default(".mandu"),
90
- minify: z.boolean().default(true),
91
- sourcemap: z.boolean().default(false),
92
- splitting: z.boolean().default(false),
93
- })
94
- .strict();
95
-
96
- /**
97
- * Dev 설정 스키마 (strict)
98
- */
99
- const DevConfigSchema = z
100
- .object({
101
- hmr: z.boolean().default(true),
102
- watchDirs: z.array(z.string()).default([]),
103
- observability: z.boolean().default(true),
104
- })
105
- .strict();
106
-
107
- /**
108
- * FS Routes 설정 스키마 (strict)
109
- */
110
- const FsRoutesConfigSchema = z
111
- .object({
112
- routesDir: z.string().default("app"),
113
- extensions: z.array(z.string()).default([".tsx", ".ts", ".jsx", ".js"]),
114
- exclude: z.array(z.string()).default([]),
115
- islandSuffix: z.string().default(".island"),
116
- })
117
- .strict();
118
-
119
- /**
120
- * SEO 설정 스키마 (strict)
121
- */
122
- const SeoConfigSchema = z
123
- .object({
124
- enabled: z.boolean().default(true),
125
- defaultTitle: z.string().optional(),
126
- titleTemplate: z.string().optional(),
127
- })
128
- .strict();
129
-
130
- const AdapterConfigSchema = z.custom<ManduAdapter | undefined>(
131
- (value) =>
132
- value === undefined ||
133
- (typeof value === "object" &&
134
- value !== null &&
135
- typeof (value as { name?: unknown }).name === "string" &&
136
- typeof (value as { createServer?: unknown }).createServer === "function"),
137
- {
138
- message: "adapter must be a ManduAdapter with name and createServer()",
139
- }
140
- );
141
-
142
- /**
143
- * Mandu 설정 스키마 (DNA-003: strict mode)
144
- *
145
- * 알 수 없는 키가 있으면 오류 발생 → 오타 즉시 감지
146
- * MANDU_STRICT=0 으로 비활성화 가능
147
- */
148
- /**
149
- * Plugin schema — array of objects with `name` (string) and optional `hooks`/`setup`.
150
- * Validated structurally; hook functions are opaque to Zod.
151
- */
152
- const ManduPluginSchema = z.custom<ManduPlugin>(
153
- (v) =>
154
- typeof v === "object" &&
155
- v !== null &&
156
- typeof (v as { name?: unknown }).name === "string",
157
- { message: "Each plugin must be an object with a `name` string" }
158
- );
159
-
160
- const ManduHooksSchema = z.custom<Partial<ManduHooks>>(
161
- (v) => typeof v === "object" && v !== null,
162
- { message: "hooks must be an object" }
163
- );
164
-
165
- export const ManduConfigSchema = z
166
- .object({
167
- adapter: AdapterConfigSchema.optional(),
168
- server: ServerConfigSchema.default({}),
169
- guard: GuardConfigSchema.default({}),
170
- build: BuildConfigSchema.default({}),
171
- dev: DevConfigSchema.default({}),
172
- fsRoutes: FsRoutesConfigSchema.default({}),
173
- seo: SeoConfigSchema.default({}),
174
- plugins: z.array(ManduPluginSchema).optional(),
175
- hooks: ManduHooksSchema.optional(),
176
- })
177
- .strict();
178
-
179
- export type ValidatedManduConfig = z.infer<typeof ManduConfigSchema>;
180
-
181
- /**
182
- * 검증 결과
183
- */
184
- export interface ValidationResult {
185
- valid: boolean;
186
- config?: ValidatedManduConfig;
187
- errors?: Array<{
188
- path: string;
189
- message: string;
190
- }>;
191
- source?: string;
192
- }
193
-
194
- /**
195
- * Assertion function: narrows unknown config to ValidatedManduConfig or throws.
196
- *
197
- * Useful in code paths that receive untrusted config objects and need
198
- * to guarantee the type after the call without a separate null-check.
199
- */
200
- export function assertValidConfig(cfg: unknown): asserts cfg is ValidatedManduConfig {
201
- const result = ManduConfigSchema.safeParse(cfg);
202
- if (!result.success) {
203
- const messages = result.error.errors.map(
204
- (e) => `${e.path.join(".")}: ${e.message}`
205
- );
206
- throw new Error(`Invalid ManduConfig:\n ${messages.join("\n ")}`);
207
- }
208
- }
209
-
210
- /**
211
- * 설정 파일 검증
212
- */
213
- export async function validateConfig(rootDir: string): Promise<ValidationResult> {
214
- for (const fileName of CONFIG_FILES) {
215
- const filePath = path.join(rootDir, fileName);
216
- if (!(await Bun.file(filePath).exists())) {
217
- continue;
218
- }
219
-
220
- try {
221
- let raw: unknown;
222
- if (fileName.endsWith(".json")) {
223
- raw = await readJsonFile(filePath);
224
- } else {
225
- const module = await import(pathToFileURL(filePath).href);
226
- raw = module?.default ?? module;
227
- }
228
-
229
- const config = ManduConfigSchema.parse(coerceConfig(raw ?? {}, fileName));
230
- return { valid: true, config, source: fileName };
231
- } catch (error) {
232
- if (error instanceof ZodError) {
233
- const errors = error.errors.map((e) => ({
234
- path: e.path.join("."),
235
- message: e.message,
236
- }));
237
- return {
238
- valid: false,
239
- errors: [
240
- { path: "", message: `Config validation failed in '${filePath}'. Fix the following field errors:` },
241
- ...errors,
242
- ],
243
- source: fileName,
244
- };
245
- }
246
-
247
- // Differentiate file-not-found from parse/import errors
248
- const errMsg = error instanceof Error ? error.message : String(error);
249
- const isModuleError = errMsg.includes("Cannot find module") || errMsg.includes("MODULE_NOT_FOUND");
250
- const isSyntaxError = error instanceof SyntaxError || errMsg.includes("SyntaxError");
251
-
252
- let detail: string;
253
- if (isModuleError) {
254
- detail = `Could not resolve config file '${filePath}'. Check that the file exists and all its imports are installed.`;
255
- } else if (isSyntaxError) {
256
- detail = `Syntax error while parsing '${filePath}': ${errMsg}. Verify the file contains valid TypeScript/JSON.`;
257
- } else {
258
- detail = `Failed to load config from '${filePath}': ${errMsg}`;
259
- }
260
-
261
- return {
262
- valid: false,
263
- errors: [{ path: "", message: detail }],
264
- source: fileName,
265
- };
266
- }
267
- }
268
-
269
- // 설정 파일 없음 - 기본값 사용
270
- return { valid: true, config: ManduConfigSchema.parse({}) };
271
- }
272
-
273
- /**
274
- * CLI용 검증 및 리포트
275
- */
276
- export async function validateAndReport(rootDir: string): Promise<ValidatedManduConfig | null> {
277
- const result = await validateConfig(rootDir);
278
-
279
- if (!result.valid) {
280
- console.error(`\n❌ Invalid config${result.source ? ` (${result.source})` : ""}:\n`);
281
- for (const error of result.errors || []) {
282
- const location = error.path ? ` ${error.path}: ` : " ";
283
- console.error(`${location}${error.message}`);
284
- }
285
- console.error("");
286
- return null;
287
- }
288
-
289
- return result.config!;
290
- }
1
+ import { z, ZodError, ZodIssueCode } from "zod";
2
+ import path from "path";
3
+ import { pathToFileURL } from "url";
4
+ import { CONFIG_FILES, coerceConfig } from "./mandu";
5
+ import { readJsonFile } from "../utils/bun";
6
+ import type { ManduAdapter } from "../runtime/adapter";
7
+ import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
8
+
9
+ /**
10
+ * DNA-003: Strict mode schema helper
11
+ *
12
+ * Creates a schema that warns about unknown keys instead of failing
13
+ * This provides the benefits of .strict() while maintaining compatibility
14
+ */
15
+ function strictWithWarnings<T extends z.ZodRawShape>(
16
+ schema: z.ZodObject<T>,
17
+ schemaName: string
18
+ ): z.ZodEffects<z.ZodObject<T>> {
19
+ return schema.superRefine((data, ctx) => {
20
+ if (typeof data !== "object" || data === null) return;
21
+
22
+ const knownKeys = new Set(Object.keys(schema.shape));
23
+ const unknownKeys = Object.keys(data).filter((key) => !knownKeys.has(key));
24
+
25
+ if (unknownKeys.length > 0 && process.env.MANDU_STRICT !== "0") {
26
+ // In strict mode (default), add warnings to issues
27
+ for (const key of unknownKeys) {
28
+ ctx.addIssue({
29
+ code: ZodIssueCode.unrecognized_keys,
30
+ keys: [key],
31
+ message: `Unknown key '${key}' in ${schemaName}. Did you mean one of: ${[...knownKeys].join(", ")}?`,
32
+ });
33
+ }
34
+ }
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Server 설정 스키마 (strict)
40
+ */
41
+ const ServerConfigSchema = z
42
+ .object({
43
+ port: z.number().min(1).max(65535).default(3000),
44
+ hostname: z.string().default("localhost"),
45
+ cors: z
46
+ .union([
47
+ z.boolean(),
48
+ z.object({
49
+ origin: z.union([z.string(), z.array(z.string())]).optional(),
50
+ methods: z.array(z.string()).optional(),
51
+ credentials: z.boolean().optional(),
52
+ }).strict(),
53
+ ])
54
+ .default(false),
55
+ streaming: z.boolean().default(false),
56
+ rateLimit: z
57
+ .union([
58
+ z.boolean(),
59
+ z.object({
60
+ windowMs: z.number().int().positive().optional(),
61
+ max: z.number().int().positive().optional(),
62
+ message: z.string().min(1).optional(),
63
+ statusCode: z.number().int().min(400).max(599).optional(),
64
+ headers: z.boolean().optional(),
65
+ }).strict(),
66
+ ])
67
+ .default(false),
68
+ })
69
+ .strict();
70
+
71
+ /**
72
+ * Guard 설정 스키마 (strict)
73
+ */
74
+ const GuardConfigSchema = z
75
+ .object({
76
+ preset: z.enum(["mandu", "fsd", "clean", "hexagonal", "atomic", "cqrs"]).default("mandu"),
77
+ srcDir: z.string().default("src"),
78
+ exclude: z.array(z.string()).default([]),
79
+ realtime: z.boolean().default(true),
80
+ rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
81
+ })
82
+ .strict();
83
+
84
+ /**
85
+ * Build 설정 스키마 (strict)
86
+ */
87
+ const BuildConfigSchema = z
88
+ .object({
89
+ outDir: z.string().default(".mandu"),
90
+ minify: z.boolean().default(true),
91
+ sourcemap: z.boolean().default(false),
92
+ splitting: z.boolean().default(false),
93
+ })
94
+ .strict();
95
+
96
+ /**
97
+ * Dev 설정 스키마 (strict)
98
+ */
99
+ const DevConfigSchema = z
100
+ .object({
101
+ hmr: z.boolean().default(true),
102
+ watchDirs: z.array(z.string()).default([]),
103
+ observability: z.boolean().default(true),
104
+ })
105
+ .strict();
106
+
107
+ /**
108
+ * FS Routes 설정 스키마 (strict)
109
+ */
110
+ const FsRoutesConfigSchema = z
111
+ .object({
112
+ routesDir: z.string().default("app"),
113
+ extensions: z.array(z.string()).default([".tsx", ".ts", ".jsx", ".js"]),
114
+ exclude: z.array(z.string()).default([]),
115
+ islandSuffix: z.string().default(".island"),
116
+ })
117
+ .strict();
118
+
119
+ /**
120
+ * SEO 설정 스키마 (strict)
121
+ */
122
+ const SeoConfigSchema = z
123
+ .object({
124
+ enabled: z.boolean().default(true),
125
+ defaultTitle: z.string().optional(),
126
+ titleTemplate: z.string().optional(),
127
+ })
128
+ .strict();
129
+
130
+ /**
131
+ * Test 설정 스키마 (Phase 12.1 — strict)
132
+ *
133
+ * `.strict()` is applied at every nested object so stale / misspelt keys
134
+ * are caught at config-load time, not when the CLI trips over them. Each
135
+ * default mirrors the TypeScript documentation in `./mandu.ts`.
136
+ */
137
+ const TestUnitConfigSchema = z
138
+ .object({
139
+ include: z.array(z.string().min(1)).default(["**/*.test.ts", "**/*.test.tsx"]),
140
+ exclude: z
141
+ .array(z.string().min(1))
142
+ .default(["node_modules/**", ".mandu/**", "dist/**"]),
143
+ timeout: z.number().int().positive().default(30_000),
144
+ })
145
+ .strict();
146
+
147
+ const TestIntegrationConfigSchema = z
148
+ .object({
149
+ include: z
150
+ .array(z.string().min(1))
151
+ .default(["tests/integration/**/*.test.ts", "tests/integration/**/*.test.tsx"]),
152
+ exclude: z
153
+ .array(z.string().min(1))
154
+ .default(["node_modules/**", ".mandu/**", "dist/**"]),
155
+ dbUrl: z.string().min(1).default("sqlite::memory:"),
156
+ sessionStore: z.enum(["memory", "sqlite"]).default("memory"),
157
+ timeout: z.number().int().positive().default(60_000),
158
+ })
159
+ .strict();
160
+
161
+ const TestE2EConfigSchema = z
162
+ .object({
163
+ reserved: z.literal(true).optional(),
164
+ })
165
+ .strict();
166
+
167
+ const TestCoverageConfigSchema = z
168
+ .object({
169
+ lines: z.number().min(0).max(100).optional(),
170
+ branches: z.number().min(0).max(100).optional(),
171
+ })
172
+ .strict();
173
+
174
+ const TestConfigSchema = z
175
+ .object({
176
+ unit: TestUnitConfigSchema.default({}),
177
+ integration: TestIntegrationConfigSchema.default({}),
178
+ e2e: TestE2EConfigSchema.default({}),
179
+ coverage: TestCoverageConfigSchema.default({}),
180
+ })
181
+ .strict();
182
+
183
+ const AdapterConfigSchema = z.custom<ManduAdapter | undefined>(
184
+ (value) =>
185
+ value === undefined ||
186
+ (typeof value === "object" &&
187
+ value !== null &&
188
+ typeof (value as { name?: unknown }).name === "string" &&
189
+ typeof (value as { createServer?: unknown }).createServer === "function"),
190
+ {
191
+ message: "adapter must be a ManduAdapter with name and createServer()",
192
+ }
193
+ );
194
+
195
+ /**
196
+ * Mandu 설정 스키마 (DNA-003: strict mode)
197
+ *
198
+ * 없는 키가 있으면 오류 발생 오타 즉시 감지
199
+ * MANDU_STRICT=0 으로 비활성화 가능
200
+ */
201
+ /**
202
+ * Plugin schema — array of objects with `name` (string) and optional `hooks`/`setup`.
203
+ * Validated structurally; hook functions are opaque to Zod.
204
+ */
205
+ const ManduPluginSchema = z.custom<ManduPlugin>(
206
+ (v) =>
207
+ typeof v === "object" &&
208
+ v !== null &&
209
+ typeof (v as { name?: unknown }).name === "string",
210
+ { message: "Each plugin must be an object with a `name` string" }
211
+ );
212
+
213
+ const ManduHooksSchema = z.custom<Partial<ManduHooks>>(
214
+ (v) => typeof v === "object" && v !== null,
215
+ { message: "hooks must be an object" }
216
+ );
217
+
218
+ export const ManduConfigSchema = z
219
+ .object({
220
+ adapter: AdapterConfigSchema.optional(),
221
+ server: ServerConfigSchema.default({}),
222
+ guard: GuardConfigSchema.default({}),
223
+ build: BuildConfigSchema.default({}),
224
+ dev: DevConfigSchema.default({}),
225
+ fsRoutes: FsRoutesConfigSchema.default({}),
226
+ seo: SeoConfigSchema.default({}),
227
+ test: TestConfigSchema.default({}),
228
+ plugins: z.array(ManduPluginSchema).optional(),
229
+ hooks: ManduHooksSchema.optional(),
230
+ })
231
+ .strict();
232
+
233
+ export type ValidatedManduConfig = z.infer<typeof ManduConfigSchema>;
234
+
235
+ /** Validated `test` block (convenience re-export for fixtures/CLI). */
236
+ export type ValidatedTestConfig = z.infer<typeof TestConfigSchema>;
237
+
238
+ /**
239
+ * Resolve the `test` block with defaults filled in.
240
+ *
241
+ * Use this from fixtures / CLI test runners that need a guaranteed-shaped
242
+ * object without having to validate the whole config.
243
+ */
244
+ export function resolveTestConfig(raw?: unknown): ValidatedTestConfig {
245
+ return TestConfigSchema.parse(raw ?? {});
246
+ }
247
+
248
+ /**
249
+ * 검증 결과
250
+ */
251
+ export interface ValidationResult {
252
+ valid: boolean;
253
+ config?: ValidatedManduConfig;
254
+ errors?: Array<{
255
+ path: string;
256
+ message: string;
257
+ }>;
258
+ source?: string;
259
+ }
260
+
261
+ /**
262
+ * Assertion function: narrows unknown config to ValidatedManduConfig or throws.
263
+ *
264
+ * Useful in code paths that receive untrusted config objects and need
265
+ * to guarantee the type after the call without a separate null-check.
266
+ */
267
+ export function assertValidConfig(cfg: unknown): asserts cfg is ValidatedManduConfig {
268
+ const result = ManduConfigSchema.safeParse(cfg);
269
+ if (!result.success) {
270
+ const messages = result.error.errors.map(
271
+ (e) => `${e.path.join(".")}: ${e.message}`
272
+ );
273
+ throw new Error(`Invalid ManduConfig:\n ${messages.join("\n ")}`);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * 설정 파일 검증
279
+ */
280
+ export async function validateConfig(rootDir: string): Promise<ValidationResult> {
281
+ for (const fileName of CONFIG_FILES) {
282
+ const filePath = path.join(rootDir, fileName);
283
+ if (!(await Bun.file(filePath).exists())) {
284
+ continue;
285
+ }
286
+
287
+ try {
288
+ let raw: unknown;
289
+ if (fileName.endsWith(".json")) {
290
+ raw = await readJsonFile(filePath);
291
+ } else {
292
+ const module = await import(pathToFileURL(filePath).href);
293
+ raw = module?.default ?? module;
294
+ }
295
+
296
+ const config = ManduConfigSchema.parse(coerceConfig(raw ?? {}, fileName));
297
+ return { valid: true, config, source: fileName };
298
+ } catch (error) {
299
+ if (error instanceof ZodError) {
300
+ const errors = error.errors.map((e) => ({
301
+ path: e.path.join("."),
302
+ message: e.message,
303
+ }));
304
+ return {
305
+ valid: false,
306
+ errors: [
307
+ { path: "", message: `Config validation failed in '${filePath}'. Fix the following field errors:` },
308
+ ...errors,
309
+ ],
310
+ source: fileName,
311
+ };
312
+ }
313
+
314
+ // Differentiate file-not-found from parse/import errors
315
+ const errMsg = error instanceof Error ? error.message : String(error);
316
+ const isModuleError = errMsg.includes("Cannot find module") || errMsg.includes("MODULE_NOT_FOUND");
317
+ const isSyntaxError = error instanceof SyntaxError || errMsg.includes("SyntaxError");
318
+
319
+ let detail: string;
320
+ if (isModuleError) {
321
+ detail = `Could not resolve config file '${filePath}'. Check that the file exists and all its imports are installed.`;
322
+ } else if (isSyntaxError) {
323
+ detail = `Syntax error while parsing '${filePath}': ${errMsg}. Verify the file contains valid TypeScript/JSON.`;
324
+ } else {
325
+ detail = `Failed to load config from '${filePath}': ${errMsg}`;
326
+ }
327
+
328
+ return {
329
+ valid: false,
330
+ errors: [{ path: "", message: detail }],
331
+ source: fileName,
332
+ };
333
+ }
334
+ }
335
+
336
+ // 설정 파일 없음 - 기본값 사용
337
+ return { valid: true, config: ManduConfigSchema.parse({}) };
338
+ }
339
+
340
+ /**
341
+ * CLI용 검증 및 리포트
342
+ */
343
+ export async function validateAndReport(rootDir: string): Promise<ValidatedManduConfig | null> {
344
+ const result = await validateConfig(rootDir);
345
+
346
+ if (!result.valid) {
347
+ console.error(`\n❌ Invalid config${result.source ? ` (${result.source})` : ""}:\n`);
348
+ for (const error of result.errors || []) {
349
+ const location = error.path ? ` ${error.path}: ` : " ";
350
+ console.error(`${location}${error.message}`);
351
+ }
352
+ console.error("");
353
+ return null;
354
+ }
355
+
356
+ return result.config!;
357
+ }