@mandujs/core 0.18.20 → 0.18.22

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.
Files changed (91) hide show
  1. package/package.json +3 -1
  2. package/src/brain/architecture/analyzer.ts +3 -5
  3. package/src/brain/architecture/types.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +1 -0
  5. package/src/brain/doctor/index.ts +1 -1
  6. package/src/brain/doctor/patcher.ts +10 -6
  7. package/src/brain/doctor/reporter.ts +4 -4
  8. package/src/brain/types.ts +14 -10
  9. package/src/bundler/build.ts +17 -17
  10. package/src/bundler/css.ts +3 -2
  11. package/src/bundler/dev.ts +1 -1
  12. package/src/client/island.ts +10 -9
  13. package/src/client/router.ts +1 -1
  14. package/src/config/mcp-ref.ts +6 -6
  15. package/src/config/metadata.test.ts +1 -1
  16. package/src/config/metadata.ts +36 -16
  17. package/src/config/symbols.ts +1 -1
  18. package/src/config/validate.ts +17 -1
  19. package/src/content/content.test.ts +3 -3
  20. package/src/content/loaders/file.ts +3 -0
  21. package/src/content/loaders/glob.ts +1 -0
  22. package/src/contract/client-safe.test.ts +1 -1
  23. package/src/contract/client.test.ts +2 -1
  24. package/src/contract/client.ts +18 -18
  25. package/src/contract/define.ts +32 -17
  26. package/src/contract/handler.ts +11 -11
  27. package/src/contract/index.ts +2 -5
  28. package/src/contract/infer.test.ts +2 -1
  29. package/src/contract/normalize.test.ts +1 -1
  30. package/src/contract/normalize.ts +17 -11
  31. package/src/contract/registry.test.ts +1 -1
  32. package/src/contract/zod-utils.ts +155 -0
  33. package/src/devtools/client/catchers/error-catcher.ts +3 -3
  34. package/src/devtools/client/catchers/network-proxy.ts +5 -1
  35. package/src/devtools/client/components/kitchen-root.tsx +2 -2
  36. package/src/devtools/client/components/panel/guard-panel.tsx +3 -3
  37. package/src/devtools/client/state-manager.ts +9 -9
  38. package/src/devtools/index.ts +8 -8
  39. package/src/devtools/init.ts +2 -2
  40. package/src/devtools/protocol.ts +4 -4
  41. package/src/devtools/server/source-context.ts +9 -3
  42. package/src/devtools/types.ts +5 -5
  43. package/src/devtools/worker/redaction-worker.ts +12 -5
  44. package/src/error/index.ts +1 -1
  45. package/src/error/result.ts +14 -0
  46. package/src/filling/deps.ts +5 -2
  47. package/src/filling/filling.ts +1 -1
  48. package/src/generator/templates.ts +2 -2
  49. package/src/guard/contract-guard.test.ts +1 -0
  50. package/src/guard/file-type.test.ts +1 -1
  51. package/src/guard/index.ts +1 -1
  52. package/src/guard/negotiation.ts +29 -1
  53. package/src/guard/presets/index.ts +3 -0
  54. package/src/guard/semantic-slots.ts +4 -4
  55. package/src/index.ts +10 -1
  56. package/src/intent/index.ts +28 -17
  57. package/src/island/index.ts +8 -8
  58. package/src/openapi/generator.ts +49 -31
  59. package/src/plugins/index.ts +1 -1
  60. package/src/plugins/registry.ts +28 -18
  61. package/src/plugins/types.ts +2 -2
  62. package/src/resource/__tests__/backward-compat.test.ts +2 -2
  63. package/src/resource/__tests__/edge-cases.test.ts +14 -13
  64. package/src/resource/__tests__/fixtures.ts +2 -2
  65. package/src/resource/__tests__/generator.test.ts +1 -1
  66. package/src/resource/__tests__/performance.test.ts +8 -6
  67. package/src/resource/schema.ts +1 -1
  68. package/src/router/fs-routes.ts +34 -40
  69. package/src/router/fs-types.ts +2 -2
  70. package/src/router/index.ts +1 -1
  71. package/src/runtime/boundary.tsx +4 -4
  72. package/src/runtime/logger.test.ts +3 -3
  73. package/src/runtime/logger.ts +1 -1
  74. package/src/runtime/server.ts +18 -16
  75. package/src/runtime/ssr.ts +1 -1
  76. package/src/runtime/stable-selector.ts +1 -2
  77. package/src/runtime/streaming-ssr.ts +15 -6
  78. package/src/seo/index.ts +5 -0
  79. package/src/seo/integration/ssr.ts +4 -4
  80. package/src/seo/render/basic.ts +12 -4
  81. package/src/seo/render/opengraph.ts +12 -6
  82. package/src/seo/render/twitter.ts +3 -2
  83. package/src/seo/resolve/url.ts +7 -0
  84. package/src/seo/types.ts +13 -0
  85. package/src/spec/schema.ts +89 -61
  86. package/src/types/branded.ts +56 -0
  87. package/src/types/index.ts +1 -0
  88. package/src/utils/hasher.test.ts +6 -6
  89. package/src/utils/hasher.ts +2 -2
  90. package/src/utils/index.ts +1 -1
  91. package/src/watcher/watcher.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.18.20",
