codeant-cli 0.1.2 → 0.1.3

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
@@ -115,6 +115,51 @@ Use `--include` and `--exclude` with glob patterns to filter files:
115
115
  - `MEDIUM` - Medium confidence, may need review
116
116
  - `FALSE_POSITIVE` - Detected but likely not a real secret (always ignored)
117
117
 
118
+ #### `static-analysis`
119
+
120
+ Run static code analysis to detect code quality issues, bugs, and code smells.
121
+
122
+ ```bash
123
+ codeant static-analysis [options]
124
+ ```
125
+
126
+ **Options:**
127
+
128
+ | Option | Description |
129
+ |--------|-------------|
130
+ | `--staged` | Scan only staged files (default) |
131
+ | `--all` | Scan all changed files compared to base branch |
132
+ | `--uncommitted` | Scan all uncommitted changes |
133
+ | `--last-commit` | Scan files from the last commit |
134
+ | `--fail-on <level>` | Fail on issues at or above this level (default: CRITICAL) |
135
+ | `--auto-fix` | Automatically apply fixes when available |
136
+ | `--include <patterns>` | Comma-separated glob patterns to include files |
137
+ | `--exclude <patterns>` | Comma-separated glob patterns to exclude files |
138
+
139
+ **Issue Levels:** `BLOCKER` > `CRITICAL` > `MAJOR` > `MINOR` > `INFO`
140
+
141
+ #### `security-analysis`
142
+
143
+ Run security analysis to detect vulnerabilities in your code.
144
+
145
+ ```bash
146
+ codeant security-analysis [options]
147
+ ```
148
+
149
+ **Options:**
150
+
151
+ | Option | Description |
152
+ |--------|-------------|
153
+ | `--staged` | Scan only staged files (default) |
154
+ | `--all` | Scan all changed files compared to base branch |
155
+ | `--uncommitted` | Scan all uncommitted changes |
156
+ | `--last-commit` | Scan files from the last commit |
157
+ | `--fail-on <level>` | Fail on issues at or above this level (default: HIGH) |
158
+ | `--include <patterns>` | Comma-separated glob patterns to include files |
159
+ | `--exclude <patterns>` | Comma-separated glob patterns to exclude files |
160
+
161
+ **Severity Levels:** `CRITICAL` > `HIGH` > `MEDIUM`
162
+
118
163
  #### `set-base-url <url>`
119
164
 
120
165
  Set a custom API base URL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeant-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,284 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Box, Text, useApp } from 'ink';
