codeant-cli 0.5.11 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,8 +16,11 @@ For installation, authentication, and self-hosted setup, see the [CodeAnt CLI se
16
16
  # Login to CodeAnt
17
17
  codeant login
18
18
 
19
- # Scan staged files for secrets
19
+ # Scan your changes for secrets
20
20
  codeant secrets
21
+
22
+ # Review your changes
23
+ codeant review
21
24
  ```
22
25
 
23
26
  ## Usage
@@ -56,10 +59,14 @@ codeant secrets [options]
56
59
 
57
60
  | Option | Description |
58
61
  |--------|-------------|
59
- | `--staged` | Scan only staged files (default) |
60
- | `--all` | Scan all changed files compared to base branch |
61
- | `--uncommitted` | Scan all uncommitted changes |
62
+ | `--all` | Scan committed (unpushed) + uncommitted changes, including untracked files (default) |
63
+ | `--committed` | Scan only unpushed commits |
64
+ | `--uncommitted` | Scan staged + unstaged + untracked changes |
65
+ | `--staged` | Scan only staged files |
62
66
  | `--last-commit` | Scan files from the last commit |
67
+ | `--last-n-commits <n>` | Scan files from the last n commits (max 5) |
68
+ | `--base <branch>` | Scan committed + uncommitted + untracked changes since the merge-base with `<branch>` |
69
+ | `--base-commit <commit>` | Scan committed + uncommitted + untracked changes since `<commit>` |
63
70
  | `--fail-on <level>` | Fail only on HIGH, MEDIUM, or all (default: HIGH) |
64
71
  | `--include <patterns>` | Comma-separated glob patterns to include files |
65
72
  | `--exclude <patterns>` | Comma-separated glob patterns to exclude files |
@@ -67,11 +74,11 @@ codeant secrets [options]
67
74
  **Examples:**
68
75
 
69
76
  ```bash
70
- # Scan staged files (default)
77
+ # Scan all changed files (default)
71
78
  codeant secrets
72
79
 
73
- # Scan all changed files
74
- codeant secrets --all
80
+ # Scan only staged files
81
+ codeant secrets --staged
75
82
 
76
83
  # Scan last commit
77
84
  codeant secrets --last-commit
@@ -108,6 +115,46 @@ Use `--include` and `--exclude` with glob patterns to filter files:
108
115
  - `MEDIUM` - Medium confidence, may need review
109
116
  - `FALSE_POSITIVE` - Detected but likely not a real secret (always ignored)
110
117
 
118
+ #### `review`
119
+
120
+ Run an AI code review on your local changes.
121
+
122
+ ```bash
123
+ codeant review [options]
124
+ ```
125
+
126
+ **Options:**
127
+
128
+ | Option | Description |
129
+ |--------|-------------|
130
+ | `--all` | Review committed (unpushed) + uncommitted changes, including untracked files (default) |
131
+ | `--committed` | Review only unpushed commits |
132
+ | `--uncommitted` | Review staged + unstaged + untracked changes |
133
+ | `--staged` | Review only staged files |
134
+ | `--last-commit` | Review the last commit |
135
+ | `--last-n-commits <n>` | Review the last n commits (max 5) |
136
+ | `--base <branch>` | Review committed + uncommitted + untracked changes since the merge-base with `<branch>` |
137
+ | `--base-commit <commit>` | Review committed + uncommitted + untracked changes since `<commit>` |
138
+ | `--fail-on <level>` | Fail on issues at or above BLOCKER, CRITICAL, MAJOR, MINOR, or INFO (default: CRITICAL) |
139
+ | `--include <patterns>` | Comma-separated glob patterns to include files |
140
+ | `--exclude <patterns>` | Comma-separated glob patterns to exclude files |
141
+ | `--headless` | Print JSON with no spinners (for agents and CI) |
142
+
143
+ `--base` and `--base-commit` take precedence over the other scope flags. They compare the base against your working tree, so staged, unstaged, and untracked files are reviewed along with commits. Files ignored by `.gitignore` are never reviewed. The same applies to `secrets`.
144
+
145
+ **Examples:**
146
+
147
+ ```bash
148
+ # Review everything on your branch since it left main, including uncommitted and new files
149
+ codeant review --base main
150
+
151
+ # Review only uncommitted work
152
+ codeant review --uncommitted
153
+
154
+ # CI: fail on MAJOR or worse, JSON output
155
+ codeant review --base main --fail-on MAJOR --headless
156
+ ```
157
+
111
158
  #### `set-base-url <url>`
112
159
 
113
160
  Set a custom API base URL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeant-cli",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "repository": {
package/src/index.js CHANGED
@@ -72,8 +72,8 @@ program
72
72
  .option('--staged', 'Scan only staged files')
73
73
  .option('--last-commit', 'Scan last commit')
74
74
  .option('--last-n-commits <n>', 'Scan last n commits (max 5)', toCount)
75
- .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
76
- .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
75
+ .option('--base <branch>', 'Scan committed + uncommitted + untracked changes since the merge-base with a branch (e.g. --base develop). Takes precedence over the other scope flags')
76
+ .option('--base-commit <commit>', 'Scan committed + uncommitted + untracked changes since a commit (e.g. --base-commit HEAD~3). Takes precedence over the other scope flags')
77
77
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
78
78
  .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
79
79
  .option('--hook', 'Running from pre-commit hook (enables bypass prompt)')
@@ -83,6 +83,8 @@ program
83
83
  let baseBranch = null;
84
84
  let baseCommit = null;
85
85
 
86
+ // Precedence: --base > --base-commit > other scope flags. The base modes already diff
87
+ // against the working tree, so they include uncommitted and untracked changes too.
86
88
  if (options.base) {
87
89
  scanType = 'base-branch';
88
90
  baseBranch = options.base;
@@ -123,8 +125,8 @@ program
123
125
  .option('--staged', 'Review only staged files')
124
126
  .option('--last-commit', 'Review last commit')
125
127
  .option('--last-n-commits <n>', 'Review last n commits (max 5)', toCount)
126
- .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
127
- .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
128
+ .option('--base <branch>', 'Review committed + uncommitted + untracked changes since the merge-base with a branch (e.g. --base develop). Takes precedence over the other scope flags')
129
+ .option('--base-commit <commit>', 'Review committed + uncommitted + untracked changes since a commit (e.g. --base-commit HEAD~3). Takes precedence over the other scope flags')
128
130
  .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
129
131
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
130
132
  .option('--exclude <paths>', 'Comma-separated list of file paths glob patterns to exclude')
@@ -135,6 +137,8 @@ program
135
137
  let baseBranch = null;
136
138
  let baseCommit = null;
137
139
 
140
+ // Precedence: --base > --base-commit > other scope flags. The base modes already diff
141
+ // against the working tree, so they include uncommitted and untracked changes too.
138
142
  if (options.base) {
139
143
  scanType = 'base-branch';
140
144
  baseBranch = options.base;
package/src/mcp/server.js CHANGED
@@ -639,8 +639,8 @@ export function createMcpServer() {
639
639
  .optional()
640
640
  .describe('Review scope. Default "uncommitted".'),
641
641
  lastNCommits: z.number().int().positive().max(5).optional(),
642
- baseBranch: z.string().optional(),
643
- baseCommit: z.string().optional(),
642
+ baseBranch: z.string().optional().describe('Branch for scope "base-branch". Reviews committed, uncommitted and untracked changes since the merge-base with this branch.'),
643
+ baseCommit: z.string().optional().describe('Commit for scope "base-commit". Reviews committed, uncommitted and untracked changes since this commit.'),
644
644
  include: z.array(z.string()).optional().describe('Glob patterns to include.'),
645
645
  exclude: z.array(z.string()).optional().describe('Glob patterns to exclude.'),
646
646
  },
@@ -1,9 +1,11 @@
1
1
  import fs from 'fs/promises';
2
+ import { constants as fsConstants } from 'fs';
2
3
  import path from 'path';
3
4
  import { promisify } from 'util';
4
- import { exec } from 'child_process';
5
+ import { exec, execFile } from 'child_process';
5
6
 
6
7
  const execAsync = promisify(exec);
8
+ const execFileAsync = promisify(execFile);
7
9
 
8
10
  /**
9
11
  * Expand untracked paths from `git status --porcelain`.
@@ -460,41 +462,90 @@ class GitDiffHelper {
460
462
 
461
463
  // Handle untracked files by generating synthetic "new file" diffs since git diff can't see them
462
464
  for (const file of expandedUntrackedFiles) {
463
- try {
464
- const absolutePath = path.resolve(this.gitRoot, file);
465
- const content = await fs.readFile(absolutePath, 'utf8');
466
- const lines = content.split('\n');
467
-
468
- let patchStr = `diff --git a/${file} b/${file}\n`;
469
- patchStr += `new file mode 100644\n`;
470
- patchStr += `index 0000000..${this._generateRandomHash()}\n`;
471
- patchStr += `--- /dev/null\n`;
472
- patchStr += `+++ b/${file}\n`;
473
- patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
474
- lines.forEach(line => { patchStr += `+${line}\n`; });
475
-
476
- diffs.push({
477
- base_file_str: '',
478
- head_file_str: content,
479
- patch_str: patchStr,
480
- filename_str: file,
481
- edit_type_str: 'ADDED',
482
- old_filename_str: null,
483
- num_plus_lines_str: String(lines.length),
484
- num_minus_lines_str: '0',
485
- tokens_str: String(content.split(/\s+/).length),
486
- start_line_str: '1',
487
- end_line_str: String(lines.length),
488
- });
489
- } catch (error) {
490
- // Skip files that can't be read
491
- }
465
+ const diff = await this._buildUntrackedFileDiff(file);
466
+ if (diff) diffs.push(diff);
492
467
  }
493
468
 
494
469
  this.baseCommit = originalBaseCommit;
495
470
  return diffs;
496
471
  }
497
472
 
473
+ /**
474
+ * Read a working-tree file. Returns null if it resolves outside the repository (a symlink
475
+ * pointing elsewhere) or isn't a regular file, so those contents are never uploaded.
476
+ */
477
+ async _readRepoFile(relPath) {
478
+ const realRoot = await fs.realpath(this.gitRoot);
479
+ const isInside = p => {
480
+ const rel = path.relative(realRoot, p);
481
+ return !!rel && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel);
482
+ };
483
+ const realPath = await fs.realpath(path.resolve(this.gitRoot, relPath));
484
+ if (!isInside(realPath)) return null;
485
+
486
+ // Open first and then check what was actually opened, so a symlink swapped in after the
487
+ // check above can't redirect the read. O_NONBLOCK keeps a FIFO from blocking the open.
488
+ const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fsConstants;
489
+ const handle = await fs.open(realPath, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
490
+ try {
491
+ const opened = await handle.stat();
492
+ if (!opened.isFile()) return null;
493
+ // The opened file must still be the file at this path, and the path must still resolve
494
+ // inside the repo (a parent directory may have been swapped for a symlink)
495
+ const nowPath = await fs.realpath(realPath);
496
+ const now = await fs.stat(nowPath);
497
+ if (!isInside(nowPath) || now.dev !== opened.dev || now.ino !== opened.ino) return null;
498
+ return await handle.readFile('utf8');
499
+ } finally {
500
+ await handle.close();
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Build a synthetic "new file" diff for an untracked file, which git diff can't see.
506
+ * Returns null if the file can't be read.
507
+ */
508
+ async _buildUntrackedFileDiff(file) {
509
+ try {
510
+ let content = await this._readRepoFile(file).catch(() => null);
511
+ let mode = '100644';
512
+ if (content === null) {
513
+ // A symlink we won't follow (it points outside the repo or nowhere): show the link
514
+ // itself, as git would once it's committed, using its target path and never the
515
+ // target's contents
516
+ const absolutePath = path.resolve(this.gitRoot, file);
517
+ if (!(await fs.lstat(absolutePath)).isSymbolicLink()) return null;
518
+ content = await fs.readlink(absolutePath);
519
+ mode = '120000';
520
+ }
521
+ const lines = content.split('\n');
522
+
523
+ let patchStr = `diff --git a/${file} b/${file}\n`;
524
+ patchStr += `new file mode ${mode}\n`;
525
+ patchStr += `index 0000000..${this._generateRandomHash()}\n`;
526
+ patchStr += `--- /dev/null\n`;
527
+ patchStr += `+++ b/${file}\n`;
528
+ patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
529
+ lines.forEach(line => { patchStr += `+${line}\n`; });
530
+
531
+ return {
532
+ base_file_str: '',
533
+ head_file_str: content,
534
+ patch_str: patchStr,
535
+ filename_str: file,
536
+ edit_type_str: 'ADDED',
537
+ old_filename_str: null,
538
+ num_plus_lines_str: String(lines.length),
539
+ num_minus_lines_str: '0',
540
+ tokens_str: String(content.split(/\s+/).length),
541
+ start_line_str: '1',
542
+ end_line_str: String(lines.length),
543
+ };
544
+ } catch (error) {
545
+ return null;
546
+ }
547
+ }
548
+
498
549
  async _getStagedChanges() {
499
550
  const { stdout: stagedFiles } = await execAsync(
500
551
  'git diff --name-only --cached HEAD',
@@ -525,7 +576,8 @@ class GitDiffHelper {
525
576
 
526
577
  /**
527
578
  * Get diff against a specific base branch.
528
- * Finds the merge-base between the given branch and HEAD, then diffs from there.
579
+ * Finds the merge-base between the given branch and HEAD, then diffs it against the
580
+ * working tree (committed + staged + unstaged + untracked).
529
581
  */
530
582
  async _getBaseBranchDiff(branch) {
531
583
  if (!this.gitRoot) {
@@ -539,8 +591,7 @@ class GitDiffHelper {
539
591
  let resolvedRef;
540
592
  for (const candidate of [branch, `origin/${branch}`]) {
541
593
  try {
542
- const { stdout } = await execAsync(`git rev-parse ${candidate}`, { cwd: this.gitRoot });
543
- resolvedRef = stdout.trim();
594
+ resolvedRef = await this._resolveCommitSha(candidate);
544
595
  break;
545
596
  } catch (err) {
546
597
  // try next
@@ -561,32 +612,12 @@ class GitDiffHelper {
561
612
  base = resolvedRef;
562
613
  }
563
614
 
564
- const { stdout: changedFiles } = await execAsync(
565
- `git diff --name-only ${base} HEAD`,
566
- { cwd: this.gitRoot }
567
- );
568
-
569
- const files = changedFiles.split('\n').map(f => f.trim()).filter(Boolean);
570
- if (files.length === 0) return [];
571
-
572
- const originalBaseCommit = this.baseCommit;
573
- try {
574
- this.baseCommit = base;
575
- this._headRef = 'HEAD';
576
-
577
- const diffs = await Promise.all(
578
- files.map(fp => this.getDiffInfoForFile(fp))
579
- );
580
- return diffs.flat();
581
- } finally {
582
- this.baseCommit = originalBaseCommit;
583
- this._headRef = null;
584
- }
615
+ return this._getWorkingTreeDiffAgainst(base);
585
616
  }
586
617
 
587
618
  /**
588
619
  * Get diff against a specific base commit.
589
- * Diffs from the given commit to HEAD.
620
+ * Diffs from the given commit to the working tree (committed + staged + unstaged + untracked).
590
621
  */
591
622
  async _getBaseCommitDiff(commit) {
592
623
  if (!this.gitRoot) {
@@ -599,29 +630,63 @@ class GitDiffHelper {
599
630
  // Resolve the commit ref (supports HEAD~3, sha, tag, etc.)
600
631
  let resolvedCommit;
601
632
  try {
602
- const { stdout } = await execAsync(`git rev-parse ${commit}`, { cwd: this.gitRoot });
603
- resolvedCommit = stdout.trim();
633
+ resolvedCommit = await this._resolveCommitSha(commit);
604
634
  } catch (err) {
605
635
  throw new Error(`Could not resolve commit: ${commit}`);
606
636
  }
607
637
 
638
+ return this._getWorkingTreeDiffAgainst(resolvedCommit);
639
+ }
640
+
641
+ /**
642
+ * Resolve a user-supplied ref (from the CLI or MCP) to a commit SHA without going through a
643
+ * shell, so later git commands only ever see the SHA. Throws if it doesn't name a commit.
644
+ */
645
+ async _resolveCommitSha(ref) {
646
+ // A leading "-" would be parsed as a git option (e.g. --output=<file>)
647
+ if (ref.startsWith('-')) throw new Error(`Invalid ref: ${ref}`);
648
+ const { stdout } = await execFileAsync(
649
+ 'git',
650
+ ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
651
+ { cwd: this.gitRoot }
652
+ );
653
+ return stdout.trim();
654
+ }
655
+
656
+ /**
657
+ * Diff a base commit against the working tree: commits since base, staged and unstaged
658
+ * changes (including staged new files), plus untracked files not ignored by .gitignore.
659
+ * Content is read from disk so it matches the patch, which getFileDiff also takes against
660
+ * the working tree.
661
+ */
662
+ async _getWorkingTreeDiffAgainst(base) {
663
+ // No HEAD argument: compares base to the working tree, which covers tracked files that
664
+ // were committed, staged or edited since base
608
665
  const { stdout: changedFiles } = await execAsync(
609
- `git diff --name-only ${resolvedCommit} HEAD`,
666
+ `git diff --name-only ${base}`,
667
+ { cwd: this.gitRoot }
668
+ );
669
+ const { stdout: untrackedStr } = await execAsync(
670
+ 'git ls-files --others --exclude-standard',
610
671
  { cwd: this.gitRoot }
611
672
  );
612
673
 
613
674
  const files = changedFiles.split('\n').map(f => f.trim()).filter(Boolean);
614
- if (files.length === 0) return [];
675
+ const untrackedFiles = untrackedStr.split('\n').map(f => f.trim()).filter(Boolean);
676
+ if (files.length === 0 && untrackedFiles.length === 0) return [];
615
677
 
616
678
  const originalBaseCommit = this.baseCommit;
617
679
  try {
618
- this.baseCommit = resolvedCommit;
619
- this._headRef = 'HEAD';
680
+ this.baseCommit = base;
681
+ this._headRef = null;
620
682
 
621
683
  const diffs = await Promise.all(
622
684
  files.map(fp => this.getDiffInfoForFile(fp))
623
685
  );
624
- return diffs.flat();
686
+ const untrackedDiffs = await Promise.all(
687
+ untrackedFiles.map(fp => this._buildUntrackedFileDiff(fp))
688
+ );
689
+ return [...diffs.flat(), ...untrackedDiffs.filter(Boolean)];
625
690
  } finally {
626
691
  this.baseCommit = originalBaseCommit;
627
692
  this._headRef = null;
@@ -762,35 +827,8 @@ class GitDiffHelper {
762
827
 
763
828
  // Handle untracked files with synthetic diffs
764
829
  for (const file of expandedUntrackedFiles) {
765
- try {
766
- const absolutePath = path.resolve(this.gitRoot, file);
767
- const content = await fs.readFile(absolutePath, 'utf8');
768
- const lines = content.split('\n');
769
-
770
- let patchStr = `diff --git a/${file} b/${file}\n`;
771
- patchStr += `new file mode 100644\n`;
772
- patchStr += `index 0000000..${this._generateRandomHash()}\n`;
773
- patchStr += `--- /dev/null\n`;
774
- patchStr += `+++ b/${file}\n`;
775
- patchStr += `@@ -0,0 +1,${lines.length} @@\n`;
776
- lines.forEach(line => { patchStr += `+${line}\n`; });
777
-
778
- diffs.push({
779
- base_file_str: '',
780
- head_file_str: content,
781
- patch_str: patchStr,
782
- filename_str: file,
783
- edit_type_str: 'ADDED',
784
- old_filename_str: null,
785
- num_plus_lines_str: String(lines.length),
786
- num_minus_lines_str: '0',
787
- tokens_str: String(content.split(/\s+/).length),
788
- start_line_str: '1',
789
- end_line_str: String(lines.length),
790
- });
791
- } catch (err) {
792
- // skip
793
- }
830
+ const diff = await this._buildUntrackedFileDiff(file);
831
+ if (diff) diffs.push(diff);
794
832
  }
795
833
  } finally {
796
834
  this.baseCommit = originalBaseCommit;
@@ -1028,8 +1066,8 @@ class GitDiffHelper {
1028
1066
  } catch (err) {
1029
1067
  // Handle untracked files
1030
1068
  try {
1031
- const absoluteFilePath = path.resolve(this.gitRoot, filePath);
1032
- const headFileStr = await fs.readFile(absoluteFilePath, 'utf8');
1069
+ const headFileStr = await this._readRepoFile(filePath);
1070
+ if (headFileStr === null) throw new Error('Not a regular file inside the repository');
1033
1071
  const lines = headFileStr.split('\n');
1034
1072
 
1035
1073
  let patchStr = `diff --git a/${filePath} b/${filePath}\n`;
@@ -1086,9 +1124,9 @@ class GitDiffHelper {
1086
1124
  { cwd: this.gitRoot }
1087
1125
  ).then(r => r.stdout).catch(() => '');
1088
1126
  } else {
1089
- headFileStr = await fs.readFile(
1090
- path.join(this.gitRoot, filePath), 'utf8'
1091
- ).catch(() => '');
1127
+ headFileStr = await this._readRepoFile(filePath)
1128
+ .then(content => content ?? '')
1129
+ .catch(() => '');
1092
1130
  }
1093
1131
 
1094
1132
  // Get patch