smart-review 1.0.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/README.md +713 -0
- package/bin/install.js +280 -0
- package/bin/review.js +256 -0
- package/index.js +5 -0
- package/lib/ai-client-pool.js +434 -0
- package/lib/ai-client.js +1413 -0
- package/lib/config-loader.js +223 -0
- package/lib/default-config.js +203 -0
- package/lib/reviewer.js +1340 -0
- package/lib/segmented-analyzer.js +490 -0
- package/lib/smart-batching.js +1671 -0
- package/lib/utils/concurrency-limiter.js +46 -0
- package/lib/utils/constants.js +117 -0
- package/lib/utils/git-diff-parser.js +624 -0
- package/lib/utils/logger.js +66 -0
- package/lib/utils/strip.js +221 -0
- package/package.json +44 -0
- package/templates/rules/best-practices.js +111 -0
- package/templates/rules/performance.js +123 -0
- package/templates/rules/security.js +311 -0
- package/templates/smart-review.json +80 -0
package/lib/ai-client.js
ADDED
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { prepareForAIWithLineMap } from './utils/strip.js';
|
|
5
|
+
import { logger } from './utils/logger.js';
|
|
6
|
+
import { AI_CONSTANTS, HTTP_STATUS } from './utils/constants.js';
|
|
7
|
+
|
|
8
|
+
export class AIClient {
|
|
9
|
+
static nodeVersionWarned = false; // 静态变量,确保只警告一次
|
|
10
|
+
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.client = null;
|
|
14
|
+
this.segmentCollector = new Map(); // 分段收集器:filePath -> {segments: [], totalSegments: number}
|
|
15
|
+
this.chunkedResponseCollector = new Map(); // 分段响应收集器:requestId -> {chunks: [], isComplete: boolean}
|
|
16
|
+
this.reviewDir = config.reviewDir; // 用于读取自定义AI提示词目录
|
|
17
|
+
|
|
18
|
+
// 性能优化缓存
|
|
19
|
+
this.promptCache = new Map(); // 缓存自定义提示词
|
|
20
|
+
this.systemPromptCache = null; // 缓存系统提示词
|
|
21
|
+
this.contentCache = new Map(); // 缓存处理后的内容
|
|
22
|
+
this.cacheStats = { hits: 0, misses: 0 };
|
|
23
|
+
|
|
24
|
+
this.initializeClient();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
initializeClient() {
|
|
28
|
+
const { apiKey, baseURL } = this.config;
|
|
29
|
+
|
|
30
|
+
if (!apiKey) {
|
|
31
|
+
throw new Error('未配置AI API密钥');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 环境检测:OpenAI SDK 推荐 Node >=18(内置 fetch)。
|
|
35
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
36
|
+
if ((nodeMajor < 18 || typeof fetch === 'undefined') && !AIClient.nodeVersionWarned) {
|
|
37
|
+
logger.warn('检测到 Node 版本 < 18 或缺少全局 fetch,可能导致连接异常。建议升级到 Node >=18。');
|
|
38
|
+
AIClient.nodeVersionWarned = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const options = { apiKey, maxRetries: 3 };
|
|
42
|
+
if (baseURL) options.baseURL = baseURL;
|
|
43
|
+
|
|
44
|
+
// 当前实现仅支持 OpenAI 客户端
|
|
45
|
+
this.client = new OpenAI(options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 智能批量文件分析:支持分段文件的合并分析
|
|
49
|
+
async analyzeSmartBatch(batchData, originalBatch = null, requestMeta = null) {
|
|
50
|
+
try {
|
|
51
|
+
// 如果是大文件分段批次,改走分段整体分析路径,确保行号为绝对源行号
|
|
52
|
+
if (originalBatch?.isLargeFileSegment) {
|
|
53
|
+
try {
|
|
54
|
+
logger.debug(`检测到分段批次,改用分段整体分析:${originalBatch.segmentedFile}(${originalBatch.totalSegments}段)`);
|
|
55
|
+
} catch (e) {}
|
|
56
|
+
const result = await this.handleSegmentBatch(originalBatch);
|
|
57
|
+
return {
|
|
58
|
+
issues: result.issues || [],
|
|
59
|
+
metadata: {
|
|
60
|
+
batchIndex: originalBatch?.batchIndex,
|
|
61
|
+
fileCount: 1,
|
|
62
|
+
isSegmented: true,
|
|
63
|
+
totalSegments: originalBatch?.totalSegments,
|
|
64
|
+
filePath: originalBatch?.segmentedFile
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// 对于所有批次,执行常规的批量分析逻辑
|
|
69
|
+
const cfg = this.config?.ai || {};
|
|
70
|
+
const includeStaticHints = cfg.includeStaticHints === true;
|
|
71
|
+
const customPrompts = await this.readCustomPrompts();
|
|
72
|
+
const messages = [
|
|
73
|
+
{ role: 'system', content: this.getSystemPrompt() },
|
|
74
|
+
{
|
|
75
|
+
role: 'user',
|
|
76
|
+
content: `我会发送一个批次的文件进行代码审查。其中可能包含分段文件(大文件被分成多段)。对于分段文件,请在收到所有段后进行整体分析。每个问题用空行分隔,务必包含"文件路径:绝对路径"与代码片段,且禁止任何"第X行/第X-Y行"等行号或行范围描述。`
|
|
77
|
+
}
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
if (customPrompts.length > 0) {
|
|
81
|
+
messages.push({ role: 'user', content: `\n[自定义提示词]\n${customPrompts.join('\n\n---\n')}` });
|
|
82
|
+
}
|
|
83
|
+
// 处理每个文件
|
|
84
|
+
const requestPreviews = [];
|
|
85
|
+
for (const file of batchData.files) {
|
|
86
|
+
// 完整文件
|
|
87
|
+
const { clean, lineMap } = await prepareForAIWithLineMap(file.content, file.filePath);
|
|
88
|
+
const attachLineNumbers = this.config?.ai?.attachLineNumbersInBatch !== false;
|
|
89
|
+
const contentForAI = attachLineNumbers ? this.addLineNumberPrefixes(clean, lineMap) : clean;
|
|
90
|
+
messages.push({
|
|
91
|
+
role: 'user',
|
|
92
|
+
content: `文件路径:${file.filePath}\n代码内容:\n\`\`\`\n${contentForAI}\n\`\`\``
|
|
93
|
+
});
|
|
94
|
+
requestPreviews.push({ filePath: file.filePath, contentForAI });
|
|
95
|
+
}
|
|
96
|
+
// 汇总静态提示(可选)
|
|
97
|
+
if (includeStaticHints) {
|
|
98
|
+
const hintsParts = [];
|
|
99
|
+
for (const file of batchData.files) {
|
|
100
|
+
const staticIssues = file.staticIssues || [];
|
|
101
|
+
if (staticIssues.length === 0) continue;
|
|
102
|
+
const lines = staticIssues.map((i, idx) => `${idx + 1}. 片段(${i.risk}):${i.message}${i.suggestion ? `;建议:${i.suggestion}` : ''};代码片段:${i.snippet || ''}`);
|
|
103
|
+
hintsParts.push(`[本地规则发现的问题 - ${file.filePath}]\n${lines.join('\n')}`);
|
|
104
|
+
}
|
|
105
|
+
if (hintsParts.length > 0) {
|
|
106
|
+
messages.push({ role: 'user', content: hintsParts.join('\n\n') });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 添加最终指令,确保包含片段并保留行号前缀
|
|
111
|
+
const finalInstructionBatch = `请逐文件进行审查,每个问题用空行分隔,必须包含"文件路径:绝对路径"与具体的代码片段。禁止使用文字行号或行范围描述(如“第X行/第X-Y行”);如片段中存在每行的[n]前缀请原样保留。`;
|
|
112
|
+
messages.push({ role: 'user', content: finalInstructionBatch });
|
|
113
|
+
// 追加严格忽略规则,避免模型输出“行号跳跃/预处理移除”的提示
|
|
114
|
+
const finalIgnoreRule = `注意:代码可能经过预处理(剥离注释、跳过无需审查片段),因此行号前缀可能不连续。这是正常的,请严格忽略“行号跳跃/行号不连续/被预处理移除”等现象,不要将其视为问题或风险,也不要提出“检查代码完整性/补全缺失代码”类建议。仅针对给定片段中的有效代码提出问题与修改建议。`;
|
|
115
|
+
messages.push({ role: 'user', content: finalIgnoreRule });
|
|
116
|
+
|
|
117
|
+
// 使用分段响应处理(携带可读的请求ID,便于日志关联)
|
|
118
|
+
const smartReqId = `smart_batch_${(batchData.files?.length || 0)}_${path.basename(batchData.files?.[0]?.filePath || 'unknown')}`;
|
|
119
|
+
// 输出请求预览,便于定位行号映射问题
|
|
120
|
+
const responseContent = await this.handleChunkedResponse(messages, smartReqId, requestMeta);
|
|
121
|
+
// 批量响应:传递文件列表用于路径匹配
|
|
122
|
+
const fileList = batchData.files.map(f => f.filePath);
|
|
123
|
+
const issues = this.parseAIResponse(responseContent, undefined, { fileList });
|
|
124
|
+
|
|
125
|
+
// 返回与其他方法一致的格式
|
|
126
|
+
return {
|
|
127
|
+
issues: issues || [],
|
|
128
|
+
metadata: {
|
|
129
|
+
batchIndex: originalBatch?.batchIndex,
|
|
130
|
+
fileCount: batchData.files.length
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
logger.error(`AI批量文件分析失败: ${error.message}`);
|
|
135
|
+
// 如果是AI请求失败,应该终止程序而不是继续处理
|
|
136
|
+
if (error.message.includes('Connection error') || error.message.includes('API') || error.message.includes('请求失败')) {
|
|
137
|
+
logger.error('AI服务连接失败,终止分析过程');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
throw error; // 重新抛出错误,让上层调用者处理
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 处理分段批次:现在所有分段都在一个批次中
|
|
145
|
+
async handleSegmentBatch(batch) {
|
|
146
|
+
const filePath = batch.segmentedFile;
|
|
147
|
+
const totalSegments = batch.totalSegments;
|
|
148
|
+
|
|
149
|
+
// 现在所有分段都在同一个批次中,直接处理
|
|
150
|
+
const segments = batch.items.map(item => ({
|
|
151
|
+
index: item.chunkIndex,
|
|
152
|
+
content: item.content,
|
|
153
|
+
startLine: item.startLine || 1,
|
|
154
|
+
endLine: item.endLine || 1,
|
|
155
|
+
tokens: item.tokens || 0
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
// 按索引排序分段
|
|
159
|
+
segments.sort((a, b) => a.index - b.index);
|
|
160
|
+
|
|
161
|
+
// 合并分段内容
|
|
162
|
+
const fullContent = segments.map(seg => seg.content).join('\n');
|
|
163
|
+
|
|
164
|
+
// 构造完整文件对象进行分析
|
|
165
|
+
const fullFile = {
|
|
166
|
+
filePath: filePath,
|
|
167
|
+
content: fullContent,
|
|
168
|
+
isChunked: true,
|
|
169
|
+
totalChunks: totalSegments,
|
|
170
|
+
chunks: segments,
|
|
171
|
+
staticIssues: batch.staticIssues || [],
|
|
172
|
+
// 承载批次上下文,便于分段日志添加“批次 i/x”前缀
|
|
173
|
+
batchIndex: typeof batch.batchIndex === 'number' ? batch.batchIndex : null,
|
|
174
|
+
batchTotal: typeof batch.totalRequests === 'number' ? batch.totalRequests : null
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// 进行整体分析 - 这里会显示分段进度
|
|
178
|
+
const result = await this.analyzeCompleteSegmentedFile(fullFile);
|
|
179
|
+
|
|
180
|
+
// 返回与analyzeSmartBatch一致的格式
|
|
181
|
+
return {
|
|
182
|
+
issues: result.issues || [],
|
|
183
|
+
metadata: result.metadata || {}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 分析完整的分段文件
|
|
188
|
+
async analyzeCompleteSegmentedFile(file) {
|
|
189
|
+
try {
|
|
190
|
+
const cfg = this.config?.ai || this.config || {};
|
|
191
|
+
const includeStaticHints = cfg.includeStaticHints === true;
|
|
192
|
+
const customPrompts = await this.readCustomPrompts();
|
|
193
|
+
|
|
194
|
+
// 并发设置:从配置读取,<=1 则保持串行(兼容顶层/嵌套两种配置形态)
|
|
195
|
+
const segConcurrency = Math.max(1, Number((this.config?.ai?.concurrency ?? this.config?.concurrency) || 1));
|
|
196
|
+
const effectiveTotal = Array.isArray(file.chunks) ? file.chunks.length : (file.totalChunks || 1);
|
|
197
|
+
const availableSlots = this.concurrencyLimiter ? this.concurrencyLimiter.getAvailable() : segConcurrency;
|
|
198
|
+
const workersHead = Math.max(1, Math.min(availableSlots, effectiveTotal));
|
|
199
|
+
const totalNote = (file.totalChunks && file.totalChunks !== effectiveTotal)
|
|
200
|
+
? `(总段数 ${file.totalChunks},当前批次 ${effectiveTotal})`
|
|
201
|
+
: '';
|
|
202
|
+
logger.progress(`开始逐段分析文件: ${file.filePath},共 ${effectiveTotal} 段${workersHead > 1 ? `(并发 ${workersHead})` : ''}${totalNote}`);
|
|
203
|
+
|
|
204
|
+
const allIssues = [];
|
|
205
|
+
|
|
206
|
+
// 单段分析函数(复用原有逻辑)
|
|
207
|
+
const analyzeOne = async (i) => {
|
|
208
|
+
const chunk = file.chunks[i];
|
|
209
|
+
|
|
210
|
+
// 提前让出事件循环,允许并发协程启动
|
|
211
|
+
// 注意:真正的“开始分析第X/段”提示将在取得并发许可后输出
|
|
212
|
+
logger.debug(`分段待启动:第 ${i + 1}/${effectiveTotal} 段 (行 ${chunk.startLine}-${chunk.endLine})`) ;
|
|
213
|
+
|
|
214
|
+
// 立即让出事件循环,让其他并发协程尽快启动打印日志
|
|
215
|
+
await Promise.resolve();
|
|
216
|
+
|
|
217
|
+
// 使用缓存避免重复处理相同内容
|
|
218
|
+
const contentKey = `${file.filePath}:${chunk.startLine}-${chunk.endLine}:${chunk.content.length}`;
|
|
219
|
+
let clean;
|
|
220
|
+
let lineMapAbs = null;
|
|
221
|
+
|
|
222
|
+
if (this.contentCache.has(contentKey)) {
|
|
223
|
+
this.cacheStats.hits++;
|
|
224
|
+
const cached = this.contentCache.get(contentKey);
|
|
225
|
+
clean = cached.clean;
|
|
226
|
+
lineMapAbs = cached.lineMapAbs || null;
|
|
227
|
+
} else {
|
|
228
|
+
this.cacheStats.misses++;
|
|
229
|
+
const prepared = await prepareForAIWithLineMap(chunk.content, file.filePath);
|
|
230
|
+
clean = prepared.clean || prepared.cleaned || prepared;
|
|
231
|
+
const lm = prepared.lineMap || [];
|
|
232
|
+
lineMapAbs = Array.isArray(lm) ? lm.map(n => (Number(n) || 0) + (chunk.startLine - 1)) : null;
|
|
233
|
+
this.contentCache.set(contentKey, { clean, lineMapAbs });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 预处理完成后将继续派发AI请求
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
// 构建分段分析消息
|
|
240
|
+
let messages = [
|
|
241
|
+
{ role: 'system', content: this.getSystemPrompt() }
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
// 添加自定义提示词
|
|
245
|
+
if (customPrompts && customPrompts.length > 0) {
|
|
246
|
+
messages.push({ role: 'user', content: `\n[自定义提示词]\n${customPrompts.join('\n\n---\n')}` });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 构建分段分析提示
|
|
250
|
+
const attachLineNumbers = (this.config?.ai?.attachLineNumbersInBatch ?? this.config?.attachLineNumbersInBatch) !== false;
|
|
251
|
+
const contentForAI = attachLineNumbers ? this.addLineNumberPrefixes(clean, lineMapAbs) : clean;
|
|
252
|
+
|
|
253
|
+
const segmentPrompt = `请对以下代码段进行完整的代码审查分析。这是一个大文件的第 ${i + 1}/${file.totalChunks} 段:
|
|
254
|
+
|
|
255
|
+
文件路径:${file.filePath}
|
|
256
|
+
代码内容:
|
|
257
|
+
\`\`\`
|
|
258
|
+
${contentForAI}
|
|
259
|
+
\`\`\`
|
|
260
|
+
|
|
261
|
+
请仔细审查这段代码,查找以下类型的问题:
|
|
262
|
+
- 类型安全问题(如使用any类型)
|
|
263
|
+
- 安全漏洞
|
|
264
|
+
- 性能问题
|
|
265
|
+
- 代码质量问题
|
|
266
|
+
- 最佳实践违反
|
|
267
|
+
|
|
268
|
+
重要:请立即开始分析,不要只是确认收到。必须按以下格式输出每个发现的问题:
|
|
269
|
+
|
|
270
|
+
**-----代码分析结果-----**
|
|
271
|
+
文件路径:${file.filePath}
|
|
272
|
+
代码片段:[具体的问题代码]
|
|
273
|
+
风险等级:[高/中/低]
|
|
274
|
+
风险原因:[问题描述]
|
|
275
|
+
修改建议:[具体的修改建议]
|
|
276
|
+
|
|
277
|
+
如果发现多个问题,每个问题都要用 **-----代码分析结果-----** 开头。
|
|
278
|
+
如果没有发现问题,请回复:
|
|
279
|
+
|
|
280
|
+
**-----代码分析结果-----**
|
|
281
|
+
本段代码无明显问题
|
|
282
|
+
|
|
283
|
+
注意:如片段中每行包含形如 [n] 的源行号前缀,请在你的输出的代码片段中原样保留这些前缀,以便后续定位。`;
|
|
284
|
+
|
|
285
|
+
messages.push({
|
|
286
|
+
role: 'user',
|
|
287
|
+
content: segmentPrompt
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// 添加静态提示(如果有且属于当前分段)
|
|
291
|
+
if (includeStaticHints && file.staticIssues && file.staticIssues.length > 0) {
|
|
292
|
+
const segmentStaticIssues = file.staticIssues.filter(issue =>
|
|
293
|
+
issue.line >= chunk.startLine && issue.line <= chunk.endLine
|
|
294
|
+
);
|
|
295
|
+
if (segmentStaticIssues.length > 0) {
|
|
296
|
+
const lines = segmentStaticIssues.map((si, idx) => `${idx + 1}. 片段(${si.risk}):${si.message}${si.suggestion ? `;建议:${si.suggestion}` : ''};代码片段:${si.snippet || ''}`);
|
|
297
|
+
messages.push({ role: 'user', content: `[本地规则发现的问题 - 第${i + 1}段]\n${lines.join('\n')}` });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 发送分段分析请求
|
|
302
|
+
const segReqId = `segment_${path.basename(file.filePath)}_${i + 1}of${file.totalChunks}`;
|
|
303
|
+
const startLabel = `开始分析 ${file.filePath} 第 ${i + 1}/${effectiveTotal} 段(行 ${chunk.startLine}-${chunk.endLine})`;
|
|
304
|
+
const responseContent = await this.handleChunkedResponse(messages, segReqId, { onStart: () => logger.info(startLabel) });
|
|
305
|
+
|
|
306
|
+
// 解析分段响应
|
|
307
|
+
const segmentResult = this.parseAIResponse(responseContent, file.filePath, {});
|
|
308
|
+
|
|
309
|
+
const batchPrefix = (typeof file.batchIndex === 'number' && typeof file.batchTotal === 'number')
|
|
310
|
+
? `批次 ${file.batchIndex + 1}/${file.batchTotal} `
|
|
311
|
+
: '';
|
|
312
|
+
if (Array.isArray(segmentResult)) {
|
|
313
|
+
allIssues.push(...segmentResult);
|
|
314
|
+
logger.success(`${batchPrefix}(${file.filePath})第 ${i + 1} 段分析完成,发现 ${segmentResult.length} 个问题`);
|
|
315
|
+
} else if (segmentResult && segmentResult.issues) {
|
|
316
|
+
allIssues.push(...segmentResult.issues);
|
|
317
|
+
logger.success(`${batchPrefix}(${file.filePath})第 ${i + 1} 段分析完成,发现 ${segmentResult.issues.length} 个问题`);
|
|
318
|
+
} else {
|
|
319
|
+
logger.success(`${batchPrefix}(${file.filePath})第 ${i + 1} 段分析完成,发现 0 个问题`);
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
logger.error(`第 ${i + 1} 段分析失败: ${error.message}`);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// 按配置执行并发或串行
|
|
327
|
+
const total = effectiveTotal;
|
|
328
|
+
const workers = Math.max(1, Math.min((this.concurrencyLimiter ? this.concurrencyLimiter.getAvailable() : segConcurrency), total));
|
|
329
|
+
const schedNote = (file.totalChunks && file.totalChunks !== total)
|
|
330
|
+
? `(总段数 ${file.totalChunks},本批次处理 ${total} 段)`
|
|
331
|
+
: '';
|
|
332
|
+
// 调度细节降为调试级别,避免扰乱终端主要进度
|
|
333
|
+
logger.debug(`分段并发调度:workers=${workers}, total=${total}${schedNote}`);
|
|
334
|
+
if (workers <= 1) {
|
|
335
|
+
for (let i = 0; i < total; i++) {
|
|
336
|
+
// eslint-disable-next-line no-await-in-loop
|
|
337
|
+
await analyzeOne(i);
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
let cursor = 0;
|
|
341
|
+
const runWorker = async (workerId) => {
|
|
342
|
+
// 并发协程启动提示降为调试级别
|
|
343
|
+
logger.debug(`启动分段并发协程 #${workerId}`);
|
|
344
|
+
while (true) {
|
|
345
|
+
const i = cursor++;
|
|
346
|
+
if (i >= total) break;
|
|
347
|
+
// eslint-disable-next-line no-await-in-loop
|
|
348
|
+
await analyzeOne(i);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
await Promise.all(Array.from({ length: workers }, (_, idx) => runWorker(idx + 1)));
|
|
352
|
+
logger.debug(`分段并发完成:已处理 ${total}${file.totalChunks && file.totalChunks !== total ? `/${file.totalChunks}` : ''} 段`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
issues: allIssues,
|
|
357
|
+
metadata: {
|
|
358
|
+
totalSegments: file.totalChunks,
|
|
359
|
+
filePath: file.filePath
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
} catch (error) {
|
|
364
|
+
logger.error(`分段文件分析失败: ${error.message}`);
|
|
365
|
+
// 如果是AI请求失败,应该终止程序而不是继续处理
|
|
366
|
+
if (error.message.includes('Connection error') || error.message.includes('API') || error.message.includes('请求失败')) {
|
|
367
|
+
logger.error('AI服务连接失败,终止分析过程');
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
throw error; // 重新抛出错误,让上层调用者处理
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Git Diff文件分析 - 专门审查变动内容
|
|
376
|
+
* @param {Object} fileData diff审查数据
|
|
377
|
+
* @param {Object} options 选项
|
|
378
|
+
* @returns {Array} 问题列表
|
|
379
|
+
*/
|
|
380
|
+
async analyzeDiffFile(fileData, options = {}) {
|
|
381
|
+
try {
|
|
382
|
+
const cfg = this.config?.ai || {};
|
|
383
|
+
const includeStaticHints = cfg.includeStaticHints === true;
|
|
384
|
+
const customPrompts = await this.readCustomPrompts();
|
|
385
|
+
const staticIssues = options.staticIssues || [];
|
|
386
|
+
|
|
387
|
+
logger.debug(`开始Git Diff分析: ${fileData.filePath} (${fileData.totalAddedLines} 行新增代码)`);
|
|
388
|
+
|
|
389
|
+
// 构建diff专用的系统提示词
|
|
390
|
+
const diffSystemPrompt = this.getDiffSystemPrompt();
|
|
391
|
+
|
|
392
|
+
const messages = [
|
|
393
|
+
{ role: 'system', content: diffSystemPrompt }
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
// 添加自定义提示词
|
|
397
|
+
if (customPrompts && customPrompts.length > 0) {
|
|
398
|
+
messages.push({ role: 'user', content: `\n[自定义提示词]\n${customPrompts.join('\n\n---\n')}` });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 构建diff分析提示
|
|
402
|
+
const diffPrompt = `请对以下Git变更进行代码审查。重点关注新增的代码行(+号标记),上下文代码仅供理解,无需审查。
|
|
403
|
+
|
|
404
|
+
文件路径:${fileData.filePath}
|
|
405
|
+
新增代码行数:${fileData.totalAddedLines}
|
|
406
|
+
智能分段数:${fileData.segments.length}
|
|
407
|
+
|
|
408
|
+
变更内容:`;
|
|
409
|
+
|
|
410
|
+
messages.push({ role: 'user', content: diffPrompt });
|
|
411
|
+
|
|
412
|
+
// 添加每个智能分段
|
|
413
|
+
for (let i = 0; i < fileData.segments.length; i++) {
|
|
414
|
+
const segment = fileData.segments[i];
|
|
415
|
+
const segmentPrompt = `
|
|
416
|
+
[智能分段 ${i + 1}/${fileData.segments.length}] (行范围: ${segment.startLine}-${segment.endLine}, 新增${segment.addedLinesCount}行, 约${segment.estimatedTokens} tokens)
|
|
417
|
+
\`\`\`diff
|
|
418
|
+
${segment.content}
|
|
419
|
+
\`\`\``;
|
|
420
|
+
|
|
421
|
+
messages.push({ role: 'user', content: segmentPrompt });
|
|
422
|
+
// 追加严格忽略规则,避免模型输出“行号跳跃/预处理移除”的提示
|
|
423
|
+
messages.push({ role: 'user', content: `重要:该分段可能经过预处理,行号可能不连续。请严格忽略“行号跳跃/行号不连续/被预处理移除”等现象,不要将其视为问题或风险,也不要提出“检查代码完整性/补全缺失代码”类建议。` });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 添加静态分析提示(如果有)
|
|
427
|
+
if (includeStaticHints && staticIssues.length > 0) {
|
|
428
|
+
const hintLines = staticIssues.map((issue, idx) =>
|
|
429
|
+
`${idx + 1}. 风险等级:${issue.risk},问题:${issue.message}${issue.suggestion ? `,建议:${issue.suggestion}` : ''},代码片段:${issue.snippet || ''}`
|
|
430
|
+
);
|
|
431
|
+
const hintsPrompt = `\n[本地规则发现的问题]\n${hintLines.join('\n')}`;
|
|
432
|
+
messages.push({ role: 'user', content: hintsPrompt });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 添加最终指令
|
|
436
|
+
const finalInstruction = `
|
|
437
|
+
请仅对标记为"+"的新增代码行进行审查,忽略删除行(-)和上下文行。每个问题用空行分隔,必须包含"文件路径:${fileData.filePath}"和具体的代码片段。禁止使用文字行号或行范围描述(如“第X行/第X-Y行”);如片段中存在每行的[行号]前缀请原样保留。`;
|
|
438
|
+
|
|
439
|
+
messages.push({ role: 'user', content: finalInstruction });
|
|
440
|
+
// 追加严格忽略规则,避免模型输出“行号跳跃/预处理移除”的提示
|
|
441
|
+
messages.push({ role: 'user', content: `注意:变更内容可能经过预处理(剥离注释、跳过无需审查片段),新增片段中的源行号可能不连续。这是正常的,请严格忽略“行号跳跃/行号不连续/被预处理移除”等现象,不要将其视为问题或风险,也不要提出“检查代码完整性/补全缺失代码”类建议。仅针对给定片段中的有效新增代码提出问题与修改建议。` });
|
|
442
|
+
|
|
443
|
+
// 记录请求信息
|
|
444
|
+
logger.debug(`发送Git Diff AI请求 - 模型: ${this.config.model ?? 'gpt-3.5-turbo'}, 消息数: ${messages.length}`);
|
|
445
|
+
|
|
446
|
+
// 发送请求并处理响应
|
|
447
|
+
const diffReqId = `diff_${path.basename(fileData.filePath)}`;
|
|
448
|
+
const responseContent = await this.handleChunkedResponse(messages, diffReqId);
|
|
449
|
+
const issues = this.parseAIResponse(responseContent, fileData.filePath);
|
|
450
|
+
|
|
451
|
+
logger.debug(`Git Diff分析完成: ${fileData.filePath},发现 ${issues.length} 个问题`);
|
|
452
|
+
|
|
453
|
+
return issues || [];
|
|
454
|
+
|
|
455
|
+
} catch (error) {
|
|
456
|
+
logger.error(`Git Diff文件分析失败: ${error.message}`);
|
|
457
|
+
if (error.message.includes('Connection error') || error.message.includes('API') || error.message.includes('请求失败')) {
|
|
458
|
+
logger.error('AI服务连接失败,终止分析过程');
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 批量文件分析:一次请求发送多个文件的完整内容
|
|
466
|
+
async analyzeFilesBatch(entries) {
|
|
467
|
+
try {
|
|
468
|
+
const cfg = this.config?.ai || {};
|
|
469
|
+
const includeStaticHints = cfg.includeStaticHints === true;
|
|
470
|
+
const customPrompts = await this.readCustomPrompts();
|
|
471
|
+
|
|
472
|
+
const messages = [
|
|
473
|
+
{ role: 'system', content: this.getSystemPrompt() },
|
|
474
|
+
{
|
|
475
|
+
role: 'user',
|
|
476
|
+
content: `我会一次性发送多个文件的完整代码,请逐文件进行审查并返回结果。每个问题用空行分隔,务必包含"文件路径:绝对路径"与代码片段,且禁止任何"第X行/第X-Y行"等行号或行范围描述。`
|
|
477
|
+
}
|
|
478
|
+
];
|
|
479
|
+
|
|
480
|
+
if (customPrompts.length > 0) {
|
|
481
|
+
messages.push({ role: 'user', content: `\n[自定义提示词]\n${customPrompts.join('\n\n---\n')}` });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 逐文件添加内容
|
|
485
|
+
const requestPreviews = [];
|
|
486
|
+
for (let i = 0; i < entries.length; i++) {
|
|
487
|
+
const { filePath, content, failedStatic } = entries[i];
|
|
488
|
+
const { clean, lineMap } = await prepareForAIWithLineMap(content, filePath);
|
|
489
|
+
const attachLineNumbers = this.config?.ai?.attachLineNumbersInBatch !== false;
|
|
490
|
+
const contentForAI = attachLineNumbers ? this.addLineNumberPrefixes(clean, lineMap) : clean;
|
|
491
|
+
messages.push({
|
|
492
|
+
role: 'user',
|
|
493
|
+
content: `文件路径:${filePath}${failedStatic ? '(本地审查未通过)' : ''}\n代码内容:\n\`\`\`\n${contentForAI}\n\`\`\``
|
|
494
|
+
});
|
|
495
|
+
requestPreviews.push({ filePath, contentForAI });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 汇总静态提示(可选)
|
|
499
|
+
if (includeStaticHints) {
|
|
500
|
+
const hintsParts = [];
|
|
501
|
+
for (const e of entries) {
|
|
502
|
+
if (e.failedStatic !== true) continue; // 仅针对本地未通过的文件汇总静态提示
|
|
503
|
+
const staticIssues = e.staticIssues || [];
|
|
504
|
+
if (staticIssues.length === 0) continue;
|
|
505
|
+
const lines = staticIssues.map((i, idx) => `${idx + 1}. 片段(${i.risk}):${i.message}${i.suggestion ? `;建议:${i.suggestion}` : ''};代码片段:${i.snippet || ''}`);
|
|
506
|
+
hintsParts.push(`[本地规则发现的问题 - ${e.filePath}]\n${lines.join('\n')}`);
|
|
507
|
+
}
|
|
508
|
+
if (hintsParts.length > 0) {
|
|
509
|
+
messages.push({ role: 'user', content: hintsParts.join('\n\n') });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 添加最终指令,确保包含片段并保留行号前缀
|
|
514
|
+
const finalInstructionBatch = `请逐文件进行审查,每个问题用空行分隔,必须包含"文件路径:绝对路径"与具体的代码片段。禁止使用文字行号或行范围描述(如“第X行/第X-Y行”);如片段中存在每行的[n]前缀请原样保留。`;
|
|
515
|
+
messages.push({ role: 'user', content: finalInstructionBatch });
|
|
516
|
+
const useDynamic = (this.config?.ai?.dynamicMaxTokens !== false);
|
|
517
|
+
const contextWindow = (() => {
|
|
518
|
+
const cw = Number(this.config.contextWindow ?? AI_CONSTANTS.DEFAULT_CONTEXT_WINDOW);
|
|
519
|
+
return Number.isFinite(cw) ? cw : AI_CONSTANTS.DEFAULT_CONTEXT_WINDOW;
|
|
520
|
+
})();
|
|
521
|
+
const reserve = (() => {
|
|
522
|
+
const rv = Number(this.config.outputReserveTokens ?? AI_CONSTANTS.DEFAULT_OUTPUT_RESERVE_TOKENS);
|
|
523
|
+
return Number.isFinite(rv) ? rv : AI_CONSTANTS.DEFAULT_OUTPUT_RESERVE_TOKENS;
|
|
524
|
+
})();
|
|
525
|
+
const totalChars = messages.reduce((acc, m) => acc + String(m.content || '').length, 0);
|
|
526
|
+
const inputTokensEst = Math.ceil(totalChars / AI_CONSTANTS.CHARS_PER_TOKEN);
|
|
527
|
+
const dynamicBudget = Math.max(AI_CONSTANTS.MIN_DYNAMIC_BUDGET, Math.min(AI_CONSTANTS.MAX_DYNAMIC_BUDGET, contextWindow - inputTokensEst - reserve));
|
|
528
|
+
const response = await this.chatWithRetry({
|
|
529
|
+
model: this.config.model ?? 'gpt-3.5-turbo',
|
|
530
|
+
messages,
|
|
531
|
+
temperature: this.config.temperature !== undefined ? this.config.temperature : 0.1,
|
|
532
|
+
max_tokens: (() => {
|
|
533
|
+
if (useDynamic) {
|
|
534
|
+
const upper = Number(this.config.maxResponseTokens ?? dynamicBudget);
|
|
535
|
+
const safeUpper = Number.isFinite(upper) ? upper : dynamicBudget;
|
|
536
|
+
return Math.max(1, Math.floor(Math.min(dynamicBudget, safeUpper)));
|
|
537
|
+
}
|
|
538
|
+
const raw = this.config.maxResponseTokens ?? AI_CONSTANTS.DEFAULT_MAX_RESPONSE_TOKENS;
|
|
539
|
+
const num = typeof raw === 'number' ? raw : Number(raw);
|
|
540
|
+
if (!Number.isFinite(num) || Number.isNaN(num)) return AI_CONSTANTS.DEFAULT_MAX_RESPONSE_TOKENS;
|
|
541
|
+
const min = 1;
|
|
542
|
+
const max = AI_CONSTANTS.MAX_RESPONSE_TOKENS_LIMIT;
|
|
543
|
+
return Math.min(max, Math.max(min, Math.floor(num)));
|
|
544
|
+
})()
|
|
545
|
+
});
|
|
546
|
+
// 批量响应:不传单个 filePath,让解析函数从AI返回的“文件名称:”中读取
|
|
547
|
+
return this.parseAIResponse(response.choices[0].message.content, undefined, {});
|
|
548
|
+
} catch (error) {
|
|
549
|
+
logger.error(`AI批量文件分析失败: ${error.message}`);
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// 通用重试封装:对 AI 请求进行重试与指数退避,处理临时失败/限流/服务器错误
|
|
555
|
+
// 通用重试封装:支持在获取并发许可后触发 onStart 钩子
|
|
556
|
+
async chatWithRetry(params, meta = null) {
|
|
557
|
+
const retries = Number(this.config.requestRetries ?? AI_CONSTANTS.DEFAULT_REQUEST_RETRIES);
|
|
558
|
+
const baseDelay = Number(this.config.requestBackoffMs ?? AI_CONSTANTS.DEFAULT_REQUEST_BACKOFF_MS);
|
|
559
|
+
let attempt = 0;
|
|
560
|
+
while (true) {
|
|
561
|
+
let release = null;
|
|
562
|
+
try {
|
|
563
|
+
if (this.concurrencyLimiter) {
|
|
564
|
+
release = await this.concurrencyLimiter.acquire();
|
|
565
|
+
// 获取到并发许可后再输出“开始分析”提示
|
|
566
|
+
if (meta && typeof meta.onStart === 'function') {
|
|
567
|
+
try { meta.onStart(); } catch (e) {}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const res = await this.client.chat.completions.create(params);
|
|
571
|
+
// 在释放并发许可之前触发成功钩子,以确保进度日志和批次完成日志先于后续开始日志
|
|
572
|
+
if (meta && typeof meta.onSuccess === 'function') {
|
|
573
|
+
try { meta.onSuccess(res); } catch (e) {}
|
|
574
|
+
}
|
|
575
|
+
return res;
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (release) {
|
|
578
|
+
try { release(); } catch (e) {}
|
|
579
|
+
release = null;
|
|
580
|
+
}
|
|
581
|
+
attempt++;
|
|
582
|
+
const status = error?.status ?? (error?.response?.status);
|
|
583
|
+
const retriable = (
|
|
584
|
+
status === undefined || status === HTTP_STATUS.TOO_MANY_REQUESTS || (typeof status === 'number' && status >= HTTP_STATUS.INTERNAL_SERVER_ERROR && status < HTTP_STATUS.SERVER_ERROR_UPPER_BOUND)
|
|
585
|
+
);
|
|
586
|
+
if (attempt > retries || !retriable) {
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
590
|
+
logger.warn(`AI请求失败,重试(${attempt}/${retries}),等待 ${delay}ms: ${error.message}`);
|
|
591
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
592
|
+
} finally {
|
|
593
|
+
if (release) {
|
|
594
|
+
try { release(); } catch (e) {}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
getSystemPrompt() {
|
|
601
|
+
// 使用缓存避免重复构建系统提示词
|
|
602
|
+
if (this.systemPromptCache) {
|
|
603
|
+
this.cacheStats.hits++;
|
|
604
|
+
return this.systemPromptCache;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
this.cacheStats.misses++;
|
|
608
|
+
this.systemPromptCache = `你是一个专业的代码审查与优化专家。请以通用的方式对收到的代码进行全面审查,识别不合理实现与潜在问题,并提出切实可行的修复或优化建议。
|
|
609
|
+
|
|
610
|
+
**分段响应协议:**
|
|
611
|
+
如果你的回答内容过长,可能超出单次响应的token限制,请按以下格式进行分段:
|
|
612
|
+
|
|
613
|
+
1. 在每段结尾添加分段标记:
|
|
614
|
+
- 未完成:[CHUNK_CONTINUE]
|
|
615
|
+
- 已完成:[CHUNK_END]
|
|
616
|
+
- 分段索引:[CHUNK_X/Y](X为当前段,Y为总段数)
|
|
617
|
+
|
|
618
|
+
2. 示例格式:
|
|
619
|
+
\`\`\`
|
|
620
|
+
这是第一段分析内容...
|
|
621
|
+
[CHUNK_1/3]
|
|
622
|
+
[CHUNK_CONTINUE]
|
|
623
|
+
\`\`\`
|
|
624
|
+
|
|
625
|
+
3. 当收到"继续"时,请继续输出下一段内容。
|
|
626
|
+
|
|
627
|
+
4. 最后一段必须以 [CHUNK_END] 结尾。
|
|
628
|
+
|
|
629
|
+
**代码分析输出格式**
|
|
630
|
+
请严格按照以下格式返回分析结果,每个问题必须使用开始/结束标记包裹,并以空行分隔:
|
|
631
|
+
|
|
632
|
+
**-----代码分析结果开始-----**
|
|
633
|
+
文件路径:{文件路径}
|
|
634
|
+
代码片段:{具体的给定审查代码的原始片段内容,不允许丢失任何字符,仅当问题属于全局性或架构性问题时才可留空}
|
|
635
|
+
风险等级:{致命/高危/中危/低危/建议}
|
|
636
|
+
风险原因:{详细原因(必须包含证据:代码片段)}
|
|
637
|
+
修改建议:{具体建议(允许多行)}
|
|
638
|
+
**-----代码分析结果结束-----**
|
|
639
|
+
|
|
640
|
+
若输入中包含"[本地规则发现的问题]",请顺便判断本地规则是否是误报,如是误报返回对应规则,并在"风险原因"中说明误报理由;若不是误报可忽略该对应本地规则。
|
|
641
|
+
|
|
642
|
+
风险等级及范围定义:
|
|
643
|
+
|
|
644
|
+
**致命级别**:可能导致系统崩溃、数据丢失、严重安全漏洞
|
|
645
|
+
- 前端:未捕获的异常导致页面崩溃、XSS攻击漏洞、敏感信息泄露到控制台、无限递归导致浏览器卡死、计时器未清理等
|
|
646
|
+
- 后端:空指针异常、数据库连接泄露、SQL注入、未授权的数据访问、死循环导致服务器宕机等
|
|
647
|
+
- 引擎/核心:内存泄漏、缓冲区溢出、竞态条件、资源未释放、关键算法错误等
|
|
648
|
+
|
|
649
|
+
**高危级别**:可能导致安全漏洞、数据泄露、业务逻辑错误
|
|
650
|
+
- 前端:CSRF攻击风险、本地存储敏感数据、不安全的API调用、用户输入未验证等
|
|
651
|
+
- 后端:密码硬编码、权限验证缺失、敏感数据未加密、API接口未鉴权、文件上传漏洞等
|
|
652
|
+
- 引擎/核心:加密算法使用错误、随机数生成不安全、配置文件暴露、日志记录敏感信息等
|
|
653
|
+
|
|
654
|
+
**中危级别**:可能影响系统稳定性、性能问题、用户体验
|
|
655
|
+
- 前端:组件重复渲染、大量DOM操作、未优化的网络请求、内存占用过高、响应式设计问题等
|
|
656
|
+
- 后端:数据库查询未优化、缓存策略不当、线程池配置不合理、API响应时间过长等
|
|
657
|
+
- 引擎/核心:算法复杂度过高、资源使用效率低、并发处理能力不足、错误处理机制不完善等
|
|
658
|
+
|
|
659
|
+
**低危级别**:代码质量问题、不符合最佳实践、可维护性问题
|
|
660
|
+
- 前端:组件职责不清、状态管理混乱、样式代码重复、TypeScript类型定义不准确等
|
|
661
|
+
- 后端:代码重复、函数过长、类设计不合理、异常处理不规范、日志记录不完整等
|
|
662
|
+
- 引擎/核心:模块耦合度高、接口设计不清晰、命名规范不统一、注释缺失等
|
|
663
|
+
|
|
664
|
+
**建议级别**:改进建议,不影响功能,提升代码质量
|
|
665
|
+
- 前端:组件拆分优化、Hook使用优化、CSS样式优化、代码格式化、变量命名改进、未使用变量清理等
|
|
666
|
+
- 后端:方法重构、参数验证完善、返回值优化、代码注释补充、单元测试覆盖等
|
|
667
|
+
- 引擎/核心:性能监控添加、配置参数调优、文档完善、代码结构优化、版本兼容性等
|
|
668
|
+
|
|
669
|
+
**代码片段格式要求:**
|
|
670
|
+
1. 必须提供具体的原始代码片段内容,直接返回纯文本代码,不要使用markdown代码块格式(如\`\`\`typescript等)
|
|
671
|
+
2. 仅在以下全局性或架构性问题时才可留空:
|
|
672
|
+
- 整体项目架构设计问题
|
|
673
|
+
- 跨多个文件的设计模式问题
|
|
674
|
+
- 全局配置或依赖管理问题
|
|
675
|
+
- 整体代码组织结构问题
|
|
676
|
+
3. 对于具体的代码问题(如函数、变量、语句等),必须提供相关的代码片段作为证据
|
|
677
|
+
4. 提供的原始内容,不允许以任何形式导致字符丢失,必须保证提供的内容准确性
|
|
678
|
+
5. 若输入中包含每行的\`[n]\`行号前缀(例如\`[123]\`),请原样保留这些前缀作为片段的一部分,不要自行生成、修改或移除行号
|
|
679
|
+
|
|
680
|
+
禁止使用文字描述的行号或行范围(如“第X行/第X-Y行”);允许片段中的\`[n]\`前缀,它是输入的一部分
|
|
681
|
+
|
|
682
|
+
**行号说明与误报避免:**
|
|
683
|
+
1. 我可能会对代码进行预处理(剥离注释、跳过标记为“无需审查”的片段等),因此片段中的\`[n]\`源行号可能出现不连续(例如从278直接跳到294)。这是正常现象,不代表代码缺失或结构问题。
|
|
684
|
+
2. 严禁将“行号不连续/行号跳跃/注释行号不连续”等现象判定为问题或风险原因;遇到此类情况请直接忽略,不要输出相应的风险或建议。
|
|
685
|
+
|
|
686
|
+
**不在审查范围(必须忽略):**
|
|
687
|
+
1. 预处理造成的元信息差异:行号不连续/跳跃、注释移除、被标记为“无需审查”的片段剥离。
|
|
688
|
+
2. 展示层的占位或截断标记(如“中间省略”)。
|
|
689
|
+
3. 请不要在任何输出字段(风险原因、建议或片段)中提及“行号跳跃”、“行号不连续”、“被预处理移除”等措辞;若遇到此类情况,直接忽略,不要生成问题。
|
|
690
|
+
|
|
691
|
+
**重点分析领域:**
|
|
692
|
+
|
|
693
|
+
1. **安全问题**:
|
|
694
|
+
- 输入验证缺失、SQL注入、XSS攻击、CSRF漏洞等
|
|
695
|
+
- 敏感信息硬编码(密码、API密钥、令牌等)
|
|
696
|
+
- 权限验证缺失、未授权访问、数据泄露风险等
|
|
697
|
+
- 不安全的加密算法、随机数生成、文件操作等
|
|
698
|
+
|
|
699
|
+
2. **性能问题**:
|
|
700
|
+
- 算法复杂度过高、嵌套循环、递归深度等
|
|
701
|
+
- 内存泄漏、资源未释放、缓存策略不当等
|
|
702
|
+
- 数据库查询未优化、N+1查询问题等
|
|
703
|
+
- 前端组件重复渲染、大量DOM操作、网络请求优化等
|
|
704
|
+
|
|
705
|
+
3. **代码质量问题**:
|
|
706
|
+
- 函数过长、类职责不清、代码重复等
|
|
707
|
+
- 复杂的条件判断、深层嵌套、魔法数字等
|
|
708
|
+
- 变量命名不规范、类型定义不准确等
|
|
709
|
+
- 异常处理不完善、错误信息不明确等
|
|
710
|
+
|
|
711
|
+
4. **架构与设计问题**:
|
|
712
|
+
- 模块耦合度过高、依赖关系混乱等
|
|
713
|
+
- 设计模式使用不当、接口设计不合理等
|
|
714
|
+
- 配置管理问题、环境变量使用等
|
|
715
|
+
- 单元测试覆盖率、可测试性设计等
|
|
716
|
+
|
|
717
|
+
**代码片段与行号约束**:
|
|
718
|
+
- 如输入片段存在每行的\`[行号]\`前缀,请原样保留,它是输入的一部分
|
|
719
|
+
- 禁止使用文字描述的行号或行范围(如“第X行/第X-Y行”);必须以片段呈现问题
|
|
720
|
+
|
|
721
|
+
**行号说明与误报避免(必须遵守)**:
|
|
722
|
+
- 由于预处理(剥离注释、跳过无需审查片段等),片段中的\`[n]\`源行号可能不连续。这是正常的,务必严格忽略“行号不连续/行号跳跃/被预处理移除”等现象,不得将其视为问题或风险原因,不得提出“检查代码完整性/补全缺失代码”等建议。
|
|
723
|
+
- 输出中禁止出现相关措辞(如“行号跳跃”、“行号不连续”、“被预处理移除”)。如遇此类情形请直接忽略,不要生成任何问题。
|
|
724
|
+
`;
|
|
725
|
+
|
|
726
|
+
return this.systemPromptCache;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Git Diff专用系统提示词
|
|
731
|
+
* @returns {string} diff专用系统提示词
|
|
732
|
+
*/
|
|
733
|
+
getDiffSystemPrompt() {
|
|
734
|
+
return `你是一个专业的代码审查与优化专家,专门负责Git变更的增量代码审查。
|
|
735
|
+
|
|
736
|
+
**Git Diff增量审查说明:**
|
|
737
|
+
1. 你将收到Git diff格式的代码变更,包含新增行(+)、删除行(-)和上下文行
|
|
738
|
+
2. **重要:仅对新增行(+号标记)进行审查,删除行(-)和上下文行仅供理解代码逻辑,无需审查**
|
|
739
|
+
3. 上下文行的作用是帮助你理解新增代码的执行环境和逻辑关系
|
|
740
|
+
4. 专注于新增代码可能引入的问题,而不是整个文件的问题
|
|
741
|
+
|
|
742
|
+
**代码分析输出格式**
|
|
743
|
+
请严格按照以下格式返回分析结果,每个问题必须使用开始/结束标记包裹,并以空行分隔:
|
|
744
|
+
|
|
745
|
+
**-----Git Diff代码分析结果开始-----**
|
|
746
|
+
文件路径:{文件路径}
|
|
747
|
+
代码片段:{具体的新增代码片段(+号标记的内容),不包含+号前缀;如片段中存在每行的[行号]前缀,请原样保留}
|
|
748
|
+
风险等级:{致命/高危/中危/低危/建议}
|
|
749
|
+
风险原因:{详细原因,重点说明新增代码可能引入的问题}
|
|
750
|
+
修改建议:{针对新增代码的具体修改建议(允许多行)}
|
|
751
|
+
**-----Git Diff代码分析结果结束-----**
|
|
752
|
+
|
|
753
|
+
**风险等级定义(专注于新增代码):**
|
|
754
|
+
|
|
755
|
+
**致命级别**:新增代码可能导致系统崩溃、数据丢失、严重安全漏洞
|
|
756
|
+
- 新增的未捕获异常、空指针引用、无限循环
|
|
757
|
+
- 新增的SQL注入、XSS攻击漏洞、敏感信息泄露
|
|
758
|
+
- 新增的内存泄漏、资源未释放、死锁风险
|
|
759
|
+
|
|
760
|
+
**高危级别**:新增代码可能导致安全漏洞、数据泄露、业务逻辑错误
|
|
761
|
+
- 新增的权限验证缺失、未授权访问
|
|
762
|
+
- 新增的密码硬编码、敏感数据未加密
|
|
763
|
+
- 新增的不安全API调用、输入验证缺失
|
|
764
|
+
|
|
765
|
+
**中危级别**:新增代码可能影响系统稳定性、性能问题
|
|
766
|
+
- 新增的性能瓶颈、算法复杂度过高
|
|
767
|
+
- 新增的数据库查询未优化、缓存策略不当
|
|
768
|
+
- 新增的组件重复渲染、内存占用过高
|
|
769
|
+
|
|
770
|
+
**低危级别**:新增代码的质量问题、不符合最佳实践
|
|
771
|
+
- 新增的代码重复、函数过长、命名不规范
|
|
772
|
+
- 新增的异常处理不规范、日志记录不完整
|
|
773
|
+
- 新增的类型定义不准确、注释缺失
|
|
774
|
+
|
|
775
|
+
**建议级别**:新增代码的改进建议,提升代码质量
|
|
776
|
+
- 新增代码的格式化、变量命名改进
|
|
777
|
+
- 新增代码的重构建议、性能优化
|
|
778
|
+
- 新增代码的单元测试覆盖、文档完善
|
|
779
|
+
|
|
780
|
+
**重点关注新增代码的问题:**
|
|
781
|
+
1. **安全风险**:新增代码是否引入安全漏洞
|
|
782
|
+
2. **性能影响**:新增代码是否影响系统性能
|
|
783
|
+
3. **逻辑错误**:新增代码是否存在业务逻辑错误
|
|
784
|
+
4. **兼容性**:新增代码是否与现有代码兼容
|
|
785
|
+
5. **可维护性**:新增代码是否易于维护和扩展
|
|
786
|
+
|
|
787
|
+
**代码片段要求:**
|
|
788
|
+
- 只提供新增的代码片段(去除+号前缀,同时保留每行的\`[行号]\`前缀)
|
|
789
|
+
- 不要包含上下文行或删除行
|
|
790
|
+
- 确保代码片段的准确性,不允许字符丢失
|
|
791
|
+
- 禁止使用文字描述的行号或行范围;允许片段中的\`[n]\`前缀,它是输入的一部分
|
|
792
|
+
|
|
793
|
+
**行号说明与误报避免:**
|
|
794
|
+
- 由于仅审查新增行以及上游可能进行的预处理(如剥离注释、跳过无需审查片段),片段中的\`[n]\`源行号可能出现不连续。这是正常的,请不要将“行号不连续/行号跳跃”等现象视为问题或风险原因,务必忽略。
|
|
795
|
+
|
|
796
|
+
**不在审查范围(必须忽略):**
|
|
797
|
+
- 预处理引起的行号不连续/跳跃、注释或无需审查内容的移除。
|
|
798
|
+
- 展示预览的占位或截断标记(如“中间省略”)。
|
|
799
|
+
- 输出中禁止出现“行号跳跃”、“行号不连续”、“被预处理移除”等措辞;如遇此类情形请忽略,不要生成任何问题。
|
|
800
|
+
|
|
801
|
+
记住:你的任务是审查新增的代码变更,帮助开发者在代码合并前发现和修复潜在问题。`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
getCacheStats() {
|
|
805
|
+
return {
|
|
806
|
+
hits: this.cacheStats.hits,
|
|
807
|
+
misses: this.cacheStats.misses,
|
|
808
|
+
hitRate: this.cacheStats.hits / (this.cacheStats.hits + this.cacheStats.misses) || 0,
|
|
809
|
+
systemPromptCached: !!this.systemPromptCache,
|
|
810
|
+
promptCacheSize: this.promptCache.size,
|
|
811
|
+
contentCacheSize: this.contentCache.size
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async readCustomPrompts() {
|
|
816
|
+
try {
|
|
817
|
+
if (!this.reviewDir) return [];
|
|
818
|
+
|
|
819
|
+
const rulesDir = path.join(this.reviewDir, 'ai-rules');
|
|
820
|
+
const cacheKey = rulesDir;
|
|
821
|
+
|
|
822
|
+
// 检查缓存
|
|
823
|
+
if (this.promptCache.has(cacheKey)) {
|
|
824
|
+
this.cacheStats.hits++;
|
|
825
|
+
return this.promptCache.get(cacheKey);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
this.cacheStats.misses++;
|
|
829
|
+
|
|
830
|
+
if (!fs.existsSync(rulesDir)) {
|
|
831
|
+
this.promptCache.set(cacheKey, []);
|
|
832
|
+
return [];
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const files = fs.readdirSync(rulesDir);
|
|
836
|
+
const prompts = [];
|
|
837
|
+
|
|
838
|
+
for (const file of files) {
|
|
839
|
+
const filePath = path.join(rulesDir, file);
|
|
840
|
+
if (fs.statSync(filePath).isFile()) {
|
|
841
|
+
try {
|
|
842
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
843
|
+
if (content && content.trim()) {
|
|
844
|
+
prompts.push(content.trim());
|
|
845
|
+
}
|
|
846
|
+
} catch (e) {
|
|
847
|
+
logger.warn(`读取AI提示文件失败 ${filePath}:`, e.message);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// 缓存结果
|
|
853
|
+
this.promptCache.set(cacheKey, prompts);
|
|
854
|
+
return prompts;
|
|
855
|
+
} catch (error) {
|
|
856
|
+
logger.warn('读取自定义AI提示词失败:', error.message);
|
|
857
|
+
return [];
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
parseAIResponse(response, filePath, context = {}) {
|
|
862
|
+
if (!response || response.trim() === '无') {
|
|
863
|
+
// 仅误报判定模式下,允许返回"无",不再生成占位建议
|
|
864
|
+
return [];
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const issues = [];
|
|
868
|
+
|
|
869
|
+
// 优先:按开始/结束标记提取块
|
|
870
|
+
const markerRegexes = [
|
|
871
|
+
/\*\*-----代码分析结果开始-----\*\*([\s\S]*?)\*\*-----代码分析结果结束-----\*\*/g,
|
|
872
|
+
/\*\*-----Git Diff代码分析结果开始-----\*\*([\s\S]*?)\*\*-----Git Diff代码分析结果结束-----\*\*/g
|
|
873
|
+
];
|
|
874
|
+
let hasStandardFormat = false;
|
|
875
|
+
for (const re of markerRegexes) {
|
|
876
|
+
const matches = Array.from(response.matchAll(re));
|
|
877
|
+
if (matches.length > 0) {
|
|
878
|
+
hasStandardFormat = true;
|
|
879
|
+
for (const m of matches) {
|
|
880
|
+
const block = m[1].trim();
|
|
881
|
+
const issue = this.parseIssueBlock(block, filePath, context);
|
|
882
|
+
if (issue) issues.push(issue);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// 兼容旧格式:单头标记(无结束标记),按空行分块
|
|
888
|
+
if (!hasStandardFormat) {
|
|
889
|
+
let blocks = response.split('\n\n');
|
|
890
|
+
for (const block of blocks) {
|
|
891
|
+
if (block.includes('**-----代码分析结果-----**') || block.includes('**-----Git Diff代码分析结果-----**')) {
|
|
892
|
+
hasStandardFormat = true;
|
|
893
|
+
const issue = this.parseIssueBlock(block, filePath, context);
|
|
894
|
+
if (issue) issues.push(issue);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// 如果没有找到标准格式,尝试解析实际的AI响应格式(问题1:, 问题2: 等)
|
|
900
|
+
if (!hasStandardFormat) {
|
|
901
|
+
// 按问题编号分割
|
|
902
|
+
const problemBlocks = this.splitByProblemNumbers(response);
|
|
903
|
+
for (const block of problemBlocks) {
|
|
904
|
+
const issue = this.parseIssueBlock(block, filePath, context);
|
|
905
|
+
if (issue) {
|
|
906
|
+
issues.push(issue);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// 若解析不到任何块,但存在本地问题,输出占位建议
|
|
912
|
+
if (issues.length === 0 && Array.isArray(context.staticIssues) && context.staticIssues.length > 0) {
|
|
913
|
+
return context.staticIssues.map((i) => ({
|
|
914
|
+
file: filePath,
|
|
915
|
+
source: 'ai',
|
|
916
|
+
risk: i.risk || 'suggestion',
|
|
917
|
+
message: `基于本地规则:${i.message}`,
|
|
918
|
+
suggestion: i.suggestion || '请根据本地规则进行复核与修复,并补充测试与监控以验证风险。',
|
|
919
|
+
snippet: i.snippet || ''
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return issues;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// 新增方法:按问题编号分割响应内容
|
|
927
|
+
splitByProblemNumbers(response) {
|
|
928
|
+
const blocks = [];
|
|
929
|
+
const lines = response.split('\n');
|
|
930
|
+
let currentBlock = [];
|
|
931
|
+
|
|
932
|
+
for (const line of lines) {
|
|
933
|
+
// 检查是否是新问题的开始(问题1:, 问题2: 等)
|
|
934
|
+
if (/^问题\d+[::]/.test(line.trim())) {
|
|
935
|
+
// 如果当前块有内容,保存它
|
|
936
|
+
if (currentBlock.length > 0) {
|
|
937
|
+
blocks.push(currentBlock.join('\n'));
|
|
938
|
+
currentBlock = [];
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
currentBlock.push(line);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// 添加最后一个块
|
|
945
|
+
if (currentBlock.length > 0) {
|
|
946
|
+
blocks.push(currentBlock.join('\n'));
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return blocks;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
parseIssueBlock(block, filePath, context = {}) {
|
|
953
|
+
const lines = block.split('\n');
|
|
954
|
+
const issue = { source: 'ai' };
|
|
955
|
+
|
|
956
|
+
// 在批量模式下,需要从AI返回的内容中匹配文件路径
|
|
957
|
+
if (!filePath && context.fileList && context.fileList.length > 0) {
|
|
958
|
+
// 从block中查找文件路径信息
|
|
959
|
+
const filePathLine = lines.find(line => line.startsWith('文件路径:'));
|
|
960
|
+
if (filePathLine) {
|
|
961
|
+
const aiPath = filePathLine.replace('文件路径:', '').trim();
|
|
962
|
+
// 查找匹配的文件路径
|
|
963
|
+
const matchedPath = context.fileList.find(path => {
|
|
964
|
+
const fileName = path.split(/[/\\]/).pop();
|
|
965
|
+
const aiFileName = aiPath.split(/[/\\]/).pop();
|
|
966
|
+
return fileName === aiFileName || path.includes(aiPath.replace(/^[A-Z]?:?\\?/, ''));
|
|
967
|
+
});
|
|
968
|
+
if (matchedPath) {
|
|
969
|
+
issue.file = matchedPath;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
} else {
|
|
973
|
+
// 单文件模式,直接使用传入的filePath
|
|
974
|
+
issue.file = filePath;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
let isInCodeBlock = false;
|
|
978
|
+
let codeLines = [];
|
|
979
|
+
let collectPlainSnippet = false; // 处理“代码片段:”后紧随的非围栏代码
|
|
980
|
+
let collectSuggestion = false; // 处理“修改建议:”的多行文本
|
|
981
|
+
let suggestionLines = [];
|
|
982
|
+
|
|
983
|
+
const isFieldLine = (text) => {
|
|
984
|
+
const t = String(text || '').trim();
|
|
985
|
+
if (!t) return false;
|
|
986
|
+
if (t.startsWith('文件路径:')) return true;
|
|
987
|
+
if (t.startsWith('风险等级:')) return true;
|
|
988
|
+
if (t.startsWith('风险原因:')) return true;
|
|
989
|
+
if (t.startsWith('修改建议:')) return true;
|
|
990
|
+
if (t.startsWith('代码片段:')) return true; // 新片段开始也视为字段边界
|
|
991
|
+
if (/^\*\*-----/.test(t)) return true; // 块分隔符
|
|
992
|
+
if (/^问题\d+[::]/.test(t)) return true; // 问题编号
|
|
993
|
+
return false;
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
for (let i = 0; i < lines.length; i++) {
|
|
997
|
+
const line = lines[i];
|
|
998
|
+
|
|
999
|
+
// 若正在收集非围栏代码片段,优先处理
|
|
1000
|
+
if (collectPlainSnippet) {
|
|
1001
|
+
if (isFieldLine(line)) {
|
|
1002
|
+
// 到达下一个字段,结束收集
|
|
1003
|
+
issue.snippet = (issue.snippet && issue.snippet.length > 0)
|
|
1004
|
+
? issue.snippet
|
|
1005
|
+
: codeLines.join('\n').trim();
|
|
1006
|
+
collectPlainSnippet = false;
|
|
1007
|
+
// 继续让本行按字段规则处理
|
|
1008
|
+
} else {
|
|
1009
|
+
codeLines.push(line);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// 若正在收集修改建议的多行文本
|
|
1015
|
+
if (collectSuggestion) {
|
|
1016
|
+
if (isFieldLine(line)) {
|
|
1017
|
+
issue.suggestion = suggestionLines.join('\n').trim();
|
|
1018
|
+
collectSuggestion = false;
|
|
1019
|
+
suggestionLines = [];
|
|
1020
|
+
// 继续处理当前字段
|
|
1021
|
+
} else {
|
|
1022
|
+
suggestionLines.push(line);
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// 处理标准格式的字段
|
|
1028
|
+
if (line.startsWith('文件路径:')) {
|
|
1029
|
+
// 跳过AI返回的文件路径,我们直接使用本地已知的完整路径
|
|
1030
|
+
} else if (line.startsWith('风险等级:')) {
|
|
1031
|
+
const level = line.replace('风险等级:', '').trim();
|
|
1032
|
+
issue.risk = this.mapRiskLevel(level);
|
|
1033
|
+
} else if (line.startsWith('风险原因:')) {
|
|
1034
|
+
issue.message = line.replace('风险原因:', '').trim();
|
|
1035
|
+
} else if (line.startsWith('修改建议:')) {
|
|
1036
|
+
const rest = line.replace('修改建议:', '').trim();
|
|
1037
|
+
collectSuggestion = true;
|
|
1038
|
+
suggestionLines = rest ? [rest] : [];
|
|
1039
|
+
} else if (line.startsWith('代码片段:')) {
|
|
1040
|
+
const snippetContent = line.replace('代码片段:', '').trim();
|
|
1041
|
+
// 如果代码片段行后面直接有内容,使用该内容
|
|
1042
|
+
if (snippetContent && !snippetContent.startsWith('```')) {
|
|
1043
|
+
// 改为启动“非围栏片段”模式,收集后续多行直到下一个字段
|
|
1044
|
+
collectPlainSnippet = true;
|
|
1045
|
+
codeLines = [snippetContent];
|
|
1046
|
+
} else {
|
|
1047
|
+
// 检查下一行是否是代码块开始
|
|
1048
|
+
if (i + 1 < lines.length && lines[i + 1].trim().startsWith('```')) {
|
|
1049
|
+
isInCodeBlock = true;
|
|
1050
|
+
i++; // 跳过 ``` 行
|
|
1051
|
+
codeLines = [];
|
|
1052
|
+
} else {
|
|
1053
|
+
// 启动非围栏代码片段收集,直到遇到下一个字段
|
|
1054
|
+
collectPlainSnippet = true;
|
|
1055
|
+
codeLines = [];
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
// 处理实际AI响应格式的字段(没有冒号前缀)
|
|
1060
|
+
else if (line.trim().startsWith('代码片段:')) {
|
|
1061
|
+
const snippetContent = line.replace(/^.*代码片段:/, '').trim();
|
|
1062
|
+
if (snippetContent && !snippetContent.startsWith('```')) {
|
|
1063
|
+
// 改为启动“非围栏片段”模式,收集后续多行直到下一个字段
|
|
1064
|
+
collectPlainSnippet = true;
|
|
1065
|
+
codeLines = [snippetContent];
|
|
1066
|
+
} else {
|
|
1067
|
+
// 检查下一行是否是代码块开始
|
|
1068
|
+
if (i + 1 < lines.length && lines[i + 1].trim().startsWith('```')) {
|
|
1069
|
+
isInCodeBlock = true;
|
|
1070
|
+
i++; // 跳过 ``` 行
|
|
1071
|
+
codeLines = [];
|
|
1072
|
+
} else {
|
|
1073
|
+
// 启动非围栏代码片段收集,直到遇到下一个字段
|
|
1074
|
+
collectPlainSnippet = true;
|
|
1075
|
+
codeLines = [];
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
} else if (line.trim().startsWith('风险等级:')) {
|
|
1079
|
+
const level = line.replace(/^.*风险等级:/, '').trim();
|
|
1080
|
+
issue.risk = this.mapRiskLevel(level);
|
|
1081
|
+
} else if (line.trim().startsWith('风险原因:')) {
|
|
1082
|
+
issue.message = line.replace(/^.*风险原因:/, '').trim();
|
|
1083
|
+
} else if (line.trim().startsWith('修改建议:')) {
|
|
1084
|
+
const rest = line.replace(/^.*修改建议:/, '').trim();
|
|
1085
|
+
collectSuggestion = true;
|
|
1086
|
+
suggestionLines = rest ? [rest] : [];
|
|
1087
|
+
} else if (isInCodeBlock) {
|
|
1088
|
+
// 处理代码块内容
|
|
1089
|
+
if (line.trim() === '```') {
|
|
1090
|
+
// 代码块结束
|
|
1091
|
+
isInCodeBlock = false;
|
|
1092
|
+
issue.snippet = codeLines.join('\n').trim();
|
|
1093
|
+
} else {
|
|
1094
|
+
// 收集代码行
|
|
1095
|
+
codeLines.push(line);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// 若到末尾仍在收集非围栏片段,收尾
|
|
1101
|
+
if (collectPlainSnippet && (!issue.snippet || issue.snippet.length === 0)) {
|
|
1102
|
+
issue.snippet = codeLines.join('\n').trim();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// 若到末尾仍在收集多行修改建议,收尾
|
|
1106
|
+
if (collectSuggestion && (!issue.suggestion || issue.suggestion.length === 0)) {
|
|
1107
|
+
issue.suggestion = suggestionLines.join('\n').trim();
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// 回退:如果未找到“代码片段:”字段但块内存在代码块,则提取第一个代码块内容作为片段
|
|
1111
|
+
if (!issue.snippet) {
|
|
1112
|
+
const fenceMatch = block.match(/```([\s\S]*?)```/);
|
|
1113
|
+
if (fenceMatch && fenceMatch[1]) {
|
|
1114
|
+
issue.snippet = fenceMatch[1].trim();
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// 片段规范化:移除“中间省略”占位行,裁剪到合理长度,并避免跨越不相邻的行号簇
|
|
1119
|
+
if (issue.snippet && typeof issue.snippet === 'string') {
|
|
1120
|
+
issue.snippet = this.normalizeSnippet(issue.snippet);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// 从代码片段中提取行号范围(支持每行的`[n]`前缀;兼容可选的'+'或空格前缀)
|
|
1124
|
+
if (issue.snippet && typeof issue.snippet === 'string') {
|
|
1125
|
+
let startNum = null;
|
|
1126
|
+
let endNum = null;
|
|
1127
|
+
const snippetLines = issue.snippet.split('\n');
|
|
1128
|
+
for (const sLine of snippetLines) {
|
|
1129
|
+
const m = sLine.match(/^\s*[+ ]?\[(\d+)\]\s/);
|
|
1130
|
+
if (m) {
|
|
1131
|
+
const n = Number(m[1]);
|
|
1132
|
+
if (Number.isFinite(n)) {
|
|
1133
|
+
if (startNum === null || n < startNum) startNum = n;
|
|
1134
|
+
if (endNum === null || n > endNum) endNum = n;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
if (startNum !== null) {
|
|
1139
|
+
issue.lineStart = startNum;
|
|
1140
|
+
issue.lineEnd = endNum !== null ? endNum : startNum;
|
|
1141
|
+
issue.line = issue.lineStart; // 兼容旧字段
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// 必须有原因才认为是有效问题
|
|
1146
|
+
if (issue.message) {
|
|
1147
|
+
// 确保文件路径不为空,如果AI没有返回文件名称,使用传入的filePath
|
|
1148
|
+
if (!issue.file) {
|
|
1149
|
+
issue.file = filePath;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// 确保所有问题都有风险等级,如果没有则设为默认值
|
|
1153
|
+
if (!issue.risk) {
|
|
1154
|
+
issue.risk = 'suggestion'; // 默认为建议等级
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// 规范化风险等级:确保在有效范围内
|
|
1158
|
+
issue.risk = this.normalizeRiskLevel(issue.risk);
|
|
1159
|
+
|
|
1160
|
+
return issue;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// 规范化AI返回的代码片段:
|
|
1167
|
+
// - 移除日志/模型生成的“中间省略 … 字符”占位行
|
|
1168
|
+
// - 按行号聚类,避免跨越相距过大的行号簇(保留首个簇)
|
|
1169
|
+
// - 限制输出的最大行数,保证片段简洁可读
|
|
1170
|
+
normalizeSnippet(snippet) {
|
|
1171
|
+
try {
|
|
1172
|
+
const MAX_LINES = Number(this.config?.displayMaxSnippetLines ?? 12);
|
|
1173
|
+
const GAP_THRESHOLD = Number(this.config?.snippetGapThreshold ?? 5); // 行号差距超过阈值则分簇
|
|
1174
|
+
const lines = String(snippet).split('\n');
|
|
1175
|
+
// 1) 移除“中间省略”占位行
|
|
1176
|
+
const cleaned = lines.filter(l => !(/\u2026|\.\.\./.test(l) && /中间省略\s+\d+\s+字符/.test(l)) && !/^\s*\.\.\.\s*$/.test(l));
|
|
1177
|
+
|
|
1178
|
+
// 2) 按行号聚类,仅保留第一个包含行号的簇
|
|
1179
|
+
const clusters = [];
|
|
1180
|
+
let current = [];
|
|
1181
|
+
let prevN = null;
|
|
1182
|
+
const lineNumRegex = /^\s*[+ ]?\[(\d+)\]\s/;
|
|
1183
|
+
for (const l of cleaned) {
|
|
1184
|
+
const m = l.match(lineNumRegex);
|
|
1185
|
+
if (m) {
|
|
1186
|
+
const n = Number(m[1]);
|
|
1187
|
+
if (prevN !== null && ((n - prevN > GAP_THRESHOLD) || (n < prevN)) && current.length > 0) {
|
|
1188
|
+
clusters.push(current);
|
|
1189
|
+
current = [];
|
|
1190
|
+
}
|
|
1191
|
+
current.push(l);
|
|
1192
|
+
prevN = n;
|
|
1193
|
+
} else {
|
|
1194
|
+
// 非行号行:跟随当前簇
|
|
1195
|
+
current.push(l);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (current.length > 0) clusters.push(current);
|
|
1199
|
+
const selected = clusters.find(c => c.some(l => lineNumRegex.test(l))) || cleaned;
|
|
1200
|
+
|
|
1201
|
+
// 3) 限制最大行数
|
|
1202
|
+
const result = selected.slice(0, Math.max(1, MAX_LINES));
|
|
1203
|
+
return result.join('\n').trim();
|
|
1204
|
+
} catch {
|
|
1205
|
+
return snippet;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// 将文本按行添加 [n] 前缀
|
|
1210
|
+
addLineNumberPrefixes(text, lineMap = null) {
|
|
1211
|
+
try {
|
|
1212
|
+
const lines = String(text).split('\n');
|
|
1213
|
+
return lines.map((l, i) => {
|
|
1214
|
+
const n = Array.isArray(lineMap) && Number.isFinite(Number(lineMap[i])) ? Number(lineMap[i]) : (i + 1);
|
|
1215
|
+
return `[${n}] ${l}`;
|
|
1216
|
+
}).join('\n');
|
|
1217
|
+
} catch {
|
|
1218
|
+
return text;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
mapRiskLevel(chineseLevel) {
|
|
1223
|
+
const mapping = {
|
|
1224
|
+
'致命': 'critical',
|
|
1225
|
+
'高危': 'high',
|
|
1226
|
+
'中危': 'medium',
|
|
1227
|
+
'低危': 'low',
|
|
1228
|
+
'建议': 'suggestion'
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
return mapping[chineseLevel] || 'suggestion';
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
normalizeRiskLevel(riskLevel) {
|
|
1235
|
+
// 定义有效的风险等级顺序(从低到高)
|
|
1236
|
+
const validLevels = ['suggestion', 'low', 'medium', 'high', 'critical'];
|
|
1237
|
+
|
|
1238
|
+
// 如果是有效等级,直接返回
|
|
1239
|
+
if (validLevels.includes(riskLevel)) {
|
|
1240
|
+
return riskLevel;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return 'suggestion';
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// 检查响应是否包含分段标记
|
|
1247
|
+
isChunkedResponse(content) {
|
|
1248
|
+
return content.includes('[CHUNK_CONTINUE]') || content.includes('[CHUNK_END]');
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// 解析分段响应信息
|
|
1252
|
+
parseChunkInfo(content) {
|
|
1253
|
+
const isContinue = content.includes('[CHUNK_CONTINUE]');
|
|
1254
|
+
const isEnd = content.includes('[CHUNK_END]');
|
|
1255
|
+
|
|
1256
|
+
// 提取分段索引信息
|
|
1257
|
+
const indexRegex = /\[CHUNK_(\d+)\/(\d+)\]/;
|
|
1258
|
+
const indexMatch = content.match(indexRegex);
|
|
1259
|
+
|
|
1260
|
+
let currentChunk = 1;
|
|
1261
|
+
let totalChunks = 1;
|
|
1262
|
+
|
|
1263
|
+
if (indexMatch) {
|
|
1264
|
+
currentChunk = parseInt(indexMatch[1]) || 1;
|
|
1265
|
+
totalChunks = parseInt(indexMatch[2]) || 1;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// 清理内容,移除分段标记
|
|
1269
|
+
let cleanContent = content
|
|
1270
|
+
.replace(/\[CHUNK_CONTINUE\]/g, '')
|
|
1271
|
+
.replace(/\[CHUNK_END\]/g, '')
|
|
1272
|
+
.replace(indexRegex, '')
|
|
1273
|
+
.trim();
|
|
1274
|
+
|
|
1275
|
+
return {
|
|
1276
|
+
content: cleanContent,
|
|
1277
|
+
currentChunk,
|
|
1278
|
+
totalChunks,
|
|
1279
|
+
isContinue,
|
|
1280
|
+
isEnd,
|
|
1281
|
+
isComplete: isEnd || (!isContinue && !isEnd) // 如果没有标记,认为是完整响应
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// 处理分段响应的主方法
|
|
1286
|
+
async handleChunkedResponse(messages, requestId = null, meta = null) {
|
|
1287
|
+
// 生成请求ID
|
|
1288
|
+
if (!requestId) {
|
|
1289
|
+
requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// 初始化收集器
|
|
1293
|
+
if (!this.chunkedResponseCollector.has(requestId)) {
|
|
1294
|
+
this.chunkedResponseCollector.set(requestId, {
|
|
1295
|
+
chunks: [],
|
|
1296
|
+
isComplete: false,
|
|
1297
|
+
messages: [...messages] // 保存原始消息
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const collector = this.chunkedResponseCollector.get(requestId);
|
|
1302
|
+
|
|
1303
|
+
try {
|
|
1304
|
+
// 记录请求信息
|
|
1305
|
+
logger.debug(`发送分段AI请求 - 模型: ${this.config.model ?? 'gpt-3.5-turbo'}, 消息数: ${collector.messages.length}`);
|
|
1306
|
+
// 输出请求消息的预览(限制每条消息长度)
|
|
1307
|
+
try {
|
|
1308
|
+
const preview = collector.messages.map((m, idx) => {
|
|
1309
|
+
const text = String(m.content ?? '');
|
|
1310
|
+
const maxLen = 1500;
|
|
1311
|
+
const cut = text.length > maxLen ? `${text.slice(0, maxLen)}\n...省略 ${text.length - maxLen} 字符` : text;
|
|
1312
|
+
return `#${idx + 1} [${m.role}]\n${cut}`;
|
|
1313
|
+
}).join('\n---\n');
|
|
1314
|
+
logger.debug(`AI请求消息预览:\n${preview}`);
|
|
1315
|
+
} catch (e) {
|
|
1316
|
+
logger.debug(`AI请求消息预览失败: ${e.message}`);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// 发送请求
|
|
1320
|
+
const isFirstCall = this.chunkedResponseCollector.get(requestId).chunks.length === 0;
|
|
1321
|
+
const response = await this.chatWithRetry({
|
|
1322
|
+
model: this.config.model ?? 'gpt-3.5-turbo',
|
|
1323
|
+
messages: collector.messages,
|
|
1324
|
+
temperature: this.config.temperature !== undefined ? this.config.temperature : 0.1,
|
|
1325
|
+
max_tokens: this.getMaxTokens()
|
|
1326
|
+
}, isFirstCall ? meta : null);
|
|
1327
|
+
|
|
1328
|
+
const content = response.choices[0].message.content;
|
|
1329
|
+
logger.debug(`AI响应: ${content.length} 字符`);
|
|
1330
|
+
// 检查是否是分段响应
|
|
1331
|
+
if (this.isChunkedResponse(content)) {
|
|
1332
|
+
const chunkInfo = this.parseChunkInfo(content);
|
|
1333
|
+
|
|
1334
|
+
// 添加到收集器
|
|
1335
|
+
collector.chunks.push({
|
|
1336
|
+
index: chunkInfo.currentChunk,
|
|
1337
|
+
content: chunkInfo.content,
|
|
1338
|
+
timestamp: Date.now()
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
// 检查是否完成
|
|
1342
|
+
if (chunkInfo.isComplete) {
|
|
1343
|
+
collector.isComplete = true;
|
|
1344
|
+
const fullContent = this.assembleChunks(collector.chunks);
|
|
1345
|
+
this.chunkedResponseCollector.delete(requestId);
|
|
1346
|
+
return fullContent;
|
|
1347
|
+
} else {
|
|
1348
|
+
// 需要继续获取下一段
|
|
1349
|
+
collector.messages.push({ role: 'assistant', content: content });
|
|
1350
|
+
collector.messages.push({
|
|
1351
|
+
role: 'user',
|
|
1352
|
+
content: '继续'
|
|
1353
|
+
});
|
|
1354
|
+
logger.debug('分段响应未完成,发送"继续"请求下一段');
|
|
1355
|
+
|
|
1356
|
+
// 递归获取下一段
|
|
1357
|
+
return await this.handleChunkedResponse(null, requestId, null);
|
|
1358
|
+
}
|
|
1359
|
+
} else {
|
|
1360
|
+
// 不是分段响应,直接返回
|
|
1361
|
+
collector.chunks.push({
|
|
1362
|
+
index: 1,
|
|
1363
|
+
content: content,
|
|
1364
|
+
timestamp: Date.now()
|
|
1365
|
+
});
|
|
1366
|
+
collector.isComplete = true;
|
|
1367
|
+
this.chunkedResponseCollector.delete(requestId);
|
|
1368
|
+
// 最终内容预览(非分段)
|
|
1369
|
+
// 信息级别输出最终原始响应预览(非分段)
|
|
1370
|
+
return content;
|
|
1371
|
+
}
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
// 清理收集器
|
|
1374
|
+
this.chunkedResponseCollector.delete(requestId);
|
|
1375
|
+
throw error;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// 组装分段内容
|
|
1380
|
+
assembleChunks(chunks) {
|
|
1381
|
+
// 按索引排序
|
|
1382
|
+
chunks.sort((a, b) => a.index - b.index);
|
|
1383
|
+
|
|
1384
|
+
// 合并内容
|
|
1385
|
+
const fullContent = chunks.map(chunk => chunk.content).join('\n\n');
|
|
1386
|
+
|
|
1387
|
+
return fullContent;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// 获取最大token数
|
|
1391
|
+
getMaxTokens() {
|
|
1392
|
+
const useDynamic = (this.config?.ai?.dynamicMaxTokens !== false);
|
|
1393
|
+
const contextWindow = Number(this.config.contextWindow ?? AI_CONSTANTS.DEFAULT_CONTEXT_WINDOW);
|
|
1394
|
+
const reserve = Number(this.config.outputReserveTokens ?? AI_CONSTANTS.DEFAULT_OUTPUT_RESERVE_TOKENS);
|
|
1395
|
+
|
|
1396
|
+
if (useDynamic) {
|
|
1397
|
+
// 这里简化处理,实际应该根据输入消息长度计算
|
|
1398
|
+
const dynamicBudget = Math.max(AI_CONSTANTS.MIN_DYNAMIC_BUDGET, Math.min(AI_CONSTANTS.MAX_DYNAMIC_BUDGET, contextWindow - reserve));
|
|
1399
|
+
const upper = Number(this.config.maxResponseTokens ?? dynamicBudget);
|
|
1400
|
+
const safeUpper = Number.isFinite(upper) ? upper : dynamicBudget;
|
|
1401
|
+
return Math.max(1, Math.floor(Math.min(dynamicBudget, safeUpper)));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const raw = this.config.maxResponseTokens ?? AI_CONSTANTS.DEFAULT_MAX_RESPONSE_TOKENS;
|
|
1405
|
+
const num = typeof raw === 'number' ? raw : Number(raw);
|
|
1406
|
+
if (!Number.isFinite(num) || Number.isNaN(num)) return AI_CONSTANTS.DEFAULT_MAX_RESPONSE_TOKENS;
|
|
1407
|
+
const min = 1;
|
|
1408
|
+
const max = AI_CONSTANTS.MAX_RESPONSE_TOKENS_LIMIT;
|
|
1409
|
+
return Math.min(max, Math.max(min, Math.floor(num)));
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
}
|