3
+ import { getConfigValue } from '../utils/config.js';
4
+ import { fetchApi } from '../utils/fetchApi.js';
5
+ import SecurityAnalysisApiHelper from '../utils/securityAnalysisApiHelper.js';
6
+
7
+ export default function SecurityAnalysis({ scanType = 'staged-only', failOn = 'HIGH', include = [], exclude = [] }) {
8
+ const { exit } = useApp();
9
+ const [status, setStatus] = useState('initializing');
10
+ const [issues, setIssues] = useState([]);
11
+ const [error, setError] = useState(null);
12
+ const [fileCount, setFileCount] = useState(0);
13
+
14
+ const apiKey = getConfigValue('apiKey');
15
+
16
+ // Handle not logged in state
17
+ useEffect(() => {
18
+ if (!apiKey) {
19
+ const id = setTimeout(() => exit(new Error('Not logged in')), 0);
20
+ return () => clearTimeout(id);
21
+ }
22
+ }, [apiKey, exit]);
23
+
24
+ // Check if logged in
25
+ if (!apiKey) {
26
+ return React.createElement(
27
+ Box,
28
+ { flexDirection: 'column', padding: 1 },
29
+ React.createElement(Text, { color: 'red' }, '✗ Not logged in.'),
30
+ React.createElement(Text, { color: 'gray' }, 'Run "codeant login" to authenticate.'),
31
+ );
32
+ }
33
+
34
+ // Helper to check if an issue should cause failure based on failOn level
35
+ const shouldFailOn = (severity) => {
36
+ const score = severity?.toUpperCase();
37
+ if (score === 'FALSE_POSITIVE') return false;
38
+ if (failOn === 'CRITICAL') return score === 'CRITICAL';
39
+ if (failOn === 'HIGH') return score === 'HIGH' || score === 'CRITICAL';
40
+ if (failOn === 'MEDIUM') return score === 'HIGH' || score === 'MEDIUM';
41
+ return true; // 'all' - fail on any non-false-positive
42
+ };
43
+
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+
47
+ async function scanForIssues() {
48
+ try {
49
+ if (cancelled) return;
50
+ setStatus('scanning');
51
+
52
+ // Initialize git helper and get staged files
53
+ const helper = new SecurityAnalysisApiHelper(process.cwd());
54
+ await helper.init();
55
+
56
+ if (cancelled) return;
57
+ const requestBody = await helper.buildSecurityAnalysisApiRequest(scanType, include, exclude);
58
+ if (cancelled) return;
59
+
60
+ if (requestBody.files.length === 0) {
61
+ if (!cancelled) setStatus('no_files');
62
+ return;
63
+ }
64
+
65
+ // Call the security analysis API
66
+ const response = await fetchApi(
67
+ '/extension/pr-review/security-analysis',
68
+ 'POST',
69
+ requestBody,
70
+ );
71
+
72
+ if (cancelled) return;
73
+
74
+ // Transform security issues response to the expected format
75
+ const detectedIssues = (response.securityIssues || []).map(file => ({
76
+ file_path: file.file_path,
77
+ issues: (file.security_issues || []).map(issue => ({
78
+ line_number: issue.line_number,
79
+ end_line: issue.end_line,
80
+ type: issue.issue_text,
81
+ confidence_score: issue.false_positive ? 'FALSE_POSITIVE' : issue.severity,
82
+ test_id: issue.test_id,
83
+ confidence: issue.confidence,
84
+ likelihood: issue.likelihood,
85
+ cwe: issue.cwe,
86
+ owasp: issue.owasp,
87
+ })),
88
+ }));
89
+
90
+ // Filter to only include files with actual issues
91
+ const filesWithIssues = detectedIssues.filter(
92
+ file => file.issues && file.issues.length > 0,
93
+ );
94
+
95
+ if (!cancelled) {
96
+ setIssues(filesWithIssues);
97
+ setStatus('done');
98
+ }
99
+ } catch (err) {
100
+ if (!cancelled) {
101
+ setError(err.message);
102
+ setStatus('error');
103
+ }
104
+ }
105
+ }
106
+
107
+ scanForIssues();
108
+
109
+ return () => {
110
+ cancelled = true;
111
+ };
112
+ }, [scanType, include, exclude]);
113
+
114
+ // Handle exit after status changes
115
+ useEffect(() => {
116
+ if (status === 'done') {
117
+ // Check if any issues should cause failure based on failOn level
118
+ const hasBlockingIssues = issues.some(file =>
119
+ file.issues.some(issue => issue.false_positive !== true &&shouldFailOn(issue.severity)),
120
+ );
121
+
122
+ if (hasBlockingIssues) {
123
+ setTimeout(() => {
124
+ process.exitCode = 1;
125
+ exit(new Error('Security issues detected'));
126
+ }, 100);
127
+ } else {
128
+ setTimeout(() => exit(), 100);
129
+ }
130
+ } else if (status === 'no_files') {
131
+ setTimeout(() => exit(), 100);
132
+ } else if (status === 'error') {
133
+ setTimeout(() => exit(new Error(error)), 100);
134
+ }
135
+ }, [status, issues]);
136
+
137
+ // Render: Initializing
138
+ if (status === 'initializing') {
139
+ return React.createElement(
140
+ Box,
141
+ { flexDirection: 'column', padding: 1 },
142
+ React.createElement(Text, { color: 'cyan' }, 'Initializing...'),
143
+ );
144
+ }
145
+
146
+ // Render: Scanning
147
+ if (status === 'scanning') {
148
+ return React.createElement(
149
+ Box,
150
+ { flexDirection: 'column', padding: 1 },
151
+ React.createElement(Text, { color: 'cyan' }, `Scanning ${fileCount} file(s) for security issues...`),
152
+ );
153
+ }
154
+
155
+ // Render: No files to scan
156
+ if (status === 'no_files') {
157
+ const noFilesMessages = {
158
+ 'staged-only': 'Stage some changes first with "git add".',
159
+ 'branch-diff': 'No changes found compared to the base branch.',
160
+ 'uncommitted': 'No uncommitted changes found.',
161
+ 'last-commit': 'No files found in the last commit.',
162
+ };
163
+
164
+ return React.createElement(
165
+ Box,
166
+ { flexDirection: 'column', padding: 1 },
167
+ React.createElement(Text, { color: 'yellow' }, 'No files to scan.'),
168
+ React.createElement(Text, { color: 'gray' }, noFilesMessages[scanType] || 'No files found.'),
169
+ );
170
+ }
171
+
172
+ // Render: Error
173
+ if (status === 'error') {
174
+ return React.createElement(
175
+ Box,
176
+ { flexDirection: 'column', padding: 1 },
177
+ React.createElement(Text, { color: 'red' }, '✗ Error: ', error),
178
+ );
179
+ }
180
+
181
+ // Render: Done with issues found
182
+ if (status === 'done' && issues.length > 0) {
183
+ const allIssues = issues.flatMap(file =>
184
+ file.issues.map(s => ({ ...s, file_path: file.file_path })),
185
+ );
186
+
187
+ const blockingIssues = allIssues.filter(s => s.false_positive !== true && shouldFailOn(s.severity));
188
+ const falsePositives = allIssues.filter(s => s.false_positive !== true && !shouldFailOn(s.severity));
189
+
190
+ const hasBlockingIssues = blockingIssues.length > 0;
191
+
192
+ // Group by file for display
193
+ const groupByFile = (issuesList) => {
194
+ const grouped = {};
195
+ issuesList.forEach(s => {
196
+ if (!grouped[s.file_path]) grouped[s.file_path] = [];
197
+ grouped[s.file_path].push(s);
198
+ });
199
+ return grouped;
200
+ };
201
+
202
+ const blockingByFile = groupByFile(blockingIssues);
203
+ const falsePositivesByFile = groupByFile(falsePositives);
204
+
205
+ const elements = [
206
+ // Header
207
+ React.createElement(
208
+ Text,
209
+ { key: 'header', color: hasBlockingIssues ? 'red' : 'yellow', bold: true },
210
+ hasBlockingIssues
211
+ ? `✗ ${blockingIssues.length} security issue(s) found!`
212
+ : `⚠ ${falsePositives.length} potential security issue(s) found (ignored)`,
213
+ ),
214
+ React.createElement(Text, { key: 'spacer1' }, ''),
215
+ ];
216
+
217
+ // Blocking issues
218
+ if (blockingIssues.length > 0) {
219
+ Object.entries(blockingByFile).forEach(([filePath, fileIssues]) => {
220
+ elements.push(
221
+ React.createElement(Text, { key: `file-${filePath}`, color: 'yellow', bold: true }, filePath),
222
+ );
223
+ fileIssues.forEach((issue, idx) => {
224
+ elements.push(
225
+ React.createElement(
226
+ Text,
227
+ { key: `issue-${filePath}-${idx}`, color: 'red' },
228
+ ` Line ${issue.line_number}: ${issue.type} (${issue.confidence_score})`,
229
+ ),
230
+ );
231
+ });
232
+ });
233
+ }
234
+
235
+ // False positives / ignored
236
+ if (falsePositives.length > 0) {
237
+ if (blockingIssues.length > 0) {
238
+ elements.push(React.createElement(Text, { key: 'spacer2' }, ''));
239
+ }
240
+ elements.push(
241
+ React.createElement(Text, { key: 'fp-header', color: 'gray' }, 'Ignored (false positives):'),
242
+ );
243
+ Object.entries(falsePositivesByFile).forEach(([filePath, fileIssues]) => {
244
+ elements.push(
245
+ React.createElement(Text, { key: `fp-file-${filePath}`, color: 'gray' }, ` ${filePath}`),
246
+ );
247
+ fileIssues.forEach((issue, idx) => {
248
+ elements.push(
249
+ React.createElement(
250
+ Text,
251
+ { key: `fp-issue-${filePath}-${idx}`, color: 'gray', dimColor: true },
252
+ ` Line ${issue.line_number}: ${issue.type} (${issue.confidence_score})`,
253
+ ),
254
+ );
255
+ });
256
+ });
257
+ }
258
+
259
+ elements.push(React.createElement(Text, { key: 'spacer3' }, ''));
260
+
261
+ if (hasBlockingIssues) {
262
+ elements.push(
263
+ React.createElement(Text, { key: 'footer', color: 'gray' }, 'Fix security issues before committing.'),
264
+ );
265
+ } else {
266
+ elements.push(
267
+ React.createElement(Text, { key: 'footer', color: 'green' }, '✓ Commit allowed (only false positives found)'),
268
+ );
269
+ }
270
+
271
+ return React.createElement(
272
+ Box,
273
+ { flexDirection: 'column', padding: 1 },
274
+ ...elements,
275
+ );
276
+ }
277
+
278
+ // Render: Done with no issues
279
+ return React.createElement(
280
+ Box,
281
+ { flexDirection: 'column', padding: 1 },
282
+ React.createElement(Text, { color: 'green' }, '✓ No security issues found'),
283
+ );
284
+ }
@@ -0,0 +1,405 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Box, Text, useApp } from 'ink';
3
+ import { getConfigValue } from '../utils/config.js';
4
+ import { fetchApi } from '../utils/fetchApi.js';
5
+ import StaticAnalysisApiHelper from '../utils/staticAnalysisApiHelper.js';
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+
9
+ export default function StaticAnalysis({ scanType = 'staged-only', failOn = 'CRITICAL', include = [], exclude = [], autoFix = false }) {
10
+ const { exit } = useApp();
11
+ const [status, setStatus] = useState('initializing');
12
+ const [issues, setIssues] = useState([]);
13
+ const [error, setError] = useState(null);
14
+ const [fileCount, setFileCount] = useState(0);
15
+ const [fixedFiles, setFixedFiles] = useState([]);
16
+ const apiKey = getConfigValue('apiKey');
17
+
18
+ // Handle not logged in state
19
+ useEffect(() => {
20
+ if (!apiKey) {
21
+ const id = setTimeout(() => exit(new Error('Not logged in')), 0);
22
+ return () => clearTimeout(id);
23
+ }
24
+ }, [apiKey, exit]);
25
+
26
+ // Check if logged in
27
+ if (!apiKey) {
28
+ return React.createElement(
29
+ Box,
30
+ { flexDirection: 'column', padding: 1 },
31
+ React.createElement(Text, { color: 'red' }, '✗ Not logged in.'),
32
+ React.createElement(Text, { color: 'gray' }, 'Run "codeant login" to authenticate.'),
33
+ );
34
+ }
35
+
36
+ // Helper to check if an issue should cause failure based on failOn level
37
+ const shouldFailOn = (issueType) => {
38
+ const type = issueType?.toUpperCase();
39
+ const failOnUpper = failOn?.toUpperCase();
40
+ if (!failOnUpper || failOnUpper === 'NONE') return false;
41
+ if (failOnUpper === 'BLOCKER') return type === 'BLOCKER';
42
+ if (failOnUpper === 'CRITICAL') return type === 'BLOCKER' || type === 'CRITICAL';
43
+ if (failOnUpper === 'MAJOR') return type === 'BLOCKER' || type === 'CRITICAL' || type === 'MAJOR';
44
+ if (failOnUpper === 'MINOR') return type === 'BLOCKER' || type === 'CRITICAL' || type === 'MAJOR' || type === 'MINOR';
45
+ return true; // 'INFO' or 'ALL' - fail on any issue
46
+ };
47
+
48
+ useEffect(() => {
49
+ let cancelled = false;
50
+
51
+ async function scanCode() {
52
+ try {
53
+ if (cancelled) return;
54
+ setStatus('scanning');
55
+
56
+ // Initialize git helper and get files
57
+ const helper = new StaticAnalysisApiHelper(process.cwd());
58
+ await helper.init();
59
+
60
+ if (cancelled) return;
61
+ const requestBody = await helper.buildStaticAnalysisApiRequest(scanType, include, exclude);
62
+ setFileCount(requestBody.filesData.length);
63
+
64
+ if (requestBody.filesData.length === 0) {
65
+ if (!cancelled) setStatus('no_files');
66
+ return;
67
+ }
68
+
69
+ // Call the static analysis API
70
+ const response = await fetchApi(
71
+ '/extension/prReview/static-analysis',
72
+ 'POST',
73
+ requestBody,
74
+ );
75
+
76
+ if (cancelled) return;
77
+ const codeSuggestions = response.codeSuggestions || [];
78
+
79
+ // Filter to only include files with actual issues
80
+ const filesWithIssues = codeSuggestions.filter(
81
+ file => file.issues && file.issues.length > 0,
82
+ );
83
+
84
+ // Auto-fix issues if enabled
85
+ const fixedFilesList = [];
86
+ if (autoFix && filesWithIssues.length > 0) {
87
+ const gitRoot = helper.getGitRoot();
88
+
89
+ for (const fileData of filesWithIssues) {
90
+ const fixableIssues = fileData.issues.filter(
91
+ issue => issue.fixAvailable && issue.fix?.inputFileEdits?.length > 0
92
+ );
93
+
94
+ if (fixableIssues.length > 0) {
95
+ try {
96
+ const filePath = path.join(gitRoot, fileData.file);
97
+ let fileContent = await fs.readFile(filePath, 'utf8');
98
+ const lines = fileContent.split('\n');
99
+
100
+ // Collect all text edits from all issues
101
+ const allEdits = [];
102
+ for (const issue of fixableIssues) {
103
+ for (const fileEdit of issue.fix.inputFileEdits) {
104
+ if (fileEdit.target === fileData.file) {
105
+ for (const textEdit of fileEdit.textEdits) {
106
+ allEdits.push({
107
+ textRange: textEdit.textRange,
108
+ newText: textEdit.newText,
109
+ });
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ // Sort edits by position (descending) to avoid offset issues
116
+ allEdits.sort((a, b) => {
117
+ if (b.textRange.startLine !== a.textRange.startLine) {
118
+ return b.textRange.startLine - a.textRange.startLine;
119
+ }
120
+ if (b.textRange.startLineOffset !== a.textRange.startLineOffset) {
121
+ return b.textRange.startLineOffset - a.textRange.startLineOffset;
122
+ }
123
+ if (b.textRange.endLine !== a.textRange.endLine) {
124
+ return b.textRange.endLine - a.textRange.endLine;
125
+ }
126
+ return b.textRange.endLineOffset - a.textRange.endLineOffset;
127
+ });
128
+
129
+ // Apply each edit
130
+ for (const edit of allEdits) {
131
+ const { startLine, startLineOffset, endLine, endLineOffset } = edit.textRange;
132
+
133
+ if (startLine === endLine) {
134
+ // Single line edit
135
+ const line = lines[startLine - 1];
136
+ lines[startLine - 1] =
137
+ line.substring(0, startLineOffset) +
138
+ edit.newText +
139
+ line.substring(endLineOffset);
140
+ } else {
141
+ // Multi-line edit
142
+ const firstLine = lines[startLine - 1];
143
+ const lastLine = lines[endLine - 1];
144
+
145
+ const newContent =
146
+ firstLine.substring(0, startLineOffset) +
147
+ edit.newText +
148
+ lastLine.substring(endLineOffset);
149
+
150
+ // Replace the lines
151
+ lines.splice(startLine - 1, endLine - startLine + 1, newContent);
152
+ }
153
+ }
154
+
155
+ await fs.writeFile(filePath, lines.join('\n'), 'utf8');
156
+ fixedFilesList.push({
157
+ file: fileData.file,
158
+ fixedCount: fixableIssues.length,
159
+ });
160
+ } catch (err) {
161
+ console.error(`Failed to fix ${fileData.file}: ${err.message}`);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ if (!cancelled) {
168
+ setFixedFiles(fixedFilesList);
169
+ setIssues(filesWithIssues);
170
+ setStatus('done');
171
+ }
172
+ } catch (err) {
173
+ if (!cancelled) {
174
+ setError(err.message);
175
+ setStatus('error');
176
+ }
177
+ }
178
+ }
179
+
180
+ scanCode();
181
+
182
+ return () => {
183
+ cancelled = true;
184
+ };
185
+ }, [scanType, include, exclude, autoFix]);
186
+
187
+ // Handle exit after status changes
188
+ useEffect(() => {
189
+ if (status === 'done') {
190
+ // Check if any issues should cause failure based on failOn level
191
+ const hasBlockingIssues = issues.some(file =>
192
+ file.issues.some(issue => shouldFailOn(issue.type)),
193
+ );
194
+
195
+ if (hasBlockingIssues) {
196
+ setTimeout(() => {
197
+ process.exitCode = 1;
198
+ exit(new Error('Code issues detected'));
199
+ }, 100);
200
+ } else {
201
+ setTimeout(() => exit(), 100);
202
+ }
203
+ } else if (status === 'no_files') {
204
+ setTimeout(() => exit(), 100);
205
+ } else if (status === 'error') {
206
+ setTimeout(() => exit(new Error(error)), 100);
207
+ }
208
+ }, [status, issues, failOn, exit, shouldFailOn, error]);
209
+
210
+ // Render: Initializing
211
+ if (status === 'initializing') {
212
+ return React.createElement(
213
+ Box,
214
+ { flexDirection: 'column', padding: 1 },
215
+ React.createElement(Text, { color: 'cyan' }, 'Initializing...'),
216
+ );
217
+ }
218
+
219
+ // Render: Scanning
220
+ if (status === 'scanning') {
221
+ return React.createElement(
222
+ Box,
223
+ { flexDirection: 'column', padding: 1 },
224
+ React.createElement(Text, { color: 'cyan' }, `Analyzing ${fileCount} file(s)...`),
225
+ );
226
+ }
227
+
228
+ // Render: No files to scan
229
+ if (status === 'no_files') {
230
+ const noFilesMessages = {
231
+ 'staged-only': 'Stage some changes first with "git add".',
232
+ 'branch-diff': 'No changes found compared to the base branch.',
233
+ 'uncommitted': 'No uncommitted changes found.',
234
+ 'last-commit': 'No files found in the last commit.',
235
+ };
236
+
237
+ return React.createElement(
238
+ Box,
239
+ { flexDirection: 'column', padding: 1 },
240
+ React.createElement(Text, { color: 'yellow' }, 'No files to scan.'),
241
+ React.createElement(Text, { color: 'gray' }, noFilesMessages[scanType] || 'No files found.'),
242
+ );
243
+ }
244
+
245
+ // Render: Error
246
+ if (status === 'error') {
247
+ return React.createElement(
248
+ Box,
249
+ { flexDirection: 'column', padding: 1 },
250
+ React.createElement(Text, { color: 'red' }, '✗ Error: ', error),
251
+ );
252
+ }
253
+
254
+ // Render: Done with issues found
255
+ if (status === 'done' && issues.length > 0) {
256
+ const allIssues = issues.flatMap(file =>
257
+ file.issues.map(i => ({ ...i, file_path: file.file })),
258
+ );
259
+
260
+ const blockingIssues = allIssues.filter(i => shouldFailOn(i.type));
261
+ const nonBlockingIssues = allIssues.filter(i => !shouldFailOn(i.type));
262
+
263
+ const hasBlockingIssues = blockingIssues.length > 0;
264
+
265
+ // Group by file for display
266
+ const groupByFile = (issuesList) => {
267
+ const grouped = Object.create(null);
268
+ issuesList.forEach(i => {
269
+ if (!grouped[i.file_path]) grouped[i.file_path] = [];
270
+ grouped[i.file_path].push(i);
271
+ });
272
+ return grouped;
273
+ };
274
+
275
+ const blockingByFile = groupByFile(blockingIssues);
276
+ const nonBlockingByFile = groupByFile(nonBlockingIssues);
277
+
278
+ // Helper to get color based on issue type
279
+ const getIssueColor = (type) => {
280
+ const t = type?.toUpperCase();
281
+ if (t === 'BLOCKER') return 'red';
282
+ if (t === 'CRITICAL') return 'red';
283
+ if (t === 'MAJOR') return 'red';
284
+ if (t === 'MINOR') return 'yellow';
285
+ return 'gray';
286
+ };
287
+
288
+ const elements = [
289
+ // Header
290
+ React.createElement(
291
+ Text,
292
+ { key: 'header', color: hasBlockingIssues ? 'red' : 'yellow', bold: true },
293
+ hasBlockingIssues
294
+ ? `✗ ${blockingIssues.length} issue(s) found!`
295
+ : `⚠ ${nonBlockingIssues.length} issue(s) found (not blocking)`,
296
+ ),
297
+ React.createElement(Text, { key: 'spacer1' }, ''),
298
+ ];
299
+
300
+ // Blocking issues
301
+ if (blockingIssues.length > 0) {
302
+ Object.entries(blockingByFile).forEach(([filePath, fileIssues]) => {
303
+ elements.push(
304
+ React.createElement(Text, { key: `file-${filePath}`, color: 'yellow', bold: true }, filePath),
305
+ );
306
+ fileIssues.forEach((issue, idx) => {
307
+ const color = getIssueColor(issue.type);
308
+ elements.push(
309
+ React.createElement(
310
+ Text,
311
+ { key: `issue-${filePath}-${idx}`, color },
312
+ ` Line ${issue.line_number}: ${issue.issue_text} (${issue.type})`,
313
+ ),
314
+ );
315
+ // Show fix suggestion if available
316
+ if (issue.fixAvailable && issue.fix?.message) {
317
+ elements.push(
318
+ React.createElement(
319
+ Text,
320
+ { key: `fix-${filePath}-${idx}`, color: 'green', dimColor: true },
321
+ ` 💡 ${issue.fix.message}`,
322
+ ),
323
+ );
324
+ }
325
+ });
326
+ });
327
+ }
328
+
329
+ // Non-blocking issues
330
+ if (nonBlockingIssues.length > 0) {
331
+ if (blockingIssues.length > 0) {
332
+ elements.push(React.createElement(Text, { key: 'spacer2' }, ''));
333
+ }
334
+ elements.push(
335
+ React.createElement(Text, { key: 'nb-header', color: 'gray' }, 'Additional issues (not blocking):')
336
+ );
337
+ Object.entries(nonBlockingByFile).forEach(([filePath, fileIssues]) => {
338
+ elements.push(
339
+ React.createElement(Text, { key: `nb-file-${filePath}`, color: 'gray' }, ` ${filePath}`)
340
+ );
341
+ fileIssues.forEach((issue, idx) => {
342
+ elements.push(
343
+ React.createElement(
344
+ Text,
345
+ { key: `nb-issue-${filePath}-${idx}`, color: 'gray', dimColor: true },
346
+ ` Line ${issue.line_number}: ${issue.issue_text} (${issue.type})`
347
+ )
348
+ );
349
+ });
350
+ });
351
+ }
352
+
353
+ elements.push(React.createElement(Text, { key: 'spacer3' }, ''));
354
+
355
+ // Show auto-fix summary
356
+ if (fixedFiles.length > 0) {
357
+ const totalFixed = fixedFiles.reduce((sum, f) => sum + f.fixedCount, 0);
358
+ elements.push(
359
+ React.createElement(Text, { key: 'autofix', color: 'green' }, `✓ Auto-fixed ${totalFixed} issue(s) in ${fixedFiles.length} file(s)`)
360
+ );
361
+ elements.push(React.createElement(Text, { key: 'spacer4' }, ''));
362
+ }
363
+
364
+ if (hasBlockingIssues) {
365
+ // TODO: change message based on whether auto-fix fixed some issues or not
366
+ const message = (fixedFiles.length > 0)
367
+ ? `✓ Fixed issues automatically. Please review changes before committing.`
368
+ : `Fix issues before committing.`;
369
+ elements.push(
370
+ React.createElement(Text, { key: 'footer', color: 'red' }, message)
371
+ );
372
+ } else {
373
+ elements.push(
374
+ React.createElement(Text, { key: 'footer', color: 'green' }, '✓ Commit allowed (no blocking issues)')
375
+ );
376
+ }
377
+
378
+ return React.createElement(
379
+ Box,
380
+ { flexDirection: 'column', padding: 1 },
381
+ ...elements
382
+ );
383
+ }
384
+
385
+ // Render: Done with no issues
386
+ const elements = [];
387
+
388
+ if (fixedFiles.length > 0) {
389
+ const totalFixed = fixedFiles.reduce((sum, f) => sum + f.fixedCount, 0);
390
+ elements.push(
391
+ React.createElement(Text, { key: 'autofix', color: 'green' }, `✓ Auto-fixed ${totalFixed} issue(s) in ${fixedFiles.length} file(s)`)
392
+ );
393
+ elements.push(React.createElement(Text, { key: 'spacer' }, ''));
394
+ }
395
+
396
+ elements.push(
397
+ React.createElement(Text, { key: 'no-issues', color: 'green' }, '✓ No issues found')
398
+ );
399
+
400
+ return React.createElement(
401
+ Box,
402
+ { flexDirection: 'column', padding: 1 },
403
+ ...elements
404
+ );
405
+ }
package/src/index.js CHANGED
@@ -8,6 +8,8 @@ import SetBaseUrl from './commands/setBaseUrl.js';
8
8
  import GetBaseUrl from './commands/getBaseUrl.js';
9
9
  import Login from './commands/login.js';
10
10
  import Logout from './commands/logout.js';
11
+ import StaticAnalysis from './commands/staticAnalysis.js';
12
+ import SecurityAnalysis from './commands/securityAnalysis.js';
11
13
  import Welcome from './components/Welcome.js';
12
14
 
13
15
  // Show welcome animation if no arguments provided
@@ -17,7 +19,7 @@ if (process.argv.length === 2) {
17
19
  program
18
20
  .name('codeant')
19
21
  .description('Code review CLI tool')
20
- .version('0.1.0');
22
+ .version('0.1.3');
21
23
 
22
24
  program
23
25
  .command('secrets')
@@ -36,11 +38,11 @@ program
36
38
  else if (options.lastCommit) scanType = 'last-commit';
37
39
 
38
40
  const include = options.include
39
- ? (Array.isArray(options.include) ? options.include : options.include.split(',')).map(s => s.trim()).filter(Boolean)
41
+ ? (Array.isArray(options.include) ? options.include : String(options.include).split(',')).map(s => s.trim()).filter(Boolean)
40
42
  : [];
41
43
 
42
44
  const exclude = options.exclude
43
- ? (Array.isArray(options.exclude) ? options.exclude : options.exclude.split(',')).map(s => s.trim()).filter(Boolean)
45
+ ? (Array.isArray(options.exclude) ? options.exclude : String(options.exclude).split(',')).map(s => s.trim()).filter(Boolean)
44
46
  : [];
45
47
 
46
48
  const failOn = options.failOn?.toUpperCase() || 'HIGH';
@@ -48,12 +50,71 @@ program
48
50
  render(React.createElement(Secrets, { scanType, failOn, include, exclude }));
49
51
  });
50
52
 
51
- program
52
- .command('set-base-url <url>')
53
- .description('Set the API base URL')
54
- .action((url) => {
55
- render(React.createElement(SetBaseUrl, { url }));
56
- });
53
+ program
54
+ .command('static-analysis')
55
+ .description('Run static analysis on your code')
56
+ .option('--staged', 'Scan only staged files (default)')
57
+ .option('--all', 'Scan all changed files')
58
+ .option('--uncommitted', 'Scan uncommitted changes')
59
+ .option('--last-commit', 'Scan last commit')
60
+ .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
61
+ .option('--auto-fix', 'Automatically apply fixes when available')
62
+ .option('--include <paths>', 'Comma-separated list of file paths regex to include')
63
+ .option('--exclude <paths>', 'Comma-separated list of file paths regex to exclude')
64
+ .action((options) => {
65
+ let scanType = 'staged-only';
66
+ if (options.all) scanType = 'branch-diff';
67
+ else if (options.uncommitted) scanType = 'uncommitted';
68
+ else if (options.lastCommit) scanType = 'last-commit';
69
+
70
+ const include = options.include
71
+ ? (Array.isArray(options.include) ? options.include : String(options.include).split(',')).map(s => s.trim()).filter(Boolean)
72
+ : [];
73
+
74
+ const exclude = options.exclude
75
+ ? (Array.isArray(options.exclude) ? options.exclude : String(options.exclude).split(',')).map(s => s.trim()).filter(Boolean)
76
+ : [];
77
+
78
+ const failOn = options.failOn?.toUpperCase() || 'CRITICAL';
79
+ const autoFix = options.autoFix || false;
80
+ render(React.createElement(StaticAnalysis, {scanType, failOn, include, exclude, autoFix}));
81
+ });
82
+
83
+ program
84
+ .command('security-analysis')
85
+ .description('Run security analysis on your code')
86
+ .option('--staged', 'Scan only staged files (default)')
87
+ .option('--all', 'Scan all changed files')
88
+ .option('--uncommitted', 'Scan uncommitted changes')
89
+ .option('--last-commit', 'Scan last commit')
90
+ .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
91
+ .option('--include <paths>', 'Comma-separated list of file paths regex to include')
92
+ .option('--exclude <paths>', 'Comma-separated list of file paths regex to exclude')
93
+ .action((options) => {
94
+ let scanType = 'staged-only';
95
+ if (options.all) scanType = 'branch-diff';
96
+ else if (options.uncommitted) scanType = 'uncommitted';
97
+ else if (options.lastCommit) scanType = 'last-commit';
98
+
99
+ const include = options.include
100
+ ? (Array.isArray(options.include) ? options.include : String(options.include).split(',')).map(s => s.trim()).filter(Boolean)
101
+ : [];
102
+
103
+ const exclude = options.exclude
104
+ ? (Array.isArray(options.exclude) ? options.exclude : String(options.exclude).split(',')).map(s => s.trim()).filter(Boolean)
105
+ : [];
106
+
107
+ const failOn = options.failOn?.toUpperCase() || 'HIGH';
108
+
109
+ render(React.createElement(SecurityAnalysis, { scanType, failOn, include, exclude }));
110
+ });
111
+
112
+ program
113
+ .command('set-base-url <url>')
114
+ .description('Set the API base URL')
115
+ .action((url) => {
116
+ render(React.createElement(SetBaseUrl, { url }));
117
+ });
57
118
 
58
119
  program
59
120
  .command('get-base-url')
@@ -0,0 +1,84 @@
1
+ import GitDiffHelper from './gitDiffHelper.js';
2
+
3
+ /**
4
+ * Common base class for API helpers that transform git diff data
5
+ * Contains shared functionality for filtering and retrieving files
6
+ */
7
+ class CommonApiHelper {
8
+ constructor(workspacePath) {
9
+ this.workspacePath = workspacePath;
10
+ this.gitHelper = new GitDiffHelper(workspacePath);
11
+ }
12
+
13
+ async init() {
14
+ await this.gitHelper.init();
15
+ }
16
+
17
+ /**
18
+ * Get staged files formatted for the API
19
+ * This is used for pre-commit hooks
20
+ */
21
+ async getStagedFilesForApi() {
22
+ const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'staged-only' });
23
+ return this._transformDiffsToApiFormat(diffs);
24
+ }
25
+
26
+ /**
27
+ * Get all changed files formatted for the API
28
+ */
29
+ async getChangedFilesForApi() {
30
+ const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'branch-diff' });
31
+ return this._transformDiffsToApiFormat(diffs);
32
+ }
33
+
34
+ /**
35
+ * Get uncommitted files formatted for the API
36
+ */
37
+ async getUncommittedFilesForApi() {
38
+ const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'uncommitted' });
39
+ return this._transformDiffsToApiFormat(diffs);
40
+ }
41
+
42
+ /**
43
+ * Get last commit files formatted for the API
44
+ */
45
+ async getLastCommitFilesForApi() {
46
+ const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'last-commit' });
47
+ return this._transformDiffsToApiFormat(diffs);
48
+ }
49
+
50
+ /**
51
+ * Transform diff info array to API format
52
+ * Must be implemented by subclasses
53
+ */
54
+ _transformDiffsToApiFormat(diffs) {
55
+ throw new Error('_transformDiffsToApiFormat must be implemented by subclass');
56
+ }
57
+
58
+
59
+ /**
60
+ * Get files based on scan type
61
+ * Returns the raw array - child classes handle filtering and wrapping
62
+ * @param {string} type - Type of scan (staged-only, branch-diff, etc.)
63
+ */
64
+ async getFilesForType(type = 'staged-only') {
65
+ switch (type) {
66
+ case 'staged-only':
67
+ return await this.getStagedFilesForApi();
68
+ case 'branch-diff':
69
+ return await this.getChangedFilesForApi();
70
+ case 'uncommitted':
71
+ return await this.getUncommittedFilesForApi();
72
+ case 'last-commit':
73
+ return await this.getLastCommitFilesForApi();
74
+ default:
75
+ return await this.getStagedFilesForApi();
76
+ }
77
+ }
78
+
79
+ getGitRoot() {
80
+ return this.gitHelper.getGitRoot();
81
+ }
82
+ }
83
+
84
+ export default CommonApiHelper;
@@ -1,4 +1,4 @@
1
- import GitDiffHelper from './gitDiffHelper.js';
1
+ import CommonApiHelper from './commonApiHelper.js';
2
2
  import { minimatch } from 'minimatch';
