@mandujs/core 0.8.2 → 0.9.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.
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Brain v0.1 - Doctor Patcher
3
+ *
4
+ * Generates and applies minimal patches to fix violations.
5
+ * Auto-apply is disabled by default - patches are suggestions only.
6
+ */
7
+
8
+ import type { PatchSuggestion } from "../types";
9
+ import type { DoctorAnalysis } from "../types";
10
+ import {
11
+ validatePatchSuggestion,
12
+ filterSafePatchSuggestions,
13
+ } from "../permissions";
14
+ import path from "path";
15
+ import fs from "fs/promises";
16
+
17
+ /**
18
+ * Patch application result
19
+ */
20
+ export interface PatchResult {
21
+ /** Whether the patch was applied */
22
+ applied: boolean;
23
+ /** The patch that was applied */
24
+ patch: PatchSuggestion;
25
+ /** Error message if failed */
26
+ error?: string;
27
+ /** Output from command (for command type) */
28
+ output?: string;
29
+ }
30
+
31
+ /**
32
+ * Batch patch result
33
+ */
34
+ export interface BatchPatchResult {
35
+ /** Total patches attempted */
36
+ total: number;
37
+ /** Successfully applied */
38
+ applied: number;
39
+ /** Failed patches */
40
+ failed: number;
41
+ /** Skipped (unsafe) patches */
42
+ skipped: number;
43
+ /** Individual results */
44
+ results: PatchResult[];
45
+ }
46
+
47
+ /**
48
+ * Prioritize patches by confidence and type
49
+ */
50
+ export function prioritizePatches(patches: PatchSuggestion[]): PatchSuggestion[] {
51
+ return [...patches].sort((a, b) => {
52
+ // Commands before modifications
53
+ if (a.type === "command" && b.type !== "command") return -1;
54
+ if (b.type === "command" && a.type !== "command") return 1;
55
+
56
+ // Higher confidence first
57
+ return b.confidence - a.confidence;
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Deduplicate patches by file and type
63
+ */
64
+ export function deduplicatePatches(patches: PatchSuggestion[]): PatchSuggestion[] {
65
+ const seen = new Set<string>();
66
+ const result: PatchSuggestion[] = [];
67
+
68
+ for (const patch of patches) {
69
+ const key = `${patch.file}:${patch.type}:${patch.command || ""}`;
70
+ if (!seen.has(key)) {
71
+ seen.add(key);
72
+ result.push(patch);
73
+ }
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Generate a minimal patch for a violation
81
+ *
82
+ * Returns a human-readable patch description.
83
+ */
84
+ export function generatePatchDescription(patch: PatchSuggestion): string {
85
+ const confidenceLabel =
86
+ patch.confidence >= 0.8
87
+ ? "높음"
88
+ : patch.confidence >= 0.5
89
+ ? "보통"
90
+ : "낮음";
91
+
92
+ switch (patch.type) {
93
+ case "command":
94
+ return `[명령어 실행] ${patch.command}\n 대상: ${patch.file}\n 설명: ${patch.description}\n 신뢰도: ${confidenceLabel}`;
95
+
96
+ case "add":
97
+ return `[파일 생성] ${patch.file}\n 설명: ${patch.description}\n 신뢰도: ${confidenceLabel}`;
98
+
99
+ case "modify":
100
+ const lineInfo = patch.line ? ` (라인 ${patch.line})` : "";
101
+ return `[파일 수정] ${patch.file}${lineInfo}\n 설명: ${patch.description}\n 신뢰도: ${confidenceLabel}`;
102
+
103
+ case "delete":
104
+ return `[파일 삭제] ${patch.file}\n 설명: ${patch.description}\n 신뢰도: ${confidenceLabel}`;
105
+
106
+ default:
107
+ return `[${patch.type}] ${patch.file}\n 설명: ${patch.description}`;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Apply a single patch
113
+ *
114
+ * NOTE: This is experimental and disabled by default.
115
+ * Only applies safe patches (commands like mandu generate).
116
+ */
117
+ export async function applyPatch(
118
+ patch: PatchSuggestion,
119
+ rootDir: string
120
+ ): Promise<PatchResult> {
121
+ // Validate patch first
122
+ const validation = validatePatchSuggestion(patch);
123
+
124
+ if (!validation.valid) {
125
+ return {
126
+ applied: false,
127
+ patch,
128
+ error: validation.reason || "Patch validation failed",
129
+ };
130
+ }
131
+
132
+ if (validation.requiresConfirmation) {
133
+ return {
134
+ applied: false,
135
+ patch,
136
+ error: `Manual confirmation required: ${validation.reason}`,
137
+ };
138
+ }
139
+
140
+ try {
141
+ switch (patch.type) {
142
+ case "command": {
143
+ if (!patch.command) {
144
+ return {
145
+ applied: false,
146
+ patch,
147
+ error: "No command specified",
148
+ };
149
+ }
150
+
151
+ // Only allow mandu commands for safety
152
+ if (!patch.command.startsWith("bunx mandu")) {
153
+ return {
154
+ applied: false,
155
+ patch,
156
+ error: "Only mandu commands are allowed for auto-apply",
157
+ };
158
+ }
159
+
160
+ // Execute the command
161
+ const proc = Bun.spawn(patch.command.split(" "), {
162
+ cwd: rootDir,
163
+ stdout: "pipe",
164
+ stderr: "pipe",
165
+ });
166
+
167
+ const stdout = await new Response(proc.stdout).text();
168
+ const stderr = await new Response(proc.stderr).text();
169
+ const exitCode = await proc.exited;
170
+
171
+ if (exitCode !== 0) {
172
+ return {
173
+ applied: false,
174
+ patch,
175
+ error: stderr || `Command exited with code ${exitCode}`,
176
+ output: stdout,
177
+ };
178
+ }
179
+
180
+ return {
181
+ applied: true,
182
+ patch,
183
+ output: stdout,
184
+ };
185
+ }
186
+
187
+ case "add":
188
+ case "modify": {
189
+ if (!patch.content) {
190
+ return {
191
+ applied: false,
192
+ patch,
193
+ error: "No content specified for file operation",
194
+ };
195
+ }
196
+
197
+ const filePath = path.join(rootDir, patch.file);
198
+
199
+ // Ensure directory exists
200
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
201
+
202
+ // Write content
203
+ await fs.writeFile(filePath, patch.content, "utf-8");
204
+
205
+ return {
206
+ applied: true,
207
+ patch,
208
+ };
209
+ }
210
+
211
+ case "delete": {
212
+ const filePath = path.join(rootDir, patch.file);
213
+
214
+ try {
215
+ await fs.unlink(filePath);
216
+ return {
217
+ applied: true,
218
+ patch,
219
+ };
220
+ } catch (error) {
221
+ return {
222
+ applied: false,
223
+ patch,
224
+ error:
225
+ error instanceof Error
226
+ ? error.message
227
+ : "Failed to delete file",
228
+ };
229
+ }
230
+ }
231
+
232
+ default:
233
+ return {
234
+ applied: false,
235
+ patch,
236
+ error: `Unknown patch type: ${patch.type}`,
237
+ };
238
+ }
239
+ } catch (error) {
240
+ return {
241
+ applied: false,
242
+ patch,
243
+ error: error instanceof Error ? error.message : "Unknown error",
244
+ };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Apply multiple patches
250
+ *
251
+ * NOTE: Auto-apply is experimental and disabled by default.
252
+ */
253
+ export async function applyPatches(
254
+ patches: PatchSuggestion[],
255
+ rootDir: string,
256
+ options: { dryRun?: boolean; minConfidence?: number } = {}
257
+ ): Promise<BatchPatchResult> {
258
+ const { dryRun = true, minConfidence = 0.5 } = options;
259
+
260
+ // Filter safe patches
261
+ const safePatches = filterSafePatchSuggestions(patches);
262
+
263
+ // Filter by confidence
264
+ const confidentPatches = safePatches.filter(
265
+ (p) => p.confidence >= minConfidence
266
+ );
267
+
268
+ // Deduplicate and prioritize
269
+ const orderedPatches = prioritizePatches(deduplicatePatches(confidentPatches));
270
+
271
+ const results: PatchResult[] = [];
272
+ let applied = 0;
273
+ let failed = 0;
274
+ const skipped = patches.length - orderedPatches.length;
275
+
276
+ for (const patch of orderedPatches) {
277
+ if (dryRun) {
278
+ // In dry run mode, just report what would be done
279
+ results.push({
280
+ applied: false,
281
+ patch,
282
+ error: "Dry run mode - patch not applied",
283
+ });
284
+ continue;
285
+ }
286
+
287
+ const result = await applyPatch(patch, rootDir);
288
+ results.push(result);
289
+
290
+ if (result.applied) {
291
+ applied++;
292
+ } else {
293
+ failed++;
294
+ }
295
+ }
296
+
297
+ return {
298
+ total: patches.length,
299
+ applied,
300
+ failed,
301
+ skipped,
302
+ results,
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Generate a patch report from analysis
308
+ */
309
+ export function generatePatchReport(analysis: DoctorAnalysis): string {
310
+ const lines: string[] = [];
311
+
312
+ lines.push("# Mandu Doctor Report");
313
+ lines.push("");
314
+ lines.push(`## 요약`);
315
+ lines.push(analysis.summary);
316
+ lines.push("");
317
+
318
+ if (analysis.explanation) {
319
+ lines.push(`## 상세 분석`);
320
+ lines.push(analysis.explanation);
321
+ lines.push("");
322
+ }
323
+
324
+ if (analysis.patches.length > 0) {
325
+ lines.push(`## 제안된 패치 (${analysis.patches.length}개)`);
326
+ lines.push("");
327
+
328
+ const prioritized = prioritizePatches(analysis.patches);
329
+
330
+ for (let i = 0; i < prioritized.length; i++) {
331
+ lines.push(`### ${i + 1}. ${prioritized[i].description}`);
332
+ lines.push(generatePatchDescription(prioritized[i]));
333
+ lines.push("");
334
+ }
335
+ }
336
+
337
+ if (analysis.nextCommand) {
338
+ lines.push(`## 권장 다음 명령어`);
339
+ lines.push("```bash");
340
+ lines.push(analysis.nextCommand);
341
+ lines.push("```");
342
+ lines.push("");
343
+ }
344
+
345
+ lines.push(`---`);
346
+ lines.push(`LLM 지원: ${analysis.llmAssisted ? "예" : "아니오"}`);
347
+
348
+ return lines.join("\n");
349
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Brain v0.1 - Doctor Reporter
3
+ *
4
+ * Formats and outputs Doctor analysis results.
5
+ * Works with or without LLM - always provides actionable output.
6
+ */
7
+
8
+ import type { DoctorAnalysis, PatchSuggestion } from "../types";
9
+ import type { GuardViolation } from "../../guard/rules";
10
+ import { prioritizePatches } from "./patcher";
11
+
12
+ /**
13
+ * ANSI color codes for terminal output
14
+ */
15
+ const colors = {
16
+ reset: "\x1b[0m",
17
+ bright: "\x1b[1m",
18
+ dim: "\x1b[2m",
19
+ red: "\x1b[31m",
20
+ green: "\x1b[32m",
21
+ yellow: "\x1b[33m",
22
+ blue: "\x1b[34m",
23
+ magenta: "\x1b[35m",
24
+ cyan: "\x1b[36m",
25
+ };
26
+
27
+ /**
28
+ * Check if colors should be used
29
+ */
30
+ function useColors(): boolean {
31
+ // Check for NO_COLOR environment variable
32
+ if (process.env.NO_COLOR) return false;
33
+
34
+ // Check if stdout is a TTY
35
+ if (typeof process?.stdout?.isTTY === "boolean") {
36
+ return process.stdout.isTTY;
37
+ }
38
+
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Apply color if enabled
44
+ */
45
+ function color(text: string, colorCode: string): string {
46
+ if (!useColors()) return text;
47
+ return `${colorCode}${text}${colors.reset}`;
48
+ }
49
+
50
+ /**
51
+ * Format a violation for terminal output
52
+ */
53
+ export function formatViolation(violation: GuardViolation): string {
54
+ const lines: string[] = [];
55
+
56
+ const severity = violation.severity || "error";
57
+ const icon = severity === "error" ? "❌" : "⚠️";
58
+ const colorCode = severity === "error" ? colors.red : colors.yellow;
59
+
60
+ lines.push(
61
+ `${icon} ${color(`[${violation.ruleId}]`, colorCode)} ${violation.file}`
62
+ );
63
+ lines.push(` ${violation.message}`);
64
+
65
+ if (violation.suggestion) {
66
+ lines.push(` ${color("💡", colors.cyan)} ${violation.suggestion}`);
67
+ }
68
+
69
+ if (violation.line) {
70
+ lines.push(` ${color("📍", colors.dim)} Line ${violation.line}`);
71
+ }
72
+
73
+ return lines.join("\n");
74
+ }
75
+
76
+ /**
77
+ * Format a patch suggestion for terminal output
78
+ */
79
+ export function formatPatch(patch: PatchSuggestion, index: number): string {
80
+ const lines: string[] = [];
81
+
82
+ const confidenceIcon =
83
+ patch.confidence >= 0.8 ? "🟢" : patch.confidence >= 0.5 ? "🟡" : "🔴";
84
+
85
+ lines.push(
86
+ `${color(`${index}.`, colors.bright)} ${patch.description} ${confidenceIcon}`
87
+ );
88
+
89
+ switch (patch.type) {
90
+ case "command":
91
+ lines.push(
92
+ ` ${color("$ ", colors.dim)}${color(patch.command || "", colors.cyan)}`
93
+ );
94
+ break;
95
+
96
+ case "add":
97
+ lines.push(` ${color("+ 파일 생성:", colors.green)} ${patch.file}`);
98
+ break;
99
+
100
+ case "modify":
101
+ lines.push(` ${color("~ 파일 수정:", colors.yellow)} ${patch.file}`);
102
+ if (patch.line) {
103
+ lines.push(` ${color("📍", colors.dim)} Line ${patch.line}`);
104
+ }
105
+ break;
106
+
107
+ case "delete":
108
+ lines.push(` ${color("- 파일 삭제:", colors.red)} ${patch.file}`);
109
+ break;
110
+ }
111
+
112
+ return lines.join("\n");
113
+ }
114
+
115
+ /**
116
+ * Print Doctor analysis to console
117
+ */
118
+ export function printDoctorReport(analysis: DoctorAnalysis): void {
119
+ const { violations, summary, explanation, patches, llmAssisted, nextCommand } =
120
+ analysis;
121
+
122
+ console.log();
123
+ console.log(color("🩺 Mandu Doctor Report", colors.bright + colors.blue));
124
+ console.log(color("─".repeat(50), colors.dim));
125
+ console.log();
126
+
127
+ // Summary
128
+ console.log(color("📋 요약", colors.bright));
129
+ console.log(` ${summary}`);
130
+ console.log();
131
+
132
+ // Violations
133
+ if (violations.length > 0) {
134
+ console.log(
135
+ color(`🔍 발견된 위반 (${violations.length}개)`, colors.bright)
136
+ );
137
+ console.log();
138
+
139
+ for (const violation of violations) {
140
+ console.log(formatViolation(violation));
141
+ console.log();
142
+ }
143
+ }
144
+
145
+ // Detailed explanation
146
+ if (explanation) {
147
+ console.log(color("📖 상세 분석", colors.bright));
148
+ console.log();
149
+
150
+ // Format explanation with proper indentation
151
+ const explLines = explanation.split("\n");
152
+ for (const line of explLines) {
153
+ if (line.startsWith("##")) {
154
+ console.log(color(line.replace("## ", "▸ "), colors.cyan));
155
+ } else if (line.trim()) {
156
+ console.log(` ${line}`);
157
+ } else {
158
+ console.log();
159
+ }
160
+ }
161
+ console.log();
162
+ }
163
+
164
+ // Patch suggestions
165
+ if (patches.length > 0) {
166
+ console.log(color(`💊 제안된 수정 (${patches.length}개)`, colors.bright));
167
+ console.log();
168
+
169
+ const prioritized = prioritizePatches(patches);
170
+
171
+ for (let i = 0; i < prioritized.length; i++) {
172
+ console.log(formatPatch(prioritized[i], i + 1));
173
+ console.log();
174
+ }
175
+ }
176
+
177
+ // Next command
178
+ if (nextCommand) {
179
+ console.log(color("▶ 권장 다음 명령어", colors.bright));
180
+ console.log();
181
+ console.log(` ${color("$ ", colors.dim)}${color(nextCommand, colors.green)}`);
182
+ console.log();
183
+ }
184
+
185
+ // Footer
186
+ console.log(color("─".repeat(50), colors.dim));
187
+ console.log(
188
+ `${color("ℹ️", colors.cyan)} LLM 지원: ${llmAssisted ? color("예", colors.green) : color("아니오", colors.yellow)}`
189
+ );
190
+ console.log();
191
+ }
192
+
193
+ /**
194
+ * Generate a JSON report for MCP/API consumption
195
+ */
196
+ export function generateJsonReport(analysis: DoctorAnalysis): string {
197
+ return JSON.stringify(
198
+ {
199
+ summary: analysis.summary,
200
+ violationCount: analysis.violations.length,
201
+ violations: analysis.violations.map((v) => ({
202
+ ruleId: v.ruleId,
203
+ file: v.file,
204
+ message: v.message,
205
+ suggestion: v.suggestion,
206
+ line: v.line,
207
+ severity: v.severity || "error",
208
+ })),
209
+ patches: analysis.patches.map((p) => ({
210
+ file: p.file,
211
+ type: p.type,
212
+ description: p.description,
213
+ command: p.command,
214
+ confidence: p.confidence,
215
+ })),
216
+ nextCommand: analysis.nextCommand,
217
+ llmAssisted: analysis.llmAssisted,
218
+ },
219
+ null,
220
+ 2
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Generate a Markdown report
226
+ */
227
+ export function generateMarkdownReport(analysis: DoctorAnalysis): string {
228
+ const lines: string[] = [];
229
+
230
+ lines.push("# 🩺 Mandu Doctor Report");
231
+ lines.push("");
232
+
233
+ lines.push("## 📋 요약");
234
+ lines.push("");
235
+ lines.push(analysis.summary);
236
+ lines.push("");
237
+
238
+ if (analysis.violations.length > 0) {
239
+ lines.push(`## 🔍 발견된 위반 (${analysis.violations.length}개)`);
240
+ lines.push("");
241
+
242
+ for (const v of analysis.violations) {
243
+ const severity = v.severity || "error";
244
+ const icon = severity === "error" ? "❌" : "⚠️";
245
+
246
+ lines.push(`### ${icon} ${v.ruleId}`);
247
+ lines.push("");
248
+ lines.push(`- **파일**: \`${v.file}\``);
249
+ lines.push(`- **메시지**: ${v.message}`);
250
+ if (v.suggestion) {
251
+ lines.push(`- **제안**: ${v.suggestion}`);
252
+ }
253
+ if (v.line) {
254
+ lines.push(`- **라인**: ${v.line}`);
255
+ }
256
+ lines.push("");
257
+ }
258
+ }
259
+
260
+ if (analysis.explanation) {
261
+ lines.push("## 📖 상세 분석");
262
+ lines.push("");
263
+ lines.push(analysis.explanation);
264
+ lines.push("");
265
+ }
266
+
267
+ if (analysis.patches.length > 0) {
268
+ lines.push(`## 💊 제안된 수정 (${analysis.patches.length}개)`);
269
+ lines.push("");
270
+
271
+ const prioritized = prioritizePatches(analysis.patches);
272
+
273
+ for (let i = 0; i < prioritized.length; i++) {
274
+ const p = prioritized[i];
275
+ const confidenceLabel =
276
+ p.confidence >= 0.8 ? "🟢 높음" : p.confidence >= 0.5 ? "🟡 보통" : "🔴 낮음";
277
+
278
+ lines.push(`### ${i + 1}. ${p.description}`);
279
+ lines.push("");
280
+ lines.push(`- **타입**: ${p.type}`);
281
+ lines.push(`- **대상**: \`${p.file}\``);
282
+ lines.push(`- **신뢰도**: ${confidenceLabel}`);
283
+
284
+ if (p.command) {
285
+ lines.push("");
286
+ lines.push("```bash");
287
+ lines.push(p.command);
288
+ lines.push("```");
289
+ }
290
+
291
+ lines.push("");
292
+ }
293
+ }
294
+
295
+ if (analysis.nextCommand) {
296
+ lines.push("## ▶ 권장 다음 명령어");
297
+ lines.push("");
298
+ lines.push("```bash");
299
+ lines.push(analysis.nextCommand);
300
+ lines.push("```");
301
+ lines.push("");
302
+ }
303
+
304
+ lines.push("---");
305
+ lines.push("");
306
+ lines.push(
307
+ `*LLM 지원: ${analysis.llmAssisted ? "예" : "아니오"}*`
308
+ );
309
+
310
+ return lines.join("\n");
311
+ }
312
+
313
+ /**
314
+ * Format output based on requested format
315
+ */
316
+ export type ReportFormat = "console" | "json" | "markdown";
317
+
318
+ export function formatDoctorReport(
319
+ analysis: DoctorAnalysis,
320
+ format: ReportFormat = "console"
321
+ ): string | void {
322
+ switch (format) {
323
+ case "console":
324
+ printDoctorReport(analysis);
325
+ return;
326
+
327
+ case "json":
328
+ return generateJsonReport(analysis);
329
+
330
+ case "markdown":
331
+ return generateMarkdownReport(analysis);
332
+
333
+ default:
334
+ printDoctorReport(analysis);
335
+ }
336
+ }