microsoft/TypeAgent

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
61fc5ba0d4792bc9cf0f4696a3ab1fcbc9e9b490

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

2984lines · modecode

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