@mandujs/core 0.9.45 → 0.10.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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/brain/doctor/config-analyzer.ts +498 -0
  3. package/src/brain/doctor/index.ts +10 -0
  4. package/src/change/snapshot.ts +46 -1
  5. package/src/change/types.ts +13 -0
  6. package/src/config/index.ts +8 -2
  7. package/src/config/mcp-ref.ts +348 -0
  8. package/src/config/mcp-status.ts +348 -0
  9. package/src/config/metadata.test.ts +308 -0
  10. package/src/config/metadata.ts +293 -0
  11. package/src/config/symbols.ts +144 -0
  12. package/src/contract/index.ts +26 -25
  13. package/src/contract/protection.ts +364 -0
  14. package/src/error/domains.ts +265 -0
  15. package/src/error/index.ts +25 -13
  16. package/src/filling/filling.ts +88 -6
  17. package/src/guard/analyzer.ts +7 -2
  18. package/src/guard/config-guard.ts +281 -0
  19. package/src/guard/decision-memory.test.ts +293 -0
  20. package/src/guard/decision-memory.ts +532 -0
  21. package/src/guard/healing.test.ts +259 -0
  22. package/src/guard/healing.ts +874 -0
  23. package/src/guard/index.ts +119 -0
  24. package/src/guard/negotiation.test.ts +282 -0
  25. package/src/guard/negotiation.ts +975 -0
  26. package/src/guard/semantic-slots.test.ts +379 -0
  27. package/src/guard/semantic-slots.ts +796 -0
  28. package/src/index.ts +2 -0
  29. package/src/lockfile/generate.ts +259 -0
  30. package/src/lockfile/index.ts +186 -0
  31. package/src/lockfile/lockfile.test.ts +410 -0
  32. package/src/lockfile/types.ts +184 -0
  33. package/src/lockfile/validate.ts +308 -0
  34. package/src/runtime/security.ts +155 -0
  35. package/src/runtime/server.ts +320 -258
  36. package/src/utils/differ.test.ts +342 -0
  37. package/src/utils/differ.ts +482 -0
  38. package/src/utils/hasher.test.ts +326 -0
  39. package/src/utils/hasher.ts +319 -0
  40. package/src/utils/index.ts +29 -0
  41. package/src/utils/safe-io.ts +188 -0
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Decision Memory - 아키텍처 결정 기억 시스템
3
+ *
4
+ * 과거 아키텍처 결정을 저장하고 AI가 일관된 선택을 하도록 유도
5
+ *
6
+ * @module guard/decision-memory
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { getDecisions, saveDecision, searchDecisions } from "@mandujs/core/guard";
11
+ *
12
+ * // 태그로 결정 검색
13
+ * const authDecisions = await searchDecisions(rootDir, ["auth", "security"]);
14
+ *
15
+ * // 새 결정 저장
16
+ * await saveDecision(rootDir, {
17
+ * id: "ADR-004",
18
+ * title: "Use JWT for API Authentication",
19
+ * status: "accepted",
20
+ * tags: ["auth", "api", "security"],
21
+ * context: "API 인증 방식 결정 필요",
22
+ * decision: "JWT + Refresh Token 조합 사용",
23
+ * consequences: ["토큰 만료 관리 필요", "Redis 세션 저장소 필요"],
24
+ * });
25
+ * ```
26
+ */
27
+
28
+ import { join, basename, extname } from "path";
29
+ import { mkdir, readdir, readFile, writeFile, stat } from "fs/promises";
30
+
31
+ // ═══════════════════════════════════════════════════════════════════════════
32
+ // Types
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+
35
+ /**
36
+ * ADR 상태
37
+ */
38
+ export type DecisionStatus =
39
+ | "proposed" // 제안됨
40
+ | "accepted" // 승인됨
41
+ | "deprecated" // 폐기됨
42
+ | "superseded"; // 대체됨
43
+
44
+ /**
45
+ * Architecture Decision Record (ADR)
46
+ */
47
+ export interface ArchitectureDecision {
48
+ /** 고유 ID (e.g., "ADR-001") */
49
+ id: string;
50
+
51
+ /** 제목 */
52
+ title: string;
53
+
54
+ /** 상태 */
55
+ status: DecisionStatus;
56
+
57
+ /** 날짜 */
58
+ date: string;
59
+
60
+ /** 태그 (검색용) */
61
+ tags: string[];
62
+
63
+ /** 컨텍스트: 왜 이 결정이 필요했는가 */
64
+ context: string;
65
+
66
+ /** 결정 내용 */
67
+ decision: string;
68
+
69
+ /** 결과 및 영향 */
70
+ consequences: string[];
71
+
72
+ /** 관련 결정 ID들 */
73
+ relatedDecisions?: string[];
74
+
75
+ /** 대체된 결정 ID (status가 superseded일 때) */
76
+ supersededBy?: string;
77
+
78
+ /** 추가 메타데이터 */
79
+ metadata?: Record<string, unknown>;
80
+ }
81
+
82
+ /**
83
+ * 결정 검색 결과
84
+ */
85
+ export interface DecisionSearchResult {
86
+ /** 검색된 결정들 */
87
+ decisions: ArchitectureDecision[];
88
+
89
+ /** 총 결정 수 */
90
+ total: number;
91
+
92
+ /** 검색 키워드 */
93
+ searchTags: string[];
94
+ }
95
+
96
+ /**
97
+ * 일관성 검사 결과
98
+ */
99
+ export interface ConsistencyCheckResult {
100
+ /** 일관성 여부 */
101
+ consistent: boolean;
102
+
103
+ /** 관련 결정들 */
104
+ relatedDecisions: ArchitectureDecision[];
105
+
106
+ /** 경고 메시지 */
107
+ warnings: string[];
108
+
109
+ /** 제안 사항 */
110
+ suggestions: string[];
111
+ }
112
+
113
+ /**
114
+ * 압축된 아키텍처 정보 (AI용)
115
+ */
116
+ export interface CompactArchitecture {
117
+ /** 프로젝트 이름 */
118
+ project: string;
119
+
120
+ /** 마지막 업데이트 */
121
+ lastUpdated: string;
122
+
123
+ /** 핵심 결정 요약 */
124
+ keyDecisions: {
125
+ id: string;
126
+ title: string;
127
+ tags: string[];
128
+ summary: string;
129
+ }[];
130
+
131
+ /** 태그별 결정 수 */
132
+ tagCounts: Record<string, number>;
133
+
134
+ /** 레이어/모듈 규칙 요약 */
135
+ rules: string[];
136
+ }
137
+
138
+ // ═══════════════════════════════════════════════════════════════════════════
139
+ // Constants
140
+ // ═══════════════════════════════════════════════════════════════════════════
141
+
142
+ const DECISIONS_DIR = "spec/decisions";
143
+ const ARCHITECTURE_FILE = "spec/architecture.json";
144
+ const ADR_TEMPLATE = `# {title}
145
+
146
+ **ID:** {id}
147
+ **Status:** {status}
148
+ **Date:** {date}
149
+ **Tags:** {tags}
150
+
151
+ ## Context
152
+
153
+ {context}
154
+
155
+ ## Decision
156
+
157
+ {decision}
158
+
159
+ ## Consequences
160
+
161
+ {consequences}
162
+
163
+ ## Related Decisions
164
+
165
+ {relatedDecisions}
166
+ `;
167
+
168
+ // ═══════════════════════════════════════════════════════════════════════════
169
+ // Core Functions
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ /**
173
+ * spec/decisions 디렉토리 확인 및 생성
174
+ */
175
+ async function ensureDecisionsDir(rootDir: string): Promise<string> {
176
+ const decisionsPath = join(rootDir, DECISIONS_DIR);
177
+ await mkdir(decisionsPath, { recursive: true });
178
+ return decisionsPath;
179
+ }
180
+
181
+ /**
182
+ * ADR 파일을 파싱하여 ArchitectureDecision으로 변환
183
+ */
184
+ export function parseADRMarkdown(content: string, filename: string): ArchitectureDecision | null {
185
+ try {
186
+ // 제목 추출
187
+ const titleMatch = content.match(/^#\s+(.+)$/m);
188
+ const title = titleMatch?.[1] || filename.replace(/\.md$/, "");
189
+
190
+ // ID 추출
191
+ const idMatch = content.match(/\*\*ID:\*\*\s*(.+)$/m);
192
+ const id = idMatch?.[1]?.trim() || filename.replace(/\.md$/, "");
193
+
194
+ // Status 추출
195
+ const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)$/m);
196
+ const status = (statusMatch?.[1]?.trim().toLowerCase() || "proposed") as DecisionStatus;
197
+
198
+ // Date 추출
199
+ const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)$/m);
200
+ const date = dateMatch?.[1]?.trim() || new Date().toISOString().split("T")[0];
201
+
202
+ // Tags 추출
203
+ const tagsMatch = content.match(/\*\*Tags:\*\*\s*(.+)$/m);
204
+ const tags = tagsMatch?.[1]
205
+ ?.split(",")
206
+ .map((t) => t.trim().toLowerCase())
207
+ .filter(Boolean) || [];
208
+
209
+ // Context 섹션 추출
210
+ const contextMatch = content.match(/## Context\s+([\s\S]*?)(?=##|$)/);
211
+ const context = contextMatch?.[1]?.trim() || "";
212
+
213
+ // Decision 섹션 추출
214
+ const decisionMatch = content.match(/## Decision\s+([\s\S]*?)(?=##|$)/);
215
+ const decision = decisionMatch?.[1]?.trim() || "";
216
+
217
+ // Consequences 섹션 추출
218
+ const consequencesMatch = content.match(/## Consequences\s+([\s\S]*?)(?=##|$)/);
219
+ const consequencesText = consequencesMatch?.[1]?.trim() || "";
220
+ const consequences = consequencesText
221
+ .split("\n")
222
+ .map((line) => line.replace(/^[-*]\s*/, "").trim())
223
+ .filter(Boolean);
224
+
225
+ // Related Decisions 추출
226
+ const relatedMatch = content.match(/## Related Decisions\s+([\s\S]*?)(?=##|$)/);
227
+ const relatedText = relatedMatch?.[1]?.trim() || "";
228
+ const relatedDecisions = relatedText
229
+ .match(/ADR-\d+/g)
230
+ ?.filter(Boolean) || [];
231
+
232
+ return {
233
+ id,
234
+ title,
235
+ status,
236
+ date,
237
+ tags,
238
+ context,
239
+ decision,
240
+ consequences,
241
+ relatedDecisions: relatedDecisions.length > 0 ? relatedDecisions : undefined,
242
+ };
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * ArchitectureDecision을 Markdown으로 변환
250
+ */
251
+ export function formatADRAsMarkdown(adr: ArchitectureDecision): string {
252
+ const consequencesList = adr.consequences.map((c) => `- ${c}`).join("\n");
253
+ const relatedList = adr.relatedDecisions?.length
254
+ ? adr.relatedDecisions.map((r) => `- ${r}`).join("\n")
255
+ : "None";
256
+
257
+ return ADR_TEMPLATE
258
+ .replace("{title}", adr.title)
259
+ .replace("{id}", adr.id)
260
+ .replace("{status}", adr.status)
261
+ .replace("{date}", adr.date)
262
+ .replace("{tags}", adr.tags.join(", "))
263
+ .replace("{context}", adr.context)
264
+ .replace("{decision}", adr.decision)
265
+ .replace("{consequences}", consequencesList)
266
+ .replace("{relatedDecisions}", relatedList);
267
+ }
268
+
269
+ /**
270
+ * 모든 결정 불러오기
271
+ */
272
+ export async function getAllDecisions(rootDir: string): Promise<ArchitectureDecision[]> {
273
+ const decisionsPath = join(rootDir, DECISIONS_DIR);
274
+
275
+ try {
276
+ const files = await readdir(decisionsPath);
277
+ const mdFiles = files.filter((f) => extname(f) === ".md");
278
+
279
+ const decisions: ArchitectureDecision[] = [];
280
+
281
+ for (const file of mdFiles) {
282
+ const content = await readFile(join(decisionsPath, file), "utf-8");
283
+ const parsed = parseADRMarkdown(content, file);
284
+ if (parsed) {
285
+ decisions.push(parsed);
286
+ }
287
+ }
288
+
289
+ // ID 순서로 정렬
290
+ return decisions.sort((a, b) => a.id.localeCompare(b.id));
291
+ } catch {
292
+ // 디렉토리가 없으면 빈 배열 반환
293
+ return [];
294
+ }
295
+ }
296
+
297
+ /**
298
+ * ID로 결정 조회
299
+ */
300
+ export async function getDecisionById(
301
+ rootDir: string,
302
+ id: string
303
+ ): Promise<ArchitectureDecision | null> {
304
+ const decisions = await getAllDecisions(rootDir);
305
+ return decisions.find((d) => d.id === id) || null;
306
+ }
307
+
308
+ /**
309
+ * 태그로 결정 검색
310
+ */
311
+ export async function searchDecisions(
312
+ rootDir: string,
313
+ tags: string[]
314
+ ): Promise<DecisionSearchResult> {
315
+ const allDecisions = await getAllDecisions(rootDir);
316
+ const normalizedTags = tags.map((t) => t.toLowerCase());
317
+
318
+ // 활성 상태(accepted, proposed)인 결정만 필터
319
+ const activeDecisions = allDecisions.filter(
320
+ (d) => d.status === "accepted" || d.status === "proposed"
321
+ );
322
+
323
+ // 태그 매칭
324
+ const matched = activeDecisions.filter((decision) =>
325
+ normalizedTags.some((tag) =>
326
+ decision.tags.some((dt) => dt.includes(tag) || tag.includes(dt))
327
+ )
328
+ );
329
+
330
+ return {
331
+ decisions: matched,
332
+ total: matched.length,
333
+ searchTags: tags,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * 새 결정 저장
339
+ */
340
+ export async function saveDecision(
341
+ rootDir: string,
342
+ decision: Omit<ArchitectureDecision, "date"> & { date?: string }
343
+ ): Promise<{ success: boolean; filePath: string; message: string }> {
344
+ const decisionsPath = await ensureDecisionsDir(rootDir);
345
+
346
+ // 날짜 기본값 설정
347
+ const fullDecision: ArchitectureDecision = {
348
+ ...decision,
349
+ date: decision.date || new Date().toISOString().split("T")[0],
350
+ };
351
+
352
+ // 파일명 생성 (ADR-001-title-slug.md)
353
+ const slug = fullDecision.title
354
+ .toLowerCase()
355
+ .replace(/[^a-z0-9]+/g, "-")
356
+ .replace(/^-|-$/g, "")
357
+ .slice(0, 50);
358
+ const filename = `${fullDecision.id}-${slug}.md`;
359
+ const filePath = join(decisionsPath, filename);
360
+
361
+ // Markdown으로 변환 및 저장
362
+ const markdown = formatADRAsMarkdown(fullDecision);
363
+ await writeFile(filePath, markdown, "utf-8");
364
+
365
+ // architecture.json 업데이트
366
+ await updateCompactArchitecture(rootDir);
367
+
368
+ return {
369
+ success: true,
370
+ filePath,
371
+ message: `Decision ${fullDecision.id} saved successfully`,
372
+ };
373
+ }
374
+
375
+ /**
376
+ * 일관성 검사
377
+ * 특정 작업이 기존 결정과 충돌하는지 확인
378
+ */
379
+ export async function checkConsistency(
380
+ rootDir: string,
381
+ intent: string,
382
+ proposedTags: string[]
383
+ ): Promise<ConsistencyCheckResult> {
384
+ const searchResult = await searchDecisions(rootDir, proposedTags);
385
+ const warnings: string[] = [];
386
+ const suggestions: string[] = [];
387
+
388
+ // 관련 결정 분석
389
+ for (const decision of searchResult.decisions) {
390
+ // Deprecated 결정 경고
391
+ if (decision.status === "deprecated") {
392
+ warnings.push(
393
+ `⚠️ ${decision.id} is deprecated: ${decision.title}`
394
+ );
395
+ }
396
+
397
+ // Superseded 결정 경고
398
+ if (decision.status === "superseded" && decision.supersededBy) {
399
+ warnings.push(
400
+ `⚠️ ${decision.id} was superseded by ${decision.supersededBy}`
401
+ );
402
+ suggestions.push(
403
+ `Check ${decision.supersededBy} for current guidelines`
404
+ );
405
+ }
406
+
407
+ // 결정 내용 기반 제안
408
+ if (decision.status === "accepted") {
409
+ suggestions.push(
410
+ `📋 ${decision.id}: ${decision.decision.slice(0, 100)}...`
411
+ );
412
+ }
413
+ }
414
+
415
+ return {
416
+ consistent: warnings.length === 0,
417
+ relatedDecisions: searchResult.decisions,
418
+ warnings,
419
+ suggestions,
420
+ };
421
+ }
422
+
423
+ /**
424
+ * 압축 아키텍처 정보 생성 (AI용)
425
+ */
426
+ export async function generateCompactArchitecture(
427
+ rootDir: string
428
+ ): Promise<CompactArchitecture> {
429
+ const decisions = await getAllDecisions(rootDir);
430
+
431
+ // 태그별 카운트
432
+ const tagCounts: Record<string, number> = {};
433
+ decisions.forEach((d) => {
434
+ d.tags.forEach((tag) => {
435
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
436
+ });
437
+ });
438
+
439
+ // 핵심 결정 (accepted만)
440
+ const acceptedDecisions = decisions.filter((d) => d.status === "accepted");
441
+ const keyDecisions = acceptedDecisions.map((d) => ({
442
+ id: d.id,
443
+ title: d.title,
444
+ tags: d.tags,
445
+ summary: d.decision.slice(0, 200),
446
+ }));
447
+
448
+ // 규칙 요약 추출 (결정에서 핵심 규칙 추출)
449
+ const rules = acceptedDecisions
450
+ .flatMap((d) => {
451
+ const ruleMatches = d.decision.match(/(?:사용|금지|위치|필수|권장)[^.]*\./g);
452
+ return ruleMatches || [];
453
+ })
454
+ .slice(0, 10);
455
+
456
+ // 프로젝트 이름 추출 시도
457
+ let projectName = "unknown";
458
+ try {
459
+ const packageJson = await readFile(join(rootDir, "package.json"), "utf-8");
460
+ const pkg = JSON.parse(packageJson);
461
+ projectName = pkg.name || "unknown";
462
+ } catch {
463
+ // ignore
464
+ }
465
+
466
+ return {
467
+ project: projectName,
468
+ lastUpdated: new Date().toISOString(),
469
+ keyDecisions,
470
+ tagCounts,
471
+ rules,
472
+ };
473
+ }
474
+
475
+ /**
476
+ * architecture.json 업데이트
477
+ */
478
+ export async function updateCompactArchitecture(rootDir: string): Promise<void> {
479
+ const compact = await generateCompactArchitecture(rootDir);
480
+ const archPath = join(rootDir, ARCHITECTURE_FILE);
481
+
482
+ // spec 디렉토리 확인
483
+ await mkdir(join(rootDir, "spec"), { recursive: true });
484
+
485
+ await writeFile(archPath, JSON.stringify(compact, null, 2), "utf-8");
486
+ }
487
+
488
+ /**
489
+ * architecture.json 읽기
490
+ */
491
+ export async function getCompactArchitecture(
492
+ rootDir: string
493
+ ): Promise<CompactArchitecture | null> {
494
+ const archPath = join(rootDir, ARCHITECTURE_FILE);
495
+
496
+ try {
497
+ const content = await readFile(archPath, "utf-8");
498
+ return JSON.parse(content);
499
+ } catch {
500
+ // 파일이 없으면 생성 후 반환
501
+ await updateCompactArchitecture(rootDir);
502
+ return generateCompactArchitecture(rootDir);
503
+ }
504
+ }
505
+
506
+ /**
507
+ * 다음 ADR ID 생성
508
+ */
509
+ export async function getNextDecisionId(rootDir: string): Promise<string> {
510
+ const decisions = await getAllDecisions(rootDir);
511
+
512
+ if (decisions.length === 0) {
513
+ return "ADR-001";
514
+ }
515
+
516
+ // 가장 높은 ID 찾기
517
+ const maxId = decisions.reduce((max, d) => {
518
+ const num = parseInt(d.id.replace("ADR-", ""), 10) || 0;
519
+ return Math.max(max, num);
520
+ }, 0);
521
+
522
+ return `ADR-${String(maxId + 1).padStart(3, "0")}`;
523
+ }
524
+
525
+ // ═══════════════════════════════════════════════════════════════════════════
526
+ // Export for index.ts
527
+ // ═══════════════════════════════════════════════════════════════════════════
528
+
529
+ export {
530
+ DECISIONS_DIR,
531
+ ARCHITECTURE_FILE,
532
+ };