codeant-cli 0.3.3 → 0.3.5

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.
Files changed (76) hide show
  1. package/package.json +8 -10
  2. package/src/commands/getBaseUrl.js +21 -0
  3. package/src/commands/login.js +120 -0
  4. package/src/commands/logout.js +30 -0
  5. package/src/commands/review.js +294 -0
  6. package/src/commands/secrets.js +107 -0
  7. package/src/commands/setBaseUrl.js +37 -0
  8. package/src/components/ReviewUI.js +410 -0
  9. package/src/components/SecretsUI.js +366 -0
  10. package/src/components/Welcome.js +144 -0
  11. package/src/index.js +390 -0
  12. package/src/reviewHeadless.js +198 -0
  13. package/src/rules/secrets.toml +4994 -0
  14. package/src/scm/azure/client.js +21 -0
  15. package/src/scm/azure/getCodeReview.js +20 -0
  16. package/src/scm/azure/getPullRequest.js +30 -0
  17. package/src/scm/azure/index.js +7 -0
  18. package/src/scm/azure/listCodeReviews.js +17 -0
  19. package/src/scm/azure/listPullRequestComments.js +41 -0
  20. package/src/scm/azure/listPullRequests.js +26 -0
  21. package/src/scm/azure/resolveConversation.js +22 -0
  22. package/src/scm/azure/searchComments.js +35 -0
  23. package/src/scm/bitbucket/client.js +18 -0
  24. package/src/scm/bitbucket/getCodeReview.js +3 -0
  25. package/src/scm/bitbucket/getPullRequest.js +26 -0
  26. package/src/scm/bitbucket/index.js +7 -0
  27. package/src/scm/bitbucket/listCodeReviews.js +17 -0
  28. package/src/scm/bitbucket/listPullRequestComments.js +33 -0
  29. package/src/scm/bitbucket/listPullRequests.js +33 -0
  30. package/src/scm/bitbucket/resolveConversation.js +21 -0
  31. package/src/scm/bitbucket/searchComments.js +29 -0
  32. package/src/scm/github/client.js +17 -0
  33. package/src/scm/github/getCodeReview.js +26 -0
  34. package/src/scm/github/getPullRequest.js +27 -0
  35. package/src/scm/github/index.js +7 -0
  36. package/src/scm/github/listCodeReviews.js +17 -0
  37. package/src/scm/github/listPullRequestComments.js +51 -0
  38. package/src/scm/github/listPullRequests.js +32 -0
  39. package/src/scm/github/resolveConversation.js +85 -0
  40. package/src/scm/github/searchComments.js +30 -0
  41. package/src/scm/gitlab/client.js +16 -0
  42. package/src/scm/gitlab/getCodeReview.js +18 -0
  43. package/src/scm/gitlab/getPullRequest.js +24 -0
  44. package/src/scm/gitlab/index.js +7 -0
  45. package/src/scm/gitlab/listCodeReviews.js +21 -0
  46. package/src/scm/gitlab/listPullRequestComments.js +44 -0
  47. package/src/scm/gitlab/listPullRequests.js +26 -0
  48. package/src/scm/gitlab/resolveConversation.js +14 -0
  49. package/src/scm/gitlab/searchComments.js +28 -0
  50. package/src/scm/index.js +97 -0
  51. package/src/tools/executeTool.js +31 -0
  52. package/src/tools/globTool.js +9 -0
  53. package/src/tools/grepTool.js +18 -0
  54. package/src/tools/lsTool.js +7 -0
  55. package/src/tools/readTool.js +12 -0
  56. package/src/utils/analytics.js +56 -0
  57. package/src/utils/baseUrl.js +11 -0
  58. package/src/utils/commonApiHelper.js +143 -0
  59. package/src/utils/config.js +42 -0
  60. package/src/utils/fetchApi.js +39 -0
  61. package/src/utils/gitDiffHelper.js +1203 -0
  62. package/src/utils/reviewApiHelper.js +291 -0
  63. package/src/utils/scmAuth.js +136 -0
  64. package/src/utils/secretsApiHelper.js +252 -0
  65. package/src/utils/secretsDetector.js +210 -0
  66. package/dist/178.index.js +0 -1
  67. package/dist/239.index.js +0 -1
  68. package/dist/701.index.js +0 -1
  69. package/dist/880.index.js +0 -1
  70. package/dist/935.index.js +0 -1
  71. package/dist/966.index.js +0 -1
  72. package/dist/index.js +0 -28
  73. package/dist/package.json +0 -1
  74. package/dist/review/239.index.js +0 -1
  75. package/dist/review/index.js +0 -8
  76. package/dist/review/package.json +0 -3