3
+ "version": "0.18.22",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -8,6 +8,8 @@
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./client": "./src/client/index.ts",
11
+ "./plugins": "./src/plugins/index.ts",
12
+ "./error": "./src/error/index.ts",
11
13
  "./*": "./src/*"
12
14
  },
13
15
  "files": [
@@ -510,15 +510,13 @@ ${JSON.stringify(this.config.folders, null, 2)}
510
510
  짧고 명확하게 답변하세요 (3줄 이내).`;
511
511
 
512
512
  try {
513
- const result = await brain.complete([
514
- { role: "user", content: prompt },
515
- ]);
513
+ const result = await brain.generate(prompt);
516
514
 
517
515
  // 응답에서 경로 추출 시도
518
- const pathMatch = result.content.match(/(?:spec\/|\.mandu\/|app\/|src\/|packages\/)[^\s,)]+/);
516
+ const pathMatch = result.match(/(?:spec\/|\.mandu\/|app\/|src\/|packages\/)[^\s,)]+/);
519
517
 
520
518
  return {
521
- suggestion: result.content,
519
+ suggestion: result,
522
520
  recommendedPath: pathMatch?.[0],
523
521
  };
524
522
  } catch {
@@ -39,7 +39,7 @@ export interface ImportRule {
39
39
  /**
40
40
  * 레이어 의존성 규칙
41
41
  */
42
- export interface LayerRule {
42
+ export interface ArchLayerRule {
43
43
  /** 레이어 이름 */
44
44
  name: string;
45
45
  /** 레이어에 속하는 폴더 패턴 */
@@ -73,17 +73,17 @@ export interface ArchitectureConfig {
73
73
  /** Import 규칙 */
74
74
  imports?: ImportRule[];
75
75
  /** 레이어 규칙 */
76
- layers?: LayerRule[];
76
+ layers?: ArchLayerRule[];
77
77
  /** 네이밍 규칙 */
78
78
  naming?: NamingRule[];
79
79
  /** 커스텀 규칙 */
80
- custom?: CustomRule[];
80
+ custom?: ArchCustomRule[];
81
81
  }
82
82
 
83
83
  /**
84
84
  * 커스텀 규칙
85
85
  */
86
- export interface CustomRule {
86
+ export interface ArchCustomRule {
87
87
  /** 규칙 ID */
88
88
  id: string;
89
89
  /** 규칙 설명 */
@@ -224,6 +224,7 @@ export function generateTemplatePatches(
224
224
  file: violation.file,
225
225
  description: violation.suggestion,
226
226
  type: "modify",
227
+ content: "",
227
228
  confidence: 0.4,
228
229
  });
229
230
  }
@@ -30,7 +30,7 @@ export {
30
30
  } from "./patcher";
31
31
 
32
32
  export {
33
- formatViolation,
33
+ formatDoctorViolation,
34
34
  formatPatch,
35
35
  printDoctorReport,
36
36
  generateJsonReport,
@@ -66,7 +66,7 @@ export function deduplicatePatches(patches: PatchSuggestion[]): PatchSuggestion[
66
66
  const result: PatchSuggestion[] = [];
67
67
 
68
68
  for (const patch of patches) {
69
- const key = `${patch.file}:${patch.type}:${patch.command || ""}`;
69
+ const key = `${patch.file}:${patch.type}:${patch.type === "command" ? patch.command : ""}`;
70
70
  if (!seen.has(key)) {
71
71
  seen.add(key);
72
72
  result.push(patch);
@@ -103,8 +103,10 @@ export function generatePatchDescription(patch: PatchSuggestion): string {
103
103
  case "delete":
104
104
  return `[파일 삭제] ${patch.file}\n 설명: ${patch.description}\n 신뢰도: ${confidenceLabel}`;
105
105
 
106
- default:
107
- return `[${patch.type}] ${patch.file}\n 설명: ${patch.description}`;
106
+ default: {
107
+ const _exhaustive: never = patch;
108
+ return `[unknown] ${(_exhaustive as PatchSuggestion).file}\n 설명: ${(_exhaustive as PatchSuggestion).description}`;
109
+ }
108
110
  }
109
111
  }
110
112
 
@@ -229,12 +231,14 @@ export async function applyPatch(
229
231
  }
230
232
  }
231
233
 
232
- default:
234
+ default: {
235
+ const _exhaustive: never = patch;
233
236
  return {
234
237
  applied: false,
235
- patch,
236
- error: `Unknown patch type: ${patch.type}`,
238
+ patch: _exhaustive as PatchSuggestion,
239
+ error: `Unknown patch type`,
237
240
  };
241
+ }
238
242
  }
239
243
  } catch (error) {
240
244
  return {
@@ -50,7 +50,7 @@ function color(text: string, colorCode: string): string {
50
50
  /**
51
51
  * Format a violation for terminal output
52
52
  */
53
- export function formatViolation(violation: GuardViolation): string {
53
+ export function formatDoctorViolation(violation: GuardViolation): string {
54
54
  const lines: string[] = [];
55
55
 
56
56
  const severity = violation.severity || "error";
@@ -137,7 +137,7 @@ export function printDoctorReport(analysis: DoctorAnalysis): void {
137
137
  console.log();
138
138
 
139
139
  for (const violation of violations) {
140
- console.log(formatViolation(violation));
140
+ console.log(formatDoctorViolation(violation));
141
141
  console.log();
142
142
  }
143
143
  }
@@ -210,7 +210,7 @@ export function generateJsonReport(analysis: DoctorAnalysis): string {
210
210
  file: p.file,
211
211
  type: p.type,
212
212
  description: p.description,
213
- command: p.command,
213
+ ...(p.type === "command" ? { command: p.command } : {}),
214
214
  confidence: p.confidence,
215
215
  })),
216
216
  nextCommand: analysis.nextCommand,
@@ -281,7 +281,7 @@ export function generateDoctorMarkdownReport(analysis: DoctorAnalysis): string {
281
281
  lines.push(`- **대상**: \`${p.file}\``);
