coderev-cli 1.0.16 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coderev-cli",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
4
4
  "description": "Multi-agent AI code review for git -- parallel agents with confidence scoring",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/blame.js ADDED
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Git blame context analysis for coderev.
3
+ *
4
+ * Runs `git blame` on modified files to distinguish:
5
+ * - **New issues**: introduced in the current diff/commit
6
+ * - **Pre-existing issues**: already present before this change
7
+ *
8
+ * This helps reviewers focus on what's actually new vs. inherited debt.
9
+ */
10
+
11
+ const { execSync } = require('child_process');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ /**
16
+ * Parse a unified diff into per-file line additions.
17
+ * @param {string} diff - The git diff text
18
+ * @returns {Array<{file: string, addedLines: number[]}>}
19
+ */
20
+ function parseDiffAddedLines(diff) {
21
+ if (!diff || typeof diff !== 'string') return [];
22
+
23
+ const result = [];
24
+ const lines = diff.split('\n');
25
+ let currentFile = null;
26
+ let currentAdded = [];
27
+ let oldStart = 0, newStart = 0, oldCount = 0, newCount = 0;
28
+ let newLineOffset = 0;
29
+
30
+ for (const line of lines) {
31
+ // Detect file header
32
+ const fileMatch = line.match(/^\+\+\+ b\/(.*)/);
33
+ if (fileMatch) {
34
+ if (currentFile && currentAdded.length > 0) {
35
+ result.push({ file: currentFile, addedLines: [...new Set(currentAdded)].sort((a, b) => a - b) });
36
+ }
37
+ currentFile = fileMatch[1];
38
+ currentAdded = [];
39
+ continue;
40
+ }
41
+
42
+ // Chunk header: @@ -oldStart,oldCount +newStart,newCount @@
43
+ const chunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
44
+ if (chunkMatch) {
45
+ oldStart = parseInt(chunkMatch[1], 10);
46
+ oldCount = chunkMatch[2] ? parseInt(chunkMatch[2], 10) : 1;
47
+ newStart = parseInt(chunkMatch[3], 10);
48
+ newCount = chunkMatch[4] ? parseInt(chunkMatch[4], 10) : 1;
49
+ newLineOffset = newStart - 1;
50
+ continue;
51
+ }
52
+
53
+ // Track added lines (context or removed = old)
54
+ if (line.startsWith('+') && !line.startsWith('+++')) {
55
+ newLineOffset++;
56
+ currentAdded.push(newLineOffset);
57
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
58
+ // Removed lines don't advance new position
59
+ continue;
60
+ } else {
61
+ // Context line: advances both old and new
62
+ newLineOffset++;
63
+ }
64
+ }
65
+
66
+ // Push last file
67
+ if (currentFile && currentAdded.length > 0) {
68
+ result.push({ file: currentFile, addedLines: [...new Set(currentAdded)].sort((a, b) => a - b) });
69
+ }
70
+
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * Run `git blame` on a file for specific line numbers.
76
+ * @param {string} filePath - Path relative to git repo root
77
+ * @param {number[]} lineNumbers - Array of line numbers to blame
78
+ * @param {string} [repoPath] - Path to git repo (default: cwd)
79
+ * @returns {Promise<object>} Map of lineNumber -> { author, commit, date, isNew }
80
+ */
81
+ function blameLines(filePath, lineNumbers, repoPath) {
82
+ return new Promise((resolve) => {
83
+ if (!lineNumbers || lineNumbers.length === 0) {
84
+ resolve({});
85
+ return;
86
+ }
87
+
88
+ const cwd = repoPath || process.cwd();
89
+ const absPath = path.resolve(cwd, filePath);
90
+
91
+ if (!fs.existsSync(absPath)) {
92
+ resolve({});
93
+ return;
94
+ }
95
+
96
+ try {
97
+ // Run git blame for specific lines, porcelain format
98
+ const lineArgs = lineNumbers.map(n => `-L ${n},${n}`).join(' ');
99
+ const cmd = `git blame --porcelain ${lineArgs} -- "${filePath}"`;
100
+
101
+ const stdout = execSync(cmd, {
102
+ cwd,
103
+ encoding: 'utf-8',
104
+ timeout: 10000,
105
+ maxBuffer: 10 * 1024 * 1024,
106
+ stdio: ['pipe', 'pipe', 'pipe'],
107
+ });
108
+
109
+ const result = {};
110
+
111
+ // Parse porcelain format
112
+ const porcelainLines = stdout.split('\n');
113
+ let i = 0;
114
+
115
+ while (i < porcelainLines.length) {
116
+ const line = porcelainLines[i];
117
+ if (!line.trim()) { i++; continue; }
118
+
119
+ // Header line: commit-hash author-line file-line (or not)
120
+ const headerMatch = line.match(/^([a-f0-9]+)\s+(\d+)\s+(\d+)\s+(\d+)$/);
121
+ if (!headerMatch) { i++; continue; }
122
+
123
+ const commitHash = headerMatch[1];
124
+ const origLine = parseInt(headerMatch[2], 10);
125
+ const finalLine = parseInt(headerMatch[3], 10);
126
+ const numLines = parseInt(headerMatch[4], 10);
127
+
128
+ // Skip boundary (not committed yet)
129
+ if (commitHash === '0000000000000000000000000000000000000000') {
130
+ result[finalLine] = { commit: commitHash, author: '(uncommitted)', date: new Date(), isNew: true };
131
+ // Skip ahead the content lines
132
+ i += numLines + 1;
133
+ while (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) { i++; }
134
+ continue;
135
+ }
136
+
137
+ // Read header fields until content
138
+ let author = '(unknown)';
139
+ let date = new Date(0);
140
+ let isNew = false;
141
+
142
+ i++;
143
+ while (i < porcelainLines.length && !porcelainLines[i].startsWith('\t')) {
144
+ const hdr = porcelainLines[i];
145
+ if (hdr.startsWith('author ')) {
146
+ author = hdr.slice(7);
147
+ } else if (hdr.startsWith('author-time ')) {
148
+ date = new Date(parseInt(hdr.slice(12), 10) * 1000);
149
+ } else if (hdr.startsWith('boundary')) {
150
+ // Boundary commit = root of history
151
+ }
152
+ i++;
153
+ }
154
+
155
+ // Line content (starts with \t)
156
+ if (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) {
157
+ // Content line - skip
158
+ }
159
+
160
+ result[finalLine] = { commit: commitHash, author, date, isNew };
161
+
162
+ // Jump to next header (skip content lines)
163
+ while (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) { i++; }
164
+ }
165
+
166
+ resolve(result);
167
+ } catch (err) {
168
+ // git blame failed (file not tracked, etc.)
169
+ resolve({});
170
+ }
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Analyze a diff with git blame to classify issues.
176
+ * @param {string} diff - The git diff
177
+ * @param {object} [options] - Options
178
+ * @param {string} [options.repoPath] - Git repo path
179
+ * @returns {Promise<{fileContexts: Array}>}
180
+ */
181
+ async function analyzeDiffContext(diff, options = {}) {
182
+ const files = parseDiffAddedLines(diff);
183
+ const fileContexts = [];
184
+
185
+ for (const fileEntry of files) {
186
+ const { file, addedLines } = fileEntry;
187
+ if (addedLines.length === 0) continue;
188
+
189
+ const blameMap = await blameLines(file, addedLines, options.repoPath);
190
+ const newLines = [];
191
+ const existingLines = [];
192
+
193
+ for (const lineNum of addedLines) {
194
+ if (blameMap[lineNum] && blameMap[lineNum].isNew) {
195
+ newLines.push(lineNum);
196
+ } else {
197
+ existingLines.push(lineNum);
198
+ }
199
+ }
200
+
201
+ fileContexts.push({
202
+ file,
203
+ totalNewLines: addedLines.length,
204
+ newLines,
205
+ existingLines,
206
+ existingCount: existingLines.length,
207
+ newCount: newLines.length,
208
+ });
209
+ }
210
+
211
+ return fileContexts;
212
+ }
213
+
214
+ /**
215
+ * Tag issues with blame context (new vs pre-existing).
216
+ * @param {Array} issues - List of review issues
217
+ * @param {Array} fileContexts - Output from analyzeDiffContext
218
+ * @returns {Array} Tagged issues with `isNew` field
219
+ */
220
+ function tagIssuesWithBlame(issues, fileContexts) {
221
+ if (!issues || !fileContexts) return issues || [];
222
+
223
+ const lineMap = {};
224
+ for (const ctx of fileContexts) {
225
+ for (const ln of ctx.newLines) {
226
+ lineMap[`${ctx.file}:${ln}`] = true;
227
+ }
228
+ }
229
+
230
+ return issues.map(issue => {
231
+ if (!issue.file || !issue.line) {
232
+ return { ...issue, isNew: null }; // Can't determine
233
+ }
234
+ const key = `${issue.file}:${issue.line}`;
235
+ return {
236
+ ...issue,
237
+ isNew: lineMap[key] || false,
238
+ };
239
+ });
240
+ }
241
+
242
+ module.exports = {
243
+ parseDiffAddedLines,
244
+ blameLines,
245
+ analyzeDiffContext,
246
+ tagIssuesWithBlame,
247
+ };
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
- // Search up from cwd
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
- return mergeDefaults(userConfig);
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
- return { ...DEFAULTS };
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