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,1340 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ import { AIClient } from './ai-client.js';
6
+ import { AIClientPool } from './ai-client-pool.js';
7
+ import { SmartBatching } from './smart-batching.js';
8
+ import { GitDiffParser } from './utils/git-diff-parser.js';
9
+ import { logger } from './utils/logger.js';
10
+ import { DEFAULT_CONFIG, BATCH_CONSTANTS } from './utils/constants.js';
11
+ import { ConcurrencyLimiter } from './utils/concurrency-limiter.js';
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ export class CodeReviewer {
16
+ constructor(config, rules) {
17
+ this.config = config;
18
+ this.rules = rules;
19
+ // 传递 reviewDir 给 AI 客户端用于读取自定义提示词目录 .smart-review/ai-rules
20
+ this.aiClient = config.ai?.enabled ? new AIClient({ ...config.ai, reviewDir: config.reviewDir }) : null;
21
+ this.issues = [];
22
+ this.aiRan = false;
23
+
24
+ // 初始化AI客户端池(根据concurrency值判断并发模式)
25
+ const aiConfig = config?.ai || {};
26
+ const concurrency = Math.round(aiConfig.concurrency || 1); // 四舍五入处理
27
+ // 创建全局并发限速器(批次与分段共享)
28
+ this.concurrencyLimiter = new ConcurrencyLimiter(concurrency);
29
+
30
+ if (aiConfig.enabled && concurrency > 1) {
31
+ this.aiClientPool = new AIClientPool(config, rules, concurrency, this.concurrencyLimiter);
32
+ this.useConcurrency = true;
33
+ this.concurrency = concurrency;
34
+ } else {
35
+ this.aiClientPool = null;
36
+ this.useConcurrency = false;
37
+ this.concurrency = 1;
38
+ }
39
+
40
+ // 单客户端也接入限速器,保证所有AI请求共享并发
41
+ if (this.aiClient) {
42
+ this.aiClient.concurrencyLimiter = this.concurrencyLimiter;
43
+ }
44
+
45
+ // 添加缓存机制以减少对象创建
46
+ this.regexCache = new Map(); // 缓存编译的正则表达式
47
+ this.commentRangeCache = new Map(); // 缓存注释范围计算结果
48
+ this.disableRangeCache = new Map(); // 缓存禁用范围计算结果
49
+ this.extensionCache = new Map(); // 缓存文件扩展名
50
+
51
+ // 缓存统计
52
+ this.cacheStats = {
53
+ regexHits: 0,
54
+ regexMisses: 0,
55
+ commentRangeHits: 0,
56
+ commentRangeMisses: 0,
57
+ disableRangeHits: 0,
58
+ disableRangeMisses: 0
59
+ };
60
+ }
61
+
62
+ async reviewStagedFiles() {
63
+ try {
64
+ logger.progress('开始审查暂存区代码...');
65
+
66
+ // 检查是否启用git diff增量审查模式
67
+ const reviewOnlyChanges = this.config.ai?.reviewOnlyChanges || false;
68
+
69
+ if (reviewOnlyChanges) {
70
+ logger.info('🔍 使用Git Diff增量审查模式 - 仅审查变动内容');
71
+ return await this.reviewStagedDiff();
72
+ } else {
73
+ logger.info('📁 使用全文件审查模式');
74
+ const stagedFiles = await this.getStagedFiles();
75
+
76
+ if (stagedFiles.length === 0) {
77
+ logger.info('📭 暂存区没有文件需要审查');
78
+ return this.generateResult();
79
+ }
80
+ logger.info(`📁 发现 ${stagedFiles.length} 个文件需要审查`);
81
+
82
+ await this.reviewFilesBatchAware(stagedFiles);
83
+ return this.generateResult();
84
+ }
85
+ } catch (error) {
86
+ logger.error('审查过程出错:', error);
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ async reviewSpecificFiles(filePaths) {
92
+ logger.progress(`开始审查指定文件: ${filePaths.join(', ')}`);
93
+ const fullPaths = [];
94
+ for (const filePath of filePaths) {
95
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.config.projectRoot, filePath);
96
+ if (fs.existsSync(fullPath)) {
97
+ fullPaths.push(fullPath);
98
+ } else {
99
+ logger.warn(`文件不存在: ${fullPath}`);
100
+ }
101
+ }
102
+ await this.reviewFilesBatchAware(fullPaths);
103
+ return this.generateResult();
104
+ }
105
+
106
+ async getStagedFiles() {
107
+ try {
108
+ const { stdout } = await execAsync('git diff --cached --name-only --diff-filter=ACM', {
109
+ cwd: this.config.projectRoot
110
+ });
111
+
112
+ return stdout.split('\n')
113
+ .filter(file => file.trim())
114
+ .map(file => path.resolve(this.config.projectRoot, file))
115
+ .filter(file => this.isReviewableFile(file));
116
+ } catch (error) {
117
+ logger.error('获取暂存区文件失败:', error);
118
+ return [];
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Git Diff增量审查 - 仅审查暂存区变动内容
124
+ */
125
+ async reviewStagedDiff() {
126
+ try {
127
+ logger.info('🔍 启动Git Diff增量审查模式...');
128
+
129
+ const contextMergeLines = this.config.ai?.contextMergeLines || 10;
130
+ const diffParser = new GitDiffParser(this.config.projectRoot, contextMergeLines, this.config);
131
+
132
+ // 获取diff审查数据
133
+ const diffReviewData = await diffParser.getStagedDiffReviewData();
134
+
135
+ if (diffReviewData.length === 0) {
136
+ logger.info('📝 暂存区无变更内容,跳过审查');
137
+ return this.generateResult();
138
+ }
139
+
140
+ logger.info(`📊 发现 ${diffReviewData.length} 个变更文件,开始增量审查...`);
141
+
142
+ // 第一阶段:对所有文件进行静态规则检查,检测阻断风险
143
+ const riskLevels = this.config.riskLevels || {};
144
+ let hasBlockingIssues = false;
145
+ const aiEligibleFiles = [];
146
+
147
+ for (let i = 0; i < diffReviewData.length; i++) {
148
+ const fileData = diffReviewData[i];
149
+ const globalIndex = i + 1;
150
+ const filePath = path.resolve(this.config.projectRoot, fileData.filePath);
151
+ if (!this.isReviewableFile(filePath)) {
152
+ logger.info(`文件已跳过审查: ${filePath} (文件类型被忽略)`);
153
+ continue;
154
+ }
155
+ logger.progress(`[${globalIndex}/${diffReviewData.length}] 审查文件: ${fileData.filePath} (新增${fileData.totalAddedLines}行, ${fileData.segments.length}个分段)`);
156
+ // 应用静态规则检查
157
+ const staticIssues = await this.applyStaticRulesToDiff(fileData, filePath);
158
+ this.issues.push(...staticIssues);
159
+
160
+ if (staticIssues.length > 0) {
161
+ logger.debug(`静态规则发现 ${staticIssues.length} 个问题`);
162
+ }
163
+
164
+ // 检查是否有阻断等级问题
165
+ const blockedIssues = staticIssues.filter(issue => {
166
+ const levelCfg = riskLevels[issue.risk || 'suggestion'];
167
+ return levelCfg && levelCfg.block === true;
168
+ });
169
+
170
+ if (blockedIssues.length > 0) {
171
+ const levelsText = [...new Set(blockedIssues.map(i => i.risk))].join(', ');
172
+ logger.error(`发现阻断等级风险 (${levelsText}),跳过AI分析`);
173
+ hasBlockingIssues = true;
174
+ } else {
175
+ // 没有阻断问题的文件可以进行AI分析
176
+ aiEligibleFiles.push(fileData);
177
+ }
178
+ }
179
+
180
+ // 如果发现阻断等级问题,终止整个审查流程
181
+ if (hasBlockingIssues) {
182
+ logger.error('发现阻断等级风险,终止审查流程');
183
+ return this.generateResult();
184
+ }
185
+
186
+ // 第二阶段:对通过静态检查的文件进行AI分析
187
+ if (aiEligibleFiles.length > 0 && this.aiClient) {
188
+ logger.info(`🤖 开始AI智能分析 ${aiEligibleFiles.length} 个文件...`);
189
+
190
+ // 使用并发处理AI分析
191
+ const concurrency = this.config.concurrency || 3;
192
+ const batches = [];
193
+
194
+ // 将文件分批处理
195
+ for (let i = 0; i < aiEligibleFiles.length; i += concurrency) {
196
+ const batch = aiEligibleFiles.slice(i, i + concurrency);
197
+ batches.push(batch);
198
+ }
199
+
200
+ // 并发处理每个批次
201
+ for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
202
+ const batch = batches[batchIndex];
203
+
204
+ // 并发处理当前批次的文件
205
+ const promises = batch.map((fileData, index) => {
206
+ const globalIndex = batchIndex * concurrency + index + 1;
207
+ logger.debug(` 🤖 [${globalIndex}/${aiEligibleFiles.length}] AI分析: ${fileData.filePath}`);
208
+ return this.performAIDiffAnalysis(fileData);
209
+ });
210
+
211
+ await Promise.all(promises);
212
+ }
213
+ }
214
+
215
+ logger.success('✨ Git Diff增量审查完成');
216
+
217
+ return this.generateResult();
218
+ } catch (error) {
219
+ logger.error('Git Diff审查过程出错:', error);
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * 审查单个文件的diff变更
226
+ * @param {Object} fileData diff审查数据
227
+ */
228
+ async reviewFileDiff(fileData) {
229
+ const filePath = path.resolve(this.config.projectRoot, fileData.filePath);
230
+
231
+ if (!this.isReviewableFile(filePath)) {
232
+ logger.info(`文件已跳过审查: ${filePath} (文件类型被忽略)`);
233
+ return;
234
+ }
235
+
236
+ try {
237
+ // 1. 对新增代码应用静态规则
238
+ logger.debug(`应用静态规则检查...`);
239
+ const staticIssues = await this.applyStaticRulesToDiff(fileData, filePath);
240
+ this.issues.push(...staticIssues);
241
+
242
+ if (staticIssues.length > 0) {
243
+ logger.debug(`静态规则发现 ${staticIssues.length} 个问题`);
244
+ }
245
+
246
+ // 2. 本地规则门槛判定
247
+ const riskLevels = this.config.riskLevels || {};
248
+ const blockedIssues = staticIssues.filter(issue => {
249
+ const levelCfg = riskLevels[issue.risk || 'suggestion'];
250
+ return levelCfg && levelCfg.block === true;
251
+ });
252
+
253
+ if (blockedIssues.length > 0) {
254
+ const levelsText = [...new Set(blockedIssues.map(i => i.risk))].join(', ');
255
+ logger.error(`发现阻断等级风险 (${levelsText}),跳过AI分析`);
256
+ } else {
257
+ // 3. 应用AI分析(如果启用)
258
+ if (this.aiClient && this.shouldUseAI(filePath, fileData.fullContent)) {
259
+ logger.debug(`启动AI智能分析...`);
260
+ const aiIssues = await this.aiClient.analyzeDiffFile(fileData, { staticIssues });
261
+ this.issues.push(...aiIssues);
262
+ this.aiRan = true;
263
+
264
+ if (aiIssues.length > 0) {
265
+ logger.debug(`AI分析发现 ${aiIssues.length} 个问题`);
266
+ }
267
+ }
268
+ }
269
+
270
+ } catch (error) {
271
+ logger.error(`审查文件变更失败 ${filePath}:`, error);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * 对通过静态检查的文件执行AI分析
277
+ * @param {Object} fileData diff审查数据
278
+ * @param {Array} staticIssues 静态规则检查结果
279
+ */
280
+ async performAIDiffAnalysis(fileData, staticIssues) {
281
+ const filePath = path.resolve(this.config.projectRoot, fileData.filePath);
282
+
283
+ try {
284
+ if (this.aiClient && this.shouldUseAI(filePath, fileData.fullContent)) {
285
+ logger.debug(`启动AI智能分析...`);
286
+ const aiIssues = await this.aiClient.analyzeDiffFile(fileData, { staticIssues });
287
+ this.issues.push(...aiIssues);
288
+ this.aiRan = true;
289
+
290
+ if (aiIssues.length > 0) {
291
+ logger.debug(`AI分析发现 ${aiIssues.length} 个问题`);
292
+ }
293
+ }
294
+ } catch (error) {
295
+ logger.error(`AI分析文件变更失败 ${filePath}:`, error);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * 对diff变更应用静态规则 - 仅检查新增代码行
301
+ * @param {Object} fileData diff审查数据
302
+ * @param {string} filePath 文件路径
303
+ * @returns {Array} 问题列表
304
+ */
305
+ async applyStaticRulesToDiff(fileData, filePath) {
306
+ const issues = [];
307
+ const ext = this.getCachedExtension(filePath);
308
+
309
+ // 对每个智能分段应用静态规则
310
+ for (const segment of fileData.segments) {
311
+ // 提取新增行内容和行号映射
312
+ const { addedLinesContent, lineMapping } = this.extractAddedLinesFromSegment(segment);
313
+
314
+ if (!addedLinesContent.trim()) {
315
+ continue; // 没有新增行内容,跳过
316
+ }
317
+
318
+ // 应用review-disable过滤
319
+ const disableRanges = this.getCachedDisableRanges(addedLinesContent, filePath);
320
+ const commentRanges = this.getCachedCommentRanges(addedLinesContent, ext);
321
+
322
+ for (const rule of this.rules) {
323
+ try {
324
+ // 函数类型规则处理
325
+ if (typeof rule.pattern === 'function') {
326
+ let result;
327
+ try {
328
+ result = rule.pattern(addedLinesContent);
329
+ } catch (_) {
330
+ result = undefined;
331
+ }
332
+
333
+ if (result) {
334
+ const pushIssue = (snippetText) => {
335
+ const snip = String(snippetText || '');
336
+ if (!snip) return;
337
+ issues.push({
338
+ file: filePath,
339
+ line: segment.startLine, // 使用段落起始行
340
+ risk: rule.risk,
341
+ message: rule.message,
342
+ suggestion: rule.suggestion,
343
+ snippet: snip,
344
+ ruleId: rule.id,
345
+ source: 'static'
346
+ });
347
+ };
348
+
349
+ if (Array.isArray(result)) {
350
+ for (const s of result) pushIssue(s);
351
+ } else {
352
+ pushIssue(result);
353
+ }
354
+ }
355
+ continue;
356
+ }
357
+
358
+ // 正则表达式规则处理
359
+ const regex = this.getCachedRegex(rule.pattern, rule.flags || 'gm');
360
+ let match;
361
+ const reportedSnippets = new Set();
362
+
363
+ while ((match = regex.exec(addedLinesContent)) !== null) {
364
+ // 检查匹配位置是否在注释中
365
+ if (this.isIndexInRanges(match.index, commentRanges)) {
366
+ continue;
367
+ }
368
+
369
+ // 检查匹配位置是否在禁用范围内
370
+ if (this.isIndexInRanges(match.index, disableRanges.suppressRanges || [])) {
371
+ continue;
372
+ }
373
+
374
+ const snippetText = (match[0] || '').substring(0, 200); // 限制片段长度
375
+
376
+ if (reportedSnippets.has(snippetText)) {
377
+ continue; // 避免重复报告
378
+ }
379
+
380
+ reportedSnippets.add(snippetText);
381
+
382
+ // 计算在新增行内容中的行号
383
+ const matchLineInAddedContent = this.getLineNumber(addedLinesContent, match.index);
384
+
385
+ // 映射回原文件的行号
386
+ const actualLineNumber = lineMapping[matchLineInAddedContent - 1];
387
+
388
+ if (actualLineNumber) {
389
+ issues.push({
390
+ file: filePath,
391
+ line: actualLineNumber,
392
+ risk: rule.risk,
393
+ message: rule.message,
394
+ suggestion: rule.suggestion,
395
+ snippet: snippetText,
396
+ ruleId: rule.id,
397
+ source: 'static'
398
+ });
399
+ }
400
+ }
401
+ } catch (error) {
402
+ logger.warn(`规则 ${rule.id} 在diff模式下执行失败:`, error.message);
403
+ }
404
+ }
405
+ }
406
+
407
+ return issues;
408
+ }
409
+
410
+ /**
411
+ * 获取段落中新增行的位置集合
412
+ * @param {Object} segment 代码段
413
+ * @returns {Set} 新增行位置集合
414
+ */
415
+ getAddedLinePositions(segment) {
416
+ const addedLines = new Set();
417
+ const lines = segment.content.split('\n');
418
+
419
+ for (let i = 0; i < lines.length; i++) {
420
+ const line = lines[i];
421
+ if (line.trim().length > 0) {
422
+ addedLines.add(i + 1); // 行号从1开始
423
+ }
424
+ }
425
+
426
+ return addedLines;
427
+ }
428
+
429
+ /**
430
+ * 从段落中提取新增行内容和行号映射
431
+ * @param {Object} segment 代码段
432
+ * @returns {Object} { addedLinesContent: string, lineMapping: Array }
433
+ */
434
+ extractAddedLinesFromSegment(segment) {
435
+ const lines = segment.content.split('\n');
436
+ const addedLines = [];
437
+ const lineMapping = []; // 映射:新内容行号 -> 原始段落行号
438
+
439
+ for (let i = 0; i < lines.length; i++) {
440
+ const line = lines[i];
441
+ // 只提取新增行(以+开头的行)
442
+ if (line.startsWith('+')) {
443
+ // 移除+前缀,保留实际代码内容
444
+ addedLines.push(line.substring(1));
445
+ lineMapping.push(i + 1); // 记录在原始段落中的行号(1-based)
446
+ }
447
+ }
448
+
449
+ return {
450
+ addedLinesContent: addedLines.join('\n'),
451
+ lineMapping
452
+ };
453
+ }
454
+
455
+
456
+
457
+ // 批量感知:优先批量AI分析(方案A),同时保留静态规则与阻断判定
458
+ async reviewFilesBatchAware(filePaths) {
459
+ // 逐文件应用静态规则,若任一文件含阻断等级问题则全局跳过AI;否则所有可用文件进入AI批量
460
+ const aiEligible = [];
461
+ let anyBlocking = false;
462
+ const riskLevels = this.config.riskLevels || {};
463
+
464
+ for (const file of filePaths) {
465
+ if (!this.isReviewableFile(file)) {
466
+ logger.info(`文件已跳过审查: ${file} (文件类型被忽略)`);
467
+ continue;
468
+ }
469
+ const relativePath = path.relative(this.config.projectRoot, file);
470
+ logger.debug(`审查文件: ${relativePath}`);
471
+
472
+ try {
473
+ const content = await this.getFileContent(file);
474
+ if (!content) continue;
475
+
476
+ // 计算代码内指令禁用范围(行/段),不支持整文件禁用(整文件由 ignore 配置控制)
477
+ const disable = this.computeDisableRanges(content, file);
478
+
479
+ // 静态规则
480
+ const staticIssues = this.applyStaticRules(content, file, disable);
481
+ this.issues.push(...staticIssues);
482
+ // 阻断级别判定(只要本地存在阻断等级问题,则全局跳过AI)
483
+ const blockedIssues = staticIssues.filter(issue => {
484
+ const levelCfg = riskLevels[issue.risk || 'suggestion'];
485
+ return levelCfg && levelCfg.block === true;
486
+ });
487
+ if (blockedIssues.length > 0) {
488
+ anyBlocking = true;
489
+ }
490
+ // 收集所有允许的文件,若最终无阻断则这些文件将进入AI批量
491
+ if (this.aiClient && this.shouldUseAI(file, content)) {
492
+ const contextStatic = this.config.ai && this.config.ai.useStaticHints === true ? staticIssues : [];
493
+ aiEligible.push({ filePath: file, content, staticIssues: contextStatic });
494
+ }
495
+ } catch (error) {
496
+ logger.error(`审查文件失败 ${file}:`, error);
497
+ }
498
+ }
499
+ // 若任意文件存在阻断等级问题,直接返回(跳过AI)
500
+ if (anyBlocking) {
501
+ logger.info('本地规则存在阻断等级风险,跳过所有文件的AI分析。');
502
+ return;
503
+ }
504
+
505
+ if (aiEligible.length === 0) return;
506
+
507
+ // 使用增量式分析器进行分析(默认行为)
508
+ logger.progress('使用增量式分析器进行分析...');
509
+ await this.reviewFilesWithIncrementalAnalyzer(aiEligible);
510
+ }
511
+
512
+ async reviewFilesWithIncrementalAnalyzer(aiEligible) {
513
+ try {
514
+ // 直接使用智能批处理,它会自动处理大文件分段和小文件组合
515
+ await this.reviewFilesWithSmartBatching(aiEligible);
516
+
517
+ } catch (error) {
518
+ logger.error(`增量式分析器失败: ${error.message}`);
519
+ throw error;
520
+ }
521
+ }
522
+
523
+ async reviewFilesWithSmartBatching(aiEligible) {
524
+ const batchCfg = this.config.ai || {};
525
+
526
+ // 使用智能分批处理
527
+ try {
528
+ const smartBatching = new SmartBatching({
529
+ maxRequestTokens: batchCfg.maxRequestTokens || 8000,
530
+ minFilesPerBatch: batchCfg.minFilesPerBatch || 1,
531
+ maxFilesPerBatch: batchCfg.maxFilesPerBatch || 20,
532
+ tokenRatio: batchCfg.tokenRatio || 4,
533
+ chunkOverlapLines: batchCfg.chunkOverlapLines || 5
534
+ });
535
+
536
+ logger.progress('开始AI智能分析,根据文件大小耗时不同,请耐心等待...');
537
+ const batchResult = smartBatching.createSmartBatches(aiEligible);
538
+
539
+ if (this.useConcurrency && this.aiClientPool) {
540
+ // 并发处理模式
541
+ await this.processBatchesConcurrently(batchResult.batches, smartBatching);
542
+ } else {
543
+ // 串行处理模式(原有逻辑)
544
+ await this.processBatchesSerially(batchResult.batches, smartBatching);
545
+ }
546
+ // 最终摘要由并发/串行流程统一输出,这里不重复输出
547
+
548
+ this.aiRan = true;
549
+ } catch (error) {
550
+ logger.error('AI智能批量分析过程出错:', error);
551
+
552
+ // 回退到原有的简单分批方式
553
+ logger.progress('回退到简单分析方式...');
554
+ try {
555
+ const max = Number(batchCfg.maxFilesPerBatch || 20);
556
+ const batches = [];
557
+ for (let i = 0; i < aiEligible.length; i += max) {
558
+ batches.push(aiEligible.slice(i, i + max));
559
+ }
560
+
561
+ logger.info(`开始AI分析,共${batches.length}批文件`);
562
+
563
+ for (let i = 0; i < batches.length; i++) {
564
+ const batch = batches[i];
565
+ logger.info(`批次 ${i + 1}/${batches.length}: 分析${batch.length}个文件`);
566
+ const aiIssues = await this.aiClient.analyzeFilesBatch(batch);
567
+ this.issues.push(...aiIssues);
568
+ logger.success(`批次 ${i + 1}/${batches.length} 完成`);
569
+ }
570
+ this.aiRan = true;
571
+ } catch (fallbackError) {
572
+ logger.error('回退分析也失败:', fallbackError);
573
+ }
574
+ }
575
+ }
576
+
577
+ /**
578
+ * 并发处理批次
579
+ * @param {Array} batches - 批次数组
580
+ * @param {SmartBatching} smartBatching - 智能分批实例
581
+ */
582
+ async processBatchesConcurrently(batches, smartBatching) {
583
+ // 只有当批次数量大于1且并发数量大于1时才显示并发处理日志
584
+ if (batches.length > 1 && this.concurrency > 1) {
585
+ logger.info(`启用并发处理,并发数: ${this.concurrency}`);
586
+ logger.info(`使用并发模式处理 ${batches.length} 个批次`);
587
+ }
588
+
589
+ // 创建进度跟踪器
590
+ const progressTracker = {
591
+ completed: 0,
592
+ failed: 0,
593
+ total: batches.length,
594
+ startTime: Date.now()
595
+ };
596
+
597
+ // 进度回调函数(不输出中间进度日志)
598
+ const progressCallback = (batchIndex, batch, status, error) => {
599
+ if (status === 'completed') {
600
+ progressTracker.completed++;
601
+ } else if (status === 'failed') {
602
+ progressTracker.failed++;
603
+ logger.warn(`批次 ${batchIndex + 1} 处理失败: ${error?.message || '未知错误'}`);
604
+ }
605
+ };
606
+
607
+ try {
608
+ // 使用AI客户端池执行并发处理
609
+ const { issues: allIssues, totalDurationMs } = await this.aiClientPool.executeConcurrentBatches(batches, progressCallback);
610
+
611
+ // 汇总结果
612
+ this.issues.push(...allIssues);
613
+
614
+ const elapsed = ((totalDurationMs) / 1000).toFixed(1);
615
+ const totalIssues = allIssues.length;
616
+ logger.success(`AI分析完成,发现${totalIssues}个问题,共耗时:${elapsed}秒`);
617
+
618
+ } catch (error) {
619
+ logger.error(`并发处理过程中发生错误: ${error.message}`);
620
+ throw error;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * 串行处理批次(原有逻辑)
626
+ * @param {Array} batches - 批次数组
627
+ * @param {SmartBatching} smartBatching - 智能分批实例
628
+ */
629
+ async processBatchesSerially(batches, smartBatching) {
630
+ logger.info(`使用串行模式处理 ${batches.length} 个批次`);
631
+ const startTime = Date.now();
632
+
633
+ for (let i = 0; i < batches.length; i++) {
634
+ const batch = batches[i];
635
+ const formattedBatch = smartBatching.formatBatchForAI(batch);
636
+
637
+ // 判断批次类型并输出相应信息
638
+ if (batch.isLargeFileSegment) {
639
+ // 大文件批次 - 显示文件路径和总段数
640
+ logger.info(`批次 ${i + 1}/${batches.length}: 分析 ${batch.segmentedFile} 文件,共${batch.totalSegments}段`);
641
+ } else {
642
+ // 小文件批次 - 列出所有文件
643
+ const fileList = batch.items.map(item => item.filePath).join(',');
644
+ logger.info(`批次 ${i + 1}/${batches.length}: 分析 ${fileList} 文件`);
645
+ }
646
+
647
+ const aiIssues = await this.aiClient.analyzeSmartBatch(formattedBatch, batch);
648
+ this.issues.push(...aiIssues);
649
+
650
+ logger.success(`批次 ${i + 1}/${batches.length} 完成`);
651
+ }
652
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
653
+ const totalIssues = this.issues.length;
654
+ logger.success(`AI分析完成,发现${totalIssues}个问题,共耗时:${elapsed}秒`);
655
+ }
656
+
657
+ async getFileContent(filePath) {
658
+ try {
659
+ const relativePath = path.relative(this.config.projectRoot, filePath);
660
+
661
+ // 尝试获取暂存区内容
662
+ try {
663
+ const { stdout } = await execAsync(`git show :"${relativePath}"`, {
664
+ cwd: this.config.projectRoot,
665
+ maxBuffer: 10 * 1024 * 1024
666
+ });
667
+ return stdout;
668
+ } catch (stagedError) {
669
+ // 回退到读取工作区文件,使用流式读取处理大文件
670
+ return await this.readFileStream(filePath);
671
+ }
672
+ } catch (error) {
673
+ logger.error(`❌ 读取文件内容失败 ${filePath}:`, error);
674
+ return null;
675
+ }
676
+ }
677
+
678
+ async readFileStream(filePath) {
679
+ return new Promise((resolve, reject) => {
680
+ const stats = fs.statSync(filePath);
681
+ const fileSizeKB = stats.size / 1024;
682
+
683
+ // 对于小文件(< 1MB),直接使用同步读取
684
+ if (fileSizeKB < 1024) {
685
+ try {
686
+ resolve(fs.readFileSync(filePath, 'utf8'));
687
+ return;
688
+ } catch (error) {
689
+ reject(error);
690
+ return;
691
+ }
692
+ }
693
+
694
+ // 对于大文件,使用流式读取
695
+ logger.debug(`使用流式读取大文件: ${filePath} (${fileSizeKB.toFixed(1)}KB)`);
696
+
697
+ const chunks = [];
698
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
699
+
700
+ stream.on('data', (chunk) => {
701
+ chunks.push(chunk);
702
+ });
703
+
704
+ stream.on('end', () => {
705
+ resolve(chunks.join(''));
706
+ });
707
+
708
+ stream.on('error', (error) => {
709
+ reject(error);
710
+ });
711
+ });
712
+ }
713
+
714
+ applyStaticRules(content, filePath, disableCtx) {
715
+ const issues = [];
716
+ const ext = this.getCachedExtension(filePath);
717
+ const commentRanges = this.getCachedCommentRanges(content, ext);
718
+ const disable = disableCtx || this.getCachedDisableRanges(content, filePath);
719
+ let skippedByComments = 0;
720
+ let skippedByDirectives = 0;
721
+ for (const rule of this.rules) {
722
+ try {
723
+ // 可选:当文件中存在指定清理/反证模式时,跳过该规则以降低误报
724
+ if (Array.isArray(rule.requiresAbsent) && rule.requiresAbsent.length > 0 && typeof rule.pattern !== 'function') {
725
+ const hasCleanup = rule.requiresAbsent.some(rxStr => {
726
+ try {
727
+ const rx = this.getCachedRegex(rxStr, rule.flags || 'gm');
728
+ return rx.test(content);
729
+ } catch (_) {
730
+ return false;
731
+ }
732
+ });
733
+ if (hasCleanup) continue; // 文件已存在对应清理逻辑,跳过此规则
734
+ }
735
+
736
+ // 简化:pattern 支持函数。若返回片段字符串或字符串数组,则直接使用该片段作为结果;
737
+ // 若返回 falsy,则视为规则校验通过(不报告)。不进行额外的二次匹配或行号计算。
738
+ if (typeof rule.pattern === 'function') {
739
+ let result;
740
+ try {
741
+ result = rule.pattern(content);
742
+ } catch (_) {
743
+ result = undefined;
744
+ }
745
+ const pushIssue = (snippetText) => {
746
+ const snip = String(snippetText || '');
747
+ if (!snip) return;
748
+ issues.push({
749
+ file: filePath,
750
+ line: undefined,
751
+ risk: rule.risk,
752
+ message: rule.message,
753
+ suggestion: rule.suggestion,
754
+ snippet: snip,
755
+ ruleId: rule.id,
756
+ source: 'static'
757
+ });
758
+ };
759
+ if (!result) {
760
+ // 规则校验通过
761
+ } else if (Array.isArray(result)) {
762
+ for (const s of result) pushIssue(s);
763
+ } else {
764
+ pushIssue(result);
765
+ }
766
+ continue; // 进入下一条规则
767
+ }
768
+
769
+ const regex = this.getCachedRegex(rule.pattern, rule.flags || 'gm');
770
+ let match;
771
+
772
+ // 记录该规则已在哪些代码片段上报过,避免重复片段
773
+ const reportedSnippets = new Set();
774
+
775
+ while ((match = regex.exec(content)) !== null) {
776
+ // 若匹配位置在注释中,跳过
777
+ if (this.isIndexInRanges(match.index, commentRanges)) {
778
+ skippedByComments++;
779
+ continue;
780
+ }
781
+ // 若匹配位置在禁用范围内,跳过
782
+ if (this.isIndexInRanges(match.index, disable.suppressRanges || [])) {
783
+ skippedByDirectives++;
784
+ continue;
785
+ }
786
+ const lineNumber = this.getLineNumber(content, match.index);
787
+ const snippetText = (match[0] || '').substring(0, BATCH_CONSTANTS.MAX_SNIPPET_LENGTH);
788
+
789
+ if (reportedSnippets.has(snippetText)) {
790
+ continue; // 本规则在该片段已报告,避免重复
791
+ }
792
+
793
+ reportedSnippets.add(snippetText);
794
+
795
+ issues.push({
796
+ file: filePath,
797
+ line: lineNumber,
798
+ risk: rule.risk,
799
+ message: rule.message,
800
+ suggestion: rule.suggestion,
801
+ snippet: snippetText,
802
+ ruleId: rule.id,
803
+ source: 'static'
804
+ });
805
+ }
806
+ } catch (error) {
807
+ logger.warn(`规则 ${rule.id} 执行失败:`, error.message);
808
+ }
809
+ }
810
+
811
+ if (skippedByComments > 0) {
812
+ logger.debug(`注释代码已跳过审查(${skippedByComments}条匹配)`);
813
+ }
814
+ if (skippedByDirectives > 0) {
815
+ logger.debug(`指令禁用范围已跳过审查(${skippedByDirectives}条匹配)`);
816
+ }
817
+
818
+ return issues;
819
+ }
820
+
821
+ getLineNumber(content, position) {
822
+ return content.substring(0, position).split('\n').length;
823
+ }
824
+
825
+ isReviewableFile(filePath) {
826
+ const extensions = this.config.fileExtensions || [];
827
+
828
+ // 统一的忽略文件配置:支持相对路径、绝对路径、glob模式和正则表达式
829
+ const ignoreFiles = this.config.ignoreFiles || [];
830
+
831
+ const ext = path.extname(filePath).toLowerCase();
832
+ const shouldInclude = extensions.includes(ext);
833
+ if (!shouldInclude || ignoreFiles.length === 0) {
834
+ return shouldInclude;
835
+ }
836
+
837
+ const normalized = filePath.replace(/\\/g, '/');
838
+ const relativePath = path.relative(this.config.projectRoot || process.cwd(), filePath).replace(/\\/g, '/');
839
+ const basename = path.basename(filePath);
840
+
841
+ // 检查是否应该忽略此文件
842
+ const shouldIgnore = ignoreFiles.some(pattern => {
843
+ const originalPattern = String(pattern);
844
+
845
+ // 1. 精确匹配(支持相对路径、绝对路径、文件名)
846
+ if (originalPattern === normalized || originalPattern === relativePath || originalPattern === basename) {
847
+ return true;
848
+ }
849
+ // 2. 检查是否为正则表达式(以/开头和结尾,或包含正则特殊字符但不是glob)
850
+ if (this.isRegexPattern(originalPattern)) {
851
+ try {
852
+ const regex = this.createRegexFromPattern(originalPattern);
853
+ const normalizedMatch = regex.test(normalized);
854
+ const relativeMatch = regex.test(relativePath);
855
+ return normalizedMatch || relativeMatch;
856
+ } catch (e) {
857
+ return false;
858
+ }
859
+ }
860
+
861
+ // 3. glob模式匹配(只对glob模式进行路径分隔符转换)
862
+ const patternStr = originalPattern.replace(/\\/g, '/');
863
+ return this.matchPattern(normalized, patternStr) || this.matchPattern(relativePath, patternStr);
864
+ });
865
+
866
+ return !shouldIgnore;
867
+ }
868
+
869
+ getFileReviewStatus(filePath) {
870
+ const extensions = this.config.fileExtensions || [];
871
+ const ignoreFiles = this.config.ignoreFiles || [];
872
+
873
+ const ext = path.extname(filePath).toLowerCase();
874
+ const shouldInclude = extensions.includes(ext);
875
+
876
+ if (!shouldInclude) {
877
+ return {
878
+ reviewable: false,
879
+ reason: `文件扩展名 ${ext} 不在支持列表中`,
880
+ matchedPattern: null
881
+ };
882
+ }
883
+
884
+ if (ignoreFiles.length === 0) {
885
+ return { reviewable: true, reason: null, matchedPattern: null };
886
+ }
887
+
888
+ const normalized = filePath.replace(/\\/g, '/');
889
+ const relativePath = path.relative(this.config.projectRoot || process.cwd(), filePath).replace(/\\/g, '/');
890
+ const basename = path.basename(filePath);
891
+
892
+ // 检查是否应该忽略此文件
893
+ for (const pattern of ignoreFiles) {
894
+ const originalPattern = String(pattern);
895
+
896
+ // 1. 精确匹配(支持相对路径、绝对路径、文件名)
897
+ if (originalPattern === normalized || originalPattern === relativePath || originalPattern === basename) {
898
+ return {
899
+ reviewable: false,
900
+ reason: '匹配精确模式',
901
+ matchedPattern: originalPattern
902
+ };
903
+ }
904
+
905
+ // 2. 检查是否为正则表达式(以/开头和结尾,或包含正则特殊字符但不是glob)
906
+ if (this.isRegexPattern(originalPattern)) {
907
+ try {
908
+ const regex = this.createRegexFromPattern(originalPattern);
909
+ const normalizedMatch = regex.test(normalized);
910
+ const relativeMatch = regex.test(relativePath);
911
+ if (normalizedMatch || relativeMatch) {
912
+ return {
913
+ reviewable: false,
914
+ reason: '匹配正则表达式',
915
+ matchedPattern: originalPattern
916
+ };
917
+ }
918
+ } catch (e) {
919
+ // 正则表达式创建失败,继续下一个模式
920
+ continue;
921
+ }
922
+ } else {
923
+ // 3. glob模式匹配(只对glob模式进行路径分隔符转换)
924
+ const patternStr = originalPattern.replace(/\\/g, '/');
925
+ if (this.matchPattern(normalized, patternStr) || this.matchPattern(relativePath, patternStr)) {
926
+ return {
927
+ reviewable: false,
928
+ reason: '匹配glob模式',
929
+ matchedPattern: originalPattern
930
+ };
931
+ }
932
+ }
933
+ }
934
+
935
+ return { reviewable: true, reason: null, matchedPattern: null };
936
+ }
937
+
938
+ matchPattern(filePath, pattern) {
939
+ // 处理glob模式转换为正则表达式
940
+ let regexPattern = pattern
941
+ // 转义正则表达式特殊字符(除了*和?)
942
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&');
943
+
944
+ // 先处理 ** 模式(必须在单个 * 之前处理)
945
+ regexPattern = regexPattern.replace(/\*\*/g, '§DOUBLESTAR§');
946
+
947
+ // 处理单个 * 匹配单个路径段中的任意字符(不包括路径分隔符)
948
+ regexPattern = regexPattern.replace(/\*/g, '[^/]*');
949
+
950
+ // 恢复 ** 为匹配任意路径(包括跨目录)
951
+ regexPattern = regexPattern.replace(/§DOUBLESTAR§/g, '.*');
952
+
953
+ // 处理 ? 匹配单个字符
954
+ regexPattern = regexPattern.replace(/\?/g, '.');
955
+
956
+ // 特殊处理:如果模式以 **/ 开头,允许匹配根目录
957
+ if (pattern.startsWith('**/')) {
958
+ regexPattern = regexPattern.replace(/^\.\*\//, '(.*\/|^)');
959
+ }
960
+
961
+ // 特殊处理:/**/* 模式应该匹配目录下的任何文件
962
+ regexPattern = regexPattern.replace(/\/\.\*\/\[.*?\]\*$/, '(/.*)?');
963
+
964
+ return new RegExp(`^${regexPattern}$`).test(filePath);
965
+ }
966
+
967
+ // 检查是否为正则表达式模式
968
+ isRegexPattern(pattern) {
969
+ // 以/开头和结尾的正则表达式格式(优先检查)
970
+ if (pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) {
971
+ return true;
972
+ }
973
+
974
+ // 检查是否包含典型的glob模式(双星号或单独的星号用于路径匹配)
975
+ const globPatterns = /\*\*\/|\*\*$|\/\*\*|^\*\*|\/\*\.|\*\.[a-zA-Z]+$/;
976
+
977
+ // 如果包含明显的glob模式,则不是正则表达式
978
+ if (globPatterns.test(pattern)) {
979
+ return false;
980
+ }
981
+
982
+ // 检查是否看起来像普通的文件路径(包含路径分隔符和文件扩展名)
983
+ if (/^[^()[\]{}^$+|\\*?]+\.[a-zA-Z0-9]+$/.test(pattern) ||
984
+ /^[^()[\]{}^$+|\\*?]*\/[^()[\]{}^$+|\\*?]*\.[a-zA-Z0-9]+$/.test(pattern)) {
985
+ return false;
986
+ }
987
+
988
+ // 包含正则特殊字符的字符串(排除普通的点号)
989
+ const regexChars = /[()[\]{}^$+|\\*?]/;
990
+
991
+ // 如果包含正则表达式特殊字符,则认为是正则表达式
992
+ return regexChars.test(pattern);
993
+ }
994
+
995
+ // 从模式字符串创建正则表达式
996
+ createRegexFromPattern(pattern) {
997
+ // 如果是/pattern/flags格式
998
+ if (pattern.startsWith('/') && pattern.length > 1) {
999
+ const lastSlash = pattern.lastIndexOf('/');
1000
+ if (lastSlash > 0) {
1001
+ const regexBody = pattern.slice(1, lastSlash);
1002
+ const flags = pattern.slice(lastSlash + 1);
1003
+ return new RegExp(regexBody, flags);
1004
+ }
1005
+ }
1006
+
1007
+ // 否则直接作为正则表达式字符串
1008
+ // 注意:pattern已经是从JSON解析后的字符串,不需要额外的转义处理
1009
+ return new RegExp(pattern);
1010
+ }
1011
+
1012
+ shouldUseAI(filePath, content) {
1013
+ if (!this.config.ai?.enabled) return false;
1014
+
1015
+ // 检查文件大小限制
1016
+ if (content.length > (this.config.ai?.maxFileSizeKB || DEFAULT_CONFIG.MAX_FILE_SIZE_KB) * 1024) {
1017
+ logger.info(`跳过AI分析大文件: ${filePath}`);
1018
+ return false;
1019
+ }
1020
+
1021
+ // 检查文件类型
1022
+ const ext = path.extname(filePath).toLowerCase();
1023
+ const enabledFor = this.config.ai?.enabledFor || [];
1024
+ return enabledFor.includes(ext);
1025
+ }
1026
+
1027
+ // 计算注释范围(基于文件扩展名的简单规则)
1028
+ computeCommentRanges(content, ext) {
1029
+ const ranges = [];
1030
+ const pushRange = (start, end) => {
1031
+ if (start >= 0 && end > start) ranges.push({ start, end });
1032
+ };
1033
+
1034
+ const addByRegex = (regex) => {
1035
+ let m;
1036
+ while ((m = regex.exec(content)) !== null) {
1037
+ pushRange(m.index, m.index + m[0].length);
1038
+ }
1039
+ };
1040
+
1041
+ const jsLike = ['.js','.jsx','.ts','.tsx','.java','.go','.c','.cpp','.h','.rs','.php'];
1042
+ if (jsLike.includes(ext)) {
1043
+ // 行注释与块注释
1044
+ addByRegex(/\/\/.*|\/\*[\s\S]*?\*\//g);
1045
+ // 追加 JSX/TSX 注释包裹:{/* ... */},避免剥离后残留"{}"
1046
+ if (ext === '.jsx' || ext === '.tsx') {
1047
+ addByRegex(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g);
1048
+ }
1049
+ } else if (ext === '.py' || ext === '.rb') {
1050
+ addByRegex(/(^|\s)#.*$/gm);
1051
+ } else if (ext === '.html' || ext === '.svelte') {
1052
+ addByRegex(/<!--[\s\S]*?-->/g);
1053
+ } else if (ext === '.css' || ext === '.scss' || ext === '.less') {
1054
+ addByRegex(/\/\*[\s\S]*?\*\//g);
1055
+ } else {
1056
+ // 通用:尝试移除常见注释模式
1057
+ addByRegex(/\/\/.*|\/\*[\s\S]*?\*\//g);
1058
+ addByRegex(/(^|\s)#.*$/gm);
1059
+ addByRegex(/<!--[\s\S]*?-->/g);
1060
+ }
1061
+
1062
+ // 合并重叠区间,避免重复剥离导致索引错位
1063
+ if (ranges.length > 1) {
1064
+ ranges.sort((a, b) => a.start - b.start);
1065
+ const merged = [];
1066
+ let prev = ranges[0];
1067
+ for (let i = 1; i < ranges.length; i++) {
1068
+ const cur = ranges[i];
1069
+ if (cur.start <= prev.end) {
1070
+ prev.end = Math.max(prev.end, cur.end);
1071
+ } else {
1072
+ merged.push(prev);
1073
+ prev = cur;
1074
+ }
1075
+ }
1076
+ merged.push(prev);
1077
+ return merged;
1078
+ }
1079
+
1080
+ return ranges;
1081
+ }
1082
+
1083
+ isIndexInRanges(index, ranges) {
1084
+ return ranges.some(r => index >= r.start && index < r.end);
1085
+ }
1086
+
1087
+ // 计算代码禁用范围(按行/按段),基于固定注释令牌
1088
+ computeDisableRanges(content, filePath) {
1089
+ const ext = path.extname(filePath).toLowerCase();
1090
+ const ranges = this.computeCommentRanges(content, ext);
1091
+ const nextToken = 'review-disable-next-line';
1092
+ const startToken = 'review-disable-start';
1093
+ const endToken = 'review-disable-end';
1094
+
1095
+ // 每行起始偏移
1096
+ const lineOffsets = [];
1097
+ const lines = content.split('\n');
1098
+ let offset = 0;
1099
+ for (const ln of lines) { lineOffsets.push(offset); offset += ln.length + 1; }
1100
+
1101
+ const suppressRanges = [];
1102
+ let pendingBlockStart = null;
1103
+
1104
+ for (const r of ranges) {
1105
+ const lower = content.slice(r.start, r.end).toLowerCase();
1106
+ // 下一行禁用
1107
+ if (lower.includes(nextToken)) {
1108
+ const lineIdx = content.substring(0, r.start).split('\n').length - 1;
1109
+ const nextStart = lineOffsets[lineIdx + 1];
1110
+ const nextEnd = lineOffsets[lineIdx + 2] ?? content.length;
1111
+ if (Number.isFinite(nextStart)) suppressRanges.push({ start: nextStart, end: nextEnd });
1112
+ continue;
1113
+ }
1114
+ // 段落开始
1115
+ if (lower.includes(startToken)) {
1116
+ const lineIdx = content.substring(0, r.start).split('\n').length - 1;
1117
+ const nextStart = lineOffsets[lineIdx + 1];
1118
+ if (Number.isFinite(nextStart)) pendingBlockStart = nextStart;
1119
+ continue;
1120
+ }
1121
+ // 段落结束
1122
+ if (lower.includes(endToken)) {
1123
+ const lineIdx = content.substring(0, r.start).split('\n').length - 1;
1124
+ const endStart = lineOffsets[lineIdx]; // 结束注释所在行的起始位置
1125
+ if (Number.isFinite(pendingBlockStart)) {
1126
+ const startPos = pendingBlockStart;
1127
+ const endPos = Number.isFinite(endStart) ? endStart : content.length;
1128
+ if (startPos < endPos) suppressRanges.push({ start: startPos, end: endPos });
1129
+ }
1130
+ pendingBlockStart = null;
1131
+ continue;
1132
+ }
1133
+ }
1134
+
1135
+ // 若存在起始但没有结束,禁用到文件末尾
1136
+ if (Number.isFinite(pendingBlockStart)) {
1137
+ suppressRanges.push({ start: pendingBlockStart, end: content.length });
1138
+ }
1139
+
1140
+ return { suppressRanges };
1141
+ }
1142
+
1143
+ stripComments(content, filePath) {
1144
+ const ext = path.extname(filePath).toLowerCase();
1145
+ const ranges = this.computeCommentRanges(content, ext);
1146
+ if (ranges.length === 0) return content;
1147
+ // 按范围从后向前移除,避免索引偏移
1148
+ let result = content;
1149
+ ranges.sort((a,b) => b.start - a.start).forEach(r => {
1150
+ result = result.slice(0, r.start) + result.slice(r.end);
1151
+ });
1152
+ return result;
1153
+ }
1154
+
1155
+ generateResult() {
1156
+ // 合并与去重策略:按 file+snippet 归并,同一代码片段仅保留一条
1157
+ // 选择规则:保留风险等级更高的;若 AI 与本地风险一致,保留本地规则项
1158
+ const riskWeight = { critical: 5, high: 4, medium: 3, low: 2, suggestion: 1 };
1159
+ const pickByKey = new Map();
1160
+
1161
+ for (const issue of this.issues) {
1162
+ const snippetPart = String(issue.snippet || '').trim().slice(0, 200);
1163
+ let keyPart = snippetPart;
1164
+ if (!keyPart) {
1165
+ const msg = String(issue.message || '').trim().replace(/\s+/g, ' ');
1166
+ const src = String(issue.source || 'unknown');
1167
+ const rid = issue.ruleId ? String(issue.ruleId) : '';
1168
+ const msgPart = msg.slice(0, 80);
1169
+ keyPart = [src, rid, msgPart].filter(Boolean).join(':');
1170
+ }
1171
+ const key = `${issue.file}::${keyPart}`;
1172
+ const current = pickByKey.get(key);
1173
+ if (!current) {
1174
+ pickByKey.set(key, issue);
1175
+ continue;
1176
+ }
1177
+
1178
+ const currWeight = riskWeight[current.risk] || 0;
1179
+ const nextWeight = riskWeight[issue.risk] || 0;
1180
+
1181
+ if (nextWeight > currWeight) {
1182
+ pickByKey.set(key, issue);
1183
+ } else if (nextWeight === currWeight) {
1184
+ // 风险一致:优先保留本地规则(static)
1185
+ if (issue.source === 'static' && current.source !== 'static') {
1186
+ pickByKey.set(key, issue);
1187
+ }
1188
+ // 同源或都为 static:保留现有,避免抖动
1189
+ }
1190
+ }
1191
+
1192
+ let deduped = Array.from(pickByKey.values());
1193
+
1194
+ const blockingLevels = Object.entries(this.config.riskLevels || {})
1195
+ .filter(([_, config]) => config.block)
1196
+ .map(([level]) => level);
1197
+
1198
+ // 如果启用了suppressLowLevelOutput,过滤掉非阻断等级的问题
1199
+ if (this.config.suppressLowLevelOutput) {
1200
+ deduped = deduped.filter(issue => blockingLevels.includes(issue.risk));
1201
+ }
1202
+
1203
+ const hasBlockingIssues = deduped.some(issue => blockingLevels.includes(issue.risk));
1204
+
1205
+ // 按文件内行号排序(起始行号优先,其次单行号),无行号的放后
1206
+ const getLineKey = (issue) => {
1207
+ const s = Number(issue.lineStart);
1208
+ const e = Number(issue.lineEnd);
1209
+ const single = Number(issue.line);
1210
+ if (Number.isFinite(s) && s > 0) return s;
1211
+ if (Number.isFinite(single) && single > 0) return single;
1212
+ if (Number.isFinite(e) && e > 0) return e;
1213
+ return Number.POSITIVE_INFINITY;
1214
+ };
1215
+ deduped = deduped.sort((a, b) => {
1216
+ if (a.file !== b.file) return String(a.file).localeCompare(String(b.file));
1217
+ const la = getLineKey(a);
1218
+ const lb = getLineKey(b);
1219
+ if (la !== lb) return la - lb;
1220
+ // 行号相同则按风险等级(高优先)稳定排序
1221
+ const riskWeight = { critical: 5, high: 4, medium: 3, low: 2, suggestion: 1 };
1222
+ const wa = riskWeight[a.risk] || 0;
1223
+ const wb = riskWeight[b.risk] || 0;
1224
+ return wb - wa;
1225
+ });
1226
+
1227
+ return {
1228
+ issues: deduped,
1229
+ blockSubmission: hasBlockingIssues,
1230
+ aiRan: !!this.aiRan,
1231
+ summary: {
1232
+ total: deduped.length,
1233
+ blocking: deduped.filter(issue => blockingLevels.includes(issue.risk)).length
1234
+ }
1235
+ };
1236
+ }
1237
+
1238
+ getCachedExtension(filePath) {
1239
+ if (this.extensionCache.has(filePath)) {
1240
+ return this.extensionCache.get(filePath);
1241
+ }
1242
+
1243
+ const ext = path.extname(filePath).toLowerCase();
1244
+ this.extensionCache.set(filePath, ext);
1245
+ return ext;
1246
+ }
1247
+
1248
+ getCachedRegex(pattern, flags) {
1249
+ const cacheKey = `${pattern}::${flags}`;
1250
+
1251
+ if (this.regexCache.has(cacheKey)) {
1252
+ this.cacheStats.regexHits++;
1253
+ return this.regexCache.get(cacheKey);
1254
+ }
1255
+
1256
+ this.cacheStats.regexMisses++;
1257
+ const regex = new RegExp(pattern, flags);
1258
+ this.regexCache.set(cacheKey, regex);
1259
+
1260
+ // 限制缓存大小,避免内存泄漏
1261
+ if (this.regexCache.size > BATCH_CONSTANTS.MAX_REGEX_CACHE_SIZE) {
1262
+ const firstKey = this.regexCache.keys().next().value;
1263
+ this.regexCache.delete(firstKey);
1264
+ }
1265
+
1266
+ return regex;
1267
+ }
1268
+
1269
+ getCachedCommentRanges(content, ext) {
1270
+ const cacheKey = `${ext}::${content.length}::${content.substring(0, BATCH_CONSTANTS.CACHE_KEY_PREFIX_LENGTH)}`;
1271
+
1272
+ if (this.commentRangeCache.has(cacheKey)) {
1273
+ this.cacheStats.commentRangeHits++;
1274
+ return this.commentRangeCache.get(cacheKey);
1275
+ }
1276
+
1277
+ this.cacheStats.commentRangeMisses++;
1278
+ const ranges = this.computeCommentRanges(content, ext);
1279
+ this.commentRangeCache.set(cacheKey, ranges);
1280
+
1281
+ // 限制缓存大小
1282
+ if (this.commentRangeCache.size > BATCH_CONSTANTS.MAX_COMMENT_RANGE_CACHE_SIZE) {
1283
+ const firstKey = this.commentRangeCache.keys().next().value;
1284
+ this.commentRangeCache.delete(firstKey);
1285
+ }
1286
+
1287
+ return ranges;
1288
+ }
1289
+
1290
+ getCachedDisableRanges(content, filePath) {
1291
+ const cacheKey = `${filePath}::${content.length}::${content.substring(0, BATCH_CONSTANTS.CACHE_KEY_PREFIX_LENGTH)}`;
1292
+
1293
+ if (this.disableRangeCache.has(cacheKey)) {
1294
+ this.cacheStats.disableRangeHits++;
1295
+ return this.disableRangeCache.get(cacheKey);
1296
+ }
1297
+
1298
+ this.cacheStats.disableRangeMisses++;
1299
+ const ranges = this.computeDisableRanges(content, filePath);
1300
+ this.disableRangeCache.set(cacheKey, ranges);
1301
+
1302
+ // 限制缓存大小
1303
+ if (this.disableRangeCache.size > BATCH_CONSTANTS.MAX_DISABLE_RANGE_CACHE_SIZE) {
1304
+ const firstKey = this.disableRangeCache.keys().next().value;
1305
+ this.disableRangeCache.delete(firstKey);
1306
+ }
1307
+
1308
+ return ranges;
1309
+ }
1310
+
1311
+ getCacheStats() {
1312
+ const totalRegex = this.cacheStats.regexHits + this.cacheStats.regexMisses;
1313
+ const totalCommentRange = this.cacheStats.commentRangeHits + this.cacheStats.commentRangeMisses;
1314
+ const totalDisableRange = this.cacheStats.disableRangeHits + this.cacheStats.disableRangeMisses;
1315
+
1316
+ return {
1317
+ regex: {
1318
+ hits: this.cacheStats.regexHits,
1319
+ misses: this.cacheStats.regexMisses,
1320
+ hitRate: totalRegex > 0 ? (this.cacheStats.regexHits / totalRegex * 100).toFixed(2) + '%' : '0%',
1321
+ cacheSize: this.regexCache.size
1322
+ },
1323
+ commentRange: {
1324
+ hits: this.cacheStats.commentRangeHits,
1325
+ misses: this.cacheStats.commentRangeMisses,
1326
+ hitRate: totalCommentRange > 0 ? (this.cacheStats.commentRangeHits / totalCommentRange * 100).toFixed(2) + '%' : '0%',
1327
+ cacheSize: this.commentRangeCache.size
1328
+ },
1329
+ disableRange: {
1330
+ hits: this.cacheStats.disableRangeHits,
1331
+ misses: this.cacheStats.disableRangeMisses,
1332
+ hitRate: totalDisableRange > 0 ? (this.cacheStats.disableRangeHits / totalDisableRange * 100).toFixed(2) + '%' : '0%',
1333
+ cacheSize: this.disableRangeCache.size
1334
+ },
1335
+ extension: {
1336
+ cacheSize: this.extensionCache.size
1337
+ }
1338
+ };
1339
+ }
1340
+ }