korekt-cli 0.10.0 → 0.11.2

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.10.0",
3
+ "version": "0.11.2",
4
4
  "description": "AI-powered code review CLI - Keep your kode korekt",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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/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,43 @@ 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 file count exceeds max_files_for_content limit
219
+ if (config.max_files_for_content && fileList.length > config.max_files_for_content) {
220
+ throw new Error(
221
+ `Too many files: ${fileList.length} files exceeds the limit of ${config.max_files_for_content}. ` +
222
+ `Please reduce the number of changed files before reviewing.`
223
+ );
224
+ }
225
+
226
+ // Check if this is a large change set (only if config has large_pr_threshold)
227
+ const isLargePr = config.large_pr_threshold && fileList.length > config.large_pr_threshold;
228
+ if (isLargePr) {
229
+ console.error(
230
+ chalk.yellow(
231
+ `Large change set (${fileList.length} files > ${config.large_pr_threshold}). Sending diffs only.`
232
+ )
233
+ );
234
+ }
235
+
194
236
  const changedFiles = [];
195
237
 
196
238
  for (const file of fileList) {
@@ -204,11 +246,24 @@ export async function runUncommittedReview(mode = 'unstaged') {
204
246
  diff = await git('diff', '-U15', '--', path);
205
247
  }
206
248
 
249
+ // Determine if we should include content for this file
250
+ const skipContent =
251
+ isLargePr ||
252
+ isDiffOnly(path, config.diff_only_extensions, config.diff_only_files) ||
253
+ status === 'A';
254
+
207
255
  // Get current content from HEAD (before changes)
208
256
  let content = '';
209
- if (status !== 'A') {
257
+ if (!skipContent && status !== 'A') {
210
258
  try {
211
- content = await git('show', `HEAD:${oldPath}`);
259
+ const originalContent = await git('show', `HEAD:${oldPath}`);
260
+
261
+ // Check if content is binary
262
+ if (isBinary(originalContent)) {
263
+ console.error(chalk.gray(` Skipping binary content: ${path}`));
264
+ } else {
265
+ content = truncateContent(originalContent, maxLines);
266
+ }
212
267
  } catch {
213
268
  console.warn(
214
269
  chalk.yellow(`Could not get HEAD content for ${oldPath}. Assuming it's new.`)
@@ -216,21 +271,24 @@ export async function runUncommittedReview(mode = 'unstaged') {
216
271
  }
217
272
  }
218
273
 
219
- // Truncate content
220
- content = truncateContent(content);
221
-
222
274
  // For deleted files, truncate the diff as well
223
275
  if (status === 'D') {
224
- diff = truncateContent(diff);
276
+ diff = truncateContent(diff, maxLines);
225
277
  }
226
278
 
227
- changedFiles.push({
279
+ // Build the file object - only include content if we have it
280
+ const fileObj = {
228
281
  path: path,
229
282
  status: status,
230
283
  diff: diff,
231
- content: content,
232
284
  ...((status === 'R' || status === 'C') && { old_path: oldPath }),
233
- });
285
+ };
286
+
287
+ if (content) {
288
+ fileObj.content = content;
289
+ }
290
+
291
+ changedFiles.push(fileObj);
234
292
  }
235
293
 
236
294
  if (!nameStatusOutput.trim() && changedFiles.length === 0) {
@@ -312,9 +370,18 @@ export async function getContributors(diffRange, repoRootPath) {
312
370
  * Main function to analyze local git changes and prepare review payload
313
371
  * @param {string|null} targetBranch - The branch to compare against. If null, uses git reflog to find fork point.
314
372
  * @param {string[]|null} ignorePatterns - Array of glob patterns to ignore files
373
+ * @param {Object} fileRulesConfig - File rules config from API (or defaults)
315
374
  * @returns {Object|null} - The payload object ready for API submission, or null on error
316
375
  */
317
- export async function runLocalReview(targetBranch = null, ignorePatterns = null) {
376
+ export async function runLocalReview(
377
+ targetBranch = null,
378
+ ignorePatterns = null,
379
+ fileRulesConfig = null
380
+ ) {
381
+ // If no config provided, use empty config (no filtering applied)
382
+ const config = fileRulesConfig || {};
383
+ const maxLines = config.max_lines ?? 2000;
384
+
318
385
  try {
319
386
  // 1. Get Repo URL, current branch name, commit hash, and repository root
320
387
  const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
@@ -454,7 +521,7 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
454
521
  });
455
522
  const fileList = parseNameStatus(nameStatusOutput);
456
523
 
457
- // Filter out ignored files
524
+ // Filter out ignored files (user-specified patterns)
458
525
  let filteredFileList = fileList;
459
526
  let ignoredCount = 0;
460
527
  if (ignorePatterns && ignorePatterns.length > 0) {
@@ -472,9 +539,46 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
472
539
  console.error(chalk.gray(`Ignored ${ignoredCount} file(s) based on patterns\n`));
473
540
  }
474
541
 
542
+ // Filter out binary/non-reviewable files (only if config has skip_extensions)
543
+ if (config.skip_extensions) {
544
+ let skippedCount = 0;
545
+ filteredFileList = filteredFileList.filter((file) => {
546
+ if (shouldSkip(file.path, config.skip_extensions)) {
547
+ skippedCount++;
548
+ console.error(chalk.gray(` Skipping binary: ${file.path}`));
549
+ return false;
550
+ }
551
+ return true;
552
+ });
553
+
554
+ if (skippedCount > 0) {
555
+ console.error(chalk.gray(`Skipped ${skippedCount} binary file(s)\n`));
556
+ }
557
+ }
558
+
559
+ // Check if file count exceeds max_files_for_content limit
560
+ if (config.max_files_for_content && filteredFileList.length > config.max_files_for_content) {
561
+ throw new Error(
562
+ `Too many files: ${filteredFileList.length} files exceeds the limit of ${config.max_files_for_content}. ` +
563
+ `Please split this PR into smaller reviews or use --ignore to exclude some files.`
564
+ );
565
+ }
566
+
567
+ // Check if this is a large PR (only if config has large_pr_threshold)
568
+ const isLargePr =
569
+ config.large_pr_threshold && filteredFileList.length > config.large_pr_threshold;
570
+ if (isLargePr) {
571
+ console.error(
572
+ chalk.yellow(
573
+ `Large PR detected (${filteredFileList.length} files > ${config.large_pr_threshold}). Sending diffs only.`
574
+ )
575
+ );
576
+ }
577
+
475
578
  console.error(chalk.gray(`Collecting diffs for ${filteredFileList.length} file(s)...`));
476
579
 
477
580
  const changedFiles = [];
581
+
478
582
  for (const file of filteredFileList) {
479
583
  const { status, path, oldPath } = file;
480
584
 
@@ -484,17 +588,30 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
484
588
  cwd: repoRootPath,
485
589
  });
486
590
 
487
- // Get the original content from the base commit
591
+ // Determine if we should include content for this file
592
+ // Skip content for: large PRs, DIFF_ONLY files, deleted files, or added files
593
+ const skipContent =
594
+ isLargePr ||
595
+ isDiffOnly(path, config.diff_only_extensions, config.diff_only_files) ||
596
+ status === 'A';
597
+
598
+ // Get the original content from the base commit (unless we're skipping it)
488
599
  let content = '';
489
- if (status !== 'A') {
490
- // Added files have no original content
600
+ if (!skipContent && status !== 'A') {
491
601
  try {
492
602
  const { stdout: originalContent } = await execa(
493
603
  'git',
494
604
  ['show', `${mergeBase.trim()}:${oldPath}`],
495
605
  { cwd: repoRootPath }
496
606
  );
497
- content = originalContent;
607
+
608
+ // Check if content is binary
609
+ if (isBinary(originalContent)) {
610
+ console.error(chalk.gray(` Skipping binary content: ${path}`));
611
+ // Don't include content for binary files
612
+ } else {
613
+ content = truncateContent(originalContent, maxLines);
614
+ }
498
615
  } catch {
499
616
  // This can happen if a file was added and modified in the same branch
500
617
  console.warn(
@@ -503,22 +620,26 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
503
620
  }
504
621
  }
505
622
 
506
- // Truncate content
507
- content = truncateContent(content);
508
-
509
623
  // For deleted files, truncate the diff as well
510
624
  let truncatedDiff = diff;
511
625
  if (status === 'D') {
512
- truncatedDiff = truncateContent(diff);
626
+ truncatedDiff = truncateContent(diff, maxLines);
513
627
  }
514
628
 
515
- changedFiles.push({
629
+ // Build the file object - only include content if we have it
630
+ const fileObj = {
516
631
  path: path,
517
632
  status: status,
518
633
  diff: truncatedDiff,
519
- content: content,
520
- ...((status === 'R' || status === 'C') && { old_path: oldPath }), // Include old_path for renames and copies
521
- });
634
+ ...((status === 'R' || status === 'C') && { old_path: oldPath }),
635
+ };
636
+
637
+ // Only include content field if we have content
638
+ if (content) {
639
+ fileObj.content = content;
640
+ }
641
+
642
+ changedFiles.push(fileObj);
522
643
  }
523
644
 
524
645
  // 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');
package/src/index.js CHANGED
@@ -22,6 +22,28 @@ export { detectCIProvider, truncateFileData, formatErrorOutput, getPrUrl } from
22
22
  const require = createRequire(import.meta.url);
23
23
  const { version } = require('../package.json');
24
24
 
25
+ /**
26
+ * Fetch file processing rules from the API.
27
+ * These rules control which files to skip, which to show diff-only, etc.
28
+ * @param {string} apiEndpoint - The API endpoint URL
29
+ * @param {string} apiKey - The API key for authentication
30
+ * @returns {Promise<Object|null>} - Config object or null if fetch fails
31
+ */
32
+ async function fetchFileRulesConfig(apiEndpoint, apiKey) {
33
+ try {
34
+ const configEndpoint = apiEndpoint.replace(/\/review\/?$/, '/config/file-rules');
35
+ const response = await axios.get(configEndpoint, {
36
+ headers: { Authorization: `Bearer ${apiKey}` },
37
+ timeout: 3000,
38
+ });
39
+ return response.data;
40
+ } catch {
41
+ // Non-fatal: continue without filtering if config unavailable
42
+ log(chalk.yellow('Warning: Could not fetch file rules config.'));
43
+ return null;
44
+ }
45
+ }
46
+
25
47
  /**
26
48
  * Helper functions for clean output separation:
27
49
  * - log() writes to stderr (progress, info, errors)
@@ -157,8 +179,11 @@ program
157
179
  process.exit(1);
158
180
  }
159
181
 
182
+ // Fetch file rules config from API
183
+ const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
184
+
160
185
  // Gather all data using our git logic module
161
- const payload = await runLocalReview(targetBranch, options.ignore);
186
+ const payload = await runLocalReview(targetBranch, options.ignore, fileRulesConfig);
162
187
 
163
188
  if (!payload) {
164
189
  log(chalk.red('Could not proceed with review due to errors during analysis.'));
@@ -334,8 +359,11 @@ async function reviewUncommitted(mode, options) {
334
359
  process.exit(1);
335
360
  }
336
361
 
362
+ // Fetch file rules config from API
363
+ const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
364
+
337
365
  const { runUncommittedReview } = await import('./git-logic.js');
338
- const payload = await runUncommittedReview(mode);
366
+ const payload = await runUncommittedReview(mode, fileRulesConfig);
339
367
 
340
368
  if (!payload) {
341
369
  log(chalk.red('No changes found or error occurred during analysis.'));
package/src/index.test.js CHANGED
@@ -153,6 +153,36 @@ describe('CLI JSON output mode', () => {
153
153
  expect(displayFile.diff).not.toContain('truncated');
154
154
  expect(displayFile.content).not.toContain('truncated');
155
155
  });
156
+
157
+ it('should handle diff-only files without content', () => {
158
+ const file = {
159
+ path: 'test.js',
160
+ status: 'M',
161
+ diff: 'some diff here',
162
+ // no content field - this is a diff-only file
163
+ };
164
+
165
+ const displayFile = truncateFileData(file);
166
+
167
+ expect(displayFile.path).toBe('test.js');
168
+ expect(displayFile.status).toBe('M');
169
+ expect(displayFile.diff).toBe('some diff here');
170
+ expect(displayFile.content).toBeUndefined();
171
+ });
172
+
173
+ it('should handle files with neither diff nor content', () => {
174
+ const file = {
175
+ path: 'deleted.js',
176
+ status: 'D',
177
+ };
178
+
179
+ const displayFile = truncateFileData(file);
180
+
181
+ expect(displayFile.path).toBe('deleted.js');
182
+ expect(displayFile.status).toBe('D');
183
+ expect(displayFile.diff).toBeUndefined();
184
+ expect(displayFile.content).toBeUndefined();
185
+ });
156
186
  });
157
187
 
158
188
  describe('error formatting for JSON mode', () => {
package/src/utils.js CHANGED
@@ -73,19 +73,27 @@ export function getPrUrl() {
73
73
  * @returns {Object} File object with truncated diff and content
74
74
  */
75
75
  export function truncateFileData(file, maxLength = 500) {
76
- return {
76
+ const result = {
77
77
  path: file.path,
78
78
  status: file.status,
79
79
  ...(file.old_path && { old_path: file.old_path }),
80
- diff:
80
+ };
81
+
82
+ if (file.diff) {
83
+ result.diff =
81
84
  file.diff.length > maxLength
82
85
  ? `${file.diff.substring(0, maxLength)}... [truncated ${file.diff.length - maxLength} chars]`
83
- : file.diff,
84
- content:
86
+ : file.diff;
87
+ }
88
+
89
+ if (file.content) {
90
+ result.content =
85
91
  file.content.length > maxLength
86
92
  ? `${file.content.substring(0, maxLength)}... [truncated ${file.content.length - maxLength} chars]`
87
- : file.content,
88
- };
93
+ : file.content;
94
+ }
95
+
96
+ return result;
89
97
  }
90
98
 
91
99
  /**