korekt-cli 0.9.7 → 0.11.0

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": "korekt-cli",
3
- "version": "0.9.7",
3
+ "version": "0.11.0",
4
4
  "description": "AI-powered code review CLI - Keep your kode korekt",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/scripts/azure.sh CHANGED
@@ -232,6 +232,9 @@ post_review_thread() {
232
232
  TOTAL_ISSUES=$(jq -r '.data.summary.total_issues // 0' "$RESULTS_FILE")
233
233
  TOTAL_PRAISES=$(jq -r '.data.summary.total_praises // 0' "$RESULTS_FILE")
234
234
  CRITICAL_ISSUES=$(jq -r '.data.summary.critical // 0' "$RESULTS_FILE")
235
+ CHANGE_SUMMARY=$(jq -r '.data.change_classification?.summary // ""' "$RESULTS_FILE")
236
+ CHANGE_INTENT=$(jq -r '.data.change_classification?.intent // ""' "$RESULTS_FILE")
237
+ CHANGE_ASPECTS=$(jq -r '.data.change_classification?.aspects // [] | join(", ")' "$RESULTS_FILE")
235
238
 
236
239
  # Post inline comments for issues (excluding low severity)
237
240
  if [ "$TOTAL_ISSUES" -gt 0 ] && [ "$POST_INLINE_COMMENTS" = "true" ]; then
@@ -318,6 +321,20 @@ else
318
321
  echo "🤖 **Automated Code Review Results**" >> "$COMMENT_FILE"
319
322
  echo "" >> "$COMMENT_FILE"
320
323
 
324
+ # Change Summary section
325
+ if [ -n "$CHANGE_SUMMARY" ]; then
326
+ echo "### 📝 Change Summary" >> "$COMMENT_FILE"
327
+ echo "$CHANGE_SUMMARY" >> "$COMMENT_FILE"
328
+ echo "" >> "$COMMENT_FILE"
329
+
330
+ # Build metadata line
331
+ META=""
332
+ [ -n "$CHANGE_INTENT" ] && META="Intent: $CHANGE_INTENT"
333
+ [ -n "$CHANGE_ASPECTS" ] && { [ -n "$META" ] && META="$META | "; META="${META}Aspects: $CHANGE_ASPECTS"; }
334
+ [ -n "$META" ] && echo "_${META}_" >> "$COMMENT_FILE"
335
+ echo "" >> "$COMMENT_FILE"
336
+ fi
337
+
321
338
  # Praises section
322
339
  if [ "$TOTAL_PRAISES" -gt 0 ]; then
323
340
  echo "### ✨ Praises ($TOTAL_PRAISES)" >> "$COMMENT_FILE"
@@ -205,6 +205,9 @@ post_inline_comment() {
205
205
  TOTAL_ISSUES=$(jq -r '.data.summary.total_issues // 0' "$RESULTS_FILE")
206
206
  TOTAL_PRAISES=$(jq -r '.data.summary.total_praises // 0' "$RESULTS_FILE")
207
207
  CRITICAL_ISSUES=$(jq -r '.data.summary.critical // 0' "$RESULTS_FILE")
208
+ CHANGE_SUMMARY=$(jq -r '.data.change_classification?.summary // ""' "$RESULTS_FILE")
209
+ CHANGE_INTENT=$(jq -r '.data.change_classification?.intent // ""' "$RESULTS_FILE")
210
+ CHANGE_ASPECTS=$(jq -r '.data.change_classification?.aspects // [] | join(", ")' "$RESULTS_FILE")
208
211
 
209
212
  # Post inline comments for issues (excluding low severity)
210
213
  if [ "$TOTAL_ISSUES" -gt 0 ] && [ "$POST_INLINE_COMMENTS" = "true" ]; then
@@ -288,6 +291,20 @@ else
288
291
  echo "🤖 **Automated Code Review Results**" >> "$COMMENT_FILE"
289
292
  echo "" >> "$COMMENT_FILE"
290
293
 
294
+ # Change Summary section
295
+ if [ -n "$CHANGE_SUMMARY" ]; then
296
+ echo "### 📝 Change Summary" >> "$COMMENT_FILE"
297
+ echo "$CHANGE_SUMMARY" >> "$COMMENT_FILE"
298
+ echo "" >> "$COMMENT_FILE"
299
+
300
+ # Build metadata line
301
+ META=""
302
+ [ -n "$CHANGE_INTENT" ] && META="Intent: $CHANGE_INTENT"
303
+ [ -n "$CHANGE_ASPECTS" ] && { [ -n "$META" ] && META="$META | "; META="${META}Aspects: $CHANGE_ASPECTS"; }
304
+ [ -n "$META" ] && echo "_${META}_" >> "$COMMENT_FILE"
305
+ echo "" >> "$COMMENT_FILE"
306
+ fi
307
+
291
308
  # Praises section
292
309
  if [ "$TOTAL_PRAISES" -gt 0 ]; then
293
310
  echo "### ✨ Praises ($TOTAL_PRAISES)" >> "$COMMENT_FILE"
package/scripts/github.sh CHANGED
@@ -204,6 +204,9 @@ post_review_comment() {
204
204
  TOTAL_ISSUES=$(jq -r '.data.summary.total_issues // 0' "$RESULTS_FILE")
205
205
  TOTAL_PRAISES=$(jq -r '.data.summary.total_praises // 0' "$RESULTS_FILE")
206
206
  CRITICAL_ISSUES=$(jq -r '.data.summary.critical // 0' "$RESULTS_FILE")
207
+ CHANGE_SUMMARY=$(jq -r '.data.change_classification?.summary // ""' "$RESULTS_FILE")
208
+ CHANGE_INTENT=$(jq -r '.data.change_classification?.intent // ""' "$RESULTS_FILE")
209
+ CHANGE_ASPECTS=$(jq -r '.data.change_classification?.aspects // [] | join(", ")' "$RESULTS_FILE")
207
210
 
208
211
  # Post inline comments for issues
209
212
  if [ "$TOTAL_ISSUES" -gt 0 ]; then
@@ -287,6 +290,20 @@ else
287
290
  echo "🤖 **Automated Code Review Results**" >> "$COMMENT_FILE"
288
291
  echo "" >> "$COMMENT_FILE"
289
292
 
293
+ # Change Summary section
294
+ if [ -n "$CHANGE_SUMMARY" ]; then
295
+ echo "### 📝 Change Summary" >> "$COMMENT_FILE"
296
+ echo "$CHANGE_SUMMARY" >> "$COMMENT_FILE"
297
+ echo "" >> "$COMMENT_FILE"
298
+
299
+ # Build metadata line
300
+ META=""
301
+ [ -n "$CHANGE_INTENT" ] && META="Intent: $CHANGE_INTENT"
302
+ [ -n "$CHANGE_ASPECTS" ] && { [ -n "$META" ] && META="$META | "; META="${META}Aspects: $CHANGE_ASPECTS"; }
303
+ [ -n "$META" ] && echo "_${META}_" >> "$COMMENT_FILE"
304
+ echo "" >> "$COMMENT_FILE"
305
+ fi
306
+
290
307
  # Praises section
291
308
  if [ "$TOTAL_PRAISES" -gt 0 ]; then
292
309
  echo "### ✨ Praises ($TOTAL_PRAISES)" >> "$COMMENT_FILE"
@@ -0,0 +1,71 @@
1
+ /**
2
+ * File processing rules for code review.
3
+ */
4
+
5
+ /**
6
+ * Check if a file should be skipped entirely (binary/non-reviewable).
7
+ * @param {string} filePath - The file path to check
8
+ * @param {string[]} skipExtensions - Extensions to skip
9
+ * @returns {boolean} - True if the file should be skipped
10
+ */
11
+ export function shouldSkip(filePath, skipExtensions) {
12
+ if (!skipExtensions || !Array.isArray(skipExtensions)) {
13
+ return false;
14
+ }
15
+
16
+ const lowerPath = filePath.toLowerCase();
17
+
18
+ for (const ext of skipExtensions) {
19
+ if (lowerPath.endsWith(ext.toLowerCase())) {
20
+ return true;
21
+ }
22
+ }
23
+
24
+ return false;
25
+ }
26
+
27
+ /**
28
+ * Check if a file should only show diff (no full content).
29
+ * @param {string} filePath - The file path to check
30
+ * @param {string[]} diffOnlyExtensions - Extensions for diff-only
31
+ * @param {string[]} diffOnlyFiles - Specific filenames for diff-only
32
+ * @returns {boolean} - True if only diff should be shown
33
+ */
34
+ export function isDiffOnly(filePath, diffOnlyExtensions, diffOnlyFiles) {
35
+ const lowerPath = filePath.toLowerCase();
36
+ const fileName = lowerPath.split('/').pop();
37
+
38
+ // Check exact filename matches first (lowercase comparison)
39
+ if (diffOnlyFiles && Array.isArray(diffOnlyFiles)) {
40
+ if (diffOnlyFiles.some((f) => f.toLowerCase() === fileName)) {
41
+ return true;
42
+ }
43
+ }
44
+
45
+ // Check extension matches
46
+ if (diffOnlyExtensions && Array.isArray(diffOnlyExtensions)) {
47
+ for (const ext of diffOnlyExtensions) {
48
+ if (lowerPath.endsWith(ext.toLowerCase())) {
49
+ return true;
50
+ }
51
+ }
52
+ }
53
+
54
+ return false;
55
+ }
56
+
57
+ /**
58
+ * Check if content appears to be binary by looking for null bytes.
59
+ * @param {string} content - The content to check
60
+ * @returns {boolean} - True if content appears binary
61
+ */
62
+ export function isBinary(content) {
63
+ if (!content || content.length === 0) {
64
+ return false;
65
+ }
66
+
67
+ const sample = content.slice(0, 8192);
68
+
69
+ // Check for null bytes - text files never contain them
70
+ return sample.includes('\0');
71
+ }
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { shouldSkip, isDiffOnly, isBinary } from './file-rules.js';
3
+
4
+ /**
5
+ * Tests for file-rules helper functions.
6
+ * These functions take config as parameters - actual rules come from API.
7
+ */
8
+
9
+ describe('shouldSkip', () => {
10
+ describe('with null/empty config', () => {
11
+ it('should return false when skipExtensions is null', () => {
12
+ expect(shouldSkip('image.png', null)).toBe(false);
13
+ });
14
+
15
+ it('should return false when skipExtensions is undefined', () => {
16
+ expect(shouldSkip('image.png', undefined)).toBe(false);
17
+ });
18
+
19
+ it('should return false when skipExtensions is empty array', () => {
20
+ expect(shouldSkip('image.png', [])).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe('with extensions provided', () => {
25
+ const testExtensions = ['.png', '.jpg', '.exe'];
26
+
27
+ it('should skip files matching extensions', () => {
28
+ expect(shouldSkip('image.png', testExtensions)).toBe(true);
29
+ expect(shouldSkip('photo.jpg', testExtensions)).toBe(true);
30
+ expect(shouldSkip('program.exe', testExtensions)).toBe(true);
31
+ });
32
+
33
+ it('should not skip files not matching extensions', () => {
34
+ expect(shouldSkip('index.js', testExtensions)).toBe(false);
35
+ expect(shouldSkip('style.css', testExtensions)).toBe(false);
36
+ });
37
+
38
+ it('should be case insensitive', () => {
39
+ expect(shouldSkip('IMAGE.PNG', testExtensions)).toBe(true);
40
+ expect(shouldSkip('Photo.JPG', testExtensions)).toBe(true);
41
+ });
42
+
43
+ it('should handle paths with directories', () => {
44
+ expect(shouldSkip('assets/images/logo.png', testExtensions)).toBe(true);
45
+ expect(shouldSkip('src/index.js', testExtensions)).toBe(false);
46
+ });
47
+ });
48
+ });
49
+
50
+ describe('isDiffOnly', () => {
51
+ describe('with null/empty config', () => {
52
+ it('should return false when both params are null', () => {
53
+ expect(isDiffOnly('config.json', null, null)).toBe(false);
54
+ });
55
+
56
+ it('should return false when both params are empty arrays', () => {
57
+ expect(isDiffOnly('config.json', [], [])).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe('with extensions provided', () => {
62
+ const testExtensions = ['.json', '.lock'];
63
+ const testFiles = [];
64
+
65
+ it('should match extension-based diff-only files', () => {
66
+ expect(isDiffOnly('config.json', testExtensions, testFiles)).toBe(true);
67
+ expect(isDiffOnly('composer.lock', testExtensions, testFiles)).toBe(true);
68
+ });
69
+
70
+ it('should not match non-diff-only files', () => {
71
+ expect(isDiffOnly('index.js', testExtensions, testFiles)).toBe(false);
72
+ });
73
+
74
+ it('should be case insensitive for extensions', () => {
75
+ expect(isDiffOnly('CONFIG.JSON', testExtensions, testFiles)).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe('with specific filenames provided', () => {
80
+ const testExtensions = [];
81
+ const testFiles = ['package-lock.json', 'yarn.lock'];
82
+
83
+ it('should match specific filenames', () => {
84
+ expect(isDiffOnly('package-lock.json', testExtensions, testFiles)).toBe(true);
85
+ expect(isDiffOnly('yarn.lock', testExtensions, testFiles)).toBe(true);
86
+ });
87
+
88
+ it('should match filenames in paths', () => {
89
+ expect(isDiffOnly('node_modules/package-lock.json', testExtensions, testFiles)).toBe(true);
90
+ });
91
+
92
+ it('should be case insensitive for filenames', () => {
93
+ expect(isDiffOnly('PACKAGE-LOCK.JSON', testExtensions, testFiles)).toBe(true);
94
+ });
95
+ });
96
+
97
+ describe('with both extensions and filenames', () => {
98
+ const testExtensions = ['.json'];
99
+ const testFiles = ['go.sum'];
100
+
101
+ it('should work with only extensions provided', () => {
102
+ expect(isDiffOnly('config.json', testExtensions, null)).toBe(true);
103
+ });
104
+
105
+ it('should work with only files provided', () => {
106
+ expect(isDiffOnly('go.sum', null, testFiles)).toBe(true);
107
+ });
108
+ });
109
+ });
110
+
111
+ describe('isBinary', () => {
112
+ describe('empty/null content', () => {
113
+ it('should return false for empty string', () => {
114
+ expect(isBinary('')).toBe(false);
115
+ });
116
+
117
+ it('should return false for null', () => {
118
+ expect(isBinary(null)).toBe(false);
119
+ });
120
+
121
+ it('should return false for undefined', () => {
122
+ expect(isBinary(undefined)).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe('text content', () => {
127
+ it('should return false for plain text', () => {
128
+ expect(isBinary('Hello, World!')).toBe(false);
129
+ });
130
+
131
+ it('should return false for code', () => {
132
+ const code = `function hello() {\n console.log('Hello');\n}`;
133
+ expect(isBinary(code)).toBe(false);
134
+ });
135
+
136
+ it('should return false for content with whitespace', () => {
137
+ expect(isBinary('Line 1\n\tIndented\r\nWindows line')).toBe(false);
138
+ });
139
+
140
+ it('should return false for UTF-8 non-Latin text', () => {
141
+ // Chinese, Japanese, Cyrillic - all valid UTF-8, no null bytes
142
+ expect(isBinary('你好世界')).toBe(false);
143
+ expect(isBinary('こんにちは')).toBe(false);
144
+ expect(isBinary('Привет мир')).toBe(false);
145
+ });
146
+
147
+ it('should return false for text with control characters (non-null)', () => {
148
+ // Control chars like \x01-\x1F are not null bytes
149
+ const content = 'text\x01\x02\x03more text';
150
+ expect(isBinary(content)).toBe(false);
151
+ });
152
+ });
153
+
154
+ describe('binary content (null bytes)', () => {
155
+ it('should return true for content with null byte at start', () => {
156
+ expect(isBinary('\x00some text')).toBe(true);
157
+ });
158
+
159
+ it('should return true for content with null byte in middle', () => {
160
+ expect(isBinary('some\x00text')).toBe(true);
161
+ });
162
+
163
+ it('should return true for content with null byte at end', () => {
164
+ expect(isBinary('some text\x00')).toBe(true);
165
+ });
166
+
167
+ it('should return true for content with many null bytes', () => {
168
+ const binary = '\x00\x00\x00\x00some text\x00\x00\x00';
169
+ expect(isBinary(binary)).toBe(true);
170
+ });
171
+ });
172
+
173
+ describe('edge cases', () => {
174
+ it('should only sample first 8192 characters', () => {
175
+ // Null byte after 8192 chars should not be detected
176
+ const textPart = 'a'.repeat(10000);
177
+ const binaryPart = '\x00';
178
+ expect(isBinary(textPart + binaryPart)).toBe(false);
179
+ });
180
+
181
+ it('should detect null byte within first 8192 characters', () => {
182
+ const textPart = 'a'.repeat(8000);
183
+ const binaryPart = '\x00';
184
+ expect(isBinary(textPart + binaryPart)).toBe(true);
185
+ });
186
+ });
187
+ });
package/src/formatter.js CHANGED
@@ -76,10 +76,28 @@ function toAbsolutePath(filePath) {
76
76
  * @param {Object} data - The API response data
77
77
  */
78
78
  export function formatReviewOutput(data) {
79
- const { review, summary } = data.data;
79
+ const { review, summary, change_classification: changeClassification } = data.data;
80
80
 
81
81
  console.log(chalk.bold.blue('🤖 Automated Code Review Results\n'));
82
82
 
83
+ // --- Change Summary Section ---
84
+ if (changeClassification && changeClassification.summary) {
85
+ console.log(chalk.bold.cyan('📝 Change Summary\n'));
86
+ console.log(` ${changeClassification.summary}\n`);
87
+
88
+ const meta = [];
89
+ if (changeClassification.intent) {
90
+ meta.push(`Intent: ${changeClassification.intent}`);
91
+ }
92
+ if (changeClassification.aspects && changeClassification.aspects.length > 0) {
93
+ meta.push(`Aspects: ${changeClassification.aspects.join(', ')}`);
94
+ }
95
+ if (meta.length > 0) {
96
+ console.log(chalk.gray(` ${meta.join(' | ')}`));
97
+ }
98
+ console.log(); // Spacing after change summary
99
+ }
100
+
83
101
  // --- Praises Section ---
84
102
  if (review && review.praises && review.praises.length > 0) {
85
103
  console.log(chalk.bold.magenta(`✨ Praises (${summary.total_praises})`));
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { formatReviewOutput } from './formatter.js';
3
+
4
+ describe('formatReviewOutput', () => {
5
+ let consoleSpy;
6
+
7
+ beforeEach(() => {
8
+ consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ describe('change_classification output', () => {
16
+ it('should display change summary when change_classification.summary is present', () => {
17
+ const data = {
18
+ data: {
19
+ review: { issues: [], praises: [] },
20
+ summary: { total_issues: 0, total_praises: 0 },
21
+ change_classification: {
22
+ intent: 'fix',
23
+ aspects: ['security', 'tests'],
24
+ summary: 'Fix authentication bypass vulnerability',
25
+ },
26
+ },
27
+ };
28
+
29
+ formatReviewOutput(data);
30
+
31
+ const allCalls = consoleSpy.mock.calls.map((call) => call[0]);
32
+ expect(allCalls.some((call) => call.includes('Change Summary'))).toBe(true);
33
+ expect(
34
+ allCalls.some((call) => call.includes('Fix authentication bypass vulnerability'))
35
+ ).toBe(true);
36
+ expect(allCalls.some((call) => call.includes('Intent: fix'))).toBe(true);
37
+ expect(allCalls.some((call) => call.includes('Aspects: security, tests'))).toBe(true);
38
+ });
39
+
40
+ it('should not display change_classification section when summary is missing', () => {
41
+ const data = {
42
+ data: {
43
+ review: { issues: [], praises: [] },
44
+ summary: { total_issues: 0, total_praises: 0 },
45
+ change_classification: null,
46
+ },
47
+ };
48
+
49
+ formatReviewOutput(data);
50
+
51
+ const allCalls = consoleSpy.mock.calls.map((call) => call[0]);
52
+ expect(allCalls.some((call) => call && call.includes('Change Summary'))).toBe(false);
53
+ });
54
+
55
+ it('should handle change_classification with only summary (no intent/aspects)', () => {
56
+ const data = {
57
+ data: {
58
+ review: { issues: [], praises: [] },
59
+ summary: { total_issues: 0, total_praises: 0 },
60
+ change_classification: {
61
+ summary: 'Add new feature',
62
+ },
63
+ },
64
+ };
65
+
66
+ formatReviewOutput(data);
67
+
68
+ const allCalls = consoleSpy.mock.calls.map((call) => call[0]);
69
+ expect(allCalls.some((call) => call.includes('Change Summary'))).toBe(true);
70
+ expect(allCalls.some((call) => call.includes('Add new feature'))).toBe(true);
71
+ // Should not have metadata line when no intent/aspects
72
+ expect(allCalls.some((call) => call && call.includes('Intent:'))).toBe(false);
73
+ });
74
+
75
+ it('should handle change_classification with empty aspects array', () => {
76
+ const data = {
77
+ data: {
78
+ review: { issues: [], praises: [] },
79
+ summary: { total_issues: 0, total_praises: 0 },
80
+ change_classification: {
81
+ intent: 'feature',
82
+ aspects: [],
83
+ summary: 'Add user dashboard',
84
+ },
85
+ },
86
+ };
87
+
88
+ formatReviewOutput(data);
89
+
90
+ const allCalls = consoleSpy.mock.calls.map((call) => call[0]);
91
+ expect(allCalls.some((call) => call.includes('Add user dashboard'))).toBe(true);
92
+ expect(allCalls.some((call) => call && call.includes('Intent: feature'))).toBe(true);
93
+ expect(allCalls.some((call) => call && call.includes('Aspects:'))).toBe(false);
94
+ });
95
+ });
96
+ });
package/src/git-logic.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execa } from 'execa';
2
2
  import chalk from 'chalk';
3
3
  import { detectCIProvider, getPrUrl, getSourceBranchFromCI } from './utils.js';
4
+ import { shouldSkip, isDiffOnly, isBinary } from './file-rules.js';
4
5
 
5
6
  /**
6
7
  * Truncate content to a maximum number of lines using "head and tail".
@@ -161,9 +162,14 @@ export function parseNameStatus(output) {
161
162
  /**
162
163
  * Analyze uncommitted changes (staged or unstaged)
163
164
  * @param {string} mode - 'staged' or 'unstaged'
165
+ * @param {Object} fileRulesConfig - File rules config from API (or defaults)
164
166
  * @returns {Object|null} - The payload object ready for API submission, or null on error
165
167
  */
166
- export async function runUncommittedReview(mode = 'unstaged') {
168
+ export async function runUncommittedReview(mode = 'unstaged', fileRulesConfig = null) {
169
+ // If no config provided, use empty config (no filtering applied)
170
+ const config = fileRulesConfig || {};
171
+ const maxLines = config.max_lines ?? 2000;
172
+
167
173
  try {
168
174
  // 1. Get Repo URL, current branch name, and repository root
169
175
  const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
@@ -190,7 +196,35 @@ export async function runUncommittedReview(mode = 'unstaged') {
190
196
  console.error(chalk.gray('Analyzing unstaged changes...'));
191
197
  }
192
198
 
193
- const fileList = parseNameStatus(nameStatusOutput);
199
+ let fileList = parseNameStatus(nameStatusOutput);
200
+
201
+ // Filter out binary/non-reviewable files (only if config has skip_extensions)
202
+ if (config.skip_extensions) {
203
+ let skippedCount = 0;
204
+ fileList = fileList.filter((file) => {
205
+ if (shouldSkip(file.path, config.skip_extensions)) {
206
+ skippedCount++;
207
+ console.error(chalk.gray(` Skipping binary: ${file.path}`));
208
+ return false;
209
+ }
210
+ return true;
211
+ });
212
+
213
+ if (skippedCount > 0) {
214
+ console.error(chalk.gray(`Skipped ${skippedCount} binary file(s)\n`));
215
+ }
216
+ }
217
+
218
+ // Check if this is a large change set (only if config has large_pr_threshold)
219
+ const isLargePr = config.large_pr_threshold && fileList.length > config.large_pr_threshold;
220
+ if (isLargePr) {
221
+ console.error(
222
+ chalk.yellow(
223
+ `Large change set (${fileList.length} files > ${config.large_pr_threshold}). Sending diffs only.`
224
+ )
225
+ );
226
+ }
227
+
194
228
  const changedFiles = [];
195
229
 
196
230
  for (const file of fileList) {
@@ -204,11 +238,24 @@ export async function runUncommittedReview(mode = 'unstaged') {
204
238
  diff = await git('diff', '-U15', '--', path);
205
239
  }
206
240
 
241
+ // Determine if we should include content for this file
242
+ const skipContent =
243
+ isLargePr ||
244
+ isDiffOnly(path, config.diff_only_extensions, config.diff_only_files) ||
245
+ status === 'A';
246
+
207
247
  // Get current content from HEAD (before changes)
208
248
  let content = '';
209
- if (status !== 'A') {
249
+ if (!skipContent && status !== 'A') {
210
250
  try {
211
- content = await git('show', `HEAD:${oldPath}`);
251
+ const originalContent = await git('show', `HEAD:${oldPath}`);
252
+
253
+ // Check if content is binary
254
+ if (isBinary(originalContent)) {
255
+ console.error(chalk.gray(` Skipping binary content: ${path}`));
256
+ } else {
257
+ content = truncateContent(originalContent, maxLines);
258
+ }
212
259
  } catch {
213
260
  console.warn(
214
261
  chalk.yellow(`Could not get HEAD content for ${oldPath}. Assuming it's new.`)
@@ -216,21 +263,24 @@ export async function runUncommittedReview(mode = 'unstaged') {
216
263
  }
217
264
  }
218
265
 
219
- // Truncate content
220
- content = truncateContent(content);
221
-
222
266
  // For deleted files, truncate the diff as well
223
267
  if (status === 'D') {
224
- diff = truncateContent(diff);
268
+ diff = truncateContent(diff, maxLines);
225
269
  }
226
270
 
227
- changedFiles.push({
271
+ // Build the file object - only include content if we have it
272
+ const fileObj = {
228
273
  path: path,
229
274
  status: status,
230
275
  diff: diff,
231
- content: content,
232
276
  ...((status === 'R' || status === 'C') && { old_path: oldPath }),
233
- });
277
+ };
278
+
279
+ if (content) {
280
+ fileObj.content = content;
281
+ }
282
+
283
+ changedFiles.push(fileObj);
234
284
  }
235
285
 
236
286
  if (!nameStatusOutput.trim() && changedFiles.length === 0) {
@@ -312,9 +362,18 @@ export async function getContributors(diffRange, repoRootPath) {
312
362
  * Main function to analyze local git changes and prepare review payload
313
363
  * @param {string|null} targetBranch - The branch to compare against. If null, uses git reflog to find fork point.
314
364
  * @param {string[]|null} ignorePatterns - Array of glob patterns to ignore files
365
+ * @param {Object} fileRulesConfig - File rules config from API (or defaults)
315
366
  * @returns {Object|null} - The payload object ready for API submission, or null on error
316
367
  */
317
- export async function runLocalReview(targetBranch = null, ignorePatterns = null) {
368
+ export async function runLocalReview(
369
+ targetBranch = null,
370
+ ignorePatterns = null,
371
+ fileRulesConfig = null
372
+ ) {
373
+ // If no config provided, use empty config (no filtering applied)
374
+ const config = fileRulesConfig || {};
375
+ const maxLines = config.max_lines ?? 2000;
376
+
318
377
  try {
319
378
  // 1. Get Repo URL, current branch name, commit hash, and repository root
320
379
  const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
@@ -454,7 +513,7 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
454
513
  });
455
514
  const fileList = parseNameStatus(nameStatusOutput);
456
515
 
457
- // Filter out ignored files
516
+ // Filter out ignored files (user-specified patterns)
458
517
  let filteredFileList = fileList;
459
518
  let ignoredCount = 0;
460
519
  if (ignorePatterns && ignorePatterns.length > 0) {
@@ -472,9 +531,38 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
472
531
  console.error(chalk.gray(`Ignored ${ignoredCount} file(s) based on patterns\n`));
473
532
  }
474
533
 
534
+ // Filter out binary/non-reviewable files (only if config has skip_extensions)
535
+ if (config.skip_extensions) {
536
+ let skippedCount = 0;
537
+ filteredFileList = filteredFileList.filter((file) => {
538
+ if (shouldSkip(file.path, config.skip_extensions)) {
539
+ skippedCount++;
540
+ console.error(chalk.gray(` Skipping binary: ${file.path}`));
541
+ return false;
542
+ }
543
+ return true;
544
+ });
545
+
546
+ if (skippedCount > 0) {
547
+ console.error(chalk.gray(`Skipped ${skippedCount} binary file(s)\n`));
548
+ }
549
+ }
550
+
551
+ // Check if this is a large PR (only if config has large_pr_threshold)
552
+ const isLargePr =
553
+ config.large_pr_threshold && filteredFileList.length > config.large_pr_threshold;
554
+ if (isLargePr) {
555
+ console.error(
556
+ chalk.yellow(
557
+ `Large PR detected (${filteredFileList.length} files > ${config.large_pr_threshold}). Sending diffs only.`
558
+ )
559
+ );
560
+ }
561
+
475
562
  console.error(chalk.gray(`Collecting diffs for ${filteredFileList.length} file(s)...`));
476
563
 
477
564
  const changedFiles = [];
565
+
478
566
  for (const file of filteredFileList) {
479
567
  const { status, path, oldPath } = file;
480
568
 
@@ -484,17 +572,30 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
484
572
  cwd: repoRootPath,
485
573
  });
486
574
 
487
- // Get the original content from the base commit
575
+ // Determine if we should include content for this file
576
+ // Skip content for: large PRs, DIFF_ONLY files, deleted files, or added files
577
+ const skipContent =
578
+ isLargePr ||
579
+ isDiffOnly(path, config.diff_only_extensions, config.diff_only_files) ||
580
+ status === 'A';
581
+
582
+ // Get the original content from the base commit (unless we're skipping it)
488
583
  let content = '';
489
- if (status !== 'A') {
490
- // Added files have no original content
584
+ if (!skipContent && status !== 'A') {
491
585
  try {
492
586
  const { stdout: originalContent } = await execa(
493
587
  'git',
494
588
  ['show', `${mergeBase.trim()}:${oldPath}`],
495
589
  { cwd: repoRootPath }
496
590
  );
497
- content = originalContent;
591
+
592
+ // Check if content is binary
593
+ if (isBinary(originalContent)) {
594
+ console.error(chalk.gray(` Skipping binary content: ${path}`));
595
+ // Don't include content for binary files
596
+ } else {
597
+ content = truncateContent(originalContent, maxLines);
598
+ }
498
599
  } catch {
499
600
  // This can happen if a file was added and modified in the same branch
500
601
  console.warn(
@@ -503,22 +604,26 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
503
604
  }
504
605
  }
505
606
 
506
- // Truncate content
507
- content = truncateContent(content);
508
-
509
607
  // For deleted files, truncate the diff as well
510
608
  let truncatedDiff = diff;
511
609
  if (status === 'D') {
512
- truncatedDiff = truncateContent(diff);
610
+ truncatedDiff = truncateContent(diff, maxLines);
513
611
  }
514
612
 
515
- changedFiles.push({
613
+ // Build the file object - only include content if we have it
614
+ const fileObj = {
516
615
  path: path,
517
616
  status: status,
518
617
  diff: truncatedDiff,
519
- content: content,
520
- ...((status === 'R' || status === 'C') && { old_path: oldPath }), // Include old_path for renames and copies
521
- });
618
+ ...((status === 'R' || status === 'C') && { old_path: oldPath }),
619
+ };
620
+
621
+ // Only include content field if we have content
622
+ if (content) {
623
+ fileObj.content = content;
624
+ }
625
+
626
+ changedFiles.push(fileObj);
522
627
  }
523
628
 
524
629
  // 5. Get contributors from commits
@@ -1298,6 +1298,228 @@ describe('getSourceBranchFromCI', () => {
1298
1298
  });
1299
1299
  });
1300
1300
 
1301
+ describe('fileRulesConfig filtering', () => {
1302
+ beforeEach(() => {
1303
+ vi.mock('execa');
1304
+ vi.mock('./utils.js', () => ({
1305
+ detectCIProvider: vi.fn().mockReturnValue(null),
1306
+ getPrUrl: vi.fn().mockReturnValue(null),
1307
+ getSourceBranchFromCI: vi.fn().mockReturnValue(null),
1308
+ }));
1309
+ });
1310
+
1311
+ afterEach(() => {
1312
+ vi.restoreAllMocks();
1313
+ });
1314
+
1315
+ it('should exclude files matching skip_extensions', async () => {
1316
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
1317
+ const command = [cmd, ...args].join(' ');
1318
+
1319
+ if (command.includes('remote get-url origin')) {
1320
+ return { stdout: 'https://github.com/user/repo.git' };
1321
+ }
1322
+ if (command.includes('rev-parse --abbrev-ref HEAD')) {
1323
+ return { stdout: 'feature-branch' };
1324
+ }
1325
+ if (command.includes('rev-parse --show-toplevel')) {
1326
+ return { stdout: '/fake/repo/path' };
1327
+ }
1328
+ if (command.includes('diff --cached --name-status')) {
1329
+ return { stdout: 'M\tfile.js\nM\timage.png\nM\tphoto.jpg' };
1330
+ }
1331
+ if (command.includes('diff --cached -U15 -- file.js')) {
1332
+ return { stdout: 'diff for file.js' };
1333
+ }
1334
+ if (command.includes('diff --cached -U15 -- image.png')) {
1335
+ return { stdout: 'diff for image.png' };
1336
+ }
1337
+ if (command.includes('diff --cached -U15 -- photo.jpg')) {
1338
+ return { stdout: 'diff for photo.jpg' };
1339
+ }
1340
+ if (command.includes('show HEAD:file.js')) {
1341
+ return { stdout: 'js content' };
1342
+ }
1343
+ if (command.includes('show HEAD:image.png')) {
1344
+ return { stdout: 'png content' };
1345
+ }
1346
+ if (command.includes('show HEAD:photo.jpg')) {
1347
+ return { stdout: 'jpg content' };
1348
+ }
1349
+
1350
+ throw new Error(`Unmocked command: ${command}`);
1351
+ });
1352
+
1353
+ const config = {
1354
+ skip_extensions: ['.png', '.jpg'],
1355
+ };
1356
+
1357
+ const result = await runUncommittedReview('staged', config);
1358
+
1359
+ expect(result).toBeDefined();
1360
+ expect(result.changed_files).toHaveLength(1);
1361
+ expect(result.changed_files[0].path).toBe('file.js');
1362
+ });
1363
+
1364
+ it('should not include content for diff_only files', async () => {
1365
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
1366
+ const command = [cmd, ...args].join(' ');
1367
+
1368
+ if (command.includes('remote get-url origin')) {
1369
+ return { stdout: 'https://github.com/user/repo.git' };
1370
+ }
1371
+ if (command.includes('rev-parse --abbrev-ref HEAD')) {
1372
+ return { stdout: 'feature-branch' };
1373
+ }
1374
+ if (command.includes('rev-parse --show-toplevel')) {
1375
+ return { stdout: '/fake/repo/path' };
1376
+ }
1377
+ if (command.includes('diff --cached --name-status')) {
1378
+ return { stdout: 'M\tfile.js\nM\tpackage-lock.json\nM\tconfig.yaml' };
1379
+ }
1380
+ if (command.includes('diff --cached -U15 -- file.js')) {
1381
+ return { stdout: 'diff for file.js' };
1382
+ }
1383
+ if (command.includes('diff --cached -U15 -- package-lock.json')) {
1384
+ return { stdout: 'diff for package-lock.json' };
1385
+ }
1386
+ if (command.includes('diff --cached -U15 -- config.yaml')) {
1387
+ return { stdout: 'diff for config.yaml' };
1388
+ }
1389
+ if (command.includes('show HEAD:file.js')) {
1390
+ return { stdout: 'js content' };
1391
+ }
1392
+ if (command.includes('show HEAD:package-lock.json')) {
1393
+ return { stdout: 'lock content' };
1394
+ }
1395
+ if (command.includes('show HEAD:config.yaml')) {
1396
+ return { stdout: 'yaml content' };
1397
+ }
1398
+
1399
+ throw new Error(`Unmocked command: ${command}`);
1400
+ });
1401
+
1402
+ const config = {
1403
+ diff_only_extensions: ['.yaml'],
1404
+ diff_only_files: ['package-lock.json'],
1405
+ };
1406
+
1407
+ const result = await runUncommittedReview('staged', config);
1408
+
1409
+ expect(result).toBeDefined();
1410
+ expect(result.changed_files).toHaveLength(3);
1411
+
1412
+ const jsFile = result.changed_files.find((f) => f.path === 'file.js');
1413
+ const lockFile = result.changed_files.find((f) => f.path === 'package-lock.json');
1414
+ const yamlFile = result.changed_files.find((f) => f.path === 'config.yaml');
1415
+
1416
+ expect(jsFile.content).toBe('js content');
1417
+ expect(lockFile.content).toBeUndefined();
1418
+ expect(yamlFile.content).toBeUndefined();
1419
+ });
1420
+
1421
+ it('should not include content for any file when large_pr_threshold exceeded', async () => {
1422
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
1423
+ const command = [cmd, ...args].join(' ');
1424
+
1425
+ if (command.includes('remote get-url origin')) {
1426
+ return { stdout: 'https://github.com/user/repo.git' };
1427
+ }
1428
+ if (command.includes('rev-parse --abbrev-ref HEAD')) {
1429
+ return { stdout: 'feature-branch' };
1430
+ }
1431
+ if (command.includes('rev-parse --show-toplevel')) {
1432
+ return { stdout: '/fake/repo/path' };
1433
+ }
1434
+ if (command.includes('diff --cached --name-status')) {
1435
+ return { stdout: 'M\tfile1.js\nM\tfile2.js\nM\tfile3.js' };
1436
+ }
1437
+ if (command.includes('diff --cached -U15 -- file1.js')) {
1438
+ return { stdout: 'diff for file1.js' };
1439
+ }
1440
+ if (command.includes('diff --cached -U15 -- file2.js')) {
1441
+ return { stdout: 'diff for file2.js' };
1442
+ }
1443
+ if (command.includes('diff --cached -U15 -- file3.js')) {
1444
+ return { stdout: 'diff for file3.js' };
1445
+ }
1446
+ if (command.includes('show HEAD:file1.js')) {
1447
+ return { stdout: 'content1' };
1448
+ }
1449
+ if (command.includes('show HEAD:file2.js')) {
1450
+ return { stdout: 'content2' };
1451
+ }
1452
+ if (command.includes('show HEAD:file3.js')) {
1453
+ return { stdout: 'content3' };
1454
+ }
1455
+
1456
+ throw new Error(`Unmocked command: ${command}`);
1457
+ });
1458
+
1459
+ const config = {
1460
+ large_pr_threshold: 2, // 3 files > 2 threshold
1461
+ };
1462
+
1463
+ const result = await runUncommittedReview('staged', config);
1464
+
1465
+ expect(result).toBeDefined();
1466
+ expect(result.changed_files).toHaveLength(3);
1467
+
1468
+ // All files should have no content due to large PR
1469
+ for (const file of result.changed_files) {
1470
+ expect(file.content).toBeUndefined();
1471
+ expect(file.diff).toBeDefined();
1472
+ }
1473
+ });
1474
+
1475
+ it('should include content when below large_pr_threshold', async () => {
1476
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
1477
+ const command = [cmd, ...args].join(' ');
1478
+
1479
+ if (command.includes('remote get-url origin')) {
1480
+ return { stdout: 'https://github.com/user/repo.git' };
1481
+ }
1482
+ if (command.includes('rev-parse --abbrev-ref HEAD')) {
1483
+ return { stdout: 'feature-branch' };
1484
+ }
1485
+ if (command.includes('rev-parse --show-toplevel')) {
1486
+ return { stdout: '/fake/repo/path' };
1487
+ }
1488
+ if (command.includes('diff --cached --name-status')) {
1489
+ return { stdout: 'M\tfile1.js\nM\tfile2.js' };
1490
+ }
1491
+ if (command.includes('diff --cached -U15 -- file1.js')) {
1492
+ return { stdout: 'diff for file1.js' };
1493
+ }
1494
+ if (command.includes('diff --cached -U15 -- file2.js')) {
1495
+ return { stdout: 'diff for file2.js' };
1496
+ }
1497
+ if (command.includes('show HEAD:file1.js')) {
1498
+ return { stdout: 'content1' };
1499
+ }
1500
+ if (command.includes('show HEAD:file2.js')) {
1501
+ return { stdout: 'content2' };
1502
+ }
1503
+
1504
+ throw new Error(`Unmocked command: ${command}`);
1505
+ });
1506
+
1507
+ const config = {
1508
+ large_pr_threshold: 5, // 2 files < 5 threshold
1509
+ };
1510
+
1511
+ const result = await runUncommittedReview('staged', config);
1512
+
1513
+ expect(result).toBeDefined();
1514
+ expect(result.changed_files).toHaveLength(2);
1515
+
1516
+ // All files should have content
1517
+ for (const file of result.changed_files) {
1518
+ expect(file.content).toBeDefined();
1519
+ }
1520
+ });
1521
+ });
1522
+
1301
1523
  describe('runLocalReview - detached HEAD handling', () => {
1302
1524
  beforeEach(() => {
1303
1525
  vi.mock('execa');