proof-of-commitment 1.32.0 → 1.34.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.
Files changed (2) hide show
  1. package/index.js +180 -32
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * proof-of-commitment CLI v1.32.0
3
+ * proof-of-commitment CLI v1.34.0
4
4
  * Scores npm/PyPI/Cargo/Go packages on behavioral commitment signals.
5
5
  * Usage: npx proof-of-commitment [packages...] [options]
6
6
  */
@@ -90,14 +90,49 @@ async function handle429(res) {
90
90
  // Non-JSON fallback — leave data as {}
91
91
  }
92
92
 
93
+ const partial = Array.isArray(data.packages_already_scored)
94
+ ? data.packages_already_scored
95
+ : [];
96
+
97
+ // Forward-compat: if backend returns partial scoring on 429,
98
+ // print what we have BEFORE the rescue message. Falls back to JSON
99
+ // dump if the row shape isn't a complete table row.
100
+ // (auditBatched aggregates across batches and prints its own table — it
101
+ // calls renderRescueCta() directly, skipping this block.)
102
+ if (partial.length > 0) {
103
+ try {
104
+ console.log();
105
+ console.log(clr(c.dim, ` Partial results scored before the limit hit (${partial.length}):`));
106
+ printTable(partial, { totalScanned: partial.length });
107
+ } catch {
108
+ console.log(JSON.stringify(partial, null, 2));
109
+ }
110
+ }
111
+
112
+ await renderRescueCta(data);
113
+ }
114
+
115
+ /**
116
+ * Render the rate-limit rescue CTA from a parsed 429 response body.
117
+ * Separated from handle429() so auditBatched can aggregate partials across
118
+ * 17 parallel batches, print ONE combined table, then surface ONE rescue
119
+ * CTA — instead of letting the first batch to land kill the process with
120
+ * only 3 of N*3 partials visible (where N = #batches hitting overshoot).
121
+ *
122
+ * Exit semantics preserved: this function ALWAYS process.exit(1) at the
123
+ * tail, so any caller that's reached the rescue CTA leaves the program.
124
+ *
125
+ * The shape mirrors `data` from a 429 JSON response:
126
+ * { message?, instant_key_url?, upgrade_url?, retry_after_seconds?,
127
+ * retry_after?, overshoot?, tier_suggestion?, upgrade?, tier?,
128
+ * shared_ip_hint? }
129
+ */
130
+ async function renderRescueCta(data) {
93
131
  const message = data.message || 'Daily free audit limit reached on this network IP.';
94
132
  const instantKeyUrl =
95
133
  data.instant_key_url ||
96
134
  data.upgrade_url ||
97
135
  'https://getcommit.dev/get-started?ref=audit-cli-429';
98
- const partial = Array.isArray(data.packages_already_scored)
99
- ? data.packages_already_scored
100
- : [];
101
136
  // Authenticated keys: retry_after (seconds, used by worker auth-middleware quota path).
102
137
  // Anonymous IPs: retry_after_seconds (legacy / overshoot rescue path).
103
138
  // Read both so both paths surface a reset-time hint.
@@ -134,19 +169,6 @@ async function handle429(res) {
134
169
  typeof data.tier === 'string' &&
135
170
  data.tier !== 'anonymous';
136
171
 
137
- // Forward-compat: if backend ever returns partial scoring on 429,
138
- // print what we have BEFORE the rescue message. Falls back to JSON
139
- // dump if the row shape isn't a complete table row.
140
- if (partial.length > 0) {
141
- try {
142
- console.log();
143
- console.log(clr(c.dim, ` Partial results scored before the limit hit (${partial.length}):`));
144
- printTable(partial, { totalScanned: partial.length });
145
- } catch {
146
- console.log(JSON.stringify(partial, null, 2));
147
- }
148
- }
149
-
150
172
  console.error('');
151
173
  console.error(clr(c.yellow + c.bold, `⚠ ${message}`));
152
174
  if (data.shared_ip_hint) {
@@ -313,6 +335,40 @@ function hasCritical(flags) {
313
335
  return flags && flags.some(f => typeof f === 'string' && f.startsWith('CRITICAL'));
314
336
  }
315
337
 
338
+ /**
339
+ * Footer-calibration helper — diagnoses when the CRITICAL count is so dense
340
+ * that the label has lost actionable meaning, and produces a reframe.
341
+ *
342
+ * Why this exists. Lockfile audits on real Node projects routinely report
343
+ * 40–60% of scanned packages as CRITICAL — the threshold (sole publisher +
344
+ * >10M weekly downloads) is structurally widespread in npm. When the footer
345
+ * says "157 CRITICAL packages found" without context, a new user reads it as
346
+ * either alarmist noise (close the tab) or as an unbounded ask (157 things
347
+ * to investigate — uninstall the tool). Both kill activation.
348
+ *
349
+ * The dogfood signal: 2026-06-16 commit-landing-v2 lockfile, authenticated,
350
+ * 326 scanned, 157 CRITICAL (48%). Reflection: "label loses meaning when
351
+ * half the tree is red." Calibration reframes 157-of-326 as "typical npm
352
+ * baseline; act on the top 3-5 by impact" — preserves the data and gives
353
+ * the user a concrete next step. Threshold 20% chosen because that's the
354
+ * point at which per-package action stops being feasible.
355
+ *
356
+ * Returns null when calibration shouldn't fire (low ratio, no scan total,
357
+ * trivially small scan). Returns a `{ ratio, percent, baseline }` object
358
+ * otherwise — caller decides how to render it.
359
+ */
360
+ function summarizeCriticalConcentration(criticalCount, totalScanned) {
361
+ if (!totalScanned || totalScanned < 10) return null;
362
+ if (!criticalCount || criticalCount <= 0) return null;
363
+ const ratio = criticalCount / totalScanned;
364
+ if (ratio < 0.20) return null;
365
+ return {
366
+ ratio,
367
+ percent: Math.round(ratio * 100),
368
+ baseline: true,
369
+ };
370
+ }
371
+
316
372
  function riskColor(flags, score) {
317
373
  if (hasCritical(flags)) return c.red + c.bold;
318
374
  if (score < 40) return c.yellow + c.bold;
@@ -592,9 +648,22 @@ function printTable(results, { totalScanned, totalCritical, lockfile } = {}) {
592
648
 
593
649
  const effectiveCritical = totalCritical !== undefined ? totalCritical : criticalInDisplay;
594
650
  if (effectiveCritical > 0) {
595
- const suffix = totalScanned ? ` (in ${totalScanned} packages scanned)` : '';
596
- console.log('\n' + clr(c.red + c.bold, `⚠ ${effectiveCritical} CRITICAL package${effectiveCritical > 1 ? 's' : ''} found${suffix}.`));
597
- console.log(clr(c.dim, ' CRITICAL = sole npm publisher + >10M weekly downloads (publish-access concentration risk)'));
651
+ const conc = summarizeCriticalConcentration(effectiveCritical, totalScanned);
652
+ if (conc) {
653
+ // High-density reframe: at ≥20% CRITICAL the per-package alarm framing
654
+ // loses meaning (the user can't audit a third of their tree). Recast as
655
+ // ecosystem baseline + actionable shortlist. Pinned by
656
+ // test/cli-critical-concentration.test.ts.
657
+ console.log('\n' + clr(c.red + c.bold, `⚠ ${effectiveCritical} CRITICAL packages (${conc.percent}% of ${totalScanned} scanned).`));
658
+ console.log(clr(c.dim, ' CRITICAL = sole npm publisher + >10M weekly downloads.'));
659
+ console.log(clr(c.dim, ' At this density you\'re seeing npm\'s baseline — most popular packages have'));
660
+ console.log(clr(c.dim, ' one publisher. Watch the top 3-5 above (highest impact); pin your lockfile'));
661
+ console.log(clr(c.dim, ' and audit deltas for the rest — concentration you can\'t avoid per-dep.'));
662
+ } else {
663
+ const suffix = totalScanned ? ` (in ${totalScanned} packages scanned)` : '';
664
+ console.log('\n' + clr(c.red + c.bold, `⚠ ${effectiveCritical} CRITICAL package${effectiveCritical > 1 ? 's' : ''} found${suffix}.`));
665
+ console.log(clr(c.dim, ' CRITICAL = sole npm publisher + >10M weekly downloads (publish-access concentration risk)'));
666
+ }
598
667
  if (provenanceCount > 0 && provenanceCount < results.length) {
599
668
  console.log(clr(c.cyan, ` 🔐 ${provenanceCount}/${results.length} use Trusted Publishing (OIDC provenance) — partial mitigation`));
600
669
  }
@@ -942,7 +1011,7 @@ async function inlineSignup(results, opts = {}) {
942
1011
 
943
1012
  function printHelp() {
944
1013
  console.log(`
945
- ${clr(c.bold, 'proof-of-commitment')} v1.32.0 — supply chain risk scorer
1014
+ ${clr(c.bold, 'proof-of-commitment')} v1.34.0 — supply chain risk scorer
946
1015
 
947
1016
  ${clr(c.bold, 'Usage:')}
948
1017
  npx proof-of-commitment Auto-detect manifest in current dir
@@ -1369,27 +1438,81 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
1369
1438
  let batchedCta = null;
1370
1439
  // Resolve auth once so all parallel batches share the same key lookup.
1371
1440
  const headers = await auditHeaders();
1372
- const results = await Promise.all(
1441
+
1442
+ // Use Promise.allSettled so a single rate-limit hit on one batch doesn't
1443
+ // discard the work the other batches successfully completed. Pre-fix
1444
+ // (2026-06-16 dogfood): backend in overshoot returns 429 with
1445
+ // RATE_LIMIT_TASTE=3 packages_already_scored on EVERY parallel batch
1446
+ // (each batch increments the IP counter, all see auditCount > limit,
1447
+ // each gets trimmed to 3 + 429). The first batch's 429 to land called
1448
+ // handle429() which process.exit(1)'d — discarding the OTHER 16 batches'
1449
+ // 3-package partials (16×3=48 packages of scored value silently dropped).
1450
+ //
1451
+ // Post-fix: aggregate 200 results AND 429 partials across all batches.
1452
+ // User sees up to N×3 packages instead of 3 when fully overshoot, and
1453
+ // (16×20)+(1×3)=323 packages instead of 0 when limit hits mid-scan.
1454
+ // Rescue CTA is surfaced ONCE at the end via _rescue (handed to
1455
+ // renderRescueCta in the calling path), not per-batch via handle429.
1456
+ const settled = await Promise.allSettled(
1373
1457
  batches.map(async (batch) => {
1374
1458
  const res = await fetch(API, {
1375
1459
  method: 'POST',
1376
1460
  headers,
1377
1461
  body: JSON.stringify({ packages: batch, ecosystem }),
1378
1462
  });
1379
- if (!res.ok) {
1380
- if (res.status === 429) await handle429(res);
1381
- const text = await res.text();
1382
- throw new Error(`API error ${res.status}: ${text}`);
1383
- }
1384
- const data = await res.json();
1385
- if (data._cta) batchedCta = data._cta;
1463
+ let data = {};
1464
+ try { data = await res.json(); } catch {}
1386
1465
  completed += batch.length;
1387
1466
  if (onProgress) onProgress(completed, packages.length);
1388
- return data.results || [];
1467
+ if (res.ok) {
1468
+ return { kind: 'ok', data };
1469
+ }
1470
+ if (res.status === 429) {
1471
+ return { kind: '429', data };
1472
+ }
1473
+ return { kind: 'error', status: res.status, data };
1389
1474
  })
1390
1475
  );
1391
1476
 
1392
- const all = results.flat();
1477
+ const aggregated = [];
1478
+ let rescue = null;
1479
+ let nonRateErrorMsg = null;
1480
+ for (const r of settled) {
1481
+ if (r.status === 'rejected') {
1482
+ nonRateErrorMsg = nonRateErrorMsg || String(r.reason && r.reason.message || r.reason || 'unknown');
1483
+ continue;
1484
+ }
1485
+ const v = r.value;
1486
+ if (v.kind === 'ok') {
1487
+ if (v.data._cta) batchedCta = v.data._cta;
1488
+ aggregated.push(...(v.data.results || []));
1489
+ } else if (v.kind === '429') {
1490
+ // First 429's payload carries the rescue metadata; later 429s'
1491
+ // metadata is interchangeable (same overshoot/upgrade context),
1492
+ // so we don't need to merge it.
1493
+ if (!rescue) rescue = v.data;
1494
+ aggregated.push(...(Array.isArray(v.data.packages_already_scored) ? v.data.packages_already_scored : []));
1495
+ } else if (v.kind === 'error') {
1496
+ const errMsg = (v.data && v.data.error) ? v.data.error : `API error ${v.status}`;
1497
+ nonRateErrorMsg = nonRateErrorMsg || errMsg;
1498
+ }
1499
+ }
1500
+
1501
+ // Hard failure: zero packages scored AND a non-rate-limit error fired.
1502
+ // (If rescue is set but aggregated is empty, fall through — the caller
1503
+ // will render the rescue CTA and exit; no need to throw.)
1504
+ if (aggregated.length === 0 && !rescue && nonRateErrorMsg) {
1505
+ throw new Error(nonRateErrorMsg);
1506
+ }
1507
+
1508
+ // Dedup by name+ecosystem — defensive against any backend echoing.
1509
+ const seen = new Set();
1510
+ const all = aggregated.filter((p) => {
1511
+ const k = `${p && p.name}:${p && p.ecosystem}`;
1512
+ if (seen.has(k)) return false;
1513
+ seen.add(k);
1514
+ return true;
1515
+ });
1393
1516
 
1394
1517
  // Sort: CRITICAL first, then by score ascending
1395
1518
  all.sort((a, b) => {
@@ -1399,7 +1522,7 @@ async function auditBatched(packages, ecosystem, { onProgress } = {}) {
1399
1522
  return (a.score || 100) - (b.score || 100);
1400
1523
  });
1401
1524
 
1402
- return { results: all, _cta: batchedCta };
1525
+ return { results: all, _cta: batchedCta, _rescue: rescue };
1403
1526
  }
1404
1527
 
1405
1528
  /** Parse --fail-on=<level>. Returns one of 'critical' | 'risky' | 'none'. */
@@ -2883,6 +3006,7 @@ async function main() {
2883
3006
  if (!structuredOutput) process.stdout.write(clr(c.dim, `Scanning ${packages.length} packages (${batches} batches in parallel)...`));
2884
3007
 
2885
3008
  let lastPct = 0;
3009
+ let batchRescue = null;
2886
3010
  try {
2887
3011
  const batchResult = await auditBatched(packages, ecosystem, {
2888
3012
  onProgress: (done, total) => {
@@ -2895,6 +3019,7 @@ async function main() {
2895
3019
  });
2896
3020
  allResults = batchResult.results;
2897
3021
  apiCta = batchResult._cta;
3022
+ batchRescue = batchResult._rescue || null;
2898
3023
  } catch (err) {
2899
3024
  console.error(`\nError: ${err.message}`);
2900
3025
  process.exit(1);
@@ -2917,6 +3042,7 @@ async function main() {
2917
3042
  criticalCount,
2918
3043
  provenanceCount,
2919
3044
  failOn,
3045
+ rateLimited: !!batchRescue,
2920
3046
  results: allResults,
2921
3047
  }, null, 2));
2922
3048
  process.exit(shouldFail(allResults, failOn) ? 1 : 0);
@@ -2928,6 +3054,28 @@ async function main() {
2928
3054
  const criticalTotal = allResults.filter(r => hasCritical(r.riskFlags)).length;
2929
3055
  printTable(displayed, { totalScanned: allResults.length, totalCritical: criticalTotal, lockfile: true });
2930
3056
  if (apiCta) console.log(clr(c.dim + c.cyan, `\n ${apiCta}`));
3057
+ // Rate-limit aggregation (2026-06-16 fix): if ANY batch hit 429, the
3058
+ // aggregated table above ALREADY includes packages_already_scored from
3059
+ // every 429'd batch (auditBatched flattens 200 + 429 results). Surface
3060
+ // ONE rescue CTA here that ALSO short-circuits the normal inlineSignup
3061
+ // path — the rescue CTA itself runs the TTY email→key prompt for free
3062
+ // tier users (overshoot/keyUpgrade branches print upgrade URL and exit).
3063
+ // renderRescueCta() always process.exit(1)s at its tail, so this is the
3064
+ // terminal path for any rate-limited scan.
3065
+ if (batchRescue) {
3066
+ // Override the per-batch "Scored 3 of 20 packages" message with
3067
+ // an aggregate-aware one before rendering. Pre-fix: user sent 326,
3068
+ // saw a "3 of 20" rescue message that referenced one batch's slice.
3069
+ // Post-fix: reflect what the user actually saw in the aggregated table.
3070
+ const isOvershoot = batchRescue.overshoot === true || batchRescue.tier_suggestion === 'developer';
3071
+ const rescueWithAggregate = {
3072
+ ...batchRescue,
3073
+ message: isOvershoot
3074
+ ? `Scored ${allResults.length} of ${packages.length} packages — you're past the free-tier daily limit on this IP. A free key gives 200/day but a ${packages.length}-package lockfile would burn through it; Developer ($15/mo) gives 1,000/day + batch API.`
3075
+ : `Scored ${allResults.length} of ${packages.length} packages — free-tier daily limit reached on this IP (often shared via corporate NAT / CI / dev container). A free API key in 30 seconds lifts the limit to 200/day.`,
3076
+ };
3077
+ await renderRescueCta(rescueWithAggregate); // exits 1
3078
+ }
2931
3079
  await inlineSignup(displayed, { engagementSignal: !!apiCta });
2932
3080
  if (shouldFail(allResults, failOn)) {
2933
3081
  console.error(clr(c.red + c.bold, `\n✗ --fail-on=${failOn} threshold met. Exit 1.`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proof-of-commitment",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "mcpName": "io.github.piiiico/proof-of-commitment",
5
5
  "description": "Supply chain security risk scorer for npm, PyPI, Cargo, and Go packages — behavioral signals that can't be faked",
6
6
  "type": "module",