microsoft/TypeAgent

Public

mirrored fromhttps://github.com/microsoft/TypeAgentAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/fix-shell-and-cli-windows-job

Branches

Tags

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

Clone

HTTPS

Download ZIP

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

2994lines · modecode

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