smart-review 1.0.1 → 1.0.3
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 +419 -280
- 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 +42 -32
- package/lib/reviewer.js +289 -97
- package/lib/segmented-analyzer.js +102 -126
- package/lib/utils/git-diff-parser.js +9 -8
- package/lib/utils/i18n.js +980 -0
- package/package.json +2 -10
- package/templates/rules/en-US/best-practices.js +123 -0
- package/templates/rules/en-US/performance.js +136 -0
- package/templates/rules/en-US/security.js +345 -0
- package/templates/rules/zh-CN/best-practices.js +123 -0
- package/templates/rules/zh-CN/performance.js +136 -0
- package/templates/rules/zh-CN/security.js +345 -0
- package/templates/smart-review.json +5 -2
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
|
|
|
@@ -320,6 +326,17 @@ export class CodeReviewer {
|
|
|
320
326
|
const commentRanges = this.getCachedCommentRanges(addedLinesContent, ext);
|
|
321
327
|
|
|
322
328
|
for (const rule of this.rules) {
|
|
329
|
+
// 扩展名过滤:若规则声明了 extensions,则仅在匹配的扩展上生效
|
|
330
|
+
const ruleExts = Array.isArray(rule.extensions) ? rule.extensions : null;
|
|
331
|
+
if (ruleExts && ruleExts.length > 0) {
|
|
332
|
+
const normalized = ruleExts.map((e) => {
|
|
333
|
+
const s = String(e).trim().toLowerCase();
|
|
334
|
+
return s.startsWith('.') ? s : `.${s}`;
|
|
335
|
+
});
|
|
336
|
+
if (!normalized.includes(ext)) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
323
340
|
try {
|
|
324
341
|
// 函数类型规则处理
|
|
325
342
|
if (typeof rule.pattern === 'function') {
|
|
@@ -359,7 +376,61 @@ export class CodeReviewer {
|
|
|
359
376
|
const regex = this.getCachedRegex(rule.pattern, rule.flags || 'gm');
|
|
360
377
|
let match;
|
|
361
378
|
const reportedSnippets = new Set();
|
|
362
|
-
|
|
379
|
+
const requiresAbsent = Array.isArray(rule.requiresAbsent) ? rule.requiresAbsent : null;
|
|
380
|
+
const isPairable = !!(requiresAbsent && requiresAbsent.some(s => /\(/.test(String(s))));
|
|
381
|
+
const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
382
|
+
const findAssignedHandleBefore = (src, mIndex) => {
|
|
383
|
+
const lineStart = src.lastIndexOf('\n', Math.max(0, mIndex - 1)) + 1;
|
|
384
|
+
const left = src.slice(lineStart, mIndex);
|
|
385
|
+
const assignMatch = /([A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)*)\s*(?:=|:=)\s*$/.exec(left);
|
|
386
|
+
if (assignMatch) return assignMatch[1];
|
|
387
|
+
return null;
|
|
388
|
+
};
|
|
389
|
+
const isEffectiveIndex = (idx) => {
|
|
390
|
+
if (this.isIndexInRanges(idx, commentRanges)) return false;
|
|
391
|
+
if (this.isIndexInRanges(idx, disableRanges.suppressRanges || [])) return false;
|
|
392
|
+
return true;
|
|
393
|
+
};
|
|
394
|
+
const buildPairedCleanupRegexes = (rxStr, handle, flags) => {
|
|
395
|
+
const raw = String(rxStr);
|
|
396
|
+
if (!/(\(|\\\()/.test(raw)) return [];
|
|
397
|
+
const escapedHandle = escapeRegExp(handle);
|
|
398
|
+
const fFlags = flags || 'gm';
|
|
399
|
+
const out = [];
|
|
400
|
+
// 1) 函数调用样式:fnName( handle )
|
|
401
|
+
try {
|
|
402
|
+
const withHandle = raw.replace(/\\\(/, `\\(\\s*${escapedHandle}\\s*`);
|
|
403
|
+
const patternStr = withHandle.endsWith(')') || /\\\)\s*$/.test(withHandle) ? withHandle : `${withHandle}\\)`;
|
|
404
|
+
out.push(new RegExp(patternStr, fFlags));
|
|
405
|
+
} catch (_) {}
|
|
406
|
+
// 2) 句柄方法样式:handle.fnName(...)
|
|
407
|
+
try {
|
|
408
|
+
const fnMatch = raw.match(/([A-Za-z_][A-Za-z0-9_.$]*)\s*(?:\\\(|\()/);
|
|
409
|
+
if (fnMatch) {
|
|
410
|
+
const fn = escapeRegExp(fnMatch[1]);
|
|
411
|
+
out.push(new RegExp(`${escapedHandle}\\s*\\.\\s*${fn}\\s*\\(`, fFlags));
|
|
412
|
+
}
|
|
413
|
+
} catch (_) {}
|
|
414
|
+
return out;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// 非配对规则:若 requiresAbsent 存在且在新增内容中命中有效清理,则跳过该规则在此段
|
|
418
|
+
if (!isPairable && requiresAbsent && requiresAbsent.length > 0) {
|
|
419
|
+
let hasCleanup = false;
|
|
420
|
+
for (const rxStr of requiresAbsent) {
|
|
421
|
+
try {
|
|
422
|
+
const rx = this.getCachedRegex(rxStr, rule.flags || 'gm');
|
|
423
|
+
let cm;
|
|
424
|
+
while ((cm = rx.exec(addedLinesContent)) !== null) {
|
|
425
|
+
if (isEffectiveIndex(cm.index)) { hasCleanup = true; break; }
|
|
426
|
+
}
|
|
427
|
+
} catch (_) {}
|
|
428
|
+
if (hasCleanup) break;
|
|
429
|
+
}
|
|
430
|
+
if (hasCleanup) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
363
434
|
while ((match = regex.exec(addedLinesContent)) !== null) {
|
|
364
435
|
// 检查匹配位置是否在注释中
|
|
365
436
|
if (this.isIndexInRanges(match.index, commentRanges)) {
|
|
@@ -371,6 +442,29 @@ export class CodeReviewer {
|
|
|
371
442
|
continue;
|
|
372
443
|
}
|
|
373
444
|
|
|
445
|
+
// 当规则的 requiresAbsent 是“函数调用样式”时:对每个匹配执行句柄级清理配对
|
|
446
|
+
if (isPairable && requiresAbsent && requiresAbsent.length > 0) {
|
|
447
|
+
const handle = findAssignedHandleBefore(addedLinesContent, match.index);
|
|
448
|
+
let cleaned = false;
|
|
449
|
+
if (handle) {
|
|
450
|
+
for (const rxStr of requiresAbsent) {
|
|
451
|
+
const pairedRegexes = buildPairedCleanupRegexes(rxStr, handle, rule.flags);
|
|
452
|
+
if (!pairedRegexes || pairedRegexes.length === 0) continue;
|
|
453
|
+
for (const pairedRx of pairedRegexes) {
|
|
454
|
+
let cm;
|
|
455
|
+
while ((cm = pairedRx.exec(addedLinesContent)) !== null) {
|
|
456
|
+
if (isEffectiveIndex(cm.index)) { cleaned = true; break; }
|
|
457
|
+
}
|
|
458
|
+
if (cleaned) break;
|
|
459
|
+
}
|
|
460
|
+
if (cleaned) break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (cleaned) {
|
|
464
|
+
continue; // 已对同一句柄执行清理,不报警
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
374
468
|
const snippetText = (match[0] || '').substring(0, 200); // 限制片段长度
|
|
375
469
|
|
|
376
470
|
if (reportedSnippets.has(snippetText)) {
|
|
@@ -399,7 +493,7 @@ export class CodeReviewer {
|
|
|
399
493
|
}
|
|
400
494
|
}
|
|
401
495
|
} catch (error) {
|
|
402
|
-
logger.warn(
|
|
496
|
+
logger.warn(t(this.config, 'rule_diff_exec_failed_warn', { id: rule.id, error: error?.message || String(error) }));
|
|
403
497
|
}
|
|
404
498
|
}
|
|
405
499
|
}
|
|
@@ -426,33 +520,54 @@ export class CodeReviewer {
|
|
|
426
520
|
return addedLines;
|
|
427
521
|
}
|
|
428
522
|
|
|
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
|
-
|
|
523
|
+
/**
|
|
524
|
+
* 从段落中提取新增行内容和行号映射
|
|
525
|
+
* @param {Object} segment 代码段
|
|
526
|
+
* @returns {Object} { addedLinesContent: string, lineMapping: Array, originalAddedLinesContent: string }
|
|
527
|
+
*/
|
|
528
|
+
extractAddedLinesFromSegment(segment) {
|
|
529
|
+
const lines = segment.content.split('\n');
|
|
530
|
+
const addedLinesOriginal = [];
|
|
531
|
+
const addedLinesNormalized = [];
|
|
532
|
+
const lineMapping = []; // 新内容行号(1-based) -> 原文件行号
|
|
533
|
+
const bracketRe = /^\s*\[\s*(\d+)\s*\]\s*/; // 解析类似 "[2067] " 的前缀
|
|
534
|
+
let plusIdx = 0; // 计数第几条新增行
|
|
535
|
+
|
|
536
|
+
for (let i = 0; i < lines.length; i++) {
|
|
537
|
+
const line = lines[i];
|
|
538
|
+
// 只提取新增行(以+开头的行)
|
|
539
|
+
if (line.startsWith('+')) {
|
|
540
|
+
const withoutPlus = line.substring(1);
|
|
541
|
+
addedLinesOriginal.push(withoutPlus);
|
|
542
|
+
|
|
543
|
+
// 优先使用 segment.addedLineNumbers 提供的真实文件行号
|
|
544
|
+
let actualLine = Array.isArray(segment.addedLineNumbers) && plusIdx < segment.addedLineNumbers.length
|
|
545
|
+
? segment.addedLineNumbers[plusIdx]
|
|
546
|
+
: undefined;
|
|
547
|
+
// 若未提供,则尝试从 [行号] 前缀中解析
|
|
548
|
+
if (!Number.isFinite(actualLine)) {
|
|
549
|
+
const m = withoutPlus.match(bracketRe);
|
|
550
|
+
if (m) actualLine = parseInt(m[1], 10);
|
|
551
|
+
}
|
|
552
|
+
// 兜底:用段起始行近似(不理想,但避免 undefined)
|
|
553
|
+
if (!Number.isFinite(actualLine)) {
|
|
554
|
+
actualLine = Number.isFinite(segment?.startLine) ? (segment.startLine + plusIdx) : undefined;
|
|
555
|
+
}
|
|
556
|
+
lineMapping.push(actualLine);
|
|
454
557
|
|
|
558
|
+
// 生成用于匹配的规范化行:移除 [行号] 前缀
|
|
559
|
+
const normalized = withoutPlus.replace(bracketRe, '');
|
|
560
|
+
addedLinesNormalized.push(normalized);
|
|
561
|
+
plusIdx++;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
455
564
|
|
|
565
|
+
return {
|
|
566
|
+
addedLinesContent: addedLinesNormalized.join('\n'),
|
|
567
|
+
lineMapping,
|
|
568
|
+
originalAddedLinesContent: addedLinesOriginal.join('\n')
|
|
569
|
+
};
|
|
570
|
+
}
|
|
456
571
|
|
|
457
572
|
// 批量感知:优先批量AI分析(方案A),同时保留静态规则与阻断判定
|
|
458
573
|
async reviewFilesBatchAware(filePaths) {
|
|
@@ -463,11 +578,11 @@ export class CodeReviewer {
|
|
|
463
578
|
|
|
464
579
|
for (const file of filePaths) {
|
|
465
580
|
if (!this.isReviewableFile(file)) {
|
|
466
|
-
logger.info(
|
|
581
|
+
logger.info(t(this.config, 'file_skipped_by_type', { path: file }));
|
|
467
582
|
continue;
|
|
468
583
|
}
|
|
469
584
|
const relativePath = path.relative(this.config.projectRoot, file);
|
|
470
|
-
logger.debug(
|
|
585
|
+
logger.debug(t(this.config, 'review_file_dbg', { file: relativePath }));
|
|
471
586
|
|
|
472
587
|
try {
|
|
473
588
|
const content = await this.getFileContent(file);
|
|
@@ -493,19 +608,19 @@ export class CodeReviewer {
|
|
|
493
608
|
aiEligible.push({ filePath: file, content, staticIssues: contextStatic });
|
|
494
609
|
}
|
|
495
610
|
} catch (error) {
|
|
496
|
-
logger.error(
|
|
611
|
+
logger.error(t(this.config, 'review_file_failed', { file, error: error?.message || String(error) }));
|
|
497
612
|
}
|
|
498
613
|
}
|
|
499
614
|
// 若任意文件存在阻断等级问题,直接返回(跳过AI)
|
|
500
615
|
if (anyBlocking) {
|
|
501
|
-
logger.info('
|
|
616
|
+
logger.info(t(this.config, 'blocking_risk_skip_all_ai_info'));
|
|
502
617
|
return;
|
|
503
618
|
}
|
|
504
619
|
|
|
505
620
|
if (aiEligible.length === 0) return;
|
|
506
621
|
|
|
507
622
|
// 使用增量式分析器进行分析(默认行为)
|
|
508
|
-
logger.progress('
|
|
623
|
+
logger.progress(t(this.config, 'using_incremental_analyzer_progress'));
|
|
509
624
|
await this.reviewFilesWithIncrementalAnalyzer(aiEligible);
|
|
510
625
|
}
|
|
511
626
|
|
|
@@ -515,7 +630,7 @@ export class CodeReviewer {
|
|
|
515
630
|
await this.reviewFilesWithSmartBatching(aiEligible);
|
|
516
631
|
|
|
517
632
|
} catch (error) {
|
|
518
|
-
logger.error(
|
|
633
|
+
logger.error(t(this.config, 'incremental_analyzer_failed_error', { error: error?.message || String(error) }));
|
|
519
634
|
throw error;
|
|
520
635
|
}
|
|
521
636
|
}
|
|
@@ -533,7 +648,7 @@ export class CodeReviewer {
|
|
|
533
648
|
chunkOverlapLines: batchCfg.chunkOverlapLines || 5
|
|
534
649
|
});
|
|
535
650
|
|
|
536
|
-
logger.progress('
|
|
651
|
+
logger.progress(t(this.config, 'ai_smart_analysis_start_progress'));
|
|
537
652
|
const batchResult = smartBatching.createSmartBatches(aiEligible);
|
|
538
653
|
|
|
539
654
|
if (this.useConcurrency && this.aiClientPool) {
|
|
@@ -547,10 +662,10 @@ export class CodeReviewer {
|
|
|
547
662
|
|
|
548
663
|
this.aiRan = true;
|
|
549
664
|
} catch (error) {
|
|
550
|
-
logger.error('
|
|
665
|
+
logger.error(t(this.config, 'smart_batching_process_error', { error: error?.message || String(error) }));
|
|
551
666
|
|
|
552
667
|
// 回退到原有的简单分批方式
|
|
553
|
-
logger.progress('
|
|
668
|
+
logger.progress(t(this.config, 'fallback_simple_analysis_progress'));
|
|
554
669
|
try {
|
|
555
670
|
const max = Number(batchCfg.maxFilesPerBatch || 20);
|
|
556
671
|
const batches = [];
|
|
@@ -558,18 +673,18 @@ export class CodeReviewer {
|
|
|
558
673
|
batches.push(aiEligible.slice(i, i + max));
|
|
559
674
|
}
|
|
560
675
|
|
|
561
|
-
logger.info(
|
|
676
|
+
logger.info(t(this.config, 'ai_analysis_start_batches', { count: batches.length }));
|
|
562
677
|
|
|
563
678
|
for (let i = 0; i < batches.length; i++) {
|
|
564
679
|
const batch = batches[i];
|
|
565
|
-
logger.info(
|
|
680
|
+
logger.info(t(this.config, 'batch_info_header', { index: i + 1, total: batches.length, files: batch.length }));
|
|
566
681
|
const aiIssues = await this.aiClient.analyzeFilesBatch(batch);
|
|
567
682
|
this.issues.push(...aiIssues);
|
|
568
|
-
logger.success(
|
|
683
|
+
logger.success(t(this.config, 'batch_done_success', { index: i + 1, total: batches.length }));
|
|
569
684
|
}
|
|
570
685
|
this.aiRan = true;
|
|
571
686
|
} catch (fallbackError) {
|
|
572
|
-
logger.error('
|
|
687
|
+
logger.error(t(this.config, 'fallback_analysis_failed', { error: fallbackError?.message || String(fallbackError) }));
|
|
573
688
|
}
|
|
574
689
|
}
|
|
575
690
|
}
|
|
@@ -582,8 +697,8 @@ export class CodeReviewer {
|
|
|
582
697
|
async processBatchesConcurrently(batches, smartBatching) {
|
|
583
698
|
// 只有当批次数量大于1且并发数量大于1时才显示并发处理日志
|
|
584
699
|
if (batches.length > 1 && this.concurrency > 1) {
|
|
585
|
-
logger.info(
|
|
586
|
-
logger.info(
|
|
700
|
+
logger.info(t(this.config, 'start_concurrency_enabled_info', { concurrency: this.concurrency }));
|
|
701
|
+
logger.info(t(this.config, 'start_concurrency_dbg', { count: batches.length }));
|
|
587
702
|
}
|
|
588
703
|
|
|
589
704
|
// 创建进度跟踪器
|
|
@@ -600,7 +715,7 @@ export class CodeReviewer {
|
|
|
600
715
|
progressTracker.completed++;
|
|
601
716
|
} else if (status === 'failed') {
|
|
602
717
|
progressTracker.failed++;
|
|
603
|
-
logger.warn(
|
|
718
|
+
logger.warn(t(this.config, 'concurrent_group_failed', { index: batchIndex + 1, error: error?.message || String(error) }));
|
|
604
719
|
}
|
|
605
720
|
};
|
|
606
721
|
|
|
@@ -613,10 +728,10 @@ export class CodeReviewer {
|
|
|
613
728
|
|
|
614
729
|
const elapsed = ((totalDurationMs) / 1000).toFixed(1);
|
|
615
730
|
const totalIssues = allIssues.length;
|
|
616
|
-
logger.success(
|
|
731
|
+
logger.success(t(this.config, 'ai_analysis_done_summary', { totalIssues, elapsed }));
|
|
617
732
|
|
|
618
733
|
} catch (error) {
|
|
619
|
-
logger.error(
|
|
734
|
+
logger.error(t(this.config, 'concurrent_processing_error', { error: error?.message || String(error) }));
|
|
620
735
|
throw error;
|
|
621
736
|
}
|
|
622
737
|
}
|
|
@@ -627,7 +742,7 @@ export class CodeReviewer {
|
|
|
627
742
|
* @param {SmartBatching} smartBatching - 智能分批实例
|
|
628
743
|
*/
|
|
629
744
|
async processBatchesSerially(batches, smartBatching) {
|
|
630
|
-
logger.info(
|
|
745
|
+
logger.info(t(this.config, 'serial_process_batches_info', { count: batches.length }));
|
|
631
746
|
const startTime = Date.now();
|
|
632
747
|
|
|
633
748
|
for (let i = 0; i < batches.length; i++) {
|
|
@@ -637,21 +752,21 @@ export class CodeReviewer {
|
|
|
637
752
|
// 判断批次类型并输出相应信息
|
|
638
753
|
if (batch.isLargeFileSegment) {
|
|
639
754
|
// 大文件批次 - 显示文件路径和总段数
|
|
640
|
-
logger.info(
|
|
755
|
+
logger.info(t(this.config, 'serial_large_file_batch_info', { index: i + 1, total: batches.length, file: batch.segmentedFile, segments: batch.totalSegments }));
|
|
641
756
|
} else {
|
|
642
757
|
// 小文件批次 - 列出所有文件
|
|
643
758
|
const fileList = batch.items.map(item => item.filePath).join(',');
|
|
644
|
-
logger.info(
|
|
759
|
+
logger.info(t(this.config, 'serial_small_file_batch_info', { index: i + 1, total: batches.length, files: fileList }));
|
|
645
760
|
}
|
|
646
761
|
|
|
647
762
|
const aiIssues = await this.aiClient.analyzeSmartBatch(formattedBatch, batch);
|
|
648
763
|
this.issues.push(...aiIssues);
|
|
649
764
|
|
|
650
|
-
logger.success(
|
|
765
|
+
logger.success(t(this.config, 'batch_done_success', { index: i + 1, total: batches.length }));
|
|
651
766
|
}
|
|
652
767
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
653
768
|
const totalIssues = this.issues.length;
|
|
654
|
-
logger.success(
|
|
769
|
+
logger.success(t(this.config, 'ai_analysis_done_summary', { totalIssues, elapsed }));
|
|
655
770
|
}
|
|
656
771
|
|
|
657
772
|
async getFileContent(filePath) {
|
|
@@ -670,7 +785,7 @@ export class CodeReviewer {
|
|
|
670
785
|
return await this.readFileStream(filePath);
|
|
671
786
|
}
|
|
672
787
|
} catch (error) {
|
|
673
|
-
logger.error(
|
|
788
|
+
logger.error(t(this.config, 'read_file_failed', { file: filePath, error: error?.message || String(error) }));
|
|
674
789
|
return null;
|
|
675
790
|
}
|
|
676
791
|
}
|
|
@@ -692,7 +807,7 @@ export class CodeReviewer {
|
|
|
692
807
|
}
|
|
693
808
|
|
|
694
809
|
// 对于大文件,使用流式读取
|
|
695
|
-
logger.debug(
|
|
810
|
+
logger.debug(t(this.config, 'stream_read_large_file_dbg', { file: filePath, sizeKB: fileSizeKB.toFixed(1) }));
|
|
696
811
|
|
|
697
812
|
const chunks = [];
|
|
698
813
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
@@ -719,9 +834,22 @@ export class CodeReviewer {
|
|
|
719
834
|
let skippedByComments = 0;
|
|
720
835
|
let skippedByDirectives = 0;
|
|
721
836
|
for (const rule of this.rules) {
|
|
837
|
+
// 扩展名过滤:若规则声明了 extensions,则仅在匹配的扩展上生效
|
|
838
|
+
const ruleExts = Array.isArray(rule.extensions) ? rule.extensions : null;
|
|
839
|
+
if (ruleExts && ruleExts.length > 0) {
|
|
840
|
+
const normalized = ruleExts.map((e) => {
|
|
841
|
+
const s = String(e).trim().toLowerCase();
|
|
842
|
+
return s.startsWith('.') ? s : `.${s}`;
|
|
843
|
+
});
|
|
844
|
+
if (!normalized.includes(ext)) {
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
722
848
|
try {
|
|
723
|
-
//
|
|
724
|
-
|
|
849
|
+
// 保留通用 requiresAbsent 跳过,但当 requiresAbsent 明显是“函数调用样式”时,改为逐匹配的配对校验
|
|
850
|
+
const requiresAbsent = Array.isArray(rule.requiresAbsent) ? rule.requiresAbsent : null;
|
|
851
|
+
const isPairable = !!(requiresAbsent && requiresAbsent.some(s => /\(/.test(String(s))));
|
|
852
|
+
if (!isPairable && requiresAbsent && requiresAbsent.length > 0 && typeof rule.pattern !== 'function') {
|
|
725
853
|
const hasCleanup = rule.requiresAbsent.some(rxStr => {
|
|
726
854
|
try {
|
|
727
855
|
const rx = this.getCachedRegex(rxStr, rule.flags || 'gm');
|
|
@@ -730,7 +858,7 @@ export class CodeReviewer {
|
|
|
730
858
|
return false;
|
|
731
859
|
}
|
|
732
860
|
});
|
|
733
|
-
if (hasCleanup) continue;
|
|
861
|
+
if (hasCleanup) continue;
|
|
734
862
|
}
|
|
735
863
|
|
|
736
864
|
// 简化:pattern 支持函数。若返回片段字符串或字符串数组,则直接使用该片段作为结果;
|
|
@@ -772,6 +900,44 @@ export class CodeReviewer {
|
|
|
772
900
|
// 记录该规则已在哪些代码片段上报过,避免重复片段
|
|
773
901
|
const reportedSnippets = new Set();
|
|
774
902
|
|
|
903
|
+
const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
904
|
+
const findAssignedHandleBefore = (src, mIndex) => {
|
|
905
|
+
const lineStart = src.lastIndexOf('\n', Math.max(0, mIndex - 1)) + 1;
|
|
906
|
+
const left = src.slice(lineStart, mIndex);
|
|
907
|
+
// 通用赋值检测:匹配“标识符或属性” + “= 或 :=”结尾(覆盖多语言常见语法)
|
|
908
|
+
const assignMatch = /([A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)*)\s*(?:=|:=)\s*$/.exec(left);
|
|
909
|
+
if (assignMatch) return assignMatch[1];
|
|
910
|
+
return null;
|
|
911
|
+
};
|
|
912
|
+
const isEffectiveIndex = (idx) => {
|
|
913
|
+
if (this.isIndexInRanges(idx, commentRanges)) return false;
|
|
914
|
+
if (this.isIndexInRanges(idx, disable.suppressRanges || [])) return false;
|
|
915
|
+
return true;
|
|
916
|
+
};
|
|
917
|
+
const buildPairedCleanupRegexes = (rxStr, handle, flags) => {
|
|
918
|
+
const raw = String(rxStr);
|
|
919
|
+
// 仅当 requiresAbsent 为“函数调用样式”时构建带句柄的清理匹配
|
|
920
|
+
if (!/(\(|\\\()/.test(raw)) return [];
|
|
921
|
+
const escapedHandle = escapeRegExp(handle);
|
|
922
|
+
const fFlags = flags || 'gm';
|
|
923
|
+
const out = [];
|
|
924
|
+
// 1) 函数调用样式:fnName( handle )
|
|
925
|
+
try {
|
|
926
|
+
const withHandle = raw.replace(/\\\(/, `\\(\\s*${escapedHandle}\\s*`);
|
|
927
|
+
const patternStr = withHandle.endsWith(')') || /\\\)\s*$/.test(withHandle) ? withHandle : `${withHandle}\\)`;
|
|
928
|
+
out.push(new RegExp(patternStr, fFlags));
|
|
929
|
+
} catch (_) {}
|
|
930
|
+
// 2) 句柄方法样式:handle.fnName( ... )
|
|
931
|
+
try {
|
|
932
|
+
const fnMatch = raw.match(/([A-Za-z_][A-Za-z0-9_.$]*)\s*(?:\\\(|\()/);
|
|
933
|
+
if (fnMatch) {
|
|
934
|
+
const fn = escapeRegExp(fnMatch[1]);
|
|
935
|
+
out.push(new RegExp(`${escapedHandle}\\s*\\.\\s*${fn}\\s*\\(`, fFlags));
|
|
936
|
+
}
|
|
937
|
+
} catch (_) {}
|
|
938
|
+
return out;
|
|
939
|
+
};
|
|
940
|
+
|
|
775
941
|
while ((match = regex.exec(content)) !== null) {
|
|
776
942
|
// 若匹配位置在注释中,跳过
|
|
777
943
|
if (this.isIndexInRanges(match.index, commentRanges)) {
|
|
@@ -783,6 +949,32 @@ export class CodeReviewer {
|
|
|
783
949
|
skippedByDirectives++;
|
|
784
950
|
continue;
|
|
785
951
|
}
|
|
952
|
+
// 当规则的 requiresAbsent 为“函数调用样式”时:逐匹配校验是否存在针对同一句柄的清理
|
|
953
|
+
if (isPairable && requiresAbsent && requiresAbsent.length > 0) {
|
|
954
|
+
const handle = findAssignedHandleBefore(content, match.index);
|
|
955
|
+
let cleaned = false;
|
|
956
|
+
if (handle) {
|
|
957
|
+
for (const rxStr of requiresAbsent) {
|
|
958
|
+
const pairedRegexes = buildPairedCleanupRegexes(rxStr, handle, rule.flags);
|
|
959
|
+
if (!pairedRegexes || pairedRegexes.length === 0) continue;
|
|
960
|
+
for (const pairedRx of pairedRegexes) {
|
|
961
|
+
let cm;
|
|
962
|
+
while ((cm = pairedRx.exec(content)) !== null) {
|
|
963
|
+
if (isEffectiveIndex(cm.index)) { cleaned = true; break; }
|
|
964
|
+
}
|
|
965
|
+
if (cleaned) break;
|
|
966
|
+
}
|
|
967
|
+
if (cleaned) break;
|
|
968
|
+
}
|
|
969
|
+
} else {
|
|
970
|
+
// 未保存返回句柄则视为未清理风险(无法对应清理目标)
|
|
971
|
+
cleaned = false;
|
|
972
|
+
}
|
|
973
|
+
if (cleaned) {
|
|
974
|
+
continue; // 已对同一句柄执行清理,不报警
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
786
978
|
const lineNumber = this.getLineNumber(content, match.index);
|
|
787
979
|
const snippetText = (match[0] || '').substring(0, BATCH_CONSTANTS.MAX_SNIPPET_LENGTH);
|
|
788
980
|
|
|
@@ -804,15 +996,15 @@ export class CodeReviewer {
|
|
|
804
996
|
});
|
|
805
997
|
}
|
|
806
998
|
} catch (error) {
|
|
807
|
-
logger.warn(
|
|
999
|
+
logger.warn(t(this.config, 'rule_exec_failed_warn', { id: rule.id, error: error?.message || String(error) }));
|
|
808
1000
|
}
|
|
809
1001
|
}
|
|
810
1002
|
|
|
811
1003
|
if (skippedByComments > 0) {
|
|
812
|
-
logger.debug(
|
|
1004
|
+
logger.debug(t(this.config, 'skip_by_comments_dbg', { count: skippedByComments }));
|
|
813
1005
|
}
|
|
814
1006
|
if (skippedByDirectives > 0) {
|
|
815
|
-
logger.debug(
|
|
1007
|
+
logger.debug(t(this.config, 'skip_by_directives_dbg', { count: skippedByDirectives }));
|
|
816
1008
|
}
|
|
817
1009
|
|
|
818
1010
|
return issues;
|
|
@@ -876,7 +1068,7 @@ export class CodeReviewer {
|
|
|
876
1068
|
if (!shouldInclude) {
|
|
877
1069
|
return {
|
|
878
1070
|
reviewable: false,
|
|
879
|
-
reason:
|
|
1071
|
+
reason: t(this.config, 'file_ext_not_supported_reason', { ext }),
|
|
880
1072
|
matchedPattern: null
|
|
881
1073
|
};
|
|
882
1074
|
}
|
|
@@ -897,7 +1089,7 @@ export class CodeReviewer {
|
|
|
897
1089
|
if (originalPattern === normalized || originalPattern === relativePath || originalPattern === basename) {
|
|
898
1090
|
return {
|
|
899
1091
|
reviewable: false,
|
|
900
|
-
reason: '
|
|
1092
|
+
reason: t(this.config, 'file_reason_exact_match'),
|
|
901
1093
|
matchedPattern: originalPattern
|
|
902
1094
|
};
|
|
903
1095
|
}
|
|
@@ -911,7 +1103,7 @@ export class CodeReviewer {
|
|
|
911
1103
|
if (normalizedMatch || relativeMatch) {
|
|
912
1104
|
return {
|
|
913
1105
|
reviewable: false,
|
|
914
|
-
reason: '
|
|
1106
|
+
reason: t(this.config, 'file_reason_regex_match'),
|
|
915
1107
|
matchedPattern: originalPattern
|
|
916
1108
|
};
|
|
917
1109
|
}
|
|
@@ -925,7 +1117,7 @@ export class CodeReviewer {
|
|
|
925
1117
|
if (this.matchPattern(normalized, patternStr) || this.matchPattern(relativePath, patternStr)) {
|
|
926
1118
|
return {
|
|
927
1119
|
reviewable: false,
|
|
928
|
-
reason: '
|
|
1120
|
+
reason: t(this.config, 'file_reason_glob_match'),
|
|
929
1121
|
matchedPattern: originalPattern
|
|
930
1122
|
};
|
|
931
1123
|
}
|
|
@@ -1014,7 +1206,7 @@ export class CodeReviewer {
|
|
|
1014
1206
|
|
|
1015
1207
|
// 检查文件大小限制
|
|
1016
1208
|
if (content.length > (this.config.ai?.maxFileSizeKB || DEFAULT_CONFIG.MAX_FILE_SIZE_KB) * 1024) {
|
|
1017
|
-
logger.info(
|
|
1209
|
+
logger.info(t(this.config, 'skip_ai_large_file', { file: filePath }));
|
|
1018
1210
|
return false;
|
|
1019
1211
|
}
|
|
1020
1212
|
|