@mandujs/core 0.10.0 → 0.11.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/README.md CHANGED
@@ -72,16 +72,20 @@ const handlers = Mandu.handler(userContract, {
72
72
 
73
73
  ```
74
74
  @mandujs/core
75
- ├── router/ # FS Routes - file-system based routing
76
- ├── guard/ # Mandu Guard - architecture enforcement
77
- ├── runtime/ # Server, SSR, streaming
78
- ├── filling/ # Handler chain API (Mandu.filling())
79
- ├── contract/ # Type-safe API contracts
80
- ├── bundler/ # Client bundling, HMR
81
- ├── client/ # Island hydration, client router
82
- ├── brain/ # Doctor, Watcher, Architecture analyzer
83
- ├── change/ # Transaction & history
84
- └── spec/ # Manifest schema & validation
75
+ ├── router/ # FS Routes - file-system based routing
76
+ ├── guard/ # Mandu Guard - architecture enforcement
77
+ ├── healing # Self-Healing Guard with auto-fix
78
+ ├── decision-memory # ADR storage (RFC-001)
79
+ ├── semantic-slots # Constraint validation (RFC-001)
80
+ │ └── negotiation # AI-Framework dialog (RFC-001)
81
+ ├── runtime/ # Server, SSR, streaming
82
+ ├── filling/ # Handler chain API (Mandu.filling())
83
+ ├── contract/ # Type-safe API contracts
84
+ ├── bundler/ # Client bundling, HMR
85
+ ├── client/ # Island hydration, client router
86
+ ├── brain/ # Doctor, Watcher, Architecture analyzer
87
+ ├── change/ # Transaction & history
88
+ └── spec/ # Manifest schema & validation
85
89
  ```
86
90
 
87
91
  ---
@@ -201,6 +205,71 @@ console.log(trend.trend); // "improving" | "stable" | "degrading"
201
205
  const markdown = generateGuardMarkdownReport(report, trend);
202
206
  ```
203
207
 
208
+ ### Self-Healing Guard (RFC-001) 🆕
209
+
210
+ ```typescript
211
+ import { checkWithHealing, healAll, explainRule } from "@mandujs/core/guard";
212
+
213
+ // Detect violations with fix suggestions
214
+ const result = await checkWithHealing({ preset: "mandu" }, process.cwd());
215
+
216
+ // Auto-fix all fixable violations
217
+ if (result.items.length > 0) {
218
+ const healResult = await healAll(result);
219
+ console.log(`Fixed: ${healResult.fixed}, Failed: ${healResult.failed}`);
220
+ }
221
+
222
+ // Explain any rule
223
+ const explanation = explainRule("layer-dependency");
224
+ console.log(explanation.description, explanation.examples);
225
+ ```
226
+
227
+ ### Decision Memory (RFC-001) 🆕
228
+
229
+ ```typescript
230
+ import {
231
+ searchDecisions,
232
+ saveDecision,
233
+ checkConsistency,
234
+ getCompactArchitecture
235
+ } from "@mandujs/core/guard";
236
+
237
+ // Search past decisions
238
+ const results = await searchDecisions(rootDir, ["auth", "jwt"]);
239
+
240
+ // Save new decision (ADR)
241
+ await saveDecision(rootDir, {
242
+ id: "ADR-002",
243
+ title: "Use PostgreSQL",
244
+ status: "accepted",
245
+ context: "Need relational database",
246
+ decision: "Use PostgreSQL with Drizzle ORM",
247
+ consequences: ["Need to manage migrations"],
248
+ tags: ["database", "orm"]
249
+ });
250
+
251
+ // Check implementation consistency
252
+ const consistency = await checkConsistency(rootDir);
253
+ ```
254
+
255
+ ### Architecture Negotiation (RFC-001) 🆕
256
+
257
+ ```typescript
258
+ import { negotiate, generateScaffold } from "@mandujs/core/guard";
259
+
260
+ // AI negotiates with framework before implementation
261
+ const plan = await negotiate({
262
+ intent: "Add user authentication",
263
+ requirements: ["JWT based", "Refresh tokens"],
264
+ constraints: ["Use existing User model"]
265
+ }, projectRoot);
266
+
267
+ if (plan.approved) {
268
+ // Generate scaffold files
269
+ await generateScaffold(plan.structure, projectRoot);
270
+ }
271
+ ```
272
+
204
273
  ---
205
274
 
206
275
  ## Filling API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,5 +1,6 @@
1
1
  export * from "./mandu";
2
2
  export * from "./validate";
3
+ export * from "./watcher";
3
4
 
4
5
  // Symbol 메타데이터 (Phase 3)
5
6
  export * from "./symbols.js";
@@ -1,78 +1,135 @@
1
- import { z, ZodError } from "zod";
1
+ import { z, ZodError, ZodIssueCode } from "zod";
2
2
  import path from "path";
3
3
  import { pathToFileURL } from "url";
4
4
  import { CONFIG_FILES, coerceConfig } from "./mandu";
5
5
  import { readJsonFile } from "../utils/bun";
6
6
 
7
7
  /**
8
- * Mandu 설정 스키마
8
+ * DNA-003: Strict mode schema helper
9
+ *
10
+ * Creates a schema that warns about unknown keys instead of failing
11
+ * This provides the benefits of .strict() while maintaining compatibility
12
+ */
13
+ function strictWithWarnings<T extends z.ZodRawShape>(
14
+ schema: z.ZodObject<T>,
15
+ schemaName: string
16
+ ): z.ZodObject<T> {
17
+ return schema.superRefine((data, ctx) => {
18
+ if (typeof data !== "object" || data === null) return;
19
+
20
+ const knownKeys = new Set(Object.keys(schema.shape));
21
+ const unknownKeys = Object.keys(data).filter((key) => !knownKeys.has(key));
22
+
23
+ if (unknownKeys.length > 0 && process.env.MANDU_STRICT !== "0") {
24
+ // In strict mode (default), add warnings to issues
25
+ for (const key of unknownKeys) {
26
+ ctx.addIssue({
27
+ code: ZodIssueCode.unrecognized_keys,
28
+ keys: [key],
29
+ message: `Unknown key '${key}' in ${schemaName}. Did you mean one of: ${[...knownKeys].join(", ")}?`,
30
+ });
31
+ }
32
+ }
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Server 설정 스키마 (strict)
38
+ */
39
+ const ServerConfigSchema = z
40
+ .object({
41
+ port: z.number().min(1).max(65535).default(3000),
42
+ hostname: z.string().default("localhost"),
43
+ cors: z
44
+ .union([
45
+ z.boolean(),
46
+ z.object({
47
+ origin: z.union([z.string(), z.array(z.string())]).optional(),
48
+ methods: z.array(z.string()).optional(),
49
+ credentials: z.boolean().optional(),
50
+ }).strict(),
51
+ ])
52
+ .default(false),
53
+ streaming: z.boolean().default(false),
54
+ })
55
+ .strict();
56
+
57
+ /**
58
+ * Guard 설정 스키마 (strict)
59
+ */
60
+ const GuardConfigSchema = z
61
+ .object({
62
+ preset: z.enum(["mandu", "fsd", "clean", "hexagonal", "atomic"]).default("mandu"),
63
+ srcDir: z.string().default("src"),
64
+ exclude: z.array(z.string()).default([]),
65
+ realtime: z.boolean().default(true),
66
+ rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
67
+ })
68
+ .strict();
69
+
70
+ /**
71
+ * Build 설정 스키마 (strict)
72
+ */
73
+ const BuildConfigSchema = z
74
+ .object({
75
+ outDir: z.string().default(".mandu"),
76
+ minify: z.boolean().default(true),
77
+ sourcemap: z.boolean().default(false),
78
+ splitting: z.boolean().default(false),
79
+ })
80
+ .strict();
81
+
82
+ /**
83
+ * Dev 설정 스키마 (strict)
84
+ */
85
+ const DevConfigSchema = z
86
+ .object({
87
+ hmr: z.boolean().default(true),
88
+ watchDirs: z.array(z.string()).default([]),
89
+ })
90
+ .strict();
91
+
92
+ /**
93
+ * FS Routes 설정 스키마 (strict)
94
+ */
95
+ const FsRoutesConfigSchema = z
96
+ .object({
97
+ routesDir: z.string().default("app"),
98
+ extensions: z.array(z.string()).default([".tsx", ".ts", ".jsx", ".js"]),
99
+ exclude: z.array(z.string()).default([]),
100
+ islandSuffix: z.string().default(".island"),
101
+ legacyManifestPath: z.string().optional(),
102
+ mergeWithLegacy: z.boolean().default(true),
103
+ })
104
+ .strict();
105
+
106
+ /**
107
+ * SEO 설정 스키마 (strict)
108
+ */
109
+ const SeoConfigSchema = z
110
+ .object({
111
+ enabled: z.boolean().default(true),
112
+ defaultTitle: z.string().optional(),
113
+ titleTemplate: z.string().optional(),
114
+ })
115
+ .strict();
116
+
117
+ /**
118
+ * Mandu 설정 스키마 (DNA-003: strict mode)
119
+ *
120
+ * 알 수 없는 키가 있으면 오류 발생 → 오타 즉시 감지
121
+ * MANDU_STRICT=0 으로 비활성화 가능
9
122
  */
10
123
  export const ManduConfigSchema = z
11
124
  .object({
12
- server: z
13
- .object({
14
- port: z.number().min(1).max(65535).default(3000),
15
- hostname: z.string().default("localhost"),
16
- cors: z
17
- .union([
18
- z.boolean(),
19
- z.object({
20
- origin: z.union([z.string(), z.array(z.string())]).optional(),
21
- methods: z.array(z.string()).optional(),
22
- credentials: z.boolean().optional(),
23
- }),
24
- ])
25
- .default(false),
26
- streaming: z.boolean().default(false),
27
- })
28
- .default({}),
29
-
30
- guard: z
31
- .object({
32
- preset: z.enum(["mandu", "fsd", "clean", "hexagonal", "atomic"]).default("mandu"),
33
- srcDir: z.string().default("src"),
34
- exclude: z.array(z.string()).default([]),
35
- realtime: z.boolean().default(true),
36
- rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
37
- })
38
- .default({}),
39
-
40
- build: z
41
- .object({
42
- outDir: z.string().default(".mandu"),
43
- minify: z.boolean().default(true),
44
- sourcemap: z.boolean().default(false),
45
- splitting: z.boolean().default(false),
46
- })
47
- .default({}),
48
-
49
- dev: z
50
- .object({
51
- hmr: z.boolean().default(true),
52
- watchDirs: z.array(z.string()).default([]),
53
- })
54
- .default({}),
55
-
56
- fsRoutes: z
57
- .object({
58
- routesDir: z.string().default("app"),
59
- extensions: z.array(z.string()).default([".tsx", ".ts", ".jsx", ".js"]),
60
- exclude: z.array(z.string()).default([]),
61
- islandSuffix: z.string().default(".island"),
62
- legacyManifestPath: z.string().optional(),
63
- mergeWithLegacy: z.boolean().default(true),
64
- })
65
- .default({}),
66
-
67
- seo: z
68
- .object({
69
- enabled: z.boolean().default(true),
70
- defaultTitle: z.string().optional(),
71
- titleTemplate: z.string().optional(),
72
- })
73
- .default({}),
125
+ server: ServerConfigSchema.default({}),
126
+ guard: GuardConfigSchema.default({}),
127
+ build: BuildConfigSchema.default({}),
128
+ dev: DevConfigSchema.default({}),
129
+ fsRoutes: FsRoutesConfigSchema.default({}),
130
+ seo: SeoConfigSchema.default({}),
74
131
  })
75
- .passthrough();
132
+ .strict();
76
133
 
77
134
  export type ValidatedManduConfig = z.infer<typeof ManduConfigSchema>;
78
135
 
@@ -0,0 +1,311 @@
1
+ /**
2
+ * DNA-006: Config Hot Reload
3
+ *
4
+ * 설정 파일 변경 감시 및 핫 리로드
5
+ * - 디바운스로 연속 변경 병합
6
+ * - 에러 발생 시 기존 설정 유지
7
+ * - 클린업 함수 반환
8
+ */
9
+
10
+ import { watch, type FSWatcher } from "fs";
11
+ import { loadManduConfig, type ManduConfig } from "./mandu.js";
12
+
13
+ /**
14
+ * 설정 변경 이벤트 타입
15
+ */
16
+ export type ConfigChangeEvent = {
17
+ /** 이전 설정 */
18
+ previous: ManduConfig;
19
+ /** 새 설정 */
20
+ current: ManduConfig;
21
+ /** 변경된 파일 경로 */
22
+ path: string;
23
+ /** 변경 시간 */
24
+ timestamp: Date;
25
+ };
26
+
27
+ /**
28
+ * 설정 감시 옵션
29
+ */
30
+ export interface WatchConfigOptions {
31
+ /** 디바운스 딜레이 (ms, 기본: 100) */
32
+ debounceMs?: number;
33
+ /** 초기 로드 시에도 콜백 호출 (기본: false) */
34
+ immediate?: boolean;
35
+ /** 에러 핸들러 */
36
+ onError?: (error: Error) => void;
37
+ }
38
+
39
+ /**
40
+ * 설정 변경 콜백
41
+ */
42
+ export type ConfigChangeCallback = (
43
+ newConfig: ManduConfig,
44
+ event: ConfigChangeEvent
45
+ ) => void;
46
+
47
+ /**
48
+ * 설정 감시 결과
49
+ */
50
+ export interface ConfigWatcher {
51
+ /** 감시 중지 */
52
+ stop: () => void;
53
+ /** 현재 설정 */
54
+ getConfig: () => ManduConfig;
55
+ /** 수동 리로드 */
56
+ reload: () => Promise<ManduConfig>;
57
+ }
58
+
59
+ /**
60
+ * 설정 파일 감시 및 핫 리로드
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const watcher = await watchConfig(
65
+ * "/path/to/project",
66
+ * (newConfig, event) => {
67
+ * console.log("Config changed:", event.path);
68
+ * applyConfig(newConfig);
69
+ * },
70
+ * { debounceMs: 200 }
71
+ * );
72
+ *
73
+ * // 나중에 정리
74
+ * watcher.stop();
75
+ * ```
76
+ */
77
+ export async function watchConfig(
78
+ rootDir: string,
79
+ onReload: ConfigChangeCallback,
80
+ options: WatchConfigOptions = {}
81
+ ): Promise<ConfigWatcher> {
82
+ const { debounceMs = 100, immediate = false, onError } = options;
83
+
84
+ // 현재 설정 로드
85
+ let currentConfig = await loadManduConfig(rootDir);
86
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
87
+ let watchers: FSWatcher[] = [];
88
+ let isWatching = true;
89
+
90
+ // 감시 대상 파일들
91
+ const configFiles = [
92
+ "mandu.config.ts",
93
+ "mandu.config.js",
94
+ "mandu.config.json",
95
+ ".mandu/guard.json",
96
+ ];
97
+
98
+ /**
99
+ * 설정 리로드 수행
100
+ */
101
+ const doReload = async (changedPath: string): Promise<void> => {
102
+ if (!isWatching) return;
103
+
104
+ try {
105
+ const previous = currentConfig;
106
+ const newConfig = await loadManduConfig(rootDir);
107
+
108
+ // 설정이 동일하면 무시
109
+ if (JSON.stringify(previous) === JSON.stringify(newConfig)) {
110
+ return;
111
+ }
112
+
113
+ // 이전에 설정이 있었는데 새 설정이 비어있으면 (파싱 에러 등)
114
+ // 기존 설정 유지하고 에러 핸들러 호출
115
+ const previousHasContent = Object.keys(previous).length > 0;
116
+ const newIsEmpty = Object.keys(newConfig).length === 0;
117
+
118
+ if (previousHasContent && newIsEmpty) {
119
+ if (onError) {
120
+ onError(new Error(`Failed to reload config from ${changedPath}`));
121
+ }
122
+ return; // 기존 설정 유지
123
+ }
124
+
125
+ currentConfig = newConfig;
126
+
127
+ const event: ConfigChangeEvent = {
128
+ previous,
129
+ current: newConfig,
130
+ path: changedPath,
131
+ timestamp: new Date(),
132
+ };
133
+
134
+ onReload(newConfig, event);
135
+ } catch (error) {
136
+ if (onError && error instanceof Error) {
137
+ onError(error);
138
+ }
139
+ // 에러 시 기존 설정 유지
140
+ }
141
+ };
142
+
143
+ /**
144
+ * 디바운스된 리로드
145
+ */
146
+ const scheduleReload = (changedPath: string): void => {
147
+ if (debounceTimer) {
148
+ clearTimeout(debounceTimer);
149
+ }
150
+
151
+ debounceTimer = setTimeout(() => {
152
+ debounceTimer = null;
153
+ doReload(changedPath);
154
+ }, debounceMs);
155
+ };
156
+
157
+ // 각 설정 파일 감시 시작
158
+ for (const fileName of configFiles) {
159
+ const filePath = `${rootDir}/${fileName}`;
160
+
161
+ try {
162
+ const watcher = watch(filePath, (eventType) => {
163
+ if (eventType === "change" && isWatching) {
164
+ scheduleReload(filePath);
165
+ }
166
+ });
167
+
168
+ watcher.on("error", () => {
169
+ // 파일이 없거나 접근 불가 - 무시
170
+ });
171
+
172
+ watchers.push(watcher);
173
+ } catch {
174
+ // 파일이 없으면 감시 생략
175
+ }
176
+ }
177
+
178
+ // 초기 콜백 호출
179
+ if (immediate) {
180
+ const event: ConfigChangeEvent = {
181
+ previous: {},
182
+ current: currentConfig,
183
+ path: rootDir,
184
+ timestamp: new Date(),
185
+ };
186
+ onReload(currentConfig, event);
187
+ }
188
+
189
+ return {
190
+ stop: () => {
191
+ isWatching = false;
192
+ if (debounceTimer) {
193
+ clearTimeout(debounceTimer);
194
+ debounceTimer = null;
195
+ }
196
+ for (const watcher of watchers) {
197
+ watcher.close();
198
+ }
199
+ watchers = [];
200
+ },
201
+
202
+ getConfig: () => currentConfig,
203
+
204
+ reload: async () => {
205
+ const previous = currentConfig;
206
+ currentConfig = await loadManduConfig(rootDir);
207
+
208
+ if (JSON.stringify(previous) !== JSON.stringify(currentConfig)) {
209
+ const event: ConfigChangeEvent = {
210
+ previous,
211
+ current: currentConfig,
212
+ path: rootDir,
213
+ timestamp: new Date(),
214
+ };
215
+ onReload(currentConfig, event);
216
+ }
217
+
218
+ return currentConfig;
219
+ },
220
+ };
221
+ }
222
+
223
+ /**
224
+ * 간단한 단일 파일 감시
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * const stop = watchConfigFile(
229
+ * "/path/to/mandu.config.ts",
230
+ * async (path) => {
231
+ * const config = await loadManduConfig(dirname(path));
232
+ * applyConfig(config);
233
+ * }
234
+ * );
235
+ *
236
+ * // 정리
237
+ * stop();
238
+ * ```
239
+ */
240
+ export function watchConfigFile(
241
+ filePath: string,
242
+ onChange: (path: string) => void,
243
+ debounceMs = 100
244
+ ): () => void {
245
+ let timer: ReturnType<typeof setTimeout> | null = null;
246
+ let watcher: FSWatcher | null = null;
247
+
248
+ try {
249
+ watcher = watch(filePath, (eventType) => {
250
+ if (eventType === "change") {
251
+ if (timer) clearTimeout(timer);
252
+ timer = setTimeout(() => {
253
+ timer = null;
254
+ onChange(filePath);
255
+ }, debounceMs);
256
+ }
257
+ });
258
+ } catch {
259
+ // 파일 없음 - 빈 함수 반환
260
+ return () => {};
261
+ }
262
+
263
+ return () => {
264
+ if (timer) {
265
+ clearTimeout(timer);
266
+ timer = null;
267
+ }
268
+ if (watcher) {
269
+ watcher.close();
270
+ watcher = null;
271
+ }
272
+ };
273
+ }
274
+
275
+ /**
276
+ * 설정 변경 감지 헬퍼
277
+ *
278
+ * 특정 설정 섹션의 변경 여부 확인
279
+ */
280
+ export function hasConfigChanged(
281
+ previous: ManduConfig,
282
+ current: ManduConfig,
283
+ section?: keyof ManduConfig
284
+ ): boolean {
285
+ if (section) {
286
+ return JSON.stringify(previous[section]) !== JSON.stringify(current[section]);
287
+ }
288
+ return JSON.stringify(previous) !== JSON.stringify(current);
289
+ }
290
+
291
+ /**
292
+ * 변경된 설정 섹션 목록
293
+ */
294
+ export function getChangedSections(
295
+ previous: ManduConfig,
296
+ current: ManduConfig
297
+ ): (keyof ManduConfig)[] {
298
+ const sections: (keyof ManduConfig)[] = [
299
+ "server",
300
+ "guard",
301
+ "build",
302
+ "dev",
303
+ "fsRoutes",
304
+ "seo",
305
+ ];
306
+
307
+ return sections.filter(
308
+ (section) =>
309
+ JSON.stringify(previous[section]) !== JSON.stringify(current[section])
310
+ );
311
+ }