coderev-cli 1.0.15 → 1.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +542 -219
- package/package.json +1 -1
- package/src/blame.js +247 -0
- package/src/cli.js +202 -170
- package/src/config.js +86 -8
- package/src/reviewer.js +19 -0
package/src/cli.js
CHANGED
|
@@ -21,9 +21,9 @@ program
|
|
|
21
21
|
.option('--base <branch>', 'Base branch for diff (requires --repo)')
|
|
22
22
|
.option('--head <branch>', 'Head branch for diff (requires --repo)')
|
|
23
23
|
.option('-c, --config <path>', 'Path to config file')
|
|
24
|
-
.option('-o, --output <format>', 'Output format (markdown|json|terminal|html)', 'terminal')
|
|
25
|
-
.option('--ci', 'CI mode: exit with non-zero code if issues found')
|
|
26
|
-
.option('--incremental', 'Only review new/changed lines (skip unchanged context)')
|
|
24
|
+
.option('-o, --output <format>', 'Output format (markdown|json|terminal|html)', 'terminal')
|
|
25
|
+
.option('--ci', 'CI mode: exit with non-zero code if issues found')
|
|
26
|
+
.option('--incremental', 'Only review new/changed lines (skip unchanged context)')
|
|
27
27
|
.option('--interactive', 'Interactively review and apply fixes for each issue')
|
|
28
28
|
.option('--pr <ref>', 'GitHub PR to review, e.g. owner/repo#42 or full URL')
|
|
29
29
|
.option('--gl <ref>', 'GitLab MR to review, e.g. owner/repo!42 or full URL')
|
|
@@ -42,6 +42,7 @@ program
|
|
|
42
42
|
.option('--single', 'Use single-agent mode (legacy, no parallel review)')
|
|
43
43
|
.option('--min-confidence <number>', 'Minimum confidence threshold 0-100 (default: 60)', '60')
|
|
44
44
|
.option('--agents <list>', 'Comma-separated agent list: security,bugs,quality')
|
|
45
|
+
.option('--blame', 'Enable git blame context analysis to distinguish new vs pre-existing issues')
|
|
45
46
|
.action(async (options) => {
|
|
46
47
|
try {
|
|
47
48
|
const config = loadConfig(options.config);
|
|
@@ -175,12 +176,13 @@ program
|
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
const result = await reviewDiff(diff, config, {
|
|
178
|
-
noCache: options.noCache === false,
|
|
179
|
+
noCache: options.noCache === false,
|
|
179
180
|
incremental: options.incremental || undefined,
|
|
180
181
|
ignorePattern,
|
|
181
182
|
audit: options.audit || undefined,
|
|
182
183
|
single: options.single || undefined,
|
|
183
184
|
minConfidence: parseInt(options.minConfidence) || undefined,
|
|
185
|
+
blame: options.blame || undefined,
|
|
184
186
|
});
|
|
185
187
|
|
|
186
188
|
let output;
|
|
@@ -261,78 +263,78 @@ program
|
|
|
261
263
|
}
|
|
262
264
|
}
|
|
263
265
|
|
|
264
|
-
// ── Interactive Fix Mode ──
|
|
265
|
-
if (options.interactive && (result.issues || []).length > 0) {
|
|
266
|
-
const { generateFix } = require('./fixer');
|
|
267
|
-
const apiKey = getApiKey(config);
|
|
268
|
-
const readline = require('readline');
|
|
269
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
270
|
-
console.log(chalk.bold('\n🛠 Interactive Fix Mode / 交互式修复模式'));
|
|
271
|
-
console.log(chalk.yellow(' Review each issue and choose to apply a fix or skip.'));
|
|
272
|
-
const allPatches = [];
|
|
273
|
-
const processIssues = async (idx) => {
|
|
274
|
-
if (idx >= (result.issues || []).length) {
|
|
275
|
-
rl.close();
|
|
276
|
-
if (allPatches.length > 0) {
|
|
277
|
-
const patchesPath = require('path').join(require('os').tmpdir(), 'coderev-fixes.patch');
|
|
278
|
-
fs.writeFileSync(patchesPath, allPatches.map(p => p.patch).filter(Boolean).join('\n\n'), 'utf8');
|
|
279
|
-
console.log(chalk.green('\n✔ Fixes saved to: ' + patchesPath));
|
|
280
|
-
console.log(chalk.gray(' Apply with: git apply "' + patchesPath + '"'));
|
|
281
|
-
} else {
|
|
282
|
-
console.log(chalk.blue(' No fixes were applied.'));
|
|
283
|
-
}
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
const issue = result.issues[idx];
|
|
287
|
-
const sevColor = issue.severity === 'high' ? chalk.red : issue.severity === 'medium' ? chalk.yellow : chalk.blue;
|
|
288
|
-
console.log(chalk.bold('\nIssue #' + (idx + 1) + ' of ' + result.issues.length));
|
|
289
|
-
console.log(' ' + sevColor('●') + ' [' + issue.severity + '] [' + issue.type + '] ' + issue.message);
|
|
290
|
-
if (issue.file) console.log(' ' + chalk.gray('File: ') + issue.file + (issue.line ? ':' + issue.line : ''));
|
|
291
|
-
if (issue.suggestion) console.log(' ' + chalk.gray('Suggestion: ') + issue.suggestion);
|
|
292
|
-
console.log(' ' + chalk.gray('Confidence: ') + (issue.confidence || 'N/A'));
|
|
293
|
-
const answer = await new Promise(resolve => {
|
|
294
|
-
rl.question(chalk.cyan(' [a]pply fix / [s]kip / [q]uit > '), resolve);
|
|
295
|
-
});
|
|
296
|
-
const cmd = (answer || '').trim().toLowerCase();
|
|
297
|
-
if (cmd === 'q') {
|
|
298
|
-
rl.close();
|
|
299
|
-
if (allPatches.length > 0) {
|
|
300
|
-
const patchesPath = require('path').join(require('os').tmpdir(), 'coderev-fixes.patch');
|
|
301
|
-
fs.writeFileSync(patchesPath, allPatches.map(p => p.patch).filter(Boolean).join('\n\n'), 'utf8');
|
|
302
|
-
console.log(chalk.green('\n✔ ' + allPatches.length + ' fixes saved to: ' + patchesPath));
|
|
303
|
-
}
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
if (cmd === 'a') {
|
|
307
|
-
console.error(chalk.blue(' ↻ Generating fix...'));
|
|
308
|
-
try {
|
|
309
|
-
const fixResult = await generateFix(diff, issue, apiKey, config);
|
|
310
|
-
if (fixResult.patch) {
|
|
311
|
-
allPatches.push(fixResult);
|
|
312
|
-
console.log(chalk.green(' ✔ ' + (fixResult.explanation || 'Fix generated')));
|
|
313
|
-
} else {
|
|
314
|
-
console.log(chalk.yellow(' ⚠ Cannot auto-fix: ' + (fixResult.explanation || 'Unknown')));
|
|
315
|
-
}
|
|
316
|
-
} catch (err) {
|
|
317
|
-
console.log(chalk.red(' ✖ Error: ' + err.message));
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
setImmediate(() => processIssues(idx + 1));
|
|
321
|
-
};
|
|
322
|
-
processIssues(0);
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// ── CI Mode ──
|
|
327
|
-
if (options.ci && (result.issues || []).length > 0) {
|
|
328
|
-
const errorIssues = result.issues.filter(i => i.type === 'error');
|
|
329
|
-
const warningIssues = result.issues.filter(i => i.type === 'warning');
|
|
330
|
-
if (errorIssues.length > 0 || warningIssues.length > 0) {
|
|
331
|
-
console.error(chalk.red('✖ CI: Found issues (' + errorIssues.length + ' errors, ' + warningIssues.length + ' warnings)'));
|
|
332
|
-
process.exitCode = 1;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
266
|
+
// ── Interactive Fix Mode ──
|
|
267
|
+
if (options.interactive && (result.issues || []).length > 0) {
|
|
268
|
+
const { generateFix } = require('./fixer');
|
|
269
|
+
const apiKey = getApiKey(config);
|
|
270
|
+
const readline = require('readline');
|
|
271
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
272
|
+
console.log(chalk.bold('\n🛠 Interactive Fix Mode / 交互式修复模式'));
|
|
273
|
+
console.log(chalk.yellow(' Review each issue and choose to apply a fix or skip.'));
|
|
274
|
+
const allPatches = [];
|
|
275
|
+
const processIssues = async (idx) => {
|
|
276
|
+
if (idx >= (result.issues || []).length) {
|
|
277
|
+
rl.close();
|
|
278
|
+
if (allPatches.length > 0) {
|
|
279
|
+
const patchesPath = require('path').join(require('os').tmpdir(), 'coderev-fixes.patch');
|
|
280
|
+
fs.writeFileSync(patchesPath, allPatches.map(p => p.patch).filter(Boolean).join('\n\n'), 'utf8');
|
|
281
|
+
console.log(chalk.green('\n✔ Fixes saved to: ' + patchesPath));
|
|
282
|
+
console.log(chalk.gray(' Apply with: git apply "' + patchesPath + '"'));
|
|
283
|
+
} else {
|
|
284
|
+
console.log(chalk.blue(' No fixes were applied.'));
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const issue = result.issues[idx];
|
|
289
|
+
const sevColor = issue.severity === 'high' ? chalk.red : issue.severity === 'medium' ? chalk.yellow : chalk.blue;
|
|
290
|
+
console.log(chalk.bold('\nIssue #' + (idx + 1) + ' of ' + result.issues.length));
|
|
291
|
+
console.log(' ' + sevColor('●') + ' [' + issue.severity + '] [' + issue.type + '] ' + issue.message);
|
|
292
|
+
if (issue.file) console.log(' ' + chalk.gray('File: ') + issue.file + (issue.line ? ':' + issue.line : ''));
|
|
293
|
+
if (issue.suggestion) console.log(' ' + chalk.gray('Suggestion: ') + issue.suggestion);
|
|
294
|
+
console.log(' ' + chalk.gray('Confidence: ') + (issue.confidence || 'N/A'));
|
|
295
|
+
const answer = await new Promise(resolve => {
|
|
296
|
+
rl.question(chalk.cyan(' [a]pply fix / [s]kip / [q]uit > '), resolve);
|
|
297
|
+
});
|
|
298
|
+
const cmd = (answer || '').trim().toLowerCase();
|
|
299
|
+
if (cmd === 'q') {
|
|
300
|
+
rl.close();
|
|
301
|
+
if (allPatches.length > 0) {
|
|
302
|
+
const patchesPath = require('path').join(require('os').tmpdir(), 'coderev-fixes.patch');
|
|
303
|
+
fs.writeFileSync(patchesPath, allPatches.map(p => p.patch).filter(Boolean).join('\n\n'), 'utf8');
|
|
304
|
+
console.log(chalk.green('\n✔ ' + allPatches.length + ' fixes saved to: ' + patchesPath));
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (cmd === 'a') {
|
|
309
|
+
console.error(chalk.blue(' ↻ Generating fix...'));
|
|
310
|
+
try {
|
|
311
|
+
const fixResult = await generateFix(diff, issue, apiKey, config);
|
|
312
|
+
if (fixResult.patch) {
|
|
313
|
+
allPatches.push(fixResult);
|
|
314
|
+
console.log(chalk.green(' ✔ ' + (fixResult.explanation || 'Fix generated')));
|
|
315
|
+
} else {
|
|
316
|
+
console.log(chalk.yellow(' ⚠ Cannot auto-fix: ' + (fixResult.explanation || 'Unknown')));
|
|
317
|
+
}
|
|
318
|
+
} catch (err) {
|
|
319
|
+
console.log(chalk.red(' ✖ Error: ' + err.message));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
setImmediate(() => processIssues(idx + 1));
|
|
323
|
+
};
|
|
324
|
+
processIssues(0);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── CI Mode ──
|
|
329
|
+
if (options.ci && (result.issues || []).length > 0) {
|
|
330
|
+
const errorIssues = result.issues.filter(i => i.type === 'error');
|
|
331
|
+
const warningIssues = result.issues.filter(i => i.type === 'warning');
|
|
332
|
+
if (errorIssues.length > 0 || warningIssues.length > 0) {
|
|
333
|
+
console.error(chalk.red('✖ CI: Found issues (' + errorIssues.length + ' errors, ' + warningIssues.length + ' warnings)'));
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
336
338
|
console.log(output);
|
|
337
339
|
} catch (err) {
|
|
338
340
|
console.error(chalk.red(`✖ ${err.message}`));
|
|
@@ -659,7 +661,13 @@ program
|
|
|
659
661
|
format: 'terminal',
|
|
660
662
|
includeScore: true,
|
|
661
663
|
},
|
|
664
|
+
inheritance: {
|
|
665
|
+
enabled: true,
|
|
666
|
+
strategy: 'deep-merge', // 'deep-merge' | 'replace'
|
|
667
|
+
},
|
|
662
668
|
};
|
|
669
|
+
console.log(chalk.blue('ℹ 提示: coderev 支持从父目录继承配置。将 .coderevrc.json'));
|
|
670
|
+
console.log(chalk.blue(' 放在项目根目录,子项目可只设覆盖字段。'));
|
|
663
671
|
const configPath = path.join(process.cwd(), '.coderevrc.json');
|
|
664
672
|
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
|
|
665
673
|
console.log(chalk.green(`✔ Default config created at ${configPath}`));
|
|
@@ -747,6 +755,18 @@ function formatTerminal(result) {
|
|
|
747
755
|
cnLines.push('\n' + chalk.bold('👍 好的实践:'));
|
|
748
756
|
for (const p of result.praise) cnLines.push(' ✅ ' + p);
|
|
749
757
|
}
|
|
758
|
+
if (result._blameContext) {
|
|
759
|
+
const bc = result._blameContext;
|
|
760
|
+
cnLines.push('\n' + chalk.bold('Git Blame 上下文分析:'));
|
|
761
|
+
if (bc.error) {
|
|
762
|
+
cnLines.push(' ' + chalk.yellow('⚠ 分析出错: ' + bc.error));
|
|
763
|
+
} else {
|
|
764
|
+
cnLines.push(' ' + chalk.green('● 新增问题: ') + chalk.bold(bc.newIssues));
|
|
765
|
+
cnLines.push(' ' + chalk.gray('○ 已有问题: ') + chalk.bold(bc.preExistingIssues));
|
|
766
|
+
if (bc.unknownIssues > 0) cnLines.push(' ' + chalk.blue('? 无法判断: ') + chalk.bold(bc.unknownIssues));
|
|
767
|
+
cnLines.push(' ' + chalk.cyan(' 分析文件数: ') + bc.filesAnalyzed);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
750
770
|
cnLines.push('\n' + '━'.repeat(50));
|
|
751
771
|
|
|
752
772
|
// English section
|
|
@@ -777,6 +797,18 @@ function formatTerminal(result) {
|
|
|
777
797
|
enLines.push('\n' + chalk.bold('👍 Good Practices:'));
|
|
778
798
|
for (const p of result.praise) enLines.push(' ✅ ' + p);
|
|
779
799
|
}
|
|
800
|
+
if (result._blameContext) {
|
|
801
|
+
const bc = result._blameContext;
|
|
802
|
+
enLines.push('\n' + chalk.bold('Git Blame Context:'));
|
|
803
|
+
if (bc.error) {
|
|
804
|
+
enLines.push(' ' + chalk.yellow('⚠ Error: ' + bc.error));
|
|
805
|
+
} else {
|
|
806
|
+
enLines.push(' ' + chalk.green('● New issues: ') + chalk.bold(bc.newIssues));
|
|
807
|
+
enLines.push(' ' + chalk.gray('○ Pre-existing: ') + chalk.bold(bc.preExistingIssues));
|
|
808
|
+
if (bc.unknownIssues > 0) enLines.push(' ' + chalk.blue('? Unknown: ') + chalk.bold(bc.unknownIssues));
|
|
809
|
+
enLines.push(' ' + chalk.cyan(' Files analyzed: ') + bc.filesAnalyzed);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
780
812
|
enLines.push('\n' + '━'.repeat(50));
|
|
781
813
|
|
|
782
814
|
return cnLines.join('\n') + '\n' + enLines.join('\n');
|
|
@@ -833,97 +865,97 @@ function formatMarkdown(result) {
|
|
|
833
865
|
}
|
|
834
866
|
|
|
835
867
|
return md;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
/**
|
|
840
|
-
* Format review result as HTML report.
|
|
841
|
-
*/
|
|
842
|
-
function formatHtml(result) {
|
|
843
|
-
const sevColors = { high: '#e74c3c', medium: '#f39c12', low: '#3498db' };
|
|
844
|
-
const sevLabels = { high: 'High / 严重', medium: 'Medium / 中等', low: 'Low / 轻微' };
|
|
845
|
-
const typeLabels = { error: 'Error', warning: 'Warning', info: 'Info' };
|
|
846
|
-
|
|
847
|
-
let issuesHtml = '';
|
|
848
|
-
if (result.issues && result.issues.length > 0) {
|
|
849
|
-
for (const issue of result.issues) {
|
|
850
|
-
const color = sevColors[issue.severity] || '#95a5a6';
|
|
851
|
-
issuesHtml += `
|
|
852
|
-
<div class="issue" style="border-left: 4px solid ${color}; margin: 10px 0; padding: 12px; background: #f8f9fa; border-radius: 0 4px 4px 0;">
|
|
853
|
-
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
|
854
|
-
<span style="background: ${color}; color: white; padding: 2px 8px; border-radius: 3px; font-size: 12px; font-weight: bold;">${sevLabels[issue.severity] || issue.severity}</span>
|
|
855
|
-
<span style="background: #e9ecef; padding: 2px 8px; border-radius: 3px; font-size: 12px;">${typeLabels[issue.type] || issue.type}</span>
|
|
856
|
-
${issue.confidence ? `<span style="color: #6c757d; font-size: 12px;">Confidence: ${issue.confidence}/100</span>` : ''}
|
|
857
|
-
</div>
|
|
858
|
-
<div style="font-size: 14px; margin-bottom: 4px;">${issue.message}</div>
|
|
859
|
-
${issue.file ? `<div style="color: #6c757d; font-size: 12px;">📁 ${issue.file}${issue.line ? ':' + issue.line : ''}</div>` : ''}
|
|
860
|
-
${issue.suggestion ? `<div style="color: #27ae60; font-size: 13px; margin-top: 6px; padding: 8px; background: #eafaf1; border-radius: 3px;">💡 ${issue.suggestion}</div>` : ''}
|
|
861
|
-
</div>`;
|
|
862
|
-
}
|
|
863
|
-
} else {
|
|
864
|
-
issuesHtml = '<p style="color: #27ae60; text-align: center; padding: 20px;">✅ No issues found / 未发现问题</p>';
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
let suggestionsHtml = '';
|
|
868
|
-
if (result.suggestions && result.suggestions.length > 0) {
|
|
869
|
-
suggestionsHtml = '<h3>Suggestions / 改进建议</h3><ul>' + result.suggestions.map(s => '<li>' + s + '</li>').join('') + '</ul>';
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
let praiseHtml = '';
|
|
873
|
-
if (result.praise && result.praise.length > 0) {
|
|
874
|
-
praiseHtml = '<h3>👍 Good Practices / 好的实践</h3><ul>' + result.praise.map(p => '<li>✅ ' + p + '</li>').join('') + '</ul>';
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
const scoreVal = result.score || 0;
|
|
878
|
-
const scoreColor = scoreVal >= 80 ? '#27ae60' : scoreVal >= 50 ? '#f39c12' : '#e74c3c';
|
|
879
|
-
const scoreLabel = scoreVal >= 80 ? 'Good / 良好' : scoreVal >= 50 ? 'Needs Improvement / 需改进' : 'Poor / 差';
|
|
880
|
-
|
|
881
|
-
return `<!DOCTYPE html>
|
|
882
|
-
<html lang="zh-CN">
|
|
883
|
-
<head>
|
|
884
|
-
<meta charset="UTF-8">
|
|
885
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
886
|
-
<title>coderev Review Report</title>
|
|
887
|
-
<style>
|
|
888
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #fff; color: #333; }
|
|
889
|
-
h1 { border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
|
890
|
-
.score-card { text-align: center; padding: 30px; background: #f8f9fa; border-radius: 8px; margin: 20px 0; }
|
|
891
|
-
.score-value { font-size: 48px; font-weight: bold; }
|
|
892
|
-
.score-label { font-size: 16px; color: #6c757d; margin-top: 5px; }
|
|
893
|
-
.summary { font-size: 16px; color: #555; margin: 15px 0; padding: 15px; background: #e8f4f8; border-radius: 6px; }
|
|
894
|
-
.stats { display: flex; gap: 20px; justify-content: center; margin: 20px 0; }
|
|
895
|
-
.stat { text-align: center; padding: 15px 25px; background: white; border: 1px solid #dee2e6; border-radius: 6px; }
|
|
896
|
-
.stat-value { font-size: 28px; font-weight: bold; }
|
|
897
|
-
.stat-label { font-size: 12px; color: #6c757d; text-transform: uppercase; }
|
|
898
|
-
h2 { margin-top: 30px; }
|
|
899
|
-
@media (prefers-color-scheme: dark) {
|
|
900
|
-
body { background: #1a1a2e; color: #e0e0e0; }
|
|
901
|
-
.score-card { background: #16213e; }
|
|
902
|
-
.summary { background: #0f3460; }
|
|
903
|
-
.issue { background: #16213e; }
|
|
904
|
-
.stat { background: #16213e; border-color: #0f3460; }
|
|
905
|
-
}
|
|
906
|
-
</style>
|
|
907
|
-
</head>
|
|
908
|
-
<body>
|
|
909
|
-
<h1>📋 coderev Review Report</h1>
|
|
910
|
-
<div class="score-card">
|
|
911
|
-
<div class="score-value" style="color: ${scoreColor}">${scoreVal}/100</div>
|
|
912
|
-
<div class="score-label">${scoreLabel}</div>
|
|
913
|
-
</div>
|
|
914
|
-
${result.summary ? '<div class="summary">📄 ' + result.summary + '</div>' : ''}
|
|
915
|
-
<div class="stats">
|
|
916
|
-
<div class="stat"><div class="stat-value" style="color: #e74c3c">${result.issues ? result.issues.filter(i => i.type === 'error').length : 0}</div><div class="stat-label">Errors</div></div>
|
|
917
|
-
<div class="stat"><div class="stat-value" style="color: #f39c12">${result.issues ? result.issues.filter(i => i.type === 'warning').length : 0}</div><div class="stat-label">Warnings</div></div>
|
|
918
|
-
<div class="stat"><div class="stat-value" style="color: #3498db">${result.issues ? result.issues.filter(i => i.type === 'info').length : 0}</div><div class="stat-label">Info</div></div>
|
|
919
|
-
<div class="stat"><div class="stat-value" style="color: #9b59b6">${result.issues ? result.issues.length : 0}</div><div class="stat-label">Total</div></div>
|
|
920
|
-
</div>
|
|
921
|
-
<h2>Issues / 问题</h2>
|
|
922
|
-
${issuesHtml}
|
|
923
|
-
${suggestionsHtml}
|
|
924
|
-
${praiseHtml}
|
|
925
|
-
<hr style="margin-top: 40px; border: none; border-top: 1px solid #eee;">
|
|
926
|
-
<p style="text-align: center; color: #6c757d; font-size: 12px;">Generated by coderev</p>
|
|
927
|
-
</body>
|
|
928
|
-
</html>`;
|
|
929
|
-
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Format review result as HTML report.
|
|
873
|
+
*/
|
|
874
|
+
function formatHtml(result) {
|
|
875
|
+
const sevColors = { high: '#e74c3c', medium: '#f39c12', low: '#3498db' };
|
|
876
|
+
const sevLabels = { high: 'High / 严重', medium: 'Medium / 中等', low: 'Low / 轻微' };
|
|
877
|
+
const typeLabels = { error: 'Error', warning: 'Warning', info: 'Info' };
|
|
878
|
+
|
|
879
|
+
let issuesHtml = '';
|
|
880
|
+
if (result.issues && result.issues.length > 0) {
|
|
881
|
+
for (const issue of result.issues) {
|
|
882
|
+
const color = sevColors[issue.severity] || '#95a5a6';
|
|
883
|
+
issuesHtml += `
|
|
884
|
+
<div class="issue" style="border-left: 4px solid ${color}; margin: 10px 0; padding: 12px; background: #f8f9fa; border-radius: 0 4px 4px 0;">
|
|
885
|
+
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
|
886
|
+
<span style="background: ${color}; color: white; padding: 2px 8px; border-radius: 3px; font-size: 12px; font-weight: bold;">${sevLabels[issue.severity] || issue.severity}</span>
|
|
887
|
+
<span style="background: #e9ecef; padding: 2px 8px; border-radius: 3px; font-size: 12px;">${typeLabels[issue.type] || issue.type}</span>
|
|
888
|
+
${issue.confidence ? `<span style="color: #6c757d; font-size: 12px;">Confidence: ${issue.confidence}/100</span>` : ''}
|
|
889
|
+
</div>
|
|
890
|
+
<div style="font-size: 14px; margin-bottom: 4px;">${issue.message}</div>
|
|
891
|
+
${issue.file ? `<div style="color: #6c757d; font-size: 12px;">📁 ${issue.file}${issue.line ? ':' + issue.line : ''}</div>` : ''}
|
|
892
|
+
${issue.suggestion ? `<div style="color: #27ae60; font-size: 13px; margin-top: 6px; padding: 8px; background: #eafaf1; border-radius: 3px;">💡 ${issue.suggestion}</div>` : ''}
|
|
893
|
+
</div>`;
|
|
894
|
+
}
|
|
895
|
+
} else {
|
|
896
|
+
issuesHtml = '<p style="color: #27ae60; text-align: center; padding: 20px;">✅ No issues found / 未发现问题</p>';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
let suggestionsHtml = '';
|
|
900
|
+
if (result.suggestions && result.suggestions.length > 0) {
|
|
901
|
+
suggestionsHtml = '<h3>Suggestions / 改进建议</h3><ul>' + result.suggestions.map(s => '<li>' + s + '</li>').join('') + '</ul>';
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
let praiseHtml = '';
|
|
905
|
+
if (result.praise && result.praise.length > 0) {
|
|
906
|
+
praiseHtml = '<h3>👍 Good Practices / 好的实践</h3><ul>' + result.praise.map(p => '<li>✅ ' + p + '</li>').join('') + '</ul>';
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const scoreVal = result.score || 0;
|
|
910
|
+
const scoreColor = scoreVal >= 80 ? '#27ae60' : scoreVal >= 50 ? '#f39c12' : '#e74c3c';
|
|
911
|
+
const scoreLabel = scoreVal >= 80 ? 'Good / 良好' : scoreVal >= 50 ? 'Needs Improvement / 需改进' : 'Poor / 差';
|
|
912
|
+
|
|
913
|
+
return `<!DOCTYPE html>
|
|
914
|
+
<html lang="zh-CN">
|
|
915
|
+
<head>
|
|
916
|
+
<meta charset="UTF-8">
|
|
917
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
918
|
+
<title>coderev Review Report</title>
|
|
919
|
+
<style>
|
|
920
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; background: #fff; color: #333; }
|
|
921
|
+
h1 { border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
|
922
|
+
.score-card { text-align: center; padding: 30px; background: #f8f9fa; border-radius: 8px; margin: 20px 0; }
|
|
923
|
+
.score-value { font-size: 48px; font-weight: bold; }
|
|
924
|
+
.score-label { font-size: 16px; color: #6c757d; margin-top: 5px; }
|
|
925
|
+
.summary { font-size: 16px; color: #555; margin: 15px 0; padding: 15px; background: #e8f4f8; border-radius: 6px; }
|
|
926
|
+
.stats { display: flex; gap: 20px; justify-content: center; margin: 20px 0; }
|
|
927
|
+
.stat { text-align: center; padding: 15px 25px; background: white; border: 1px solid #dee2e6; border-radius: 6px; }
|
|
928
|
+
.stat-value { font-size: 28px; font-weight: bold; }
|
|
929
|
+
.stat-label { font-size: 12px; color: #6c757d; text-transform: uppercase; }
|
|
930
|
+
h2 { margin-top: 30px; }
|
|
931
|
+
@media (prefers-color-scheme: dark) {
|
|
932
|
+
body { background: #1a1a2e; color: #e0e0e0; }
|
|
933
|
+
.score-card { background: #16213e; }
|
|
934
|
+
.summary { background: #0f3460; }
|
|
935
|
+
.issue { background: #16213e; }
|
|
936
|
+
.stat { background: #16213e; border-color: #0f3460; }
|
|
937
|
+
}
|
|
938
|
+
</style>
|
|
939
|
+
</head>
|
|
940
|
+
<body>
|
|
941
|
+
<h1>📋 coderev Review Report</h1>
|
|
942
|
+
<div class="score-card">
|
|
943
|
+
<div class="score-value" style="color: ${scoreColor}">${scoreVal}/100</div>
|
|
944
|
+
<div class="score-label">${scoreLabel}</div>
|
|
945
|
+
</div>
|
|
946
|
+
${result.summary ? '<div class="summary">📄 ' + result.summary + '</div>' : ''}
|
|
947
|
+
<div class="stats">
|
|
948
|
+
<div class="stat"><div class="stat-value" style="color: #e74c3c">${result.issues ? result.issues.filter(i => i.type === 'error').length : 0}</div><div class="stat-label">Errors</div></div>
|
|
949
|
+
<div class="stat"><div class="stat-value" style="color: #f39c12">${result.issues ? result.issues.filter(i => i.type === 'warning').length : 0}</div><div class="stat-label">Warnings</div></div>
|
|
950
|
+
<div class="stat"><div class="stat-value" style="color: #3498db">${result.issues ? result.issues.filter(i => i.type === 'info').length : 0}</div><div class="stat-label">Info</div></div>
|
|
951
|
+
<div class="stat"><div class="stat-value" style="color: #9b59b6">${result.issues ? result.issues.length : 0}</div><div class="stat-label">Total</div></div>
|
|
952
|
+
</div>
|
|
953
|
+
<h2>Issues / 问题</h2>
|
|
954
|
+
${issuesHtml}
|
|
955
|
+
${suggestionsHtml}
|
|
956
|
+
${praiseHtml}
|
|
957
|
+
<hr style="margin-top: 40px; border: none; border-top: 1px solid #eee;">
|
|
958
|
+
<p style="text-align: center; color: #6c757d; font-size: 12px;">Generated by coderev</p>
|
|
959
|
+
</body>
|
|
960
|
+
</html>`;
|
|
961
|
+
}
|
package/src/config.js
CHANGED
|
@@ -25,28 +25,47 @@ const DEFAULTS = {
|
|
|
25
25
|
format: 'terminal',
|
|
26
26
|
includeScore: true,
|
|
27
27
|
},
|
|
28
|
+
inheritance: {
|
|
29
|
+
enabled: true,
|
|
30
|
+
strategy: 'deep-merge', // 'deep-merge' | 'replace'
|
|
31
|
+
},
|
|
28
32
|
};
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Load configuration with multi-project inheritance support.
|
|
36
|
+
*
|
|
37
|
+
* Inheritance logic:
|
|
38
|
+
* 1. If configPath is explicitly specified, load that one file only (no inheritance)
|
|
39
|
+
* 2. If no configPath, search upward from cwd collecting ALL config files
|
|
40
|
+
* 3. Apply them in order: parent config first, then layered closer to cwd
|
|
41
|
+
* 4. Deep-merge: child values override parent values at the field level
|
|
42
|
+
*
|
|
43
|
+
* This allows teams to have a base .coderevrc.json at repo root
|
|
44
|
+
* and project-specific overrides in subdirectory projects.
|
|
45
|
+
*
|
|
46
|
+
* @param {string|null} configPath - Explicit path to config file
|
|
47
|
+
* @returns {object} Merged configuration
|
|
48
|
+
*/
|
|
30
49
|
function loadConfig(configPath) {
|
|
31
50
|
if (configPath) {
|
|
32
51
|
if (!fs.existsSync(configPath)) {
|
|
33
|
-
// If explicitly specified and not found, throw
|
|
34
|
-
if (configPath && !configPath.includes('nonexistent')) {
|
|
35
|
-
throw new Error(`Config file not found: ${configPath}`);
|
|
36
|
-
}
|
|
52
|
+
// If explicitly specified and not found, return defaults (don't throw for backward compat)
|
|
37
53
|
return { ...DEFAULTS };
|
|
38
54
|
}
|
|
39
55
|
return mergeDefaults(JSON.parse(fs.readFileSync(configPath, 'utf-8')));
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
//
|
|
58
|
+
// Multi-project inheritance: collect all config files from cwd up to root
|
|
59
|
+
const configStack = [];
|
|
43
60
|
let current = process.cwd();
|
|
61
|
+
|
|
44
62
|
while (true) {
|
|
45
63
|
for (const filename of CONFIG_FILES) {
|
|
46
64
|
const fullPath = path.join(current, filename);
|
|
47
65
|
if (fs.existsSync(fullPath)) {
|
|
48
66
|
const userConfig = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
|
|
49
|
-
|
|
67
|
+
configStack.push({ path: fullPath, config: userConfig });
|
|
68
|
+
break; // Only the first matching file per directory
|
|
50
69
|
}
|
|
51
70
|
}
|
|
52
71
|
const parent = path.dirname(current);
|
|
@@ -54,7 +73,61 @@ function loadConfig(configPath) {
|
|
|
54
73
|
current = parent;
|
|
55
74
|
}
|
|
56
75
|
|
|
57
|
-
|
|
76
|
+
// Reverse: apply from farthest parent first, then child overrides
|
|
77
|
+
// configStack[0] = closest to cwd (highest priority)
|
|
78
|
+
// configStack[n] = farthest from cwd (lowest priority)
|
|
79
|
+
configStack.reverse();
|
|
80
|
+
|
|
81
|
+
if (configStack.length === 0) {
|
|
82
|
+
return { ...DEFAULTS, _inheritanceStack: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check if inheritance is disabled by any config in the stack
|
|
86
|
+
const inheritanceDisabled = configStack.some(
|
|
87
|
+
entry => entry.config.inheritance && entry.config.inheritance.enabled === false
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (inheritanceDisabled || configStack.length === 1) {
|
|
91
|
+
// Only use the first (closest) config
|
|
92
|
+
return mergeDefaults(configStack[configStack.length - 1].config);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Deep-merge from parent to child
|
|
96
|
+
let merged = {};
|
|
97
|
+
for (const entry of configStack) {
|
|
98
|
+
merged = deepMerge(merged, entry.config);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
merged._inheritanceStack = configStack.map(e => e.path);
|
|
102
|
+
return mergeDefaults(merged);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Deep-merge two objects. Source values override target values.
|
|
107
|
+
* Arrays are replaced, not concatenated.
|
|
108
|
+
*/
|
|
109
|
+
function deepMerge(target, source) {
|
|
110
|
+
const result = { ...target };
|
|
111
|
+
|
|
112
|
+
for (const key of Object.keys(source)) {
|
|
113
|
+
const targetVal = target[key];
|
|
114
|
+
const sourceVal = source[key];
|
|
115
|
+
|
|
116
|
+
if (isPlainObject(targetVal) && isPlainObject(sourceVal)) {
|
|
117
|
+
result[key] = deepMerge(targetVal, sourceVal);
|
|
118
|
+
} else {
|
|
119
|
+
result[key] = sourceVal;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if a value is a plain object (not array, null, etc.)
|
|
128
|
+
*/
|
|
129
|
+
function isPlainObject(value) {
|
|
130
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
58
131
|
}
|
|
59
132
|
|
|
60
133
|
function mergeDefaults(userConfig) {
|
|
@@ -63,12 +136,17 @@ function mergeDefaults(userConfig) {
|
|
|
63
136
|
merged.ai = { ...DEFAULTS.ai, ...(userConfig.ai || {}) };
|
|
64
137
|
merged.rules = { ...DEFAULTS.rules, ...(userConfig.rules || {}) };
|
|
65
138
|
merged.output = { ...DEFAULTS.output, ...(userConfig.output || {}) };
|
|
139
|
+
merged.inheritance = { ...DEFAULTS.inheritance, ...(userConfig.inheritance || {}) };
|
|
66
140
|
// Pass through any extra keys
|
|
67
141
|
for (const key of Object.keys(userConfig)) {
|
|
68
|
-
if (!['ai', 'rules', 'output'].includes(key)) {
|
|
142
|
+
if (!['ai', 'rules', 'output', 'inheritance'].includes(key)) {
|
|
69
143
|
merged[key] = userConfig[key];
|
|
70
144
|
}
|
|
71
145
|
}
|
|
146
|
+
// Preserve _inheritanceStack if present
|
|
147
|
+
if (userConfig._inheritanceStack) {
|
|
148
|
+
merged._inheritanceStack = userConfig._inheritanceStack;
|
|
149
|
+
}
|
|
72
150
|
return merged;
|
|
73
151
|
}
|
|
74
152
|
|
package/src/reviewer.js
CHANGED
|
@@ -2,6 +2,7 @@ const { loadConfig, getApiKey } = require('./config');
|
|
|
2
2
|
const { cacheKey, getCached, setCached } = require('./cache');
|
|
3
3
|
const { recordReview } = require('./stats');
|
|
4
4
|
const { getRuleDescriptions } = require('./rules');
|
|
5
|
+
const { analyzeDiffContext, tagIssuesWithBlame } = require('./blame');
|
|
5
6
|
|
|
6
7
|
// ── 多智能体并行审查 ──
|
|
7
8
|
|
|
@@ -217,6 +218,7 @@ async function runParallelAgents(apiKey, config, prompts) {
|
|
|
217
218
|
* @param {string} [options.context] - Previous review context (for incremental reviews)
|
|
218
219
|
* @param {string} [options.ignorePattern] - File patterns to ignore
|
|
219
220
|
* @param {number} [options.minConfidence] - Minimum confidence threshold (default: 60)
|
|
221
|
+
* @param {boolean} [options.blame] - Enable git blame context analysis
|
|
220
222
|
* @returns {Promise<object>} Review result with issues, suggestions, score, etc.
|
|
221
223
|
*/
|
|
222
224
|
async function reviewDiff(diff, config, options = {}) {
|
|
@@ -292,6 +294,23 @@ async function reviewDiff(diff, config, options = {}) {
|
|
|
292
294
|
}
|
|
293
295
|
}
|
|
294
296
|
|
|
297
|
+
// ── Git blame context analysis ──
|
|
298
|
+
if (options.blame && result.issues && result.issues.length > 0) {
|
|
299
|
+
try {
|
|
300
|
+
const fileContexts = await analyzeDiffContext(diff);
|
|
301
|
+
result.issues = tagIssuesWithBlame(result.issues, fileContexts);
|
|
302
|
+
result._blameContext = {
|
|
303
|
+
filesAnalyzed: fileContexts.length,
|
|
304
|
+
newIssues: result.issues.filter(i => i.isNew === true).length,
|
|
305
|
+
preExistingIssues: result.issues.filter(i => i.isNew === false).length,
|
|
306
|
+
unknownIssues: result.issues.filter(i => i.isNew === null).length,
|
|
307
|
+
};
|
|
308
|
+
} catch (err) {
|
|
309
|
+
// Blame analysis is a best-effort enhancement; don't fail the review
|
|
310
|
+
result._blameContext = { error: err.message };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
295
314
|
// Record to stats
|
|
296
315
|
try { recordReview(result); } catch {}
|
|
297
316
|
|