import path from "node:path"; import { safeReadText } from "../utils/file.js"; import type { Finding, ScanInput, SeverityLabel, ChainHop } from "../types.js"; import { severityOrder } from "../constants.js"; import { compareVersions, looksLikeVersion } from "../utils/version.js"; import { getPrimaryParent } from "../utils/finding.js"; import { isNpmLockSource, packageManagerForSource } from "../utils/package-manager-source.js"; import { calculatePathCoverage, formatDependencyPath } from "../utils/path-coverage.js"; import { pluralize } from "../utils/string.js"; import { quoteShellPath, shellQuote } from "../utils/shell-quote.js"; import { buildNpmWorkspaceMap } from "../parsers/package-lock.js"; import { buildPnpmWorkspaceMap } from "../parsers/pnpm-lock.js"; import { buildYarnWorkspaceMap, isYarnBerry } from "../parsers/yarn-lock.js"; import { buildBunWorkspaceMap } from "../parsers/bun-lock.js"; import { readConfiguredCooldown, cooldownWarningFor, type CooldownWarning } from "./cooldown.js"; import { isVerifiedUpgrade } from "./parent-upgrade.js"; import { installPackageSpec, packageInstanceKey, packageNameKey, registryPackageName } from "../utils/package-identity.js"; // Rendered next to a parent-upgrade fix target whose recommendedParentUpgrade // could not be verified end-to-end (parent-upgrade.ts confidence: "unverified"). // Shared across terminal and HTML output so the wording stays in sync. export const UNVERIFIED_PARENT_UPGRADE_NOTE = "⚠ unverified - rescan after upgrading to confirm this resolves the finding"; export type SuggestedFixPackageManager = "npm" | "pnpm" | "yarn" | "bun"; export type SuggestedFixTarget = { package: string; registryName?: string; currentVersion?: string; // Version of `package` after the fix. For parent-update this is the parent's // own version (unchanged): the target refreshes a child through the parent's // existing range rather than upgrading the parent. See childPackage / // childTargetVersion for the child being refreshed. targetVersion: string; // parent-update only, and only when `package` is the parent rather than the // child itself: the finding.pkg being refreshed through the parent's range. childPackage?: string; childTargetVersion?: string; scannedVersions?: number | null; knownVulnerableVersions?: number | null; // The version the advisory itself suggested, before registry validation moved // it. Rendered as its own column so the table can show the adjustment without // a prose note beside it. advisoryVersion?: string | null; kind: "direct" | "parent-upgrade" | "parent-update"; urgent: boolean; severity: SeverityLabel; adjusted: boolean; adjustmentNote?: string | null; // Set from RecommendedParentUpgrade.confidence when this target was built from // finding.recommendedParentUpgrade. Undefined for direct fixes, within-range // parent updates, and validated chain upgrades, which don't carry this distinction. confidence?: "verified" | "unverified"; reason: string; command?: string; workspaces?: string[]; yarnBerry?: boolean; fixVersionPublishedAt?: string | null; cooldownWarning?: CooldownWarning | null; coverage?: "complete" | "partial"; coveredPaths?: string[][]; remainingPaths?: string[][]; usage?: { imported: boolean | null; files: string[] } | null; isDev?: boolean; chainProof?: ChainHop[]; chainSafeVersion?: string; chainVulnerablePackage?: string; }; export type SuggestedFixSkip = { package: string; version: string; relationship: Finding["relationship"]; reason: string; /** * The advisory's fixed-version hint is absent from the registry that was queried. * Carried through from the finding so the terminal output can group these without * matching the reason text. */ advisoryHintUnavailable?: boolean; }; export type SuggestedFixCommandPlan = { packageManager: SuggestedFixPackageManager; sourceLabel: string; command: string | null; sections: Array<{ key: string; kind: "urgent" | "direct" | "direct-adjusted" | "parent-upgrade" | "parent-update" | "validated-chain-upgrade"; severity: SeverityLabel; title: string; command: string; targets: SuggestedFixTarget[]; }>; targets: SuggestedFixTarget[]; skipped: SuggestedFixSkip[]; coveredFindingCount: number; totalFindingCount: number; }; interface FixPlanContext { packageManager: SuggestedFixPackageManager; scanInput: ScanInput; workspaceMap: Map; unresolvedYarnWorkspaces: Map; hasYarnDeclarationData: boolean; targetsByKey: Map; skippedByKey: Map; offline: boolean; } // null rules out an upgrade; [] retains legacy behavior without versioned data. // This lookup has no side effects: callers explicitly record skipped targets. function workspacesFor(context: FixPlanContext, name: string, version: string): string[] | null { if (context.hasYarnDeclarationData) { const resolved = context.scanInput.packages.find(pkg => packageNameKey(pkg) === name && pkg.version === version); const resolvedWorkspaces = new Set(context.scanInput.packages .filter(pkg => packageNameKey(pkg) === name) .flatMap(pkg => pkg.yarnDirectWorkspaces ?? [])); const workspaces = [...new Set([ ...(resolved?.yarnDirectWorkspaces ?? []), ...(context.unresolvedYarnWorkspaces.get(name) ?? []) .filter(workspace => !resolvedWorkspaces.has(workspace)), ])]; return workspaces.length ? workspaces : null; } return (context.workspaceMap.get(name) ?? []).filter(workspace => context.packageManager === "yarn" || workspace !== "."); } function recordUnmatchedDeclaration(context: FixPlanContext, finding: Finding, name: string, version: string): void { context.skippedByKey.set(`${finding.relationship}:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: `No direct Yarn declaration resolves to ${name}@${version}. Inspect the declarations and dependency paths before updating, then rescan.`, }); } function addDirectTarget(context: FixPlanContext, finding: Finding, urgent: boolean): boolean { const { offline, targetsByKey, skippedByKey } = context; const hasValidatedField = Object.prototype.hasOwnProperty.call(finding, "validatedFirstFixedVersion"); const directTarget = finding.validatedFirstFixedVersion ?? finding.firstFixedVersion; // In offline mode validateDirectFixTargets never runs, so an unset // validatedFirstFixedVersion is "did not validate" rather than "validation // failed" - fall back to the advisory hint instead of dropping the target. const validatedFieldOk = offline || !hasValidatedField || finding.validatedFirstFixedVersion !== null; // A confirmed fix version is a direct answer to "is there a safe upgrade", // where the counter comparison below is only an inference about one. When // the two disagree the confirmed version wins, because reporting a dead // end in the same scan that names the fix is the worse failure. This also // stops the rule depending on two counters that separate passes maintain // (#1213). const hasConfirmedFix = finding.validatedFirstFixedVersion != null; const allVersionsVulnerable = !hasConfirmedFix && finding.validatedTargetScannedVersions != null && finding.validatedTargetKnownVulnerableVersions != null && finding.validatedTargetScannedVersions > 0 && finding.validatedTargetKnownVulnerableVersions >= finding.validatedTargetScannedVersions; if (directTarget && allVersionsVulnerable) { skippedByKey.set(`direct:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: `No safe upgrade: all ${finding.validatedTargetScannedVersions} scanned ${pluralize(finding.validatedTargetScannedVersions ?? 0, "version")} ${pluralize(finding.validatedTargetScannedVersions ?? 0, "is", "are")} still known-vulnerable. This package may be unmaintained.`, }); return false; } if ( directTarget && isUpgradeTarget(finding.pkg.version, directTarget) && validatedFieldOk ) { const pkgWorkspaces = workspacesFor(context, packageNameKey(finding.pkg), finding.pkg.version); if (pkgWorkspaces === null) { recordUnmatchedDeclaration(context, finding, packageNameKey(finding.pkg), finding.pkg.version); return false; } upsertTarget(targetsByKey, finding, { package: finding.pkg.name, ...(finding.pkg.registryName ? { registryName: finding.pkg.registryName } : {}), currentVersion: finding.pkg.version, targetVersion: directTarget, scannedVersions: finding.validatedTargetScannedVersions ?? null, knownVulnerableVersions: finding.validatedTargetKnownVulnerableVersions ?? null, advisoryVersion: finding.firstFixedVersion ?? null, kind: "direct", urgent, severity: finding.severity, adjusted: Boolean( finding.fixVersionValidationNote && finding.validatedFirstFixedVersion && finding.validatedFirstFixedVersion !== finding.firstFixedVersion, ), adjustmentNote: finding.fixVersionValidationNote && finding.validatedFirstFixedVersion && finding.validatedFirstFixedVersion !== finding.firstFixedVersion ? finding.fixVersionValidationNote : null, reason: `Direct upgrade target for ${finding.pkg.name}@${finding.pkg.version}`, usage: finding.usage ?? null, workspaces: pkgWorkspaces.length > 0 ? pkgWorkspaces : undefined, isDev: finding.pkg.dev ?? false, fixVersionPublishedAt: finding.fixVersionPublishedAt ?? null, }); return true; } else if (finding.fixVersionValidationNote) { skippedByKey.set(`${finding.relationship}:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: finding.fixVersionValidationNote, advisoryHintUnavailable: finding.advisoryHintUnavailable ?? false, }); } else if (finding.firstFixedVersion) { skippedByKey.set(`${finding.relationship}:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: `Fixed-version hint ${directTarget} is not an upgrade from installed ${finding.pkg.version}.`, }); } else { skippedByKey.set(`${finding.relationship}:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: urgent ? "No safe upgrade target is known for this urgent direct dependency." : "No safe upgrade target is known for this direct dependency.", }); } return false; } export function buildSuggestedFixCommandPlan( findings: Finding[], scanInput: ScanInput, options?: { offline?: boolean; subfolder?: string; nowMs?: number }, ): SuggestedFixCommandPlan | null { const packageManager = packageManagerForSource(scanInput.source); if (!packageManager) return null; const offline = !!options?.offline; const yarnBerry = packageManager === "yarn" && !!scanInput.filePath && isYarnBerry(safeReadText(scanInput.filePath) ?? ""); let unresolvedYarnWorkspaces = new Map(); let workspaceMap: Map = new Map(); if (scanInput.filePath) { try { if (packageManager === "npm" && isNpmLockSource(scanInput.source)) { workspaceMap = buildNpmWorkspaceMap(scanInput.filePath, { includeAliasIdentities: true }); } else if (packageManager === "pnpm" && scanInput.source === "pnpm-lock") { workspaceMap = buildPnpmWorkspaceMap(scanInput.filePath); } else if (packageManager === "yarn" && scanInput.source === "yarn-lock") { workspaceMap = buildYarnWorkspaceMap(scanInput.filePath); unresolvedYarnWorkspaces = buildYarnWorkspaceMap(scanInput.filePath, { unresolvedRangesOnly: true }); } else if (packageManager === "bun" && scanInput.source === "bun-lock") { workspaceMap = buildBunWorkspaceMap(scanInput.filePath); } } catch { // workspace map is best-effort } } const hasYarnDeclarationData = packageManager === "yarn" && scanInput.packages.some(pkg => pkg.yarnDirectWorkspaces !== undefined); const devLookup = new Map(); for (const pkg of scanInput.packages) { devLookup.set(`${pkg.name}@${pkg.version}`, pkg.dev ?? false); } const prioritizedFindings = [...findings] .filter(f => f.severity === "critical" || f.severity === "high") .sort((a, b) => { const sevDelta = severityOrder[b.severity] - severityOrder[a.severity]; if (sevDelta !== 0) return sevDelta; const usageScore = (finding: Finding) => finding.usage?.imported ? 1 : 0; const usageDelta = usageScore(b) - usageScore(a); if (usageDelta !== 0) return usageDelta; const relScore = (finding: Finding) => finding.relationship === "direct" ? 1 : 0; const relDelta = relScore(b) - relScore(a); if (relDelta !== 0) return relDelta; return a.pkg.name.localeCompare(b.pkg.name); }); const unresolvedDirectFindings = new Set(); const targetsByKey = new Map(); const skippedByKey = new Map(); const orderedFindings = [ ...prioritizedFindings, ...findings .filter(f => f.severity !== "critical" && f.severity !== "high") .sort((a, b) => { const sevDelta = severityOrder[b.severity] - severityOrder[a.severity]; if (sevDelta !== 0) return sevDelta; const usageScore = (finding: Finding) => finding.usage?.imported ? 1 : 0; const usageDelta = usageScore(b) - usageScore(a); if (usageDelta !== 0) return usageDelta; const relScore = (finding: Finding) => finding.relationship === "direct" ? 1 : 0; const relDelta = relScore(b) - relScore(a); if (relDelta !== 0) return relDelta; return a.pkg.name.localeCompare(b.pkg.name); }), ]; const context: FixPlanContext = { packageManager, scanInput, workspaceMap, unresolvedYarnWorkspaces, hasYarnDeclarationData, targetsByKey, skippedByKey, offline, }; for (const finding of orderedFindings) { const urgent = finding.severity === "critical" || finding.severity === "high"; if (finding.relationship === "direct") { addDirectTarget(context, finding, urgent); continue; } if (packageManager === "yarn" && finding.relationship === "transitive" && finding.pkg.yarnDirectWorkspaces?.length && finding.pkg.yarnTransitivePaths?.length) { // Keep the direct upgrade alongside parent remediation, using the same // validated version and resolved declarations as an ordinary direct finding. if (!addDirectTarget(context, { ...finding, relationship: "direct" }, urgent)) { unresolvedDirectFindings.add(finding); } const skipped = skippedByKey.get(`direct:${packageInstanceKey(finding.pkg)}`); if (skipped) { const declarations = finding.pkg.yarnDirectWorkspaces.map(workspace => workspace === "." ? "the root workspace" : workspace); skipped.reason = `Also declared directly in ${declarations.join(", ")}. ${skipped.reason}`; } } if (finding.relationship === "unknown") { skippedByKey.set(`unknown:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: `Dependency path for ${finding.pkg.name}@${finding.pkg.version} could not be resolved in the lockfile. Inspect your lockfile to identify which package pulls it in.`, }); continue; } if ( packageManager === "npm" && finding.relationship === "transitive" && finding.recommendedNpmTransitiveRemediation?.kind === "update-parent-within-range" ) { upsertTarget(targetsByKey, finding, parentUpdateTarget(finding, urgent, { package: finding.recommendedNpmTransitiveRemediation.package, currentVersion: finding.recommendedNpmTransitiveRemediation.currentVersion, childTargetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion, reason: finding.recommendedNpmTransitiveRemediation.reason, // Name the package that actually moves. This remediation means the parent // already allows a safe child version, so the parent stays where it is and // the child is what has to be refreshed. Naming the parent achieves nothing // when it is already at its newest allowed version, because npm has no // reason to re-resolve its subtree. command: buildNpmUpdateCommand(finding.pkg.name, finding.recommendedNpmTransitiveRemediation.workspaces), isDev: devLookup.get(`${finding.recommendedNpmTransitiveRemediation.package}@${finding.recommendedNpmTransitiveRemediation.currentVersion}`) ?? false, workspaces: finding.recommendedNpmTransitiveRemediation.workspaces, })); continue; } // A parent upgrade that a direct dependency would undo is not a fix: that // dependency keeps the vulnerable copy installed (#1457). if (finding.relationship === "transitive" && finding.recommendedParentUpgrade && finding.parentUpgradeBlockers?.length) { skippedByKey.set(`${finding.relationship}:${finding.pkg.name}@${finding.pkg.version}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: formatParentUpgradeBlockedReason(finding), }); continue; } if ( packageManager === "npm" && finding.relationship === "transitive" && finding.recommendedNpmTransitiveRemediation?.kind === "upgrade-parent-to-version" && finding.recommendedNpmTransitiveRemediation.targetVersion && isUpgradeTarget( finding.recommendedNpmTransitiveRemediation.currentVersion, finding.recommendedNpmTransitiveRemediation.targetVersion, ) ) { const coverage = calculatePathCoverage( finding.dependencyPaths, finding.recommendedNpmTransitiveRemediation.viaPath, ); const chainForNpmRemediation = finding.chainResolution?.directDep === finding.recommendedNpmTransitiveRemediation.package ? finding.chainResolution : null; const npmParentUpgradeWorkspaces = workspaceMap.get(finding.recommendedNpmTransitiveRemediation.package)?.filter(w => w !== ".") ?? []; upsertTarget(targetsByKey, finding, { package: finding.recommendedNpmTransitiveRemediation.package, currentVersion: finding.recommendedNpmTransitiveRemediation.currentVersion, targetVersion: finding.recommendedNpmTransitiveRemediation.targetVersion, scannedVersions: null, knownVulnerableVersions: null, kind: "parent-upgrade", urgent, severity: finding.severity, adjusted: false, adjustmentNote: null, reason: buildParentUpgradeReason(finding, coverage), coverage: coverage.coverage, coveredPaths: coverage.coveredPaths, remainingPaths: coverage.remainingPaths, usage: finding.usage ?? null, isDev: devLookup.get(`${finding.recommendedNpmTransitiveRemediation.package}@${finding.recommendedNpmTransitiveRemediation.currentVersion}`) ?? false, workspaces: npmParentUpgradeWorkspaces.length > 0 ? npmParentUpgradeWorkspaces : undefined, chainProof: chainForNpmRemediation?.chain, chainSafeVersion: chainForNpmRemediation?.safeVersion, chainVulnerablePackage: chainForNpmRemediation ? finding.pkg.name : undefined, }); continue; } if ( finding.relationship === "transitive" && finding.recommendedParentUpgrade && isUpgradeTarget( finding.recommendedParentUpgrade.currentVersion, finding.recommendedParentUpgrade.targetVersion, ) ) { const coverage = calculatePathCoverage( finding.dependencyPaths, finding.recommendedParentUpgrade.viaPath, ); const chainForParentUpgrade = finding.chainResolution?.directDep === finding.recommendedParentUpgrade.package ? finding.chainResolution : null; const parentUpgradeWorkspaces = workspacesFor(context, finding.recommendedParentUpgrade.package, finding.recommendedParentUpgrade.currentVersion); if (parentUpgradeWorkspaces === null) { recordUnmatchedDeclaration(context, finding, finding.recommendedParentUpgrade.package, finding.recommendedParentUpgrade.currentVersion); continue; } upsertTarget(targetsByKey, finding, { package: finding.recommendedParentUpgrade.package, currentVersion: finding.recommendedParentUpgrade.currentVersion, targetVersion: finding.recommendedParentUpgrade.targetVersion, scannedVersions: null, knownVulnerableVersions: null, kind: "parent-upgrade", urgent, severity: finding.severity, adjusted: false, adjustmentNote: null, reason: buildParentUpgradeReason(finding, coverage), coverage: coverage.coverage, coveredPaths: coverage.coveredPaths, remainingPaths: coverage.remainingPaths, usage: finding.usage ?? null, isDev: devLookup.get(`${finding.recommendedParentUpgrade.package}@${finding.recommendedParentUpgrade.currentVersion}`) ?? false, workspaces: parentUpgradeWorkspaces.length > 0 ? parentUpgradeWorkspaces : undefined, chainProof: chainForParentUpgrade?.chain, chainSafeVersion: chainForParentUpgrade?.safeVersion, chainVulnerablePackage: chainForParentUpgrade ? finding.pkg.name : undefined, confidence: finding.recommendedParentUpgrade.confidence, }); continue; } if (finding.relationship === "transitive" && finding.recommendedParentUpgrade) { skippedByKey.set(`${finding.relationship}:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: `Suggested parent target ${finding.recommendedParentUpgrade.package}@${finding.recommendedParentUpgrade.targetVersion} is not an upgrade from installed ${finding.recommendedParentUpgrade.currentVersion}.`, }); continue; } if ( packageManager !== "npm" && finding.relationship === "transitive" && finding.recommendedNpmTransitiveRemediation?.kind === "update-parent-within-range" ) { const lockfileRefreshCommand = packageManager === "pnpm" ? buildPnpmLockfileRefreshCommand(finding.pkg.name, finding.recommendedNpmTransitiveRemediation.workspaces) : packageManager === "yarn" ? buildYarnUpgradeCommand(finding.pkg.name, finding.recommendedNpmTransitiveRemediation.workspaces) : buildBunUpdateCommand(finding.pkg.name); upsertTarget(targetsByKey, finding, parentUpdateTarget(finding, urgent, { package: finding.pkg.name, currentVersion: finding.pkg.version, childTargetVersion: finding.recommendedNpmTransitiveRemediation.targetChildVersion, reason: `${finding.pkg.name}@${finding.pkg.version} can be refreshed to ${finding.recommendedNpmTransitiveRemediation.targetChildVersion}+; no parent upgrade needed.`, command: lockfileRefreshCommand, workspaces: finding.recommendedNpmTransitiveRemediation?.workspaces, })); continue; } const primaryParent = getPrimaryParent(finding); if (finding.relationship === "transitive" && primaryParent) { if (finding.chainResolution) { const isDev = devLookup.get(`${finding.chainResolution.directDep}@${finding.chainResolution.directDepCurrentVersion}`) ?? false; const coverage = calculatePathCoverage(finding.dependencyPaths, [finding.chainResolution.directDep]); const chainWorkspaces = workspacesFor(context, finding.chainResolution.directDep, finding.chainResolution.directDepCurrentVersion); if (chainWorkspaces === null) { recordUnmatchedDeclaration(context, finding, finding.chainResolution.directDep, finding.chainResolution.directDepCurrentVersion); continue; } upsertTarget(targetsByKey, finding, { package: finding.chainResolution.directDep, currentVersion: finding.chainResolution.directDepCurrentVersion, targetVersion: finding.chainResolution.targetVersion, kind: "parent-upgrade", urgent, severity: finding.severity, adjusted: false, adjustmentNote: null, reason: buildParentUpgradeReason(finding, coverage), command: finding.chainResolution.command, chainProof: finding.chainResolution.chain, chainSafeVersion: finding.chainResolution.safeVersion, chainVulnerablePackage: finding.pkg.name, usage: finding.usage ?? null, isDev, workspaces: chainWorkspaces.length > 0 ? chainWorkspaces : undefined, }); continue; } const fixHint = finding.validatedFirstFixedVersion ?? finding.firstFixedVersion; const fixClause = fixHint ? ` - check for a release that resolves ${finding.pkg.name} to ${fixHint}+` : ""; skippedByKey.set(`${finding.relationship}:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: `${finding.pkg.name}@${finding.pkg.version} is pulled in by ${primaryParent}. No safe upgrade version for ${primaryParent} was identified automatically${fixClause}.`, }); continue; } skippedByKey.set(`${finding.relationship}:${packageInstanceKey(finding.pkg)}`, { package: finding.pkg.name, version: finding.pkg.version, relationship: finding.relationship, reason: finding.relationship === "transitive" ? `No dependency path available for ${finding.pkg.name}@${finding.pkg.version}. Inspect your lockfile to find which package pulls it in.` : "No confident automatic fix command could be generated for this issue.", }); } const targets = [...targetsByKey.values()].sort((a, b) => { const sevDelta = severityOrder[b.severity] - severityOrder[a.severity]; if (sevDelta !== 0) return sevDelta; const urgentDelta = Number(b.urgent) - Number(a.urgent); if (urgentDelta !== 0) return urgentDelta; const kindScore = (target: SuggestedFixTarget) => target.kind === "direct" ? 1 : 0; const kindDelta = kindScore(b) - kindScore(a); if (kindDelta !== 0) return kindDelta; return a.package.localeCompare(b.package); }); // Some install targets carry an explicit `command` built without workspace // context (e.g. a chain-resolution command). This raw field is exposed in JSON // and embedded in the HTML report, so scope it to the declaring member too. // Parent-update commands are rebuilt from the (possibly merged) workspace list // so the printed plan matches what --fix will spawn. for (const target of targets) { if (target.kind === "parent-update") { const updatePkg = target.childPackage ?? target.package; if (packageManager === "npm") { target.command = buildNpmUpdateCommand(updatePkg, target.workspaces); } else if (packageManager === "pnpm") { target.command = buildPnpmLockfileRefreshCommand(updatePkg, target.workspaces); } else if (packageManager === "yarn") { target.command = buildYarnUpgradeCommand(updatePkg, target.workspaces); } else if (packageManager === "bun") { target.command = buildBunUpdateCommand(updatePkg); } continue; } if (packageManager === "yarn") target.yarnBerry = yarnBerry; if (target.command && target.workspaces?.length) { const scoped = buildWorkspaceInstallCommands([target], packageManager); if (scoped.length > 0) target.command = scoped.join(" && "); } } const nowMs = options?.nowMs ?? Date.now(); if (scanInput.filePath && (packageManager === "npm" || packageManager === "pnpm" || packageManager === "yarn")) { const cooldown = readConfiguredCooldown(path.dirname(scanInput.filePath), packageManager); if (cooldown) { for (const target of targets) { target.cooldownWarning = cooldownWarningFor(target.fixVersionPublishedAt, cooldown, nowMs); } } } const skipped = [...skippedByKey.values()].sort((a, b) => a.package.localeCompare(b.package)); const sections = buildSections(targets, packageManager); const command = targets.length > 0 ? buildCommandForTargets(targets, packageManager) : null; const plan: SuggestedFixCommandPlan = { packageManager, sourceLabel: packageManagerSourceLabel(scanInput), command, sections, targets, skipped, coveredFindingCount: 0, totalFindingCount: findings.length, }; // A finding only counts as covered when it has a matching fix target AND that // target isn't an unverified parent upgrade - an unverified recommendation may // not actually resolve the finding, so it must not inflate the "fix all" claim. plan.coveredFindingCount = findings.filter(f => { if (unresolvedDirectFindings.has(f)) return false; const target = findFixTargetForFinding(plan, f); if (!target) return false; // Coverage is a per-finding property, not a per-target one: two unrelated // findings can merge into the same target (same package upgrade), so a // finding's own recommendedParentUpgrade confidence - not the blended // target confidence - decides whether it is genuinely covered. if (f.recommendedParentUpgrade) return isVerifiedUpgrade(f.recommendedParentUpgrade.confidence); return true; }).length; if (options?.subfolder) { const prefix = `cd ${quoteShellPath(options.subfolder)} && `; if (plan.command) plan.command = prefix + plan.command; for (const section of plan.sections) { section.command = prefix + section.command; } } return plan; } function buildNpmUpdateCommand(pkg: string, workspaces?: string[]): string { const nonRoot = (workspaces ?? []).filter(w => w !== "."); const pkgQ = shellQuote(pkg); if (nonRoot.length === 1) { return `npm update --workspace=${shellQuote(nonRoot[0])} ${pkgQ}`; } if (nonRoot.length > 1) { return `npm update --workspaces ${pkgQ}`; } return `npm update ${pkgQ}`; } function buildPnpmLockfileRefreshCommand(pkg: string, workspaces?: string[]): string { const pkgQ = shellQuote(pkg); if (!workspaces || workspaces.length === 0 || workspaces.length > 1) { return `pnpm update --recursive --no-save ${pkgQ}`; } const workspace = workspaces[0]; if (workspace === ".") { return `pnpm update --no-save ${pkgQ}`; } return `pnpm --filter ${shellQuote("./" + workspace)} update --no-save ${pkgQ}`; } function buildYarnUpgradeCommand(pkg: string, workspaces?: string[]): string { const pkgQ = shellQuote(pkg); const nonRoot = (workspaces ?? []).filter(w => w !== "."); if (nonRoot.length === 0) return `yarn upgrade ${pkgQ}`; return nonRoot.map(ws => `yarn workspace ${shellQuote(ws)} upgrade ${pkgQ}`).join(" && "); } function buildBunUpdateCommand(pkg: string): string { return `bun update ${shellQuote(pkg)}`; } export function commandPrefix(packageManager: SuggestedFixPackageManager): string { if (packageManager === "npm") return "npm install"; if (packageManager === "pnpm") return "pnpm add"; if (packageManager === "bun") return "bun add"; return "yarn add"; } function devFlag(packageManager: SuggestedFixPackageManager): string { return packageManager === "bun" ? "--dev" : "-D"; } export function findFixTargetForFinding( plan: SuggestedFixCommandPlan, finding: Finding, ): SuggestedFixTarget | null { // A sibling finding may share the parent upgrade, but a direct dependency // keeps this copy installed, so no command in the plan fixes this one. if (finding.recommendedParentUpgrade && finding.parentUpgradeBlockers?.length) return null; const target = plan.targets.find(item => { if (finding.recommendedNpmTransitiveRemediation?.kind === "update-parent-within-range") { const rem = finding.recommendedNpmTransitiveRemediation; if (item.kind !== "parent-update") return false; if (item.package !== rem.package && item.package !== finding.pkg.name) return false; // Parent-named parent-update: match only on child identity. Falling // through to targetVersion compares the parent's version to the child's // destination and can attach the wrong sibling. if (item.childPackage) { return item.childPackage === finding.pkg.name && item.childTargetVersion === rem.targetChildVersion; } // Self-refresh / lockfile refresh: destination stays on targetVersion. return item.targetVersion === rem.targetChildVersion; } if (finding.recommendedParentUpgrade) { if (item.package !== finding.recommendedParentUpgrade.package) return false; if (item.targetVersion === finding.recommendedParentUpgrade.targetVersion) return true; // A command targeting a higher version also satisfies a lower-version recommendation return ( looksLikeVersion(item.targetVersion) && looksLikeVersion(finding.recommendedParentUpgrade.targetVersion) && compareVersions(item.targetVersion, finding.recommendedParentUpgrade.targetVersion) >= 0 ); } if (finding.chainResolution) { if (item.package !== finding.chainResolution.directDep) return false; if (item.targetVersion === finding.chainResolution.targetVersion) return true; // Mirrors the recommendedParentUpgrade branch above. When several children are // cleared by the same parent, upsertTarget keeps the highest parent version, so a // child whose chain was verified at a lower version failed an exact match and was // left with no command, even though the command already in the plan covers it. return ( looksLikeVersion(item.targetVersion) && looksLikeVersion(finding.chainResolution.targetVersion) && compareVersions(item.targetVersion, finding.chainResolution.targetVersion) >= 0 ); } const directTarget = finding.validatedFirstFixedVersion ?? finding.firstFixedVersion; if (item.package !== finding.pkg.name) return false; if (registryPackageName({ name: item.package, registryName: item.registryName }) !== registryPackageName(finding.pkg)) { return false; } // A transitive finding is only fixed through a remediation, and each kind of // remediation has its own branch above. Falling through to here for one means // nothing was resolved for it, so matching it against a direct install of the same // package name would claim that installing at the root moves a copy nested under a // parent. npm does not guarantee that. A finding with no verified fix belongs in the // skipped list with a reason, not holding somebody else's command. if (finding.relationship !== "direct") return false; if (item.targetVersion === directTarget) return true; // Mirrors the recommendedParentUpgrade branch above: a command targeting a // higher version also satisfies a lower-version fix. This is the merged case, // where a package is both a direct finding and the parent of other findings, // so the plan installs a higher version than this finding alone requires. // Targets are unique per package name, so this cannot select a different package. return ( !!directTarget && looksLikeVersion(item.targetVersion) && looksLikeVersion(directTarget) && compareVersions(item.targetVersion, directTarget) >= 0 ); }); return target ?? null; } // The single command that applies (or refreshes) one fix target, workspace-scoped // where the target declares a member. Shared by the displayed per-finding command // and the --fix executor so both stay consistent. export function buildTargetCommand( target: SuggestedFixTarget, packageManager: SuggestedFixPackageManager, ): string { // parent-update targets carry an explicit command that is already // workspace-scoped (npm update --workspace / pnpm --filter ... update). if (target.kind === "parent-update" && target.command) return target.command; // For install targets (direct / parent-upgrade), a workspace membership must // win over any flat explicit command (e.g. a chain-resolution command built // without workspace context), mirroring the plan-level command builder. if (target.workspaces?.length) { const commands = buildWorkspaceInstallCommands([target], packageManager); if (commands.length > 0) return commands.join(" && "); } if (target.command) return target.command; const flag = target.isDev ? ` ${devFlag(packageManager)}` : ""; return `${commandPrefix(packageManager)}${flag} ${shellQuote(installPackageSpec(target))}`; } export function findSuggestedCommandForFinding( plan: SuggestedFixCommandPlan, finding: Finding, ): string | null { const target = findFixTargetForFinding(plan, finding); if (!target) return null; return buildTargetCommand(target, plan.packageManager); } function packageManagerSourceLabel(scanInput: ScanInput): string { if (scanInput.filePath) { return scanInput.filePath.split(/[/\\]/).pop() ?? scanInput.source; } if (scanInput.source === "package-lock") return "package-lock.json"; if (scanInput.source === "pnpm-lock") return "pnpm-lock.yaml"; if (scanInput.source === "yarn-lock") return "yarn.lock"; if (scanInput.source === "bun-lock") return "bun.lock"; return scanInput.source; } // Install targets (direct / parent-upgrade) all mean "install @", // so findings that land on the same package merge into one target and the higher // version wins. A parent-update target means something else: "refresh within 's declared range". Its targetVersion is 's own // version (unchanged); the child's identity lives on childPackage / childTargetVersion // when package is the parent. Merging it with an install target for the same package // used to compare versions of two different packages, and the child's version could // end up pinned onto the parent (`npm install axios@4.0.6`, where 4.0.6 is form-data's // version - #1007). Keep parent-update targets in their own keyspace, per child, so // they only ever merge with a refresh of the same child through the same package. // The key carries the vulnerable package's INSTALLED version as well as its name. A tree // routinely holds two majors of one package, and keying on the name alone merged them into // a single target: the surviving row recorded one version pair, and the other finding was // left with no target and no skipped entry, so it disappeared from the plan while having a // known fix. The duplicate commands that now result collapse again downstream, because the // emitted command list is de-duplicated. function targetKey(finding: Finding, target: SuggestedFixTarget): string { return target.kind === "parent-update" ? `parent-update:${target.package}>${finding.pkg.name}@${finding.pkg.version}` : `install:${packageNameKey({ name: target.package, registryName: target.registryName })}`; } // Build a parent-update target whose targetVersion always belongs to `package`. // When the named package is the parent of the finding, the child's version moves // onto childPackage / childTargetVersion so renderers cannot treat it as an // upgrade of the parent (#1152). When the named package is the child itself // (deep-chain npm update, pnpm/yarn/bun lockfile refresh), targetVersion stays // the child's destination. function parentUpdateTarget( finding: Finding, urgent: boolean, args: { package: string; currentVersion: string; childTargetVersion: string; reason: string; command: string; isDev?: boolean; workspaces?: string[]; }, ): SuggestedFixTarget { const namesParent = args.package !== finding.pkg.name; // Keep "." in the list: pnpm treats a sole root workspace as a plain refresh // (no --filter / no --recursive). Stripping it here collapsed that case into // the empty-workspaces branch and emitted --recursive. const workspaces = args.workspaces ?? []; return { package: args.package, currentVersion: args.currentVersion, targetVersion: namesParent ? args.currentVersion : args.childTargetVersion, childPackage: namesParent ? finding.pkg.name : undefined, childTargetVersion: namesParent ? args.childTargetVersion : undefined, scannedVersions: null, knownVulnerableVersions: null, kind: "parent-update", urgent, severity: finding.severity, adjusted: false, adjustmentNote: null, reason: args.reason, command: args.command, usage: finding.usage ?? null, isDev: args.isDev, workspaces: workspaces.length > 0 ? workspaces : undefined, }; } /** * The version of finding.pkg that the plan is aiming for. * A parent-update that names a different package stores the child's destination * on childTargetVersion, not on targetVersion (which is the parent's). */ export function planVersionForFinding( plan: SuggestedFixCommandPlan, finding: Finding, ): string | undefined { const target = findFixTargetForFinding(plan, finding); if (!target) return undefined; if (target.childPackage === finding.pkg.name && target.childTargetVersion) { return target.childTargetVersion; } return target.targetVersion; } function upsertTarget( targetsByKey: Map, finding: Finding, next: SuggestedFixTarget, ): void { const key = targetKey(finding, next); const existing = targetsByKey.get(key); if (!existing) { targetsByKey.set(key, next); return; } const merged: SuggestedFixTarget = { ...existing, urgent: existing.urgent || next.urgent, severity: severityOrder[next.severity] > severityOrder[existing.severity] ? next.severity : existing.severity, adjusted: existing.adjusted || next.adjusted, adjustmentNote: existing.adjustmentNote ?? next.adjustmentNote ?? null, kind: existing.kind === "direct" || next.kind === "direct" ? "direct" : existing.kind === "parent-update" || next.kind === "parent-update" ? "parent-update" : "parent-upgrade", command: existing.command ?? next.command, isDev: (existing.isDev ?? false) && (next.isDev ?? false), workspaces: mergeStringArrays(existing.workspaces, next.workspaces), coverage: existing.coverage === "partial" || next.coverage === "partial" ? "partial" : existing.coverage ?? next.coverage, coveredPaths: mergePathArrays(existing.coveredPaths ?? [], next.coveredPaths ?? []), remainingPaths: mergePathArrays(existing.remainingPaths ?? [], next.remainingPaths ?? []), usage: existing.usage || next.usage, chainProof: existing.chainProof ?? next.chainProof, chainSafeVersion: existing.chainSafeVersion ?? next.chainSafeVersion, chainVulnerablePackage: existing.chainVulnerablePackage ?? next.chainVulnerablePackage, childPackage: existing.childPackage ?? next.childPackage, childTargetVersion: higherVersion(existing.childTargetVersion, next.childTargetVersion) ?? existing.childTargetVersion ?? next.childTargetVersion, // Conservative: if either contributing finding's recommendation is unverified, // keep the merged target unverified rather than overstate coverage. confidence: existing.confidence === "unverified" || next.confidence === "unverified" ? "unverified" : existing.confidence ?? next.confidence, }; if (looksLikeVersion(existing.targetVersion) && looksLikeVersion(next.targetVersion)) { const comparison = compareVersions(next.targetVersion, existing.targetVersion); if (comparison > 0) { merged.targetVersion = next.targetVersion; merged.currentVersion = next.currentVersion ?? merged.currentVersion; // An explicit command is built for one specific version (a chain-resolution // command, say). Once the merge raises the target, a command built for the // lower version would hand the user a version the scan already found still // vulnerable, so it must not survive. Clearing it falls through to buildTargetCommand // deriving the command from targetVersion. merged.command = next.command ?? undefined; // The adjustment note narrates one specific version ("using lowest known // non-vulnerable version 4.20.0"). It belongs to whichever target won the // comparison, so inheriting the loser's leaves a note contradicting the // Target column it is printed beside. merged.adjustmentNote = next.adjustmentNote ?? null; merged.advisoryVersion = next.advisoryVersion ?? existing.advisoryVersion ?? null; // Keep the reason from the higher-severity finding so the most critical issue is visible // in the context column. Only update the reason if next has strictly higher severity. if (severityOrder[next.severity] > severityOrder[existing.severity]) { merged.reason = next.reason; } merged.scannedVersions = next.scannedVersions ?? merged.scannedVersions ?? null; merged.knownVulnerableVersions = next.knownVulnerableVersions ?? merged.knownVulnerableVersions ?? null; merged.fixVersionPublishedAt = next.fixVersionPublishedAt ?? merged.fixVersionPublishedAt ?? null; } else if (comparison < 0) { // Mirror case: the existing target wins, so a command or note built for the // lower incoming version is equally stale and must not be inherited. merged.command = existing.command ?? undefined; merged.adjustmentNote = existing.adjustmentNote ?? null; merged.advisoryVersion = existing.advisoryVersion ?? next.advisoryVersion ?? null; } targetsByKey.set(key, merged); return; } if (next.kind === "direct" && existing.kind !== "direct") { merged.currentVersion = next.currentVersion ?? merged.currentVersion; merged.targetVersion = next.targetVersion; merged.reason = next.reason; merged.adjustmentNote = next.adjustmentNote ?? merged.adjustmentNote; merged.scannedVersions = next.scannedVersions ?? merged.scannedVersions ?? null; merged.knownVulnerableVersions = next.knownVulnerableVersions ?? merged.knownVulnerableVersions ?? null; merged.fixVersionPublishedAt = next.fixVersionPublishedAt ?? merged.fixVersionPublishedAt ?? null; } targetsByKey.set(key, merged); } function buildParentUpgradeReason( finding: Finding, coverage: { coverage: "complete" | "partial"; coveredPaths: string[][]; remainingPaths: string[][] }, ): string { const coveredPath = coverage.coveredPaths[0]; const pathText = coveredPath ? ` for ${formatDependencyPath(coveredPath)}` : ""; const base = coverage.coverage === "complete" ? `Parent upgrade for vulnerable ${finding.pkg.name}@${finding.pkg.version}${pathText}` : coveredPath ? `Path-specific parent upgrade for ${formatDependencyPath(coveredPath)} (${finding.pkg.name}@${finding.pkg.version})` : `Path-specific parent upgrade for vulnerable ${finding.pkg.name}@${finding.pkg.version}`; if (coverage.coverage === "complete") return base; const remainingCount = coverage.remainingPaths.length; return `${base}; run this command, then rescan. ${remainingCount} other known ${pluralize(remainingCount, "path")} may still need separate parent upgrades.`; } function mergeStringArrays(a?: string[], b?: string[]): string[] | undefined { if (!a?.length && !b?.length) return undefined; const result = [...new Set([...(a ?? []), ...(b ?? [])])]; return result.length > 0 ? result : undefined; } function mergePathArrays(left: string[][], right: string[][]): string[][] { const seen = new Set(); const output: string[][] = []; for (const path of [...left, ...right]) { const key = path.join(">"); if (seen.has(key)) continue; seen.add(key); output.push(path); } return output; } function isUpgradeTarget(currentVersion: string, targetVersion: string): boolean { if (!currentVersion || !targetVersion || currentVersion === targetVersion) return false; if (looksLikeVersion(currentVersion) && looksLikeVersion(targetVersion)) { return compareVersions(targetVersion, currentVersion) > 0; } return true; } function higherVersion(a?: string, b?: string): string | undefined { if (!a) return b; if (!b) return a; if (looksLikeVersion(a) && looksLikeVersion(b) && compareVersions(b, a) > 0) return b; return a; } function buildSections( targets: SuggestedFixTarget[], packageManager: SuggestedFixPackageManager, ): SuggestedFixCommandPlan["sections"] { const groups: SuggestedFixCommandPlan["sections"] = []; const sectionOrder: Array<{ kind: "urgent" | "direct" | "direct-adjusted" | "parent-upgrade" | "parent-update" | "validated-chain-upgrade"; severity: SeverityLabel }> = [ { kind: "urgent", severity: "critical" }, { kind: "parent-update", severity: "critical" }, { kind: "urgent", severity: "high" }, { kind: "parent-update", severity: "high" }, { kind: "direct", severity: "medium" }, { kind: "direct", severity: "low" }, { kind: "direct", severity: "unknown" }, { kind: "direct-adjusted", severity: "medium" }, { kind: "direct-adjusted", severity: "low" }, { kind: "direct-adjusted", severity: "unknown" }, { kind: "parent-update", severity: "medium" }, { kind: "parent-update", severity: "low" }, { kind: "parent-update", severity: "unknown" }, { kind: "validated-chain-upgrade", severity: "critical" }, { kind: "validated-chain-upgrade", severity: "high" }, { kind: "validated-chain-upgrade", severity: "medium" }, { kind: "validated-chain-upgrade", severity: "low" }, { kind: "validated-chain-upgrade", severity: "unknown" }, { kind: "parent-upgrade", severity: "medium" }, { kind: "parent-upgrade", severity: "low" }, { kind: "parent-upgrade", severity: "unknown" }, ]; for (const entry of sectionOrder) { const sectionTargets = targets.filter(target => { const targetKind = target.kind === "parent-update" ? "parent-update" : target.urgent ? "urgent" : target.adjusted ? "direct-adjusted" : target.chainProof !== undefined ? "validated-chain-upgrade" : target.kind; return targetKind === entry.kind && target.severity === entry.severity; }); if (sectionTargets.length === 0) continue; groups.push({ key: `${entry.kind}:${entry.severity}`, kind: entry.kind, severity: entry.severity, title: buildSectionTitle(entry.kind, entry.severity, sectionTargets), command: buildCommandForTargets(sectionTargets, packageManager), targets: sectionTargets, }); } return groups; } function buildSectionTitle( kind: "urgent" | "direct" | "direct-adjusted" | "parent-upgrade" | "parent-update" | "validated-chain-upgrade", severity: SeverityLabel, targets: SuggestedFixTarget[] = [], ): string { const severityTitle = severity === "unknown" ? "Unknown severity" : `${capitalize(severity)} severity`; // Every other kind names the table it produces, and the renderer picks the table // from the target kinds. An urgent section can hold either kind, so a fixed // "fix commands" title described a direct-fix table in one project and a // parent-upgrade table in another. Name what the reader will actually see. if (kind === "urgent") { const hasDirect = targets.some(target => target.kind === "direct"); const hasParent = targets.some(target => target.kind === "parent-upgrade" || target.kind === "parent-update"); if (hasDirect && !hasParent) return `${severityTitle} direct fixes`; if (hasParent && !hasDirect) return `${severityTitle} parent upgrades`; return `${severityTitle} fix commands`; } if (kind === "direct") { return `${severityTitle} direct fixes`; } if (kind === "direct-adjusted") { return `${severityTitle} direct fixes (registry-adjusted)`; } if (kind === "parent-update") { return `${severityTitle} parent updates within range`; } if (kind === "validated-chain-upgrade") { return `${severityTitle} validated chain upgrades`; } return `${severityTitle} parent upgrades`; } function groupByWorkspaceKey(targets: SuggestedFixTarget[]): Map { const groups = new Map(); for (const target of targets) { const key = (target.workspaces ?? []).slice().sort().join("\0"); const group = groups.get(key) ?? []; group.push(target); groups.set(key, group); } return groups; } function buildWorkspaceInstallCommands( targets: SuggestedFixTarget[], packageManager: SuggestedFixPackageManager, ): string[] { const groups = groupByWorkspaceKey(targets); const commands: string[] = []; for (const [wsKey, groupTargets] of groups) { const workspaces = wsKey ? wsKey.split("\0") : []; const devTargets = groupTargets.filter(t => t.isDev); const prodTargets = groupTargets.filter(t => !t.isDev); for (const [targets, isDev] of [[prodTargets, false], [devTargets, true]] as const) { if (targets.length === 0) continue; const pkgArgs = targets.map(t => shellQuote(installPackageSpec(t))).join(" "); const flag = isDev ? ` ${devFlag(packageManager)}` : ""; if (packageManager === "npm") { const wsFlags = workspaces.map(ws => `-w ${shellQuote(ws)}`).join(" "); commands.push(`npm install${flag}${wsFlags ? " " + wsFlags : ""} ${pkgArgs}`); } else if (packageManager === "pnpm") { // pnpm's --filter selector is a CLI-level flag that precedes the command // (`pnpm --filter ./client add pkg`), not a trailing argument. const wsFlags = workspaces.map(ws => `--filter ${shellQuote("./" + ws)}`).join(" "); const prefix = wsFlags ? `pnpm ${wsFlags} add` : "pnpm add"; commands.push(`${prefix}${flag} ${pkgArgs}`); } else if (packageManager === "yarn") { if (workspaces.length === 0) { commands.push(`yarn add${flag} ${pkgArgs}`); } else { for (const ws of workspaces) { commands.push(ws === "." ? `yarn add${targets[0]?.yarnBerry ? "" : " -W"}${flag} ${pkgArgs}` : `yarn workspace ${shellQuote(ws)} add${flag} ${pkgArgs}`); } } } else if (packageManager === "bun") { const wsFlags = workspaces.map(ws => `--filter ${shellQuote(ws)}`).join(" "); commands.push(`bun add${flag}${wsFlags ? " " + wsFlags : ""} ${pkgArgs}`); } else { commands.push(`${commandPrefix(packageManager)}${flag} ${pkgArgs}`); } } } return commands; } function buildCommandForTargets( targets: SuggestedFixTarget[], packageManager: SuggestedFixPackageManager, ): string { const explicitCommands = targets .map(target => target.command) .filter((value): value is string => Boolean(value)); const commandParts: string[] = []; const parentUpdateTargets = targets.filter(target => target.kind === "parent-update"); const installTargets = targets.filter(target => target.kind !== "parent-update"); if (parentUpdateTargets.length > 0) { const updateCommands = explicitCommands.length > 0 ? [...new Set(explicitCommands)] : packageManager === "npm" ? [`npm update ${parentUpdateTargets.map(target => shellQuote(target.childPackage ?? target.package)).join(" ")}`] : []; commandParts.push(...updateCommands); } if (installTargets.length > 0) { const hasWorkspaces = installTargets.some(t => t.workspaces?.length); if (hasWorkspaces) { commandParts.push(...buildWorkspaceInstallCommands(installTargets, packageManager)); } else { const prodInstall = installTargets.filter(t => !t.isDev); const devInstall = installTargets.filter(t => t.isDev); if (prodInstall.length > 0) { commandParts.push(`${commandPrefix(packageManager)} ${prodInstall.map(t => shellQuote(installPackageSpec(t))).join(" ")}`); } if (devInstall.length > 0) { commandParts.push(`${commandPrefix(packageManager)} ${devFlag(packageManager)} ${devInstall.map(t => shellQuote(installPackageSpec(t))).join(" ")}`); } } } return commandParts.join(" && "); } function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } // Shared by the plan and the per-finding recommendation, so both say the same thing. export function formatParentUpgradeBlockedReason(finding: Finding): string { const upgrade = finding.recommendedParentUpgrade; const blockers = (finding.parentUpgradeBlockers ?? []) .map(blocker => `${blocker.package}@${blocker.version} requires ${blocker.range}`) .join(", "); const parent = upgrade ? `${upgrade.package} to ${upgrade.targetVersion}` : "the parent"; return `Upgrading ${parent} alone leaves ${finding.pkg.name}@${finding.pkg.version} installed: ${blockers}. Those dependencies need upgrading too.`; }