282
282
  lines.push(`- **신뢰도**: ${confidenceLabel}`);
283
283
 
284
- if (p.command) {
284
+ if (p.type === "command") {
285
285
  lines.push("");
286
286
  lines.push("```bash");
287
287
  lines.push(p.command);
@@ -73,25 +73,29 @@ export interface AdapterConfig {
73
73
  // ========== Doctor Types ==========
74
74
 
75
75
  /**
76
- * Patch suggestion from Doctor
76
+ * Patch suggestion from Doctor (discriminated union by type)
77
+ *
78
+ * type별로 필요한 필드가 타입 레벨에서 강제됨:
79
+ * - add: content 필수
80
+ * - modify: content 필수, line optional
81
+ * - delete: file + confidence만 필요
82
+ * - command: command 필수
77
83
  */
78
- export interface PatchSuggestion {
84
+ interface PatchSuggestionBase {
79
85
  /** Target file path */
80
86
  file: string;
81
87
  /** Description of the change */
82
88
  description: string;
83
- /** Type of patch */
84
- type: "add" | "modify" | "delete" | "command";
85
- /** Content to add/modify (for add/modify types) */
86
- content?: string;
87
- /** Line number to modify (for modify type) */
88
- line?: number;
89
- /** Command to run (for command type) */
90
- command?: string;
91
89
  /** Confidence level (0-1) */
92
90
  confidence: number;
93
91
  }
94
92
 
93
+ export type PatchSuggestion =
94
+ | (PatchSuggestionBase & { type: "add"; content: string })
95
+ | (PatchSuggestionBase & { type: "modify"; content: string; line?: number })
96
+ | (PatchSuggestionBase & { type: "delete" })
97
+ | (PatchSuggestionBase & { type: "command"; command: string });
98
+
95
99
  /**
96
100
  * Doctor analysis result
97
101
  */
@@ -929,14 +929,15 @@ async function buildRuntime(
929
929
  outputPath: `/.mandu/client/${outputName}`,
930
930
  errors: [],
931
931
  };
932
- } catch (error: any) {
932
+ } catch (error: unknown) {
933
933
  // 예외 발생 시에도 디버깅을 위해 소스 파일을 남겨둠
934
934
  const extra: string[] = [];
935
- if (error?.errors && Array.isArray(error.errors)) {
936
- extra.push(...error.errors.map((e: any) => String(e?.message || e)));
935
+ const errObj = error as Record<string, unknown> | null;
936
+ if (errObj && Array.isArray(errObj.errors)) {
937
+ extra.push(...errObj.errors.map((e: unknown) => String((e as Record<string, unknown>)?.message || e)));
937
938
  }
938
- if (error?.logs && Array.isArray(error.logs)) {
939
- extra.push(...error.logs.map((l: any) => String(l?.message || l)));
939
+ if (errObj && Array.isArray(errObj.logs)) {
940
+ extra.push(...errObj.logs.map((l: unknown) => String((l as Record<string, unknown>)?.message || l)));
940
941
  }
941
942
 
942
943
  return {
@@ -1328,19 +1329,18 @@ export async function buildClientBundles(
1328
1329
  }
1329
1330
 
1330
1331
  // 3-4. Runtime, Router, Vendor, DevTools 번들 병렬 빌드 (서로 독립적)
1331
- const buildPromises: Promise<any>[] = [
1332
- buildRuntime(outDir, options),
1333
- buildRouterRuntime(outDir, options),
1334
- buildVendorShims(outDir, options),
1335
- ];
1336
-
1337
- // DevTools 번들은 dev 모드에서만 빌드
1338
1332
  const isDev = env === "development";
1339
- if (isDev) {
1340
- buildPromises.push(buildDevtoolsBundle(outDir, options));
1341
- }
1342
-
1343
- const [runtimeResult, routerResult, vendorResult, devtoolsResult] = await Promise.all(buildPromises);
1333
+ const runtimePromise = buildRuntime(outDir, options);
1334
+ const routerPromise = buildRouterRuntime(outDir, options);
1335
+ const vendorPromise = buildVendorShims(outDir, options);
1336
+ const devtoolsPromise = isDev ? buildDevtoolsBundle(outDir, options) : null;
1337
+
1338
+ const [runtimeResult, routerResult, vendorResult, devtoolsResult] = await Promise.all([
1339
+ runtimePromise,
1340
+ routerPromise,
1341
+ vendorPromise,
1342
+ devtoolsPromise,
1343
+ ]);
1344
1344
 
1345
1345
  if (!runtimeResult.success) {
1346
1346
  errors.push(...runtimeResult.errors.map((e: string) => `[Runtime] ${e}`));
@@ -11,6 +11,7 @@
11
11
  import { spawn, type Subprocess } from "bun";
12
12
  import path from "path";
13
13
  import fs from "fs/promises";
14
+ import { watch as fsWatch, type FSWatcher } from "fs";
14
15
 
15
16
  // ========== Types ==========
16
17
 
@@ -223,12 +224,12 @@ export async function startCSSWatch(options: CSSBuildOptions): Promise<CSSWatche
223
224
 
224
225
  // 출력 파일 워처로 빌드 완료 감지 (stdout 패턴보다 신뢰성 높음, #111)
225
226
  // Tailwind CLI stdout 출력 형식은 버전마다 달라질 수 있으므로 파일 변경으로 감지
226
- let fsWatcher: ReturnType<typeof fs.watch> | null = null;
227
+ let fsWatcher: FSWatcher | null = null;
227
228
  let lastMtime = 0;
228
229
 
229
230
  const startFileWatcher = () => {
230
231
  try {
231
- fsWatcher = fs.watch(outputPath, () => {
232
+ fsWatcher = fsWatch(outputPath, () => {
232
233
  // 연속 이벤트 중복 방지 (50ms 이내 재발생 무시)
233
234
  const now = Date.now();
234
235
  if (now - lastMtime < 50) return;
@@ -396,7 +396,7 @@ export interface HMRMessage {
396
396
  * HMR WebSocket 서버 생성
397
397
  */
398
398
  export function createHMRServer(port: number): HMRServer {
399
- const clients = new Set<any>();
399
+ const clients = new Set<{ send: (data: string) => void; close: () => void }>();
400
400
  const hmrPort = port + PORTS.HMR_OFFSET;
401
401
  let restartHandler: (() => Promise<void>) | null = null;
402
402
 
@@ -3,8 +3,8 @@
3
3
  * Hydration을 위한 클라이언트 사이드 컴포넌트 정의
4
4
  */
5
5
 
6
- import type { ReactNode } from "react";
7
- import { getServerData as getGlobalServerData } from "./window-state";
6
+ import type { ReactNode } from "react";
7
+ import { getServerData as getGlobalServerData } from "./window-state";
8
8
 
9
9
  /**
10
10
  * Island 정의 타입
@@ -118,12 +118,12 @@ export function island<TServerData, TSetupResult = TServerData>(
118
118
  * SSR 데이터에 안전하게 접근하는 훅
119
119
  * 서버 데이터가 없는 경우 fallback 반환
120
120
  */
121
- export function useServerData<T>(key: string, fallback: T): T {
122
- if (typeof window === "undefined") return fallback;
123
-
124
- const data = getGlobalServerData<T>(key);
125
- return data === undefined ? fallback : data;
126
- }
121
+ export function useServerData<T>(key: string, fallback: T): T {
122
+ if (typeof window === "undefined") return fallback;
123
+
124
+ const data = getGlobalServerData<T>(key);
125
+ return data === undefined ? fallback : data;
126
+ }
127
127
 
128
128
  /**
129
129
  * Hydration 상태를 추적하는 훅
@@ -171,7 +171,7 @@ export function useIslandEvent<T = unknown>(
171
171
  handler: (data: T) => void
172
172
  ): IslandEventHandle<T>["emit"] & IslandEventHandle<T> {
173
173
  if (typeof window === "undefined") {
174
- const noop = (() => {}) as IslandEventHandle<T>["emit"] & IslandEventHandle<T>;
174
+ const noop = (() => {}) as unknown as IslandEventHandle<T>["emit"] & IslandEventHandle<T>;
175
175
  noop.emit = noop;
176
176
  noop.cleanup = () => {};
177
177
  return noop;
@@ -473,6 +473,7 @@ export interface PartialGroup {
473
473
  * ```
474
474
  */
475
475
  export function createPartialGroup(): PartialGroup {
476
+ // React ComponentType variance requires `any` for heterogeneous component storage
476
477
  const partials = new Map<string, CompiledPartial<any>>();
477
478
 
478
479
  return {
@@ -421,7 +421,7 @@ export function cleanupRouter(): void {
421
421
 
422
422
  window.removeEventListener("popstate", handlePopState);
423
423
  document.removeEventListener("click", handleLinkClick);
424
- listeners.clear();
424
+ listeners.current.clear();
425
425
  initialized = false;
426
426
  }
427
427
 
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { z } from "zod";
20
- import { withMetadata, withMetadataMultiple } from "./metadata.js";
20
+ import { withMetadata, withMetadataMultiple, getMetadata, hasMetadata } from "./metadata.js";
21
21
  import {
22
22
  SCHEMA_REFERENCE,
23
23
  SENSITIVE_FIELD,
@@ -313,7 +313,7 @@ export function createMcpServerSchemaWithSecrets() {
313
313
  * 스키마가 MCP 서버 참조인지 확인
314
314
  */
315
315
  export function isMcpServerRef(schema: z.ZodType): boolean {
316
- const ref = (schema as any)[SCHEMA_REFERENCE] as SchemaReferenceMetadata | undefined;
316
+ const ref = getMetadata(schema, SCHEMA_REFERENCE);
317
317
  return ref?.type === "mcpServer";
318
318
  }
319
319
 
@@ -321,7 +321,7 @@ export function isMcpServerRef(schema: z.ZodType): boolean {
321
321
  * 스키마에서 MCP 서버 이름 추출
322
322
  */
323
323
  export function getMcpServerName(schema: z.ZodType): string | undefined {
324
- const ref = (schema as any)[SCHEMA_REFERENCE] as SchemaReferenceMetadata | undefined;
324
+ const ref = getMetadata(schema, SCHEMA_REFERENCE);
325
325
  return ref?.type === "mcpServer" ? ref.name : undefined;
326
326
  }
327
327
 
@@ -329,20 +329,20 @@ export function getMcpServerName(schema: z.ZodType): string | undefined {
329
329
  * 스키마가 민감 필드인지 확인
330
330
  */
331
331
  export function isSensitiveField(schema: z.ZodType): boolean {
332
- return SENSITIVE_FIELD in (schema as any);
332
+ return hasMetadata(schema, SENSITIVE_FIELD);
333
333
  }
334
334
 
335
335
  /**
336
336
  * 스키마가 보호된 필드인지 확인
337
337
  */
338
338
  export function isProtectedField(schema: z.ZodType): boolean {
339
- return PROTECTED_FIELD in (schema as any);
339
+ return hasMetadata(schema, PROTECTED_FIELD);
340
340
  }
341
341
 
342
342
  /**
343
343
  * 스키마가 환경 변수 기반인지 확인
344
344
  */
345
345
  export function isEnvBasedField(schema: z.ZodType): boolean {
346
- const source = (schema as any)[FIELD_SOURCE] as FieldSourceMetadata | undefined;
346
+ const source = getMetadata(schema, FIELD_SOURCE);
347
347
  return source?.source === "env";
348
348
  }
@@ -228,7 +228,7 @@ describe("runtimeInjected", () => {
228
228
  const schema = runtimeInjected(z.string());
229
229
  const { RUNTIME_INJECTED } = require("./symbols.js");
230
230
 
231
- expect((schema as any)[RUNTIME_INJECTED]).toBe(true);
231
+ expect((schema as unknown as Record<symbol, unknown>)[RUNTIME_INJECTED]).toBe(true); // accessing runtime-injected symbol
232
232
  });
233
233
  });
234
234
 
@@ -31,6 +31,20 @@ import {
31
31
  VALIDATION_CONTEXT,
32
32
  } from "./symbols.js";
33
33
 
34
+ // ============================================
35
+ // 타입 안전한 Symbol 프로퍼티 접근 유틸리티
36
+ // ============================================
37
+
38
+ /**
39
+ * SchemaRecord: Zod 스키마를 symbol 키 접근 가능한 레코드로 변환.
40
+ * `as any` 캐스팅을 이 한 곳에서만 수행하여 나머지 코드의 타입 안전성을 보장.
41
+ */
42
+ type SchemaRecord = Record<symbol, unknown>;
43
+
44
+ function asRecord(schema: z.ZodType): SchemaRecord {
45
+ return schema as unknown as SchemaRecord;
46
+ }
47
+
34
48
  // ============================================
35
49
  // 메타데이터 부착
36
50
  // ============================================
@@ -60,7 +74,7 @@ export function withMetadata<
60
74
  key: K,
61
75
  value: SymbolMetadataMap[K]
62
76
  ): T {
63
- (schema as any)[key] = value;
77
+ asRecord(schema)[key] = value;
64
78
  return schema;
65
79
  }
66
80
 
@@ -79,8 +93,9 @@ export function withMetadataMultiple<T extends z.ZodType>(
79
93
  schema: T,
80
94
  entries: Array<[symbol, unknown]>
81
95
  ): T {
96
+ const record = asRecord(schema);
82
97
  for (const [key, value] of entries) {
83
- (schema as any)[key] = value;
98
+ record[key] = value;
84
99
  }
85
100
  return schema;
86
101
  }
@@ -100,14 +115,14 @@ export function getMetadata<K extends keyof SymbolMetadataMap>(
100
115
  schema: z.ZodType,
101
116
  key: K
102
117
  ): SymbolMetadataMap[K] | undefined {
103
- return (schema as any)[key];
118
+ return asRecord(schema)[key] as SymbolMetadataMap[K] | undefined;
104
119
  }
105
120
 
106
121
  /**
107
122
  * 스키마에 특정 메타데이터가 있는지 확인
108
123
  */
109
124
  export function hasMetadata(schema: z.ZodType, key: symbol): boolean {
110
- return key in (schema as any);
125
+ return key in asRecord(schema);
111
126
  }
112
127
 
113
128
  /**
@@ -116,15 +131,16 @@ export function hasMetadata(schema: z.ZodType, key: symbol): boolean {
116
131
  export function getAllMetadata(
117
132
  schema: z.ZodType
118
133
  ): Partial<SymbolMetadataMap> {
119
- const result: Partial<SymbolMetadataMap> = {};
134
+ const result = {} as Record<symbol, unknown>;
135
+ const record = asRecord(schema);
120
136
 
121
137
  for (const sym of ALL_METADATA_SYMBOLS) {
122
- if (sym in (schema as any)) {
123
- (result as any)[sym] = (schema as any)[sym];
138
+ if (sym in record) {
139
+ result[sym] = record[sym];
124
140
  }
125
141
  }
126
142
 
127
- return result;
143
+ return result as Partial<SymbolMetadataMap>;
128
144
  }
129
145
 
130
146
  // ============================================
@@ -138,7 +154,7 @@ export function removeMetadata<T extends z.ZodType>(
138
154
  schema: T,
139
155
  key: symbol
140
156
  ): T {
141
- delete (schema as any)[key];
157
+ delete asRecord(schema)[key];
142
158
  return schema;
143
159
  }
144
160
 
@@ -146,9 +162,10 @@ export function removeMetadata<T extends z.ZodType>(
146
162
  * 스키마에서 모든 mandu 메타데이터 제거
147
163
  */
148
164
  export function clearAllMetadata<T extends z.ZodType>(schema: T): T {
165
+ const record = asRecord(schema);
149
166
  for (const sym of ALL_METADATA_SYMBOLS) {
150
- if (sym in (schema as any)) {
151
- delete (schema as any)[sym];
167
+ if (sym in record) {
168
+ delete record[sym];
152
169
  }
153
170
  }
154
171
  return schema;
@@ -165,9 +182,11 @@ export function copyMetadata<T extends z.ZodType>(
165
182
  from: z.ZodType,
166
183
  to: T
167
184
  ): T {
185
+ const fromRecord = asRecord(from);
186
+ const toRecord = asRecord(to);
168
187
  for (const sym of ALL_METADATA_SYMBOLS) {
169
- if (sym in (from as any)) {
170
- (to as any)[sym] = (from as any)[sym];
188
+ if (sym in fromRecord) {
189
+ toRecord[sym] = fromRecord[sym];
171
190
  }
172
191
  }
173
192
  return to;
@@ -273,7 +292,7 @@ export function getManduSymbolKeys(obj: object): symbol[] {
273
292
  * 스키마에 메타데이터가 있는지 확인
274
293
  */
275
294
  export function hasAnyMetadata(schema: z.ZodType): boolean {
276
- return getManduSymbolKeys(schema as any).length > 0;
295
+ return getManduSymbolKeys(schema as object).length > 0;
277
296
  }
278
297
 
279
298
  /**
@@ -283,10 +302,11 @@ export function serializeMetadata(
283
302
  schema: z.ZodType
284
303
  ): Record<string, unknown> {
285
304
  const result: Record<string, unknown> = {};
305
+ const record = asRecord(schema);
286
306
 
287
- for (const sym of getManduSymbolKeys(schema as any)) {
307
+ for (const sym of getManduSymbolKeys(schema as object)) {
288
308
  const name = sym.description ?? sym.toString();
289
- result[name] = (schema as any)[sym];
309
+ result[name] = record[sym];
290
310
  }
291
311
 
292
312
  return result;
@@ -133,7 +133,7 @@ export const ALL_METADATA_SYMBOLS = [
133
133
  * 심볼이 mandu 메타데이터 심볼인지 확인
134
134
  */
135
135
  export function isManduMetadataSymbol(sym: symbol): boolean {
136
- return ALL_METADATA_SYMBOLS.includes(sym as any);
136
+ return (ALL_METADATA_SYMBOLS as readonly symbol[]).includes(sym);
137
137
  }
138
138
 
139
139
  /**
@@ -13,7 +13,7 @@ import { readJsonFile } from "../utils/bun";
13
13
  function strictWithWarnings<T extends z.ZodRawShape>(
14
14
  schema: z.ZodObject<T>,
15
15
  schemaName: string
16
- ): z.ZodObject<T> {
16
+ ): z.ZodEffects<z.ZodObject<T>> {
17
17
  return schema.superRefine((data, ctx) => {
18
18
  if (typeof data !== "object" || data === null) return;
19
19
 
@@ -156,6 +156,22 @@ export interface ValidationResult {
156
156
  source?: string;
157
157
  }
158
158
 
159
+ /**
160
+ * Assertion function: narrows unknown config to ValidatedManduConfig or throws.
161
+ *
162
+ * Useful in code paths that receive untrusted config objects and need
163
+ * to guarantee the type after the call without a separate null-check.
164
+ */
165
+ export function assertValidConfig(cfg: unknown): asserts cfg is ValidatedManduConfig {
166
+ const result = ManduConfigSchema.safeParse(cfg);
167
+ if (!result.success) {
168
+ const messages = result.error.errors.map(
169
+ (e) => `${e.path.join(".")}: ${e.message}`
170
+ );
171
+ throw new Error(`Invalid ManduConfig:\n ${messages.join("\n ")}`);
172
+ }
173
+ }
174
+
159
175
  /**
160
176
  * 설정 파일 검증
161
177
  */
@@ -345,8 +345,8 @@ describe("LoaderContext", () => {
345
345
  data: { title: "Hello", count: 5 },
346
346
  });
347
347
 
348
- expect(valid.title).toBe("Hello");
349
- expect(valid.count).toBe(5);
348
+ expect((valid as Record<string, unknown>).title).toBe("Hello");
349
+ expect((valid as Record<string, unknown>).count).toBe(5);
350
350
 
351
351
  // Invalid data should throw
352
352
  await expect(
@@ -370,7 +370,7 @@ describe("LoaderContext", () => {
370
370
  data: { anything: "goes" },
371
371
  });
372
372
 
373
- expect(data.anything).toBe("goes");
373
+ expect((data as Record<string, unknown>).anything).toBe("goes");
374
374
  });
375
375
 
376
376
  test("generateDigest creates hash", () => {
@@ -39,6 +39,7 @@ function parseJson(content: string, filePath: string): unknown {
39
39
  async function parseYaml(content: string, filePath: string): Promise<unknown> {
40
40
  try {
41
41
  // yaml 패키지 동적 로드
42
+ // @ts-ignore dynamic optional import
42
43
  const yaml = await import("yaml").catch(() => null);
43
44
 
44
45
  if (!yaml) {
@@ -64,7 +65,9 @@ async function parseYaml(content: string, filePath: string): Promise<unknown> {
64
65
  async function parseToml(content: string, filePath: string): Promise<unknown> {
65
66
  try {
66
67
  // @iarna/toml 또는 toml 패키지 동적 로드
68
+ // @ts-ignore dynamic optional import
67
69
  const toml = await import("@iarna/toml").catch(() =>
70
+ // @ts-ignore dynamic optional import
68
71
  import("toml").catch(() => null)
69
72
  );
70
73
 
@@ -46,6 +46,7 @@ async function parseFrontmatter(
46
46
 
47
47
  try {
48
48
  // yaml 동적 로드
49
+ // @ts-ignore dynamic optional import
49
50
  const yaml = await import("yaml").catch(() => null);
50
51
 
51
52
  if (!yaml) {
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { Mandu, createClientContract } from "../index";
4
4
 
@@ -6,7 +6,8 @@
6
6
 
7
7
  import { describe, it, expect, mock } from "bun:test";
8
8
  import { z } from "zod";
9
- import { Mandu, createClient, contractFetch } from "./index";
9
+ import { Mandu } from "../index";
10
+ import { createClient, contractFetch } from "./index";
10
11
 
11
12
  // === Test Contract ===
12
13
  const testContract = Mandu.contract({