@@ -0,0 +1,291 @@
1
+ import CommonApiHelper from './commonApiHelper.js';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { minimatch } from 'minimatch';
5
+
6
+ const MAX_REVIEW_FILES = 10;
7
+ const MAX_FILE_LINES = 5000;
8
+ const MAX_FILE_SIZE_BYTES = 200 * 1024; // 200 KB
9
+
10
+ const EXCLUDED_FILENAMES = new Set([
11
+ 'package-lock.json',
12
+ 'yarn.lock',
13
+ 'pnpm-lock.yaml',
14
+ 'composer.lock',
15
+ 'Gemfile.lock',
16
+ 'Cargo.lock',
17
+ 'poetry.lock',
18
+ 'Pipfile.lock',
19
+ 'go.sum',
20
+ '.DS_Store',
21
+ 'Thumbs.db',
22
+ ]);
23
+
24
+ const EXCLUDED_EXTENSIONS = new Set([
25
+ // Images
26
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.svg', '.webp', '.avif',
27
+ // Fonts
28
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
29
+ // Binary / compiled
30
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.class', '.pyc', '.pyo',
31
+ '.wasm', '.jar', '.war', '.ear',
32
+ // Archives
33
+ '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
34
+ // Media
35
+ '.mp3', '.mp4', '.wav', '.avi', '.mov', '.flv', '.wmv', '.ogg', '.webm',
36
+ // Data / generated
37
+ '.min.js', '.min.css', '.map',
38
+ '.pb', '.proto.bin',
39
+ // PDF / docs
40
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
41
+ // Data / config (often large, auto-generated)
42
+ '.csv', '.tsv', '.parquet', '.avro', '.ndjson',
43
+ // Database
44
+ '.sqlite', '.db',
45
+ ]);
46
+
47
+ function matchesPatterns(filePath, includePatterns, excludePatterns) {
48
+ if (includePatterns.length > 0) {
49
+ const included = includePatterns.some(pattern => minimatch(filePath, pattern, { matchBase: true }));
50
+ if (!included) return false;
51
+ }
52
+ if (excludePatterns.length > 0) {
53
+ const excluded = excludePatterns.some(pattern => minimatch(filePath, pattern, { matchBase: true }));
54
+ if (excluded) return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ function isReviewableFile(filePath) {
60
+ const basename = path.basename(filePath);
61
+
62
+ if (EXCLUDED_FILENAMES.has(basename)) return false;
63
+
64
+ const ext = path.extname(basename).toLowerCase();
65
+ if (EXCLUDED_EXTENSIONS.has(ext)) return false;
66
+
67
+ // Check for .min.js / .min.css (double extension)
68
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
69
+
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Transforms git diff data into the format expected by the agentic review API
75
+ */
76
+ class ReviewApiHelper extends CommonApiHelper {
77
+ /**
78
+ * Pass structured diffs through so buildReviewApiRequest can filter them
79
+ */
80
+ _transformDiffsToApiFormat(diffs) {
81
+ return diffs;
82
+ }
83
+
84
+ /**
85
+ * Build the complete request body for the agentic review API
86
+ */
87
+ async buildReviewApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = [], options = {}) {
88
+ const diffs = await this.getFilesForType(type, options);
89
+
90
+ // diffs is either a string (from getAllDiffs) or an array of structured diff objects
91
+ if (typeof diffs === 'string') {
92
+ // Split the raw diff into per-file sections and filter out deleted files
93
+ const gitRoot = this.getGitRoot() || process.cwd();
94
+ const fileContents = {};
95
+ const fileSections = diffs.split(/(?=^diff --git )/m).filter(Boolean);
96
+ const keptSections = [];
97
+ const skipped = [];
98
+
99
+ for (const section of fileSections) {
100
+ const nameMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m);
101
+ if (!nameMatch) continue;
102
+ const f = nameMatch[1];
103
+ if (!isReviewableFile(f)) {
104
+ skipped.push({ file: f, reason: 'binary/generated' });
105
+ continue;
106
+ }
107
+ // Apply user-specified --include/--exclude glob patterns
108
+ if (!matchesPatterns(f, includePatterns, excludePatterns)) {
109
+ skipped.push({ file: f, reason: 'excluded by pattern' });
110
+ continue;
111
+ }
112
+
113
+ // Skip deleted files — no file content to review, diff alone isn't actionable
114
+ if (/deleted file mode/m.test(section) || /\+\+\+ \/dev\/null/m.test(section)) {
115
+ skipped.push({ file: f, reason: 'deleted' });
116
+ continue;
117
+ }
118
+
119
+ // Skip files exceeding size or line limits to avoid blowing up the AI context window
120
+ try {
121
+ const fullPath = path.resolve(gitRoot, f);
122
+ if (fs.existsSync(fullPath)) {
123
+ const stats = fs.statSync(fullPath);
124
+ if (stats.size > MAX_FILE_SIZE_BYTES) {
125
+ skipped.push({ file: f, reason: `too large (${(stats.size / 1024).toFixed(0)} KB, max ${MAX_FILE_SIZE_BYTES / 1024} KB)` });
126
+ continue;
127
+ }
128
+ const content = fs.readFileSync(fullPath, 'utf-8');
129
+ const lineCount = content.split('\n').length;
130
+ if (lineCount > MAX_FILE_LINES) {
131
+ skipped.push({ file: f, reason: `too large (${lineCount} lines, max ${MAX_FILE_LINES})` });
132
+ continue;
133
+ }
134
+ keptSections.push(section);
135
+ fileContents[f] = content;
136
+ } else {
137
+ keptSections.push(section);
138
+ }
139
+ } catch {
140
+ // Skip files that can't be read
141
+ }
142
+ }
143
+
144
+ const reviewedFiles = Object.keys(fileContents);
145
+ return {
146
+ diff_content: keptSections.join(''),
147
+ file_contents: fileContents,
148
+ _meta: {
149
+ reviewed_files: reviewedFiles,
150
+ total_changed: fileSections.length,
151
+ skipped,
152
+ capped: false,
153
+ max_files: MAX_REVIEW_FILES,
154
+ },
155
+ };
156
+ }
157
+
158
+ // Filter and track skipped files
159
+ const skipped = [];
160
+ const allUniqueFiles = new Set(diffs.map(d => d.filename_str));
161
+ const reviewable = [];
162
+
163
+ for (const d of diffs) {
164
+ if (!isReviewableFile(d.filename_str)) {
165
+ skipped.push({ file: d.filename_str, reason: 'binary/generated' });
166
+ continue;
167
+ }
168
+ if (d.edit_type_str === 'DELETED') {
169
+ skipped.push({ file: d.filename_str, reason: 'deleted' });
170
+ continue;
171
+ }
172
+ if (!matchesPatterns(d.filename_str, includePatterns, excludePatterns)) {
173
+ skipped.push({ file: d.filename_str, reason: 'excluded by pattern' });
174
+ continue;
175
+ }
176
+ reviewable.push(d);
177
+ }
178
+
179
+ // Dedupe skipped entries
180
+ const seenSkipped = new Set();
181
+ const dedupedSkipped = skipped.filter(s => {
182
+ if (seenSkipped.has(s.file)) return false;
183
+ seenSkipped.add(s.file);
184
+ return true;
185
+ });
186
+
187
+ // Dedupe by filename, keeping all hunks, but limit unique files to MAX_REVIEW_FILES
188
+ const fileOrder = [];
189
+ const fileHunks = new Map();
190
+ let capped = false;
191
+ for (const d of reviewable) {
192
+ const f = d.filename_str;
193
+ if (!fileHunks.has(f)) {
194
+ if (fileOrder.length >= MAX_REVIEW_FILES) {
195
+ capped = true;
196
+ if (!seenSkipped.has(f)) {
197
+ dedupedSkipped.push({ file: f, reason: `exceeded ${MAX_REVIEW_FILES}-file limit` });
198
+ seenSkipped.add(f);
199
+ }
200
+ continue;
201
+ }
202
+ fileOrder.push(f);
203
+ fileHunks.set(f, []);
204
+ }
205
+ fileHunks.get(f).push(d);
206
+ }
207
+
208
+ // Reconstruct a combined diff string from the filtered hunks
209
+ const parts = [];
210
+ for (const f of fileOrder) {
211
+ const hunks = fileHunks.get(f);
212
+ // Build a file-level diff header + all hunk patches
213
+ const header = `diff --git a/${f} b/${f}\n--- a/${f}\n+++ b/${f}`;
214
+ const hunkPatches = hunks.map(h => h.patch_str).filter(Boolean);
215
+ if (hunkPatches.length > 0) {
216
+ parts.push(header + '\n' + hunkPatches.join('\n'));
217
+ }
218
+ }
219
+
220
+ // Use head_file_str from diffs so file content matches the diff version (avoids working tree mismatch for --last-commit etc.)
221
+ const fileContents = {};
222
+ const skippedLargeFiles = new Set();
223
+ for (const f of fileOrder) {
224
+ const hunks = fileHunks.get(f);
225
+ const content = hunks[0]?.head_file_str || '';
226
+ if (content) {
227
+ const contentSize = Buffer.byteLength(content, 'utf-8');
228
+ if (contentSize > MAX_FILE_SIZE_BYTES) {
229
+ skippedLargeFiles.add(f);
230
+ dedupedSkipped.push({ file: f, reason: `too large (${(contentSize / 1024).toFixed(0)} KB, max ${MAX_FILE_SIZE_BYTES / 1024} KB)` });
231
+ continue;
232
+ }
233
+ const lineCount = content.split('\n').length;
234
+ if (lineCount > MAX_FILE_LINES) {
235
+ skippedLargeFiles.add(f);
236
+ dedupedSkipped.push({ file: f, reason: `too large (${lineCount} lines, max ${MAX_FILE_LINES})` });
237
+ continue;
238
+ }
239
+ fileContents[f] = content;
240
+ }
241
+ }
242
+
243
+ // Remove diff sections for skipped files
244
+ const filteredParts = parts.filter((_, i) => !skippedLargeFiles.has(fileOrder[i]));
245
+
246
+ const reviewedFiles = fileOrder.filter(f => !skippedLargeFiles.has(f));
247
+ return {
248
+ diff_content: filteredParts.join('\n'),
249
+ file_contents: fileContents,
250
+ _meta: {
251
+ reviewed_files: reviewedFiles,
252
+ total_changed: allUniqueFiles.size,
253
+ skipped: dedupedSkipped,
254
+ capped,
255
+ max_files: MAX_REVIEW_FILES,
256
+ },
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Split a combined review request into per-file payloads.
262
+ * Each payload has a single file's diff and content.
263
+ */
264
+ static splitIntoPerFileRequests(requestBody) {
265
+ if (!requestBody?.diff_content?.length) return [];
266
+
267
+ const fileSections = requestBody.diff_content.split(/(?=^diff --git )/m).filter(Boolean);
268
+ const perFileRequests = [];
269
+
270
+ for (const section of fileSections) {
271
+ const nameMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m);
272
+ if (!nameMatch) continue;
273
+ const filename = nameMatch[1];
274
+
275
+ const fileContents = {};
276
+ if (requestBody.file_contents?.[filename]) {
277
+ fileContents[filename] = requestBody.file_contents[filename];
278
+ }
279
+
280
+ perFileRequests.push({
281
+ diff_content: section,
282
+ file_contents: fileContents,
283
+ _filename: filename,
284
+ });
285
+ }
286
+
287
+ return perFileRequests;
288
+ }
289
+ }
290
+
291
+ export default ReviewApiHelper;
@@ -0,0 +1,136 @@
1
+ import { execSync } from 'child_process';
2
+ import { getConfigValue } from './config.js';
3
+
4
+ /**
5
+ * Resolves auth tokens and base URLs for each SCM platform.
6
+ * Priority: env var → platform CLI → ~/.codeant/config.json → auto-detect from git origin
7
+ */
8
+
9
+ function tryExec(cmd) {
10
+ try {
11
+ return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ // ─── Auto-detect host from git remote origin ───
18
+
19
+ const PUBLIC_HOSTS = {
20
+ 'github.com': 'github',
21
+ 'gitlab.com': 'gitlab',
22
+ 'bitbucket.org': 'bitbucket',
23
+ 'dev.azure.com': 'azure',
24
+ 'visualstudio.com': 'azure',
25
+ };
26
+
27
+ let _originInfo = undefined; // cached
28
+
29
+ function getOriginInfo() {
30
+ if (_originInfo !== undefined) return _originInfo;
31
+ _originInfo = null;
32
+ try {
33
+ const origin = execSync('git remote get-url origin', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
34
+
35
+ // Parse host from SSH (git@host:owner/repo) or HTTPS (https://host/owner/repo)
36
+ let host = null;
37
+ const sshMatch = origin.match(/^[^@]+@([^:]+):/);
38
+ const httpsMatch = origin.match(/^https?:\/\/([^/]+)/);
39
+ if (sshMatch) host = sshMatch[1];
40
+ else if (httpsMatch) host = httpsMatch[1];
41
+
42
+ if (!host) return _originInfo;
43
+
44
+ // Determine platform from host
45
+ let platform = null;
46
+ for (const [publicHost, p] of Object.entries(PUBLIC_HOSTS)) {
47
+ if (host === publicHost || host.endsWith('.' + publicHost)) {
48
+ platform = p;
49
+ break;
50
+ }
51
+ }
52
+ // Heuristic for self-hosted: check if hostname contains platform name
53
+ if (!platform) {
54
+ if (host.includes('github')) platform = 'github';
55
+ else if (host.includes('gitlab')) platform = 'gitlab';
56
+ else if (host.includes('bitbucket')) platform = 'bitbucket';
57
+ else if (host.includes('azure') || host.includes('visualstudio')) platform = 'azure';
58
+ }
59
+
60
+ const isPublic = Object.keys(PUBLIC_HOSTS).some(h => host === h);
61
+ const baseUrl = isPublic ? null : `https://${host}`;
62
+
63
+ _originInfo = { host, platform, baseUrl, origin };
64
+ } catch {}
65
+ return _originInfo;
66
+ }
67
+
68
+ function detectBaseUrlForPlatform(platform) {
69
+ const info = getOriginInfo();
70
+ if (info && info.platform === platform && info.baseUrl) return info.baseUrl;
71
+ return null;
72
+ }
73
+
74
+ // ─── Token resolvers ───
75
+
76
+ export function getGitHubToken() {
77
+ return process.env.GITHUB_TOKEN
78
+ || process.env.GH_TOKEN
79
+ || tryExec('gh auth token')
80
+ || getConfigValue('githubToken')
81
+ || null;
82
+ }
83
+
84
+ export function getGitLabToken() {
85
+ return process.env.GITLAB_TOKEN
86
+ || tryExec('glab auth token 2>/dev/null')
87
+ || getConfigValue('gitlabToken')
88
+ || null;
89
+ }
90
+
91
+ export function getBitbucketToken() {
92
+ return process.env.BITBUCKET_TOKEN
93
+ || getConfigValue('bitbucketToken')
94
+ || null;
95
+ }
96
+
97
+ export function getAzureDevOpsToken() {
98
+ return process.env.AZURE_DEVOPS_TOKEN
99
+ || process.env.AZURE_DEVOPS_PAT
100
+ || getConfigValue('azureDevOpsToken')
101
+ || null;
102
+ }
103
+
104
+ export function getTokenForRemote(remote) {
105
+ switch (remote) {
106
+ case 'github': return getGitHubToken();
107
+ case 'gitlab': return getGitLabToken();
108
+ case 'bitbucket': return getBitbucketToken();
109
+ case 'azure': return getAzureDevOpsToken();
110
+ default: throw new Error(`Unknown remote: ${remote}. Use: github, gitlab, bitbucket, azure`);
111
+ }
112
+ }
113
+
114
+ // ─── Base URL resolvers for self-hosted instances ───
115
+ // Priority: env var → config → auto-detect from git origin
116
+
117
+ export function getGitHubBaseUrl() {
118
+ return process.env.GITHUB_API_URL
119
+ || process.env.GH_ENTERPRISE_URL
120
+ || getConfigValue('githubBaseUrl')
121
+ || detectBaseUrlForPlatform('github');
122
+ }
123
+
124
+ export function getGitLabBaseUrl() {
125
+ return process.env.GITLAB_URL
126
+ || process.env.GITLAB_HOST
127
+ || getConfigValue('gitlabBaseUrl')
128
+ || detectBaseUrlForPlatform('gitlab');
129
+ }
130
+
131
+ export function getBitbucketBaseUrl() {
132
+ return process.env.BITBUCKET_URL
133
+ || process.env.BITBUCKET_SERVER_URL
134
+ || getConfigValue('bitbucketBaseUrl')
135
+ || detectBaseUrlForPlatform('bitbucket');
136
+ }
@@ -0,0 +1,252 @@
1
+ import CommonApiHelper from './commonApiHelper.js';
2
+ import path from 'path';
3
+ import { minimatch } from 'minimatch';
4
+
5
+ /**
6
+ * Transforms git diff data into the format expected by the secrets detection API
7
+ *
8
+ * API Input Format:
9
+ * {
10
+ * "files": [
11
+ * {
12
+ * "file_path": str,
13
+ * "code": str,
14
+ * "diffs": [{ "start_line": int, "end_line": int }]
15
+ * }
16
+ * ]
17
+ * }
18
+ */
19
+
20
+ const EXCLUDED_FILENAMES = new Set([
21
+ 'package-lock.json',
22
+ 'yarn.lock',
23
+ 'pnpm-lock.yaml',
24
+ 'composer.lock',
25
+ 'Gemfile.lock',
26
+ 'Cargo.lock',
27
+ 'poetry.lock',
28
+ 'Pipfile.lock',
29
+ 'go.sum',
30
+ '.DS_Store',
31
+ 'Thumbs.db',
32
+ ]);
33
+
34
+ const EXCLUDED_EXTENSIONS = new Set([
35
+ // Images
36
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.svg', '.webp', '.avif',
37
+ // Fonts
38
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
39
+ // Binary / compiled
40
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.class', '.pyc', '.pyo',
41
+ '.wasm', '.jar', '.war', '.ear',
42
+ // Archives
43
+ '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
44
+ // Media
45
+ '.mp3', '.mp4', '.wav', '.avi', '.mov', '.flv', '.wmv', '.ogg', '.webm',
46
+ // Data / generated
47
+ '.min.js', '.min.css', '.map',
48
+ '.pb', '.proto.bin',
49
+ // PDF / docs
50
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
51
+ // Data / config (often large, auto-generated)
52
+ '.parquet', '.avro',
53
+ // Database
54
+ '.sqlite', '.db',
55
+ ]);
56
+
57
+ const MAX_FILE_LINES = 5000;
58
+ const MAX_FILE_SIZE_BYTES = 200 * 1024; // 200 KB
59
+
60
+ function isScannableFile(filePath) {
61
+ const basename = path.basename(filePath);
62
+
63
+ if (EXCLUDED_FILENAMES.has(basename)) return false;
64
+
65
+ const ext = path.extname(basename).toLowerCase();
66
+ if (EXCLUDED_EXTENSIONS.has(ext)) return false;
67
+
68
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
69
+
70
+ // Skip markdown files
71
+ if (ext === '.md') return false;
72
+
73
+ return true;
74
+ }
75
+
76
+ class SecretsApiHelper extends CommonApiHelper {
77
+ /**
78
+ * Transform diff info array to API format
79
+ * Groups by file and extracts diff ranges
80
+ */
81
+ _transformDiffsToApiFormat(diffs) {
82
+ // Group diffs by filename
83
+ const fileMap = new Map();
84
+
85
+ for (const diff of diffs) {
86
+ const filePath = diff.filename_str;
87
+
88
+ // Skip diffs with missing or invalid filename
89
+ if (!filePath) {
90
+ continue;
91
+ }
92
+
93
+ if (!fileMap.has(filePath)) {
94
+ fileMap.set(filePath, {
95
+ file_path: filePath,
96
+ code: diff.head_file_str || '',
97
+ diffs: []
98
+ });
99
+ }
100
+
101
+ // Add diff range if available
102
+ if (diff.start_line_str && diff.end_line_str) {
103
+ const startLine = parseInt(diff.start_line_str, 10);
104
+ const endLine = parseInt(diff.end_line_str, 10);
105
+
106
+ if (!isNaN(startLine) && !isNaN(endLine)) {
107
+ fileMap.get(filePath).diffs.push({
108
+ start_line: startLine,
109
+ end_line: endLine
110
+ });
111
+ }
112
+ }
113
+ }
114
+
115
+ return Array.from(fileMap.values());
116
+ }
117
+
118
+ /**
119
+ * Filter files based on include and exclude glob patterns
120
+ * @param {Array} files - Array of file objects with 'file_path' property
121
+ * @param {Array} includePatterns - Array of glob pattern strings to include
122
+ * @param {Array} excludePatterns - Array of glob pattern strings to exclude
123
+ */
124
+ _filterFiles(files, includePatterns = [], excludePatterns = []) {
125
+ return files.filter(fileObj => {
126
+ const filePath = fileObj.file_path;
127
+
128
+ // If include patterns are specified, file must match at least one
129
+ if (includePatterns.length > 0) {
130
+ const matchesInclude = includePatterns.some(pattern => {
131
+ try {
132
+ if (pattern instanceof RegExp) {
133
+ return pattern.test(filePath);
134
+ }
135
+ return minimatch(filePath, pattern, { matchBase: true });
136
+ } catch (e) {
137
+ console.warn(`Invalid include pattern: ${pattern}`, e.message);
138
+ return false;
139
+ }
140
+ });
141
+
142
+ if (!matchesInclude) {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ // If exclude patterns are specified, file must not match any
148
+ if (excludePatterns.length > 0) {
149
+ const matchesExclude = excludePatterns.some(pattern => {
150
+ try {
151
+ if (pattern instanceof RegExp) {
152
+ return pattern.test(filePath);
153
+ }
154
+ return minimatch(filePath, pattern, { matchBase: true });
155
+ } catch (e) {
156
+ console.warn(`Invalid exclude pattern: ${pattern}`, e.message);
157
+ return false;
158
+ }
159
+ });
160
+
161
+ if (matchesExclude) {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ return true;
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Build the complete request body for the secrets API
172
+ * @param {string} type - Type of scan (staged-only, branch-diff, etc.)
173
+ * @param {Array} includePatterns - Optional array of glob patterns to include files
174
+ * @param {Array} excludePatterns - Optional array of glob patterns to exclude files
175
+ * @param {Object} options - Additional options like lastNCommits
176
+ */
177
+ async buildSecretsApiRequest(type = 'staged-only', includePatterns = [], excludePatterns = [], options = {}) {
178
+ let files = await this.getFilesForType(type, options);
179
+
180
+ // Handle null/undefined return
181
+ if (!files || !Array.isArray(files)) {
182
+ files = [];
183
+ }
184
+
185
+ const totalChanged = files.length;
186
+ const skipped = [];
187
+
188
+ // Filter out non-scannable files (binary, lock files, markdown, etc.)
189
+ files = files.filter(fileObj => {
190
+ const filePath = fileObj.file_path;
191
+
192
+ if (!isScannableFile(filePath)) {
193
+ skipped.push({ file: filePath, reason: 'binary/generated' });
194
+ return false;
195
+ }
196
+
197
+ // Skip test files — backend filters these out anyway
198
+ if (filePath.toLowerCase().includes('test')) {
199
+ skipped.push({ file: filePath, reason: 'test file' });
200
+ return false;
201
+ }
202
+
203
+ // Skip deleted files (empty code)
204
+ if (fileObj.code === '' || fileObj.code === undefined) {
205
+ skipped.push({ file: filePath, reason: 'deleted' });
206
+ return false;
207
+ }
208
+
209
+ // Skip files exceeding size or line limits
210
+ if (fileObj.code) {
211
+ const contentSize = Buffer.byteLength(fileObj.code, 'utf-8');
212
+ if (contentSize > MAX_FILE_SIZE_BYTES) {
213
+ skipped.push({ file: filePath, reason: `too large (${(contentSize / 1024).toFixed(0)} KB, max ${MAX_FILE_SIZE_BYTES / 1024} KB)` });
214
+ return false;
215
+ }
216
+ const lineCount = fileObj.code.split('\n').length;
217
+ if (lineCount > MAX_FILE_LINES) {
218
+ skipped.push({ file: filePath, reason: `too large (${lineCount} lines, max ${MAX_FILE_LINES})` });
219
+ return false;
220
+ }
221
+ }
222
+
223
+ return true;
224
+ });
225
+
226
+ // Apply include/exclude filters
227
+ const beforePatternFilter = files.length;
228
+ files = this._filterFiles(files, includePatterns, excludePatterns);
229
+
230
+ // Track pattern-excluded files
231
+ if (files.length < beforePatternFilter) {
232
+ // We don't have exact file names for pattern-excluded, but the count is in skipped
233
+ const patternExcluded = beforePatternFilter - files.length;
234
+ if (patternExcluded > 0) {
235
+ skipped.push({ file: `${patternExcluded} file(s)`, reason: 'excluded by pattern' });
236
+ }
237
+ }
238
+
239
+ const scannedFiles = files.map(f => f.file_path);
240
+
241
+ return {
242
+ files,
243
+ _meta: {
244
+ scanned_files: scannedFiles,
245
+ total_changed: totalChanged,
246
+ skipped,
247
+ },
248
+ };
249
+ }
250
+ }
251
+
252
+ export default SecretsApiHelper;