smart-review 1.0.1 → 1.0.2
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.en-US.md +580 -0
- package/README.md +93 -54
- package/bin/install.js +192 -55
- package/bin/review.js +42 -47
- package/index.js +0 -1
- package/lib/ai-client-pool.js +63 -25
- package/lib/ai-client.js +262 -415
- package/lib/config-loader.js +35 -7
- package/lib/default-config.js +33 -24
- package/lib/reviewer.js +267 -97
- package/lib/segmented-analyzer.js +102 -126
- package/lib/utils/git-diff-parser.js +9 -8
- package/lib/utils/i18n.js +986 -0
- package/package.json +2 -10
- package/templates/rules/en-US/best-practices.js +111 -0
- package/templates/rules/en-US/performance.js +123 -0
- package/templates/rules/en-US/security.js +311 -0
- package/templates/rules/zh-CN/best-practices.js +111 -0
- package/templates/rules/zh-CN/performance.js +123 -0
- package/templates/rules/zh-CN/security.js +311 -0
- package/templates/smart-review.json +2 -1
package/lib/reviewer.js
CHANGED
|
@@ -9,6 +9,7 @@ import { GitDiffParser } from './utils/git-diff-parser.js';
|
|
|
9
9
|
import { logger } from './utils/logger.js';
|
|
10
10
|
import { DEFAULT_CONFIG, BATCH_CONSTANTS } from './utils/constants.js';
|
|
11
11
|
import { ConcurrencyLimiter } from './utils/concurrency-limiter.js';
|
|
12
|
+
import { t } from './utils/i18n.js';
|
|
12
13
|
|
|
13
14
|
const execAsync = promisify(exec);
|
|
14
15
|
|
|
@@ -16,8 +17,8 @@ export class CodeReviewer {
|
|
|
16
17
|
constructor(config, rules) {
|
|
17
18
|
this.config = config;
|
|
18
19
|
this.rules = rules;
|
|
19
|
-
// 传递 reviewDir 给 AI
|
|
20
|
-
this.aiClient = config.ai?.enabled ? new AIClient({ ...config.ai, reviewDir: config.reviewDir }) : null;
|
|
20
|
+
// 传递 reviewDir 与 locale 给 AI 客户端用于读取自定义提示词目录与国际化
|
|
21
|
+
this.aiClient = config.ai?.enabled ? new AIClient({ ...config.ai, reviewDir: config.reviewDir, locale: config.locale }) : null;
|
|
21
22
|
this.issues = [];
|
|
22
23
|
this.aiRan = false;
|
|
23
24
|
|
|
@@ -61,42 +62,42 @@ export class CodeReviewer {
|
|
|
61
62
|
|
|
62
63
|
async reviewStagedFiles() {
|
|
63
64
|
try {
|
|
64
|
-
logger.progress('
|
|
65
|
+
logger.progress(t(this.config, 'review_staged_start'));
|
|
65
66
|
|
|
66
67
|
// 检查是否启用git diff增量审查模式
|
|
67
68
|
const reviewOnlyChanges = this.config.ai?.reviewOnlyChanges || false;
|
|
68
69
|
|
|
69
70
|
if (reviewOnlyChanges) {
|
|
70
|
-
logger.info(
|
|
71
|
+
logger.info(t(this.config, 'using_diff_mode'));
|
|
71
72
|
return await this.reviewStagedDiff();
|
|
72
73
|
} else {
|
|
73
|
-
logger.info(
|
|
74
|
+
logger.info(t(this.config, 'using_full_mode'));
|
|
74
75
|
const stagedFiles = await this.getStagedFiles();
|
|
75
76
|
|
|
76
77
|
if (stagedFiles.length === 0) {
|
|
77
|
-
logger.info(
|
|
78
|
+
logger.info(t(this.config, 'no_staged_files'));
|
|
78
79
|
return this.generateResult();
|
|
79
80
|
}
|
|
80
|
-
logger.info(
|
|
81
|
+
logger.info(t(this.config, 'found_staged_files_n', { count: stagedFiles.length }));
|
|
81
82
|
|
|
82
83
|
await this.reviewFilesBatchAware(stagedFiles);
|
|
83
84
|
return this.generateResult();
|
|
84
85
|
}
|
|
85
86
|
} catch (error) {
|
|
86
|
-
logger.error('
|
|
87
|
+
logger.error(t(this.config, 'review_error', { error: error?.message || String(error) }));
|
|
87
88
|
throw error;
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
async reviewSpecificFiles(filePaths) {
|
|
92
|
-
logger.progress(
|
|
93
|
+
logger.progress(t(this.config, 'review_specific_start', { files: filePaths.join(', ') }));
|
|
93
94
|
const fullPaths = [];
|
|
94
95
|
for (const filePath of filePaths) {
|
|
95
96
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.config.projectRoot, filePath);
|
|
96
97
|
if (fs.existsSync(fullPath)) {
|
|
97
98
|
fullPaths.push(fullPath);
|
|
98
99
|
} else {
|
|
99
|
-
logger.warn(
|
|
100
|
+
logger.warn(t(this.config, 'file_not_exists', { file: fullPath }));
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
await this.reviewFilesBatchAware(fullPaths);
|
|
@@ -114,7 +115,7 @@ export class CodeReviewer {
|
|
|
114
115
|
.map(file => path.resolve(this.config.projectRoot, file))
|
|
115
116
|
.filter(file => this.isReviewableFile(file));
|
|
116
117
|
} catch (error) {
|
|
117
|
-
logger.error('
|
|
118
|
+
logger.error(t(this.config, 'get_staged_failed', { error: error?.message || String(error) }));
|
|
118
119
|
return [];
|
|
119
120
|
}
|
|
120
121
|
}
|
|
@@ -124,7 +125,7 @@ export class CodeReviewer {
|
|
|
124
125
|
*/
|
|
125
126
|
async reviewStagedDiff() {
|
|
126
127
|
try {
|
|
127
|
-
logger.info(
|
|
128
|
+
logger.info(t(this.config, 'start_git_diff_mode'));
|
|
128
129
|
|
|
129
130
|
const contextMergeLines = this.config.ai?.contextMergeLines || 10;
|
|
130
131
|
const diffParser = new GitDiffParser(this.config.projectRoot, contextMergeLines, this.config);
|
|
@@ -133,11 +134,10 @@ export class CodeReviewer {
|
|
|
133
134
|
const diffReviewData = await diffParser.getStagedDiffReviewData();
|
|
134
135
|
|
|
135
136
|
if (diffReviewData.length === 0) {
|
|
136
|
-
logger.info(
|
|
137
|
+
logger.info(t(this.config, 'no_changes_skip'));
|
|
137
138
|
return this.generateResult();
|
|
138
139
|
}
|
|
139
|
-
|
|
140
|
-
logger.info(`📊 发现 ${diffReviewData.length} 个变更文件,开始增量审查...`);
|
|
140
|
+
logger.info(t(this.config, 'found_changed_files_n', { count: diffReviewData.length }));
|
|
141
141
|
|
|
142
142
|
// 第一阶段:对所有文件进行静态规则检查,检测阻断风险
|
|
143
143
|
const riskLevels = this.config.riskLevels || {};
|
|
@@ -149,16 +149,22 @@ export class CodeReviewer {
|
|
|
149
149
|
const globalIndex = i + 1;
|
|
150
150
|
const filePath = path.resolve(this.config.projectRoot, fileData.filePath);
|
|
151
151
|
if (!this.isReviewableFile(filePath)) {
|
|
152
|
-
logger.info(
|
|
152
|
+
logger.info(t(this.config, 'file_skipped_by_type', { path: filePath }));
|
|
153
153
|
continue;
|
|
154
154
|
}
|
|
155
|
-
logger.progress(
|
|
155
|
+
logger.progress(t(this.config, 'review_file_progress', {
|
|
156
|
+
index: globalIndex,
|
|
157
|
+
total: diffReviewData.length,
|
|
158
|
+
file: fileData.filePath,
|
|
159
|
+
added: fileData.totalAddedLines,
|
|
160
|
+
segments: fileData.segments.length
|
|
161
|
+
}));
|
|
156
162
|
// 应用静态规则检查
|
|
157
163
|
const staticIssues = await this.applyStaticRulesToDiff(fileData, filePath);
|
|
158
164
|
this.issues.push(...staticIssues);
|
|
159
165
|
|
|
160
166
|
if (staticIssues.length > 0) {
|
|
161
|
-
logger.debug(
|
|
167
|
+
logger.debug(t(this.config, 'static_rules_found_n_dbg', { count: staticIssues.length }));
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
// 检查是否有阻断等级问题
|
|
@@ -169,7 +175,7 @@ export class CodeReviewer {
|
|
|
169
175
|
|
|
170
176
|
if (blockedIssues.length > 0) {
|
|
171
177
|
const levelsText = [...new Set(blockedIssues.map(i => i.risk))].join(', ');
|
|
172
|
-
logger.error(
|
|
178
|
+
logger.error(t(this.config, 'blocking_risk_detected_skip_ai', { levels: levelsText }));
|
|
173
179
|
hasBlockingIssues = true;
|
|
174
180
|
} else {
|
|
175
181
|
// 没有阻断问题的文件可以进行AI分析
|
|
@@ -179,13 +185,13 @@ export class CodeReviewer {
|
|
|
179
185
|
|
|
180
186
|
// 如果发现阻断等级问题,终止整个审查流程
|
|
181
187
|
if (hasBlockingIssues) {
|
|
182
|
-
logger.error('
|
|
188
|
+
logger.error(t(this.config, 'blocking_risk_terminate'));
|
|
183
189
|
return this.generateResult();
|
|
184
190
|
}
|
|
185
191
|
|
|
186
192
|
// 第二阶段:对通过静态检查的文件进行AI分析
|
|
187
193
|
if (aiEligibleFiles.length > 0 && this.aiClient) {
|
|
188
|
-
logger.info(
|
|
194
|
+
logger.info(t(this.config, 'ai_start_n_files', { count: aiEligibleFiles.length }));
|
|
189
195
|
|
|
190
196
|
// 使用并发处理AI分析
|
|
191
197
|
const concurrency = this.config.concurrency || 3;
|
|
@@ -204,7 +210,7 @@ export class CodeReviewer {
|
|
|
204
210
|
// 并发处理当前批次的文件
|
|
205
211
|
const promises = batch.map((fileData, index) => {
|
|
206
212
|
const globalIndex = batchIndex * concurrency + index + 1;
|
|
207
|
-
logger.debug(
|
|
213
|
+
logger.debug(t(this.config, 'ai_file_progress_dbg', { index: globalIndex, total: aiEligibleFiles.length, file: fileData.filePath }));
|
|
208
214
|
return this.performAIDiffAnalysis(fileData);
|
|
209
215
|
});
|
|
210
216
|
|
|
@@ -212,11 +218,11 @@ export class CodeReviewer {
|
|
|
212
218
|
}
|
|
213
219
|
}
|
|
214
220
|
|
|
215
|
-
logger.success(
|
|
221
|
+
logger.success(t(this.config, 'git_diff_done'));
|
|
216
222
|
|
|
217
223
|
return this.generateResult();
|
|
218
224
|
} catch (error) {
|
|
219
|
-
logger.error(
|
|
225
|
+
logger.error(t(this.config, 'git_diff_error', { error: error?.message || String(error) }));
|
|
220
226
|
throw error;
|
|
221
227
|
}
|
|
222
228
|
}
|
|
@@ -229,18 +235,18 @@ export class CodeReviewer {
|
|
|
229
235
|
const filePath = path.resolve(this.config.projectRoot, fileData.filePath);
|
|
230
236
|
|
|
231
237
|
if (!this.isReviewableFile(filePath)) {
|
|
232
|
-
logger.info(
|
|
238
|
+
logger.info(t(this.config, 'file_skipped_by_type', { path: filePath }));
|
|
233
239
|
return;
|
|
234
240
|
}
|
|
235
241
|
|
|
236
242
|
try {
|
|
237
243
|
// 1. 对新增代码应用静态规则
|
|
238
|
-
logger.debug(
|
|
244
|
+
logger.debug(t(this.config, 'apply_static_rules_dbg'));
|
|
239
245
|
const staticIssues = await this.applyStaticRulesToDiff(fileData, filePath);
|
|
240
246
|
this.issues.push(...staticIssues);
|
|
241
247
|
|
|
242
248
|
if (staticIssues.length > 0) {
|
|
243
|
-
logger.debug(
|
|
249
|
+
logger.debug(t(this.config, 'static_rules_found_n_dbg', { count: staticIssues.length }));
|
|
244
250
|
}
|
|
245
251
|
|
|
246
252
|
// 2. 本地规则门槛判定
|
|
@@ -252,23 +258,23 @@ export class CodeReviewer {
|
|
|
252
258
|
|
|
253
259
|
if (blockedIssues.length > 0) {
|
|
254
260
|
const levelsText = [...new Set(blockedIssues.map(i => i.risk))].join(', ');
|
|
255
|
-
logger.error(
|
|
261
|
+
logger.error(t(this.config, 'blocking_risk_detected_skip_ai', { levels: levelsText }));
|
|
256
262
|
} else {
|
|
257
263
|
// 3. 应用AI分析(如果启用)
|
|
258
264
|
if (this.aiClient && this.shouldUseAI(filePath, fileData.fullContent)) {
|
|
259
|
-
logger.debug(
|
|
265
|
+
logger.debug(t(this.config, 'ai_start_progress_note'));
|
|
260
266
|
const aiIssues = await this.aiClient.analyzeDiffFile(fileData, { staticIssues });
|
|
261
267
|
this.issues.push(...aiIssues);
|
|
262
268
|
this.aiRan = true;
|
|
263
269
|
|
|
264
270
|
if (aiIssues.length > 0) {
|
|
265
|
-
logger.debug(
|
|
271
|
+
logger.debug(t(this.config, 'ai_issues_found_n_dbg', { count: aiIssues.length }));
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
274
|
}
|
|
269
275
|
|
|
270
276
|
} catch (error) {
|
|
271
|
-
logger.error(
|
|
277
|
+
logger.error(t(this.config, 'review_file_failed', { file: filePath, error: error?.message || String(error) }));
|
|
272
278
|
}
|
|
273
279
|
}
|
|
274
280
|
|
|
@@ -282,17 +288,17 @@ export class CodeReviewer {
|
|
|
282
288
|
|
|
283
289
|
try {
|
|
284
290
|
if (this.aiClient && this.shouldUseAI(filePath, fileData.fullContent)) {
|
|
285
|
-
logger.debug(
|
|
291
|
+
logger.debug(t(this.config, 'ai_start_progress_note'));
|
|
286
292
|
const aiIssues = await this.aiClient.analyzeDiffFile(fileData, { staticIssues });
|
|
287
293
|
this.issues.push(...aiIssues);
|
|
288
294
|
this.aiRan = true;
|
|
289
295
|
|
|
290
296
|
if (aiIssues.length > 0) {
|
|
291
|
-
logger.debug(
|
|
297
|
+
logger.debug(t(this.config, 'ai_issues_found_n_dbg', { count: aiIssues.length }));
|
|
292
298
|
}
|
|
293
299
|
}
|
|
294
300
|
} catch (error) {
|
|
295
|
-
logger.error(
|
|
301
|
+
logger.error(t(this.config, 'ai_diff_failed', { path: filePath, error: error?.message || String(error) }));
|
|
296
302
|
}
|
|
297
303
|
}
|
|
298
304
|
|
|
@@ -359,7 +365,61 @@ export class CodeReviewer {
|
|
|
359
365
|
const regex = this.getCachedRegex(rule.pattern, rule.flags || 'gm');
|
|
360
366
|
let match;
|
|
361
367
|
const reportedSnippets = new Set();
|
|
362
|
-
|
|
368
|
+
const requiresAbsent = Array.isArray(rule.requiresAbsent) ? rule.requiresAbsent : null;
|
|
369
|
+
const isPairable = !!(requiresAbsent && requiresAbsent.some(s => /\(/.test(String(s))));
|
|
370
|
+
const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
371
|
+
const findAssignedHandleBefore = (src, mIndex) => {
|
|
372
|
+
const lineStart = src.lastIndexOf('\n', Math.max(0, mIndex - 1)) + 1;
|
|
373
|
+
const left = src.slice(lineStart, mIndex);
|
|
374
|
+
const assignMatch = /([A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)*)\s*(?:=|:=)\s*$/.exec(left);
|
|
375
|
+
if (assignMatch) return assignMatch[1];
|
|
376
|
+
return null;
|
|
377
|
+
};
|
|
378
|
+
const isEffectiveIndex = (idx) => {
|
|
379
|
+
if (this.isIndexInRanges(idx, commentRanges)) return false;
|
|
380
|
+
if (this.isIndexInRanges(idx, disableRanges.suppressRanges || [])) return false;
|
|
381
|
+
return true;
|
|
382
|
+
};
|
|
383
|
+
const buildPairedCleanupRegexes = (rxStr, handle, flags) => {
|
|
384
|
+
const raw = String(rxStr);
|
|
385
|
+
if (!/(\(|\\\()/.test(raw)) return [];
|
|
386
|
+
const escapedHandle = escapeRegExp(handle);
|
|
387
|
+
const fFlags = flags || 'gm';
|
|
388
|
+
const out = [];
|
|
389
|
+
// 1) 函数调用样式:fnName( handle )
|
|
390
|
+
try {
|
|
391
|
+
const withHandle = raw.replace(/\\\(/, `\\(\\s*${escapedHandle}\\s*`);
|
|
392
|
+
const patternStr = withHandle.endsWith(')') || /\\\)\s*$/.test(withHandle) ? withHandle : `${withHandle}\\)`;
|
|
393
|
+
out.push(new RegExp(patternStr, fFlags));
|
|
394
|
+
} catch (_) {}
|
|
395
|
+
// 2) 句柄方法样式:handle.fnName(...)
|
|
396
|
+
try {
|
|
397
|
+
const fnMatch = raw.match(/([A-Za-z_][A-Za-z0-9_.$]*)\s*(?:\\\(|\()/);
|
|
398
|
+
if (fnMatch) {
|
|
399
|
+
const fn = escapeRegExp(fnMatch[1]);
|
|
400
|
+
out.push(new RegExp(`${escapedHandle}\\s*\\.\\s*${fn}\\s*\\(`, fFlags));
|
|
401
|
+
}
|
|
402
|
+
} catch (_) {}
|
|
403
|
+
return out;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// 非配对规则:若 requiresAbsent 存在且在新增内容中命中有效清理,则跳过该规则在此段
|
|
407
|
+
if (!isPairable && requiresAbsent && requiresAbsent.length > 0) {
|
|
408
|
+
let hasCleanup = false;
|
|
409
|
+
for (const rxStr of requiresAbsent) {
|
|
410
|
+
try {
|
|
411
|
+
const rx = this.getCachedRegex(rxStr, rule.flags || 'gm');
|
|
412
|
+
let cm;
|
|
413
|
+
while ((cm = rx.exec(addedLinesContent)) !== null) {
|
|
414
|
+
if (isEffectiveIndex(cm.index)) { hasCleanup = true; break; }
|
|
415
|
+
}
|
|
416
|
+
} catch (_) {}
|
|
417
|
+
if (hasCleanup) break;
|
|
418
|
+
}
|
|
419
|
+
if (hasCleanup) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
363
423
|
while ((match = regex.exec(addedLinesContent)) !== null) {
|
|
364
424
|
// 检查匹配位置是否在注释中
|
|
365
425
|
if (this.isIndexInRanges(match.index, commentRanges)) {
|
|
@@ -371,6 +431,29 @@ export class CodeReviewer {
|
|
|
371
431
|
continue;
|
|
372
432
|
}
|
|
373
433
|
|
|
434
|
+
// 当规则的 requiresAbsent 是“函数调用样式”时:对每个匹配执行句柄级清理配对
|
|
435
|
+
if (isPairable && requiresAbsent && requiresAbsent.length > 0) {
|
|
436
|
+
const handle = findAssignedHandleBefore(addedLinesContent, match.index);
|
|
437
|
+
let cleaned = false;
|
|
438
|
+
if (handle) {
|
|
439
|
+
for (const rxStr of requiresAbsent) {
|
|
440
|
+
const pairedRegexes = buildPairedCleanupRegexes(rxStr, handle, rule.flags);
|
|
441
|
+
if (!pairedRegexes || pairedRegexes.length === 0) continue;
|
|
442
|
+
for (const pairedRx of pairedRegexes) {
|
|
443
|
+
let cm;
|
|
444
|
+
while ((cm = pairedRx.exec(addedLinesContent)) !== null) {
|
|
445
|
+
if (isEffectiveIndex(cm.index)) { cleaned = true; break; }
|
|
446
|
+
}
|
|
447
|
+
if (cleaned) break;
|
|
448
|
+
}
|
|
449
|
+
if (cleaned) break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (cleaned) {
|
|
453
|
+
continue; // 已对同一句柄执行清理,不报警
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
374
457
|
const snippetText = (match[0] || '').substring(0, 200); // 限制片段长度
|
|
375
458
|
|
|
376
459
|
if (reportedSnippets.has(snippetText)) {
|
|
@@ -399,7 +482,7 @@ export class CodeReviewer {
|
|
|
399
482
|
}
|
|
400
483
|
}
|
|
401
484
|
} catch (error) {
|
|
402
|
-
logger.warn(
|
|
485
|
+
logger.warn(t(this.config, 'rule_diff_exec_failed_warn', { id: rule.id, error: error?.message || String(error) }));
|
|
403
486
|
}
|
|
404
487
|
}
|
|
405
488
|
}
|
|
@@ -426,33 +509,54 @@ export class CodeReviewer {
|
|
|
426
509
|
return addedLines;
|
|
427
510
|
}
|
|
428
511
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
512
|
+
/**
|
|
513
|
+
* 从段落中提取新增行内容和行号映射
|
|
514
|
+
* @param {Object} segment 代码段
|
|
515
|
+
* @returns {Object} { addedLinesContent: string, lineMapping: Array, originalAddedLinesContent: string }
|
|
516
|
+
*/
|
|
517
|
+
extractAddedLinesFromSegment(segment) {
|
|
518
|
+
const lines = segment.content.split('\n');
|
|
519
|
+
const addedLinesOriginal = [];
|
|
520
|
+
const addedLinesNormalized = [];
|
|
521
|
+
const lineMapping = []; // 新内容行号(1-based) -> 原文件行号
|
|
522
|
+
const bracketRe = /^\s*\[\s*(\d+)\s*\]\s*/; // 解析类似 "[2067] " 的前缀
|
|
523
|
+
let plusIdx = 0; // 计数第几条新增行
|
|
524
|
+
|
|
525
|
+
for (let i = 0; i < lines.length; i++) {
|
|
526
|
+
const line = lines[i];
|
|
527
|
+
// 只提取新增行(以+开头的行)
|
|
528
|
+
if (line.startsWith('+')) {
|
|
529
|
+
const withoutPlus = line.substring(1);
|
|
530
|
+
addedLinesOriginal.push(withoutPlus);
|
|
531
|
+
|
|
532
|
+
// 优先使用 segment.addedLineNumbers 提供的真实文件行号
|
|
533
|
+
let actualLine = Array.isArray(segment.addedLineNumbers) && plusIdx < segment.addedLineNumbers.length
|
|
534
|
+
? segment.addedLineNumbers[plusIdx]
|
|
535
|
+
: undefined;
|
|
536
|
+
// 若未提供,则尝试从 [行号] 前缀中解析
|
|
537
|
+
if (!Number.isFinite(actualLine)) {
|
|
538
|
+
const m = withoutPlus.match(bracketRe);
|
|
539
|
+
if (m) actualLine = parseInt(m[1], 10);
|
|
540
|
+
}
|
|
541
|
+
// 兜底:用段起始行近似(不理想,但避免 undefined)
|
|
542
|
+
if (!Number.isFinite(actualLine)) {
|
|
543
|
+
actualLine = Number.isFinite(segment?.startLine) ? (segment.startLine + plusIdx) : undefined;
|
|
544
|
+
}
|
|
545
|
+
lineMapping.push(actualLine);
|
|
454
546
|
|
|
547
|
+
// 生成用于匹配的规范化行:移除 [行号] 前缀
|
|
548
|
+
const normalized = withoutPlus.replace(bracketRe, '');
|
|
549
|
+
addedLinesNormalized.push(normalized);
|
|
550
|
+
plusIdx++;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
455
553
|
|
|
554
|
+
return {
|
|
555
|
+
addedLinesContent: addedLinesNormalized.join('\n'),
|
|
556
|
+
lineMapping,
|
|
557
|
+
originalAddedLinesContent: addedLinesOriginal.join('\n')
|
|
558
|
+
};
|
|
559
|
+
}
|
|
456
560
|
|
|
457
561
|
// 批量感知:优先批量AI分析(方案A),同时保留静态规则与阻断判定
|
|
458
562
|
async reviewFilesBatchAware(filePaths) {
|
|
@@ -463,11 +567,11 @@ export class CodeReviewer {
|
|
|
463
567
|
|
|
464
568
|
for (const file of filePaths) {
|
|
465
569
|
if (!this.isReviewableFile(file)) {
|
|
466
|
-
logger.info(
|
|
570
|
+
logger.info(t(this.config, 'file_skipped_by_type', { path: file }));
|
|
467
571
|
continue;
|
|
468
572
|
}
|
|
469
573
|
const relativePath = path.relative(this.config.projectRoot, file);
|
|
470
|
-
logger.debug(
|
|
574
|
+
logger.debug(t(this.config, 'review_file_dbg', { file: relativePath }));
|
|
471
575
|
|
|
472
576
|
try {
|
|
473
577
|
const content = await this.getFileContent(file);
|
|
@@ -493,19 +597,19 @@ export class CodeReviewer {
|
|
|
493
597
|
aiEligible.push({ filePath: file, content, staticIssues: contextStatic });
|
|
494
598
|
}
|
|
495
599
|
} catch (error) {
|
|
496
|
-
logger.error(
|
|
600
|
+
logger.error(t(this.config, 'review_file_failed', { file, error: error?.message || String(error) }));
|
|
497
601
|
}
|
|
498
602
|
}
|
|
499
603
|
// 若任意文件存在阻断等级问题,直接返回(跳过AI)
|
|
500
604
|
if (anyBlocking) {
|
|
501
|
-
logger.info('
|
|
605
|
+
logger.info(t(this.config, 'blocking_risk_skip_all_ai_info'));
|
|
502
606
|
return;
|
|
503
607
|
}
|
|
504
608
|
|
|
505
609
|
if (aiEligible.length === 0) return;
|
|
506
610
|
|
|
507
611
|
// 使用增量式分析器进行分析(默认行为)
|
|
508
|
-
logger.progress('
|
|
612
|
+
logger.progress(t(this.config, 'using_incremental_analyzer_progress'));
|
|
509
613
|
await this.reviewFilesWithIncrementalAnalyzer(aiEligible);
|
|
510
614
|
}
|
|
511
615
|
|
|
@@ -515,7 +619,7 @@ export class CodeReviewer {
|
|
|
515
619
|
await this.reviewFilesWithSmartBatching(aiEligible);
|
|
516
620
|
|
|
517
621
|
} catch (error) {
|
|
518
|
-
logger.error(
|
|
622
|
+
logger.error(t(this.config, 'incremental_analyzer_failed_error', { error: error?.message || String(error) }));
|
|
519
623
|
throw error;
|
|
520
624
|
}
|
|
521
625
|
}
|
|
@@ -533,7 +637,7 @@ export class CodeReviewer {
|
|
|
533
637
|
chunkOverlapLines: batchCfg.chunkOverlapLines || 5
|
|
534
638
|
});
|
|
535
639
|
|
|
536
|
-
logger.progress('
|
|
640
|
+
logger.progress(t(this.config, 'ai_smart_analysis_start_progress'));
|
|
537
641
|
const batchResult = smartBatching.createSmartBatches(aiEligible);
|
|
538
642
|
|
|
539
643
|
if (this.useConcurrency && this.aiClientPool) {
|
|
@@ -547,10 +651,10 @@ export class CodeReviewer {
|
|
|
547
651
|
|
|
548
652
|
this.aiRan = true;
|
|
549
653
|
} catch (error) {
|
|
550
|
-
logger.error('
|
|
654
|
+
logger.error(t(this.config, 'smart_batching_process_error', { error: error?.message || String(error) }));
|
|
551
655
|
|
|
552
656
|
// 回退到原有的简单分批方式
|
|
553
|
-
logger.progress('
|
|
657
|
+
logger.progress(t(this.config, 'fallback_simple_analysis_progress'));
|
|
554
658
|
try {
|
|
555
659
|
const max = Number(batchCfg.maxFilesPerBatch || 20);
|
|
556
660
|
const batches = [];
|
|
@@ -558,18 +662,18 @@ export class CodeReviewer {
|
|
|
558
662
|
batches.push(aiEligible.slice(i, i + max));
|
|
559
663
|
}
|
|
560
664
|
|
|
561
|
-
logger.info(
|
|
665
|
+
logger.info(t(this.config, 'ai_analysis_start_batches', { count: batches.length }));
|
|
562
666
|
|
|
563
667
|
for (let i = 0; i < batches.length; i++) {
|
|
564
668
|
const batch = batches[i];
|
|
565
|
-
logger.info(
|
|
669
|
+
logger.info(t(this.config, 'batch_info_header', { index: i + 1, total: batches.length, files: batch.length }));
|
|
566
670
|
const aiIssues = await this.aiClient.analyzeFilesBatch(batch);
|
|
567
671
|
this.issues.push(...aiIssues);
|
|
568
|
-
logger.success(
|
|
672
|
+
logger.success(t(this.config, 'batch_done_success', { index: i + 1, total: batches.length }));
|
|
569
673
|
}
|
|
570
674
|
this.aiRan = true;
|
|
571
675
|
} catch (fallbackError) {
|
|
572
|
-
logger.error('
|
|
676
|
+
logger.error(t(this.config, 'fallback_analysis_failed', { error: fallbackError?.message || String(fallbackError) }));
|
|
573
677
|
}
|
|
574
678
|
}
|
|
575
679
|
}
|
|
@@ -582,8 +686,8 @@ export class CodeReviewer {
|
|
|
582
686
|
async processBatchesConcurrently(batches, smartBatching) {
|
|
583
687
|
// 只有当批次数量大于1且并发数量大于1时才显示并发处理日志
|
|
584
688
|
if (batches.length > 1 && this.concurrency > 1) {
|
|
585
|
-
logger.info(
|
|
586
|
-
logger.info(
|
|
689
|
+
logger.info(t(this.config, 'start_concurrency_enabled_info', { concurrency: this.concurrency }));
|
|
690
|
+
logger.info(t(this.config, 'start_concurrency_dbg', { count: batches.length }));
|
|
587
691
|
}
|
|
588
692
|
|
|
589
693
|
// 创建进度跟踪器
|
|
@@ -600,7 +704,7 @@ export class CodeReviewer {
|
|
|
600
704
|
progressTracker.completed++;
|
|
601
705
|
} else if (status === 'failed') {
|
|
602
706
|
progressTracker.failed++;
|
|
603
|
-
logger.warn(
|
|
707
|
+
logger.warn(t(this.config, 'concurrent_group_failed', { index: batchIndex + 1, error: error?.message || String(error) }));
|
|
604
708
|
}
|
|
605
709
|
};
|
|
606
710
|
|
|
@@ -613,10 +717,10 @@ export class CodeReviewer {
|
|
|
613
717
|
|
|
614
718
|
const elapsed = ((totalDurationMs) / 1000).toFixed(1);
|
|
615
719
|
const totalIssues = allIssues.length;
|
|
616
|
-
logger.success(
|
|
720
|
+
logger.success(t(this.config, 'ai_analysis_done_summary', { totalIssues, elapsed }));
|
|
617
721
|
|
|
618
722
|
} catch (error) {
|
|
619
|
-
logger.error(
|
|
723
|
+
logger.error(t(this.config, 'concurrent_processing_error', { error: error?.message || String(error) }));
|
|
620
724
|
throw error;
|
|
621
725
|
}
|
|
622
726
|
}
|
|
@@ -627,7 +731,7 @@ export class CodeReviewer {
|
|
|
627
731
|
* @param {SmartBatching} smartBatching - 智能分批实例
|
|
628
732
|
*/
|
|
629
733
|
async processBatchesSerially(batches, smartBatching) {
|
|
630
|
-
logger.info(
|
|
734
|
+
logger.info(t(this.config, 'serial_process_batches_info', { count: batches.length }));
|
|
631
735
|
const startTime = Date.now();
|
|
632
736
|
|
|
633
737
|
for (let i = 0; i < batches.length; i++) {
|
|
@@ -637,21 +741,21 @@ export class CodeReviewer {
|
|
|
637
741
|
// 判断批次类型并输出相应信息
|
|
638
742
|
if (batch.isLargeFileSegment) {
|
|
639
743
|
// 大文件批次 - 显示文件路径和总段数
|
|
640
|
-
logger.info(
|
|
744
|
+
logger.info(t(this.config, 'serial_large_file_batch_info', { index: i + 1, total: batches.length, file: batch.segmentedFile, segments: batch.totalSegments }));
|
|
641
745
|
} else {
|
|
642
746
|
// 小文件批次 - 列出所有文件
|
|
643
747
|
const fileList = batch.items.map(item => item.filePath).join(',');
|
|
644
|
-
logger.info(
|
|
748
|
+
logger.info(t(this.config, 'serial_small_file_batch_info', { index: i + 1, total: batches.length, files: fileList }));
|
|
645
749
|
}
|
|
646
750
|
|
|
647
751
|
const aiIssues = await this.aiClient.analyzeSmartBatch(formattedBatch, batch);
|
|
648
752
|
this.issues.push(...aiIssues);
|
|
649
753
|
|
|
650
|
-
logger.success(
|
|
754
|
+
logger.success(t(this.config, 'batch_done_success', { index: i + 1, total: batches.length }));
|
|
651
755
|
}
|
|
652
756
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
653
757
|
const totalIssues = this.issues.length;
|
|
654
|
-
logger.success(
|
|
758
|
+
logger.success(t(this.config, 'ai_analysis_done_summary', { totalIssues, elapsed }));
|
|
655
759
|
}
|
|
656
760
|
|
|
657
761
|
async getFileContent(filePath) {
|
|
@@ -670,7 +774,7 @@ export class CodeReviewer {
|
|
|
670
774
|
return await this.readFileStream(filePath);
|
|
671
775
|
}
|
|
672
776
|
} catch (error) {
|
|
673
|
-
logger.error(
|
|
777
|
+
logger.error(t(this.config, 'read_file_failed', { file: filePath, error: error?.message || String(error) }));
|
|
674
778
|
return null;
|
|
675
779
|
}
|
|
676
780
|
}
|
|
@@ -692,7 +796,7 @@ export class CodeReviewer {
|
|
|
692
796
|
}
|
|
693
797
|
|
|
694
798
|
// 对于大文件,使用流式读取
|
|
695
|
-
logger.debug(
|
|
799
|
+
logger.debug(t(this.config, 'stream_read_large_file_dbg', { file: filePath, sizeKB: fileSizeKB.toFixed(1) }));
|
|
696
800
|
|
|
697
801
|
const chunks = [];
|
|
698
802
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
@@ -720,8 +824,10 @@ export class CodeReviewer {
|
|
|
720
824
|
let skippedByDirectives = 0;
|
|
721
825
|
for (const rule of this.rules) {
|
|
722
826
|
try {
|
|
723
|
-
//
|
|
724
|
-
|
|
827
|
+
// 保留通用 requiresAbsent 跳过,但当 requiresAbsent 明显是“函数调用样式”时,改为逐匹配的配对校验
|
|
828
|
+
const requiresAbsent = Array.isArray(rule.requiresAbsent) ? rule.requiresAbsent : null;
|
|
829
|
+
const isPairable = !!(requiresAbsent && requiresAbsent.some(s => /\(/.test(String(s))));
|
|
830
|
+
if (!isPairable && requiresAbsent && requiresAbsent.length > 0 && typeof rule.pattern !== 'function') {
|
|
725
831
|
const hasCleanup = rule.requiresAbsent.some(rxStr => {
|
|
726
832
|
try {
|
|
727
833
|
const rx = this.getCachedRegex(rxStr, rule.flags || 'gm');
|
|
@@ -730,7 +836,7 @@ export class CodeReviewer {
|
|
|
730
836
|
return false;
|
|
731
837
|
}
|
|
732
838
|
});
|
|
733
|
-
if (hasCleanup) continue;
|
|
839
|
+
if (hasCleanup) continue;
|
|
734
840
|
}
|
|
735
841
|
|
|
736
842
|
// 简化:pattern 支持函数。若返回片段字符串或字符串数组,则直接使用该片段作为结果;
|
|
@@ -772,6 +878,44 @@ export class CodeReviewer {
|
|
|
772
878
|
// 记录该规则已在哪些代码片段上报过,避免重复片段
|
|
773
879
|
const reportedSnippets = new Set();
|
|
774
880
|
|
|
881
|
+
const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
882
|
+
const findAssignedHandleBefore = (src, mIndex) => {
|
|
883
|
+
const lineStart = src.lastIndexOf('\n', Math.max(0, mIndex - 1)) + 1;
|
|
884
|
+
const left = src.slice(lineStart, mIndex);
|
|
885
|
+
// 通用赋值检测:匹配“标识符或属性” + “= 或 :=”结尾(覆盖多语言常见语法)
|
|
886
|
+
const assignMatch = /([A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)*)\s*(?:=|:=)\s*$/.exec(left);
|
|
887
|
+
if (assignMatch) return assignMatch[1];
|
|
888
|
+
return null;
|
|
889
|
+
};
|
|
890
|
+
const isEffectiveIndex = (idx) => {
|
|
891
|
+
if (this.isIndexInRanges(idx, commentRanges)) return false;
|
|
892
|
+
if (this.isIndexInRanges(idx, disable.suppressRanges || [])) return false;
|
|
893
|
+
return true;
|
|
894
|
+
};
|
|
895
|
+
const buildPairedCleanupRegexes = (rxStr, handle, flags) => {
|
|
896
|
+
const raw = String(rxStr);
|
|
897
|
+
// 仅当 requiresAbsent 为“函数调用样式”时构建带句柄的清理匹配
|
|
898
|
+
if (!/(\(|\\\()/.test(raw)) return [];
|
|
899
|
+
const escapedHandle = escapeRegExp(handle);
|
|
900
|
+
const fFlags = flags || 'gm';
|
|
901
|
+
const out = [];
|
|
902
|
+
// 1) 函数调用样式:fnName( handle )
|
|
903
|
+
try {
|
|
904
|
+
const withHandle = raw.replace(/\\\(/, `\\(\\s*${escapedHandle}\\s*`);
|
|
905
|
+
const patternStr = withHandle.endsWith(')') || /\\\)\s*$/.test(withHandle) ? withHandle : `${withHandle}\\)`;
|
|
906
|
+
out.push(new RegExp(patternStr, fFlags));
|
|
907
|
+
} catch (_) {}
|
|
908
|
+
// 2) 句柄方法样式:handle.fnName( ... )
|
|
909
|
+
try {
|
|
910
|
+
const fnMatch = raw.match(/([A-Za-z_][A-Za-z0-9_.$]*)\s*(?:\\\(|\()/);
|
|
911
|
+
if (fnMatch) {
|
|
912
|
+
const fn = escapeRegExp(fnMatch[1]);
|
|
913
|
+
out.push(new RegExp(`${escapedHandle}\\s*\\.\\s*${fn}\\s*\\(`, fFlags));
|
|
914
|
+
}
|
|
915
|
+
} catch (_) {}
|
|
916
|
+
return out;
|
|
917
|
+
};
|
|
918
|
+
|
|
775
919
|
while ((match = regex.exec(content)) !== null) {
|
|
776
920
|
// 若匹配位置在注释中,跳过
|
|
777
921
|
if (this.isIndexInRanges(match.index, commentRanges)) {
|
|
@@ -783,6 +927,32 @@ export class CodeReviewer {
|
|
|
783
927
|
skippedByDirectives++;
|
|
784
928
|
continue;
|
|
785
929
|
}
|
|
930
|
+
// 当规则的 requiresAbsent 为“函数调用样式”时:逐匹配校验是否存在针对同一句柄的清理
|
|
931
|
+
if (isPairable && requiresAbsent && requiresAbsent.length > 0) {
|
|
932
|
+
const handle = findAssignedHandleBefore(content, match.index);
|
|
933
|
+
let cleaned = false;
|
|
934
|
+
if (handle) {
|
|
935
|
+
for (const rxStr of requiresAbsent) {
|
|
936
|
+
const pairedRegexes = buildPairedCleanupRegexes(rxStr, handle, rule.flags);
|
|
937
|
+
if (!pairedRegexes || pairedRegexes.length === 0) continue;
|
|
938
|
+
for (const pairedRx of pairedRegexes) {
|
|
939
|
+
let cm;
|
|
940
|
+
while ((cm = pairedRx.exec(content)) !== null) {
|
|
941
|
+
if (isEffectiveIndex(cm.index)) { cleaned = true; break; }
|
|
942
|
+
}
|
|
943
|
+
if (cleaned) break;
|
|
944
|
+
}
|
|
945
|
+
if (cleaned) break;
|
|
946
|
+
}
|
|
947
|
+
} else {
|
|
948
|
+
// 未保存返回句柄则视为未清理风险(无法对应清理目标)
|
|
949
|
+
cleaned = false;
|
|
950
|
+
}
|
|
951
|
+
if (cleaned) {
|
|
952
|
+
continue; // 已对同一句柄执行清理,不报警
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
786
956
|
const lineNumber = this.getLineNumber(content, match.index);
|
|
787
957
|
const snippetText = (match[0] || '').substring(0, BATCH_CONSTANTS.MAX_SNIPPET_LENGTH);
|
|
788
958
|
|
|
@@ -804,15 +974,15 @@ export class CodeReviewer {
|
|
|
804
974
|
});
|
|
805
975
|
}
|
|
806
976
|
} catch (error) {
|
|
807
|
-
logger.warn(
|
|
977
|
+
logger.warn(t(this.config, 'rule_exec_failed_warn', { id: rule.id, error: error?.message || String(error) }));
|
|
808
978
|
}
|
|
809
979
|
}
|
|
810
980
|
|
|
811
981
|
if (skippedByComments > 0) {
|
|
812
|
-
logger.debug(
|
|
982
|
+
logger.debug(t(this.config, 'skip_by_comments_dbg', { count: skippedByComments }));
|
|
813
983
|
}
|
|
814
984
|
if (skippedByDirectives > 0) {
|
|
815
|
-
logger.debug(
|
|
985
|
+
logger.debug(t(this.config, 'skip_by_directives_dbg', { count: skippedByDirectives }));
|
|
816
986
|
}
|
|
817
987
|
|
|
818
988
|
return issues;
|
|
@@ -876,7 +1046,7 @@ export class CodeReviewer {
|
|
|
876
1046
|
if (!shouldInclude) {
|
|
877
1047
|
return {
|
|
878
1048
|
reviewable: false,
|
|
879
|
-
reason:
|
|
1049
|
+
reason: t(this.config, 'file_ext_not_supported_reason', { ext }),
|
|
880
1050
|
matchedPattern: null
|
|
881
1051
|
};
|
|
882
1052
|
}
|
|
@@ -897,7 +1067,7 @@ export class CodeReviewer {
|
|
|
897
1067
|
if (originalPattern === normalized || originalPattern === relativePath || originalPattern === basename) {
|
|
898
1068
|
return {
|
|
899
1069
|
reviewable: false,
|
|
900
|
-
reason: '
|
|
1070
|
+
reason: t(this.config, 'file_reason_exact_match'),
|
|
901
1071
|
matchedPattern: originalPattern
|
|
902
1072
|
};
|
|
903
1073
|
}
|
|
@@ -911,7 +1081,7 @@ export class CodeReviewer {
|
|
|
911
1081
|
if (normalizedMatch || relativeMatch) {
|
|
912
1082
|
return {
|
|
913
1083
|
reviewable: false,
|
|
914
|
-
reason: '
|
|
1084
|
+
reason: t(this.config, 'file_reason_regex_match'),
|
|
915
1085
|
matchedPattern: originalPattern
|
|
916
1086
|
};
|
|
917
1087
|
}
|
|
@@ -925,7 +1095,7 @@ export class CodeReviewer {
|
|
|
925
1095
|
if (this.matchPattern(normalized, patternStr) || this.matchPattern(relativePath, patternStr)) {
|
|
926
1096
|
return {
|
|
927
1097
|
reviewable: false,
|
|
928
|
-
reason: '
|
|
1098
|
+
reason: t(this.config, 'file_reason_glob_match'),
|
|
929
1099
|
matchedPattern: originalPattern
|
|
930
1100
|
};
|
|
931
1101
|
}
|
|
@@ -1014,7 +1184,7 @@ export class CodeReviewer {
|
|
|
1014
1184
|
|
|
1015
1185
|
// 检查文件大小限制
|
|
1016
1186
|
if (content.length > (this.config.ai?.maxFileSizeKB || DEFAULT_CONFIG.MAX_FILE_SIZE_KB) * 1024) {
|
|
1017
|
-
logger.info(
|
|
1187
|
+
logger.info(t(this.config, 'skip_ai_large_file', { file: filePath }));
|
|
1018
1188
|
return false;
|
|
1019
1189
|
}
|
|
1020
1190
|
|