@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,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
+ });