microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/remove-deprecated-dependencies

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

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