@prodcycle/prodcycle 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,58 +1,496 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ComplianceApiClient = void 0;
3
+ exports.ComplianceApiClient = exports.ApiError = void 0;
4
+ exports.chunkFiles = chunkFiles;
5
+ /**
6
+ * Error thrown for any non-2xx response. Carries the parsed body + status so
7
+ * callers can branch on `details.suggestedEndpoint` (413 → chunked-session
8
+ * fallback) or `Retry-After` (429 / 503 → backoff + retry).
9
+ */
10
+ class ApiError extends Error {
11
+ statusCode;
12
+ body;
13
+ retryAfterSeconds;
14
+ constructor(statusCode, body, retryAfterSeconds, message) {
15
+ super(message);
16
+ this.statusCode = statusCode;
17
+ this.body = body;
18
+ this.retryAfterSeconds = retryAfterSeconds;
19
+ this.name = 'ApiError';
20
+ }
21
+ }
22
+ exports.ApiError = ApiError;
23
+ const DEFAULT_API_URL = 'https://api.prodcycle.com';
24
+ /**
25
+ * Read a positive integer from an env var or fall back to a default. Used
26
+ * for the timeout / retry knobs below so operators can tune behavior in CI
27
+ * without forking the CLI.
28
+ */
29
+ function envInt(name, fallback) {
30
+ const raw = process.env[name];
31
+ if (!raw)
32
+ return fallback;
33
+ const parsed = Number(raw);
34
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
35
+ }
36
+ /**
37
+ * Maximum retry attempts for 429/503 responses. After this many tries we
38
+ * give up and surface the error to the caller.
39
+ */
40
+ const MAX_RETRY_ATTEMPTS = envInt('PC_MAX_RETRY_ATTEMPTS', 4);
41
+ /**
42
+ * Hard ceiling on Retry-After (seconds). Even if the server asks for more
43
+ * than this we cap it so the CLI doesn't appear to hang indefinitely on a
44
+ * misconfigured server.
45
+ */
46
+ const MAX_RETRY_AFTER_SECONDS = envInt('PC_MAX_RETRY_AFTER_SECONDS', 300);
47
+ /**
48
+ * Per-request fetch timeout. Without this a stalled connection would tie
49
+ * up the CLI indefinitely, bypassing both the retry cap and the async-poll
50
+ * deadline. Default is 2 minutes — long enough for the largest non-async
51
+ * sync `/validate` call, short enough that a hung TCP socket gets aborted.
52
+ */
53
+ const REQUEST_TIMEOUT_MS = envInt('PC_REQUEST_TIMEOUT_MS', 120_000);
54
+ /**
55
+ * Conservative client-side chunk sizing for the chunked-session flow. The
56
+ * /chunks endpoint accepts up to 50 MB / 2000 files per request, but most
57
+ * customer payloads are well under this and smaller chunks shorten
58
+ * tail-latency on a single saturated chunk. The server's per-content
59
+ * findings cache means re-scans of unchanged files are O(1) regardless of
60
+ * chunk size, so picking on the smaller side costs little.
61
+ */
62
+ const DEFAULT_CHUNK_MAX_BYTES = envInt('PC_DEFAULT_CHUNK_MAX_BYTES', 5 * 1024 * 1024);
63
+ const DEFAULT_CHUNK_MAX_FILES = envInt('PC_DEFAULT_CHUNK_MAX_FILES', 200);
64
+ /**
65
+ * Async-validate poll cadence. The server typically completes scans in
66
+ * 10–60 s; polling every 2 s keeps the round-trip overhead bounded while
67
+ * still feeling responsive in interactive use.
68
+ */
69
+ const ASYNC_POLL_INTERVAL_MS = envInt('PC_ASYNC_POLL_INTERVAL_MS', 2000);
70
+ const ASYNC_POLL_TIMEOUT_MS = envInt('PC_ASYNC_POLL_TIMEOUT_MS', 10 * 60 * 1000);
71
+ /**
72
+ * Keys in `options.config` that route client-side behavior (sync /
73
+ * async / chunked dispatch, chunk sizing) and MUST NOT be forwarded to
74
+ * the server. Some endpoints validate `options` strictly and would 400
75
+ * on unknown keys, so leaking these would silently break every call.
76
+ */
77
+ const CLIENT_ONLY_CONFIG_KEYS = new Set([
78
+ 'mode',
79
+ 'chunkMaxBytes',
80
+ 'chunkMaxFiles',
81
+ ]);
4
82
  class ComplianceApiClient {
5
83
  apiUrl;
6
84
  apiKey;
7
85
  constructor(apiUrl, apiKey) {
8
- this.apiUrl = apiUrl || process.env.PC_API_URL || 'https://api.prodcycle.com';
86
+ this.apiUrl = apiUrl || process.env.PC_API_URL || DEFAULT_API_URL;
9
87
  this.apiKey = apiKey || process.env.PC_API_KEY || '';
10
- if (!this.apiKey && process.env.NODE_ENV !== 'test') {
11
- console.warn('Warning: PC_API_KEY is not set. API calls will likely fail.');
88
+ if (!this.apiKey &&
89
+ process.env.NODE_ENV !== 'test' &&
90
+ !process.env.PC_SUPPRESS_WARNINGS) {
91
+ process.stderr.write('Warning: PC_API_KEY is not set. API calls will likely fail.\n');
12
92
  }
13
93
  }
94
+ /**
95
+ * Synchronous validate. On a 413 with `details.suggestedEndpoint ===
96
+ * '/v1/compliance/scans'`, silently falls back to the chunked-session
97
+ * flow so large-repo CI jobs don't have to know the difference.
98
+ */
14
99
  async validate(files, frameworks, options = {}) {
15
- return this.post('/v1/compliance/validate', {
100
+ try {
101
+ return await this.request('POST', '/v1/compliance/validate', {
102
+ files,
103
+ frameworks,
104
+ options: this.buildOptions(options),
105
+ });
106
+ }
107
+ catch (err) {
108
+ if (err instanceof ApiError &&
109
+ err.statusCode === 413 &&
110
+ err.body?.error?.details?.suggestedEndpoint === '/v1/compliance/scans') {
111
+ // Server says: this payload won't fit, use chunked sessions instead.
112
+ // Fall back transparently — the caller asked for `validate`, the
113
+ // semantics (single scanId with final findings) are preserved.
114
+ return this.validateChunked(files, frameworks, options);
115
+ }
116
+ throw err;
117
+ }
118
+ }
119
+ /**
120
+ * Hook endpoint — small per-write call from coding agents. No
121
+ * suggestedEndpoint fallback because /hook keeps the historical 50 MB
122
+ * ceiling; if a single hook write exceeds that, the caller's batching
123
+ * is the bug to fix.
124
+ */
125
+ async hook(files, frameworks, options = {}) {
126
+ return this.request('POST', '/v1/compliance/hook', {
16
127
  files,
17
128
  frameworks,
18
- options: {
19
- severity_threshold: options.severityThreshold,
20
- fail_on: options.failOn,
21
- ...options.config,
22
- },
129
+ options: this.buildOptions(options),
23
130
  });
24
131
  }
25
- async hook(files, frameworks, options = {}) {
26
- return this.post('/v1/compliance/hook', {
27
- files,
132
+ // ─── Chunked sessions ───────────────────────────────────────────────────
133
+ /**
134
+ * Open a chunked scan session. Returns a `scanId` that subsequent
135
+ * `appendChunk` / `completeSession` calls reference. Server-side TTL is
136
+ * 30 minutes by default — abandoned sessions self-clean via the
137
+ * stale-session reaper.
138
+ */
139
+ async openSession(frameworks, options = {}) {
140
+ return this.request('POST', '/v1/compliance/scans', {
28
141
  frameworks,
29
- options: {
30
- severity_threshold: options.severityThreshold,
31
- fail_on: options.failOn,
32
- ...options.config,
33
- },
142
+ options: this.buildOptions(options),
34
143
  });
35
144
  }
36
- async post(endpoint, data) {
37
- const url = `${this.apiUrl}${endpoint}`;
145
+ /**
146
+ * Append a chunk of files to an open session. Each call has its own
147
+ * /hook-style cap (50 MB / 2000 files). The server caches per-content
148
+ * findings, so re-scans of unchanged files are O(1).
149
+ */
150
+ async appendChunk(scanId, files) {
151
+ return this.request('POST', `/v1/compliance/scans/${encodeURIComponent(scanId)}/chunks`, {
152
+ files,
153
+ });
154
+ }
155
+ /**
156
+ * Finalize a chunked session: flips status to COMPLETED, computes
157
+ * summary + passed, returns final findings.
158
+ */
159
+ async completeSession(scanId) {
160
+ return this.request('POST', `/v1/compliance/scans/${encodeURIComponent(scanId)}/complete`, {});
161
+ }
162
+ /**
163
+ * High-level helper: open → append (in chunks) → complete. Returns the
164
+ * same shape as `validate()` so callers that auto-fallback don't have
165
+ * to special-case the result.
166
+ *
167
+ * Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
168
+ * to override the conservative defaults.
169
+ */
170
+ async validateChunked(files, frameworks, options = {}) {
171
+ const chunkMaxBytes = options.config?.chunkMaxBytes ?? DEFAULT_CHUNK_MAX_BYTES;
172
+ const chunkMaxFiles = options.config?.chunkMaxFiles ?? DEFAULT_CHUNK_MAX_FILES;
173
+ const session = await this.openSession(frameworks, options);
174
+ const chunks = chunkFiles(files, chunkMaxBytes, chunkMaxFiles);
175
+ for (const chunk of chunks) {
176
+ await this.appendChunk(session.scanId, chunk);
177
+ }
178
+ const result = await this.completeSession(session.scanId);
179
+ const enriched = await this.backfillFindingsIfMissing(session.scanId, result);
180
+ return { scanId: session.scanId, ...enriched };
181
+ }
182
+ /**
183
+ * Some server versions of `POST /scans/:id/complete` return only the summary,
184
+ * leaving `findings` empty even when `summary.total > 0`. The findings are
185
+ * persisted on the scan record and recoverable via `GET /scans/:id`. Call
186
+ * this after `completeSession` (and any other path where the response shape
187
+ * may be summary-only) so SARIF/JSON consumers always see structured findings,
188
+ * not just a count. No-op when findings are already present or the scan is
189
+ * genuinely clean.
190
+ *
191
+ * Timeout: the follow-up GET goes through `this.request`, which wraps every
192
+ * fetch with `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` (120 s default,
193
+ * tunable via `PC_REQUEST_TIMEOUT_MS`). A stalled server can't hang
194
+ * `validateChunked` indefinitely; if the abort fires, the catch below
195
+ * falls through with the original summary-only result.
196
+ */
197
+ async backfillFindingsIfMissing(scanId, result) {
198
+ const findingsLength = Array.isArray(result.findings) ? result.findings.length : 0;
199
+ const summaryTotal = result.summary?.total ?? 0;
200
+ if (findingsLength > 0 || summaryTotal === 0)
201
+ return result;
38
202
  try {
39
- const response = await fetch(url, {
40
- method: 'POST',
41
- headers: {
42
- 'Authorization': `Bearer ${this.apiKey}`,
43
- 'Content-Type': 'application/json',
203
+ const full = await this.getScan(scanId);
204
+ if (Array.isArray(full.findings) && full.findings.length > 0) {
205
+ return { ...result, findings: full.findings };
206
+ }
207
+ // GET succeeded but findings were empty despite `summary.total > 0`.
208
+ // Most likely cause: eventual consistency between `/complete`'s summary
209
+ // computation and the scan-record findings writer. Without surfacing a
210
+ // signal here, the caller would see exactly the silent-drop state the
211
+ // backfill was added to prevent. Mark as `BACKFILL_GET_RETURNED_EMPTY`
212
+ // (distinct from the throw case) so consumers can branch on retry vs.
213
+ // hard-fail behavior.
214
+ const message = `findings still empty after GET /scans/${scanId} (summary reports ${summaryTotal})`;
215
+ process.stderr.write(`⚠ Findings backfill ${message}. ` +
216
+ `Run \`prodcycle scans ${scanId}\` after a short delay to retry.\n`);
217
+ return {
218
+ ...result,
219
+ backfillError: {
220
+ code: 'BACKFILL_GET_RETURNED_EMPTY',
221
+ message,
222
+ scanId,
223
+ summaryTotal,
44
224
  },
45
- body: JSON.stringify(data),
46
- });
47
- const responseData = await response.json();
48
- if (!response.ok) {
49
- throw new Error(responseData.error?.message || `API request failed with status ${response.status}`);
225
+ };
226
+ }
227
+ catch (err) {
228
+ // Best-effort enrichment: if the follow-up GET fails, fall through with
229
+ // the original result rather than break the scan call. The user still
230
+ // has the summary + scanId.
231
+ //
232
+ // BUT — without a user-facing signal, the resulting state (`findings: []`
233
+ // alongside `summary.total > 0`) looks exactly like the original bug we
234
+ // were fixing, and the user has no way to know they need to retry via
235
+ // `prodcycle scans <id>`. Surface the failure as both:
236
+ // - a stderr warning (humans running the CLI interactively)
237
+ // - a structured `backfillError` field (programmatic consumers / SARIF)
238
+ const message = err instanceof Error ? err.message : String(err);
239
+ process.stderr.write(`⚠ Findings backfill GET /scans/${scanId} failed (${message}). ` +
240
+ `${summaryTotal} finding(s) were detected but only the summary is available. ` +
241
+ `Run \`prodcycle scans ${scanId}\` to fetch the structured findings.\n`);
242
+ return {
243
+ ...result,
244
+ backfillError: {
245
+ code: 'BACKFILL_GET_FAILED',
246
+ message,
247
+ scanId,
248
+ summaryTotal,
249
+ },
250
+ };
251
+ }
252
+ }
253
+ // ─── Async validate ─────────────────────────────────────────────────────
254
+ /**
255
+ * Async-validate: returns a `scanId` immediately; caller polls
256
+ * `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
257
+ * runners that don't want to hold a connection for a 60 s scan.
258
+ */
259
+ async validateAsync(files, frameworks, options = {}) {
260
+ return this.request('POST', '/v1/compliance/validate?async=true', {
261
+ files,
262
+ frameworks,
263
+ options: this.buildOptions(options),
264
+ });
265
+ }
266
+ /**
267
+ * Fetch the current state of any scan (sync, async, or chunked-session).
268
+ */
269
+ async getScan(scanId) {
270
+ return this.request('GET', `/v1/compliance/scans/${encodeURIComponent(scanId)}`, null);
271
+ }
272
+ /**
273
+ * High-level helper: kicks off an async-validate, polls until terminal,
274
+ * returns the same shape as `validate()`.
275
+ */
276
+ async validateAndPoll(files, frameworks, options = {}) {
277
+ const { scanId } = await this.validateAsync(files, frameworks, options);
278
+ const deadline = Date.now() + ASYNC_POLL_TIMEOUT_MS;
279
+ // Always poll at least once, and always do one final poll *after*
280
+ // the last sleep before declaring a timeout — otherwise a scan that
281
+ // completes during the trailing sleep window would be reported as a
282
+ // timeout even though the result is sitting on the server.
283
+ while (true) {
284
+ const scan = await this.getScan(scanId);
285
+ if (scan.status === 'COMPLETED' || scan.status === 'FAILED') {
286
+ return { scanId, ...scan };
50
287
  }
51
- return responseData;
288
+ if (Date.now() >= deadline)
289
+ break;
290
+ await sleep(ASYNC_POLL_INTERVAL_MS);
52
291
  }
53
- catch (error) {
54
- throw new Error(`Failed to connect to ProdCycle API: ${error.message}`);
292
+ throw new Error(`Async validate scan ${scanId} did not complete within ${ASYNC_POLL_TIMEOUT_MS / 1000}s. Re-run with the same scanId to keep polling: prodcycle scans ${scanId}`);
293
+ }
294
+ // ─── Internals ──────────────────────────────────────────────────────────
295
+ buildOptions(options) {
296
+ const merged = {
297
+ severity_threshold: options.severityThreshold,
298
+ fail_on: options.failOn,
299
+ };
300
+ if (options.config && typeof options.config === 'object') {
301
+ // Strip client-routing keys (mode / chunkMaxBytes / chunkMaxFiles)
302
+ // before forwarding — they steer this SDK, not the server. The
303
+ // server's `options` schema may reject unknown keys strictly, in
304
+ // which case leaking these would cause 400s on every call.
305
+ for (const [key, value] of Object.entries(options.config)) {
306
+ if (CLIENT_ONLY_CONFIG_KEYS.has(key))
307
+ continue;
308
+ merged[key] = value;
309
+ }
55
310
  }
311
+ return merged;
312
+ }
313
+ /**
314
+ * Single HTTP request with:
315
+ * - auth header
316
+ * - 429/503 + Retry-After honoring (up to MAX_RETRY_ATTEMPTS)
317
+ * - structured error with parsed body so callers can introspect
318
+ * `details.suggestedEndpoint` etc.
319
+ */
320
+ async request(method, endpoint, data) {
321
+ const url = `${this.apiUrl.replace(/\/+$/, '')}${endpoint}`;
322
+ let lastError = null;
323
+ for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
324
+ let response;
325
+ try {
326
+ response = await fetch(url, {
327
+ method,
328
+ headers: {
329
+ Authorization: `Bearer ${this.apiKey}`,
330
+ ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}),
331
+ },
332
+ ...(data !== null ? { body: JSON.stringify(data) } : {}),
333
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
334
+ });
335
+ }
336
+ catch (networkErr) {
337
+ // Connection-level failures (DNS, TCP, TLS). Treat as retryable up
338
+ // to the same cap as 503 — the server may be momentarily down or
339
+ // the network blip may resolve.
340
+ lastError =
341
+ networkErr instanceof Error ? networkErr : new Error(String(networkErr));
342
+ if (attempt < MAX_RETRY_ATTEMPTS - 1) {
343
+ await sleep(retryBackoffMs(attempt));
344
+ continue;
345
+ }
346
+ throw new Error(`Failed to connect to ProdCycle API: ${lastError.message}`);
347
+ }
348
+ const responseText = await response.text();
349
+ let parsed = null;
350
+ try {
351
+ parsed = responseText ? JSON.parse(responseText) : null;
352
+ }
353
+ catch {
354
+ // Non-JSON body (e.g. ALB-level 502/504). Leave parsed as null;
355
+ // the retry path below handles based on status code.
356
+ }
357
+ if (response.ok) {
358
+ // Unwrap the API envelope only when the discriminant
359
+ // {status: "success" | "error", statusCode, data} is present.
360
+ // Bare key presence isn't enough: a `ScanResult` already has a
361
+ // `status` field (PASSED/FAILED/IN_PROGRESS) and an open index
362
+ // signature, so checking only for `'status' in parsed && 'data'
363
+ // in parsed` would misidentify a scan result that happens to
364
+ // include a `data` key as an envelope and silently drop the
365
+ // top-level `passed`, `scanId`, `findings` fields.
366
+ if (isApiEnvelope(parsed)) {
367
+ return parsed.data;
368
+ }
369
+ return parsed ?? {};
370
+ }
371
+ // Non-2xx. Inspect the body + Retry-After to decide whether to retry.
372
+ const retryAfterSeconds = parseRetryAfter(response.headers.get('retry-after'));
373
+ const errorBody = parsed ?? null;
374
+ const errorMessage = errorBody?.error?.message ?? `API request failed with status ${response.status}`;
375
+ const isRetryable = response.status === 429 || response.status === 503;
376
+ if (isRetryable && attempt < MAX_RETRY_ATTEMPTS - 1) {
377
+ const delayMs = retryAfterSeconds != null ? retryAfterSeconds * 1000 : retryBackoffMs(attempt);
378
+ const cappedDelayMs = Math.min(delayMs, MAX_RETRY_AFTER_SECONDS * 1000);
379
+ await sleep(cappedDelayMs);
380
+ continue;
381
+ }
382
+ throw new ApiError(response.status, errorBody, retryAfterSeconds, errorMessage);
383
+ }
384
+ // Unreachable in practice: every iteration returns, throws, or
385
+ // continues. Kept only to satisfy the type-checker that this method
386
+ // always returns or throws.
387
+ /* istanbul ignore next */
388
+ throw lastError ?? new Error('Exhausted retries without a response');
56
389
  }
57
390
  }
58
391
  exports.ComplianceApiClient = ComplianceApiClient;
392
+ // ─── Helpers ──────────────────────────────────────────────────────────────
393
+ function sleep(ms) {
394
+ return new Promise((resolve) => setTimeout(resolve, ms));
395
+ }
396
+ /**
397
+ * Exponential backoff with jitter for retryable errors that don't carry an
398
+ * explicit Retry-After (network failures, malformed 503).
399
+ */
400
+ function retryBackoffMs(attempt) {
401
+ const base = 1000 * 2 ** attempt; // 1s, 2s, 4s, 8s, ...
402
+ const jitter = Math.random() * 500;
403
+ return base + jitter;
404
+ }
405
+ /**
406
+ * Parse Retry-After header. Spec allows either:
407
+ * - delta-seconds (an integer)
408
+ * - HTTP-date
409
+ * We support both. Returns seconds as a non-negative integer, or null if
410
+ * the header is missing/unparseable.
411
+ */
412
+ /**
413
+ * Detect the API's *success* envelope shape strictly: a plain object
414
+ * whose `status` is exactly "success" and that carries a `data`
415
+ * payload. We deliberately do NOT match `status: "error"` here because
416
+ * `isApiEnvelope` is only consulted on the 2xx branch — accepting an
417
+ * error envelope on a 200 response would silently return `null as T`
418
+ * to the caller without ever raising, so downstream code would see
419
+ * `passed: undefined` with no signal that anything went wrong. Non-2xx
420
+ * error envelopes are handled by the explicit error path below.
421
+ *
422
+ * The strict-discriminant check is also necessary because `ScanResult`
423
+ * itself has a `status` field (PASSED/FAILED/IN_PROGRESS) and an open
424
+ * index signature, so a bare scan result with a `data` key would be
425
+ * misidentified as an envelope and have its top-level fields dropped.
426
+ */
427
+ function isApiEnvelope(value) {
428
+ if (!value || typeof value !== 'object')
429
+ return false;
430
+ const v = value;
431
+ return v.status === 'success' && 'data' in v;
432
+ }
433
+ function parseRetryAfter(value) {
434
+ if (!value)
435
+ return null;
436
+ const asInt = Number.parseInt(value, 10);
437
+ if (!Number.isNaN(asInt))
438
+ return Math.max(0, asInt);
439
+ const asDate = Date.parse(value);
440
+ if (!Number.isNaN(asDate)) {
441
+ return Math.max(0, Math.ceil((asDate - Date.now()) / 1000));
442
+ }
443
+ return null;
444
+ }
445
+ /**
446
+ * Split a `{ path: content }` map into chunks that respect both a byte
447
+ * cap and a file-count cap. UTF-8 byte-length is used since the server
448
+ * counts the request body's bytes after JSON serialisation; this is a
449
+ * conservative client-side approximation.
450
+ */
451
+ // Per-file overhead in the JSON-serialized request body. Each entry is
452
+ // `"path":"content",` which adds two pairs of quotes (4 bytes), one
453
+ // colon, one comma, and a small margin for backslash-escapes inside the
454
+ // content. 16 bytes is a conservative upper bound; the goal is to avoid
455
+ // undersizing — the server enforces the real cap.
456
+ const PER_FILE_JSON_OVERHEAD = 16;
457
+ // One-time wrapper overhead for the chunk request body itself
458
+ // (`{"files":{...}}`).
459
+ const PER_CHUNK_JSON_OVERHEAD = 16;
460
+ function chunkFiles(files, maxBytes, maxFiles) {
461
+ const chunks = [];
462
+ let current = {};
463
+ let currentBytes = PER_CHUNK_JSON_OVERHEAD;
464
+ let currentCount = 0;
465
+ for (const [filePath, content] of Object.entries(files)) {
466
+ const fileBytes = Buffer.byteLength(content, 'utf8') +
467
+ Buffer.byteLength(filePath, 'utf8') +
468
+ PER_FILE_JSON_OVERHEAD;
469
+ // If a single file exceeds the cap on its own we can't split it further
470
+ // here — emit it as its own chunk and let the server's per-file cap (if
471
+ // any) reject if needed. Common case: huge SQL dumps, generated bundles.
472
+ if (fileBytes + PER_CHUNK_JSON_OVERHEAD > maxBytes) {
473
+ if (currentCount > 0) {
474
+ chunks.push(current);
475
+ current = {};
476
+ currentBytes = PER_CHUNK_JSON_OVERHEAD;
477
+ currentCount = 0;
478
+ }
479
+ chunks.push({ [filePath]: content });
480
+ continue;
481
+ }
482
+ if (currentBytes + fileBytes > maxBytes || currentCount + 1 > maxFiles) {
483
+ chunks.push(current);
484
+ current = {};
485
+ currentBytes = PER_CHUNK_JSON_OVERHEAD;
486
+ currentCount = 0;
487
+ }
488
+ current[filePath] = content;
489
+ currentBytes += fileBytes;
490
+ currentCount += 1;
491
+ }
492
+ if (currentCount > 0) {
493
+ chunks.push(current);
494
+ }
495
+ return chunks;
496
+ }
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,15 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ /**
3
+ * Detect CI environment via well-known env vars set by the major
4
+ * platforms. When CI is detected, default `--format` flips to `sarif`
5
+ * (so output drops straight into GitHub code scanning / GitLab security
6
+ * dashboards / etc. without extra configuration). Users can still
7
+ * override with `--format table|json|prompt`.
8
+ *
9
+ * The flip is opt-out (set `--format table` explicitly to keep the
10
+ * human-readable output in CI logs). Heuristic, not load-bearing — if
11
+ * we miss a CI platform here the user gets the same default they
12
+ * would have anyway (`table`), they just have to add `--format sarif`
13
+ * by hand.
14
+ */
15
+ export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;