3
3
 
4
4
  /**
@@ -15,49 +15,7 @@ import { minimatch } from 'minimatch';
15
15
  * ]
16
16
  * }
17
17
  */
18
- class SecretsApiHelper {
19
- constructor(workspacePath) {
20
- this.workspacePath = workspacePath;
21
- this.gitHelper = new GitDiffHelper(workspacePath);
22
- }
23
-
24
- async init() {
25
- await this.gitHelper.init();
26
- }
27
-
28
- /**
29
- * Get staged files formatted for the secrets API
30
- * This is used for pre-commit hooks
31
- */
32
- async getStagedFilesForApi() {
33
- const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'staged-only' });
34
- return this._transformDiffsToApiFormat(diffs);
35
- }
36
-
37
- /**
38
- * Get all changed files formatted for the secrets API
39
- */
40
- async getChangedFilesForApi() {
41
- const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'branch-diff' });
42
- return this._transformDiffsToApiFormat(diffs);
43
- }
44
-
45
- /**
46
- * Get uncommitted files formatted for the secrets API
47
- */
48
- async getUncommittedFilesForApi() {
49
- const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'uncommitted' });
50
- return this._transformDiffsToApiFormat(diffs);
51
- }
52
-
53
- /**
54
- * Get last commit files formatted for the secrets API
55
- */
56
- async getLastCommitFilesForApi() {
57
- const diffs = await this.gitHelper.getDiffBasedOnReviewConfig({ type: 'last-commit' });
58
- return this._transformDiffsToApiFormat(diffs);
59
- }
60
-
18
+ class SecretsApiHelper extends CommonApiHelper {
61
19
  /**
62
20
  * Transform diff info array to API format
63
21
  * Groups by file and extracts diff ranges
@@ -69,6 +27,11 @@ class SecretsApiHelper {
69
27
  for (const diff of diffs) {
70
28
  const filePath = diff.filename_str;
71
29
 
30
+ // Skip diffs with missing or invalid filename
31
+ if (!filePath) {
32
+ continue;
33
+ }
34
+
72
35
  if (!fileMap.has(filePath)) {
73
36
  fileMap.set(filePath, {
74
37
  file_path: filePath,
@@ -96,24 +59,21 @@ class SecretsApiHelper {
96
59
 
97
60
  /**
98
61
  * Filter files based on include and exclude glob patterns
99
- * @param {Array} files - Array of file objects with file_path property
62
+ * @param {Array} files - Array of file objects with 'file_path' property
100
63
  * @param {Array} includePatterns - Array of glob pattern strings to include
101
64
  * @param {Array} excludePatterns - Array of glob pattern strings to exclude
102
- * @returns {Array} Filtered array of files
103
65
  */
104
66
  _filterFiles(files, includePatterns = [], excludePatterns = []) {
105
- return files.filter(file => {
106
- const filePath = file.file_path;
67
+ return files.filter(fileObj => {
68
+ const filePath = fileObj.file_path;
107
69
 
108
70
  // If include patterns are specified, file must match at least one
109
71
  if (includePatterns.length > 0) {
110
72
  const matchesInclude = includePatterns.some(pattern => {
111
73
  try {
112
- // If pattern is a RegExp, test it directly against the file path.
113
74
  if (pattern instanceof RegExp) {
114
75
  return pattern.test(filePath);
115
76
  }
116
- // Use matchBase option to match basename patterns like '*.js' for glob strings
117
77
  return minimatch(filePath, pattern, { matchBase: true });
118
78
  } catch (e) {
119
79
  console.warn(`Invalid include pattern: ${pattern}`, e.message);
@@ -130,11 +90,9 @@ class SecretsApiHelper {
130
90
  if (excludePatterns.length > 0) {
131
91
  const matchesExclude = excludePatterns.some(pattern => {
132
92
  try {
133
- // If pattern is a RegExp, test it directly against the file path.
134
93
  if (pattern instanceof RegExp) {
135
94
  return pattern.test(filePath);
136
95
  }
137
- // Use matchBase option to match basename patterns like '*.js' for glob strings
138
96
  return minimatch(filePath, pattern, { matchBase: true });
139
97
  } catch (e) {
140
98
  console.warn(`Invalid exclude pattern: ${pattern}`, e.message);
@@ -158,23 +116,11 @@ class SecretsApiHelper {
158
116
  * @param {Array} excludePatterns - Optional array of glob patterns to exclude files
159
117
  */
160
118
  async buildSecretsApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = []) {
161
- let files;
162
-
163
- switch (type) {
164
- case 'staged-only':
165
- files = await this.getStagedFilesForApi();
166
- break;
167
- case 'branch-diff':
168
- files = await this.getChangedFilesForApi();
169
- break;
170
- case 'uncommitted':
171
- files = await this.getUncommittedFilesForApi();
172
- break;
173
- case 'last-commit':
174
- files = await this.getLastCommitFilesForApi();
175
- break;
176
- default:
177
- files = await this.getStagedFilesForApi();
119
+ let files = await this.getFilesForType(type);
120
+
121
+ // Handle null/undefined return
122
+ if (!files || !Array.isArray(files)) {
123
+ files = [];
178
124
  }
179
125
 
180
126
  // Apply include/exclude filters
@@ -182,10 +128,6 @@ class SecretsApiHelper {
182
128
 
183
129
  return { files };
184
130
  }
185
-
186
- getGitRoot() {
187
- return this.gitHelper.getGitRoot();
188
- }
189
131
  }
190
132
 
191
133
  export default SecretsApiHelper;
@@ -0,0 +1,118 @@
1
+ import CommonApiHelper from './commonApiHelper.js';
2
+ import { minimatch } from 'minimatch';
3
+
4
+ /**
5
+ * Transforms git diff data into the format expected by the static analysis API
6
+ *
7
+ * API Input Format:
8
+ * {
9
+ * "filesData": [
10
+ * {
11
+ * "file": str,
12
+ * "code": str
13
+ * }
14
+ * ]
15
+ * }
16
+ */
17
+ class SecurityAnalysisApiHelper extends CommonApiHelper {
18
+ /**
19
+ * Transform diff info array to API format
20
+ * Groups by file (without diff ranges for static analysis)
21
+ */
22
+ _transformDiffsToApiFormat(diffs) {
23
+ // Group diffs by filename
24
+ const fileMap = new Map();
25
+
26
+ for (const diff of diffs) {
27
+ const filePath = diff.filename_str;
28
+
29
+ // Skip diffs with missing or invalid filename
30
+ if (!filePath) {
31
+ continue;
32
+ }
33
+
34
+ if (!fileMap.has(filePath)) {
35
+ fileMap.set(filePath, {
36
+ file_path: filePath,
37
+ code: diff.head_file_str || ''
38
+ });
39
+ }
40
+ }
41
+
42
+ return Array.from(fileMap.values());
43
+ }
44
+
45
+ /**
46
+ * Filter files based on include and exclude glob patterns
47
+ * @param {Array} files - Array of file objects with 'file' property
48
+ * @param {Array} includePatterns - Array of glob pattern strings to include
49
+ * @param {Array} excludePatterns - Array of glob pattern strings to exclude
50
+ */
51
+ _filterFiles(files, includePatterns = [], excludePatterns = []) {
52
+ return files.filter(fileObj => {
53
+ const filePath = fileObj.file;
54
+
55
+ // If include patterns are specified, file must match at least one
56
+ if (includePatterns.length > 0) {
57
+ const matchesInclude = includePatterns.some(pattern => {
58
+ try {
59
+ if (pattern instanceof RegExp) {
60
+ return pattern.test(filePath);
61
+ }
62
+ return minimatch(filePath, pattern, { matchBase: true });
63
+ } catch (e) {
64
+ console.warn(`Invalid include pattern: ${pattern}`, e.message);
65
+ return false;
66
+ }
67
+ });
68
+
69
+ if (!matchesInclude) {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // If exclude patterns are specified, file must not match any
75
+ if (excludePatterns.length > 0) {
76
+ const matchesExclude = excludePatterns.some(pattern => {
77
+ try {
78
+ if (pattern instanceof RegExp) {
79
+ return pattern.test(filePath);
80
+ }
81
+ return minimatch(filePath, pattern, { matchBase: true });
82
+ } catch (e) {
83
+ console.warn(`Invalid exclude pattern: ${pattern}`, e.message);
84
+ return false;
85
+ }
86
+ });
87
+
88
+ if (matchesExclude) {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ return true;
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Build the complete request body for the static analysis API
99
+ * @param {string} type - Type of scan (staged-only, branch-diff, etc.)
100
+ * @param {Array} includePatterns - Optional array of glob patterns to include files
101
+ * @param {Array} excludePatterns - Optional array of glob patterns to exclude files
102
+ */
103
+ async buildSecurityAnalysisApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = []) {
104
+ let files = await this.getFilesForType(type);
105
+
106
+ // Handle null/undefined return
107
+ if (!files || !Array.isArray(files)) {
108
+ files = [];
109
+ }
110
+
111
+ // Apply include/exclude filters
112
+ files = this._filterFiles(files, includePatterns, excludePatterns);
113
+
114
+ return { files };
115
+ }
116
+ }
117
+
118
+ export default SecurityAnalysisApiHelper;
@@ -0,0 +1,118 @@
1
+ import CommonApiHelper from './commonApiHelper.js';
2
+ import { minimatch } from 'minimatch';
3
+
4
+ /**
5
+ * Transforms git diff data into the format expected by the static analysis API
6
+ *
7
+ * API Input Format:
8
+ * {
9
+ * "filesData": [
10
+ * {
11
+ * "file": str,
12
+ * "code": str
13
+ * }
14
+ * ]
15
+ * }
16
+ */
17
+ class StaticAnalysisApiHelper extends CommonApiHelper {
18
+ /**
19
+ * Transform diff info array to API format
20
+ * Groups by file (without diff ranges for static analysis)
21
+ */
22
+ _transformDiffsToApiFormat(diffs) {
23
+ // Group diffs by filename
24
+ const fileMap = new Map();
25
+
26
+ for (const diff of diffs) {
27
+ const filePath = diff.filename_str;
28
+
29
+ // Skip diffs with missing or invalid filename
30
+ if (!filePath) {
31
+ continue;
32
+ }
33
+
34
+ if (!fileMap.has(filePath)) {
35
+ fileMap.set(filePath, {
36
+ file: filePath,
37
+ code: diff.head_file_str || ''
38
+ });
39
+ }
40
+ }
41
+
42
+ return Array.from(fileMap.values());
43
+ }
44
+
45
+ /**
46
+ * Filter files based on include and exclude glob patterns
47
+ * @param {Array} files - Array of file objects with 'file' property
48
+ * @param {Array} includePatterns - Array of glob pattern strings to include
49
+ * @param {Array} excludePatterns - Array of glob pattern strings to exclude
50
+ */
51
+ _filterFiles(files, includePatterns = [], excludePatterns = []) {
52
+ return files.filter(fileObj => {
53
+ const filePath = fileObj.file;
54
+
55
+ // If include patterns are specified, file must match at least one
56
+ if (includePatterns.length > 0) {
57
+ const matchesInclude = includePatterns.some(pattern => {
58
+ try {
59
+ if (pattern instanceof RegExp) {
60
+ return pattern.test(filePath);
61
+ }
62
+ return minimatch(filePath, pattern, { matchBase: true });
63
+ } catch (e) {
64
+ console.warn(`Invalid include pattern: ${pattern}`, e.message);
65
+ return false;
66
+ }
67
+ });
68
+
69
+ if (!matchesInclude) {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // If exclude patterns are specified, file must not match any
75
+ if (excludePatterns.length > 0) {
76
+ const matchesExclude = excludePatterns.some(pattern => {
77
+ try {
78
+ if (pattern instanceof RegExp) {
79
+ return pattern.test(filePath);
80
+ }
81
+ return minimatch(filePath, pattern, { matchBase: true });
82
+ } catch (e) {
83
+ console.warn(`Invalid exclude pattern: ${pattern}`, e.message);
84
+ return false;
85
+ }
86
+ });
87
+
88
+ if (matchesExclude) {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ return true;
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Build the complete request body for the static analysis API
99
+ * @param {string} type - Type of scan (staged-only, branch-diff, etc.)
100
+ * @param {Array} includePatterns - Optional array of glob patterns to include files
101
+ * @param {Array} excludePatterns - Optional array of glob patterns to exclude files
102
+ */
103
+ async buildStaticAnalysisApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = []) {
104
+ let files = await this.getFilesForType(type);
105
+
106
+ // Handle null/undefined return
107
+ if (!files || !Array.isArray(files)) {
108
+ files = [];
109
+ }
110
+
111
+ // Apply include/exclude filters
112
+ files = this._filterFiles(files, includePatterns, excludePatterns);
113
+
114
+ return { filesData: files };
115
+ }
116
+ }
117
+
118
+ export default StaticAnalysisApiHelper;