npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dep-up-surgeon

v4.3.0

Published

Upgrade npm dependencies one-by-one with validation, rollback, and conflict reporting.

Readme

dep-up-surgeon

CI npm version npm downloads npm license npm unpacked size Node.js engines TypeScript GitHub stars GitHub forks GitHub issues GitHub pull requests GitHub contributors Last commit Commit activity Libraries.io release Libraries.io dependents Website

Website: https://dep-up-surgeon.netlify.app/

Production-oriented CLI that upgrades npm dependencies with npm install + validation after each change, and rolls back on failure. It is framework-agnostic: grouping and conflict handling come from registry metadata and parsed npm output, not hardcoded stacks (React, Angular, etc.).

Install

npm install -g dep-up-surgeon

Or run locally after cloning:

npm install
npm run build
npx dep-up-surgeon --help

What's new in 4.2

A correctness and safety release from a full review of the tool. What you may notice:

  • Policy caps hold everywhere. maxVersion and allowMajorAfter now also bind linked-group members, coordinated fallbacks, the peer-range resolver and --dry-run plans — before, a group or a fallback could land a version the policy blocks.
  • Transitive CVEs are fixed more gently. With npm, --apply-overrides first tries npm update <name> and writes no pin when the refreshed lockfile is clean (method: "lockfile-update"). Every pin is installed and validated; a failed one gets package.json, pnpm-workspace.yaml and the lockfiles back byte-for-byte. pnpm 10+ pins go to pnpm-workspace.yaml when the project keeps its overrides there. --security-only also lists advisories on transitive packages, with a fix hint, under transitiveAdvisories.
  • The branch matches the report. With --git-commit, override pins and the --fix-lockfile dedupe land in one extra commit. --open-pr refuses to push when --git-branch is the PR's base branch.
  • Safer file restore. Files the run rewrote are only restored inside the project directory, and the changed version is first copied to node_modules/.cache/dep-up-surgeon/side-effects/, so an edit you saved mid-run is never lost. Your --summary-file and Yarn zero-install files are kept.
  • Workspaces. pnpm shared-workspace-lockfile=false no longer turns on parallel installs (pnpm install still installs every project under pnpm-workspace.yaml). yarn --install-mode filtered always runs a full yarn install, because yarn workspaces focus doesn't write yarn.lock. A member's own lockfile is restored on rollback, and a workspace target that throws is recorded as [target:<label>] instead of crashing the run.
  • Peer conflicts. Dependents npm lists under Found: whose range is already satisfied are no longer treated as blockers; blockers are never moved below their installed version; a resolver answer that changes nothing is reported as a failure, not a no-op success.
  • undo and --retry-failed. Override pins are reverted newest first, and a pin changed since the run is skipped as drifted. A report replayed with --file edits this checkout, not the path recorded on the CI machine. Retries chain: a --retry-failed run keeps the previous retry's freezes.
  • doctor. The pnpm peer scan no longer runs pnpm install (it could rewrite pnpm-lock.yaml). A peer scan that can't run is yellow, a missing package.json is red, bun.lockb is green, and workspace-coherence flags declared entries that don't resolve to a member.
  • Changelogs. Breaking-change detection reads the full release notes plus the first release of each crossed major. Monorepo packages match their own tags ([email protected] before v5.0.0). An empty ## Breaking Changes heading isn't flagged. @mentions and issue refs are wrapped in backticks, so your commits and PRs don't ping people or close issues.
  • Detection. npm-shrinkwrap.json is read before package-lock.json; a yarn.lock next to bun.lock is bun's; dist-tags with digits (ts5.6) stay tags; blast radius counts @types/x for files importing x.
  • Interactive mode is ignored with --json, and Ctrl+C at a prompt stops the run and rolls back the step in progress.
  • .dep-up-surgeonrc accepts comments, trailing commas and a BOM, as the jsonc examples here assume. Adding names to ignore keeps the file's formatting; an rc with comments is left for you to edit by hand.

--json consumers: project.isolatedLockfilesSource is now always "per-workspace-lockfiles", and project.yarnSupportsFocus is informational. New fields: transitiveAdvisories, upgraded[].section, overrides.attempts[].method.

What's new in 4.1

  • A failed linked group no longer drops its safe members. When the whole group fails, the members that jump to a new major are held back and the rest upgrade on their own (e.g. an eslint-config-next patch lands while eslint@10 is reported as failed). See When a linked group can't move as a whole. Consumers that key on [group:<id>] rows: a held-back member is now reported as a plain row with linkedGroupId.
  • A lagging latest dist-tag no longer hides updates. When the installed version is newer than the tag, the newest release of the installed major is used (@types/node 26.5.1 → 26.6.1 while latest pointed at 22.20.3); outdated reports it with latestTag.
  • Peer hold-backs name their blocker (skipped[].blockedBy), and a resolver tuple equal to what's installed is no longer installed and validated again. failed[].requestedLatest records the version each failure was aiming for.
  • --progress keeps the progress lines on stderr together with --json, for tools that show a live log next to the report.

Upgrading from 3.x

4.0 changes a few defaults — check these if you run dep-up-surgeon in CI:

  • Run state moved from .dep-up-surgeon.last-run.json in the project root to node_modules/.cache/dep-up-surgeon/ (last-run.json + last-run.<lockfile>), so a run leaves only package.json and the lockfile changed. A root report from an older version is still read by undo / --retry-failed when the new one is missing, with a warning naming the file, its finishedAt and toolVersion (it may be months old); the next run deletes it unless git tracks it. Cache or upload the new directory instead of the old file.
  • Validation is stricter. The default validator also runs your lint / typecheck / type-check scripts (see Pre-flight check), and every install is followed by a peer-range check between installed direct dependencies (see Conflict detection). Expect rollbacks — and exit 1 — where 3.x kept an upgrade that broke linting or left the tree uninstallable.
  • Files the run rewrites are restored. Tracked files other than dependency files that change during the run (e.g. tsconfig.json rewritten by next build) are checked out again at the end.

Usage

From your project root (where package.json lives):

dep-up-surgeon [options]
dep-up-surgeon doctor [options]
dep-up-surgeon undo [options]
dep-up-surgeon outdated [options]

Options

