microsoft/TypeAgent

Public

mirrored from https://github.com/microsoft/TypeAgentAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
bc2dc7df084977bc3da24a9398fd3a08d55c3e7e

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

ts/tools/scripts/fix-dependabot-alerts.mjs

3479lines · modecode

1#!/usr/bin/env node
2// Copyright (c) Microsoft Corporation.
3// Licensed under the MIT License.
4
5/**
6 * Downloads open Dependabot alerts from GitHub and attempts to resolve them
7 * by updating packages in the pnpm lock file via `pnpm update` or overrides.
8 *
9 * Run with --help to see available options and exit codes.
10 */
11
12import { spawnSync, execFile, execFileSync } from "node:child_process";
13import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { resolve, dirname } from "node:path";
16import { AsyncLocalStorage } from "node:async_hooks";
17import chalk from "chalk";
18import semver from "semver";
19
20// Derive ROOT from the git root + workspace prefix so running from a
21// subdirectory (e.g. ts/tools) still targets the correct workspace root.
22function detectWorkspaceRoot() {
23 try {
24 const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
25 encoding: "utf8",
26 })
27 .trim()
28 .replace(/\\/g, "/");
29 const cwdNorm = process.cwd().replace(/\\/g, "/");
30 const rel =
31 cwdNorm === gitRoot
32 ? ""
33 : cwdNorm.startsWith(gitRoot + "/")
34 ? cwdNorm.slice(gitRoot.length + 1)
35 : "";
36 const wsPrefix = rel ? rel.split("/")[0] : "";
37 return wsPrefix ? resolve(gitRoot, wsPrefix) : resolve(cwdNorm);
38 } catch {
39 return resolve(process.cwd());
40 }
41}
42const ROOT = detectWorkspaceRoot();
43
44const args = process.argv.slice(2);
45const KNOWN_FLAG_PREFIXES = [
46 "--dry-run",
47 "--apply-overrides",
48 "--update-parents",
49 "--auto-fix",
50 "--show-chains",
51 "--prune-overrides",
52 "--skip-shell-check",
53 "--skip-install",
54 "--json",
55 "--verbose",
56 "--help",
57];
58const unknownFlags = args.filter(
59 (a) =>
60 a.startsWith("--") &&
61 !KNOWN_FLAG_PREFIXES.some(
62 (prefix) => a === prefix || a.startsWith(prefix + "="),
63 ),
64);
65if (unknownFlags.length > 0) {
66 console.error(
67 `Error: unrecognized flag(s): ${unknownFlags.join(", ")}\nRun with --help to see available options.`,
68 );
69 process.exit(1);
70}
71
72/**
73 * Parse a flag that may be a bare boolean or have a comma-separated
74 * package list. Returns:
75 * - false if the flag is not present
76 * - true if the flag is present without a value (apply to all)
77 * - Set if the flag has a value (apply only to listed packages)
78 */
79function parseFilterFlag(flagName) {
80 const arg = args.find(
81 (a) => a === flagName || a.startsWith(flagName + "="),
82 );
83 if (!arg) return false;
84 if (arg === flagName) return true;
85 const value = arg.slice(flagName.length + 1);
86 return new Set(
87 value
88 .split(",")
89 .map((s) => s.trim())
90 .filter(Boolean),
91 );
92}
93
94const DRY_RUN = args.includes("--dry-run");
95const AUTO_FIX = parseFilterFlag("--auto-fix");
96const _applyOverrides = parseFilterFlag("--apply-overrides");
97const _updateParents = parseFilterFlag("--update-parents");
98
99// Merge --auto-fix into the two sub-flags
100const APPLY_OVERRIDES = mergeFilterFlags(AUTO_FIX, _applyOverrides);
101const UPDATE_PARENTS = mergeFilterFlags(AUTO_FIX, _updateParents);
102
103/**
104 * Merge two filter flags. If either is `true` (all), the result is `true`.
105 * If both are `false`, the result is `false`.
106 * Otherwise, merge the two Sets.
107 */
108function mergeFilterFlags(a, b) {
109 if (a === true || b === true) return true;
110 if (!a && !b) return false;
111 if (!a) return b;
112 if (!b) return a;
113 return new Set([...a, ...b]);
114}
115
116/** Check if a filter flag enables a specific package. */
117function flagAllows(flag, pkg) {
118 if (flag === true) return true;
119 if (flag instanceof Set) return flag.has(pkg);
120 return false;
121}
122const SHOW_CHAINS =
123 args.includes("--show-chains") || args.includes("--show-chains=full");
124const SHOW_CHAINS_FULL = args.includes("--show-chains=full");
125const PRUNE_OVERRIDES = args.includes("--prune-overrides");
126const SKIP_SHELL_CHECK = args.includes("--skip-shell-check");
127const SKIP_INSTALL = args.includes("--skip-install");
128const JSON_OUTPUT = args.includes("--json");
129const VERBOSE = args.includes("--verbose");
130
131if (args.includes("--help")) {
132 console.log(`Usage: node tools/scripts/fix-dependabot-alerts.mjs [options]
133
134Options:
135 --dry-run Analyse and report what would be done without making any
136 changes.
137 --apply-overrides[=pkg1,pkg2,...]
138 Automatically add pnpm.overrides for transitive deps
139 that can't be updated directly. Optionally specify
140 package names to limit which overrides to apply.
141 --update-parents[=pkg1,pkg2,...]
142 Update parent packages in workspace package.json
143 files to fixed versions and run pnpm install.
144 Optionally specify package names to limit scope.
145 --auto-fix[=pkg1,pkg2,...]
146 Shorthand for --apply-overrides --update-parents.
147 Optionally specify package names to limit scope.
148 --show-chains Show full dependency chains (collapsed to 3 levels)
149 --show-chains=full Show fully expanded dependency chains
150 --prune-overrides Remove pnpm.overrides entries that are no longer needed.
151 Cannot be combined with --apply-overrides or
152 --update-parents.
153 --skip-shell-check Skip the electron-builder shell packaging compatibility
154 check. By default, overrides for packages in the shell's
155 production dependency tree are blocked because
156 electron-builder validates exact version matches.
157 --skip-install Skip the initial pnpm install --frozen-lockfile. Use when
158 dependencies are already installed (e.g. in CI).
159 --json Output results as structured JSON (for CI integration)
160 --verbose Show detailed constraint analysis, advisory IDs, and
161 debug output
162 --help Show this help message and exit
163
164Exit codes:
165 0 All alerts resolved (or no open alerts found)
166 1 One or more alerts remain blocked, have no published patch, failed to
167 apply, or a fatal error occurred (fetch failure, unknown flag, etc.)`);
168 process.exit(0);
169}
170
171if (PRUNE_OVERRIDES && (APPLY_OVERRIDES || UPDATE_PARENTS)) {
172 console.error(
173 "Error: --prune-overrides cannot be combined with --apply-overrides or --update-parents.\n" +
174 "Run fixes first, then re-run with --prune-overrides to clean up stale entries.",
175 );
176 process.exit(1);
177}
178
179// ── Color scheme ─────────────────────────────────────────────────────────────
180// Severity ordering for sorting (higher = more severe)
181const SEVERITY_ORDER = { critical: 4, high: 3, medium: 2, low: 1, unknown: 0 };
182
183// Category helpers — change colors in one place to restyle all output.
184const clr = {
185 fail: chalk.red, // status: fail ✗
186 ok: chalk.greenBright, // status: success ✓
187 warn: chalk.yellow, // status: warning ⚠
188 version: chalk.yellowBright, // neutral version specs, upgrade hints
189 versionOk: chalk.greenBright, // version that satisfies fix
190 versionBad: chalk.redBright, // version that is vulnerable
191 pkg: chalk.whiteBright, // package names (deps, parents, constraints)
192 chain: chalk.blueBright, // dependency chain intermediate nodes
193 root: chalk.blue, // workspace root names (dep chain leaves)
194 chrome: chalk.cyanBright, // structural chrome (headers, CLI flags)
195 meta: chalk.gray, // metadata, arrows, de-emphasized text
196};
197
198// ── Helpers ──────────────────────────────────────────────────────────────────
199
200// Cache for npm/pnpm subprocess results to avoid redundant invocations.
201// packageDeps and workspacePkgPaths are managed directly; the rest use cachedAsync.
202const _cache = {
203 packageDeps: new Map(),
204 workspacePkgPaths: null,
205};
206
207const _inflight = {
208 packageDeps: new Map(),
209};
210
211// ── Concurrency control ───────────────────────────────────────────────────────
212
213/**
214 * Limits concurrent npm registry calls to avoid rate-limiting.
215 * Override with DEPFIX_NPM_CONCURRENCY env var (default 8).
216 */
217class Semaphore {
218 constructor(n, label) {
219 if (!Number.isFinite(n) || n < 1) {
220 const original = n;
221 n = 1;
222 if (!JSON_OUTPUT) {
223 console.warn(
224 ` ⚠ ${label ?? "Semaphore"}: invalid concurrency ${original}, using ${n}`,
225 );
226 }
227 }
228 this._n = n;
229 this._queue = [];
230 }
231 acquire() {
232 if (this._n > 0) {
233 this._n--;
234 return Promise.resolve();
235 }
236 return new Promise((r) => this._queue.push(r));
237 }
238 release() {
239 // NOTE: shift()() resolves the next waiter's promise synchronously,
240 // but its microtask runs after the current synchronous block completes.
241 // Callers in `finally` blocks rely on this: _inflight cleanup statements
242 // after release() execute before the woken waiter runs. Do not reorder.
243 if (this._queue.length > 0) this._queue.shift()();
244 else this._n++;
245 }
246}
247const _npmSem = new Semaphore(
248 parseInt(process.env.DEPFIX_NPM_CONCURRENCY ?? "8", 10),
249 "DEPFIX_NPM_CONCURRENCY",
250);
251
252/**
253 * Build a cached, inflight-deduplicated async function.
254 * Returns a function `fn(key)` that caches results by key and deduplicates
255 * concurrent calls for the same key. Exposes `fn.cache` and `fn.inflight`
256 * Maps for direct manipulation (e.g. cache invalidation).
257 *
258 * @param {string} label - Name for verbose logging on failure
259 * @param {object} opts
260 * @param {Function} opts.fetchFn - async (key) => result
261 * @param {Semaphore} [opts.semaphore] - optional concurrency limiter
262 * @param {*} [opts.fallback=null] - value to cache on failure
263 * @param {Function} [opts.isFatal] - (e) => boolean. When provided and it
264 * returns true for a thrown error, the error is rethrown instead of
265 * caching `fallback`. Use for failures (missing binaries, permission
266 * errors) where treating the empty fallback as "no data" would mask a
267 * broken environment and produce silently-wrong downstream results.
268 */
269function cachedAsync(label, { fetchFn, semaphore, fallback = null, isFatal }) {
270 const cache = new Map();
271 const inflight = new Map();
272
273 function fn(key) {
274 if (cache.has(key)) return Promise.resolve(cache.get(key));
275 if (inflight.has(key)) return inflight.get(key);
276
277 const p = (async () => {
278 if (semaphore) await semaphore.acquire();
279 try {
280 const result = await fetchFn(key);
281 cache.set(key, result);
282 return result;
283 } catch (e) {
284 // Some failures (e.g. missing binaries) are too dangerous to
285 // mask with a fallback — they let downstream code conclude
286 // "no data" when the real cause is a broken environment.
287 if (isFatal && isFatal(e)) throw e;
288 verbose(`${label}(${key}) failed: ${e.message}`);
289 cache.set(key, fallback);
290 return fallback;
291 } finally {
292 if (semaphore) semaphore.release();
293 inflight.delete(key);
294 }
295 })();
296 inflight.set(key, p);
297 return p;
298 }
299
300 fn.cache = cache;
301 fn.inflight = inflight;
302 return fn;
303}
304
305// ── Per-package log buffering ─────────────────────────────────────────────────
306// When packages are analysed concurrently, each one buffers its own log lines
307// so output is flushed in order (not interleaved).
308
309const _logStorage = new AsyncLocalStorage();
310
311const MAX_BUFFER = 10 * 1024 * 1024; // 10 MB
312
313function checkCmdError(cmd, result) {
314 if (result.error) {
315 if (result.error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
316 throw new Error(
317 `${cmd} output exceeded buffer limit (${MAX_BUFFER / 1024 / 1024} MB); consider reducing scope`,
318 );
319 }
320 throw new Error(`Failed to spawn ${cmd}: ${result.error.message}`);
321 }
322}
323
324/**
325 * Spawn a command with an argument array (no shell interpolation).
326 * Throws on non-zero exit, spawn failure, or signal kill.
327 * Pass { nothrow: true } to return null on failure instead of throwing.
328 */
329function runCmd(cmd, cmdArgs, { nothrow, ...opts } = {}) {
330 const result = spawnSync(cmd, cmdArgs, {
331 cwd: ROOT,
332 encoding: "utf-8",
333 maxBuffer: MAX_BUFFER,
334 ...opts,
335 });
336 if (nothrow) {
337 if (result.error || result.signal || result.status !== 0) {
338 verbose(
339 `${cmd} ${cmdArgs.join(" ")} failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status}`}`,
340 );
341 return null;
342 }
343 return result.stdout.trim();
344 }
345 checkCmdError(cmd, result);
346 if (result.signal) {
347 throw new Error(`${cmd} was killed by signal ${result.signal}`);
348 }
349 if (result.status !== 0) {
350 throw new Error(
351 `Command failed (exit ${result.status}): ${cmd} ${cmdArgs.join(" ")}\n${result.stderr}`,
352 );
353 }
354 return result.stdout.trim();
355}
356
357/**
358 * Async variant of runCmd — uses execFile so the event loop is not blocked.
359 * Throws on non-zero exit, spawn failure, or signal kill.
360 * Pass { nothrow: true } to return null on failure instead of throwing.
361 */
362function runCmdAsync(cmd, cmdArgs, { nothrow, ...opts } = {}) {
363 return new Promise((resolve, reject) => {
364 execFile(
365 cmd,
366 cmdArgs,
367 { cwd: ROOT, maxBuffer: MAX_BUFFER, encoding: "utf-8", ...opts },
368 (error, stdout, stderr) => {
369 if (error) {
370 // `nothrow` should swallow non-zero exits but NOT spawn
371 // failures (ENOENT, EACCES, etc.) — those usually indicate
372 // a misconfigured environment (missing binary, missing
373 // permissions) and silently treating them as "no output"
374 // can mask real bugs (e.g. classifying vulnerable packages
375 // as already-fixed because `pnpm why` couldn't even run).
376 const isSpawnFailure =
377 typeof error.code === "string" &&
378 !Number.isFinite(Number(error.code));
379 if (nothrow && !isSpawnFailure) {
380 verbose(
381 `${cmd} ${cmdArgs.join(" ")} failed: ${error.message}`,
382 );
383 resolve(null);
384 } else {
385 // pnpm prints its actionable error context (e.g.
386 // ERR_PNPM_OVERRIDE_NO_VERSIONS_MATCH) to stdout —
387 // include both streams so callers can diagnose the
388 // failure without re-running with verbose output.
389 const detail =
390 [stderr, stdout]
391 .map((s) => (s || "").trim())
392 .filter(Boolean)
393 .join("\n") || error.message;
394 reject(
395 new Error(
396 `Command failed: ${cmd} ${cmdArgs.join(" ")}\n${detail}`,
397 ),
398 );
399 }
400 } else {
401 resolve(stdout.trim());
402 }
403 },
404 );
405 });
406}
407
408function verbose(msg) {
409 if (VERBOSE && !JSON_OUTPUT) _emit(clr.meta(` [verbose] ${msg}`));
410}
411
412// Logging helpers — when running inside an AsyncLocalStorage context (concurrent
413// package analysis), lines are buffered and flushed in order by the caller.
414function _emit(line) {
415 if (JSON_OUTPUT) return;
416 const buf = _logStorage.getStore();
417 if (buf) buf.push(line);
418 else console.log(line);
419}
420function log(msg) {
421 _emit(msg);
422}
423function header(msg) {
424 _emit(
425 `\n${clr.chrome("═".repeat(70))}\n ${clr.chrome.bold(msg)}\n${clr.chrome("═".repeat(70))}`,
426 );
427}
428function warn(msg) {
429 _emit(clr.warn(` ⚠ ${msg}`));
430}
431function ok(msg) {
432 _emit(clr.ok(` ✓ ${msg}`));
433}
434function fail(msg) {
435 _emit(clr.fail(` ✗ ${msg}`));
436}
437/**
438 * Parse JSON output from `gh --paginate` or `pnpm --json` that may
439 * concatenate multiple JSON arrays (e.g. `][` between pages).
440 */
441function parsePaginatedJson(raw) {
442 // Try parsing as-is first; fall back to concatenation heuristic
443 try {
444 const parsed = JSON.parse(raw);
445 return Array.isArray(parsed) ? parsed : [parsed];
446 } catch {
447 // gh --paginate may concatenate multiple JSON arrays like `][`
448 return JSON.parse("[" + raw.replace(/\]\s*\[/g, ",") + "]").flat();
449 }
450}
451
452/**
453 * Remove duplicates from an array, keeping the first occurrence of each key.
454 */
455function deduplicateBy(items, keyFn) {
456 const seen = new Set();
457 return items.filter((item) => {
458 const key = keyFn(item);
459 if (seen.has(key)) return false;
460 seen.add(key);
461 return true;
462 });
463}
464
465/**
466 * Classify constraints into three categories: blockers, upgradeable, and allowing.
467 */
468function classifyConstraints(constraints) {
469 const blockers = [];
470 const upgradeable = [];
471 const allowing = [];
472 for (const c of constraints) {
473 if (c.allows) allowing.push(c);
474 else if (c.fixVersion) upgradeable.push(c);
475 else blockers.push(c);
476 }
477 return { blockers, upgradeable, allowing };
478}
479
480/**
481 * Group fix-plan actions by type into workspace and intermediate arrays.
482 */
483function groupActionsByType(actions) {
484 const workspace = [];
485 const intermediate = [];
486 for (const a of actions || []) {
487 if (a.type === "update-workspace") workspace.push(a);
488 else if (a.type === "update-intermediate") intermediate.push(a);
489 }
490 return { workspace, intermediate };
491}
492
493function fmtDepChain(whyData, pkg) {
494 // Build a tree of intermediate → workspace roots (top-down from vuln pkg)
495 // then render with one node per line, grouping leaves (workspace roots)
496 function buildTree(node, isRoot) {
497 if (node.depField) {
498 return { label: node.name, depField: node.depField, children: [] };
499 }
500 const children = [];
501 if (node.dependents) {
502 for (const dep of node.dependents) {
503 children.push(buildTree(dep, false));
504 }
505 }
506 if (isRoot) {
507 return { label: null, children }; // skip root, shown in 📦 header
508 }
509 return {
510 label: `${node.name}@${node.version}`,
511 children,
512 };
513 }
514
515 // Merge duplicate subtrees and collect workspace roots as leaf groups
516 function mergeChildren(children) {
517 const byLabel = new Map();
518 for (const child of children) {
519 const key = child.label || "";
520 if (byLabel.has(key)) {
521 const existing = byLabel.get(key);
522 existing.children.push(...child.children);
523 if (child.depField) existing.depField = child.depField;
524 } else {
525 byLabel.set(key, { ...child, children: [...child.children] });
526 }
527 }
528 for (const [, node] of byLabel) {
529 node.children = mergeChildren(node.children);
530 }
531 return [...byLabel.values()];
532 }
533
534 function renderTree(nodes, depth, rendered) {
535 const MAX_DEPTH = SHOW_CHAINS_FULL ? Infinity : 3;
536 // Separate workspace roots (leaves) from intermediates
537 const leaves = nodes.filter((n) => n.depField);
538 const intermediates = nodes.filter((n) => !n.depField);
539
540 if (depth >= MAX_DEPTH && intermediates.length > 0) {
541 const indent = " " + " ".repeat(depth);
542 log(
543 `${indent}${clr.meta(`… ${intermediates.length} more level(s) collapsed (use --show-chains=full)`)}`,
544 );
545 return;
546 }
547
548 for (const node of intermediates) {
549 const indent = " " + " ".repeat(depth);
550 if (rendered.has(node.label)) {
551 log(
552 `${indent}${clr.meta("→")} ${clr.chain(node.label)} ${clr.meta("(see above)")}`,
553 );
554 continue;
555 }
556 rendered.add(node.label);
557 log(`${indent}${clr.meta("→")} ${clr.chain(node.label)}`);
558 if (node.children.length > 0) {
559 renderTree(node.children, depth + 1, rendered);
560 }
561 }
562
563 if (leaves.length > 0) {
564 const indent = " " + " ".repeat(depth);
565 const maxShow = 3;
566 const shown = leaves
567 .slice(0, maxShow)
568 .map((l) => clr.root(l.label));
569 if (leaves.length > maxShow) {
570 shown.push(clr.meta(`… +${leaves.length - maxShow} more`));
571 }
572 log(`${indent}${clr.meta("→")} ${shown.join(clr.meta(", "))}`);
573 }
574 }
575
576 const reverseTree = toReverseTree(whyData, pkg);
577 const roots = [];
578 for (const entry of reverseTree) {
579 const tree = buildTree(entry, true);
580 roots.push(...tree.children);
581 }
582 const merged = mergeChildren(roots);
583 if (merged.length > 0) {
584 renderTree(merged, 0, new Set());
585 }
586}
587
588const SEVERITY_COLORS = {
589 critical: (s) => clr.fail.bold.inverse(` ${s} `),
590 high: (s) => clr.fail.bold(s),
591 medium: (s) => clr.warn(s),
592};
593const colorSeverity = (severity) =>
594 (SEVERITY_COLORS[severity] ?? clr.meta)(severity);
595
596// ── Utilities ────────────────────────────────────────────────────────────────
597
598/**
599 * Get the latest published version of a package.
600 * Derived from getNpmInfo to avoid a redundant npm call.
601 */
602async function getLatestVersion(pkg) {
603 return (await getNpmInfo(pkg))?.latest ?? null;
604}
605
606/**
607 * Extract unique sorted resolved versions of a specific package from
608 * `pnpm why <pkg> -r --json` output.
609 *
610 * Two output shapes are supported because pnpm changed the JSON format:
611 *
612 * 1. pnpm 10.x (current) — reverse tree. The top-level array contains
613 * one entry per resolved version of <pkg>:
614 * [ { name: <pkg>, version: <pkgVersion>, dependents: [...] }, ... ]
615 * Each entry's `name`/`version` IS the queried package's version, and
616 * `dependents` walks back toward workspace roots. There are no forward
617 * `dependencies`/`devDependencies`/`optionalDependencies` children at
618 * the root.
619 *
620 * 2. Older pnpm — forward tree. The top-level array contains one entry
621 * per workspace project; each entry's `name`/`version` is the workspace
622 * project (NOT the queried package) and the queried package's versions
623 * appear under nested `dependencies`/`devDependencies`/`optionalDependencies`
624 * children.
625 *
626 * To handle both, we record `node.version` whenever `node.name === pkg`
627 * (covers shape 1's top-level entries and shape 2's nested matches) AND
628 * recurse through the forward `depKeys` (covers shape 2's nesting). For
629 * shape 2 specifically, ignoring the top-level entry's `name`/`version`
630 * is critical — that's the workspace project's own version, not the
631 * queried package's — but the `node.name === pkg` guard correctly skips
632 * those top-level workspace entries.
633 *
634 * History: an earlier revision read `e.version` off the top-level entries
635 * unconditionally, which under shape 2 always returned the workspace
636 * project's own version and made `verifyAllVersionsFixed` incorrectly
637 * conclude that updates had left vulnerable versions in place. The fix
638 * was to gate top-level version capture on `node.name === pkg`.
639 */
640function getResolvedVersions(whyData, pkg) {
641 const versions = new Set();
642 const depKeys = ["dependencies", "devDependencies", "optionalDependencies"];
643
644 function walk(node) {
645 if (!node || typeof node !== "object") return;
646 // Capture top-level reverse-tree matches (pnpm 10.x) and any
647 // nested matches under forward depKeys (older pnpm). The
648 // `node.name === pkg` guard correctly skips top-level workspace
649 // project entries from the older forward-tree shape — see the
650 // function-level JSDoc for details.
651 if (node.name === pkg && typeof node.version === "string") {
652 versions.add(node.version);
653 }
654 for (const key of depKeys) {
655 const deps = node[key];
656 if (!deps || typeof deps !== "object") continue;
657 for (const [depName, depNode] of Object.entries(deps)) {
658 if (!depNode || typeof depNode !== "object") continue;
659 if (depName === pkg && typeof depNode.version === "string") {
660 versions.add(depNode.version);
661 }
662 walk(depNode);
663 }
664 }
665 }
666
667 for (const entry of whyData) walk(entry);
668
669 return [...versions].filter((v) => semver.valid(v)).sort(semver.compare);
670}
671
672/**
673 * Re-query `pnpm why` (clearing caches) after an update and verify
674 * that every resolved version of `pkg` is >= `requiredVersion`.
675 *
676 * Returns { ok, versions, unfixed } where:
677 * - ok: true if all resolved versions are fixed
678 * - versions: all unique resolved versions
679 * - unfixed: versions still below requiredVersion
680 */
681async function verifyAllVersionsFixed(pkg, requiredVersion) {
682 // Drain any in-flight request before clearing the cache so concurrent
683 // callers that already hold a reference to the promise still resolve
684 // correctly, and a third caller arriving mid-drain doesn't launch a
685 // duplicate request.
686 if (getPnpmWhy.inflight.has(pkg)) {
687 await getPnpmWhy.inflight.get(pkg).catch(() => {});
688 }
689 getPnpmWhy.cache.delete(pkg);
690 getPnpmWhy.inflight.delete(pkg);
691 const versions = getResolvedVersions(await getPnpmWhy(pkg), pkg);
692 const unfixed = versions.filter((v) => semver.lt(v, requiredVersion));
693 return { ok: unfixed.length === 0, versions, unfixed };
694}
695
696/**
697 * Run `pnpm why <pkg> -r --json` and return parsed entries.
698 *
699 * Spawn-level failures (e.g. `pnpm` not on PATH) propagate to callers
700 * rather than getting masked as "no data" — see the `isFatal` clause.
701 * Without this, a missing pnpm shim would let analysis report every
702 * vulnerable package as `alreadyFixed`.
703 */
704const getPnpmWhy = cachedAsync("getPnpmWhy", {
705 fetchFn: async (pkg) => {
706 const output = await runCmdAsync("pnpm", ["why", pkg, "-r", "--json"], {
707 nothrow: true,
708 });
709 if (!output || output === "[]") return [];
710 return parsePaginatedJson(output);
711 },
712 fallback: [],
713 // runCmdAsync now throws on spawn failure even with nothrow; surface
714 // those instead of caching `[]`.
715 isFatal: (e) => /\bENOENT\b|\bEACCES\b|\bEPERM\b/.test(e?.message || ""),
716});
717
718async function findConstrainingParentsFromData(whyData, pkg) {
719 const reverseTree = toReverseTree(whyData, pkg);
720 const pairs = deduplicateBy(
721 reverseTree.flatMap((entry) => entry.dependents || []),
722 (dep) => `${dep.name}@${dep.version}`,
723 );
724 const specs = await Promise.all(
725 pairs.map(async (dep) => {
726 try {
727 return await getParentDepSpec(dep.name, dep.version, pkg);
728 } catch {
729 return null;
730 }
731 }),
732 );
733 return pairs.map((dep, i) => ({
734 name: dep.name,
735 version: dep.version,
736 requiredSpec: specs[i],
737 }));
738}
739
740/**
741 * Convert the forward-direction `pnpm why <pkg> -r --json` output into the
742 * reverse-direction `{version, dependents: [...]}` shape that the rest of
743 * the planner (planFixes, walkUpTree, fmtDepChain, extractWorkspaceRoots,
744 * findConstrainingParentsFromData) was originally written for.
745 *
746 * The current pnpm CLI emits forward-direction trees only:
747 * [
748 * { name: <ws-name>, version, path,
749 * dependencies: { foo: { version, dependencies: { bar: {...} } } },
750 * devDependencies: {...}, optionalDependencies: {...}
751 * }, ...
752 * ]
753 *
754 * walkUpTree / planFixes were written assuming each top-level entry is a
755 * distinct resolved version of `pkg` with a `dependents: [...]` array
756 * walking back toward the workspace roots — a shape that pnpm no longer
757 * produces (and may never have, depending on which version of pnpm the
758 * original code was tested against). With the wrong shape, every entry
759 * had `dependents === undefined`, the `if (!entry.dependents) continue;`
760 * guard in planFixes silently dropped every transitively-vulnerable
761 * package, and the planner reported them as `alreadyFixed`.
762 *
763 * This helper does a single forward walk per workspace, collecting every
764 * path that ends at `pkg`, then reverses each path and merges paths that
765 * share the same parent chain so the resulting reverse-tree has the
766 * dedup characteristics walkUpTree's cache assumes.
767 *
768 * Returned shape (matches what walkUpTree expects):
769 * [
770 * { version: <pkg-version>,
771 * dependents: [
772 * { name, version, dependents: [...] }, // intermediate
773 * { name: <ws-name>, depField: <field> } // workspace (terminal;
774 * // no `dependents` field)
775 * ]
776 * }, ...
777 * ]
778 */
779function toReverseTree(whyData, pkg) {
780 if (!Array.isArray(whyData) || whyData.length === 0) return [];
781 const depFields = [
782 "dependencies",
783 "devDependencies",
784 "optionalDependencies",
785 ];
786
787 // Collect every root→pkg path. Each entry in `path` is one node; the
788 // first node is the workspace and the last is `pkg`.
789 const paths = [];
790 const visited = new WeakSet();
791
792 function walk(node, path, fromWorkspaceField) {
793 if (!node || typeof node !== "object") return;
794 // Cycle guard: pnpm output should be tree-shaped but defend anyway.
795 if (visited.has(node)) return;
796 visited.add(node);
797 for (const field of depFields) {
798 const deps = node[field];
799 if (!deps || typeof deps !== "object") continue;
800 for (const [depName, depNode] of Object.entries(deps)) {
801 if (!depNode || typeof depNode !== "object") continue;
802 if (typeof depNode.version !== "string") continue;
803 const childEntry = {
804 name: depName,
805 version: depNode.version,
806 };
807 const newPath = [...path, childEntry];
808 if (depName === pkg) {
809 // Record the workspace's depField on the path's
810 // workspace node (path[0]) so the reverse tree can
811 // surface it. Use the field nearest to the workspace
812 // (i.e. the first traversal's field).
813 paths.push({
814 pkgVersion: depNode.version,
815 chain: newPath,
816 workspaceDepField: fromWorkspaceField ?? field,
817 });
818 }
819 walk(depNode, newPath, fromWorkspaceField ?? field);
820 }
821 }
822 visited.delete(node);
823 }
824
825 for (const ws of whyData) {
826 if (!ws || typeof ws !== "object") continue;
827 walk(
828 ws,
829 [
830 {
831 name: ws.name,
832 version: ws.version,
833 isWorkspace: true,
834 },
835 ],
836 null,
837 );
838 }
839
840 if (paths.length === 0) return [];
841
842 // Group paths by the resolved version of `pkg` they end at.
843 const byVersion = new Map();
844 for (const p of paths) {
845 if (!byVersion.has(p.pkgVersion)) byVersion.set(p.pkgVersion, []);
846 byVersion.get(p.pkgVersion).push(p);
847 }
848
849 // For each version, reverse the chains (drop the pkg leaf so the
850 // reverse-tree's first level holds pkg's *parents*) and merge nodes
851 // that share the same identity.
852 function buildLevel(reversedChains) {
853 const buckets = new Map();
854 for (const chain of reversedChains) {
855 if (chain.length === 0) continue;
856 const head = chain[0];
857 const key = head.isWorkspace
858 ? `ws:${head.name}`
859 : `${head.name}@${head.version}`;
860 if (!buckets.has(key)) {
861 buckets.set(key, { head, tails: [] });
862 }
863 buckets.get(key).tails.push(chain.slice(1));
864 }
865 const nodes = [];
866 for (const { head, tails } of buckets.values()) {
867 if (head.isWorkspace) {
868 // walkUpTree only checks `node.depField` truthiness to
869 // recognise workspace nodes; getWorkspaceDepInfo() reads
870 // the workspace's package.json directly to resolve the
871 // actual spec. The recorded depField is informational.
872 nodes.push({
873 name: head.name,
874 depField: head.workspaceDepField || "dependencies",
875 });
876 } else {
877 nodes.push({
878 name: head.name,
879 version: head.version,
880 dependents: buildLevel(tails),
881 });
882 }
883 }
884 return nodes;
885 }
886
887 const result = [];
888 for (const [version, ps] of byVersion) {
889 const reversed = ps.map((p) => {
890 // Drop the pkg leaf, reverse, then attach workspaceDepField
891 // to the workspace node (now at the end after reversal).
892 const noLeaf = p.chain.slice(0, -1);
893 const reversedChain = [...noLeaf].reverse();
894 // The workspace is the last element of reversedChain; clone it
895 // before tagging — the same workspace identity object is shared
896 // across every path emitted from that workspace, so mutating
897 // workspaceDepField in place would let later paths overwrite
898 // earlier ones (and the chosen depField would become
899 // traversal-order dependent).
900 if (reversedChain.length > 0) {
901 const lastIdx = reversedChain.length - 1;
902 const wsNode = reversedChain[lastIdx];
903 if (wsNode.isWorkspace) {
904 reversedChain[lastIdx] = {
905 ...wsNode,
906 workspaceDepField: p.workspaceDepField,
907 };
908 }
909 }
910 return reversedChain;
911 });
912 result.push({
913 version,
914 dependents: buildLevel(reversed),
915 });
916 }
917
918 return result;
919}
920
921/**
922 * Get the version spec that parentPkg@parentVersion requires for depPkg.
923 */
924async function getParentDepSpec(parentPkg, parentVersion, depPkg) {
925 const deps = await getPackageDeps(parentPkg, parentVersion);
926 if (deps && deps[depPkg]) return deps[depPkg];
927 return null;
928}
929
930function getPackageDeps(pkgName, version) {
931 const cacheKey = `${pkgName}@${version}`;
932 if (_cache.packageDeps.has(cacheKey))
933 return Promise.resolve(_cache.packageDeps.get(cacheKey));
934 if (_inflight.packageDeps.has(cacheKey))
935 return _inflight.packageDeps.get(cacheKey);
936
937 // Workspace packages: read package.json directly (sync — no npm call needed)
938 if (isWorkspacePackage(pkgName)) {
939 const pkgJsonPath = getWorkspacePackagePaths().get(pkgName);
940 try {
941 const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
942 const deps = {
943 ...pkgJson.dependencies,
944 ...pkgJson.devDependencies,
945 };
946 const result = Object.keys(deps).length > 0 ? deps : null;
947 _cache.packageDeps.set(cacheKey, result);
948 return Promise.resolve(result);
949 } catch (e) {
950 verbose(
951 `getPackageDeps(${cacheKey}) workspace read failed: ${e.message}`,
952 );
953 throw e;
954 }
955 }
956
957 const p = (async () => {
958 await _npmSem.acquire();
959 try {
960 const output = await runCmdAsync(
961 "npm",
962 ["view", `${pkgName}@${version}`, "dependencies", "--json"],
963 { nothrow: true },
964 );
965 if (!output || output === "undefined") return null;
966 const deps = JSON.parse(output);
967 _cache.packageDeps.set(cacheKey, deps);
968 return deps;
969 } catch (e) {
970 verbose(`getPackageDeps(${cacheKey}) failed: ${e.message}`);
971 // Cache failures to avoid thundering-herd retries for the same key
972 _cache.packageDeps.set(cacheKey, null);
973 return null;
974 } finally {
975 _npmSem.release();
976 _inflight.packageDeps.delete(cacheKey);
977 }
978 })();
979 _inflight.packageDeps.set(cacheKey, p);
980 return p;
981}
982
983function specAllowsVersion(spec, version) {
984 try {
985 return semver.satisfies(version, spec);
986 } catch {
987 return false;
988 }
989}
990
991/**
992 * Check if a dep spec guarantees that the resolved version will be
993 * at least `minVersion`. This is true when:
994 * 1. The spec directly allows `minVersion` (e.g. ^5.4.0 allows 5.5.7), OR
995 * 2. The spec's minimum resolvable version is >= minVersion
996 * (e.g. exact pin "5.5.8" → minVersion("5.5.8") = 5.5.8 >= 5.5.7).
997 */
998function specGuaranteesMinVersion(spec, minVersion) {
999 try {
1000 if (semver.satisfies(minVersion, spec)) return true;
1001 const specMin = semver.minVersion(spec);
1002 if (specMin && semver.gte(specMin.version, minVersion)) return true;
1003 return false;
1004 } catch {
1005 return false;
1006 }
1007}
1008
1009// ── Tree-walk fix planner ────────────────────────────────────────────────────
1010//
1011// The fix planner walks each resolved version of a vulnerable package
1012// bottom-up through the `pnpm why` dependents tree. The lockfile can
1013// resolve *multiple* versions of the same package, so each version is
1014// analysed independently.
1015//
1016// At every edge in the tree the planner asks:
1017// "Does the parent's dep spec allow the required child version?"
1018//
1019// YES → pnpm update will resolve this edge – stop walking.
1020// NO → find a newer published version of the parent whose dep spec
1021// *does* allow the required child version.
1022// • Not found → BLOCKED (needs pnpm.overrides).
1023// • Found → the parent must be upgraded. Recurse upward:
1024// does the grandparent's spec allow the new parent
1025// version? Repeat until we hit a workspace root
1026// or another "allows" edge.
1027//
1028// When a workspace root is reached, its package.json spec is checked.
1029// If the spec is too narrow for the required child version, an
1030// `update-workspace` action is emitted (edit package.json + pnpm install).
1031//
1032// Possible strategies:
1033// • update — all parent specs allow the fix; just run
1034// `pnpm update <pkg> -r`.
1035// • workspace — a workspace package.json spec must be widened
1036// so a newer intermediate dep can be installed.
1037// • override — no parent upgrade exists; add pnpm.overrides.
1038//
1039// Results are cached by (parent@version → child ≥ requiredVersion) to
1040// avoid redundant npm-registry lookups across duplicate sub-trees.
1041//
1042// Entry point: planFixes() → called from classifyWithFixPlan()
1043// classifyWithFixPlan() → called from analyzeVulnerabilities()
1044// executeResolutions() consumes the resulting fixPlan.
1045
1046/**
1047 * Look up the dep spec a workspace package.json has for `depPkg`.
1048 * Returns { spec, depField, pkgJsonPath } or null if not found.
1049 */
1050function getWorkspaceDepInfo(workspaceName, depPkg) {
1051 const pkgJsonPath = getWorkspacePackagePaths().get(workspaceName);
1052 if (!pkgJsonPath) return null;
1053 try {
1054 const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
1055 for (const field of ["dependencies", "devDependencies"]) {
1056 if (pkgJson[field]?.[depPkg]) {
1057 return {
1058 spec: pkgJson[field][depPkg],
1059 depField: field,
1060 pkgJsonPath,
1061 };
1062 }
1063 }
1064 } catch (e) {
1065 verbose(
1066 `getWorkspaceDepInfo(${workspaceName}, ${depPkg}) failed: ${e.message}`,
1067 );
1068 throw e;
1069 }
1070 return null;
1071}
1072
1073/**
1074 * Fetch `versions` and `dist-tags.latest` for a package from npm.
1075 * @returns {{ versions: string[], latest: string|null } | null}
1076 */
1077const getNpmInfo = cachedAsync("getNpmInfo", {
1078 fetchFn: async (pkgName) => {
1079 const output = await runCmdAsync(
1080 "npm",
1081 ["view", pkgName, "versions", "dist-tags.latest", "--json"],
1082 { nothrow: true },
1083 );
1084 if (!output) return null;
1085 const raw = JSON.parse(output);
1086 return {
1087 versions: raw.versions || [],
1088 latest: raw["dist-tags.latest"] || raw["dist-tags"]?.latest || null,
1089 };
1090 },
1091 semaphore: _npmSem,
1092});
1093
1094/**
1095 * Find the smallest published version of `pkgName` newer than
1096 * `currentVersion` whose dependency on `depPkg` allows (or drops)
1097 * `requiredDepVersion`. Returns the version string or null.
1098 */
1099async function findVersionThatAllows(
1100 pkgName,
1101 currentVersion,
1102 depPkg,
1103 requiredDepVersion,
1104) {
1105 if (isWorkspacePackage(pkgName)) return null;
1106
1107 try {
1108 const npmData = await getNpmInfo(pkgName);
1109 if (!npmData) return null;
1110 const { versions: allVersions, latest: latestVersion } = npmData;
1111
1112 // Check latest first (most common fix path)
1113 if (latestVersion && latestVersion !== currentVersion) {
1114 const deps = await getPackageDeps(pkgName, latestVersion);
1115 if (deps) {
1116 const spec = deps[depPkg];
1117 if (
1118 !spec ||
1119 specGuaranteesMinVersion(spec, requiredDepVersion)
1120 ) {
1121 return latestVersion;
1122 }
1123 }
1124 }
1125
1126 // Fetch deps in batches to avoid queuing hundreds of npm calls;
1127 // short-circuit as soon as the first satisfying version is found.
1128 if (allVersions.length > 0) {
1129 const candidates = allVersions
1130 .filter((v) => semver.gt(v, currentVersion))
1131 .sort(semver.compare);
1132 const BATCH = 20;
1133 for (let b = 0; b < candidates.length; b += BATCH) {
1134 const batch = candidates.slice(b, b + BATCH);
1135 const batchDeps = await Promise.all(
1136 batch.map((v) => getPackageDeps(pkgName, v)),
1137 );
1138 for (let i = 0; i < batch.length; i++) {
1139 const deps = batchDeps[i];
1140 if (!deps) continue;
1141 const spec = deps[depPkg];
1142 if (
1143 !spec ||
1144 specGuaranteesMinVersion(spec, requiredDepVersion)
1145 ) {
1146 return batch[i];
1147 }
1148 }
1149 }
1150 }
1151 } catch (e) {
1152 verbose(`findVersionThatAllows(${pkgName}) failed: ${e.message}`);
1153 }
1154 return null;
1155}
1156
1157/**
1158 * Recursively walk up the pnpm-why dependents tree to determine what
1159 * actions are needed so that `childPkg@requiredChildVersion` can resolve.
1160 *
1161 * @param {object[]} parentNodes - dependents array from pnpm-why
1162 * @param {string} childPkg - package that needs the required version
1163 * @param {string} requiredChildVersion - minimum version needed
1164 * @param {Map} cache - memoisation map
1165 * @returns {{
1166 * actions: Array<{ type: 'workspace'|'update', pkg: string,
1167 * workspace?: string, depField?: string,
1168 * oldSpec?: string, newSpec?: string,
1169 * fromVersion?: string, toVersion?: string }>,
1170 * blocked: boolean,
1171 * blockReasons: string[],
1172 * constraints: Array<{ parent: string, parentVersion: string,
1173 * child: string, requiredSpec: string|null,
1174 * allows: boolean, fixVersion: string|null }>
1175 * }}
1176 */
1177async function walkUpTree(parentNodes, childPkg, requiredChildVersion, cache) {
1178 const actions = [];
1179 const blockReasons = [];
1180 const constraints = [];
1181 let blocked = false;
1182
1183 for (const node of parentNodes) {
1184 // ── Workspace root ───────────────────────────────────────────────────
1185 if (node.depField) {
1186 try {
1187 const info = getWorkspaceDepInfo(node.name, childPkg);
1188 if (
1189 info &&
1190 !specAllowsVersion(info.spec, requiredChildVersion)
1191 ) {
1192 const newSpec = buildVersionSpec(
1193 info.spec,
1194 requiredChildVersion,
1195 );
1196 actions.push({
1197 type: "update-workspace",
1198 workspace: node.name,
1199 pkg: childPkg,
1200 oldSpec: info.spec,
1201 newSpec,
1202 depField: info.depField,
1203 pkgJsonPath: info.pkgJsonPath,
1204 });
1205 }
1206 // else: spec already allows it or child is transitive — pnpm update handles it
1207 } catch (e) {
1208 blocked = true;
1209 blockReasons.push(
1210 `${node.name}: failed to read workspace package.json: ${e.message}`,
1211 );
1212 }
1213 continue;
1214 }
1215
1216 // ── Intermediate package ─────────────────────────────────────────────
1217 const cacheKey = `${node.name}@${node.version}\u2192${childPkg}\u2265${requiredChildVersion}`;
1218 if (cache.has(cacheKey)) {
1219 const entry = cache.get(cacheKey);
1220 const cached = entry instanceof Promise ? await entry : entry;
1221 actions.push(...cached.actions);
1222 blockReasons.push(...cached.blockReasons);
1223 constraints.push(...cached.constraints);
1224 if (cached.blocked) blocked = true;
1225 continue;
1226 }
1227
1228 // Store a promise immediately so concurrent callers hitting the same
1229 // cacheKey await the same computation instead of launching duplicates.
1230 let resolveResult;
1231 const resultPromise = new Promise((r) => {
1232 resolveResult = r;
1233 });
1234 cache.set(cacheKey, resultPromise);
1235
1236 const result = {
1237 actions: [],
1238 blocked: false,
1239 blockReasons: [],
1240 constraints: [],
1241 };
1242
1243 try {
1244 const depSpec = await getParentDepSpec(
1245 node.name,
1246 node.version,
1247 childPkg,
1248 );
1249 const allows =
1250 !depSpec ||
1251 specGuaranteesMinVersion(depSpec, requiredChildVersion);
1252
1253 result.constraints.push({
1254 parent: node.name,
1255 parentVersion: node.version,
1256 child: childPkg,
1257 requiredSpec: depSpec,
1258 allows,
1259 fixVersion: null,
1260 });
1261
1262 if (!allows) {
1263 // Parent blocks — find a newer version that allows it
1264 if (process.stderr.isTTY && !JSON_OUTPUT) {
1265 process.stderr.write(
1266 `\r\x1b[K \ud83d\udd0d ${clr.meta(`Checking npm for ${node.name} versions that fix ${childPkg}...`)}`,
1267 );
1268 }
1269
1270 const fixVersion = await findVersionThatAllows(
1271 node.name,
1272 node.version,
1273 childPkg,
1274 requiredChildVersion,
1275 );
1276
1277 if (!fixVersion) {
1278 result.blocked = true;
1279 result.blockReasons.push(
1280 `${node.name}@${node.version} has no upgrade that allows ${childPkg}>=${requiredChildVersion}`,
1281 );
1282 } else {
1283 // Record the fix version for display
1284 result.constraints[
1285 result.constraints.length - 1
1286 ].fixVersion = fixVersion;
1287
1288 // Recurse: can the parent's parents accommodate node@fixVersion?
1289 if (node.dependents && node.dependents.length > 0) {
1290 const upResult = await walkUpTree(
1291 node.dependents,
1292 node.name,
1293 fixVersion,
1294 cache,
1295 );
1296 result.actions.push(...upResult.actions);
1297 result.constraints.push(...upResult.constraints);
1298 if (upResult.blocked) {
1299 result.blocked = true;
1300 result.blockReasons.push(...upResult.blockReasons);
1301 }
1302 }
1303
1304 // If the path through this intermediate is unblocked,
1305 // emit an action to update it so pnpm can resolve the
1306 // child to the required version.
1307 if (!result.blocked) {
1308 result.actions.push({
1309 type: "update-intermediate",
1310 pkg: node.name,
1311 fromVersion: node.version,
1312 toVersion: fixVersion,
1313 });
1314 }
1315 }
1316 }
1317 } catch (e) {
1318 result.blocked = true;
1319 result.blockReasons.push(
1320 `${node.name}@${node.version}: ${e.message}`,
1321 );
1322 }
1323
1324 resolveResult(result);
1325 // Replace the promise with the resolved value so future cache hits
1326 // avoid awaiting an already-settled promise.
1327 cache.set(cacheKey, result);
1328 actions.push(...result.actions);
1329 blockReasons.push(...result.blockReasons);
1330 constraints.push(...result.constraints);
1331 if (result.blocked) blocked = true;
1332 }
1333
1334 return { actions, blocked, blockReasons, constraints };
1335}
1336
1337/**
1338 * Analyse every resolved instance of a vulnerable package and produce
1339 * a fix plan with concrete actions.
1340 *
1341 * Iterates each entry from `pnpm why <pkg> -r --json`. Each entry
1342 * represents a distinct resolved version. Entries whose version
1343 * already satisfies `requiredVersion` are skipped. For the rest,
1344 * `walkUpTree` is called to determine what actions are needed.
1345 *
1346 * Results are partitioned per-version so that unblocked subtrees can
1347 * be acted on even when other subtrees are blocked. A shared `cache`
1348 * Map avoids redundant npm-registry lookups across duplicate sub-trees.
1349 *
1350 * @param {string} pkg - vulnerable package name
1351 * @param {string} requiredVersion - minimum safe version (from advisory)
1352 * @param {object[]} whyData - parsed output of `pnpm why <pkg> -r --json`
1353 * @returns {{
1354 * unblockedActions: Array<{ type: 'workspace'|'update', pkg: string,
1355 * workspace?: string, depField?: string,
1356 * oldSpec?: string, newSpec?: string,
1357 * fromVersion?: string, toVersion?: string }>,
1358 * blockedVersions: string[],
1359 * blockReasons: string[],
1360 * constraints: Array<{ parent: string, parentVersion: string,
1361 * child: string, requiredSpec: string|null,
1362 * allows: boolean, fixVersion: string|null }>
1363 * }}
1364 */
1365async function planFixes(pkg, requiredVersion, whyData) {
1366 const cache = new Map();
1367 const unblockedActions = [];
1368 const blockedVersions = [];
1369 const allBlockReasons = [];
1370 const allConstraints = [];
1371
1372 // pnpm emits forward-direction trees; convert once per planFixes call.
1373 const reverseTree = toReverseTree(whyData, pkg);
1374
1375 for (const entry of reverseTree) {
1376 if (!entry.version || !semver.valid(entry.version)) continue;
1377 if (semver.gte(entry.version, requiredVersion)) continue; // already OK
1378 if (!entry.dependents || entry.dependents.length === 0) continue;
1379
1380 const result = await walkUpTree(
1381 entry.dependents,
1382 pkg,
1383 requiredVersion,
1384 cache,
1385 );
1386 allConstraints.push(...result.constraints);
1387 if (result.blocked) {
1388 blockedVersions.push(entry.version);
1389 allBlockReasons.push(...result.blockReasons);
1390 } else {
1391 unblockedActions.push(...result.actions);
1392 }
1393 }
1394
1395 if (process.stderr.isTTY && !JSON_OUTPUT) {
1396 process.stderr.write("\r\x1b[K");
1397 }
1398
1399 return {
1400 unblockedActions: deduplicateActions(unblockedActions),
1401 blockedVersions: [...new Set(blockedVersions)],
1402 blockReasons: [...new Set(allBlockReasons)],
1403 constraints: allConstraints,
1404 };
1405}
1406
1407/**
1408 * Remove duplicate actions that can arise when the same sub-tree appears
1409 * in multiple dependency paths (common in monorepos with many workspaces).
1410 */
1411const deduplicateActions = (actions) =>
1412 deduplicateBy(
1413 actions,
1414 (a) =>
1415 `${a.type}:${a.workspace ?? ""}:${a.pkg}:${a.newSpec ?? a.toVersion ?? ""}`,
1416 );
1417
1418/**
1419 * Build the data model for blocker chains. Pure data assembly — no rendering.
1420 * For each unique blocker, traces the dependency path from vulnPkg through
1421 * upgradeable intermediates, and fetches the blocker's latest-published version
1422 * and its dep spec for the child package.
1423 *
1424 * @param {object[]} blockers - Constraint entries that block the fix
1425 * @param {object[]} upgradeable - Constraint entries that could be updated
1426 * @param {string} vulnPkg - The vulnerable package being analysed
1427 * @returns {Array<{
1428 * blocker: { parent: string, parentVersion: string, child: string,
1429 * requiredSpec: string|null },
1430 * chain: object[],
1431 * blockerLatest: string|null,
1432 * blockerLatestSpec: string|null
1433 * }>}
1434 */
1435async function buildBlockerChains(blockers, upgradeable, vulnPkg) {
1436 const chains = [];
1437 for (const blocker of deduplicateBy(
1438 blockers,
1439 (b) => `${b.parent}@${b.parentVersion}`,
1440 )) {
1441 const chain = buildConstraintChain(vulnPkg, blocker, upgradeable);
1442
1443 const blockerLatest = await getLatestVersion(blocker.parent);
1444 let blockerLatestSpec = null;
1445 if (blockerLatest && blockerLatest !== blocker.parentVersion) {
1446 try {
1447 const deps = await getPackageDeps(
1448 blocker.parent,
1449 blockerLatest,
1450 );
1451 if (deps && deps[blocker.child]) {
1452 blockerLatestSpec = deps[blocker.child];
1453 }
1454 } catch {
1455 // Display-only — don't crash if we can't fetch latest deps
1456 }
1457 }
1458
1459 chains.push({ blocker, chain, blockerLatest, blockerLatestSpec });
1460 }
1461 return chains;
1462}
1463
1464/**
1465 * Display constraint info gathered during the tree walk.
1466 *
1467 * Groups constraints into blocking chains (ending at a ✗ blocker) and
1468 * non-blocking entries (✓ parents that already allow the fix).
1469 *
1470 * For each blocker, shows:
1471 * - "Blocked by: <blocker>" with the blocker's spec and latest version info
1472 * - A condensed "path:" showing the chain from vuln pkg to blocker
1473 * For non-blockers, shows a simple "✓ parent" line.
1474 */
1475async function displayConstraints(constraintInfo, vulnPkg) {
1476 if (!constraintInfo || constraintInfo.length === 0) return;
1477
1478 // Deduplicate and classify constraints
1479 const unique = deduplicateBy(
1480 constraintInfo,
1481 (c) =>
1482 `${c.parent}@${c.parentVersion}\u2192${c.child}:${c.requiredSpec ?? ""}`,
1483 );
1484 if (unique.length === 0) return;
1485
1486 const { blockers, upgradeable, allowing } = classifyConstraints(unique);
1487
1488 // Build chains: for each blocker, trace the path from vulnPkg to blocker
1489 // through the upgradeable constraints
1490 const blockerChains = await buildBlockerChains(
1491 blockers,
1492 upgradeable,
1493 vulnPkg,
1494 );
1495
1496 // Render blocker chains
1497 for (const {
1498 blocker,
1499 chain,
1500 blockerLatest,
1501 blockerLatestSpec,
1502 } of blockerChains) {
1503 const blockerName = `${blocker.parent}@${blocker.parentVersion}`;
1504
1505 // Blocker line
1506 log(` ${clr.fail("\u2717")} ${clr.pkg.bold(blockerName)}`);
1507
1508 // Show the path if there are intermediate steps
1509 if (chain.length > 0) {
1510 const pathParts = [clr.pkg(vulnPkg)];
1511 for (const step of chain) {
1512 const majorTag =
1513 step.fixVersion &&
1514 isBreakingBump(step.parentVersion, step.fixVersion)
1515 ? clr.warn(" ⚠ breaking")
1516 : "";
1517 pathParts.push(
1518 clr.chain(`${step.parent}@${step.parentVersion}`) +
1519 clr.meta(` (→ ${step.fixVersion})`) +
1520 majorTag,
1521 );
1522 }
1523 pathParts.push(clr.pkg.bold(blocker.parent));
1524 log(
1525 ` ${clr.meta("path:")} ${pathParts.join(clr.meta(" ← "))}`,
1526 );
1527 }
1528
1529 // Show what the blocker requires and what latest does
1530 // Use "pins" for exact specs, "depends on" for range specs
1531 let reqInfo;
1532 if (blocker.requiredSpec) {
1533 const isExact =
1534 /^\d/.test(blocker.requiredSpec) &&
1535 !blocker.requiredSpec.includes("||");
1536 reqInfo = isExact
1537 ? `pins ${blocker.child} ${blocker.requiredSpec}`
1538 : `depends on ${blocker.child} ${blocker.requiredSpec}`;
1539 } else {
1540 reqInfo = `depends on ${blocker.child}`;
1541 }
1542
1543 let latestInfo;
1544 if (!blockerLatest || blockerLatest === blocker.parentVersion) {
1545 latestInfo = "already at latest";
1546 } else if (blockerLatestSpec) {
1547 // Check if latest version's spec would allow the required version
1548 const vulnRequiredVersion = unique.find(
1549 (c) => c.child === blocker.child && c.fixVersion,
1550 );
1551 const requiredChildVer = vulnRequiredVersion
1552 ? vulnRequiredVersion.fixVersion
1553 : null;
1554 const latestAllows = requiredChildVer
1555 ? specGuaranteesMinVersion(blockerLatestSpec, requiredChildVer)
1556 : false;
1557 if (latestAllows) {
1558 latestInfo = `latest ${blocker.parent}@${clr.versionOk(blockerLatest)} allows ${blockerLatestSpec} ${clr.versionOk("✓")}`;
1559 } else {
1560 latestInfo = `latest ${blocker.parent}@${clr.versionBad(blockerLatest)} still pins ${blockerLatestSpec}`;
1561 }
1562 } else {
1563 latestInfo = `latest ${blocker.parent}@${clr.versionOk(blockerLatest)} drops ${blocker.child} dep ${clr.versionOk("✓")}`;
1564 }
1565 log(` ${clr.meta(reqInfo + ", " + latestInfo)}`);
1566 }
1567
1568 // Render upgradeable intermediates: immediate parent → pnpm update action
1569 if (upgradeable.length > 0) {
1570 const renderedUpgrades = new Set();
1571 for (const u of upgradeable) {
1572 const lineKey = `${u.parent}@${u.parentVersion}→${u.fixVersion}`;
1573 if (renderedUpgrades.has(lineKey)) continue;
1574 renderedUpgrades.add(lineKey);
1575 log(
1576 ` ${clr.ok("\u2713")} ${clr.pkg(`${u.parent}@${u.parentVersion}`)} ${clr.meta("\u2192")} pnpm update ${clr.pkg(u.parent)} (${clr.versionBad(u.parentVersion)} ${clr.meta("\u2192")} ${clr.versionOk(u.fixVersion)})`,
1577 );
1578 }
1579 }
1580
1581 // Render non-blocking parents that don't have a related upgradeable
1582 if (allowing.length > 0) {
1583 const upgradeableParentNames = new Set(
1584 upgradeable.map((u) => u.parent),
1585 );
1586 for (const c of deduplicateBy(
1587 allowing.filter((c) => !upgradeableParentNames.has(c.child)),
1588 (c) => `${c.parent}@${c.parentVersion}`,
1589 )) {
1590 log(
1591 ` ${clr.ok("\u2713")} ${clr.pkg(`${c.parent}@${c.parentVersion}`)}`,
1592 );
1593 }
1594 }
1595}
1596
1597/**
1598 * Build a condensed chain of upgradeable constraints between vulnPkg and
1599 * a blocker. Returns an array of constraint steps (from vulnPkg outward).
1600 *
1601 * Each step is an upgradeable constraint (has fixVersion).
1602 * We trace: vulnPkg → child of some constraint → parent → ... → blocker.child
1603 */
1604function buildConstraintChain(vulnPkg, blocker, upgradeable) {
1605 // Build a lookup: child → list of upgradeable parents
1606 const byChild = new Map();
1607 for (const c of upgradeable) {
1608 if (!byChild.has(c.child)) byChild.set(c.child, []);
1609 byChild.get(c.child).push(c);
1610 }
1611
1612 // BFS from blocker.child backwards to vulnPkg through upgradeable edges
1613 // The chain is: vulnPkg ← parent1 ← parent2 ← ... ← blocker
1614 // blocker.child is some intermediate package that the blocker constrains.
1615 // We want to find a path from vulnPkg to blocker.child through edges
1616 // where child→parent is an upgradeable constraint.
1617
1618 // Direct case: blocker.child === vulnPkg (blocker directly depends on vuln pkg)
1619 if (blocker.child === vulnPkg) {
1620 return [];
1621 }
1622
1623 // Find path from vulnPkg to blocker.child through upgradeable constraints
1624 // Each upgradeable constraint says: parent requires child, and parent can
1625 // be upgraded to fixVersion. So child → parent is an edge.
1626 const visited = new Set();
1627 const queue = [[vulnPkg, []]]; // [currentPkg, pathSoFar]
1628 visited.add(vulnPkg);
1629
1630 while (queue.length > 0) {
1631 const [current, path] = queue.shift();
1632 const parents = byChild.get(current) || [];
1633 for (const constraint of parents) {
1634 const parentKey = `${constraint.parent}@${constraint.parentVersion}`;
1635 if (visited.has(parentKey)) continue;
1636 visited.add(parentKey);
1637
1638 const newPath = [...path, constraint];
1639
1640 // Did we reach the package that the blocker constrains?
1641 if (
1642 constraint.parent === blocker.child ||
1643 constraint.parent === blocker.parent
1644 ) {
1645 // If we reached the blocker's child, the path is complete
1646 if (constraint.parent === blocker.child) {
1647 return newPath;
1648 }
1649 // If we reached the blocker itself via an upgradeable edge, trim it
1650 return newPath.slice(0, -1);
1651 }
1652
1653 queue.push([constraint.parent, newPath]);
1654 }
1655 }
1656
1657 // Couldn't trace a full path — return whatever upgradeable constraints
1658 // involve vulnPkg directly
1659 return (byChild.get(vulnPkg) || []).filter(
1660 (c) => c.parent !== blocker.parent,
1661 );
1662}
1663
1664/**
1665 * Apply update-workspace actions: edit workspace package.json files.
1666 * Returns the number of updates applied.
1667 */
1668function applyFixActions(actions) {
1669 const wsUpdates = actions.filter((a) => a.type === "update-workspace");
1670 if (wsUpdates.length === 0) return 0;
1671
1672 const byFile = new Map();
1673 for (const u of wsUpdates) {
1674 if (!byFile.has(u.pkgJsonPath)) byFile.set(u.pkgJsonPath, []);
1675 byFile.get(u.pkgJsonPath).push(u);
1676 }
1677
1678 let appliedCount = 0;
1679 for (const [pkgJsonPath, fileUpdates] of byFile) {
1680 try {
1681 const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
1682 let updated = false;
1683 for (const u of fileUpdates) {
1684 if (pkgJson[u.depField]?.[u.pkg]) {
1685 pkgJson[u.depField][u.pkg] = u.newSpec;
1686 updated = true;
1687 appliedCount++;
1688 ok(
1689 `${u.workspace}: ${u.pkg} ${clr.versionBad(u.oldSpec)} \u2192 ${clr.versionOk(u.newSpec)} in ${u.depField}`,
1690 );
1691 }
1692 }
1693 if (updated) {
1694 writeFileSync(
1695 pkgJsonPath,
1696 JSON.stringify(pkgJson, null, 2) + "\n",
1697 "utf-8",
1698 );
1699 }
1700 } catch (e) {
1701 warn(`Failed to update ${pkgJsonPath}: ${e.message}`);
1702 }
1703 }
1704 return appliedCount;
1705}
1706
1707/**
1708 * Returns true if upgrading from oldVersion to newVersion is a breaking
1709 * semver change: either a major version bump, or a minor bump within 0.x
1710 * (where 0.x → 0.y is considered breaking by convention).
1711 */
1712function isBreakingBump(oldVersion, newVersion) {
1713 try {
1714 const oldMajor = semver.major(oldVersion);
1715 const newMajor = semver.major(newVersion);
1716 if (newMajor > oldMajor) return true;
1717 // In 0.x, minor bumps are breaking
1718 if (
1719 oldMajor === 0 &&
1720 newMajor === 0 &&
1721 semver.minor(newVersion) > semver.minor(oldVersion)
1722 ) {
1723 return true;
1724 }
1725 return false;
1726 } catch {
1727 return false;
1728 }
1729}
1730
1731/**
1732 * Build a new version spec preserving the range prefix from the old spec.
1733 * e.g. oldSpec="^1.2.3", newVersion="1.5.0" → "^1.5.0"
1734 */
1735function buildVersionSpec(oldSpec, newVersion) {
1736 if (
1737 oldSpec === "*" ||
1738 oldSpec === "latest" ||
1739 oldSpec.startsWith("workspace:")
1740 ) {
1741 return oldSpec;
1742 }
1743 // npm: aliases (e.g. "npm:other-pkg@^1.0.0") — preserve the alias prefix
1744 // and recurse on the version portion after the alias.
1745 const aliasMatch = oldSpec.match(/^(npm:[^@]+@)(.*)/);
1746 if (aliasMatch) {
1747 return aliasMatch[1] + buildVersionSpec(aliasMatch[2], newVersion);
1748 }
1749 const prefixMatch = oldSpec.match(/^([~^]|>=?)/);
1750 const prefix = prefixMatch ? prefixMatch[0] : "";
1751 // Compound specs (e.g. ">=1.0.0 <2.0.0", "1.x || >=2.0.0") can't be
1752 // safely rewritten by prefix alone — warn the user to review manually.
1753 if (oldSpec.includes(" ") || oldSpec.includes("||")) {
1754 warn(
1755 `buildVersionSpec: complex spec "${oldSpec}" cannot be rewritten automatically; using "${prefix}${newVersion}" — review manually`,
1756 );
1757 }
1758 return `${prefix}${newVersion}`;
1759}
1760
1761// ── Fix plan query helpers ───────────────────────────────────────────────────
1762// Every decision about what to do with an entry is derived from its
1763// `fixPlan` (or absence thereof).
1764
1765/** True when some resolved versions are blocked and need pnpm.overrides. */
1766function needsOverride(entry) {
1767 return entry.fixPlan?.blockedVersions?.length > 0;
1768}
1769
1770/** True when workspace/intermediate actions are needed (unblocked path). */
1771function hasUnblockedActions(entry) {
1772 return entry.fixPlan?.unblockedActions?.length > 0;
1773}
1774
1775/** True when a simple `pnpm update` is sufficient (no actions, no blocks). */
1776function isSimpleUpdate(entry) {
1777 return (
1778 entry.fixPlan && !needsOverride(entry) && !hasUnblockedActions(entry)
1779 );
1780}
1781
1782/**
1783 * Assess the risk of a fix action for a given analysis entry.
1784 * Works for entries with unblocked actions or overrides.
1785 * Returns { level: "low"|"medium"|"high", reason: string }.
1786 */
1787async function assessRisk(entry) {
1788 const { pkg, patched, currentVersion } = entry;
1789
1790 // Check for cross-major bump
1791 const crossMajor =
1792 currentVersion &&
1793 semver.valid(currentVersion) &&
1794 semver.valid(patched) &&
1795 isBreakingBump(currentVersion, patched);
1796
1797 if (hasUnblockedActions(entry)) {
1798 const { workspace: wsActions } = groupActionsByType(
1799 entry.fixPlan?.unblockedActions,
1800 );
1801 const majorUpdates = wsActions.filter((a) => {
1802 const oldMin = semver.minVersion(a.oldSpec);
1803 const newMin = semver.minVersion(a.newSpec);
1804 return (
1805 oldMin &&
1806 newMin &&
1807 isBreakingBump(oldMin.version, newMin.version)
1808 );
1809 });
1810
1811 if (crossMajor || majorUpdates.length > 0) {
1812 const parts = [];
1813 if (crossMajor)
1814 parts.push(`major bump ${currentVersion} → ${patched}`);
1815 if (majorUpdates.length > 0) {
1816 const names = majorUpdates.map((a) => a.pkg).join(", ");
1817 parts.push(
1818 `${majorUpdates.length} workspace dep(s) need breaking bump: ${names}`,
1819 );
1820 }
1821 return { level: "high", reason: parts.join(", ") };
1822 }
1823 if (wsActions.length >= 3) {
1824 return {
1825 level: "medium",
1826 reason: `${wsActions.length} workspace package.json files to update`,
1827 };
1828 }
1829 return {
1830 level: "low",
1831 reason: `${wsActions.length || 1} workspace update(s), patch/minor bump`,
1832 };
1833 }
1834
1835 // Override-based entries
1836 const whyData = await getPnpmWhy(pkg);
1837 const parents = await findConstrainingParentsFromData(whyData, pkg);
1838 const blockingParents = parents.filter(
1839 (p) =>
1840 p.requiredSpec &&
1841 !specGuaranteesMinVersion(p.requiredSpec, patched),
1842 );
1843
1844 if (crossMajor) {
1845 return {
1846 level: "high",
1847 reason: `major version bump ${currentVersion} → ${patched}, ${blockingParents.length} parent(s) may break`,
1848 };
1849 }
1850 if (blockingParents.length >= 3) {
1851 return {
1852 level: "medium",
1853 reason: `${blockingParents.length} parent(s) constrain this package`,
1854 };
1855 }
1856 return {
1857 level: "low",
1858 reason: `patch/minor bump, ${blockingParents.length || "no"} constrained parent(s)`,
1859 };
1860}
1861
1862/**
1863 * Returns a Map of workspace package name → absolute package.json path.
1864 */
1865function getWorkspacePackagePaths() {
1866 if (_cache.workspacePkgPaths) return _cache.workspacePkgPaths;
1867 _cache.workspacePkgPaths = new Map();
1868 try {
1869 const output = runCmd("pnpm", ["ls", "-r", "--depth", "-1", "--json"], {
1870 nothrow: true,
1871 });
1872 if (!output) return _cache.workspacePkgPaths;
1873 const parsed = parsePaginatedJson(output);
1874 for (const ws of parsed) {
1875 if (ws.name && ws.path) {
1876 _cache.workspacePkgPaths.set(
1877 ws.name,
1878 resolve(ws.path, "package.json"),
1879 );
1880 }
1881 }
1882 } catch (e) {
1883 verbose(`getWorkspacePackagePaths failed: ${e.message}`);
1884 warn("Could not build workspace package path map");
1885 }
1886 return _cache.workspacePkgPaths;
1887}
1888
1889function isWorkspacePackage(pkgName) {
1890 return getWorkspacePackagePaths().has(pkgName);
1891}
1892
1893// ── Shell packaging guard ────────────────────────────────────────────────────
1894//
1895// electron-builder's traversalNodeModulesCollector validates that installed
1896// package versions exactly match the version ranges declared in each parent's
1897// package.json. pnpm overrides change the *resolved* version but do NOT
1898// update the declaring package.json, so overrides for any package in the
1899// shell's production dependency tree will break `shell:package`.
1900//
1901// Pre-check: resolve the shell's production dep tree and block overrides
1902// for packages that appear in it.
1903// Post-check: run `electron-builder install-app-deps` after applying
1904// overrides and roll back any that cause failures.
1905
1906const SHELL_WORKSPACE = "agent-shell";
1907
1908/**
1909 * Build a Set of all packages in the shell workspace's production
1910 * dependency tree. Uses `pnpm ls --prod --json` filtered to the shell
1911 * workspace.
1912 *
1913 * Returns an empty set (with a warning) if the shell workspace is not
1914 * found or the command fails — the post-check will still catch problems.
1915 */
1916function getShellProductionDeps() {
1917 if (_cache.shellProdDeps) return _cache.shellProdDeps;
1918
1919 try {
1920 const output = runCmd(
1921 "pnpm",
1922 [
1923 "ls",
1924 "--filter",
1925 SHELL_WORKSPACE,
1926 "--prod",
1927 "--depth",
1928 "Infinity",
1929 "--json",
1930 ],
1931 { nothrow: true },
1932 );
1933 if (!output) {
1934 warn(
1935 "Could not resolve shell production deps — shell packaging post-check will still validate",
1936 );
1937 _cache.shellProdDeps = new Set();
1938 return _cache.shellProdDeps;
1939 }
1940 const deps = new Set();
1941 const parsed = parsePaginatedJson(output);
1942 function collectDeps(node) {
1943 if (!node) return;
1944 for (const [name, info] of Object.entries(node)) {
1945 deps.add(name);
1946 if (info.dependencies) collectDeps(info.dependencies);
1947 }
1948 }
1949 for (const ws of parsed) {
1950 if (ws.dependencies) collectDeps(ws.dependencies);
1951 }
1952 verbose(`Shell production deps: ${deps.size} packages`);
1953 _cache.shellProdDeps = deps;
1954 return deps;
1955 } catch (e) {
1956 verbose(`getShellProductionDeps failed: ${e.message}`);
1957 warn(
1958 "Could not resolve shell production deps — shell packaging post-check will still validate",
1959 );
1960 _cache.shellProdDeps = new Set();
1961 return _cache.shellProdDeps;
1962 }
1963}
1964
1965/**
1966 * Check if a package is in the shell's production dependency tree.
1967 * Returns false when --skip-shell-check is set or if the dep tree
1968 * could not be resolved (falls through to post-check).
1969 */
1970function isInShellBundle(pkg) {
1971 if (SKIP_SHELL_CHECK) return false;
1972 const deps = getShellProductionDeps();
1973 return deps.has(pkg);
1974}
1975
1976/**
1977 * Post-check: run `pnpm deploy --prod` for the shell workspace into a
1978 * temporary directory, then run `electron-builder install-app-deps` to
1979 * validate that the dependency tree is consistent. Returns { ok, error? }.
1980 *
1981 * This catches any override that causes electron-builder's
1982 * traversalNodeModulesCollector to fail with "Production dependency not found".
1983 */
1984async function verifyShellPackaging() {
1985 if (SKIP_SHELL_CHECK) return { ok: true };
1986
1987 verbose(
1988 "Running electron-builder install-app-deps to verify shell packaging …",
1989 );
1990 const shellPath = getWorkspacePackagePaths().get(SHELL_WORKSPACE);
1991 if (!shellPath) {
1992 verbose("Shell workspace not found — skipping post-check");
1993 return { ok: true };
1994 }
1995 const shellDir = dirname(shellPath);
1996 const electronBuilderConfig = resolve(
1997 shellDir,
1998 "electron-builder.config.js",
1999 );
2000 const deployDir = mkdtempSync(resolve(tmpdir(), "depfix-shell-"));
2001
2002 try {
2003 await runCmdAsync(
2004 "pnpm",
2005 [
2006 "deploy",
2007 "--filter",
2008 SHELL_WORKSPACE,
2009 "--prod",
2010 "--ignore-scripts",
2011 deployDir,
2012 ],
2013 { timeout: 300000 },
2014 );
2015
2016 await runCmdAsync(
2017 "npx",
2018 [
2019 "electron-builder",
2020 "install-app-deps",
2021 "--projectDir",
2022 deployDir,
2023 "--config",
2024 electronBuilderConfig,
2025 ],
2026 { timeout: 300000 },
2027 );
2028 return { ok: true };
2029 } catch (e) {
2030 return { ok: false, error: e.message };
2031 } finally {
2032 try {
2033 rmSync(deployDir, { recursive: true, force: true });
2034 } catch {
2035 // Ignore cleanup errors
2036 }
2037 }
2038}
2039
2040/**
2041 * Add multiple pnpm.overrides entries in a single read/write cycle.
2042 * @param {Map<string, string>} overridesMap - package name → version spec
2043 */
2044function addOverrides(overridesMap) {
2045 const pkgJsonPath = resolve(ROOT, "package.json");
2046 const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
2047
2048 if (!pkgJson.pnpm) pkgJson.pnpm = {};
2049 if (!pkgJson.pnpm.overrides) pkgJson.pnpm.overrides = {};
2050
2051 for (const [pkg, versionSpec] of overridesMap) {
2052 pkgJson.pnpm.overrides[pkg] = versionSpec;
2053 }
2054
2055 writeFileSync(
2056 pkgJsonPath,
2057 JSON.stringify(pkgJson, null, 2) + "\n",
2058 "utf-8",
2059 );
2060}
2061
2062/**
2063 * Run planFixes on a set of vulnerable whyData entries
2064 * and populate fixPlan + blockingReasons.
2065 *
2066 * Four outcomes (derived from the fixPlan):
2067 * 1. No actions, no blocked versions — isSimpleUpdate()
2068 * All parent specs already allow the patched version; a simple
2069 * `pnpm update <pkg> -r` will resolve the lockfile.
2070 *
2071 * 2. Has unblocked actions, no blocked versions — hasUnblockedActions() && !needsOverride()
2072 * Some workspace package.json files need version bumps before the
2073 * fix can propagate. Requires --update-parents (or --auto-fix).
2074 *
2075 * 3. All versions blocked — needsOverride() && !hasUnblockedActions()
2076 * Every resolved version's tree is blocked. Requires pnpm.overrides.
2077 *
2078 * 4. Mixed: some unblocked, some blocked — hasUnblockedActions() && needsOverride()
2079 * Unblocked subtrees get workspace/update actions; blocked subtrees
2080 * need pnpm.overrides. Both --update-parents and --apply-overrides
2081 * are needed for a full fix.
2082 */
2083
2084// ── Compact display helpers ──────────────────────────────────────────────────
2085//
2086// These functions produce the redesigned 2-4 line per-package output.
2087// The detailed constraint/chain output is gated behind --verbose / --show-chains.
2088
2089/**
2090 * Extract workspace root names from pnpm-why data by walking the dependents
2091 * tree to find leaf nodes (those with a `depField` property).
2092 */
2093function extractWorkspaceRoots(whyData, pkg) {
2094 const roots = new Set();
2095 const reverseTree = toReverseTree(whyData, pkg);
2096 function walk(node) {
2097 if (node.depField) {
2098 roots.add(node.name);
2099 return;
2100 }
2101 if (node.dependents) {
2102 for (const dep of node.dependents) walk(dep);
2103 }
2104 }
2105 for (const entry of reverseTree) {
2106 if (entry.dependents) {
2107 for (const dep of entry.dependents) walk(dep);
2108 }
2109 }
2110 return [...roots].sort();
2111}
2112
2113/**
2114 * Format the reason-centric action list for a vulnerability fix.
2115 * Each line explains WHY an action is needed, tagged with the action type.
2116 *
2117 * Tags:
2118 * [workspace] — a workspace package.json dep spec is too narrow
2119 * [update] — an intermediate package needs updating to widen its dep spec
2120 * [override] — no parent upgrade can fix this; needs pnpm.overrides
2121 */
2122async function formatActions(entry) {
2123 const { pkg, patched, fixPlan } = entry;
2124 const lines = [];
2125
2126 // Use pre-computed risk from analyzeVulnerabilities (avoids redundant async call)
2127 let riskLine = null;
2128 if (entry.risk) {
2129 const risk = entry.risk;
2130 const riskIcon =
2131 risk.level === "high"
2132 ? clr.fail("▲ high")
2133 : risk.level === "medium"
2134 ? clr.warn("■ medium")
2135 : clr.ok("▽ low");
2136 riskLine = `Risk: ${riskIcon} ${clr.meta("—")} ${clr.meta(risk.reason)}`;
2137 }
2138
2139 if (isSimpleUpdate(entry)) {
2140 lines.push(`Fix: ${clr.chrome(`pnpm update ${pkg} -r`)}`);
2141 return lines;
2142 }
2143
2144 // Check if required flags are present
2145 const needsFlags = entry.blockingReasons.length > 0;
2146 const flagHint = needsFlags
2147 ? ` ${clr.meta("(requires")} ${clr.chrome("--auto-fix")}${clr.meta(")")}`
2148 : "";
2149
2150 // Build a lookup of constraints for richer "why" descriptions
2151 const constraints = fixPlan?.constraints || [];
2152 const { workspace: wsActions, intermediate: intermediateActions } =
2153 groupActionsByType(fixPlan?.unblockedActions);
2154
2155 // Workspace package.json updates — spec is too narrow
2156 for (const act of wsActions) {
2157 lines.push(
2158 ` ${clr.chrome("[workspace]")} ${clr.pkg(act.workspace)} depends on ${clr.pkg(act.pkg)} ${clr.versionBad(act.oldSpec)}, needs ${clr.versionOk(act.newSpec)} for fix`,
2159 );
2160 }
2161
2162 // Intermediate package updates — their dep spec blocks the fix
2163 for (const act of deduplicateBy(intermediateActions, (a) => a.pkg)) {
2164 // Find the matching constraint to explain what spec blocks it
2165 const constraint = constraints.find(
2166 (c) =>
2167 c.parent === act.pkg &&
2168 c.parentVersion === act.fromVersion &&
2169 !c.allows,
2170 );
2171 const reason = constraint?.requiredSpec
2172 ? `depends on ${clr.pkg(constraint.child)} ${clr.version(constraint.requiredSpec)}, blocking fix`
2173 : `blocks ${clr.pkg(pkg)} from resolving`;
2174 lines.push(
2175 ` ${clr.chrome("[update]")} ${clr.pkg(act.pkg)}${clr.meta("@")}${clr.versionBad(act.fromVersion)} ${reason} ${clr.meta("→")} ${clr.versionOk(act.toVersion)} fixes it`,
2176 );
2177 }
2178
2179 // Override — explain why no parent update can help
2180 if (needsOverride(entry)) {
2181 const { blockers } = classifyConstraints(constraints);
2182 for (const b of deduplicateBy(
2183 blockers,
2184 (b) => `${b.parent}@${b.parentVersion}`,
2185 )) {
2186 const specDesc = b.requiredSpec
2187 ? `pins ${clr.pkg(b.child)} ${clr.version(b.requiredSpec)}`
2188 : `blocks ${clr.pkg(b.child)}`;
2189 const blockerLatest = await getLatestVersion(b.parent);
2190 let latestNote = "";
2191 if (!blockerLatest || blockerLatest === b.parentVersion) {
2192 latestNote = clr.meta(", already at latest");
2193 }
2194 const scopeNote = hasUnblockedActions(entry)
2195 ? ` ${clr.meta("(versions: " + fixPlan.blockedVersions.join(", ") + ")")}`
2196 : "";
2197 lines.push(
2198 ` ${clr.chrome("[override]")} ${clr.pkg(b.parent)}${clr.meta("@")}${clr.versionBad(b.parentVersion)} ${specDesc}${latestNote} — no update available${scopeNote}`,
2199 );
2200 }
2201 }
2202
2203 // Prepend header with flag hint if needed
2204 if (lines.length > 0) {
2205 lines.unshift(`Actions:${flagHint}`);
2206 }
2207
2208 // Append risk line
2209 if (riskLine) {
2210 lines.push(riskLine);
2211 }
2212
2213 return lines;
2214}
2215
2216/**
2217 * Render the compact analysis output for a single package.
2218 * Produces 1-4 lines depending on fix plan:
2219 * Line 1: Package identity, severity, version gap
2220 * Line 2: "Why" — root cause (only for blocked/workspace/mixed)
2221 * Line 3: "↳ used by" — workspace roots (only if relevant)
2222 * Line 4: "Fix" — actionable command + inline risk
2223 *
2224 * When --verbose is active, the full constraint/chain details follow.
2225 */
2226async function formatPackageAnalysis(entry, whyData, pkgIndex, pkgTotal) {
2227 const { pkg, patched, severity, alertNums, ghsaIds } = entry;
2228 const progress = clr.meta(`[${pkgIndex}/${pkgTotal}]`);
2229
2230 // ── Line 1: Identity ─────────────────────────────────────────────────
2231 if (!patched) {
2232 const noPatchVersions = getResolvedVersions(whyData, pkg);
2233 const installedStr =
2234 noPatchVersions.length > 0
2235 ? noPatchVersions.map((v) => clr.versionBad(v)).join(", ")
2236 : clr.meta("?");
2237 log(
2238 `\n ${progress} \ud83d\udce6 ${clr.pkg.bold(pkg)} ${clr.fail("\u2717 no patch")} (${colorSeverity(severity)}) ${clr.meta("—")} installed: ${installedStr}, no fix published`,
2239 );
2240 return;
2241 }
2242
2243 if (!entry.fixPlan) {
2244 log(
2245 `\n ${progress} \ud83d\udce6 ${clr.pkg.bold(pkg)} ${clr.ok("\u2713 fixed")} (${colorSeverity(severity)}) ${clr.meta("—")} all installed versions \u2265${clr.versionOk(patched)}`,
2246 );
2247 return;
2248 }
2249
2250 // For strategies that need action: show version gap
2251 const uniqueVersions = getResolvedVersions(whyData, pkg);
2252 const vulnVersions = uniqueVersions.filter((v) => semver.lt(v, patched));
2253 const versionGap = vulnVersions
2254 .map((v) => clr.versionBad(`\u2717 ${v}`))
2255 .join(", ");
2256
2257 log(
2258 `\n ${progress} \ud83d\udce6 ${clr.pkg.bold(pkg)} (${colorSeverity(severity)}) ${clr.meta("—")} ${versionGap} ${clr.meta("\u2192")} need ${clr.versionOk(`\u2265${patched}`)}`,
2259 );
2260
2261 // ── Advisory IDs (verbose only) ──────────────────────────────────────
2262 if (VERBOSE) {
2263 const uniqueGhsaIds = [...new Set(ghsaIds)];
2264 if (uniqueGhsaIds.length > 0) {
2265 log(
2266 ` Advisories: ${uniqueGhsaIds.map((id) => clr.meta(id)).join(clr.meta(", "))}`,
2267 );
2268 }
2269 }
2270
2271 // ── "Used by" — which workspace packages are affected ─────────────
2272 if (!isSimpleUpdate(entry)) {
2273 const wsRoots = extractWorkspaceRoots(whyData, pkg);
2274 if (wsRoots.length > 0) {
2275 const rootsStr =
2276 wsRoots
2277 .slice(0, 4)
2278 .map((r) => clr.root(r))
2279 .join(clr.meta(", ")) +
2280 (wsRoots.length > 4
2281 ? clr.meta(` +${wsRoots.length - 4} more`)
2282 : "");
2283 log(` ${clr.meta("↳ used by:")} ${rootsStr}`);
2284 }
2285 }
2286
2287 // ── Actions: reason-centric fix steps + risk ─────────────────────────
2288 const actionLines = await formatActions(entry);
2289 for (const line of actionLines) {
2290 log(` ${line}`);
2291 }
2292
2293 // ── Verbose: full constraint and chain details ───────────────────────
2294 if (VERBOSE && entry.fixPlan) {
2295 await displayConstraints(entry.fixPlan.constraints, pkg);
2296 }
2297 if (SHOW_CHAINS) {
2298 fmtDepChain(whyData, pkg);
2299 }
2300}
2301
2302/**
2303 * Populate fixPlan and blockingReasons on the entry.
2304 * Pure classification — no display output.
2305 */
2306async function classifyWithFixPlan(entry, whyData) {
2307 const { pkg, patched } = entry;
2308
2309 entry.fixPlan = await planFixes(pkg, patched, whyData);
2310
2311 if (hasUnblockedActions(entry)) {
2312 if (!flagAllows(UPDATE_PARENTS, pkg)) {
2313 entry.blockingReasons.push("--update-parents not specified");
2314 }
2315 }
2316 if (needsOverride(entry)) {
2317 if (!flagAllows(APPLY_OVERRIDES, pkg)) {
2318 entry.blockingReasons.push("--apply-overrides not specified");
2319 }
2320 // Pre-check: block overrides for packages in the shell's production
2321 // dependency tree — electron-builder will reject the version mismatch.
2322 if (!SKIP_SHELL_CHECK && isInShellBundle(pkg)) {
2323 entry.blockingReasons.push(
2324 "in shell production bundle — override would break electron-builder packaging (use --skip-shell-check to override)",
2325 );
2326 verbose(
2327 `${pkg}: blocked override — package is in ${SHELL_WORKSPACE} production deps`,
2328 );
2329 }
2330 }
2331}
2332
2333// ── Stage functions ──────────────────────────────────────────────────────────
2334
2335/** Stage 1: Fetch open Dependabot alerts from GitHub. */
2336function fetchAlerts() {
2337 header("Fetching open Dependabot alerts from GitHub");
2338
2339 let alerts;
2340 try {
2341 const remoteUrl = runCmd("git", ["remote", "get-url", "origin"]);
2342 const match = remoteUrl.match(
2343 /github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/,
2344 );
2345 if (!match) {
2346 throw new Error(
2347 `Cannot parse GitHub owner/repo from remote: ${remoteUrl}`,
2348 );
2349 }
2350 const [, owner, repo] = match;
2351 if (
2352 !/^[A-Za-z0-9._-]+$/.test(owner) ||
2353 !/^[A-Za-z0-9._-]+$/.test(repo)
2354 ) {
2355 throw new Error(
2356 `Unexpected characters in owner/repo parsed from remote: ${owner}/${repo}`,
2357 );
2358 }
2359 log(` Repository: ${clr.chrome(owner + "/" + repo)}`);
2360
2361 const raw = runCmd(
2362 "gh",
2363 [
2364 "api",
2365 `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/dependabot/alerts?state=open&per_page=100`,
2366 "--paginate",
2367 ],
2368 { timeout: 120000 },
2369 );
2370
2371 alerts = parsePaginatedJson(raw);
2372 } catch (e) {
2373 console.error("Failed to fetch alerts:", e.message);
2374 console.error(
2375 "Make sure `gh` is installed, authenticated, and has access to Dependabot alerts.",
2376 );
2377 process.exit(1);
2378 }
2379
2380 // Only npm ecosystem alerts
2381 alerts = alerts.filter((a) => a.dependency?.package?.ecosystem === "npm");
2382
2383 // Filter to alerts belonging to the current workspace.
2384 // Derive wsPrefix from ROOT (which already points at the workspace root,
2385 // even when the script is invoked from a subdirectory like ts/tools).
2386 let wsPrefix = "";
2387 try {
2388 const gitRoot = runCmd("git", ["rev-parse", "--show-toplevel"]).replace(
2389 /\\/g,
2390 "/",
2391 );
2392 const rootNorm = ROOT.replace(/\\/g, "/");
2393 wsPrefix = rootNorm.startsWith(gitRoot + "/")
2394 ? rootNorm.slice(gitRoot.length + 1).split("/")[0]
2395 : "";
2396 } catch {
2397 verbose(
2398 " Could not determine git root; skipping workspace-specific alert filtering.",
2399 );
2400 }
2401 if (wsPrefix) {
2402 const before = alerts.length;
2403 alerts = alerts.filter((a) => {
2404 const manifest = a.dependency?.manifest_path ?? "";
2405 return manifest.startsWith(wsPrefix + "/") || manifest === wsPrefix;
2406 });
2407 if (alerts.length < before) {
2408 verbose(
2409 ` Filtered to ${alerts.length}/${before} alerts matching workspace "${wsPrefix}/"`,
2410 );
2411 }
2412 }
2413
2414 if (alerts.length === 0) {
2415 log(" No open npm Dependabot alerts found. 🎉");
2416 process.exit(0);
2417 }
2418
2419 return alerts;
2420}
2421
2422/** Stage 2: Group alerts by vulnerable package, keeping the highest required patch version. */
2423function deduplicateAlerts(alerts) {
2424 const byPackage = new Map();
2425 for (const alert of alerts) {
2426 const pkg = alert.dependency.package.name;
2427 const patched =
2428 alert.security_vulnerability?.first_patched_version?.identifier;
2429 const severity = alert.security_advisory?.severity ?? "unknown";
2430 const ghsaId = alert.security_advisory?.ghsa_id ?? "";
2431 const manifest = alert.dependency?.manifest_path ?? "";
2432
2433 if (!byPackage.has(pkg)) {
2434 byPackage.set(pkg, {
2435 package: pkg,
2436 patched,
2437 severity,
2438 alerts: [],
2439 ghsaIds: [],
2440 manifests: new Set(),
2441 });
2442 }
2443 const entry = byPackage.get(pkg);
2444 entry.alerts.push(alert.number);
2445 if (ghsaId) entry.ghsaIds.push(ghsaId);
2446 if (manifest) entry.manifests.add(manifest);
2447
2448 // Keep the highest severity across all alerts for this package
2449 if (
2450 (SEVERITY_ORDER[severity] || 0) >
2451 (SEVERITY_ORDER[entry.severity] || 0)
2452 ) {
2453 entry.severity = severity;
2454 }
2455
2456 if (
2457 patched &&
2458 semver.valid(patched) &&
2459 (!entry.patched ||
2460 !semver.valid(entry.patched) ||
2461 semver.compare(patched, entry.patched) > 0)
2462 ) {
2463 entry.patched = patched;
2464 }
2465 }
2466
2467 // Sort by severity (critical first), then alphabetically by package name
2468 const sorted = new Map(
2469 [...byPackage.entries()].sort(([, a], [, b]) => {
2470 const sevDiff =
2471 (SEVERITY_ORDER[b.severity] || 0) -
2472 (SEVERITY_ORDER[a.severity] || 0);
2473 return sevDiff !== 0 ? sevDiff : a.package.localeCompare(b.package);
2474 }),
2475 );
2476
2477 log(
2478 ` Found ${clr.warn.bold(alerts.length)} alert(s) across ${clr.chrome.bold(sorted.size)} package(s)`,
2479 );
2480 return sorted;
2481}
2482
2483/** Stage 3+4: Classify each vulnerable package and run fix planner. */
2484async function analyzeVulnerabilities(byPackage) {
2485 header("Analyzing vulnerabilities");
2486
2487 const pkgTotal = byPackage.size;
2488 const items = [...byPackage.entries()];
2489
2490 // Max concurrent package analyses. Each analysis fires multiple npm calls
2491 // which are themselves rate-limited by _npmSem (DEPFIX_NPM_CONCURRENCY).
2492 const CONCURRENCY = parseInt(process.env.DEPFIX_CONCURRENCY ?? "5", 10);
2493 const sem = new Semaphore(CONCURRENCY, "DEPFIX_CONCURRENCY");
2494
2495 // Analyse all packages concurrently. Each task buffers its own log lines
2496 // so output can be flushed in the original sorted order (not interleaved).
2497 const results = await Promise.all(
2498 items.map(async ([pkg, info], idx) => {
2499 await sem.acquire();
2500 const pkgIndex = idx + 1;
2501 const lines = [];
2502 try {
2503 const entry = await _logStorage.run(lines, async () => {
2504 const {
2505 patched,
2506 severity,
2507 alerts: alertNums,
2508 ghsaIds,
2509 manifests,
2510 } = info;
2511 const manifestList = [...manifests].join(", ");
2512
2513 // Entry shape:
2514 // pkg: string — vulnerable package name
2515 // patched: string|undefined — minimum safe version; undefined = no patch
2516 // severity: 'critical'|'high'|'medium'|'low'|'unknown'
2517 // alertNums: number[] — GitHub alert numbers
2518 // ghsaIds: string[] — GHSA advisory IDs
2519 // manifestList: string — comma-separated manifest paths from alerts
2520 // currentVersion: string|null — installed vulnerable version
2521 // latestVersion: string|null — latest published version
2522 // fixPlan: object|null — populated by classifyWithFixPlan(); see planFixes() return type
2523 // blockingReasons: string[] — reasons why automated fix is blocked
2524 // risk: object|null — pre-computed by assessRisk(); used by printSummary/emitJson
2525 // error?: string — set if the apply step failed for this entry
2526 const e = {
2527 pkg,
2528 patched,
2529 severity,
2530 alertNums,
2531 ghsaIds,
2532 manifestList,
2533 currentVersion: null,
2534 latestVersion: null,
2535 fixPlan: null,
2536 blockingReasons: [],
2537 risk: null,
2538 };
2539
2540 let whyData;
2541 try {
2542 whyData = await getPnpmWhy(pkg);
2543 } catch (whyErr) {
2544 e.error = `pnpm why failed: ${whyErr.message}`;
2545 e.blockingReasons.push(e.error);
2546 fail(`${pkg}: ${e.error}`);
2547 return e;
2548 }
2549
2550 if (whyData.length === 0) {
2551 verbose(
2552 `${pkg}: pnpm why returned no data — package may not be installed`,
2553 );
2554 }
2555
2556 if (!patched) {
2557 const noPatchVersions = getResolvedVersions(
2558 whyData,
2559 pkg,
2560 );
2561 e.currentVersion =
2562 noPatchVersions.length > 0
2563 ? noPatchVersions[0]
2564 : null;
2565 } else {
2566 const uniqueVersions = getResolvedVersions(
2567 whyData,
2568 pkg,
2569 );
2570 const vulnVersions = uniqueVersions.filter((v) =>
2571 semver.lt(v, patched),
2572 );
2573 const fixedVersions = uniqueVersions.filter((v) =>
2574 semver.gte(v, patched),
2575 );
2576 e.currentVersion =
2577 vulnVersions.length > 0
2578 ? vulnVersions[0]
2579 : fixedVersions[0] || null;
2580 e.latestVersion = await getLatestVersion(pkg);
2581
2582 if (vulnVersions.length > 0) {
2583 await classifyWithFixPlan(e, whyData);
2584 // Pre-compute risk so printSummary/emitJson stay sync
2585 if (hasUnblockedActions(e) || needsOverride(e)) {
2586 e.risk = await assessRisk(e);
2587 }
2588 }
2589 }
2590
2591 await formatPackageAnalysis(e, whyData, pkgIndex, pkgTotal);
2592 return e;
2593 });
2594 return { entry, lines };
2595 } finally {
2596 sem.release();
2597 }
2598 }),
2599 );
2600
2601 if (process.stderr.isTTY && !JSON_OUTPUT) {
2602 process.stderr.write("\r\x1b[K");
2603 }
2604
2605 // Flush each package's buffered log lines in original order, then collect entries
2606 const analyses = [];
2607 for (const { entry, lines } of results) {
2608 if (!JSON_OUTPUT) {
2609 for (const line of lines) console.log(line);
2610 }
2611 analyses.push(entry);
2612 }
2613 return analyses;
2614}
2615
2616/** Stage 5: Execute resolutions. */
2617async function executeResolutions(analyses) {
2618 const actionable = analyses.filter(
2619 (a) => a.fixPlan && a.blockingReasons.length === 0,
2620 );
2621
2622 if (actionable.length > 0) {
2623 header(DRY_RUN ? "Resolution plan (dry run)" : "Applying resolutions");
2624 }
2625
2626 const results = {
2627 alreadyFixed: analyses.filter((a) => a.patched && !a.fixPlan),
2628 resolved: [],
2629 blocked: analyses.filter((a) => a.blockingReasons.length > 0),
2630 noPatch: analyses.filter((a) => !a.patched),
2631 failed: [],
2632 };
2633
2634 /**
2635 * Read the current `pnpm.overrides[pkg]` spec from the workspace root
2636 * package.json, if any.
2637 * @param {string} pkg
2638 * @returns {string|undefined}
2639 */
2640 function readExistingOverride(pkg) {
2641 try {
2642 const pkgJson = JSON.parse(
2643 readFileSync(resolve(ROOT, "package.json"), "utf-8"),
2644 );
2645 return pkgJson?.pnpm?.overrides?.[pkg];
2646 } catch {
2647 return undefined;
2648 }
2649 }
2650
2651 /**
2652 * True if `spec` is satisfied by some version below `patched` — i.e. the
2653 * existing override is wide enough to keep the vulnerable version pinned.
2654 */
2655 function overrideAllowsVulnerable(spec, patched) {
2656 if (!spec || typeof spec !== "string") return false;
2657 try {
2658 const min = semver.minVersion(spec);
2659 return min ? semver.lt(min.version, patched) : false;
2660 } catch {
2661 return false;
2662 }
2663 }
2664
2665 /**
2666 * Build the raised pnpm.overrides spec.
2667 *
2668 * Naively returning `>=${patched}` discards any existing upper bound
2669 * (e.g. `^12.2.0` becomes `>=12.5.0`), which can silently broaden the
2670 * override into a major-version-spanning range and change resolution
2671 * behaviour for unrelated dependencies.
2672 *
2673 * Instead, AND-combine the existing range with `>=${patched}` so the
2674 * upper bound (if any) is preserved. If the combined range would be
2675 * empty (existing range is capped below patched), fall back to
2676 * `>=${patched}` and warn — there's no way to honour both.
2677 */
2678 function raiseOverrideSpec(existing, patched) {
2679 const fallback = `>=${patched}`;
2680 if (!existing || typeof existing !== "string") return fallback;
2681 // Compound specs we already warn about elsewhere; keep simple here.
2682 try {
2683 // If patched is greater than every version satisfying existing,
2684 // intersecting would yield an empty range. Drop the upper bound
2685 // explicitly and warn.
2686 if (semver.gtr(patched, existing)) {
2687 warn(
2688 `Existing override "${existing}" caps below patched ${patched}; widening to "${fallback}" — review manually`,
2689 );
2690 return fallback;
2691 }
2692 const combined = `${existing} ${fallback}`;
2693 if (!semver.validRange(combined)) {
2694 return fallback;
2695 }
2696 return combined;
2697 } catch {
2698 return fallback;
2699 }
2700 }
2701
2702 /**
2703 * Snapshot package.json (and pnpm-lock.yaml if present) so the
2704 * override-raise path can roll back cleanly when the second verify
2705 * still fails. Without this, a failed raise leaves the modified
2706 * override + lockfile on disk, polluting subsequent package fixes
2707 * in the same run and producing a dirty working tree for what we
2708 * report as a "blocked" outcome.
2709 */
2710 function snapshotRoot() {
2711 const pkgJsonPath = resolve(ROOT, "package.json");
2712 const lockPath = resolve(ROOT, "pnpm-lock.yaml");
2713 const snap = { pkgJson: readFileSync(pkgJsonPath, "utf-8") };
2714 try {
2715 snap.lock = readFileSync(lockPath, "utf-8");
2716 } catch {
2717 snap.lock = null;
2718 }
2719 return snap;
2720 }
2721
2722 async function restoreRoot(snap) {
2723 const pkgJsonPath = resolve(ROOT, "package.json");
2724 const lockPath = resolve(ROOT, "pnpm-lock.yaml");
2725 writeFileSync(pkgJsonPath, snap.pkgJson, "utf-8");
2726 if (snap.lock !== null) writeFileSync(lockPath, snap.lock, "utf-8");
2727 // Re-run install so the in-memory state of node_modules matches the
2728 // restored lockfile before the next package's analysis kicks in.
2729 try {
2730 await runCmdAsync("pnpm", ["install"], {
2731 timeout: 180000,
2732 nothrow: true,
2733 });
2734 } catch {
2735 /* best-effort */
2736 }
2737 }
2738
2739 /**
2740 * Try raising a stale `pnpm.overrides[pkg]` entry that allows the
2741 * vulnerable version, then `pnpm install` and re-verify. Returns
2742 * { outcome: "ok" } – raise + install + verify all succeeded
2743 * { outcome: "blocked", check } – raise applied but still unfixed
2744 * { outcome: "noop" } – no stale override; nothing attempted
2745 * { outcome: "failed", error } – raise install threw
2746 * Always restores package.json + lockfile if anything goes wrong.
2747 */
2748 async function tryRaiseStaleOverride(a) {
2749 const existing = readExistingOverride(a.pkg);
2750 if (!existing || !overrideAllowsVulnerable(existing, a.patched)) {
2751 return { outcome: "noop" };
2752 }
2753 const newSpec = raiseOverrideSpec(existing, a.patched);
2754 verbose(
2755 `Stale override pnpm.overrides["${a.pkg}"] = "${existing}" allows vulnerable versions — raising to "${newSpec}"`,
2756 );
2757 const snap = snapshotRoot();
2758 addOverrides(new Map([[a.pkg, newSpec]]));
2759 let check;
2760 try {
2761 // Use --no-frozen-lockfile because we just mutated
2762 // pnpm.overrides; in CI (CI=true) pnpm install defaults to
2763 // --frozen-lockfile and will refuse to proceed with
2764 // ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the new override
2765 // doesn't match what's recorded in pnpm-lock.yaml. Updating
2766 // the lockfile is exactly the intent of the raise.
2767 await runCmdAsync("pnpm", ["install", "--no-frozen-lockfile"], {
2768 timeout: 180000,
2769 });
2770 check = await verifyAllVersionsFixed(a.pkg, a.patched);
2771 } catch (e) {
2772 await restoreRoot(snap);
2773 return { outcome: "failed", error: e, existing, newSpec };
2774 }
2775 if (check.ok) {
2776 ok(
2777 `Raised pnpm.overrides["${a.pkg}"] from "${existing}" to "${newSpec}" — all versions fixed: ${check.versions.join(", ")}`,
2778 );
2779 return { outcome: "ok" };
2780 }
2781 warn(
2782 `Raised pnpm.overrides["${a.pkg}"] to "${newSpec}" but still unfixed: ${check.unfixed.join(", ")} — rolling back`,
2783 );
2784 await restoreRoot(snap);
2785 return { outcome: "blocked", check, existing, newSpec };
2786 }
2787
2788 /**
2789 * Run `pnpm update <pkg> -r` and verify all versions are fixed.
2790 * Returns "ok" | "blocked" | "failed".
2791 */
2792 async function runUpdateAndVerify(a) {
2793 try {
2794 await runCmdAsync("pnpm", ["update", a.pkg, "-r"], {
2795 timeout: 120000,
2796 });
2797 let check = await verifyAllVersionsFixed(a.pkg, a.patched);
2798 if (check.ok) {
2799 ok(
2800 `Updated ${a.pkg} — all versions fixed: ${check.versions.join(", ")}`,
2801 );
2802 return "ok";
2803 }
2804
2805 // pnpm update couldn't move past an existing pnpm.overrides[pkg]
2806 // entry that's wider than `>=patched`. Raise the override and
2807 // re-run install + verify. Without this, packages whose only
2808 // remaining vulnerability gate is a stale override (e.g. an
2809 // earlier security bump that has since been superseded by a new
2810 // advisory) get classified as blocked.
2811 const raised = await tryRaiseStaleOverride(a);
2812 if (raised.outcome === "ok") return "ok";
2813 if (raised.outcome === "failed") {
2814 fail(
2815 `Override raise install failed for ${a.pkg}: ${raised.error.message}`,
2816 );
2817 a.error = raised.error.message;
2818 return "failed";
2819 }
2820 if (raised.outcome === "blocked") {
2821 check = raised.check;
2822 }
2823
2824 warn(
2825 `pnpm update left unfixed versions of ${a.pkg}: ${check.unfixed.join(", ")} (need >=${a.patched})`,
2826 );
2827 a.blockingReasons.push(
2828 `pnpm update left unfixed versions: ${check.unfixed.join(", ")}`,
2829 );
2830 return "blocked";
2831 } catch (e) {
2832 // pnpm update threw — most often a stale `pnpm.overrides[pkg]`
2833 // entry whose lower bound now excludes any non-vulnerable version
2834 // makes pnpm refuse to resolve the install entirely. Try the
2835 // same override-raise recovery path before giving up.
2836 const raised = await tryRaiseStaleOverride(a);
2837 if (raised.outcome === "ok") return "ok";
2838 if (raised.outcome === "blocked") {
2839 a.blockingReasons.push(
2840 `pnpm update threw and raised override still leaves unfixed versions: ${raised.check.unfixed.join(", ")}`,
2841 );
2842 return "blocked";
2843 }
2844 // Either no stale override was found (noop) or the raised
2845 // install also threw (failed) — surface the original update
2846 // error since that's the actionable signal.
2847 fail(`pnpm update failed for ${a.pkg}: ${e.message}`);
2848 a.error = e.message;
2849 return "failed";
2850 }
2851 }
2852
2853 for (const a of actionable) {
2854 const dryTag = DRY_RUN ? clr.meta("[dry-run] ") : "";
2855
2856 if (isSimpleUpdate(a)) {
2857 if (DRY_RUN) {
2858 const existing = readExistingOverride(a.pkg);
2859 if (existing && overrideAllowsVulnerable(existing, a.patched)) {
2860 // The real path tries `pnpm update` first and only falls
2861 // back to raising the override when update alone leaves
2862 // unfixed versions. Mirror that sequence in the dry-run
2863 // message so reviewers don't think we're skipping the
2864 // cheap `update` attempt.
2865 const newSpec = raiseOverrideSpec(existing, a.patched);
2866 ok(
2867 `${dryTag}pnpm update ${a.pkg} -r → >=${a.patched}; if still unfixed, raise pnpm.overrides["${a.pkg}"] from "${existing}" to "${newSpec}" + pnpm install`,
2868 );
2869 } else {
2870 ok(`${dryTag}pnpm update ${a.pkg} -r → >=${a.patched}`);
2871 }
2872 } else {
2873 const outcome = await runUpdateAndVerify(a);
2874 if (outcome === "blocked") {
2875 results.blocked.push(a);
2876 continue;
2877 }
2878 if (outcome === "failed") {
2879 results.failed.push(a);
2880 continue;
2881 }
2882 }
2883 results.resolved.push(a);
2884 } else if (hasUnblockedActions(a)) {
2885 const { workspace: wsActions, intermediate: intermediateActions } =
2886 groupActionsByType(a.fixPlan?.unblockedActions);
2887 if (DRY_RUN) {
2888 for (const act of wsActions) {
2889 ok(
2890 `${dryTag}${act.workspace}: ${act.pkg} ${clr.versionBad(act.oldSpec)} \u2192 ${clr.versionOk(act.newSpec)}`,
2891 );
2892 }
2893 for (const act of intermediateActions) {
2894 ok(
2895 `${dryTag}pnpm update ${act.pkg} -r (${clr.versionBad(act.fromVersion)} \u2192 ${clr.versionOk(act.toVersion)})`,
2896 );
2897 }
2898 ok(`${dryTag}pnpm update ${a.pkg} -r → >=${a.patched}`);
2899 if (needsOverride(a)) {
2900 ok(
2901 `${dryTag}Add pnpm.overrides["${a.pkg}"] = ">=${a.patched}" (for blocked versions: ${a.fixPlan.blockedVersions.join(", ")})`,
2902 );
2903 }
2904 } else {
2905 if (wsActions.length > 0) {
2906 const applied = applyFixActions(wsActions);
2907 if (applied === 0) {
2908 warn(`No parent updates could be applied for ${a.pkg}`);
2909 }
2910 }
2911 // Update intermediate packages first so their newer
2912 // versions widen the dep spec for the vulnerable package
2913 if (intermediateActions.length > 0) {
2914 const intermediatePkgs = [
2915 ...new Set(intermediateActions.map((act) => act.pkg)),
2916 ];
2917 verbose(
2918 `Updating intermediates: ${intermediatePkgs.join(", ")}`,
2919 );
2920 const intResult = await runCmdAsync(
2921 "pnpm",
2922 ["update", ...intermediatePkgs, "-r"],
2923 { timeout: 120000 },
2924 ).catch(() => null);
2925 if (intResult === null) {
2926 warn(
2927 `pnpm update failed for intermediate(s): ${intermediatePkgs.join(", ")} — fix for ${a.pkg} may be incomplete`,
2928 );
2929 }
2930 }
2931 // Run pnpm update to fix the unblocked subtrees
2932 const outcome = await runUpdateAndVerify(a);
2933 if (outcome === "ok") {
2934 // pnpm update fixed everything (including blocked subtrees
2935 // that may have resolved anyway) — no override needed
2936 } else if (outcome === "failed") {
2937 results.failed.push(a);
2938 continue;
2939 } else if (needsOverride(a)) {
2940 // Expected: blocked versions remain — override handles them.
2941 // Don't mutate a.blockingReasons; the override path below
2942 // will resolve the remaining blocked versions.
2943 } else {
2944 // workspace actions but update didn't fully resolve
2945 results.blocked.push(a);
2946 continue;
2947 }
2948 }
2949 results.resolved.push(a);
2950 } else if (needsOverride(a)) {
2951 if (DRY_RUN) {
2952 ok(
2953 `${dryTag}Add pnpm.overrides["${a.pkg}"] = ">=${a.patched}"`,
2954 );
2955 }
2956 // Overrides are batched and written after the loop
2957 results.resolved.push(a);
2958 }
2959 }
2960
2961 // Batch-write all override entries in a single read/write cycle
2962 if (!DRY_RUN) {
2963 const pendingOverrides = new Map();
2964 for (const a of results.resolved) {
2965 if (needsOverride(a)) {
2966 pendingOverrides.set(a.pkg, `>=${a.patched}`);
2967 }
2968 }
2969 if (pendingOverrides.size > 0) {
2970 try {
2971 addOverrides(pendingOverrides);
2972 for (const [pkg, spec] of pendingOverrides) {
2973 ok(`Added pnpm.overrides["${pkg}"] = "${spec}"`);
2974 }
2975 } catch (e) {
2976 fail(`Failed to write overrides: ${e.message}`);
2977 // Move all override entries to failed
2978 results.resolved = results.resolved.filter((a) => {
2979 if (needsOverride(a)) {
2980 a.error = e.message;
2981 results.failed.push(a);
2982 return false;
2983 }
2984 return true;
2985 });
2986 }
2987 }
2988 }
2989
2990 // If overrides were added, run pnpm install to apply them
2991 if (!DRY_RUN) {
2992 const needsInstall = results.resolved.some((a) => needsOverride(a));
2993 if (needsInstall) {
2994 log("");
2995 try {
2996 await runCmdAsync("pnpm", ["install"], { timeout: 300000 });
2997 ok("pnpm install completed successfully");
2998 } catch (e) {
2999 fail(`pnpm install failed: ${e.message}`);
3000 warn(
3001 "package.json was modified but lockfile is out of sync — run `pnpm install` manually",
3002 );
3003 // Overrides were written but not applied — all override
3004 // entries are unverified; move them to failed.
3005 results.resolved = results.resolved.filter((a) => {
3006 if (needsOverride(a)) {
3007 a.error = `pnpm install failed: ${e.message}`;
3008 results.failed.push(a);
3009 return false;
3010 }
3011 return true;
3012 });
3013 }
3014 }
3015 }
3016
3017 // Post-check: verify electron-builder shell packaging is not broken
3018 // by any applied overrides. If it fails, identify and roll back the
3019 // offending overrides, re-run pnpm install, then retry verification.
3020 if (!DRY_RUN && !SKIP_SHELL_CHECK) {
3021 const overrideResolved = results.resolved.filter((a) =>
3022 needsOverride(a),
3023 );
3024 if (overrideResolved.length > 0) {
3025 log("");
3026 log(clr.chrome(" Verifying shell packaging compatibility …"));
3027 const check = await verifyShellPackaging();
3028 if (!check.ok) {
3029 warn(
3030 `electron-builder shell packaging check failed: ${check.error}`,
3031 );
3032 warn("Rolling back overrides for shell-bundle packages …");
3033
3034 // Identify which overrides are for shell-bundle packages
3035 const shellDeps = getShellProductionDeps();
3036 const toRollback = overrideResolved.filter((a) =>
3037 shellDeps.has(a.pkg),
3038 );
3039
3040 if (toRollback.length > 0) {
3041 // Remove the offending overrides from package.json
3042 const pkgJsonPath = resolve(ROOT, "package.json");
3043 const pkgJson = JSON.parse(
3044 readFileSync(pkgJsonPath, "utf-8"),
3045 );
3046 for (const a of toRollback) {
3047 if (pkgJson.pnpm?.overrides?.[a.pkg]) {
3048 delete pkgJson.pnpm.overrides[a.pkg];
3049 fail(
3050 `Rolled back pnpm.overrides["${a.pkg}"] — in shell bundle`,
3051 );
3052 }
3053 a.error =
3054 "override rolled back — breaks electron-builder shell packaging";
3055 results.failed.push(a);
3056 }
3057 writeFileSync(
3058 pkgJsonPath,
3059 JSON.stringify(pkgJson, null, 2) + "\n",
3060 "utf-8",
3061 );
3062
3063 results.resolved = results.resolved.filter(
3064 (a) => !toRollback.includes(a),
3065 );
3066
3067 // Re-run pnpm install after removing overrides
3068 try {
3069 await runCmdAsync("pnpm", ["install"], {
3070 timeout: 300000,
3071 });
3072 ok("pnpm install completed after rollback");
3073
3074 // Retry the shell packaging check
3075 const recheck = await verifyShellPackaging();
3076 if (recheck.ok) {
3077 ok("Shell packaging check passed after rollback");
3078 } else {
3079 warn(
3080 `Shell packaging still fails after rollback: ${recheck.error}`,
3081 );
3082 warn(
3083 "Run 'pnpm run -C packages/shell package' manually to diagnose",
3084 );
3085 }
3086 } catch (e) {
3087 fail(
3088 `pnpm install failed after rollback: ${e.message}`,
3089 );
3090 }
3091 } else {
3092 // No shell-bundle overrides identified, but check still
3093 // failed — may be a pre-existing issue.
3094 warn(
3095 "Shell packaging failure may be pre-existing — no shell-bundle overrides to roll back",
3096 );
3097 }
3098 } else {
3099 ok("Shell packaging check passed ✓");
3100 }
3101 }
3102 }
3103
3104 return results;
3105}
3106
3107/**
3108 * Derive a summary action tag from the entry's actual fix plan actions,
3109 * e.g. "[workspace+update]", "[update+override]", "[override]".
3110 */
3111function formatActionTag(entry) {
3112 const parts = [];
3113 const actions = entry.fixPlan?.unblockedActions || [];
3114 if (actions.some((a) => a.type === "update-workspace")) {
3115 parts.push("workspace");
3116 }
3117 if (
3118 isSimpleUpdate(entry) ||
3119 actions.some((a) => a.type === "update-intermediate")
3120 ) {
3121 parts.push("update");
3122 }
3123 if (needsOverride(entry)) {
3124 parts.push("override");
3125 }
3126 return `[${parts.join("+")}]`;
3127}
3128
3129/** Stage 6: Print summary and set exit code. */
3130function printSummary(results) {
3131 header("Summary");
3132
3133 const summaryParts = [
3134 results.alreadyFixed.length > 0 &&
3135 clr.ok(results.alreadyFixed.length + " already fixed"),
3136 results.resolved.length > 0 &&
3137 clr.ok(
3138 results.resolved.length +
3139 (DRY_RUN ? " to resolve" : " resolved"),
3140 ),
3141 results.blocked.length > 0 &&
3142 clr.warn(results.blocked.length + " blocked"),
3143 results.noPatch.length > 0 &&
3144 clr.fail(results.noPatch.length + " no fix available"),
3145 results.failed.length > 0 &&
3146 clr.fail(results.failed.length + " failed"),
3147 ].filter(Boolean);
3148 if (summaryParts.length > 0) {
3149 log(`\n ${summaryParts.join(" | ")}`);
3150 }
3151
3152 if (results.blocked.length > 0) {
3153 const parentBlocked = results.blocked.filter(
3154 (a) => hasUnblockedActions(a) && !needsOverride(a),
3155 );
3156 const overrideBlocked = results.blocked.filter(
3157 (a) =>
3158 needsOverride(a) &&
3159 a.blockingReasons.some(
3160 (r) =>
3161 r === "--apply-overrides not specified" ||
3162 r === "--update-parents not specified",
3163 ),
3164 );
3165 // Truly unfixable: all blocking reasons are NOT just missing flags —
3166 // at least one reason remains that no flag can address.
3167 const FLAG_REASONS = new Set([
3168 "--apply-overrides not specified",
3169 "--update-parents not specified",
3170 ]);
3171 const unfixable = results.blocked.filter((a) =>
3172 a.blockingReasons.some((r) => !FLAG_REASONS.has(r)),
3173 );
3174
3175 // Fixed packages — show before risk so good news comes first
3176 const fixedEntries = results.resolved.filter((a) => a.fixPlan);
3177 if (fixedEntries.length > 0) {
3178 log(clr.meta(`\n Fixed packages:`));
3179 for (const a of fixedEntries) {
3180 const strategyTag = clr.chrome(formatActionTag(a));
3181 const fromVer = a.currentVersion
3182 ? `${clr.meta(a.currentVersion)} ${clr.meta("→")} `
3183 : "";
3184 log(
3185 ` ${clr.ok("✓")} ${strategyTag} ${clr.pkg(a.pkg)} ${fromVer}${clr.versionOk(`>=${a.patched}`)}`,
3186 );
3187 }
3188 }
3189
3190 // Risk assessment for blocked entries
3191 const RISK_ORDER = { high: 0, medium: 1, low: 2 };
3192 const riskEntries = results.blocked.filter((a) => a.fixPlan && a.risk);
3193 if (riskEntries.length > 0) {
3194 const assessed = riskEntries.map((a) => ({
3195 entry: a,
3196 risk: a.risk,
3197 }));
3198 assessed.sort((a, b) => {
3199 const riskDiff =
3200 (RISK_ORDER[a.risk.level] ?? 3) -
3201 (RISK_ORDER[b.risk.level] ?? 3);
3202 if (riskDiff !== 0) return riskDiff;
3203 // Secondary: override > workspace > update
3204 const ACTION_ORDER = {
3205 override: 0,
3206 workspace: 1,
3207 update: 2,
3208 };
3209 const aTag = formatActionTag(a.entry);
3210 const bTag = formatActionTag(b.entry);
3211 const aAction = aTag.includes("override")
3212 ? "override"
3213 : aTag.includes("workspace")
3214 ? "workspace"
3215 : "update";
3216 const bAction = bTag.includes("override")
3217 ? "override"
3218 : bTag.includes("workspace")
3219 ? "workspace"
3220 : "update";
3221 return (
3222 (ACTION_ORDER[aAction] ?? 3) - (ACTION_ORDER[bAction] ?? 3)
3223 );
3224 });
3225 log(clr.meta(`\n Risk assessment:`));
3226 for (const { entry: a, risk } of assessed) {
3227 const riskIcon =
3228 risk.level === "high"
3229 ? clr.fail("▲ high")
3230 : risk.level === "medium"
3231 ? clr.warn("■ medium")
3232 : clr.ok("▽ low");
3233 const strategyTag = clr.chrome(formatActionTag(a));
3234 log(
3235 ` ${riskIcon} ${strategyTag} ${clr.pkg(a.pkg)} ${clr.versionOk(`>=${a.patched}`)}: ${clr.meta(risk.reason)}`,
3236 );
3237 }
3238 log("");
3239 }
3240
3241 if (parentBlocked.length > 0 || overrideBlocked.length > 0) {
3242 const autoFixPkgs = [
3243 ...parentBlocked.map((a) => a.pkg),
3244 ...overrideBlocked.map((a) => a.pkg),
3245 ];
3246 log(
3247 ` Run with ${clr.chrome("--auto-fix")} to fix: ${autoFixPkgs.map((p) => clr.pkg(p)).join(", ")}`,
3248 );
3249 if (parentBlocked.length > 0) {
3250 const parentDetails = parentBlocked.map((a) => {
3251 const updatedPkgs = a.fixPlan?.unblockedActions
3252 ?.filter(
3253 (act) =>
3254 act.type === "update-workspace" &&
3255 act.pkg !== a.pkg,
3256 )
3257 .map((act) => act.pkg);
3258 const via =
3259 updatedPkgs?.length > 0
3260 ? ` ${clr.meta("via")} ${updatedPkgs.map((p) => clr.pkg(p)).join(", ")}`
3261 : "";
3262 return `${clr.pkg(a.pkg)}${via}`;
3263 });
3264 log(
3265 ` (or ${clr.chrome("--update-parents")} for: ${parentDetails.join("; ")})`,
3266 );
3267 }
3268 if (overrideBlocked.length > 0) {
3269 log(
3270 ` (or ${clr.chrome("--apply-overrides")} for: ${overrideBlocked.map((a) => clr.pkg(a.pkg)).join(", ")})`,
3271 );
3272 }
3273 }
3274 if (unfixable.length > 0) {
3275 log(clr.warn(`\n Packages with no automated fix:`));
3276 for (const a of unfixable) {
3277 const reasons = a.blockingReasons.filter(
3278 (r) => r !== "--apply-overrides not specified",
3279 );
3280 log(` ${clr.pkg(a.pkg)}: ${clr.meta(reasons.join("; "))}`);
3281 }
3282 }
3283
3284 if (!SHOW_CHAINS) {
3285 log(
3286 `\n Run with ${clr.chrome("--show-chains")} to see full dependency paths for blocked packages.`,
3287 );
3288 }
3289 }
3290
3291 if (DRY_RUN) {
3292 log(
3293 `\n ${clr.warn.bold("⚠ DRY RUN — no changes were made.")} Run without ${clr.chrome("--dry-run")} to apply.`,
3294 );
3295 }
3296
3297 log("");
3298
3299 if (
3300 results.blocked.length > 0 ||
3301 results.noPatch.length > 0 ||
3302 results.failed.length > 0
3303 ) {
3304 process.exit(1);
3305 }
3306}
3307
3308/**
3309 * Check existing pnpm.overrides and remove entries whose override version
3310 * is already satisfied by the naturally resolved version.
3311 */
3312async function pruneOverrides() {
3313 header("Pruning stale pnpm.overrides");
3314
3315 const pkgJsonPath = resolve(ROOT, "package.json");
3316 const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
3317
3318 const overrides = pkgJson.pnpm?.overrides;
3319 if (!overrides || Object.keys(overrides).length === 0) {
3320 ok("No pnpm.overrides found — nothing to prune");
3321 return;
3322 }
3323
3324 log(
3325 ` Found ${clr.chrome.bold(Object.keys(overrides).length)} override(s)`,
3326 );
3327 const toRemove = [];
3328
3329 for (const [pkg, spec] of Object.entries(overrides)) {
3330 // Check if the package is still in the dependency tree
3331 const whyData = await getPnpmWhy(pkg);
3332 if (whyData.length === 0) {
3333 warn(
3334 `${pkg}: not installed in dependency tree — override is dead weight`,
3335 );
3336 toRemove.push(pkg);
3337 continue;
3338 }
3339 const parents = await findConstrainingParentsFromData(whyData, pkg);
3340
3341 // Parse the minimum version from the override spec
3342 const minVersion = semver.minVersion(spec);
3343 if (!minVersion) {
3344 log(
3345 ` ${clr.pkg(pkg)}: ${clr.meta(`cannot parse spec "${spec}" — keeping`)}`,
3346 );
3347 continue;
3348 }
3349
3350 const allAllow =
3351 parents.length === 0 ||
3352 parents.every(
3353 (cp) =>
3354 cp.requiredSpec &&
3355 specGuaranteesMinVersion(
3356 cp.requiredSpec,
3357 minVersion.version,
3358 ),
3359 );
3360
3361 if (allAllow) {
3362 ok(
3363 `${pkg}: override "${spec}" no longer needed (parents allow ${minVersion.version})`,
3364 );
3365 toRemove.push(pkg);
3366 } else {
3367 log(
3368 ` ${clr.pkg(pkg)}: ${clr.warn("still needed")} — parents don't allow ${minVersion.version}`,
3369 );
3370 }
3371 }
3372
3373 if (toRemove.length === 0) {
3374 log(`\n All overrides are still needed.`);
3375 return;
3376 }
3377
3378 if (DRY_RUN) {
3379 log(
3380 clr.meta(
3381 `\n ℹ Would remove ${toRemove.length} override(s). Run without --dry-run to apply.`,
3382 ),
3383 );
3384 return;
3385 }
3386
3387 for (const pkg of toRemove) {
3388 delete pkgJson.pnpm.overrides[pkg];
3389 }
3390 if (Object.keys(pkgJson.pnpm.overrides).length === 0) {
3391 delete pkgJson.pnpm.overrides;
3392 }
3393 if (Object.keys(pkgJson.pnpm).length === 0) {
3394 delete pkgJson.pnpm;
3395 }
3396
3397 writeFileSync(
3398 pkgJsonPath,
3399 JSON.stringify(pkgJson, null, 2) + "\n",
3400 "utf-8",
3401 );
3402 ok(`Removed ${toRemove.length} stale override(s)`);
3403}
3404
3405/**
3406 * Emit JSON output for CI integration.
3407 */
3408function emitJson(results) {
3409 const toJson = (a) => ({
3410 package: a.pkg,
3411 severity: a.severity,
3412 alertNumbers: a.alertNums,
3413 ghsaIds: a.ghsaIds,
3414 currentVersion: a.currentVersion,
3415 patchedVersion: a.patched,
3416 latestVersion: a.latestVersion,
3417 inShellBundle: isInShellBundle(a.pkg),
3418 blockingReasons: a.blockingReasons,
3419 risk: a.risk ?? null,
3420 fixPlan: a.fixPlan
3421 ? {
3422 unblockedActions: a.fixPlan.unblockedActions,
3423 blockedVersions: a.fixPlan.blockedVersions,
3424 blockReasons: a.fixPlan.blockReasons,
3425 }
3426 : null,
3427 });
3428
3429 const output = {
3430 summary: {
3431 alreadyFixed: results.alreadyFixed.length,
3432 resolved: results.resolved.length,
3433 blocked: results.blocked.length,
3434 noPatch: results.noPatch.length,
3435 failed: results.failed.length,
3436 },
3437 dryRun: DRY_RUN,
3438 alreadyFixed: results.alreadyFixed.map(toJson),
3439 resolved: results.resolved.map(toJson),
3440 blocked: results.blocked.map(toJson),
3441 noPatch: results.noPatch.map(toJson),
3442 failed: results.failed.map(toJson),
3443 };
3444
3445 console.log(JSON.stringify(output, null, 2));
3446}
3447
3448// ── Main ─────────────────────────────────────────────────────────────────────
3449
3450async function main() {
3451 // Ensure node_modules matches the lockfile — pnpm why reads from the
3452 // installed virtual store, not the lockfile itself.
3453 if (!SKIP_INSTALL) {
3454 if (!JSON_OUTPUT)
3455 console.log("Running pnpm install --frozen-lockfile …");
3456 runCmd("pnpm", ["install", "--frozen-lockfile"]);
3457 }
3458
3459 if (PRUNE_OVERRIDES) {
3460 await pruneOverrides();
3461 return;
3462 }
3463
3464 const alerts = fetchAlerts();
3465 const byPackage = deduplicateAlerts(alerts);
3466 const analyses = await analyzeVulnerabilities(byPackage);
3467 const results = await executeResolutions(analyses);
3468
3469 if (JSON_OUTPUT) {
3470 emitJson(results);
3471 } else {
3472 printSummary(results);
3473 }
3474}
3475
3476main().catch((e) => {
3477 console.error(e.message);
3478 process.exit(1);
3479});
3480