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.
@@ -0,0 +1,490 @@
1
+ /**
2
+ * 分段分析器 - 针对大文件进行智能分段分析
3
+ * 实现每段独立分析,带上下文重叠,支持摘要传递
4
+ */
5
+
6
+ import { stripCommentsForAI, stripNoReviewForAI } from './utils/strip.js';
7
+ import { logger } from './utils/logger.js';
8
+ import { SEGMENTATION_CONSTANTS, AI_CONSTANTS } from './utils/constants.js';
9
+
10
+ export class SegmentedAnalyzer {
11
+ constructor(config = {}) {
12
+ this.config = {
13
+ // 每段最大行数
14
+ maxLinesPerSegment: config.maxLinesPerSegment || SEGMENTATION_CONSTANTS.DEFAULT_MAX_LINES_PER_SEGMENT,
15
+ // 上下文重叠行数
16
+ contextOverlapLines: config.contextOverlapLines || SEGMENTATION_CONSTANTS.DEFAULT_CONTEXT_OVERLAP_LINES,
17
+ // 每段最大Token数
18
+ maxTokensPerSegment: config.maxTokensPerSegment || SEGMENTATION_CONSTANTS.DEFAULT_MAX_TOKENS_PER_SEGMENT,
19
+ // Token估算比例(字符数/Token数)
20
+ tokenRatio: config.tokenRatio || SEGMENTATION_CONSTANTS.DEFAULT_TOKEN_RATIO,
21
+ // 是否启用摘要传递
22
+ enableSummaryContext: config.enableSummaryContext !== false,
23
+ // 摘要最大长度
24
+ maxSummaryLength: config.maxSummaryLength || SEGMENTATION_CONSTANTS.DEFAULT_MAX_SUMMARY_LENGTH,
25
+ ...config
26
+ };
27
+ }
28
+
29
+ /**
30
+ * 分段分析文件
31
+ * @param {string} filePath - 文件路径
32
+ * @param {string} content - 文件内容
33
+ * @param {Object} aiClient - AI客户端
34
+ * @param {string} customPrompt - 自定义提示词
35
+ * @param {Array} staticIssues - 静态分析问题
36
+ * @returns {Object} 分析结果
37
+ */
38
+ async analyzeFileSegmented(filePath, content, aiClient, customPrompt = '', staticIssues = []) {
39
+ try {
40
+ logger.progress(`开始分段分析文件: ${filePath}`);
41
+
42
+ // 第一步:智能分段
43
+ const segments = this.createIntelligentSegments(content, filePath);
44
+ logger.info(`文件分为 ${segments.length} 段`);
45
+
46
+ const allIssues = [];
47
+ const segmentSummaries = []; // 存储每段的摘要
48
+ let previousContext = ''; // 前面段落的上下文摘要
49
+
50
+ // 第二步:逐段分析
51
+ for (let i = 0; i < segments.length; i++) {
52
+ const segment = segments[i];
53
+ logger.info(`开始分析第 ${i + 1}/${segments.length} 段 (行 ${segment.startLine}-${segment.endLine})`);
54
+ logger.info(` 预估${segment.tokens} tokens, 共${segment.endLine - segment.startLine + 1} 行代码`);
55
+
56
+ try {
57
+ // 分析当前段
58
+ const segmentResult = await this.analyzeSegment(
59
+ segment,
60
+ filePath,
61
+ aiClient,
62
+ customPrompt,
63
+ staticIssues,
64
+ previousContext,
65
+ i + 1,
66
+ segments.length
67
+ );
68
+
69
+ // 收集问题
70
+ if (segmentResult.issues && segmentResult.issues.length > 0) {
71
+ allIssues.push(...segmentResult.issues);
72
+ logger.success(`第 ${i + 1} 段分析完成,发现 ${segmentResult.issues.length} 个问题`);
73
+ } else {
74
+ logger.success(`第 ${i + 1} 段分析完成,未发现问题`);
75
+ }
76
+
77
+ // 收集摘要(如果启用)
78
+ if (this.config.enableSummaryContext && segmentResult.summary) {
79
+ segmentSummaries.push({
80
+ segmentIndex: i + 1,
81
+ summary: segmentResult.summary,
82
+ lineRange: `${segment.startLine}-${segment.endLine}`
83
+ });
84
+
85
+ // 更新上下文(保留最近几段的摘要)
86
+ const recentSummaries = segmentSummaries.slice(-3); // 保留最近3段摘要
87
+ previousContext = recentSummaries.map(s =>
88
+ `第${s.segmentIndex}段(行${s.lineRange}): ${s.summary}`
89
+ ).join('\n');
90
+ }
91
+
92
+ } catch (error) {
93
+ logger.error(`第 ${i + 1} 段分析失败: ${error.message}`);
94
+ // 继续分析下一段,不中断整个流程
95
+ }
96
+ }
97
+
98
+ return {
99
+ issues: allIssues,
100
+ metadata: {
101
+ totalSegments: segments.length,
102
+ successfulSegments: segments.length, // 简化处理,实际可以统计成功的段数
103
+ summaries: segmentSummaries,
104
+ filePath: filePath
105
+ }
106
+ };
107
+
108
+ } catch (error) {
109
+ logger.error(`分段分析失败: ${error.message}`);
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 创建智能分段
116
+ * @param {string} content - 文件内容
117
+ * @param {string} filePath - 文件路径
118
+ * @returns {Array} 分段数组
119
+ */
120
+ createIntelligentSegments(content, filePath) {
121
+ const lines = content.split('\n');
122
+ const segments = [];
123
+
124
+ let currentSegmentStart = 0;
125
+ let currentSegmentLines = [];
126
+ let currentTokens = 0;
127
+
128
+ for (let i = 0; i < lines.length; i++) {
129
+ const line = lines[i];
130
+ const lineTokens = this.estimateTokens(line);
131
+
132
+ // 检查是否需要结束当前段
133
+ const shouldEndSegment = (
134
+ currentSegmentLines.length >= this.config.maxLinesPerSegment ||
135
+ currentTokens + lineTokens > this.config.maxTokensPerSegment
136
+ ) && currentSegmentLines.length > 0;
137
+
138
+ if (shouldEndSegment) {
139
+ // 创建当前段
140
+ const segment = this.createSegmentWithContext(
141
+ lines,
142
+ currentSegmentStart,
143
+ currentSegmentStart + currentSegmentLines.length - 1,
144
+ segments.length,
145
+ segments.length === 0 // 是否为第一段
146
+ );
147
+ segments.push(segment);
148
+
149
+ // 开始新段,考虑重叠
150
+ const overlapStart = Math.max(0, currentSegmentStart + currentSegmentLines.length - this.config.contextOverlapLines);
151
+ currentSegmentStart = overlapStart;
152
+ currentSegmentLines = lines.slice(overlapStart, i + 1);
153
+ currentTokens = this.estimateTokens(currentSegmentLines.join('\n'));
154
+ } else {
155
+ // 继续当前段
156
+ currentSegmentLines.push(line);
157
+ currentTokens += lineTokens;
158
+ }
159
+ }
160
+
161
+ // 处理最后一段
162
+ if (currentSegmentLines.length > 0) {
163
+ const segment = this.createSegmentWithContext(
164
+ lines,
165
+ currentSegmentStart,
166
+ lines.length - 1,
167
+ segments.length,
168
+ segments.length === 0 // 是否为第一段
169
+ );
170
+ segments.push(segment);
171
+ }
172
+
173
+ return segments;
174
+ }
175
+
176
+ /**
177
+ * 创建带上下文的分段
178
+ * @param {Array} allLines - 所有行
179
+ * @param {number} startLine - 起始行号(0基)
180
+ * @param {number} endLine - 结束行号(0基)
181
+ * @param {number} segmentIndex - 分段索引
182
+ * @param {boolean} isFirstSegment - 是否为第一段
183
+ * @returns {Object} 分段对象
184
+ */
185
+ createSegmentWithContext(allLines, startLine, endLine, segmentIndex, isFirstSegment) {
186
+ const contextLines = this.config.contextOverlapLines;
187
+
188
+ // 第一段不需要前置上下文
189
+ const actualStartLine = isFirstSegment ? startLine : Math.max(0, startLine - contextLines);
190
+ const actualEndLine = Math.min(allLines.length - 1, endLine + contextLines);
191
+
192
+ const segmentLines = allLines.slice(actualStartLine, actualEndLine + 1);
193
+ const content = segmentLines.join('\n');
194
+
195
+ // 计算上下文信息
196
+ const contextInfo = {
197
+ hasPreContext: !isFirstSegment && actualStartLine < startLine,
198
+ hasPostContext: actualEndLine > endLine,
199
+ preContextLines: !isFirstSegment ? Math.min(contextLines, startLine - actualStartLine) : 0,
200
+ postContextLines: Math.min(contextLines, actualEndLine - endLine)
201
+ };
202
+
203
+ return {
204
+ index: segmentIndex,
205
+ startLine: startLine + 1, // 转为1基行号
206
+ endLine: endLine + 1, // 转为1基行号
207
+ actualStartLine: actualStartLine + 1, // 包含上下文的实际起始行
208
+ actualEndLine: actualEndLine + 1, // 包含上下文的实际结束行
209
+ content: content,
210
+ tokens: this.estimateTokens(content),
211
+ contextInfo: contextInfo,
212
+ isFirstSegment: isFirstSegment
213
+ };
214
+ }
215
+
216
+ /**
217
+ * 分析单个分段
218
+ * @param {Object} segment - 分段对象
219
+ * @param {string} filePath - 文件路径
220
+ * @param {Object} aiClient - AI客户端
221
+ * @param {string} customPrompt - 自定义提示词
222
+ * @param {Array} staticIssues - 静态问题
223
+ * @param {string} previousContext - 前面段落的上下文
224
+ * @param {number} currentSegmentNum - 当前段号
225
+ * @param {number} totalSegments - 总段数
226
+ * @returns {Object} 分析结果
227
+ */
228
+ async analyzeSegment(segment, filePath, aiClient, customPrompt, staticIssues, previousContext, currentSegmentNum, totalSegments) {
229
+ try {
230
+ logger.debug(` 准备第 ${currentSegmentNum} 段代码内容...`);
231
+
232
+ // 准备代码内容
233
+ const prepared = await stripNoReviewForAI(segment.content, filePath);
234
+ const clean = await stripCommentsForAI(prepared, filePath);
235
+
236
+ logger.debug(` 构建第 ${currentSegmentNum} 段分析提示词...`);
237
+
238
+ // 构建分段分析提示词
239
+ const segmentPrompt = this.buildSegmentPrompt(
240
+ segment,
241
+ filePath,
242
+ clean,
243
+ customPrompt,
244
+ staticIssues,
245
+ previousContext,
246
+ currentSegmentNum,
247
+ totalSegments
248
+ );
249
+
250
+
251
+
252
+ // 调用AI分析
253
+ const response = await aiClient.chatWithRetry({
254
+ model: aiClient.config.model ?? 'gpt-3.5-turbo',
255
+ messages: [
256
+ { role: 'system', content: this.getSegmentSystemPrompt() },
257
+ { role: 'user', content: segmentPrompt }
258
+ ],
259
+ temperature: aiClient.config.temperature !== undefined ? aiClient.config.temperature : 0.1,
260
+ max_tokens: aiClient.config.maxResponseTokens ?? AI_CONSTANTS.DEFAULT_MAX_RESPONSE_TOKENS
261
+ });
262
+
263
+
264
+
265
+ const responseContent = response.choices[0].message.content;
266
+
267
+ // 解析AI响应
268
+ const result = this.parseSegmentResponse(responseContent, filePath, segment);
269
+
270
+ logger.info(` 第 ${currentSegmentNum} 段响应解析完成,发现 ${result.issues ? result.issues.length : 0} 个问题`);
271
+
272
+ return result;
273
+
274
+ } catch (error) {
275
+ logger.error(`第 ${currentSegmentNum} 段分析失败: ${error.message}`);
276
+ throw error;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * 构建分段分析提示词
282
+ */
283
+ buildSegmentPrompt(segment, filePath, cleanContent, customPrompt, staticIssues, previousContext, currentSegmentNum, totalSegments) {
284
+ let prompt = `我正在对一个大文件进行分段代码审查。以下是第 ${currentSegmentNum}/${totalSegments} 段的内容:
285
+
286
+ **文件路径**: ${filePath}
287
+ **当前分段**: 第 ${currentSegmentNum}/${totalSegments} 段
288
+ **代码行范围**: ${segment.startLine}-${segment.endLine}`;
289
+
290
+ // 添加上下文说明
291
+ if (segment.contextInfo.hasPreContext || segment.contextInfo.hasPostContext) {
292
+ prompt += `\n**上下文说明**: `;
293
+ if (segment.contextInfo.hasPreContext) {
294
+ prompt += `前 ${segment.contextInfo.preContextLines} 行为上文上下文`;
295
+ }
296
+ if (segment.contextInfo.hasPostContext) {
297
+ if (segment.contextInfo.hasPreContext) prompt += `,`;
298
+ prompt += `后 ${segment.contextInfo.postContextLines} 行为下文上下文`;
299
+ }
300
+ prompt += `,这些上下文行仅用于帮助理解代码逻辑,请避免对重叠部分重复报告问题。`;
301
+ }
302
+
303
+ // 添加前面段落的摘要上下文
304
+ if (previousContext && this.config.enableSummaryContext) {
305
+ prompt += `\n\n**前面段落摘要**:\n${previousContext}`;
306
+ }
307
+
308
+ // 添加代码内容
309
+ prompt += `\n\n**代码内容**:\n\`\`\`\n${cleanContent}\n\`\`\``;
310
+
311
+ // 添加静态问题提示
312
+ if (staticIssues && staticIssues.length > 0) {
313
+ const relevantStaticIssues = staticIssues.filter(issue => {
314
+ // 简单的行号匹配,实际可以更精确
315
+ return issue.line >= segment.startLine && issue.line <= segment.endLine;
316
+ });
317
+
318
+ if (relevantStaticIssues.length > 0) {
319
+ prompt += `\n\n**本段相关的静态检测问题**:\n`;
320
+ relevantStaticIssues.forEach((issue, idx) => {
321
+ prompt += `${idx + 1}. 第${issue.line}行 (${issue.risk}): ${issue.message}\n`;
322
+ });
323
+ }
324
+ }
325
+
326
+ // 添加自定义提示词
327
+ if (customPrompt) {
328
+ prompt += `\n\n**自定义审查要求**:\n${customPrompt}`;
329
+ }
330
+
331
+ // 添加分段分析要求
332
+ prompt += `\n\n**分段分析要求**:
333
+ 1. 请仅分析当前分段的代码,不要分析上下文行中的问题
334
+ 2. 如果启用摘要功能,请在分析结果后提供一个简短的代码摘要
335
+ 3. 严格按照指定格式返回结果
336
+ 4. 对于跨段的问题,请在当前段中标注,后续段会参考前面的摘要进行综合判断
337
+ 5. 分段上下文限制:不要评估“导入是否被使用”,严格忽略关于“未使用的导入/模块/依赖”的任何提示或建议(包括建议删除未使用的导入)`;
338
+
339
+ return prompt;
340
+ }
341
+
342
+ /**
343
+ * 获取分段分析的系统提示词
344
+ */
345
+ getSegmentSystemPrompt() {
346
+ let systemPrompt = `你是一个专业的代码审查专家,正在对大文件进行分段分析。
347
+
348
+ **分段分析特点**:
349
+ - 你收到的是文件的一个片段,可能包含上下文行
350
+ - 上下文行仅用于理解代码逻辑,不应对其报告问题
351
+ - 需要考虑代码的连续性和上下文关系
352
+ - 避免对重叠部分重复报告问题
353
+
354
+ **输出格式要求**:
355
+ 请严格按照以下格式返回分析结果:
356
+
357
+ **-----代码分析结果-----**
358
+ 文件路径:{文件路径}
359
+ 代码片段:{具体的问题代码片段}
360
+ 风险等级:{致命/高危/中危/低危/建议}
361
+ 风险原因:{详细原因}
362
+ 修改建议:{具体建议}
363
+
364
+ [如果有多个问题,用空行分隔]`;
365
+
366
+ // 如果启用摘要功能,添加摘要要求
367
+ if (this.config.enableSummaryContext) {
368
+ systemPrompt += `
369
+
370
+ **摘要要求**:
371
+ 在所有问题分析完成后,请提供一个简短摘要:
372
+
373
+ **-----段落摘要-----**
374
+ {简要描述这段代码的主要功能、关键逻辑和重要特征,控制在${this.config.maxSummaryLength}字符以内}`;
375
+ }
376
+
377
+ systemPrompt += `
378
+
379
+ **风险等级定义**:
380
+ - 致命:可能导致系统崩溃、数据丢失、严重安全漏洞
381
+ - 高危:可能导致安全漏洞、数据泄露、业务逻辑错误
382
+ - 中危:可能影响系统稳定性、性能问题、用户体验
383
+ - 低危:代码质量问题、不符合最佳实践
384
+ - 建议:改进建议,提升代码质量`;
385
+
386
+ systemPrompt += `
387
+
388
+ **分段场景的忽略规则(必须遵守)**:
389
+ - 由于其它段可能引用当前段的导入,请不要评估或报告“未使用的导入/模块/依赖”相关问题;即使当前片段内未见引用,也不要建议移除该导入。
390
+ - 仅在本段内可以明确识别的实际错误或风险时再输出问题。`;
391
+
392
+ return systemPrompt;
393
+ }
394
+
395
+ /**
396
+ * 解析分段响应
397
+ */
398
+ parseSegmentResponse(responseContent, filePath, segment) {
399
+ const issues = [];
400
+ let summary = '';
401
+
402
+ try {
403
+ // 分离问题分析和摘要
404
+ const parts = responseContent.split('**-----段落摘要-----**');
405
+ const analysisContent = parts[0];
406
+ const summaryContent = parts[1];
407
+
408
+ // 解析问题
409
+ if (analysisContent.includes('**-----代码分析结果-----**')) {
410
+ const problemSections = analysisContent.split('**-----代码分析结果-----**').slice(1);
411
+
412
+ for (const section of problemSections) {
413
+ const issue = this.parseIssueSection(section.trim(), filePath, segment);
414
+ if (issue) {
415
+ issues.push(issue);
416
+ }
417
+ }
418
+ }
419
+
420
+ // 解析摘要
421
+ if (summaryContent && this.config.enableSummaryContext) {
422
+ summary = summaryContent.trim().substring(0, this.config.maxSummaryLength);
423
+ }
424
+
425
+ } catch (error) {
426
+ logger.warn(`解析AI响应失败: ${error.message}`);
427
+ }
428
+
429
+ return {
430
+ issues: issues,
431
+ summary: summary
432
+ };
433
+ }
434
+
435
+ /**
436
+ * 解析单个问题段落
437
+ */
438
+ parseIssueSection(section, filePath, segment) {
439
+ try {
440
+ const lines = section.split('\n').filter(line => line.trim());
441
+
442
+ let issue = {
443
+ file: filePath,
444
+ source: 'ai',
445
+ segment: segment.index + 1,
446
+ segmentRange: `${segment.startLine}-${segment.endLine}`
447
+ };
448
+
449
+ for (const line of lines) {
450
+ if (line.startsWith('文件路径:')) {
451
+ // 文件路径已知,跳过
452
+ } else if (line.startsWith('代码片段:')) {
453
+ issue.snippet = line.substring('代码片段:'.length).trim();
454
+ } else if (line.startsWith('风险等级:')) {
455
+ issue.risk = line.substring('风险等级:'.length).trim();
456
+ } else if (line.startsWith('风险原因:')) {
457
+ issue.message = line.substring('风险原因:'.length).trim();
458
+ } else if (line.startsWith('修改建议:')) {
459
+ issue.suggestion = line.substring('修改建议:'.length).trim();
460
+ }
461
+ }
462
+
463
+ // 验证必要字段
464
+ if (issue.risk && issue.message) {
465
+ return issue;
466
+ }
467
+
468
+ } catch (error) {
469
+ logger.warn(`解析问题段落失败: ${error.message}`);
470
+ }
471
+
472
+ return null;
473
+ }
474
+
475
+ /**
476
+ * 估算文本的Token数量
477
+ */
478
+ estimateTokens(text) {
479
+ if (!text || typeof text !== 'string') return 0;
480
+
481
+ const charCount = text.length;
482
+ const chineseCharCount = (text.match(/[\u4e00-\u9fff]/g) || []).length;
483
+ const englishCharCount = charCount - chineseCharCount;
484
+
485
+ // 中文字符按1.5倍计算
486
+ const adjustedCharCount = englishCharCount + (chineseCharCount * 1.5);
487
+
488
+ return Math.ceil(adjustedCharCount / this.config.tokenRatio);
489
+ }
490
+ }