@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.
- package/package.json +1 -1
- package/src/brain/doctor/config-analyzer.ts +498 -0
- package/src/brain/doctor/index.ts +10 -0
- package/src/change/snapshot.ts +46 -1
- package/src/change/types.ts +13 -0
- package/src/config/index.ts +8 -2
- package/src/config/mcp-ref.ts +348 -0
- package/src/config/mcp-status.ts +348 -0
- package/src/config/metadata.test.ts +308 -0
- package/src/config/metadata.ts +293 -0
- package/src/config/symbols.ts +144 -0
- package/src/contract/index.ts +26 -25
- package/src/contract/protection.ts +364 -0
- package/src/error/domains.ts +265 -0
- package/src/error/index.ts +25 -13
- package/src/filling/filling.ts +88 -6
- package/src/guard/analyzer.ts +7 -2
- package/src/guard/config-guard.ts +281 -0
- package/src/guard/decision-memory.test.ts +293 -0
- package/src/guard/decision-memory.ts +532 -0
- package/src/guard/healing.test.ts +259 -0
- package/src/guard/healing.ts +874 -0
- package/src/guard/index.ts +119 -0
- package/src/guard/negotiation.test.ts +282 -0
- package/src/guard/negotiation.ts +975 -0
- package/src/guard/semantic-slots.test.ts +379 -0
- package/src/guard/semantic-slots.ts +796 -0
- package/src/index.ts +2 -0
- package/src/lockfile/generate.ts +259 -0
- package/src/lockfile/index.ts +186 -0
- package/src/lockfile/lockfile.test.ts +410 -0
- package/src/lockfile/types.ts +184 -0
- package/src/lockfile/validate.ts +308 -0
- package/src/runtime/security.ts +155 -0
- package/src/runtime/server.ts +320 -258
- package/src/utils/differ.test.ts +342 -0
- package/src/utils/differ.ts +482 -0
- package/src/utils/hasher.test.ts +326 -0
- package/src/utils/hasher.ts +319 -0
- package/src/utils/index.ts +29 -0
- 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
|
+
};
|