chatbot-analyze-qweasd 1.0.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/apiflow5-gui.mjs +1383 -0
- package/apiflow5.mjs +1036 -0
- package/package.json +12 -0
package/apiflow5.mjs
ADDED
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* apiflow5: staged function flow -> flowchart SVG
|
|
4
|
+
* Stage 1: overview with checks
|
|
5
|
+
* Stage 2: detail for a selected node
|
|
6
|
+
* Requirements:
|
|
7
|
+
* - `codex` CLI available in PATH
|
|
8
|
+
* - devDep: @mermaid-js/mermaid-cli (provides mmdc)
|
|
9
|
+
* - schema: ir5-stage1.schema.json / ir5-stage2.schema.json
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
function die(msg, code = 1) {
|
|
17
|
+
console.error(msg);
|
|
18
|
+
process.exit(code);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 進度輸出(給 GUI 捕獲)
|
|
22
|
+
function emitProgress(stage, percent, message) {
|
|
23
|
+
const payload = JSON.stringify({ stage, percent, message });
|
|
24
|
+
console.error(`[PROGRESS] ${payload}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const args = {
|
|
29
|
+
folder: process.cwd(),
|
|
30
|
+
project: "",
|
|
31
|
+
query: "",
|
|
32
|
+
path: "",
|
|
33
|
+
out: "",
|
|
34
|
+
schema: "",
|
|
35
|
+
wrap: 85,
|
|
36
|
+
keep: false,
|
|
37
|
+
stage: 1,
|
|
38
|
+
node: "",
|
|
39
|
+
nodeLabel: "",
|
|
40
|
+
nodeFocus: ""
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < argv.length; i++) {
|
|
44
|
+
const a = argv[i];
|
|
45
|
+
const next = () => (i + 1 < argv.length ? argv[++i] : "");
|
|
46
|
+
|
|
47
|
+
if (a === "--folder" || a === "-f") args.folder = next();
|
|
48
|
+
else if (a === "--project" || a === "-p") args.project = next();
|
|
49
|
+
else if (a === "--path") args.path = next();
|
|
50
|
+
else if (a === "--query") args.query = next();
|
|
51
|
+
else if (a === "--out" || a === "-o") args.out = next();
|
|
52
|
+
else if (a === "--schema") args.schema = next();
|
|
53
|
+
else if (a === "--wrap") args.wrap = Number(next() || "85");
|
|
54
|
+
else if (a === "--keep") args.keep = true;
|
|
55
|
+
else if (a === "--stage") args.stage = Number(next() || "1");
|
|
56
|
+
else if (a === "--node") args.node = next();
|
|
57
|
+
else if (a === "--node-label") args.nodeLabel = next();
|
|
58
|
+
else if (a === "--node-focus") args.nodeFocus = next();
|
|
59
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 支援兩種輸入:--path 或 --query
|
|
63
|
+
// --path 會自動組成 query: `path: ...`
|
|
64
|
+
if (!args.query && args.path) args.query = `path: ${args.path}`;
|
|
65
|
+
|
|
66
|
+
return args;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function help() {
|
|
70
|
+
console.log(`
|
|
71
|
+
用法:
|
|
72
|
+
apiflow5 --folder <workspaceRoot> --project <projectName> --path "<symbol>" --out <svgPath> --stage 1
|
|
73
|
+
apiflow5 --folder <workspaceRoot> --project <projectName> --path "<symbol>" --out <svgPath> --stage 2 --node <nodeId>
|
|
74
|
+
|
|
75
|
+
範例:
|
|
76
|
+
apiflow5 --folder /Users/Timothy/IdeaProjects --project gemini \\
|
|
77
|
+
--path "ai.omnichat.service.AiService#enterAiSession(java.lang.String, java.lang.String, java.lang.String, java.lang.String, ai.omnichat.dto.social.webhook.OcMessage)" \\
|
|
78
|
+
--out dist/flow.svg --stage 1
|
|
79
|
+
|
|
80
|
+
參數:
|
|
81
|
+
--folder, -f workspace 根目錄(folder A)
|
|
82
|
+
--project,-p 目標 project 名稱(例如 gemini)
|
|
83
|
+
--path function/symbol 位置(會自動變成 query: path: ...)
|
|
84
|
+
--query 直接給 codex 用的查詢(若你不用 --path)
|
|
85
|
+
--out, -o 輸出 svg 檔案路徑
|
|
86
|
+
--schema schema 檔路徑(預設:stage1/2 的 schema)
|
|
87
|
+
--wrap label 自動換行寬度(預設 85)
|
|
88
|
+
--keep 保留中間檔(ir.json / flow-stage1.mmd / flow-stage2.mmd / codex.last.json)
|
|
89
|
+
--stage 1=概要 2=細化(預設 1)
|
|
90
|
+
--node stage=2 時要細化的節點 id
|
|
91
|
+
--node-label stage=2 時節點 label(選填)
|
|
92
|
+
--node-focus stage=2 時節點 focus(選填)
|
|
93
|
+
`.trim());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function runCmd(cmd, args, opts = {}) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const p = spawn(cmd, args, { ...opts, stdio: ["ignore", "pipe", "pipe"] });
|
|
99
|
+
let out = "";
|
|
100
|
+
let err = "";
|
|
101
|
+
p.stdout.on("data", (d) => (out += d.toString("utf8")));
|
|
102
|
+
p.stderr.on("data", (d) => (err += d.toString("utf8")));
|
|
103
|
+
p.on("close", (code) => {
|
|
104
|
+
if (code !== 0) return reject(new Error(`${cmd} exit ${code}\n${err}`));
|
|
105
|
+
resolve({ out, err });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function runCmdWithInput(cmd, args, input, opts = {}) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const p = spawn(cmd, args, { ...opts, stdio: ["pipe", "pipe", "pipe"] });
|
|
113
|
+
let out = "";
|
|
114
|
+
let err = "";
|
|
115
|
+
p.stdout.on("data", (d) => (out += d.toString("utf8")));
|
|
116
|
+
p.stderr.on("data", (d) => (err += d.toString("utf8")));
|
|
117
|
+
p.on("close", (code) => {
|
|
118
|
+
if (code !== 0) return reject(new Error(`${cmd} exit ${code}\n${err}`));
|
|
119
|
+
resolve({ out, err });
|
|
120
|
+
});
|
|
121
|
+
p.stdin.write(input || "");
|
|
122
|
+
p.stdin.end();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function escapeText(s) {
|
|
127
|
+
return String(s).replace(/\r?\n/g, " ").replace(/"/g, "'");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function wrapText(text, maxLen = 85) {
|
|
131
|
+
const s = String(text);
|
|
132
|
+
if (s.length <= maxLen) return s;
|
|
133
|
+
|
|
134
|
+
// 先以 <br/> 為硬換行,避免把 HTML tag 切爛
|
|
135
|
+
const hardLines = s.split("<br/>");
|
|
136
|
+
|
|
137
|
+
const wrapped = hardLines.map((line) => {
|
|
138
|
+
if (line.length <= maxLen) return line;
|
|
139
|
+
|
|
140
|
+
const parts = line.split(/([\/\-\_\s\|\(\)\,\:])/);
|
|
141
|
+
const lines = [];
|
|
142
|
+
let current = "";
|
|
143
|
+
|
|
144
|
+
for (const part of parts) {
|
|
145
|
+
if (!part) continue;
|
|
146
|
+
if ((current + part).length > maxLen) {
|
|
147
|
+
if (current.trim()) lines.push(current.trim());
|
|
148
|
+
current = part;
|
|
149
|
+
} else {
|
|
150
|
+
current += part;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (current.trim()) lines.push(current.trim());
|
|
154
|
+
return lines.join("<br/>");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return wrapped.join("<br/>");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pid(name) {
|
|
161
|
+
return String(name || "unknown").replace(/[^a-zA-Z0-9_]/g, "_");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeProjectName(p) {
|
|
165
|
+
if (!p) return "unknown";
|
|
166
|
+
return String(p).trim() || "unknown";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractFeatureToggle(text) {
|
|
170
|
+
const m = String(text || "").match(/\bImTeamFeature\.([A-Z0-9_]+)\b/);
|
|
171
|
+
return m ? m[1] : "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const classLocationCache = new Map();
|
|
175
|
+
const methodLocationCache = new Map();
|
|
176
|
+
|
|
177
|
+
function resolveCallLocation({ folder, project, className, methodName }) {
|
|
178
|
+
if (!folder || !project || !className) return null;
|
|
179
|
+
const cacheKey = [project, className, methodName || ""].join("::");
|
|
180
|
+
if (classLocationCache.has(cacheKey)) return classLocationCache.get(cacheKey);
|
|
181
|
+
const projectRoot = path.join(folder, project);
|
|
182
|
+
const safeClass = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
183
|
+
const defPattern = `\\b(interface|class|enum)\\s+${safeClass}\\b`;
|
|
184
|
+
|
|
185
|
+
const findDef = spawnSync(
|
|
186
|
+
"rg",
|
|
187
|
+
["-n", "-m", "1", "--glob", "*.java", "--glob", "*.kt", defPattern],
|
|
188
|
+
{ cwd: projectRoot, encoding: "utf8" }
|
|
189
|
+
);
|
|
190
|
+
if (findDef.error || findDef.status !== 0 || !findDef.stdout) {
|
|
191
|
+
classLocationCache.set(cacheKey, null);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const defLine = findDef.stdout.trim().split("\n")[0];
|
|
196
|
+
const match = defLine.match(/^(.+?):(\d+):/);
|
|
197
|
+
if (!match) {
|
|
198
|
+
classLocationCache.set(cacheKey, null);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const fileRel = match[1];
|
|
202
|
+
const defLineNum = Number(match[2]) || null;
|
|
203
|
+
|
|
204
|
+
let methodLine = defLineNum;
|
|
205
|
+
if (methodName) {
|
|
206
|
+
const findMethod = spawnSync(
|
|
207
|
+
"rg",
|
|
208
|
+
["-n", "-m", "1", "--fixed-strings", methodName, fileRel],
|
|
209
|
+
{ cwd: projectRoot, encoding: "utf8" }
|
|
210
|
+
);
|
|
211
|
+
if (!findMethod.error && findMethod.status === 0 && findMethod.stdout) {
|
|
212
|
+
const methodMatch = findMethod.stdout.trim().match(/^.+?:(\d+):/);
|
|
213
|
+
if (methodMatch) methodLine = Number(methodMatch[1]) || methodLine;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = { file: fileRel, line: methodLine };
|
|
218
|
+
classLocationCache.set(cacheKey, result);
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveMethodInProject({ folder, project, methodName }) {
|
|
223
|
+
if (!folder || !project || !methodName) return null;
|
|
224
|
+
const cacheKey = [project, methodName].join("::");
|
|
225
|
+
if (methodLocationCache.has(cacheKey)) return methodLocationCache.get(cacheKey);
|
|
226
|
+
const projectRoot = path.join(folder, project);
|
|
227
|
+
const safeMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
228
|
+
const pattern = `\\b${safeMethod}\\s*\\(`;
|
|
229
|
+
|
|
230
|
+
const findMethod = spawnSync(
|
|
231
|
+
"rg",
|
|
232
|
+
["-n", "-m", "1", "--glob", "*.java", "--glob", "*.kt", pattern],
|
|
233
|
+
{ cwd: projectRoot, encoding: "utf8" }
|
|
234
|
+
);
|
|
235
|
+
if (findMethod.error || findMethod.status !== 0 || !findMethod.stdout) {
|
|
236
|
+
methodLocationCache.set(cacheKey, null);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
const match = findMethod.stdout.trim().match(/^(.+?):(\d+):/);
|
|
240
|
+
if (!match) {
|
|
241
|
+
methodLocationCache.set(cacheKey, null);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const result = { file: match[1], line: Number(match[2]) || null };
|
|
245
|
+
methodLocationCache.set(cacheKey, result);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resolveMethodInFile({ folder, project, fileRel, methodName }) {
|
|
250
|
+
if (!folder || !project || !fileRel || !methodName) return null;
|
|
251
|
+
const cacheKey = [project, fileRel, methodName].join("::");
|
|
252
|
+
if (methodLocationCache.has(cacheKey)) return methodLocationCache.get(cacheKey);
|
|
253
|
+
const projectRoot = path.join(folder, project);
|
|
254
|
+
const findMethod = spawnSync(
|
|
255
|
+
"rg",
|
|
256
|
+
["-n", "-m", "1", "--fixed-strings", `${methodName}(`, fileRel],
|
|
257
|
+
{ cwd: projectRoot, encoding: "utf8" }
|
|
258
|
+
);
|
|
259
|
+
if (findMethod.error || findMethod.status !== 0 || !findMethod.stdout) {
|
|
260
|
+
methodLocationCache.set(cacheKey, null);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const match = findMethod.stdout.trim().match(/^.+?:(\d+):/);
|
|
264
|
+
if (!match) {
|
|
265
|
+
methodLocationCache.set(cacheKey, null);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const result = { file: fileRel, line: Number(match[1]) || null };
|
|
269
|
+
methodLocationCache.set(cacheKey, result);
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildMethodIndexFromFile({ folder, project, fileRel }) {
|
|
274
|
+
if (!folder || !project || !fileRel) return {};
|
|
275
|
+
const projectRoot = path.join(folder, project);
|
|
276
|
+
try {
|
|
277
|
+
const text = readFileSync(path.join(projectRoot, fileRel), "utf8");
|
|
278
|
+
const lines = text.split(/\r?\n/);
|
|
279
|
+
const map = {};
|
|
280
|
+
for (let i = 0; i < lines.length; i++) {
|
|
281
|
+
const line = lines[i];
|
|
282
|
+
let match = line.match(/^\s*(public|protected|private|\s)*\s*(static\s+)?[\w<>\[\], ?]+\s+([A-Za-z_][\w]*)\s*\(/);
|
|
283
|
+
if (match && !map[match[3]]) {
|
|
284
|
+
map[match[3]] = i + 1;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
match = line.match(/^\s*fun\s+([A-Za-z_][\w]*)\s*\(/);
|
|
288
|
+
if (match && !map[match[1]]) {
|
|
289
|
+
map[match[1]] = i + 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return map;
|
|
293
|
+
} catch {
|
|
294
|
+
return {};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 預處理:提取目標函數的程式碼上下文
|
|
300
|
+
* 這樣 Codex 就不需要自己搜尋,可以直接分析
|
|
301
|
+
*/
|
|
302
|
+
async function extractContext({ folder, project, functionPath, onProgress }) {
|
|
303
|
+
const projectRoot = path.join(folder, project);
|
|
304
|
+
|
|
305
|
+
// 解析 functionPath: ai.omnichat.service.AiService#enterAiSession(...)
|
|
306
|
+
const hashIdx = functionPath.indexOf("#");
|
|
307
|
+
let className, methodName, fullClassName;
|
|
308
|
+
|
|
309
|
+
if (hashIdx !== -1) {
|
|
310
|
+
fullClassName = functionPath.slice(0, hashIdx);
|
|
311
|
+
const methodPart = functionPath.slice(hashIdx + 1);
|
|
312
|
+
methodName = methodPart.replace(/\(.*\)$/, ""); // 去掉參數
|
|
313
|
+
className = fullClassName.split(".").pop();
|
|
314
|
+
} else {
|
|
315
|
+
fullClassName = functionPath;
|
|
316
|
+
className = functionPath.split(".").pop();
|
|
317
|
+
methodName = null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
onProgress?.({ stage: 1, percent: 10, message: `正在尋找 ${className}...` });
|
|
321
|
+
|
|
322
|
+
// Step 1: 用 rg 找到 class 定義的檔案
|
|
323
|
+
const safeClass = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
324
|
+
const classPattern = `\\b(class|interface|enum)\\s+${safeClass}\\b`;
|
|
325
|
+
|
|
326
|
+
const findClass = spawnSync(
|
|
327
|
+
"rg",
|
|
328
|
+
["-l", "--glob", "*.java", "--glob", "*.kt", classPattern],
|
|
329
|
+
{ cwd: projectRoot, encoding: "utf8" }
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
if (findClass.error || findClass.status !== 0 || !findClass.stdout.trim()) {
|
|
333
|
+
return {
|
|
334
|
+
success: false,
|
|
335
|
+
error: `找不到 class: ${className}`,
|
|
336
|
+
extractedCode: null,
|
|
337
|
+
filePath: null,
|
|
338
|
+
imports: []
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const files = findClass.stdout.trim().split("\n").filter(Boolean);
|
|
343
|
+
// 優先選擇名稱完全匹配的檔案
|
|
344
|
+
let targetFile = files.find(f => f.endsWith(`${className}.java`) || f.endsWith(`${className}.kt`)) || files[0];
|
|
345
|
+
|
|
346
|
+
onProgress?.({ stage: 1, percent: 20, message: `找到檔案: ${targetFile}` });
|
|
347
|
+
|
|
348
|
+
// Step 2: 讀取整個檔案
|
|
349
|
+
const fullPath = path.join(projectRoot, targetFile);
|
|
350
|
+
let fileContent;
|
|
351
|
+
try {
|
|
352
|
+
fileContent = readFileSync(fullPath, "utf8");
|
|
353
|
+
} catch (err) {
|
|
354
|
+
return {
|
|
355
|
+
success: false,
|
|
356
|
+
error: `無法讀取檔案: ${fullPath}`,
|
|
357
|
+
extractedCode: null,
|
|
358
|
+
filePath: null,
|
|
359
|
+
imports: []
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const lines = fileContent.split("\n");
|
|
364
|
+
|
|
365
|
+
// Step 3: 提取 import 語句
|
|
366
|
+
const imports = [];
|
|
367
|
+
for (const line of lines) {
|
|
368
|
+
const importMatch = line.match(/^\s*import\s+(.+?)\s*;/);
|
|
369
|
+
if (importMatch) {
|
|
370
|
+
imports.push(importMatch[1]);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
onProgress?.({ stage: 1, percent: 25, message: `提取了 ${imports.length} 個 import` });
|
|
375
|
+
|
|
376
|
+
// Step 4: 如果有 methodName,提取該方法;否則提取整個 class
|
|
377
|
+
let extractedCode;
|
|
378
|
+
let methodStartLine = null;
|
|
379
|
+
let methodEndLine = null;
|
|
380
|
+
|
|
381
|
+
if (methodName) {
|
|
382
|
+
onProgress?.({ stage: 1, percent: 28, message: `正在提取方法: ${methodName}...` });
|
|
383
|
+
|
|
384
|
+
// 找到方法定義行
|
|
385
|
+
// 匹配模式:方法名後面跟著 (,前面是修飾符和回傳類型
|
|
386
|
+
const methodPattern = new RegExp(
|
|
387
|
+
`\\b${methodName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
for (let i = 0; i < lines.length; i++) {
|
|
391
|
+
if (methodPattern.test(lines[i])) {
|
|
392
|
+
// 確認這是方法定義(不是呼叫)- 前面應該有類型或修飾符
|
|
393
|
+
const prevChars = lines[i].slice(0, lines[i].search(methodPattern));
|
|
394
|
+
if (/\b(public|private|protected|static|final|void|int|long|String|boolean|List|Map|Set|Optional|\w+)\s*$/.test(prevChars) ||
|
|
395
|
+
/^\s*(public|private|protected|static|final|abstract|synchronized|@\w+)/.test(lines[i])) {
|
|
396
|
+
methodStartLine = i;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (methodStartLine !== null) {
|
|
403
|
+
// 使用 brace matching 找到方法結束
|
|
404
|
+
let braceCount = 0;
|
|
405
|
+
let foundFirstBrace = false;
|
|
406
|
+
|
|
407
|
+
for (let i = methodStartLine; i < lines.length; i++) {
|
|
408
|
+
for (const char of lines[i]) {
|
|
409
|
+
if (char === "{") {
|
|
410
|
+
braceCount++;
|
|
411
|
+
foundFirstBrace = true;
|
|
412
|
+
} else if (char === "}") {
|
|
413
|
+
braceCount--;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (foundFirstBrace && braceCount === 0) {
|
|
418
|
+
methodEndLine = i;
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (methodEndLine !== null) {
|
|
424
|
+
// 往上找 annotation(@Override, @Transactional 等)
|
|
425
|
+
let annotationStart = methodStartLine;
|
|
426
|
+
for (let i = methodStartLine - 1; i >= 0; i--) {
|
|
427
|
+
const trimmed = lines[i].trim();
|
|
428
|
+
if (trimmed.startsWith("@") || trimmed === "") {
|
|
429
|
+
annotationStart = i;
|
|
430
|
+
} else {
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
extractedCode = lines.slice(annotationStart, methodEndLine + 1).join("\n");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 如果沒找到方法,或者沒指定方法,就提取整個 class(但限制長度)
|
|
441
|
+
if (!extractedCode) {
|
|
442
|
+
onProgress?.({ stage: 1, percent: 28, message: `提取整個 class...` });
|
|
443
|
+
|
|
444
|
+
// 找到 class 定義開始
|
|
445
|
+
let classStartLine = null;
|
|
446
|
+
for (let i = 0; i < lines.length; i++) {
|
|
447
|
+
if (new RegExp(classPattern).test(lines[i])) {
|
|
448
|
+
classStartLine = i;
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (classStartLine !== null) {
|
|
454
|
+
// 限制最多 300 行
|
|
455
|
+
const maxLines = Math.min(lines.length, classStartLine + 300);
|
|
456
|
+
extractedCode = lines.slice(0, maxLines).join("\n");
|
|
457
|
+
if (maxLines < lines.length) {
|
|
458
|
+
extractedCode += "\n// ... (truncated, total " + lines.length + " lines)";
|
|
459
|
+
}
|
|
460
|
+
} else {
|
|
461
|
+
// fallback: 取前 300 行
|
|
462
|
+
extractedCode = lines.slice(0, 300).join("\n");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
onProgress?.({ stage: 1, percent: 30, message: `程式碼提取完成 (${extractedCode.split("\n").length} 行)` });
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
success: true,
|
|
470
|
+
error: null,
|
|
471
|
+
extractedCode,
|
|
472
|
+
filePath: targetFile,
|
|
473
|
+
fullPath,
|
|
474
|
+
className,
|
|
475
|
+
methodName,
|
|
476
|
+
fullClassName,
|
|
477
|
+
imports,
|
|
478
|
+
methodStartLine: methodStartLine !== null ? methodStartLine + 1 : null, // 1-based
|
|
479
|
+
methodEndLine: methodEndLine !== null ? methodEndLine + 1 : null
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function isNoise(call) {
|
|
484
|
+
const t = `${call.from} ${call.to}`.toLowerCase();
|
|
485
|
+
|
|
486
|
+
// Framework 內部方法(不是真正的 API 呼叫)
|
|
487
|
+
// 匹配 #execute, #doExecute, #exchange, #getDatabase 等結尾
|
|
488
|
+
if (/#(execute|doexecute|exchange|invoke|call|doinvoke|request|send|getdatabase|getcollection|getconnection)$/i.test(call.to || "")) {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return (
|
|
493
|
+
t.includes("mongotemplate") ||
|
|
494
|
+
t.includes("mongorepository") ||
|
|
495
|
+
t.includes("mongodb") ||
|
|
496
|
+
t.includes("mongodbmanager") ||
|
|
497
|
+
t.includes("mongoclient") ||
|
|
498
|
+
t.includes("datasource") ||
|
|
499
|
+
t.includes("connectionpool") ||
|
|
500
|
+
t.includes("mapper#") ||
|
|
501
|
+
t.includes("mybatis") ||
|
|
502
|
+
t.includes("repo#") ||
|
|
503
|
+
t.includes("repository#") ||
|
|
504
|
+
t.includes("springframework") ||
|
|
505
|
+
t.includes("resttemplate") ||
|
|
506
|
+
t.includes("restclient") ||
|
|
507
|
+
t.includes("webclient") ||
|
|
508
|
+
t.includes("httpclient") ||
|
|
509
|
+
t.includes("hikari") ||
|
|
510
|
+
t.includes("jdbctemplate")
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function splitMember(ref) {
|
|
515
|
+
const s = String(ref || "");
|
|
516
|
+
const idx = s.lastIndexOf("#");
|
|
517
|
+
if (idx === -1) return { owner: s, member: "" };
|
|
518
|
+
return { owner: s.slice(0, idx), member: s.slice(idx + 1) };
|
|
519
|
+
}
|
|
520
|
+
function shortClassName(owner) {
|
|
521
|
+
const s = String(owner || "");
|
|
522
|
+
const parts = s.split(".");
|
|
523
|
+
return parts[parts.length - 1] || s;
|
|
524
|
+
}
|
|
525
|
+
function methodFirstLabel(ref) {
|
|
526
|
+
const { owner, member } = splitMember(ref);
|
|
527
|
+
const cls = shortClassName(owner);
|
|
528
|
+
if (member) return `${escapeText(member)}<br/>${escapeText(cls)}`;
|
|
529
|
+
return escapeText(cls);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// 簡潔版:只取方法名
|
|
533
|
+
function methodOnlyLabel(ref) {
|
|
534
|
+
const { member } = splitMember(ref);
|
|
535
|
+
// 去掉參數列表,只保留方法名
|
|
536
|
+
const methodName = member ? member.replace(/\(.*\)$/, "") : "";
|
|
537
|
+
return methodName ? escapeText(methodName) : escapeText(shortClassName(ref));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 抽 endpoint(可選)
|
|
541
|
+
function extractEndpointFromEvidence(evidence) {
|
|
542
|
+
const e = String(evidence || "");
|
|
543
|
+
const m1 = e.match(/@(Get|Post|Put|Patch|Delete)Mapping\(\s*["']([^"']+)["']\s*\)/i);
|
|
544
|
+
if (m1) return `${m1[1].toUpperCase()} ${m1[2]}`;
|
|
545
|
+
const m2 = e.match(/\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b\s+([\/][^\s,"')]+)/i);
|
|
546
|
+
if (m2) return `${m2[1].toUpperCase()} ${m2[2]}`;
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function buildPrompt({ project, query, context, stage, focus }) {
|
|
551
|
+
const baseRules = `
|
|
552
|
+
你是程式碼分析助手,請在目前 workspace(你啟動所在資料夾)分析程式碼。
|
|
553
|
+
|
|
554
|
+
【硬性規則】
|
|
555
|
+
- 只允許輸出 JSON,禁止輸出任何解釋文字、Markdown
|
|
556
|
+
- 輸出必須完全符合 JSON Schema;若無法符合,請自行調整直到符合
|
|
557
|
+
- 只分析指定 function 本體,不展開被呼叫的 function 內部
|
|
558
|
+
`.trim();
|
|
559
|
+
|
|
560
|
+
const headerFromContext = context && context.success && context.extractedCode
|
|
561
|
+
? (() => {
|
|
562
|
+
const fileInfo = context.filePath ? `檔案: ${context.filePath}` : "";
|
|
563
|
+
const lineInfo = context.methodStartLine ? ` (行 ${context.methodStartLine}-${context.methodEndLine})` : "";
|
|
564
|
+
const importsInfo = context.imports.length > 0
|
|
565
|
+
? `\n相關 imports:\n${context.imports.slice(0, 20).map(i => ` - ${i}`).join("\n")}`
|
|
566
|
+
: "";
|
|
567
|
+
return `
|
|
568
|
+
${baseRules}
|
|
569
|
+
|
|
570
|
+
【分析目標】
|
|
571
|
+
project: ${project}
|
|
572
|
+
class: ${context.fullClassName || context.className}
|
|
573
|
+
method: ${context.methodName || "(整個 class)"}
|
|
574
|
+
${fileInfo}${lineInfo}
|
|
575
|
+
${importsInfo}
|
|
576
|
+
|
|
577
|
+
【已提取的程式碼】
|
|
578
|
+
\`\`\`java
|
|
579
|
+
${context.extractedCode}
|
|
580
|
+
\`\`\`
|
|
581
|
+
`.trim();
|
|
582
|
+
})()
|
|
583
|
+
: `
|
|
584
|
+
${baseRules}
|
|
585
|
+
|
|
586
|
+
【分析入口】
|
|
587
|
+
project: ${project}
|
|
588
|
+
${query}
|
|
589
|
+
`.trim();
|
|
590
|
+
|
|
591
|
+
if (stage === 2) {
|
|
592
|
+
return `
|
|
593
|
+
${headerFromContext}
|
|
594
|
+
|
|
595
|
+
【細化目標】
|
|
596
|
+
focus_node: ${focus?.id || ""}
|
|
597
|
+
focus_label: ${focus?.label || ""}
|
|
598
|
+
focus_hint: ${focus?.focus || ""}
|
|
599
|
+
|
|
600
|
+
【任務】
|
|
601
|
+
1) 只針對 focus_node 對應的那一段流程做「細節化」展開。
|
|
602
|
+
2) 只涵蓋 A function 本體,不展開被呼叫方法內部。
|
|
603
|
+
3) 以流程圖輸出,包含 start 與 end 節點。
|
|
604
|
+
4) if 節點需提供條件,edges 用 label 標示 true/false。
|
|
605
|
+
5) call 節點需提供簡短功能描述(1-2 句),可根據命名/註解推測。
|
|
606
|
+
6) 外部呼叫(HTTP/DB/第三方)只需顯示呼叫名稱或 endpoint,不需展開。
|
|
607
|
+
7) 遇到 return 就結束流程,不要列出 return 之後的語句或步驟。
|
|
608
|
+
8) 不要把「解析錯誤/JSON parse」這類內部處理訊息當成流程步驟。
|
|
609
|
+
9) DB 查詢(例如 findByTeamAndChannelIdAndUserId)只需顯示方法名,不要附加說明。
|
|
610
|
+
|
|
611
|
+
【節點欄位說明】
|
|
612
|
+
- call 節點需帶 call 欄位:
|
|
613
|
+
- name: 呼叫目標全名(盡量包含 class#method)
|
|
614
|
+
- kind: method/http/db/external/unknown
|
|
615
|
+
- project/file/line: 若為專案內部方法,請提供檔案相對路徑與行號
|
|
616
|
+
- label: 用於圖上顯示,請簡短
|
|
617
|
+
- summary: call 節點的功能描述(1-2 句,純文字)
|
|
618
|
+
|
|
619
|
+
【輸出資料結構】
|
|
620
|
+
- focus_node: 必須等於 focus_node
|
|
621
|
+
- nodes: 流程節點列表,id 請用簡單編號(如 n1, n2, n3)。
|
|
622
|
+
- edges: 連線關係,if 分支用 label 標示 true/false。
|
|
623
|
+
- 所有欄位都必須出現,若沒有值請填 null。
|
|
624
|
+
`.trim();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return `
|
|
628
|
+
${headerFromContext}
|
|
629
|
+
|
|
630
|
+
【任務】
|
|
631
|
+
1) 產生「概要流程圖」,只涵蓋 A function 的主要步驟與檢核點。
|
|
632
|
+
2) nodes/edges 以步驟順序描述呼叫與 if 判斷,避免過細節。
|
|
633
|
+
3) 對重要檢核/外部呼叫節點填寫 focus(供後續細化用)。
|
|
634
|
+
4) call 節點需提供簡短功能描述(1-2 句),可根據命名/註解推測。
|
|
635
|
+
5) 外部呼叫(HTTP/DB/第三方)只需顯示呼叫名稱或 endpoint,不需展開。
|
|
636
|
+
6) if 節點需提供條件,並用 edges 標記 true/false。
|
|
637
|
+
7) 若為專案內部方法,盡量提供檔案路徑與行號(相對於專案根目錄)。
|
|
638
|
+
8) nodes 請包含 start 與 end 節點,並把流程串起來。
|
|
639
|
+
9) 遇到 return 就結束流程,不要列出 return 之後的語句或步驟。
|
|
640
|
+
10) 不要把「解析錯誤/JSON parse」這類內部處理訊息當成流程步驟。
|
|
641
|
+
11) DB 查詢(例如 findByTeamAndChannelIdAndUserId)只需顯示方法名,不要附加說明。
|
|
642
|
+
|
|
643
|
+
【節點欄位說明】
|
|
644
|
+
- call 節點需帶 call 欄位:
|
|
645
|
+
- name: 呼叫目標全名(盡量包含 class#method)
|
|
646
|
+
- kind: method/http/db/external/unknown
|
|
647
|
+
- project/file/line: 若為專案內部方法,請提供檔案相對路徑與行號
|
|
648
|
+
- label: 用於圖上顯示,請簡短
|
|
649
|
+
- summary: call 節點的功能描述(1-2 句,純文字)
|
|
650
|
+
- focus: 需要細化的節點用簡短關鍵字描述;不需要細化則填 null
|
|
651
|
+
|
|
652
|
+
【輸出資料結構】
|
|
653
|
+
- nodes: 流程節點列表,id 請用簡單編號(如 n1, n2, n3)。
|
|
654
|
+
- edges: 連線關係,if 分支用 label 標示 true/false。
|
|
655
|
+
- 所有欄位都必須出現,若沒有值請填 null。
|
|
656
|
+
`.trim();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function runCodexIR({ folder, schemaPath, project, query, keepDir, context, stage, focus }) {
|
|
660
|
+
const prompt = buildPrompt({ project, query, context, stage, focus });
|
|
661
|
+
const outputPath = keepDir
|
|
662
|
+
? path.join(keepDir, "codex.last.json")
|
|
663
|
+
: path.join(folder, ".apiflow.tmp.codex.json");
|
|
664
|
+
|
|
665
|
+
// 如果有預提取的程式碼,不需要 --cd(避免 Codex 索引整個 workspace)
|
|
666
|
+
const hasExtractedCode = context?.success && context?.extractedCode;
|
|
667
|
+
|
|
668
|
+
const args = hasExtractedCode
|
|
669
|
+
? [
|
|
670
|
+
"exec",
|
|
671
|
+
"--output-schema",
|
|
672
|
+
schemaPath,
|
|
673
|
+
"--output-last-message",
|
|
674
|
+
outputPath,
|
|
675
|
+
"-"
|
|
676
|
+
]
|
|
677
|
+
: [
|
|
678
|
+
"exec",
|
|
679
|
+
"--output-schema",
|
|
680
|
+
schemaPath,
|
|
681
|
+
"--output-last-message",
|
|
682
|
+
outputPath,
|
|
683
|
+
"--cd",
|
|
684
|
+
folder,
|
|
685
|
+
"-"
|
|
686
|
+
];
|
|
687
|
+
|
|
688
|
+
await runCmdWithInput("codex", args, prompt, { cwd: folder });
|
|
689
|
+
const jsonText = readFileSync(outputPath, "utf8");
|
|
690
|
+
|
|
691
|
+
let structured = null;
|
|
692
|
+
try {
|
|
693
|
+
structured = JSON.parse(jsonText);
|
|
694
|
+
} catch (err) {
|
|
695
|
+
die("codex 輸出不是有效 JSON,請確認 --output-schema 或 prompt 是否正確。");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (keepDir) {
|
|
699
|
+
writeFileSync(path.join(keepDir, "codex.last.json"), JSON.stringify(structured, null, 2), "utf8");
|
|
700
|
+
writeFileSync(path.join(keepDir, "ir.json"), JSON.stringify(structured, null, 2), "utf8");
|
|
701
|
+
} else {
|
|
702
|
+
try {
|
|
703
|
+
unlinkSync(outputPath);
|
|
704
|
+
} catch {}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return structured;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function renderMermaidFlowchart(ir, wrapWidth = 85, opts = {}) {
|
|
711
|
+
const folder = opts.folder || "";
|
|
712
|
+
const targetProject = normalizeProjectName(ir.target_project || opts.defaultProject || "");
|
|
713
|
+
const entryName = String(ir.entry || "");
|
|
714
|
+
const entryOwner = entryName.split("#")[0] || "";
|
|
715
|
+
const entryClass = entryOwner.split(".").pop() || "";
|
|
716
|
+
const entryMethod = (entryName.split("#")[1] || "").replace(/\(.*\)$/, "");
|
|
717
|
+
const entryLocation = resolveCallLocation({
|
|
718
|
+
folder,
|
|
719
|
+
project: targetProject,
|
|
720
|
+
className: entryClass,
|
|
721
|
+
methodName: entryMethod
|
|
722
|
+
});
|
|
723
|
+
const entryMethodIndex = entryLocation
|
|
724
|
+
? buildMethodIndexFromFile({
|
|
725
|
+
folder,
|
|
726
|
+
project: targetProject,
|
|
727
|
+
fileRel: entryLocation.file
|
|
728
|
+
})
|
|
729
|
+
: {};
|
|
730
|
+
const allNodes = Array.isArray(ir.nodes) ? ir.nodes : [];
|
|
731
|
+
const allEdges = Array.isArray(ir.edges) ? ir.edges : [];
|
|
732
|
+
const noisePatterns = [
|
|
733
|
+
/parse error json/i,
|
|
734
|
+
/parse json string/i,
|
|
735
|
+
/json parse/i
|
|
736
|
+
];
|
|
737
|
+
const isNoise = (node) => {
|
|
738
|
+
const text = `${node?.label || ""} ${node?.summary || ""}`.trim();
|
|
739
|
+
return noisePatterns.some((re) => re.test(text));
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const nodes = allNodes.filter((n) => !isNoise(n));
|
|
743
|
+
const validIds = new Set(nodes.map((n) => n.id));
|
|
744
|
+
const edges = allEdges.filter((e) => validIds.has(e.from) && validIds.has(e.to));
|
|
745
|
+
const functionMap = {};
|
|
746
|
+
|
|
747
|
+
const idMap = new Map();
|
|
748
|
+
const getId = (id) => {
|
|
749
|
+
if (!idMap.has(id)) idMap.set(id, pid(id));
|
|
750
|
+
return idMap.get(id);
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
let mmd = "";
|
|
754
|
+
mmd += "flowchart TD\n";
|
|
755
|
+
|
|
756
|
+
for (const node of nodes) {
|
|
757
|
+
let callInfo = node.call ? { ...node.call } : null;
|
|
758
|
+
const feature = node.type === "if"
|
|
759
|
+
? extractFeatureToggle(`${node.condition || ""} ${node.label || ""} ${node.summary || ""} ${callInfo?.name || ""}`)
|
|
760
|
+
: "";
|
|
761
|
+
const nodeLabelOverride = feature ? `feature toggle: ${feature}` : "";
|
|
762
|
+
if (node.type === "call" && callInfo && !callInfo.project && targetProject !== "unknown") {
|
|
763
|
+
callInfo.project = targetProject;
|
|
764
|
+
}
|
|
765
|
+
if (node.type === "call" && callInfo && !callInfo.file && callInfo.name) {
|
|
766
|
+
const owner = callInfo.name.split("#")[0] || "";
|
|
767
|
+
const methodName = (callInfo.name.split("#")[1] || "").replace(/\(.*\)$/, "");
|
|
768
|
+
const className = owner.split(".").pop() || owner;
|
|
769
|
+
const resolved = resolveCallLocation({
|
|
770
|
+
folder,
|
|
771
|
+
project: callInfo.project || targetProject,
|
|
772
|
+
className,
|
|
773
|
+
methodName
|
|
774
|
+
});
|
|
775
|
+
if (resolved) {
|
|
776
|
+
callInfo.file = resolved.file;
|
|
777
|
+
callInfo.line = resolved.line;
|
|
778
|
+
} else if (entryLocation && methodName) {
|
|
779
|
+
const localLine = entryMethodIndex[methodName];
|
|
780
|
+
if (localLine) {
|
|
781
|
+
callInfo.file = entryLocation.file;
|
|
782
|
+
callInfo.line = localLine;
|
|
783
|
+
} else {
|
|
784
|
+
const any = resolveMethodInProject({
|
|
785
|
+
folder,
|
|
786
|
+
project: callInfo.project || targetProject,
|
|
787
|
+
methodName
|
|
788
|
+
});
|
|
789
|
+
if (any) {
|
|
790
|
+
callInfo.file = any.file;
|
|
791
|
+
callInfo.line = any.line;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
} else if (methodName) {
|
|
795
|
+
const any = resolveMethodInProject({
|
|
796
|
+
folder,
|
|
797
|
+
project: callInfo.project || targetProject,
|
|
798
|
+
methodName
|
|
799
|
+
});
|
|
800
|
+
if (any) {
|
|
801
|
+
callInfo.file = any.file;
|
|
802
|
+
callInfo.line = any.line;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const safeId = getId(node.id || "");
|
|
808
|
+
const labelBase = wrapText(escapeText(nodeLabelOverride || node.label || ""), wrapWidth);
|
|
809
|
+
const isDbCall = node.type === "call" && callInfo && callInfo.kind === "db";
|
|
810
|
+
const summary = !isDbCall && node.summary ? wrapText(escapeText(node.summary), wrapWidth) : "";
|
|
811
|
+
const label = summary ? `${labelBase}<br/>${summary}` : labelBase;
|
|
812
|
+
|
|
813
|
+
if (node.type === "if") {
|
|
814
|
+
mmd += ` ${safeId}{${label}}\n`;
|
|
815
|
+
} else if (node.type === "start" || node.type === "end") {
|
|
816
|
+
mmd += ` ${safeId}([${label}])\n`;
|
|
817
|
+
} else {
|
|
818
|
+
mmd += ` ${safeId}["${label}"]\n`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (node.type === "call" && callInfo && callInfo.file) {
|
|
822
|
+
functionMap[safeId] = {
|
|
823
|
+
file: callInfo.file,
|
|
824
|
+
line: callInfo.line,
|
|
825
|
+
project: callInfo.project,
|
|
826
|
+
name: callInfo.name
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
for (const edge of edges) {
|
|
832
|
+
const fromId = getId(edge.from || "");
|
|
833
|
+
const toId = getId(edge.to || "");
|
|
834
|
+
const label = edge.label ? wrapText(escapeText(edge.label), wrapWidth) : "";
|
|
835
|
+
if (label) {
|
|
836
|
+
mmd += ` ${fromId} --|${label}|--> ${toId}\n`;
|
|
837
|
+
} else {
|
|
838
|
+
mmd += ` ${fromId} --> ${toId}\n`;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return { mmd, functionMap };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function findMmdcBin(folder) {
|
|
846
|
+
const local = path.join(folder, "node_modules", ".bin", process.platform === "win32" ? "mmdc.cmd" : "mmdc");
|
|
847
|
+
return existsSync(local) ? local : "mmdc";
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function renderSvg({ folder, mmdText, outSvgPath, keepDir, functionMap, mmdName }) {
|
|
851
|
+
const name = mmdName || "flow.mmd";
|
|
852
|
+
const mmdPath = keepDir
|
|
853
|
+
? path.join(keepDir, name)
|
|
854
|
+
: path.join(folder, `.apiflow.tmp.${name}`);
|
|
855
|
+
|
|
856
|
+
writeFileSync(mmdPath, mmdText, "utf8");
|
|
857
|
+
|
|
858
|
+
const mmdc = findMmdcBin(folder);
|
|
859
|
+
const absOut = path.isAbsolute(outSvgPath) ? outSvgPath : path.join(folder, outSvgPath);
|
|
860
|
+
mkdirSync(path.dirname(absOut), { recursive: true });
|
|
861
|
+
|
|
862
|
+
await runCmd(mmdc, ["-i", mmdPath, "-o", absOut], { cwd: folder });
|
|
863
|
+
|
|
864
|
+
// SVG 後處理:加入 data 屬性讓節點可點擊
|
|
865
|
+
if (functionMap && Object.keys(functionMap).length > 0) {
|
|
866
|
+
let svg = readFileSync(absOut, "utf8");
|
|
867
|
+
|
|
868
|
+
// 加入可點擊的 CSS 樣式
|
|
869
|
+
svg = svg.replace(
|
|
870
|
+
"<style>",
|
|
871
|
+
`<style>
|
|
872
|
+
.clickable-node { cursor: pointer; }
|
|
873
|
+
.clickable-node:hover .label text { fill: #007aff !important; }
|
|
874
|
+
.clickable-node:hover .label span { color: #007aff !important; }
|
|
875
|
+
`
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
const escapeAttr = (s) =>
|
|
879
|
+
String(s ?? "")
|
|
880
|
+
.replace(/&/g, "&")
|
|
881
|
+
.replace(/"/g, """)
|
|
882
|
+
.replace(/</g, "<")
|
|
883
|
+
.replace(/>/g, ">");
|
|
884
|
+
|
|
885
|
+
for (const [nodeId, info] of Object.entries(functionMap)) {
|
|
886
|
+
const dataAttrs = ` data-function="${escapeAttr(info.name)}" data-file="${escapeAttr(
|
|
887
|
+
info.file || ""
|
|
888
|
+
)}" data-line="${escapeAttr(info.line || "")}" data-project="${escapeAttr(info.project || "")}"`;
|
|
889
|
+
const regex = new RegExp(`<g\\s+[^>]*id="flowchart-${nodeId}-[^"]*"[^>]*>`, "g");
|
|
890
|
+
svg = svg.replace(regex, (tag) => {
|
|
891
|
+
let updated = tag;
|
|
892
|
+
if (updated.includes('class="')) {
|
|
893
|
+
updated = updated.replace(/class="([^"]*)"/, (m, cls) => `class="${cls} clickable-node"`);
|
|
894
|
+
} else {
|
|
895
|
+
updated = updated.replace(/^<g\s+/, '<g class="clickable-node" ');
|
|
896
|
+
}
|
|
897
|
+
if (!updated.includes("data-file=")) {
|
|
898
|
+
updated = updated.replace(/>$/, `${dataAttrs}>`);
|
|
899
|
+
}
|
|
900
|
+
return updated;
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
writeFileSync(absOut, svg, "utf8");
|
|
905
|
+
|
|
906
|
+
// 同時輸出 function-map.json
|
|
907
|
+
if (keepDir) {
|
|
908
|
+
writeFileSync(
|
|
909
|
+
path.join(keepDir, "function-map.json"),
|
|
910
|
+
JSON.stringify(functionMap, null, 2),
|
|
911
|
+
"utf8"
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// 如果沒 keep,就清理暫存 mmd
|
|
917
|
+
if (!keepDir) {
|
|
918
|
+
try {
|
|
919
|
+
// 不強求刪除,避免權限問題造成失敗
|
|
920
|
+
// eslint-disable-next-line no-empty
|
|
921
|
+
} catch {}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return absOut;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function main() {
|
|
928
|
+
const args = parseArgs(process.argv.slice(2));
|
|
929
|
+
if (args.help) return help();
|
|
930
|
+
|
|
931
|
+
if (!args.project) die("缺少 --project,例如 --project gemini");
|
|
932
|
+
if (!args.query) die("缺少 --path 或 --query");
|
|
933
|
+
if (!args.out) die("缺少 --out,例如 --out dist/flow.svg");
|
|
934
|
+
if (![1, 2].includes(args.stage)) die("無效的 --stage,請使用 1 或 2");
|
|
935
|
+
if (args.stage === 2 && !args.node) die("stage=2 需要 --node");
|
|
936
|
+
|
|
937
|
+
const folder = path.resolve(args.folder);
|
|
938
|
+
const schemaPath = args.schema
|
|
939
|
+
? path.resolve(args.schema)
|
|
940
|
+
: path.join(folder, args.stage === 2 ? "ir5-stage2.schema.json" : "ir5-stage1.schema.json");
|
|
941
|
+
|
|
942
|
+
if (!existsSync(schemaPath)) {
|
|
943
|
+
die(`找不到 schema:${schemaPath}\n請放 ir5-stage1.schema.json / ir5-stage2.schema.json 在 workspace root,或用 --schema 指定。`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// 中間檔輸出目錄(keep 模式才寫)
|
|
947
|
+
const keepDir = args.keep ? path.join(folder, "dist") : null;
|
|
948
|
+
if (keepDir) mkdirSync(keepDir, { recursive: true });
|
|
949
|
+
|
|
950
|
+
// Stage 1: 預處理 - 提取程式碼
|
|
951
|
+
emitProgress(1, 5, "準備分析環境...");
|
|
952
|
+
|
|
953
|
+
// 從 query 中提取 functionPath(如果是 path: xxx 格式)
|
|
954
|
+
let functionPath = args.path;
|
|
955
|
+
if (!functionPath && args.query) {
|
|
956
|
+
const pathMatch = args.query.match(/^path:\s*(.+)$/);
|
|
957
|
+
if (pathMatch) functionPath = pathMatch[1].trim();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
let context = null;
|
|
961
|
+
if (functionPath) {
|
|
962
|
+
emitProgress(1, 8, "正在提取程式碼上下文...");
|
|
963
|
+
context = await extractContext({
|
|
964
|
+
folder,
|
|
965
|
+
project: args.project,
|
|
966
|
+
functionPath,
|
|
967
|
+
onProgress: (p) => emitProgress(p.stage, p.percent, p.message)
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
if (context.success) {
|
|
971
|
+
emitProgress(1, 30, `程式碼提取完成: ${context.filePath} (${context.extractedCode.split("\n").length} 行)`);
|
|
972
|
+
|
|
973
|
+
// 保存提取的上下文到 dist(方便除錯)
|
|
974
|
+
if (keepDir) {
|
|
975
|
+
writeFileSync(
|
|
976
|
+
path.join(keepDir, "extracted-context.txt"),
|
|
977
|
+
`File: ${context.filePath}\nClass: ${context.fullClassName}\nMethod: ${context.methodName}\nLines: ${context.methodStartLine}-${context.methodEndLine}\n\n${context.extractedCode}`,
|
|
978
|
+
"utf8"
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
} else {
|
|
982
|
+
emitProgress(1, 30, `無法提取程式碼: ${context.error},將使用 Codex 搜尋`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Stage 2: Codex 產生 IR
|
|
987
|
+
if (context && context.success) {
|
|
988
|
+
emitProgress(2, 35, "Codex 正在分析程式碼(純分析模式,較快)...");
|
|
989
|
+
} else {
|
|
990
|
+
emitProgress(2, 35, "Codex 正在搜尋並分析程式碼(需索引 workspace,較慢)...");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const focus = args.stage === 2
|
|
994
|
+
? { id: args.node, label: args.nodeLabel || "", focus: args.nodeFocus || "" }
|
|
995
|
+
: null;
|
|
996
|
+
|
|
997
|
+
const ir = await runCodexIR({
|
|
998
|
+
folder,
|
|
999
|
+
schemaPath,
|
|
1000
|
+
project: args.project,
|
|
1001
|
+
query: args.query,
|
|
1002
|
+
keepDir,
|
|
1003
|
+
context,
|
|
1004
|
+
stage: args.stage,
|
|
1005
|
+
focus
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// Stage 3: IR -> Mermaid
|
|
1009
|
+
emitProgress(3, 85, "分析完成,正在生成 Mermaid 圖表...");
|
|
1010
|
+
const { mmd, functionMap } = renderMermaidFlowchart(
|
|
1011
|
+
ir,
|
|
1012
|
+
Number.isFinite(args.wrap) ? args.wrap : 85,
|
|
1013
|
+
{ folder, defaultProject: args.project }
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
// Stage 4: Mermaid -> SVG
|
|
1017
|
+
emitProgress(4, 92, "正在轉換為 SVG...");
|
|
1018
|
+
const mmdName = args.stage === 2 ? "flow-stage2.mmd" : "flow-stage1.mmd";
|
|
1019
|
+
const outSvg = await renderSvg({
|
|
1020
|
+
folder,
|
|
1021
|
+
mmdText: mmd,
|
|
1022
|
+
outSvgPath: args.out,
|
|
1023
|
+
keepDir,
|
|
1024
|
+
functionMap,
|
|
1025
|
+
mmdName
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// Stage 5: 完成
|
|
1029
|
+
emitProgress(5, 100, "完成!");
|
|
1030
|
+
console.log(`OK: ${outSvg}`);
|
|
1031
|
+
if (!args.keep) {
|
|
1032
|
+
console.log("提示:加上 --keep 可以保留 dist/ir.json、dist/flow-stage1.mmd、dist/flow-stage2.mmd、dist/codex.last.json");
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
main().catch((e) => die(e?.stack || e?.message || String(e)));
|