| Option | Description | |--------|-------------| | --dry-run | Resolve latest versions and print the plan; does not change package.json or run installs. | | --interactive | On failure, prompts for next steps (see Interactive mode). After the run, optionally bulk-add failed names to .dep-up-surgeonrc. Ignored with --json (prompts would corrupt the report) and --ci. | | --force | Keep a version bump even when validation fails; also skips rollback when structured conflicts are detected in npm output after a successful exit code (use with care). | | --ignore <pkgs> | Comma-separated package names to skip in every workspace (merged with .dep-up-surgeonrc). This is global by name — unlike --retry-failed, which freezes per workspace. | | --json | Machine-readable report on stdout (see JSON report). Warnings and errors go to stderr, so stdout is always valid JSON. Progress lines are off unless --progress is added. | | --progress | With --json, still print progress (installs, validation, rollbacks, the lines of Live progress) to stderr; stdout keeps only the JSON report. For tools that show a live log next to the report (e.g. PumpBar). Parallel --concurrency drops to 1 so the lines stay readable. | | --fallback-strategy <mode> | major-lines (default), minor-lines, or none. After @latest fails, major-lines tries the best stable version per major (e.g. 9.x → 8.x → 7.x …). minor-lines steps one major.minor line at a time. If npm output looks like ESM vs CommonJS (ERR_REQUIRE_ESM), further fallbacks for that package stop. none only attempts @latest. For linked groups, members step back together, and when the whole group still fails the members that jump to a new major are held back while the rest upgrade on their own (see When a linked group can't move as a whole); none turns both off. | | --link-groups <mode> | auto (default) or none. auto builds linked batches from the registry graph and optional linkedGroups. none upgrades one dependency per step. | | --validate <cmd> | Override the validator command run after every install. Defaults to <manager> test if a test script exists, else <manager> run build (yarn classic uses yarn build), else nothing — plus any lint / typecheck / type-check script, guarded by its pre-flight exit code (see Pre-flight check). Useful in monorepos where the default build is heavy or fragile (e.g. --validate "tsc -p tsconfig.json --noEmit"). | | --no-validate | Skip validation entirely. Upgrades are kept regardless of test/build outcome. Different from --force: --force runs the validator and only keeps the bump when it fails, --no-validate doesn’t run a validator at all. | | --package-manager <mgr> | auto (default), npm, pnpm, yarn, or bun. auto reads the packageManager field, then falls back to lockfile detection (pnpm-lock.yaml → pnpm, yarn.lock → yarn, bun.lock / bun.lockb → bun, package-lock.json → npm), then pnpm-workspace.yaml, then npm. The chosen manager drives both the install command (<mgr> install) and the default validator (<mgr> test / <mgr> run build; bun uses bun run test / bun run build). | | --cwd <path> | Run against this directory instead of process.cwd(). Default is still the current working directory, so scripts that cd first keep working. Same flag as doctor / undo / outdated. | | --include-workspace-deps | By default, dependencies whose name matches a local workspace package (resolved via workspaces in package.json or pnpm-workspace.yaml) are skipped — their version comes from the local workspace, not the registry. Pass this flag to upgrade them anyway (e.g. when local workspace packages also publish to the registry). | | --include-peers | Also upgrade peerDependencies entries. Default: skip peers (they are a consumer contract; auto-bumping them is usually wrong for libraries). | | --pin-exact | Write bare exact versions (1.2.3) into package.json instead of preserving the previous ^ / ~ range style. | | --workspaces | Traverse the root package.json and every workspace member (one engine pass per package.json). Install + validation always run from the workspace root so the lockfile and validator see the whole monorepo. | | --workspaces-only | Like --workspaces but skips the root package.json. Only workspace members are traversed. | | --workspace <names> | Comma-separated workspace member names (the name field from each child package.json) to traverse. Pass root to also include the root. Example: --workspace "@org/core,@org/web,root". Unknown names produce a friendly error listing the known members. | | --install-mode <mode> | Workspace install strategy. root (default) always runs <mgr> install from the workspace root after every mutation — the safest option, supported by every package manager. filtered rewrites per-child installs to their workspace-scoped form: npm 7+ uses npm install --workspace <name>, pnpm / bun use <mgr> install --filter <name>, and yarn (classic and berry) always falls back to a full root install with a one-time warning — yarn workspaces focus doesn't write yarn.lock and installs only part of the tree, so a kept bump would ship with a stale lockfile. Only meaningful with --workspaces / --workspaces-only / --workspace <names>. | | --concurrency <n> | Maximum number of workspace targets to traverse in parallel (1–16; default 1). Higher values overlap registry scan + plan phases across targets while a shared mutex keeps install + validation strictly serialized — the workspace lockfile is shared, so concurrent installs would corrupt it. The default in-process registry cache also deduplicates pacote.manifest / pacote.packument calls across targets, so even at concurrency 1 you get a speedup when the same dep appears in many workspaces. Requires --json so per-target log lines don't interleave; non-JSON mode silently downgrades to 1 with a warning. In an isolated-lockfile monorepo (every workspace member is an independent project shipping its own lockfile) installs + validation are ALSO run in parallel — see Parallel installs below. | | --no-parallel-installs | Force installs + validation to stay serialized even when an isolated-lockfile monorepo is detected. Useful when debugging a flaky install step (parallel installs mask the ordering) or when a per-workspace postinstall script touches shared state outside its workspace. | | --retry-failed | Resume the previous run from its last-run report (node_modules/.cache/dep-up-surgeon/last-run.json): freeze last-run successes and terminal failures (peer, validation-script) per workspace, and re-attempt only non-terminal residue (install, validation-conflicts, versions, unknown). User --ignore / rc lists stay global. See Retry-failed mode below. | | --no-persist-report | Do not write the last-run report after the run. By default it is written to node_modules/.cache/dep-up-surgeon/last-run.json (with pre-run lockfile backups) for undo, --retry-failed and CI consumers. | | --summary <format> | Write a human-friendly summary of the run as md (default) or html. Destination is $GITHUB_STEP_SUMMARY if set (appended), otherwise --summary-file <path>, otherwise ./dep-up-surgeon-summary.<ext>. | | --summary-file <path> | Override the destination for --summary. Wins over $GITHUB_STEP_SUMMARY. | | --ci | Convenience flag for CI / bot use. Disables --interactive, auto-enables --summary md (great with $GITHUB_STEP_SUMMARY), and exits 0 even when individual upgrades fail (only pre-flight failures and fatal errors exit 1) so per-package conflicts surface in the PR description instead of failing the job. | | --git-commit | Commit successful upgrades to git as the run progresses. Refuses to start on a dirty working tree (override with --git-allow-dirty). Only stages package.json + the lockfile (+ pnpm-workspace.yaml when a catalog: entry moved) — never git add -A, so unrelated WIP, generated files, and prepare/postinstall side effects are never accidentally swept into a commit. Skipped silently in --dry-run. | | --git-commit-mode <mode> | How to group commits: per-success (default, one commit per upgrade — best for review and git revert-friendly), per-target (one commit per workspace target with all its successes squashed), or all (one commit at the end with everything). Linked-group upgrades (e.g. react + react-dom) always land in a single commit regardless of mode. | | --git-commit-prefix <prefix> | String prepended to every commit message (default "deps: "). Use "chore(deps): " for Conventional Commits or set it to your team's preferred convention. | | --git-branch <name> | Create + checkout this branch before any commits. If the branch already exists, switches to it. Pairs nicely with --ci for PR-bot workflows (e.g. --git-branch "deps/auto-$(date +%Y-%m-%d)"). | | --git-sign | Pass --gpg-sign to every commit. Requires a signing key configured in git (user.signingkey + gpg.format). Failed signatures are recorded as failed commits in the JSON report rather than aborting the run. | | --git-allow-dirty | Allow --git-commit to run on a dirty working tree. We still only git add files we touched, so your WIP isn't swept up — but if you also git add your own files manually, they'll land in dep-up-surgeon's commits. | | --changelog / --no-changelog | Fetch the bumped package's release notes (GitHub Releases first, then its published CHANGELOG.md) and include them in commit bodies + --summary. Default ON when --git-commit or --summary is active. Network failures are non-fatal — missing changelogs are silently skipped. See Changelog excerpts below. | | --security-only | Run npm audit (or pnpm/yarn equivalent) first, then upgrade only the packages with open advisories. Every successful bump carries the advisory severity + ID into its commit subject ([security:high]) and into the summary's Security fixes table. Pairs well with --git-commit-mode per-success to produce one PR per CVE. See Security-first mode below. | | --min-severity <level> | Minimum advisory severity to consider under --security-only: low (default), moderate, high, or critical. Lower-severity advisories are filtered out before the upgrade plan is built. | | --blast-radius / --no-blast-radius | Scan project source files to list which files actually import/require each upgraded package, and surface the list in --json + --summary. Default ON when --summary is active. See Blast radius below. | | --resolve-peers / --no-resolve-peers | When a linked-group bump (e.g. react + react-dom + @types/react) or a single-package bump fails with a peer-dependency conflict, compute the intersection of peer ranges across the registry packument and retry with a satisfiable version tuple (members may land below latest). Linked graphs with 10+ members automatically use a SAT-style AC-3 solver; single-package failures synthesize an ad-hoc group from direct-dep blockers named in the install output. Default ON. See Peer-range intersection resolver below. | | --apply-overrides | After the main upgrade loop, fix transitive CVEs that no direct bump could reach by writing a package-manager override (overrides for npm, pnpm.overrides / pnpm-workspace.yaml overrides for pnpm, resolutions for yarn) pinning each vulnerable transitive to its audit-recommended safe version. With npm it first tries npm update <name> and writes no pin when the refreshed lockfile is clean. Runs install + validator after each pin and rolls back automatically when the validator fails. Requires --security-only. See Transitive overrides below. | | --override <spec...> | Apply one or more manual override pins independent of the audit. Repeatable and also accepts comma-separated values. Syntax: <chain>@<range>, where <chain> is a bare name (lodash), a pnpm-style chain (some-dep>foo, any depth), or a yarn-style chain (parent/child). Scoped names (@scope/pkg) are preserved as single chain segments. Written to the manager-native nested form (npm object, pnpm >-keys, yarn /-keys) and run through the same install + validator + rollback loop as --apply-overrides. Works standalone — --security-only is not required. See Transitive overrides below. | | --override-force | Used with --apply-overrides. Overwrite an existing override entry whose value conflicts with the audit-recommended version. By default we refuse to clobber user-managed pins and record conflict in the report. | | --fix-lockfile | After the main upgrade loop, run the package manager's native dedupe command (npm dedupe / pnpm dedupe / yarn dedupe) to collapse redundant transitive copies without touching package.json, and flag transitives more than a minor or a full major behind registry latest. Lockfile is backed up before dedupe and restored if dedupe OR the post-dedupe validator fails. Yarn classic (v1) has no dedupe subcommand — recorded as skipped: "unsupported". See Lockfile fix below. | | --no-sync-lockfiles | Leave other package managers' root lockfiles alone. By default a lockfile the run's manager doesn't write — package-lock.json next to bun.lock, say — is re-resolved after the run by its own manager in lockfile-only mode, so CI or a host that installs from it gets the upgraded versions too. See Other lockfiles below. | | --open-pr | After --git-commit --git-branch pushes the branch, open a GitHub PR with the --summary markdown as the body (falls back to a deterministic minimal body). Uses the gh CLI (must be installed + authenticated); never fatal — a missing binary, auth failure, or push rejection is recorded as pullRequest.error in the JSON report without aborting the run. See Auto-opening a PR below. | | --open-pr-title <title> | Override the PR title. Default: derived from the upgrade counts, e.g. deps: [breaking+security] bump 3 packages. | | --open-pr-draft | Open the PR as a draft. Recommended with --force or on Fridays so merge-queue bots don't auto-land it. | | --open-pr-base <branch> | Target base branch. Default: the repo default branch as reported by gh repo view. | | --open-pr-reviewers <users> / --open-pr-assignees <users> | Comma-separated usernames passed straight to gh pr create --reviewer / --assignee. |

Exit code 1 when any upgrade could not be kept (unless --force). The CLI also exits 1 when the pre-flight validator (run on the unchanged tree) fails — see Pre-flight check below. Fatal errors also exit 1.

Pre-flight check

Before mutating any dependency, the CLI runs the resolved validator command once against the unchanged tree:

  • If it passes, the run continues normally.
  • If it fails, the run aborts immediately with an error containing the validator command, exit code, and last ~40 lines of output. This prevents the common failure mode where every per-group rollback looks identical because the project build was already broken before the run.
  • To proceed anyway, use --validate "<cmd>" to swap the validator, --no-validate to skip it, or --force to ignore the pre-flight failure.

Extra check scripts. A build alone can pass while an upgrade breaks linting — TypeScript 7, for example, drops the JS API that typescript-eslint loads, so next build succeeds and npm run lint crashes. With the default validator, pre-flight therefore also runs each lint, typecheck and type-check script the project defines:

  • scripts that pass on the unchanged tree run after test / build on every upgrade (Pre-flight ok: \npm run build && npm run lint``), and a failure there rolls the upgrade back;
  • scripts that already fail don't block the run (a warning says so) but still run after every upgrade, compared by exit code: the upgrade is rolled back only when the script exits differently than on the unchanged tree. Existing lint errors (ESLint exit 1) are tolerated; a crash the upgrade causes (ESLint exit 2) is not; an upgrade that fixes the script (exit 0) is fine.

An explicit --validate "<cmd>" / rc validate or --no-validate is used exactly as given. The CRA-style watch-mode trap is handled too: the test script runs with CI=true (unless you already set CI), so react-scripts test runs once instead of waiting forever.

The pre-flight outcome is also surfaced under preflight / preflightAborted in --json output (preflight.extraScripts lists the extra checks, preflight.failingScripts the ones that already failed, with their exit code). A run that aborts at pre-flight changed nothing, so it does not overwrite the last-run report.

Persisted last-run report

After every CLI run that changes the project the structured report is written to node_modules/.cache/dep-up-surgeon/last-run.json under the workspace root (set --no-persist-report to opt out). Like the Nx / Babel / Vite caches it lives in node_modules/.cache, and the directory carries its own .gitignore, so a run leaves only package.json and the lockfile changed in git status. Deleting node_modules (e.g. npm ci) deletes the record too — run undo before that. A report an older version wrote to the project root (.dep-up-surgeon.last-run.json) is only read when the cache one is missing, and undo / --retry-failed then warn with its path, finishedAt and toolVersion, since it can describe a much older run. Writing a new report deletes that root file and any .dep-up-surgeon.last-run.<lockfile> backups, unless git tracks them. --dry-run never writes it, so a dry run can't overwrite the record undo and --retry-failed rely on. The file mirrors the --json output and adds a small header (finishedAt, toolVersion, cwd, dryRun, plus retryFrozen after a --retry-failed run) so CI dashboards / bots can pick it up without re-running the tool. Each upgraded / failed row carries a workspace field when more than one target was traversed — --retry-failed uses that label so a freeze in one member does not skip the same package name in another. When the run changed a lockfile, its pre-run bytes are saved alongside as last-run.<lockfile> (e.g. last-run.bun.lock) for undo.

Retry-failed mode (--retry-failed)

Pass --retry-failed to resume the previous run instead of starting from scratch:

  • dep-up-surgeon reads the last-run report and freezes every package that either:
    • succeeded in the last run (no need to redo work), or
    • failed for a terminal reason: peer (real peer-dep conflict; bumping the same package alone almost always fails the same way) or validation-script (the project's own test/build script crashed; re-running won't help without a code change).
  • Freezes are per workspace, keyed as workspace::name (for example @org/web::lodash). A success or terminal failure in @org/web does not skip the same package in @org/api. Bare --ignore / .dep-up-surgeonrc ignore entries remain global (the name is skipped in every workspace). Rows from root-only runs or older reports without a workspace field still freeze the bare name everywhere (same as before). root::name freezes from a --workspaces run also apply when the retry only targets the root.
  • Retries chain: a --retry-failed run records the keys it froze (retryFrozen in the report), and the next --retry-failed keeps them frozen on top of that run's own successes and terminal failures. A normal run starts over.
  • It then re-attempts only the residue: failures classified as install, validation-conflicts, versions, or unknown. These are the cases where another dependency move during the new run can plausibly unblock them.
  • Linked-group failures (name === '[group:<id>]') are expanded to every member of the group via the persisted groups field, scoped to the workspace that owned the group, so freezing a peer-failed group correctly freezes every package in it without leaking to other members.
  • If the last-run report is missing the CLI exits 1 with a friendly message; pass --retry-failed only after at least one prior run. Falling back to an older version's root report prints a warning with its path and age.

Typical workflow:

dep-up-surgeon --workspaces           # first pass: lots of moves, some failures
# fix the script that caused a `validation-script` failure (or accept it)
dep-up-surgeon --retry-failed         # second pass: only retries install/conflict residue

Monorepo example — last run with --workspaces:

| Package | Workspace | Last-run result | --retry-failed | |---------|-----------|-----------------|------------------| | lodash | @org/web | succeeded | frozen (skipped) | | lodash | @org/api | install failure | retried | | react | @org/web | peer failure | frozen (skipped) | | react | @org/api | not in last run | planned normally |

--ignore lodash would still skip lodash in both workspaces. --retry-failed after the table above only skips the @org/web rows.

Summary writer (--summary <md|html>)

Pass --summary md (or --summary html) to render a human-friendly report alongside the normal output:

  • GitHub Actions: when GITHUB_STEP_SUMMARY is set, the Markdown summary is appended to that file — it shows up in the job summary tab without any extra workflow plumbing.
  • Explicit destination: --summary-file <path> overrides everything (wins over $GITHUB_STEP_SUMMARY).
  • Default: ./dep-up-surgeon-summary.<md|html>.

The summary contains: counts (upgraded / failed / skipped), detected project info, target list, an Upgraded table (Package | Workspace | From | To | Notes), a Failed table (Package | Workspace | Reason | Attempted | Detail), pre-flight status when it aborted, and the ignored list. HTML output escapes all dynamic content. Designed to be ~40 lines of code on the producer side and easy to embed in PR comments / dashboards.

The HTML output is self-contained and styled: an inline <style> block scoped to .dep-up-surgeon-report gives severity chips (critical / high / moderate / low), breaking / peer-resolved / fallback / forced badges on the Upgraded table, clickable advisory IDs linking to github.com/advisories/..., and responsive tables. No external CSS or web-font requests — the file opens cleanly in any browser, and when the style tag is stripped (GitHub step-summary sanitizer) the tables still render as plain HTML. Release notes and Blast radius per-package blocks are folded inside <details> elements so the summary stays scannable.

CI / bot mode (--ci)

--ci is a convenience flag for unattended runs (GitHub Actions, GitLab CI, Renovate-style bots). It:

  • Disables --interactive unconditionally — never blocks on stdin.
  • Auto-enables --summary md so a Markdown report lands in $GITHUB_STEP_SUMMARY (or ./dep-up-surgeon-summary.md outside Actions). Pass an explicit --summary html if you'd rather have HTML.
  • Remaps the exit code: per-package failures (peer conflicts, install crashes, validation script errors) are recorded in the report and the run still exits 0, so the bot's PR carries the diagnostic instead of the job failing red. Pre-flight failures and fatal errors still exit 1 — those mean the project itself is broken before any upgrade and a human needs to look.

Typical GitHub Actions step:

- name: dep-up-surgeon
  run: npx dep-up-surgeon --workspaces --ci

The job stays green; the Summary tab shows the upgraded / failed tables; node_modules/.cache/dep-up-surgeon/ is cached or uploaded as an artifact (restore it into the next job's node_modules/.cache) so a follow-up --retry-failed job can resume the residue.

Git integration (--git-commit)

Pair dep-up-surgeon with git so every successful upgrade lands as its own atomic commit — perfect for code-review-friendly auto-update PRs.

# One commit per upgrade (best for review).
npx dep-up-surgeon --workspaces --git-commit

# One commit per workspace target (squashed) on a fresh branch.
npx dep-up-surgeon --workspaces \
  --git-commit --git-commit-mode per-target \
  --git-branch "deps/auto-$(date +%Y-%m-%d)"

# CI bot: per-success commits, Conventional Commits prefix, signed.
npx dep-up-surgeon --workspaces --ci \
  --git-commit \
  --git-commit-prefix "chore(deps): " \
  --git-sign

Three commit modes:

  • per-success (default) — one commit per upgrade. Each commit contains exactly the package.json + lockfile diff for one dependency. Trivial to revert any single bump (git revert <sha>) and trivially reviewable in a PR. Linked-group upgrades (e.g. react + react-dom) still land as one commit since they were a single install.
  • per-target — one commit per workspace target, listing every successful upgrade in the commit body. Useful for monorepos where you want each member's bumps grouped.
  • all — one commit at the end with everything. Good for tiny single-package projects; avoid in monorepos.

Safety:

  • Refuses to start on a dirty working tree unless you pass --git-allow-dirty. We don't want to accidentally commit your WIP.
  • Only stages package.json + the lockfile — never git add -A (in a Yarn zero-install repo also .pnp.cjs / .pnp.loader.mjs / .yarn/cache, which must match yarn.lock). Files modified by prepare/postinstall hooks (e.g. .husky/) or other side effects of npm install stay uncommitted.
  • Override pins (--apply-overrides / --override) and the --fix-lockfile dedupe run after the upgrades and land in one extra commit (deps: apply 2 override pins + dedupe lockfile), so the branch — and a PR opened from it — has what the report lists.
  • Errors out cleanly when not in a git repo (instead of silently skipping).
  • Skipped silently in --dry-run (no upgrades happen → nothing to commit).
  • A failed git commit (signing rejected, pre-commit hook refused, etc.) is recorded as commits[].ok === false in the JSON report with the git stderr — the upgrade itself is never rolled back because of a commit failure.

Concurrency-safe. --git-commit works fine with --workspaces --concurrency 8: every stage + commit runs through one queue, so two targets can't race the git index or commit each other's staged files, and each package's install + validation + commit (and each per-target flush) holds the install lock, so another target's install can't land in the lockfile between an upgrade and its commit.

Structured report. Every commit attempt (success or failure) appears under commits in --json output:

{
  "gitCommitMode": "per-success",
  "commits": [
    {
      "ok": true,
      "sha": "a1b2c3d",
      "message": "deps: bump axios from ^1.6.0 to ^1.7.2",
      "files": ["package.json", "package-lock.json"],
      "workspace": "root"
    }
  ]
}

Changelog excerpts

Every successful upgrade can be annotated with the package's release notes so reviewers don't have to open five GitHub tabs per PR. Enabled by default when --git-commit or --summary is set; disable with --no-changelog.

  • Source. First preference is the GitHub Releases API (one GET /repos/:owner/:repo/releases per package, tag matched locally), resolved from the package's repository field in its package.json. Package-specific tags are tried before generic ones — <pkg>@<v>, <short-name>@<v>, <short-name>-<v>, then v<v>, <v>, … — so a monorepo package (create-vite, @vitejs/plugin-react) never picks up the main package's v<v> notes. Fallback is the CHANGELOG.md extracted from the published tarball via pacote.extract — the matching version section is parsed out with a Markdown-aware heading scanner (handles ## 1.2.3, ## [1.2.3] - 2024-..., ## v1.2.3, etc.).
  • No pings, no auto-closed issues. Release notes land in your commits and PR bodies, so upstream @mentions and issue refs (#123, owner/repo#123, GH-123) are wrapped in backticks: they read the same, but GitHub neither notifies those people nor links (or, via fixes #123, closes) issues in your repo.
  • Where it shows up. In --git-commit-mode per-success, the excerpt is embedded directly in the commit body. In per-target / all modes it collapses to a compact See: <release-url> footer so the commit doesn't balloon. --summary md / --summary html renders each excerpt in a collapsible <details> block — clean in PR bodies, compact in GitHub's Job Summary.
  • Caching & resilience. A run-local cache deduplicates fetches across workspaces. Network errors, missing tags, private repos, and malformed CHANGELOG.md files are all silently skipped — a missing excerpt never fails a commit.
  • GitHub auth. Anonymous GitHub API requests are rate-limited to 60/hour. Set GITHUB_TOKEN (or GH_TOKEN) in the environment to lift that to 5,000/hour — dep-up-surgeon uses it automatically for changelog fetches and nothing else.

Security-first mode

--security-only flips the tool from "bump everything safely" to "bump only packages with known CVEs". Competes directly with Dependabot's security-alert surface, but runs locally and respects your validator / policy / link groups.

  1. Runs npm audit --json (or pnpm audit --json / yarn audit depending on the detected manager) before the upgrade plan is built.
  2. Filters the audit to advisories at or above --min-severity <low|moderate|high|critical>.
  3. Builds a restrictToNames set from the vulnerable package names and passes it to the engine — every other dependency gets added to the ignore list automatically (visible as reason: "ignored" in the report).
  4. Attaches the severity + advisory ID + title to every upgraded record's security field, which the CLI then propagates into:
    • Commit subjects: deps: [security:high] bump axios from 1.6.0 to 1.7.2
    • Commit bodies: full advisory ID, URL, and title
    • --summary: a prominent Security fixes table above the normal upgraded table
    • --json: upgraded[].security = { severity, ids, url, title, vulnerableRange, recommendedVersion }
  5. Advisories on a package that no direct dependency names can't be fixed by an upgrade. They're listed as warnings with a way out — npm update <name> when the packages depending on it allow the fixed version, or --apply-overrides to pin it — and under transitiveAdvisories in --json.
# Only critical + high; one commit per CVE on a dedicated branch.
npx dep-up-surgeon --workspaces --security-only --min-severity high \
  --git-commit --git-commit-mode per-success \
  --git-branch "deps/security-$(date +%Y-%m-%d)"

The whole path is covered by test/unit/security-only.test.mjs — a hermetic regression harness that drives runAudit with a canned npm audit --json blob, asserts --min-severity filters at every tier, and exercises the full runUpgradeFlow → install → validator → rollback cycle without touching the registry (via the UpgradeFlowOptions.installer injection point).

Policy engine (policy-as-code)

Drop a .dep-up-surgeon.policy.yaml (or .json) in the repo root to encode upgrade rules that survive across runs and humans. Loaded automatically on startup; violations are reported per-package and the engine skips the offending bumps instead of failing.

# .dep-up-surgeon.policy.yaml
freeze:
  - pattern: react               # never touch it
    reason: "React 18 pinned until Q3 refactor"
  - pattern: "@types/*"          # wildcard — freezes every @types/* scope
maxVersion:
  - pattern: next
    range: "<=14"                # refuse anything outside this semver range
allowMajorAfter:
  - pattern: eslint
    date: "2026-06-01"           # patch/minor OK now, majors blocked until the date
requireReviewers:                # metadata: surfaced in --summary / --json for your bot to consume
  major: 2
  minor: 1
  patch: 0
autoMerge:                       # metadata: ditto
  patch: true
  minor: false
  include:
    - "eslint-plugin-*"

How rules interact

  • freeze always wins. Exact names go straight into the ignore list; wildcards are matched against the scanned deps inside the engine so rules like @types/* don't have to be unrolled by hand. Freezes produce a reason: "policy" skip record with the originating pattern.
  • maxVersion caps the candidate list. If no candidate satisfies the range, the package is skipped with reason: "policy" — it won't degrade to a no-op install. Caps (and allowMajorAfter gates) hold everywhere a version is picked: single upgrades, linked-group members (a capped member moves to the newest allowed release), coordinated fallbacks, the peer-range resolver, and --dry-run plans.
  • allowMajorAfter blocks cross-major bumps until the specified date (checked against Date.now()), demoting the candidate to the newest in-major version. Patch/minor still flow through normally.
  • requireReviewers and autoMerge are metadata only — attached to the policy block of --json + --summary for downstream automation (GitHub Actions PR-opener, the SaaS bot, etc.) to consume.

Every applied rule appears in the Policy section of --summary and under policy.applied / policy.frozen / policy.warnings in --json, so audits show exactly which rule blocked which package.

Blast radius

Before handing the PR to a reviewer, dep-up-surgeon can list which of your own source files actually import each upgraded package. Surfaced automatically under --summary; attach it to --json too with --blast-radius.

  • Scans: .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue, .svelte, .astro.
  • Skips: node_modules, .git, .next, .turbo, .vercel, .cache, .parcel-cache, .output at any depth; the build-output folders dist, build, out, coverage only at the project root and in package roots (any folder with its own package.json, e.g. workspace members) — a src/build/ or lib/out/ folder deeper down is scanned as source.
  • Detects: ES imports (import x from '<pkg>'), re-exports (export … from '<pkg>'), CommonJS require('<pkg>'), dynamic import('<pkg>'), and subpath imports (from '<pkg>/sub' still counts as a hit on <pkg>). Word-boundary safe — looking for react does not falsely match react-dom; looking for @types/node does not match @types/node-ipc. Type packages count for the package they type: @types/react hits files importing react, @types/babel__core hits @babel/core (@types/node keeps its literal name).
  • Output: per-package { total, truncated, files[] } entries in upgraded[].blastRadius, plus a collapsible per-package list in the Markdown / HTML summary. Caps at 20 file paths per package by default; total keeps counting past the cap so the summary can honestly say "used in 134 files".
  • Cost: a single pass over the tree, at most 1 MB read per file, parallel I/O (default concurrency 8). Failures are non-fatal — a broken symlink never aborts the run. Turn it off in huge monorepos with --no-blast-radius.

Breaking-change detection

Whenever a changelog excerpt is fetched, dep-up-surgeon scans it for breaking-change markers and flags the upgrade so reviewers catch them before clicking merge. Works alongside --changelog (enabled by default with --git-commit / --summary) with no extra flags.

  • What we match: BREAKING CHANGE: / BREAKING CHANGES: footers (Conventional Commits), the 💥 and ⚠️ BREAKING emoji conventions used by Changesets / tsup / Vitest, explicit Node-version drops (drop support for Node 16, requires Node >= 20), API-removal bullets (- Removed the …), and no longer supported / renamed … to … phrasing. Deprecation notices alone do not trip the scan, and neither does a release template's ## Breaking Changes heading with nothing (or None / N/A) under it.
  • What we scan: the full release notes, not the 30-line excerpt shown in commits / summaries. When the bump crosses a major (or a 0.x minor) — 1.4.0 → 3.2.1, 0.3.2 → 0.5.1 — the notes of the first release of each crossed line (3.0.0, 2.0.0; nearest 3 at most) are scanned too, since a patch-level target rarely repeats its major's breaking changes. They come from the same source: free from the GitHub releases page / the tarball's CHANGELOG.md, or one extra GitHub lookup each when they fell off a full releases page. Lines found there are prefixed with their version (2.0.0: Dropped support for Node 16).
  • Where it shows up:
    • Commit subjects gain a [breaking] tag (emitted BEFORE [security:<sev>] when both apply): deps: [breaking][security:high] bump axios from 1.6.0 to 2.0.0.
    • Commit bodies get a Breaking changes detected: section listing the exact matched lines, capped at 5 per package.
    • --summary md|html renders a prominent ⚠️ Breaking changes detected section ABOVE the upgraded table, plus a ⚠️ breaking badge in the Notes column.
    • --json → upgraded[].changelog.breaking = { hasBreaking, matchedLines[], reasons[] } (only present when the scan matched).
  • Never fatal, never noisy: absence of a changelog means no scan, which means no flag. The scan caps matches at 10 per package and dedupes identical lines so verbose changelogs don't drown out the signal.

Peer-range intersection resolver

Linked-group bumps (e.g. react + react-dom + @types/react, or the Jest / Testing-Library / Vitest families) frequently fail because one member's latest demands a peer version another member can't yet satisfy. Without help, the whole batch rolls back and the user has to figure out the right tuple by hand.

--resolve-peers (default ON) turns this into an automated constraint-satisfaction problem:

  1. The first batch attempt runs exactly like today — every linked member goes to its registry latest.
  2. If the install fails with a peer conflict, the resolver fetches each linked package's full registry packument (cached — one call per package per run) and reads every published version's peerDependencies block.
  3. Each member gets a candidate domain: every version between its installed version (the lockfile entry for that workspace; else currentRange's minVersion) and the originally-requested target, sorted newest-first, minus deprecated / pre-release versions.
  4. A newest-first backtracking search enumerates version tuples (variable = one package, domain = its candidate versions). For each partial assignment, every peer constraint that has become knowable is checked; peers on packages inside the linked group are checked against the chosen version, peers on packages OUTSIDE the group are checked against that package's installed version (lockfile), else the floor of its range in the current package.json (via semver.minVersion).
  5. For large linked graphs (≥ 10 members) — where the 400-tuple backtracking budget can be burned before the solver escapes the first variable's domain — the resolver automatically switches to a SAT-style path (arc-consistency + least-constraining-value DFS). It pre-prunes every member-version that can't be satisfied against external peers, runs up to 128 AC-3 rounds across every ordered member pair until the pruned domains reach a fixed point, then does an MRV-ordered (smallest domain first) newest-first DFS on whatever survived. For monorepo link groups up to ~50 members × ~30 recent versions this finishes in milliseconds where plain DFS would return undefined. When the SAT path fails the dispatcher falls back to the plain backtracker automatically.
  6. The first complete tuple to satisfy every constraint is also the least-downgrade one. The engine rewrites the batch's target versions and retries the install + validator. On success, every affected row is tagged with resolvedPeer = { originalTarget, reason, tuplesExplored } — reason carries a [backtracking] or [sat] method tag so reviewers can tell which solver path produced the tuple.
  7. If the resolver can't find a satisfiable tuple, or the retried install still fails, the batch falls back to the pre-resolver behavior (rollback + kind: 'peer' failure row).

Ad-hoc resolver for non-linked bumps. Single-package upgrades that fail with a peer conflict used to be rolled back unconditionally — the resolver was linked-groups-only. Now we synthesize an ad-hoc group from the parsed install output: the primary + every blocker named in the peer-conflict lines that's already a direct dep of the workspace (peers on unknown transitives stay out of scope). The same resolver (and the same SAT fallback) runs on that synthesized group. On success the engine writes a small batch: the primary at whatever version the resolver picked, plus any blocker the resolver wants moved within its current pinned range (the ad-hoc path is allowed to downgrade the primary, never to silently bump a blocker past its pin).

Blockers come from the conflict itself: npm's Could not resolve dependency: / Conflicting peer dependency: lines, plus dependents listed under Found: <pkg>@<version> whose range that version does not satisfy — satisfied ones (peer react@">=16.8.0" from @emotion/[email protected] under Found: [email protected]) are the current tree, not the conflict. Each blocker is modelled at its installed version (the lockfile entry for that workspace) and never moved below it: bumping react 19.2.8 → 19.3.0 while @react-three/[email protected] needs react <19.3 settles on the newest react that fiber 9.7.0 accepts, instead of rolling fiber back to an older release that allowed 19.3. When the only answer is the primary staying at its installed version, nothing is retried and the bump is reported as a peer failure.

Guard rails that keep it safe:

  • Bounded search — capped at 400 tuples explored per batch (small graphs) or 400 × members tuples for the SAT path's DFS phase. Past that the resolver gives up silently instead of hanging the run on pathological inputs.
  • Optional peers (peerDependenciesMeta[name].optional === true) are ignored. An unsatisfied optional peer isn't a hard conflict.
  • Installed direct deps outside the batch still bound it — each member's candidates are narrowed to versions that satisfy the peer ranges declared by the installed direct dependencies that aren't being bumped (a package already at latest never joins the batch, but its peers still count).
  • Deprecated versions never appear in the domain. We'd rather fail to find a solution than auto-suggest a known-bad version.
  • Ad-hoc group size cap — default 6 members (primary + up to 5 direct-dep blockers). Prevents registry fetch storms on pathological peer graphs.
  • Ad-hoc never adds dependencies — a peer on a transitive that isn't already a direct dep is ignored rather than introduced.
  • --force bypasses the resolver — the user has explicitly opted into barreling through peer conflicts.
  • --no-resolve-peers keeps the old behavior when you WANT peer failures to surface so a human resolves them instead of the tool silently nudging versions off latest. Applies to both the linked-group and ad-hoc paths.

Where it shows up:

  • Console output: upgraded: react-dom → 18.3.1 (group react-pair) [peer-resolved from 19.0.0].
  • --summary md|html: a dedicated Peer-range resolutions table (package / group / requested / installed / tuples explored) above the upgraded table, plus a peer-resolved from <v> badge in the upgraded row's Notes column.
  • Commit subjects: [peer-resolved] tag sits between [breaking] and [security:<sev>] (stable order). The body gets a Peer-range resolutions (kept linked group satisfiable): footer listing each pinned member.
  • --json: upgraded[].resolvedPeer = { originalTarget, reason, tuplesExplored } plus upgraded[].requestedLatest still reflects the pre-resolver target so downstream tools can diff them.
  • Kept at the installed version: when the resolver's tuple is what's already installed, the group isn't installed and validated again. The members are reported as skipped no change rows with requestedLatest and blockedBy: [{ name, version, range }], e.g. @react-three/fiber 9.7.0 needing react >=19 <19.3.

Transitive overrides (--apply-overrides / --override)

--security-only by itself can only fix vulnerabilities reachable from a direct dependency. For CVEs that live in transitives (very common — [email protected] buried six levels deep under a toolchain package), pair --security-only with --apply-overrides and the tool will write a package-manager override to pin the vulnerable transitive to its safe version.

  • Which field: overrides for npm (>=8.3) and bun, pnpm.overrides for pnpm, resolutions for yarn (classic + berry). With pnpm, pins go to the overrides map of pnpm-workspace.yaml (comments kept) when that file holds one, or when it exists and package.json has no pnpm.overrides block yet — pnpm 10+ reads it and pnpm 11 ignores pnpm.overrides. A packageManager of pnpm@9 or older keeps new pins in package.json. An existing pin is always rewritten where it lives.
  • Lockfile refresh first (npm): before pinning, the tool runs npm update <name>. When no copy left in the lockfile is in the advisory's vulnerable range and the validator passes, the advisory is fixed without an override (method: "lockfile-update", applied lists the installed versions). Otherwise package.json and the lockfiles are put back byte-for-byte and the override is pinned as usual. Skipped when a pin for the package already exists or the audit gave no vulnerable range.
  • How it picks the pin: uses the audit's own fixAvailable.version when present; otherwise minVersion of the first safe range the manager reported.
  • Rollback on failure: after each override, the tool runs a full install and then the validator. If either fails, package.json, pnpm-workspace.yaml and every root lockfile get their pre-attempt bytes back (so the rejected version doesn't stay locked), install re-runs to restore the starting state, and the next advisory is still attempted. A failed override never strands the workspace — report.overrides.attempts[].rolledBack === true appears in the JSON and the summary.
  • Conflict protection: when the user already has a manual override with a value that conflicts with the audit recommendation, we refuse to clobber by default (reason: "conflicts with target ..."). Pass --override-force to overwrite explicitly. Pins written as unquoted YAML numbers (qs: 6) count as existing pins too. Pinning a package that already carries child pins ("foo": { ".": "1.0.0", "bar": "2.0.0" }) updates its "." and keeps the children.
  • Where it shows up:
    • --summary: dedicated Overrides applied table with Package / Pinned to / Source / Severity / Advisory. Parent-scoped pins render as a › b › c so the chain is visible at a glance; advisories fixed by npm update show updated in lockfile (<versions>) instead of a pin.
    • --json: overrides.field + overrides.attempts[] with the full decision trail (ok, skipped, reason, previous, applied, installLog, rolledBack, chain, source, method). Parent-scoped pins carry chain: ["parent", "child"]; source distinguishes "advisory" from "manual"; method is "lockfile-update" when no pin was written (absent = an override).

Parent-scoped pins (--override)

--apply-overrides only writes the flat name → version form — every occurrence of the package gets pinned. When you need to pin a transitive only when it appears under a specific parent (e.g. you want [email protected] under some-dep while the rest of the tree uses [email protected]), use --override to write a parent-scoped selector. Works standalone — no --security-only required.

Syntax: <chain>@<range>. The chain supports three forms, all normalized internally:

Each selector is written to the manager's native nested encoding:

| Manager | Shape written | | --- | --- | | npm | { "overrides": { "some-dep": { "foo": "1.2.3" } } } — nested object; an existing flat pin for the parent is preserved via npm's "." self-selector. | | pnpm | { "pnpm": { "overrides": { "some-dep>foo": "1.2.3" } } } — pnpm's >-chain keys, deep chains supported. Lands in pnpm-workspace.yaml overrides instead under the rules in Which field above. | | yarn | { "resolutions": { "some-dep/foo": "1.2.3" } } — /-chain keys. | | bun | Flat pins only ({ "overrides": { "foo": "1.2.3" } }): Bun ignores nested overrides, so a parent-scoped selector is skipped with reason: "bun only supports top-level overrides; …". |

Every pin runs through the same install + validator + rollback loop as advisory-driven pins. A failed manual pin is rolled back (only that specific slot) and the rest of the run continues; a flat pin and a parent-scoped pin with the same leaf name coexist as separate entries.

# Pin `[email protected]` ONLY when it's a transitive of `some-dep`, and pin `[email protected]`
# globally. Both live in the same run; one failing never touches the other.
npx dep-up-surgeon \
  --override "some-dep>[email protected]" \
  --override "[email protected]" \
  --validate "npm test"
# Weekly security sweep: direct bumps first, then transitive overrides (audit-driven +
# one manual pin), then a draft PR.
npx dep-up-surgeon --workspaces \
  --security-only --min-severity high \
  --apply-overrides \
  --override "@babel/core>@babel/[email protected]" \
  --git-commit --git-commit-mode per-success --git-branch "deps/security-$(date +%Y-%m-%d)" \
  --summary md \
  --open-pr --open-pr-draft

Override policy file (.dep-up-surgeonrc overrides)

Re-typing --override "parent>[email protected]" on every CI run gets old fast. Commit the pins to .dep-up-surgeonrc instead and they'll apply on every run the same way ignore does — merging with any CLI --override flags on the same invocation (CLI wins on chain conflict). The committed form supports a reason string that flows straight into the report + summary so reviewers can see why each transitive is pinned (CVE ID, vendor guidance, upstream PR link) without grepping commit history.

Two input shapes are accepted:

{
  "overrides": [
    // Structured: explicit chain + range. `chain: "lodash"` is shorthand for the flat case.
    { "chain": ["some-dep", "foo"], "range": "1.2.3", "reason": "CVE-2025-1234" },

    // Selector form: same syntax as the `--override` CLI flag.
    { "selector": "@babel/core>@babel/[email protected]", "reason": "upstream PR #16012 pending" },
    { "selector": "[email protected]" }
  ]
}

Behavior:

  • Merge + dedupe: entries are merged with CLI --override selectors by exact chain. A CLI pin for the same chain replaces the rc entry (including the reason), so one-off ad-hoc overrides always win over committed policy.
  • Malformed CLI selectors are warnings, not fatal: a typo in one --override flag won't prevent committed rc pins from applying. rc entries with a bad shape produce per-entry warnings in .dep-up-surgeonrc.warnings and the run continues.
  • Same lifecycle as --override: every pin goes through the install + validator + rollback loop. Failures are per-pin — one bad pin never strands the rest.
  • reason surfaces in the report: the Overrides applied table in --summary adds a Reason column whenever at least one attempt carries one; --json ships overrides.attempts[].policyReason verbatim for bots.
# Run the committed overrides policy. No CLI flags needed; `overrides: [...]` in
# `.dep-up-surgeonrc` is enough to trigger the flow.
npx dep-up-surgeon --summary md

# Add a one-off pin on top of the committed set for this run only:
npx dep-up-surgeon --override "[email protected]" --summary md

Disaster recovery (dep-up-surgeon undo)

Every run writes a last-run report (node_modules/.cache/dep-up-surgeon/last-run.json) — a structured record of what the tool did (previous ranges, to values, every override attempt with applied/previous, workspace targets). dep-up-surgeon undo replays that record in reverse:

  1. For every successful upgrade row, write the recorded from back to package.json (in the section the row recorded, or — for older reports — the section that holds the recorded to, across the root and every workspace target).
  2. For every successful override attempt, newest first, drop the pin we added — or, when the attempt recorded a previous value (the run replaced an existing pin), restore that previous value where the pin lives (package.json or pnpm-workspace.yaml). Newest first means a slot pinned twice in one run ends at its pre-run value; a parent the run turned from "x" into { ".": "x", child } goes back to "x". Advisories fixed by npm update wrote no pin: step 3's lockfile restore reverts them.
  3. Put back the pre-run bytes of every root lockfile the run changed (the run saved them next to the report as last-run.<lockfile>), then run <manager> install once per edited target. Reinstalling alone isn't enough: a reverted ^16.3.1 still allows the 16.3.5 the run installed, so without the backup those versions would stay.
  4. Run the validator so you see green/red before you commit the revert.

Drift protection:

  • If the current package.json value for a dep doesn't match the to the run landed on (another run, or a human edit, moved it), the row is skipped with reason: 'drifted'. The same goes for an override pin that no longer holds the applied value the run wrote. Undo never rewrites state we don't recognize.
  • Paths in the report are taken relative to the recorded run's cwd and applied under the directory undo runs in (--cwd, else the current one). Replaying a CI artifact with --file therefore edits this checkout, never the path recorded on the CI machine; when none of the report's package.json files exist here, undo exits 1.
  • When node_modules/.cache/dep-up-surgeon/last-run.json is missing and an older version's root report is used instead, undo warns with that file's path, finishedAt and toolVersion (warnings in --json).
  • The lockfile backup is only restored when every row reverted and the lockfile still hashes to what the run left behind; otherwise undo says why (lockfile bun.lock: not restored (…)) and falls back to reinstalling from the reverted ranges.
  • When the recorded run was --dry-run, undo is a no-op.
  • Missing / unparseable run report → the command exits with status 2 and a clear error message.
# Replay the newest recorded run in this directory.
npx dep-up-surgeon undo

# Compute the reverse plan WITHOUT touching the disk. Use for review/CI dry-runs.
npx dep-up-surgeon undo --dry-run

# Replay a specific run file (e.g. from a CI artifact).
npx dep-up-surgeon undo --file ./ci-logs/2026-04-18-upgrade.last-run.json

# Skip the validator — handy when the project has no test script but you still want the
# dep ranges rolled back.
npx de