sanjang 0.3.7 → 0.4.1
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/dashboard/app.js +78 -3
- package/dist/lib/config.js +62 -0
- package/dist/lib/engine/ai.d.ts +25 -0
- package/dist/lib/engine/ai.js +100 -0
- package/dist/lib/engine/change-report.d.ts +9 -1
- package/dist/lib/engine/change-report.js +51 -25
- package/dist/lib/engine/config-hotfix.js +56 -2
- package/dist/lib/engine/conflict.d.ts +6 -4
- package/dist/lib/engine/conflict.js +48 -8
- package/dist/lib/engine/diagnostics.js +42 -7
- package/dist/lib/engine/self-heal.d.ts +6 -1
- package/dist/lib/engine/self-heal.js +68 -3
- package/dist/lib/engine/smart-pr.js +2 -8
- package/dist/lib/engine/snapshot.d.ts +1 -0
- package/dist/lib/engine/snapshot.js +24 -0
- package/dist/lib/engine/suggest.d.ts +11 -2
- package/dist/lib/engine/suggest.js +50 -1
- package/dist/lib/engine/worktree.d.ts +5 -2
- package/dist/lib/engine/worktree.js +51 -3
- package/dist/lib/server.js +171 -16
- package/package.json +3 -3
package/dashboard/app.js
CHANGED
|
@@ -16,8 +16,12 @@ const logs = new Map();
|
|
|
16
16
|
// Route inference — map file paths to preview routes
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
|
|
19
|
+
/** @type {Map<string, string|null>} Client-side cache for AI-inferred routes */
|
|
20
|
+
const aiRouteCache = new Map();
|
|
21
|
+
const AI_ROUTE_CACHE_MAX = 200;
|
|
22
|
+
|
|
19
23
|
/**
|
|
20
|
-
* Infer a preview route from a file path.
|
|
24
|
+
* Infer a preview route from a file path (sync, pattern-based).
|
|
21
25
|
* Only works for file-based routing patterns (pages/, app/, views/).
|
|
22
26
|
* Returns null if no route can be inferred.
|
|
23
27
|
*/
|
|
@@ -47,9 +51,45 @@ function inferRouteFromPath(filePath) {
|
|
|
47
51
|
return route || '/';
|
|
48
52
|
}
|
|
49
53
|
}
|
|
54
|
+
|
|
55
|
+
// Return cached AI result if available
|
|
56
|
+
if (aiRouteCache.has(filePath)) return aiRouteCache.get(filePath);
|
|
57
|
+
|
|
50
58
|
return null;
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Fetch AI-inferred route from the server for a file path.
|
|
63
|
+
* Results are cached to avoid duplicate calls.
|
|
64
|
+
* @param {string} filePath
|
|
65
|
+
* @param {string} [framework]
|
|
66
|
+
* @returns {Promise<string|null>}
|
|
67
|
+
*/
|
|
68
|
+
async function inferRouteFromPathAI(filePath, framework) {
|
|
69
|
+
if (aiRouteCache.has(filePath)) return aiRouteCache.get(filePath);
|
|
70
|
+
|
|
71
|
+
// Mark as in-flight to prevent duplicate requests
|
|
72
|
+
if (aiRouteCache.size >= AI_ROUTE_CACHE_MAX) {
|
|
73
|
+
const oldest = aiRouteCache.keys().next().value;
|
|
74
|
+
aiRouteCache.delete(oldest);
|
|
75
|
+
}
|
|
76
|
+
aiRouteCache.set(filePath, null);
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const data = await api('POST', '/api/infer-route', { filePath, framework });
|
|
80
|
+
const route = data.route || null;
|
|
81
|
+
if (aiRouteCache.size >= AI_ROUTE_CACHE_MAX) {
|
|
82
|
+
const oldest = aiRouteCache.keys().next().value;
|
|
83
|
+
aiRouteCache.delete(oldest);
|
|
84
|
+
}
|
|
85
|
+
aiRouteCache.set(filePath, route);
|
|
86
|
+
return route;
|
|
87
|
+
} catch {
|
|
88
|
+
aiRouteCache.delete(filePath);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
53
93
|
/**
|
|
54
94
|
* Navigate the preview iframe to a given route.
|
|
55
95
|
*/
|
|
@@ -1629,24 +1669,59 @@ async function fetchAndRenderReport(campName, withAi = false) {
|
|
|
1629
1669
|
const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
|
|
1630
1670
|
return route
|
|
1631
1671
|
? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></li>`
|
|
1632
|
-
: `<li>${escHtml(item)}</li>`;
|
|
1672
|
+
: `<li data-ai-route-path="${cat === 'ui' && file ? escHtml(file.path) : ''}">${escHtml(item)}</li>`;
|
|
1633
1673
|
}).join('')}</ul>`
|
|
1634
1674
|
: `<ul class="ws-report-cat-items">${files.map(f => {
|
|
1635
1675
|
const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
|
|
1636
1676
|
const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
|
|
1637
1677
|
return route
|
|
1638
1678
|
? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></li>`
|
|
1639
|
-
: `<li>${label}</li>`;
|
|
1679
|
+
: `<li data-ai-route-path="${cat === 'ui' ? escHtml(f.path) : ''}">${label}</li>`;
|
|
1640
1680
|
}).join('')}</ul>`
|
|
1641
1681
|
}
|
|
1642
1682
|
</div>`;
|
|
1643
1683
|
}).join('');
|
|
1644
1684
|
|
|
1685
|
+
// Async AI fallback: resolve routes for UI files that pattern matching missed
|
|
1686
|
+
resolveAiRoutes(categoriesEl);
|
|
1687
|
+
|
|
1645
1688
|
} catch {
|
|
1646
1689
|
section.style.display = 'none';
|
|
1647
1690
|
}
|
|
1648
1691
|
}
|
|
1649
1692
|
|
|
1693
|
+
/**
|
|
1694
|
+
* After report renders, find UI file items that didn't get a route via pattern matching
|
|
1695
|
+
* and attempt AI-based route inference for them, updating the DOM when routes are found.
|
|
1696
|
+
* @param {HTMLElement} container
|
|
1697
|
+
*/
|
|
1698
|
+
async function resolveAiRoutes(container) {
|
|
1699
|
+
const pending = [...container.querySelectorAll('li[data-ai-route-path]')];
|
|
1700
|
+
const tasks = pending
|
|
1701
|
+
.map(li => {
|
|
1702
|
+
const filePath = li.getAttribute('data-ai-route-path');
|
|
1703
|
+
if (!filePath) return null;
|
|
1704
|
+
return { li, filePath, promise: inferRouteFromPathAI(filePath) };
|
|
1705
|
+
})
|
|
1706
|
+
.filter(Boolean);
|
|
1707
|
+
|
|
1708
|
+
const results = await Promise.allSettled(tasks.map(t => t.promise));
|
|
1709
|
+
|
|
1710
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
1711
|
+
const { li, filePath } = tasks[i];
|
|
1712
|
+
const result = results[i];
|
|
1713
|
+
const route = result.status === 'fulfilled' ? result.value : null;
|
|
1714
|
+
if (route) {
|
|
1715
|
+
const text = li.textContent;
|
|
1716
|
+
li.className = 'ws-report-nav-item';
|
|
1717
|
+
li.setAttribute('onclick', `navigatePreview('${escHtml(route)}')`);
|
|
1718
|
+
li.setAttribute('title', `${filePath} → ${route}`);
|
|
1719
|
+
li.innerHTML = `${escHtml(text)} <span class="ws-report-nav-hint">→ 보기</span>`;
|
|
1720
|
+
}
|
|
1721
|
+
li.removeAttribute('data-ai-route-path');
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1650
1725
|
function transitionReportToSaved() {
|
|
1651
1726
|
const section = document.getElementById('ws-report-section');
|
|
1652
1727
|
if (!section || !lastReport) return;
|
package/dist/lib/config.js
CHANGED
|
@@ -9,6 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
|
+
import { claudeSync, extractJson, isDangerousCommand } from "./engine/ai.js";
|
|
12
13
|
import { deepFindEnvFiles, detectSetupIssues } from "./engine/smart-init.js";
|
|
13
14
|
const CONFIG_FILE = "sanjang.config.js";
|
|
14
15
|
const DEFAULTS = {
|
|
@@ -190,6 +191,10 @@ export function detectProject(projectRoot) {
|
|
|
190
191
|
copyFiles: findEnvFiles(projectRoot),
|
|
191
192
|
};
|
|
192
193
|
}
|
|
194
|
+
// AI fallback: ask Claude to analyze the project and suggest a dev command
|
|
195
|
+
const aiDetected = detectProjectWithAI(projectRoot, pkg);
|
|
196
|
+
if (aiDetected)
|
|
197
|
+
return aiDetected;
|
|
193
198
|
return {
|
|
194
199
|
framework: "unknown",
|
|
195
200
|
dev: { command: "npm run dev", port: 3000, portFlag: "--port", cwd: ".", env: {} },
|
|
@@ -197,6 +202,63 @@ export function detectProject(projectRoot) {
|
|
|
197
202
|
copyFiles: [],
|
|
198
203
|
};
|
|
199
204
|
}
|
|
205
|
+
function detectProjectWithAI(projectRoot, pkg) {
|
|
206
|
+
const contextParts = [];
|
|
207
|
+
if (!existsSync(projectRoot))
|
|
208
|
+
return null;
|
|
209
|
+
const lsResult = readdirSync(projectRoot, { withFileTypes: true });
|
|
210
|
+
const listing = lsResult
|
|
211
|
+
.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`)
|
|
212
|
+
.join("\n");
|
|
213
|
+
contextParts.push(`## Files in project root:\n${listing}`);
|
|
214
|
+
if (pkg?.scripts) {
|
|
215
|
+
contextParts.push(`## package.json scripts:\n${JSON.stringify(pkg.scripts, null, 2)}`);
|
|
216
|
+
}
|
|
217
|
+
const makefilePath = join(projectRoot, "Makefile");
|
|
218
|
+
if (existsSync(makefilePath)) {
|
|
219
|
+
try {
|
|
220
|
+
const content = readFileSync(makefilePath, "utf8");
|
|
221
|
+
contextParts.push(`## Makefile (first 20 lines):\n${content.split("\n").slice(0, 20).join("\n")}`);
|
|
222
|
+
}
|
|
223
|
+
catch { /* ignore */ }
|
|
224
|
+
}
|
|
225
|
+
const prompt = `You are analyzing a software project to determine how to run its dev server.
|
|
226
|
+
|
|
227
|
+
${contextParts.join("\n\n")}
|
|
228
|
+
|
|
229
|
+
Based on these files, determine the correct command to start the development server.
|
|
230
|
+
Respond with ONLY a JSON object (no markdown, no explanation):
|
|
231
|
+
{"command": "the dev command", "port": 3000, "portFlag": "--port", "setup": "install command or null"}
|
|
232
|
+
|
|
233
|
+
Rules:
|
|
234
|
+
- command: the shell command to start the dev server
|
|
235
|
+
- port: the default port number (number, not string)
|
|
236
|
+
- portFlag: the CLI flag to override port, or null if not applicable
|
|
237
|
+
- setup: the install/setup command, or null if none needed`;
|
|
238
|
+
const raw = claudeSync(prompt, { model: "haiku" });
|
|
239
|
+
if (!raw)
|
|
240
|
+
return null;
|
|
241
|
+
const suggestion = extractJson(raw);
|
|
242
|
+
if (!suggestion?.command || typeof suggestion.command !== "string")
|
|
243
|
+
return null;
|
|
244
|
+
if (isDangerousCommand(suggestion.command))
|
|
245
|
+
return null;
|
|
246
|
+
if (suggestion.setup && isDangerousCommand(suggestion.setup))
|
|
247
|
+
return null;
|
|
248
|
+
return {
|
|
249
|
+
framework: "AI-detected",
|
|
250
|
+
dev: {
|
|
251
|
+
command: suggestion.command,
|
|
252
|
+
port: typeof suggestion.port === "number" ? suggestion.port : 3000,
|
|
253
|
+
portFlag: suggestion.portFlag ?? null,
|
|
254
|
+
cwd: ".",
|
|
255
|
+
env: {},
|
|
256
|
+
},
|
|
257
|
+
setup: suggestion.setup ?? "npm install",
|
|
258
|
+
copyFiles: findEnvFiles(projectRoot),
|
|
259
|
+
_note: "Project type detected by AI. Verify the dev command is correct.",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
200
262
|
/**
|
|
201
263
|
* Scan first-level subdirectories for app candidates.
|
|
202
264
|
* Returns array of { dir, framework, detected } sorted by dir name.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared AI (Claude CLI) utilities.
|
|
3
|
+
*
|
|
4
|
+
* Centralises claude CLI availability checks, prompt execution,
|
|
5
|
+
* JSON extraction, and dangerous command detection so that engine
|
|
6
|
+
* modules do not duplicate these patterns.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isClaudeAvailable(): boolean;
|
|
9
|
+
export interface ClaudeSyncOptions {
|
|
10
|
+
model?: string;
|
|
11
|
+
timeout?: number;
|
|
12
|
+
cwd?: string;
|
|
13
|
+
outputFormat?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Call `claude -p` synchronously and return trimmed stdout, or null on failure.
|
|
17
|
+
* Checks availability first (cached), strips markdown fences from output.
|
|
18
|
+
*/
|
|
19
|
+
export declare function claudeSync(prompt: string, opts?: ClaudeSyncOptions): string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Extract a JSON object or array from text that may contain markdown fences
|
|
22
|
+
* or surrounding prose. Returns parsed value or null.
|
|
23
|
+
*/
|
|
24
|
+
export declare function extractJson<T = unknown>(text: string): T | null;
|
|
25
|
+
export declare function isDangerousCommand(cmd: string): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared AI (Claude CLI) utilities.
|
|
3
|
+
*
|
|
4
|
+
* Centralises claude CLI availability checks, prompt execution,
|
|
5
|
+
* JSON extraction, and dangerous command detection so that engine
|
|
6
|
+
* modules do not duplicate these patterns.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Claude CLI availability (cached)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
let _claudeAvailable = null;
|
|
13
|
+
export function isClaudeAvailable() {
|
|
14
|
+
if (_claudeAvailable === null) {
|
|
15
|
+
_claudeAvailable = spawnSync("which", ["claude"], { stdio: "pipe" }).status === 0;
|
|
16
|
+
}
|
|
17
|
+
return _claudeAvailable;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Call `claude -p` synchronously and return trimmed stdout, or null on failure.
|
|
21
|
+
* Checks availability first (cached), strips markdown fences from output.
|
|
22
|
+
*/
|
|
23
|
+
export function claudeSync(prompt, opts) {
|
|
24
|
+
if (!isClaudeAvailable())
|
|
25
|
+
return null;
|
|
26
|
+
const args = ["-p", prompt];
|
|
27
|
+
if (opts?.model)
|
|
28
|
+
args.push("--model", opts.model);
|
|
29
|
+
if (opts?.outputFormat)
|
|
30
|
+
args.push("--output-format", opts.outputFormat);
|
|
31
|
+
try {
|
|
32
|
+
const result = spawnSync("claude", args, {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
35
|
+
timeout: opts?.timeout ?? 15_000,
|
|
36
|
+
cwd: opts?.cwd,
|
|
37
|
+
});
|
|
38
|
+
if (result.status !== 0 || !result.stdout)
|
|
39
|
+
return null;
|
|
40
|
+
return result.stdout.trim();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// JSON extraction
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
/**
|
|
50
|
+
* Extract a JSON object or array from text that may contain markdown fences
|
|
51
|
+
* or surrounding prose. Returns parsed value or null.
|
|
52
|
+
*/
|
|
53
|
+
export function extractJson(text) {
|
|
54
|
+
if (!text)
|
|
55
|
+
return null;
|
|
56
|
+
// Strip markdown code fences
|
|
57
|
+
const stripped = text.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
58
|
+
// Try direct parse first (common case: entire response is JSON)
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(stripped);
|
|
61
|
+
}
|
|
62
|
+
catch { /* not pure JSON, try extraction */ }
|
|
63
|
+
// Find JSON boundaries by trying to parse from each opening bracket
|
|
64
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
65
|
+
const ch = stripped[i];
|
|
66
|
+
if (ch !== "{" && ch !== "[")
|
|
67
|
+
continue;
|
|
68
|
+
// Try progressively shorter substrings from this position
|
|
69
|
+
for (let j = stripped.length; j > i; j--) {
|
|
70
|
+
const endCh = stripped[j - 1];
|
|
71
|
+
if ((ch === "{" && endCh !== "}") || (ch === "[" && endCh !== "]"))
|
|
72
|
+
continue;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(stripped.slice(i, j));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Dangerous command detection
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
const DANGEROUS_COMMAND_PATTERNS = [
|
|
87
|
+
/\brm\s+(-\w*[rf]|--recursive|--force)/i,
|
|
88
|
+
/\bsudo\b/i,
|
|
89
|
+
/\bcurl\b.*\|\s*\b(sh|bash|zsh)\b/i,
|
|
90
|
+
/\bwget\b.*\|\s*\b(sh|bash|zsh)\b/i,
|
|
91
|
+
/\bmkfs\b/i,
|
|
92
|
+
/\bdd\s+(if=|of=)/i,
|
|
93
|
+
/\b(shutdown|reboot|halt|poweroff)\b/i,
|
|
94
|
+
/\bchmod\s+777\b/i,
|
|
95
|
+
/\b>\s*\/dev\//i,
|
|
96
|
+
/:\(\)\{.*\|.*&\s*\}|fork\s+bomb/i,
|
|
97
|
+
];
|
|
98
|
+
export function isDangerousCommand(cmd) {
|
|
99
|
+
return DANGEROUS_COMMAND_PATTERNS.some((p) => p.test(cmd));
|
|
100
|
+
}
|
|
@@ -5,6 +5,12 @@ type FileCategory = "ui" | "api" | "config" | "test" | "docs" | "other";
|
|
|
5
5
|
* Rules are checked in priority order: test > ui > api > docs > config > other
|
|
6
6
|
*/
|
|
7
7
|
export declare function categorizeFile(filePath: string): FileCategory;
|
|
8
|
+
/**
|
|
9
|
+
* Re-classify "other" files using AI (claude -p --model haiku).
|
|
10
|
+
* Only file paths are sent — file contents are never read (cost/speed optimisation).
|
|
11
|
+
* On failure the original categories are preserved.
|
|
12
|
+
*/
|
|
13
|
+
export declare function recategorizeWithAI(files: ChangeReportFile[]): ChangeReportFile[];
|
|
8
14
|
/**
|
|
9
15
|
* Detect warnings from a list of categorized files.
|
|
10
16
|
* Returns deduplicated warnings (one per type).
|
|
@@ -17,7 +23,9 @@ export declare function detectWarnings(files: ChangeReportFile[]): ChangeReportW
|
|
|
17
23
|
export declare function buildChangeReport(rawFiles: {
|
|
18
24
|
path: string;
|
|
19
25
|
status: ChangeReportFile["status"];
|
|
20
|
-
}[]
|
|
26
|
+
}[], options?: {
|
|
27
|
+
ai?: boolean;
|
|
28
|
+
}): ChangeReport;
|
|
21
29
|
/**
|
|
22
30
|
* Enrich a ChangeReport with AI-generated category-level descriptions.
|
|
23
31
|
* Each category gets human-readable bullet points explaining what changed.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { basename, extname } from "node:path";
|
|
2
|
+
import { claudeSync, extractJson } from "./ai.js";
|
|
3
3
|
/**
|
|
4
4
|
* Classify a file path into one of the known categories.
|
|
5
5
|
* Rules are checked in priority order: test > ui > api > docs > config > other
|
|
@@ -74,6 +74,41 @@ export function categorizeFile(filePath) {
|
|
|
74
74
|
// 6. other
|
|
75
75
|
return "other";
|
|
76
76
|
}
|
|
77
|
+
const VALID_AI_CATEGORIES = new Set(["ui", "api", "config", "test", "docs"]);
|
|
78
|
+
/**
|
|
79
|
+
* Re-classify "other" files using AI (claude -p --model haiku).
|
|
80
|
+
* Only file paths are sent — file contents are never read (cost/speed optimisation).
|
|
81
|
+
* On failure the original categories are preserved.
|
|
82
|
+
*/
|
|
83
|
+
export function recategorizeWithAI(files) {
|
|
84
|
+
const otherFiles = files.filter((f) => f.category === "other");
|
|
85
|
+
if (otherFiles.length === 0)
|
|
86
|
+
return files;
|
|
87
|
+
const pathList = otherFiles.map((f) => f.path).join("\n");
|
|
88
|
+
const prompt = `아래 파일 경로들을 다음 카테고리 중 하나로 분류해: ui, api, config, test, docs
|
|
89
|
+
경로만 보고 판단해.
|
|
90
|
+
|
|
91
|
+
파일 목록:
|
|
92
|
+
${pathList}
|
|
93
|
+
|
|
94
|
+
JSON으로만 응답해 (다른 텍스트 없이):
|
|
95
|
+
{ "파일경로": "카테고리", ... }`;
|
|
96
|
+
const raw = claudeSync(prompt, { model: "haiku" });
|
|
97
|
+
if (!raw)
|
|
98
|
+
return files;
|
|
99
|
+
const mapping = extractJson(raw);
|
|
100
|
+
if (!mapping)
|
|
101
|
+
return files;
|
|
102
|
+
return files.map((f) => {
|
|
103
|
+
if (f.category !== "other")
|
|
104
|
+
return f;
|
|
105
|
+
const aiCat = mapping[f.path];
|
|
106
|
+
if (typeof aiCat === "string" && VALID_AI_CATEGORIES.has(aiCat)) {
|
|
107
|
+
return { ...f, category: aiCat };
|
|
108
|
+
}
|
|
109
|
+
return f;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
77
112
|
/**
|
|
78
113
|
* Detect warnings from a list of categorized files.
|
|
79
114
|
* Returns deduplicated warnings (one per type).
|
|
@@ -117,12 +152,15 @@ export function detectWarnings(files) {
|
|
|
117
152
|
* Build a ChangeReport from raw file list (path + status).
|
|
118
153
|
* summary and humanDescription are set to null — call generateReportSummary to enrich.
|
|
119
154
|
*/
|
|
120
|
-
export function buildChangeReport(rawFiles) {
|
|
121
|
-
|
|
155
|
+
export function buildChangeReport(rawFiles, options) {
|
|
156
|
+
let files = rawFiles.map((f) => ({
|
|
122
157
|
path: f.path,
|
|
123
158
|
status: f.status,
|
|
124
159
|
category: categorizeFile(f.path),
|
|
125
160
|
}));
|
|
161
|
+
if (options?.ai) {
|
|
162
|
+
files = recategorizeWithAI(files);
|
|
163
|
+
}
|
|
126
164
|
const byCategory = {};
|
|
127
165
|
for (const f of files) {
|
|
128
166
|
const cat = f.category;
|
|
@@ -192,30 +230,18 @@ JSON으로만 응답해 (다른 텍스트 없이):
|
|
|
192
230
|
{"summary": "전체 한 줄 요약 (30자 이내)", "categories": {"ui": ["설명1", "설명2"], "api": ["설명1"], ...}}
|
|
193
231
|
|
|
194
232
|
categories의 키는 반드시 다음 중 하나: ${Object.keys(report.byCategory).join(", ")}`;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (parsed.summary && parsed.categories) {
|
|
206
|
-
return {
|
|
207
|
-
...report,
|
|
208
|
-
summary: parsed.summary,
|
|
209
|
-
humanDescription: null,
|
|
210
|
-
categoryDetails: parsed.categories,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
}
|
|
233
|
+
const raw = claudeSync(prompt, { model: "haiku", timeout: 30_000 });
|
|
234
|
+
if (raw) {
|
|
235
|
+
const parsed = extractJson(raw);
|
|
236
|
+
if (parsed?.summary && parsed?.categories) {
|
|
237
|
+
return {
|
|
238
|
+
...report,
|
|
239
|
+
summary: parsed.summary,
|
|
240
|
+
humanDescription: null,
|
|
241
|
+
categoryDetails: parsed.categories,
|
|
242
|
+
};
|
|
214
243
|
}
|
|
215
244
|
}
|
|
216
|
-
catch {
|
|
217
|
-
// Fall through to fallback
|
|
218
|
-
}
|
|
219
245
|
// Fallback: 파일 기반 설명 생성
|
|
220
246
|
const fallbackDetails = {};
|
|
221
247
|
for (const [cat, files] of Object.entries(report.byCategory)) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { claudeSync, extractJson, isDangerousCommand } from "./ai.js";
|
|
3
4
|
import { deepFindEnvFiles } from "./smart-init.js";
|
|
4
5
|
const PATTERNS = [
|
|
5
6
|
{
|
|
@@ -42,10 +43,59 @@ const PATTERNS = [
|
|
|
42
43
|
},
|
|
43
44
|
];
|
|
44
45
|
// ---------------------------------------------------------------------------
|
|
46
|
+
// Constants
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
const CONFIG_FILE = "sanjang.config.js";
|
|
49
|
+
/** Maximum characters of config content to include in AI prompts. */
|
|
50
|
+
const MAX_CONFIG_CONTENT_FOR_PROMPT = 3000;
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// AI helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
function aiFallback(projectRoot, logs) {
|
|
55
|
+
const configPath = join(projectRoot, CONFIG_FILE);
|
|
56
|
+
const configContent = existsSync(configPath)
|
|
57
|
+
? readFileSync(configPath, "utf8").slice(0, MAX_CONFIG_CONTENT_FOR_PROMPT)
|
|
58
|
+
: "(config file not found)";
|
|
59
|
+
const lastLines = logs.slice(-20).join("\n");
|
|
60
|
+
const prompt = [
|
|
61
|
+
"You are a build-config assistant for the sanjang dev tool.",
|
|
62
|
+
"Given the error log and the current sanjang.config.js, suggest a fix.",
|
|
63
|
+
"",
|
|
64
|
+
"## Error log (last 20 lines)",
|
|
65
|
+
lastLines,
|
|
66
|
+
"",
|
|
67
|
+
"## Current sanjang.config.js",
|
|
68
|
+
configContent,
|
|
69
|
+
"",
|
|
70
|
+
"Respond with ONLY a JSON object (no markdown fences, no explanation):",
|
|
71
|
+
'{ "type": "add-copyfiles" | "update-setup" | "info", "description": "<한국어 설명>", "patch": { ... } }',
|
|
72
|
+
"",
|
|
73
|
+
'For "update-setup", include patch.setup with the corrected shell command.',
|
|
74
|
+
'For "add-copyfiles", include patch.copyFiles with file paths array.',
|
|
75
|
+
'For "info", include a descriptive patch explaining the issue.',
|
|
76
|
+
].join("\n");
|
|
77
|
+
const raw = claudeSync(prompt, { model: "haiku" });
|
|
78
|
+
if (!raw)
|
|
79
|
+
return null;
|
|
80
|
+
const parsed = extractJson(raw);
|
|
81
|
+
if (!parsed)
|
|
82
|
+
return null;
|
|
83
|
+
if (!parsed.type || !parsed.description || !parsed.patch)
|
|
84
|
+
return null;
|
|
85
|
+
if (!["add-copyfiles", "update-setup", "info"].includes(parsed.type))
|
|
86
|
+
return null;
|
|
87
|
+
if (parsed.type === "update-setup" && typeof parsed.patch.setup === "string") {
|
|
88
|
+
if (isDangerousCommand(parsed.patch.setup))
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
45
94
|
// suggestConfigFix — analyze logs and return a fix, or null
|
|
46
95
|
// ---------------------------------------------------------------------------
|
|
47
96
|
export function suggestConfigFix(projectRoot, logs) {
|
|
48
97
|
const combined = logs.join("\n");
|
|
98
|
+
// Fast path: pattern matching
|
|
49
99
|
for (const pattern of PATTERNS) {
|
|
50
100
|
const match = combined.match(pattern.test);
|
|
51
101
|
if (match) {
|
|
@@ -54,12 +104,16 @@ export function suggestConfigFix(projectRoot, logs) {
|
|
|
54
104
|
return fix;
|
|
55
105
|
}
|
|
56
106
|
}
|
|
107
|
+
// Slow path: AI fallback — only if logs contain error signals
|
|
108
|
+
const hasErrorSignal = /\b(error|ERR!|FATAL|ENOSPC|EPERM|EACCES|TypeError|ReferenceError|SyntaxError|failed to|build failed)\b/i.test(combined);
|
|
109
|
+
if (hasErrorSignal) {
|
|
110
|
+
return aiFallback(projectRoot, logs);
|
|
111
|
+
}
|
|
57
112
|
return null;
|
|
58
113
|
}
|
|
59
114
|
// ---------------------------------------------------------------------------
|
|
60
115
|
// applyConfigFix — modify sanjang.config.js in place
|
|
61
116
|
// ---------------------------------------------------------------------------
|
|
62
|
-
const CONFIG_FILE = "sanjang.config.js";
|
|
63
117
|
export function applyConfigFix(projectRoot, fix) {
|
|
64
118
|
const configPath = join(projectRoot, CONFIG_FILE);
|
|
65
119
|
if (!existsSync(configPath))
|
|
@@ -80,7 +134,7 @@ export function applyConfigFix(projectRoot, fix) {
|
|
|
80
134
|
break;
|
|
81
135
|
}
|
|
82
136
|
case "update-setup": {
|
|
83
|
-
// Informational —
|
|
137
|
+
// Informational only — don't auto-change setup without explicit user confirmation
|
|
84
138
|
return false;
|
|
85
139
|
}
|
|
86
140
|
case "info": {
|
|
@@ -19,7 +19,9 @@ export interface ConflictSection {
|
|
|
19
19
|
* Returns an array of conflict sections found in the file.
|
|
20
20
|
*/
|
|
21
21
|
export declare function parseConflictSections(content: string): ConflictSection[];
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
/** Detail about a single file's conflict sections, used to embed content in the prompt. */
|
|
23
|
+
export interface ConflictDetail {
|
|
24
|
+
path: string;
|
|
25
|
+
sections: ConflictSection[];
|
|
26
|
+
}
|
|
27
|
+
export declare function buildConflictPrompt(conflictFiles: string[], conflictDetails?: ConflictDetail[]): string;
|
|
@@ -55,19 +55,59 @@ export function parseConflictSections(content) {
|
|
|
55
55
|
}
|
|
56
56
|
return sections;
|
|
57
57
|
}
|
|
58
|
+
/** Truncate text to a maximum number of lines. */
|
|
59
|
+
function truncateLines(text, maxLines) {
|
|
60
|
+
const lines = text.split("\n");
|
|
61
|
+
if (lines.length <= maxLines)
|
|
62
|
+
return text;
|
|
63
|
+
return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines}줄 생략)`;
|
|
64
|
+
}
|
|
58
65
|
/**
|
|
59
66
|
* Build a Claude prompt to resolve merge conflicts.
|
|
67
|
+
* When conflictDetails is provided, ours/theirs content is embedded directly
|
|
68
|
+
* so Claude can resolve without re-reading the files.
|
|
60
69
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
/** Maximum lines to include per conflict side (ours/theirs) in the prompt. */
|
|
71
|
+
const MAX_LINES_PER_SIDE = 50;
|
|
72
|
+
export function buildConflictPrompt(conflictFiles, conflictDetails) {
|
|
73
|
+
const lines = [
|
|
63
74
|
"아래 파일들에 git merge 충돌이 발생했습니다.",
|
|
64
|
-
"각 파일의 충돌 마커(<<<<<<< ======= >>>>>>>)를 읽고,",
|
|
65
75
|
"두 버전의 의도를 모두 살려서 충돌을 해결해주세요.",
|
|
66
76
|
"해결 후 충돌 마커는 완전히 제거해야 합니다.",
|
|
67
77
|
"",
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
];
|
|
79
|
+
if (conflictDetails?.length) {
|
|
80
|
+
for (const detail of conflictDetails) {
|
|
81
|
+
lines.push(`## ${detail.path}`);
|
|
82
|
+
if (detail.sections.length === 0) {
|
|
83
|
+
lines.push("(충돌 섹션을 파싱하지 못했습니다. 파일을 직접 읽어주세요.)");
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
for (let i = 0; i < detail.sections.length; i++) {
|
|
87
|
+
const s = detail.sections[i];
|
|
88
|
+
lines.push(`### 충돌 #${i + 1} (line ${s.startLine})`);
|
|
89
|
+
lines.push("**Ours (현재 브랜치):**");
|
|
90
|
+
lines.push("```");
|
|
91
|
+
lines.push(truncateLines(s.ours, MAX_LINES_PER_SIDE));
|
|
92
|
+
lines.push("```");
|
|
93
|
+
lines.push("**Theirs (병합 대상):**");
|
|
94
|
+
lines.push("```");
|
|
95
|
+
lines.push(truncateLines(s.theirs, MAX_LINES_PER_SIDE));
|
|
96
|
+
lines.push("```");
|
|
97
|
+
lines.push("");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
lines.push("");
|
|
101
|
+
}
|
|
102
|
+
lines.push("위 내용을 바탕으로 각 파일의 충돌을 해결해주세요.");
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
lines.push("충돌 파일 목록:");
|
|
106
|
+
for (const f of conflictFiles) {
|
|
107
|
+
lines.push(`- ${f}`);
|
|
108
|
+
}
|
|
109
|
+
lines.push("");
|
|
110
|
+
lines.push("각 파일을 읽고 수정해주세요.");
|
|
111
|
+
}
|
|
112
|
+
return lines.join("\n");
|
|
73
113
|
}
|