@prodcycle/prodcycle 0.6.3 → 0.6.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.
@@ -47,10 +47,25 @@ const MAX_RETRY_AFTER_SECONDS = envInt('PC_MAX_RETRY_AFTER_SECONDS', 300);
47
47
  /**
48
48
  * Per-request fetch timeout. Without this a stalled connection would tie
49
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.
50
+ * deadline.
51
+ *
52
+ * Default is 5 minutes — chosen so the chunked-session `/chunks` upload
53
+ * path has enough headroom under server-side load. The bottleneck on
54
+ * busy servers is the per-chunk transaction (policy eval + per-finding
55
+ * unique-index check on `(scan_id, fingerprint)`), which can take tens
56
+ * of seconds on big chunks. Sync `/validate` scans normally finish in
57
+ * seconds, so a longer default doesn't hurt them — it only matters
58
+ * when a single request stalls. CI runs that want tighter feedback can
59
+ * shrink via `PC_REQUEST_TIMEOUT_MS`.
60
+ *
61
+ * Pre-fix this was 120 s and a megarepo chunked scan (infisical-
62
+ * infisical, ~11.5 k files, 2026-05-13 GA-validation sweep) burned
63
+ * through the full retry budget (4 × 120 s per stuck chunk) before
64
+ * giving up with `Failed to connect to ProdCycle API: The operation
65
+ * was aborted due to timeout`. The body-read retry path from #30 was
66
+ * firing correctly — it just wasn't enough budget.
52
67
  */
53
- const REQUEST_TIMEOUT_MS = envInt('PC_REQUEST_TIMEOUT_MS', 120_000);
68
+ const REQUEST_TIMEOUT_MS = envInt('PC_REQUEST_TIMEOUT_MS', 300_000);
54
69
  /**
55
70
  * Conservative client-side chunk sizing for the chunked-session flow. The
56
71
  * /chunks endpoint accepts up to 50 MB / 2000 files per request, but most
package/dist/utils/fs.js CHANGED
@@ -38,6 +38,12 @@ const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const minimatch_1 = require("minimatch");
40
40
  const MAX_FILE_SIZE = 256 * 1024; // 256 KB
41
+ // Reusable strict UTF-8 decoder. `TextDecoder` is stateless on the same
42
+ // instance (encoding + fatal are fixed at construction), so we allocate
43
+ // once at module load instead of once per file in the walk loop —
44
+ // repos with thousands of files would otherwise produce thousands of
45
+ // short-lived decoder objects per scan.
46
+ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
41
47
  /**
42
48
  * Total file ceiling per scan. Hit on the OSS-CLI benchmark scanning
43
49
  * `hapifhir/hapi-fhir` (~13k files) — the CLI silently dropped ~3k files
@@ -397,18 +403,31 @@ function walk(dir, repoRoot, gitignores, prodcycleIgnores, includePatterns, user
397
403
  }
398
404
  if (isBinary(buffer))
399
405
  continue;
400
- const content = buffer.toString('utf8');
401
- // Post-decode filter: the service enforces the 256 KB per-file limit
402
- // on the UTF-8 byte length of the decoded string content, which can
403
- // differ from the file's on-disk byte count. `buffer.toString('utf8')`
404
- // silently replaces invalid UTF-8 byte sequences with U+FFFD (3 UTF-8
405
- // bytes each), so a file with invalid bytes that's under 256 KB on
406
- // disk can balloon over the limit after the round trip. The cheap
407
- // `stats.size` check above would let it through; the service then
408
- // rejects the entire chunk with 413 and torpedoes the scan.
409
- // Re-measuring here keeps the CLI's filter aligned with the service
410
- // enforcement. Concrete case from the GA-validation sweep:
411
- // `web/pnpm-lock.yaml` with stray non-UTF-8 bytes.
406
+ // Strict UTF-8 decode: throw (and skip the file) on invalid byte
407
+ // sequences. Pre-fix this was `buffer.toString('utf8')`, which
408
+ // silently replaces invalid bytes with U+FFFD and includes the file
409
+ // anyway. Python's `open(encoding='utf-8')` raises UnicodeDecodeError
410
+ // on the same input and skips the file via its except clause, so
411
+ // Node ended up sending files Python wouldn't. Those files were
412
+ // overwhelmingly garbage (U+FFFD soup with no real content), but
413
+ // the inflated payload pushed many scans over the sync /validate
414
+ // limit and into the chunked-session fallback Node ran 5–75x
415
+ // slower than Python on the same repos (dexidp-dex: npm=525s vs
416
+ // py=7s, frappe-erpnext: 1448s vs 42s, both 0 finding differences)
417
+ // during the 2026-05-12 GA-validation sweep. Catch the decode
418
+ // error and treat exactly like Python.
419
+ let content;
420
+ try {
421
+ content = UTF8_DECODER.decode(buffer);
422
+ }
423
+ catch {
424
+ continue;
425
+ }
426
+ // Post-decode size filter mirrors the service's 256 KB per-file
427
+ // enforcement. Without invalid-UTF-8 inflation (now skipped above),
428
+ // post-decode byte length usually matches `stats.size`, but a BOM
429
+ // or rare normalization edge case can still differ — keep the
430
+ // re-measure as defense-in-depth.
412
431
  if (Buffer.byteLength(content, 'utf8') > MAX_FILE_SIZE)
413
432
  continue;
414
433
  files[relPath] = content;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Multi-framework policy-as-code compliance scanner for infrastructure and application code.",
5
5
  "homepage": "https://docs.prodcycle.com",
6
6
  "repository": {