@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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config Guard - 설정 무결성 검증
|
|
3
|
+
*
|
|
4
|
+
* Lockfile을 사용한 설정 무결성 검증을 Guard 시스템에 통합
|
|
5
|
+
*
|
|
6
|
+
* @see docs/plans/09_lockfile_integration_plan.md
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
readLockfile,
|
|
11
|
+
readMcpConfig,
|
|
12
|
+
validateLockfile,
|
|
13
|
+
validateWithPolicy,
|
|
14
|
+
detectMode,
|
|
15
|
+
type ManduLockfile,
|
|
16
|
+
type LockfileValidationResult,
|
|
17
|
+
type LockfileMode,
|
|
18
|
+
} from "../lockfile";
|
|
19
|
+
import type { ConfigDiff } from "../utils/differ";
|
|
20
|
+
|
|
21
|
+
// ============================================
|
|
22
|
+
// 타입
|
|
23
|
+
// ============================================
|
|
24
|
+
|
|
25
|
+
export interface ConfigGuardError {
|
|
26
|
+
code: string;
|
|
27
|
+
message: string;
|
|
28
|
+
details?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ConfigGuardWarning {
|
|
32
|
+
code: string;
|
|
33
|
+
message: string;
|
|
34
|
+
details?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ConfigGuardResult {
|
|
38
|
+
/** 설정 로드 성공 여부 */
|
|
39
|
+
configValid: boolean;
|
|
40
|
+
/** lockfile 검증 통과 여부 */
|
|
41
|
+
lockfileValid: boolean;
|
|
42
|
+
/** lockfile 존재 여부 */
|
|
43
|
+
lockfileExists: boolean;
|
|
44
|
+
/** 심각한 오류 */
|
|
45
|
+
errors: ConfigGuardError[];
|
|
46
|
+
/** 경고 */
|
|
47
|
+
warnings: ConfigGuardWarning[];
|
|
48
|
+
/** 설정 변경 사항 */
|
|
49
|
+
diff?: ConfigDiff;
|
|
50
|
+
/** 현재 해시 */
|
|
51
|
+
currentHash?: string;
|
|
52
|
+
/** lockfile 해시 */
|
|
53
|
+
lockedHash?: string;
|
|
54
|
+
/** 정책 액션 */
|
|
55
|
+
action: "pass" | "warn" | "error" | "block";
|
|
56
|
+
/** 우회 여부 */
|
|
57
|
+
bypassed: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ConfigGuardOptions {
|
|
61
|
+
/** 검증 모드 (환경 자동 감지가 기본) */
|
|
62
|
+
mode?: LockfileMode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ============================================
|
|
66
|
+
// 메인 함수
|
|
67
|
+
// ============================================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 설정 무결성 검증 (Guard 통합용)
|
|
71
|
+
*
|
|
72
|
+
* @param rootDir 프로젝트 루트 디렉토리
|
|
73
|
+
* @param config 현재 설정 객체
|
|
74
|
+
* @param options 검증 옵션
|
|
75
|
+
* @returns 검증 결과
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```typescript
|
|
79
|
+
* const result = await guardConfig(rootDir, config);
|
|
80
|
+
* if (!result.lockfileValid) {
|
|
81
|
+
* console.error("설정 무결성 검증 실패");
|
|
82
|
+
* }
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export async function guardConfig(
|
|
86
|
+
rootDir: string,
|
|
87
|
+
config: Record<string, unknown>,
|
|
88
|
+
options: ConfigGuardOptions = {}
|
|
89
|
+
): Promise<ConfigGuardResult> {
|
|
90
|
+
const errors: ConfigGuardError[] = [];
|
|
91
|
+
const warnings: ConfigGuardWarning[] = [];
|
|
92
|
+
|
|
93
|
+
// 1. Lockfile 읽기
|
|
94
|
+
const lockfile = await readLockfile(rootDir);
|
|
95
|
+
const lockfileExists = lockfile !== null;
|
|
96
|
+
|
|
97
|
+
// 1-1. MCP 설정 읽기 (선택)
|
|
98
|
+
let mcpConfig: Record<string, unknown> | null = null;
|
|
99
|
+
try {
|
|
100
|
+
mcpConfig = await readMcpConfig(rootDir);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
warnings.push({
|
|
103
|
+
code: "MCP_CONFIG_PARSE_ERROR",
|
|
104
|
+
message: `MCP 설정 로드 실패: ${error instanceof Error ? error.message : String(error)}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. 정책 기반 검증
|
|
109
|
+
const mode = options.mode ?? detectMode();
|
|
110
|
+
const { result, action, bypassed } = validateWithPolicy(config, lockfile, mode, mcpConfig);
|
|
111
|
+
|
|
112
|
+
// 3. 결과 처리
|
|
113
|
+
if (!lockfileExists) {
|
|
114
|
+
warnings.push({
|
|
115
|
+
code: "LOCKFILE_NOT_FOUND",
|
|
116
|
+
message: "Lockfile이 존재하지 않습니다. 'mandu lock'으로 생성하세요.",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (result) {
|
|
121
|
+
// 오류 변환
|
|
122
|
+
for (const error of result.errors) {
|
|
123
|
+
errors.push({
|
|
124
|
+
code: error.code,
|
|
125
|
+
message: error.message,
|
|
126
|
+
details: error.details,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 경고 변환
|
|
131
|
+
for (const warning of result.warnings) {
|
|
132
|
+
warnings.push({
|
|
133
|
+
code: warning.code,
|
|
134
|
+
message: warning.message,
|
|
135
|
+
details: warning.details,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
configValid: true,
|
|
142
|
+
lockfileValid: result?.valid ?? false,
|
|
143
|
+
lockfileExists,
|
|
144
|
+
errors,
|
|
145
|
+
warnings,
|
|
146
|
+
diff: result?.diff,
|
|
147
|
+
currentHash: result?.currentHash,
|
|
148
|
+
lockedHash: result?.lockedHash,
|
|
149
|
+
action,
|
|
150
|
+
bypassed,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 빠른 무결성 검증 (해시만 비교)
|
|
156
|
+
*/
|
|
157
|
+
export async function quickConfigGuard(
|
|
158
|
+
rootDir: string,
|
|
159
|
+
config: Record<string, unknown>
|
|
160
|
+
): Promise<boolean> {
|
|
161
|
+
const lockfile = await readLockfile(rootDir);
|
|
162
|
+
if (!lockfile) return true; // lockfile 없으면 통과
|
|
163
|
+
let mcpConfig: Record<string, unknown> | null = null;
|
|
164
|
+
try {
|
|
165
|
+
mcpConfig = await readMcpConfig(rootDir);
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const result = validateLockfile(config, lockfile, mcpConfig);
|
|
171
|
+
return result.valid;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================
|
|
175
|
+
// 포맷팅
|
|
176
|
+
// ============================================
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Config Guard 결과를 콘솔 메시지로 변환
|
|
180
|
+
*/
|
|
181
|
+
export function formatConfigGuardResult(result: ConfigGuardResult): string {
|
|
182
|
+
const lines: string[] = [];
|
|
183
|
+
|
|
184
|
+
if (result.lockfileValid) {
|
|
185
|
+
lines.push("✅ 설정 무결성 확인됨");
|
|
186
|
+
if (result.currentHash) {
|
|
187
|
+
lines.push(` 해시: ${result.currentHash}`);
|
|
188
|
+
}
|
|
189
|
+
} else if (!result.lockfileExists) {
|
|
190
|
+
lines.push("💡 Lockfile 없음");
|
|
191
|
+
lines.push(" 'mandu lock'으로 생성 권장");
|
|
192
|
+
} else {
|
|
193
|
+
lines.push("❌ 설정 무결성 검증 실패");
|
|
194
|
+
|
|
195
|
+
for (const error of result.errors) {
|
|
196
|
+
lines.push(` 🔴 ${error.message}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (result.warnings.length > 0 && result.lockfileExists) {
|
|
201
|
+
lines.push("");
|
|
202
|
+
lines.push(" 경고:");
|
|
203
|
+
for (const warning of result.warnings) {
|
|
204
|
+
lines.push(` ⚠️ ${warning.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (result.bypassed) {
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(" ⚡ MANDU_LOCK_BYPASS=1로 우회됨");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Config Guard 결과를 JSON으로 변환 (에이전트용)
|
|
218
|
+
*/
|
|
219
|
+
export function formatConfigGuardAsJSON(result: ConfigGuardResult): string {
|
|
220
|
+
return JSON.stringify(
|
|
221
|
+
{
|
|
222
|
+
ok: result.lockfileValid,
|
|
223
|
+
lockfileExists: result.lockfileExists,
|
|
224
|
+
action: result.action,
|
|
225
|
+
bypassed: result.bypassed,
|
|
226
|
+
currentHash: result.currentHash,
|
|
227
|
+
lockedHash: result.lockedHash,
|
|
228
|
+
errors: result.errors,
|
|
229
|
+
warnings: result.warnings,
|
|
230
|
+
hasDiff: result.diff?.hasChanges ?? false,
|
|
231
|
+
},
|
|
232
|
+
null,
|
|
233
|
+
2
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ============================================
|
|
238
|
+
// 통합 헬스 체크
|
|
239
|
+
// ============================================
|
|
240
|
+
|
|
241
|
+
export interface UnifiedHealthResult {
|
|
242
|
+
/** 전체 통과 여부 */
|
|
243
|
+
ok: boolean;
|
|
244
|
+
/** 건강 점수 (0-100) */
|
|
245
|
+
healthScore: number;
|
|
246
|
+
/** 아키텍처 검증 */
|
|
247
|
+
architecture: {
|
|
248
|
+
violations: number;
|
|
249
|
+
errors: number;
|
|
250
|
+
warnings: number;
|
|
251
|
+
};
|
|
252
|
+
/** 설정 검증 */
|
|
253
|
+
config: ConfigGuardResult;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 통합 헬스 점수 계산
|
|
258
|
+
*/
|
|
259
|
+
export function calculateHealthScore(
|
|
260
|
+
archViolations: number,
|
|
261
|
+
archErrors: number,
|
|
262
|
+
configResult: ConfigGuardResult
|
|
263
|
+
): number {
|
|
264
|
+
let score = 100;
|
|
265
|
+
|
|
266
|
+
// 아키텍처 위반 감점
|
|
267
|
+
score -= archErrors * 10;
|
|
268
|
+
score -= (archViolations - archErrors) * 2;
|
|
269
|
+
|
|
270
|
+
// 설정 무결성 감점
|
|
271
|
+
if (!configResult.lockfileExists) {
|
|
272
|
+
score -= 5; // lockfile 없음
|
|
273
|
+
} else if (!configResult.lockfileValid) {
|
|
274
|
+
score -= 20; // 불일치
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 경고 감점
|
|
278
|
+
score -= configResult.warnings.length * 1;
|
|
279
|
+
|
|
280
|
+
return Math.max(0, Math.min(100, score));
|
|
281
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision Memory Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
6
|
+
import { mkdir, rm, mkdtemp, readFile } from "fs/promises";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
import {
|
|
10
|
+
parseADRMarkdown,
|
|
11
|
+
formatADRAsMarkdown,
|
|
12
|
+
getAllDecisions,
|
|
13
|
+
getDecisionById,
|
|
14
|
+
searchDecisions,
|
|
15
|
+
saveDecision,
|
|
16
|
+
checkConsistency,
|
|
17
|
+
generateCompactArchitecture,
|
|
18
|
+
getNextDecisionId,
|
|
19
|
+
type ArchitectureDecision,
|
|
20
|
+
} from "./decision-memory";
|
|
21
|
+
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
// Test Setup
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
25
|
+
|
|
26
|
+
let TEST_DIR: string;
|
|
27
|
+
|
|
28
|
+
const SAMPLE_ADR_CONTENT = `# Use JWT for API Authentication
|
|
29
|
+
|
|
30
|
+
**ID:** ADR-001
|
|
31
|
+
**Status:** accepted
|
|
32
|
+
**Date:** 2024-01-15
|
|
33
|
+
**Tags:** auth, api, security, jwt
|
|
34
|
+
|
|
35
|
+
## Context
|
|
36
|
+
|
|
37
|
+
API 인증 방식을 결정해야 합니다. 세션 기반과 토큰 기반 중 선택이 필요합니다.
|
|
38
|
+
|
|
39
|
+
## Decision
|
|
40
|
+
|
|
41
|
+
JWT (JSON Web Token) + Refresh Token 조합을 사용합니다.
|
|
42
|
+
- Access Token: 15분 만료
|
|
43
|
+
- Refresh Token: 7일 만료, Redis에 저장
|
|
44
|
+
|
|
45
|
+
## Consequences
|
|
46
|
+
|
|
47
|
+
- 토큰 만료 관리가 필요합니다
|
|
48
|
+
- Redis 세션 저장소가 필요합니다
|
|
49
|
+
- Stateless 아키텍처 유지 가능
|
|
50
|
+
|
|
51
|
+
## Related Decisions
|
|
52
|
+
|
|
53
|
+
- ADR-002
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const SAMPLE_ADR_CONTENT_2 = `# Use Redis for Session Storage
|
|
57
|
+
|
|
58
|
+
**ID:** ADR-002
|
|
59
|
+
**Status:** accepted
|
|
60
|
+
**Date:** 2024-01-16
|
|
61
|
+
**Tags:** cache, session, redis, infrastructure
|
|
62
|
+
|
|
63
|
+
## Context
|
|
64
|
+
|
|
65
|
+
세션 및 캐시 저장소 선택이 필요합니다.
|
|
66
|
+
|
|
67
|
+
## Decision
|
|
68
|
+
|
|
69
|
+
Redis를 통합 캐시/세션 저장소로 사용합니다.
|
|
70
|
+
server/infra/cache/를 통해서만 접근합니다.
|
|
71
|
+
|
|
72
|
+
## Consequences
|
|
73
|
+
|
|
74
|
+
- 직접 Redis 클라이언트 사용 금지
|
|
75
|
+
- 캐시 추상화 레이어 필요
|
|
76
|
+
|
|
77
|
+
## Related Decisions
|
|
78
|
+
|
|
79
|
+
- ADR-001
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
// 임시 디렉토리 생성
|
|
84
|
+
TEST_DIR = await mkdtemp(join(tmpdir(), "test-decision-memory-"));
|
|
85
|
+
|
|
86
|
+
// spec/decisions 디렉토리 생성
|
|
87
|
+
await mkdir(join(TEST_DIR, "spec", "decisions"), { recursive: true });
|
|
88
|
+
|
|
89
|
+
// 샘플 ADR 파일 생성
|
|
90
|
+
await Bun.write(
|
|
91
|
+
join(TEST_DIR, "spec", "decisions", "ADR-001-jwt-auth.md"),
|
|
92
|
+
SAMPLE_ADR_CONTENT
|
|
93
|
+
);
|
|
94
|
+
await Bun.write(
|
|
95
|
+
join(TEST_DIR, "spec", "decisions", "ADR-002-redis-session.md"),
|
|
96
|
+
SAMPLE_ADR_CONTENT_2
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// package.json 생성 (프로젝트 이름용)
|
|
100
|
+
await Bun.write(
|
|
101
|
+
join(TEST_DIR, "package.json"),
|
|
102
|
+
JSON.stringify({ name: "test-project" }, null, 2)
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(async () => {
|
|
107
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
+
// Unit Tests
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
describe("Decision Memory", () => {
|
|
115
|
+
describe("parseADRMarkdown", () => {
|
|
116
|
+
it("should parse ADR markdown correctly", () => {
|
|
117
|
+
const result = parseADRMarkdown(SAMPLE_ADR_CONTENT, "ADR-001-jwt-auth.md");
|
|
118
|
+
|
|
119
|
+
expect(result).not.toBeNull();
|
|
120
|
+
expect(result!.id).toBe("ADR-001");
|
|
121
|
+
expect(result!.title).toBe("Use JWT for API Authentication");
|
|
122
|
+
expect(result!.status).toBe("accepted");
|
|
123
|
+
expect(result!.date).toBe("2024-01-15");
|
|
124
|
+
expect(result!.tags).toContain("auth");
|
|
125
|
+
expect(result!.tags).toContain("jwt");
|
|
126
|
+
expect(result!.context).toContain("API 인증 방식");
|
|
127
|
+
expect(result!.decision).toContain("JWT");
|
|
128
|
+
expect(result!.consequences.length).toBeGreaterThan(0);
|
|
129
|
+
expect(result!.relatedDecisions).toContain("ADR-002");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should handle malformed content gracefully", () => {
|
|
133
|
+
const result = parseADRMarkdown("# Simple Title\n\nSome content", "test.md");
|
|
134
|
+
|
|
135
|
+
expect(result).not.toBeNull();
|
|
136
|
+
expect(result!.title).toBe("Simple Title");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("formatADRAsMarkdown", () => {
|
|
141
|
+
it("should format decision as markdown", () => {
|
|
142
|
+
const decision: ArchitectureDecision = {
|
|
143
|
+
id: "ADR-003",
|
|
144
|
+
title: "Test Decision",
|
|
145
|
+
status: "proposed",
|
|
146
|
+
date: "2024-02-01",
|
|
147
|
+
tags: ["test", "example"],
|
|
148
|
+
context: "This is the context",
|
|
149
|
+
decision: "This is the decision",
|
|
150
|
+
consequences: ["Consequence 1", "Consequence 2"],
|
|
151
|
+
relatedDecisions: ["ADR-001"],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const markdown = formatADRAsMarkdown(decision);
|
|
155
|
+
|
|
156
|
+
expect(markdown).toContain("# Test Decision");
|
|
157
|
+
expect(markdown).toContain("**ID:** ADR-003");
|
|
158
|
+
expect(markdown).toContain("**Status:** proposed");
|
|
159
|
+
expect(markdown).toContain("test, example");
|
|
160
|
+
expect(markdown).toContain("This is the context");
|
|
161
|
+
expect(markdown).toContain("- Consequence 1");
|
|
162
|
+
expect(markdown).toContain("- ADR-001");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("getAllDecisions", () => {
|
|
167
|
+
it("should load all decisions from spec/decisions", async () => {
|
|
168
|
+
const decisions = await getAllDecisions(TEST_DIR);
|
|
169
|
+
|
|
170
|
+
expect(decisions.length).toBe(2);
|
|
171
|
+
expect(decisions[0].id).toBe("ADR-001");
|
|
172
|
+
expect(decisions[1].id).toBe("ADR-002");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should return empty array for non-existent directory", async () => {
|
|
176
|
+
const emptyDir = await mkdtemp(join(tmpdir(), "empty-"));
|
|
177
|
+
const decisions = await getAllDecisions(emptyDir);
|
|
178
|
+
|
|
179
|
+
expect(decisions).toEqual([]);
|
|
180
|
+
|
|
181
|
+
await rm(emptyDir, { recursive: true, force: true });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("getDecisionById", () => {
|
|
186
|
+
it("should find decision by ID", async () => {
|
|
187
|
+
const decision = await getDecisionById(TEST_DIR, "ADR-001");
|
|
188
|
+
|
|
189
|
+
expect(decision).not.toBeNull();
|
|
190
|
+
expect(decision!.title).toBe("Use JWT for API Authentication");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should return null for non-existent ID", async () => {
|
|
194
|
+
const decision = await getDecisionById(TEST_DIR, "ADR-999");
|
|
195
|
+
|
|
196
|
+
expect(decision).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("searchDecisions", () => {
|
|
201
|
+
it("should find decisions by tags", async () => {
|
|
202
|
+
const result = await searchDecisions(TEST_DIR, ["auth"]);
|
|
203
|
+
|
|
204
|
+
expect(result.decisions.length).toBe(1);
|
|
205
|
+
expect(result.decisions[0].id).toBe("ADR-001");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should find decisions by multiple tags", async () => {
|
|
209
|
+
const result = await searchDecisions(TEST_DIR, ["auth", "cache"]);
|
|
210
|
+
|
|
211
|
+
expect(result.decisions.length).toBe(2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should return empty for non-matching tags", async () => {
|
|
215
|
+
const result = await searchDecisions(TEST_DIR, ["nonexistent"]);
|
|
216
|
+
|
|
217
|
+
expect(result.decisions.length).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("should perform partial tag matching", async () => {
|
|
221
|
+
const result = await searchDecisions(TEST_DIR, ["sec"]); // should match "security"
|
|
222
|
+
|
|
223
|
+
expect(result.decisions.length).toBeGreaterThan(0);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("saveDecision", () => {
|
|
228
|
+
it("should save new decision as markdown file", async () => {
|
|
229
|
+
const newDecision: Omit<ArchitectureDecision, "date"> = {
|
|
230
|
+
id: "ADR-003",
|
|
231
|
+
title: "Use Feature Flags",
|
|
232
|
+
status: "proposed",
|
|
233
|
+
tags: ["feature", "deployment"],
|
|
234
|
+
context: "Need controlled rollout",
|
|
235
|
+
decision: "Use feature flags for gradual rollout",
|
|
236
|
+
consequences: ["Need flag management system"],
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const result = await saveDecision(TEST_DIR, newDecision);
|
|
240
|
+
|
|
241
|
+
expect(result.success).toBe(true);
|
|
242
|
+
expect(result.filePath).toContain("ADR-003");
|
|
243
|
+
|
|
244
|
+
// 파일이 실제로 생성되었는지 확인
|
|
245
|
+
const content = await readFile(result.filePath, "utf-8");
|
|
246
|
+
expect(content).toContain("Use Feature Flags");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("checkConsistency", () => {
|
|
251
|
+
it("should find related decisions for consistency check", async () => {
|
|
252
|
+
const result = await checkConsistency(TEST_DIR, "Add caching layer", ["cache"]);
|
|
253
|
+
|
|
254
|
+
expect(result.relatedDecisions.length).toBeGreaterThan(0);
|
|
255
|
+
expect(result.suggestions.length).toBeGreaterThan(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("should return consistent=true when no warnings", async () => {
|
|
259
|
+
const result = await checkConsistency(TEST_DIR, "New feature", ["auth"]);
|
|
260
|
+
|
|
261
|
+
// accepted 상태이므로 warning 없음
|
|
262
|
+
expect(result.consistent).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("generateCompactArchitecture", () => {
|
|
267
|
+
it("should generate compact architecture summary", async () => {
|
|
268
|
+
const compact = await generateCompactArchitecture(TEST_DIR);
|
|
269
|
+
|
|
270
|
+
expect(compact.project).toBe("test-project");
|
|
271
|
+
expect(compact.keyDecisions.length).toBeGreaterThan(0);
|
|
272
|
+
expect(compact.tagCounts["auth"]).toBeGreaterThan(0);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("getNextDecisionId", () => {
|
|
277
|
+
it("should return next sequential ID", async () => {
|
|
278
|
+
// ADR-003을 추가했으므로 다음은 ADR-004
|
|
279
|
+
const nextId = await getNextDecisionId(TEST_DIR);
|
|
280
|
+
|
|
281
|
+
expect(nextId).toBe("ADR-004");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("should return ADR-001 for empty project", async () => {
|
|
285
|
+
const emptyDir = await mkdtemp(join(tmpdir(), "empty-decisions-"));
|
|
286
|
+
const nextId = await getNextDecisionId(emptyDir);
|
|
287
|
+
|
|
288
|
+
expect(nextId).toBe("ADR-001");
|
|
289
|
+
|
|
290
|
+
await rm(emptyDir, { recursive: true, force: true });
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|