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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coderev-cli",
3
- "version": "1.0.15",
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
+ };