Dependency Upgrade Validation
The upgrade is the easy part; proving the project still coheres is the work. This skill takes over after versions are chosen -- typically bun update -i -r or an equivalent interactive pick -- and validates what landed: the real delta from git, peer and engine integrity, breaking changes and migrations across every version crossed, features worth adopting, and verification gates that run the project rather than reading about it. It also answers the inverse question: can a held-back package move, and why is it in package.json at all.
When to Use
- Validating a batch upgrade -- "I upgraded a lot of packages, validate everything"
- Assessing a held-back package -- "can we upgrade X?", "what blocks it?"
- Questioning a dependency -- "why do we even have X, we don't use it"
- Reading release notes -- breaking changes, migration steps, new features across the versions crossed
- Post-upgrade triage -- type, build or test failures that appeared after an update
- Pre-upgrade recon -- what a bump would cost before anyone runs it
Critical Rules
- Detect the manager first, then use only its column. A Bun project gets
bunthroughout, a pnpm projectpnpm, an npm projectnpm, a yarn projectyarn. Another manager's CLI may not be installed at all, and if it is, it resolves against its own rules and can write a second lockfile or a differently-shaped tree. Every capability this skill needs has a native form in all four managers -- see the Capability Matrix -- so there is never a reason to reach across. - Establish the delta from git, never from the prompt. "I upgraded a lot of packages" is a starting point, not an inventory. The lockfile diff is the only complete record -- it carries transitive bumps that
package.jsonnever shows. outdatedreports drift, not compatibility. A cleanbun outdatedmeans every dependency sits at the newest version its range allows; it says nothing about whether the tree still resolves, builds or runs. Never report an upgrade as validated on that basis.- Peer ranges of ecosystem packages are the ceiling, not the registry's
latest. A framework plugin pinningnext@">=16.2.6 <17.0.0"caps the framework regardless of what the registry calls latest. Read the peers of the packages that wrap the target before proposing a bump. - A peer-required package is required with zero imports.
graphqlis a mandatorypeerDependencyofpayload@3.88.0; an app that never writes a query still must declare it. Runbun whyand check every installed peer before proposing any removal (seereferences/dependency-audit.md). - Never run a migration, codemod, or version change without explicit approval. Report the exact command and what it will touch. Schema migrations additionally need an ordering and rollback story before they are worth proposing.
- Read notes for every version crossed, not just the target. A breaking change introduced in
16.9and unmentioned in16.14's notes is still a breaking change for a project coming from16.8. - Verify by running the project. Typecheck, build and tests are the evidence. A changelog that promises compatibility is not evidence.
- Report what you did not check. Skipped packages, unread notes and untested paths belong in the report; silence reads as coverage.
Capability Matrix
Read down your project's column and use nothing else. Blank means the manager has no such command -- the workaround in the notes stays inside that manager.
| Capability | bun | npm | pnpm | yarn (berry) |
|---|---|---|---|---|
| Registry metadata | bun info <pkg> [prop] |
npm view <pkg> <field> |
pnpm view <pkg> <field> |
yarn npm info <pkg> -f <fields> |
| Why installed | bun why <pkg> |
npm why <pkg> |
pnpm why <pkg> |
yarn why <pkg> --peers |
| Installed tree | bun pm ls --all |
npm ls --all |
pnpm list --depth Infinity |
yarn info -A -R |
| Peer validation | peer-check.ts (references/dependency-audit.md) |
npm ls --all --json -> .problems |
pnpm peers check |
yarn explain peer-requirements <hash> |
| Advisories | bun audit --json |
npm audit --json |
pnpm audit --json |
yarn npm audit --json |
| Outdated | bun outdated (table only) |
npm outdated --json |
pnpm outdated --format json |
-- |
| Versions in a range | bun info <pkg> versions + Bun.semver |
npm view '<pkg>@<range>' version |
pnpm view <pkg> versions --json + filter |
yarn npm info <pkg> -f versions --json + filter |
| Lockfile check | bun install --frozen-lockfile --dry-run |
npm ci --dry-run |
pnpm install --frozen-lockfile |
yarn install --immutable |
| Read package.json | bun pm pkg get <field> |
npm pkg get <field> |
pnpm pkg get <field> |
read the file |
| Run a tool | bunx <tool> |
npx <tool> |
pnpm dlx <tool> |
yarn dlx <tool> |
Where the notes matter:
- Peer validation differs sharply in quality.
pnpm peers checkandyarn explain peer-requirementsare purpose-built: both name the requiring package, the wanted range and the installed version, andpnpm peers checkexits1, so it works as a gate. npm has no dedicated command --npm ls --all --jsonsurfaces"invalid: <pkg>@<ver>"under.problems, andnpm install --dry-runprints the full chain without writing. Bun has the weakest story (a non-fatal warning that names nobody), which is why it gets the script inreferences/dependency-audit.md. - pnpm and yarn report peer problems during install, so the install output is worth reading rather than discarding: pnpm ends with
Issues with peer dependencies found, yarn emitsYN0060naming the package and the six-letterp-prefixed hash thatyarn explain peer-requirementstakes. - Only npm enumerates a version range directly.
npm view '<pkg>@>16.8.0 <=17.0.2' versionlists every match;bun infoandpnpm viewgiven the same range resolve to the single highest one instead, silently hiding what came between. Elsewhere, list all versions and filter. - The yarn column is berry (v2+). Yarn Classic is a different CLI. Confirm which before running anything:
yarn.lockwith a.yarnrc.ymlis berry,yarn.lockalone is v1. Classic keeps the npm-style surface --yarn outdated,yarn info <pkg>,yarn audit --json,yarn why <pkg>(no--peers),yarn list, andyarn install --frozen-lockfilerather than--immutable. Running berry'syarn npm infooryarn dlxagainst v1 simply fails. - Yarn berry has no
outdated-- v1 does, and it was dropped in the rewrite, soyarn outdatedon berry fails as an unknown script. On berry useyarn dlx tazeoryarn upgrade-interactive. - Bun's
outdatedhas no JSON.--jsonis accepted and ignored. Parse the table -- the columns areCurrent | Update | Latest.
Filtering versions to a range, per manager:
# bun -- Bun.semver is built in, nothing to download
bun -e 'const vs = JSON.parse(await Bun.$`bun info graphql versions --json`.text());
console.log(vs.filter(v => Bun.semver.satisfies(v, ">16.8.0 <=17.0.2"))
.sort(Bun.semver.order).join("\n"));'
# npm -- native
npm view 'graphql@>16.8.0 <=17.0.2' version
# pnpm / yarn -- list, then filter with the semver CLI via that manager's runner
pnpm view graphql versions --json | jq -r '.[]' | xargs pnpm dlx semver -r '>16.8.0 <=17.0.2'
yarn npm info graphql -f versions --json | jq -r '.versions[]' | xargs yarn dlx semver -r '>16.8.0 <=17.0.2'
Semver ranges exclude prereleases unless the range names one, so canaries and rc builds drop out of all four without extra filtering.
Other tools are assumptions too. These recipes use jq and gh; neither ships with a package manager. Check before relying on them (command -v jq gh) -- gh additionally needs auth for release reads. Without jq, parse with the project's own runtime; without gh, fall back to WebFetch on the releases page.
Package Manager Detection
Check in this order -- the packageManager field wins where both exist, since it is what Corepack and CI enforce:
bun pm pkg get packageManager 2>/dev/null # or: cat package.json | grep packageManager
ls bun.lock bun.lockb package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null
ls bunfig.toml .yarnrc.yml deno.lock 2>/dev/null # bunfig.toml marks bun; .yarnrc.yml marks yarn berry
| Lockfile | Manager | Interactive upgrade command |
|---|---|---|
bun.lock (text) or bun.lockb (binary) |
bun | bun update -i -r |
pnpm-lock.yaml |
pnpm | pnpm update -i -r -L |
yarn.lock + .yarnrc.yml |
yarn berry (v2+) | yarn upgrade-interactive |
yarn.lock, no .yarnrc.yml |
yarn classic (v1) | yarn upgrade-interactive |
package-lock.json |
npm | none built in -- npx taze -I or npx npm-check-updates -i |
A deno.lock with no npm lockfile is a Deno project: none of the four columns apply, and the phases below still do (see references/package-managers.md). Say so rather than guessing a manager.
Workspaces change the shape of every command: a monorepo needs the recursive or filtered form, and every workspace package.json enters the delta.
Reference: See
references/package-managers.mdfor the full verified per-manager command matrix.
Phase 0: Establish the Delta
Nothing else is trustworthy until this is exact.
# Declared changes (root + workspaces)
git diff HEAD -- package.json '**/package.json'
# Resolved changes, including transitive bumps nothing declared
git diff HEAD -- bun.lock # or pnpm-lock.yaml / yarn.lock / package-lock.json
# Already committed on a branch
git diff origin/main...HEAD -- package.json '**/package.json' bun.lock
Classify every change, because the bump type sets how much scrutiny it earns:
| Change | Risk | Treatment |
|---|---|---|
Major (1.x -> 2.x) |
High | Full release-note read, migration hunt, feature scan |
Minor on 0.x |
High | Semver grants no compatibility below 1.0 -- treat as major |
| Prerelease / rc / canary | High | Confirm it was intentional; check it is not a stray --latest artifact |
Minor (1.2 -> 1.5) |
Medium | Scan notes for BREAKING, deprecat, removed, migrat |
| Patch | Low | Skip notes unless it is a direct runtime dependency or a security fix |
| Transitive only (lockfile) | Medium | No notes; confirm the tree still resolves and nothing crossed a major |
| Range widened, version same | Low | Note it -- the next install can drift without a code change |
Binary lockfiles produce no readable diff. For bun.lockb, either snapshot the resolved tree before and after with bun pm ls --all and diff the snapshots, or convert the project once to the text lockfile that has been the default since Bun 1.2:
cp bun.lockb /tmp/bun.lockb.bak # keep the original
bun install --save-text-lockfile --frozen-lockfile --lockfile-only # rewrites the lockfile only
--frozen-lockfile --lockfile-only keeps the conversion to a re-encoding: no resolution beyond what the lockfile already pins, and no touching node_modules. Without them the same command is free to resolve new versions, which changes the delta being measured.
The conversion leaves bun.lockb in place, and deleting it is the user's call, not a cleanup step -- it is the only copy of the resolution if the new bun.lock turns out wrong. Verify the converted file first (bun install --frozen-lockfile --dry-run succeeds against it, and the resolved versions match the pre-conversion bun pm ls --all snapshot), then ask before rm bun.lockb.
Confirm the working tree matches the lockfile before drawing any conclusion from it:
bun install --frozen-lockfile --dry-run # bun -- validates, writes nothing
npm ci --dry-run # npm -- validates, writes nothing
pnpm install --frozen-lockfile --lockfile-only # pnpm -- validates without linking
yarn install --immutable # yarn -- validates, but installs
Bun and npm give a true dry run: both fail when package.json and the lockfile disagree, and neither touches disk. pnpm splits the job -- pnpm install --dry-run previews what an install would change while writing nothing, while --frozen-lockfile is the validation and installs as a side effect; pairing it with --lockfile-only checks without linking node_modules. Yarn has no dry run here: yarn install --immutable fails fast on drift and installs while doing it, so run it only where installing is acceptable.
Phase 1: Static Integrity
Run these before reading a single release note -- they are cheap and they catch the failures that no changelog would have warned about.
Peer conflicts (the highest-yield check). Use your manager's:
pnpm peers check # names package, wanted range, installed version; exits 1
yarn explain peer-requirements <hash> # hash comes from the YN0060 line in install output
npm ls --all --json | jq '.problems' # "invalid: <pkg>@<ver>"; npm install --dry-run for the full chain
bun run peer-check.ts # see references/dependency-audit.md
Bun is the one that needs the script. It reports a violated range as warn: incorrect peer dependency "graphql@17.0.2" -- non-fatal, naming nobody, and absent from any machine-readable output:
$ bun run peer-check.ts
CONFLICT graphql-tag@2.12.6 needs graphql@^0.9.0 || ... || ^16.0.0 -- installed 17.0.2
npm's plain npm ls is equally misleading -- at depth 0 it prints a peer-invalid tree as clean at exit 0, so --all --json is required.
Engines vs the runtime actually in use:
bun info <pkg>@<ver> engines # npm view / pnpm view / yarn npm info -f engines
bun --version; node --version
Deprecations introduced by the bump:
bun info <pkg>@<ver> deprecated || echo "not deprecated"
A package that is not deprecated has no such property, and bun info treats a missing property as an error: error: Property deprecated not found, exit 1. That is the healthy case -- do not report it as a lookup failure. npm view and pnpm view print nothing and exit 0 for the same case.
Duplicate majors in the tree -- two copies of a stateful library (React, GraphQL, an ORM client) is a runtime bug, not a size problem. Use the Why-installed and Installed-tree rows of the matrix:
bun why <pkg> --depth 3 # pnpm why / npm why / yarn why --peers
bun pm ls --all # pnpm list --depth Infinity / npm ls --all / yarn info -A -R
Security posture after the bump:
bun audit --json | jq 'to_entries[] | {pkg: .key, advisories: [.value[] | {severity, title, vulnerable_versions}]}'
bun pm scan # bun only: lockfile scan, no installed tree needed
npm audit --json, pnpm audit --json and yarn npm audit --json answer the same question with their own output shapes -- read the shape before writing a filter.
Type package alignment -- @types/* majors track their runtime package's major. A mismatch surfaces as type errors during Phase 6, not as an install failure, so pair them in the delta table.
Reference: See
references/dependency-audit.mdfor the peer-ceiling method, duplicate-major diagnosis, and the removal decision tree.
Phase 2: Release Notes
Budget this phase by the risk column from Phase 0. Fetching notes for every transitive patch burns context and buries the findings that matter.
bun info <pkg> repository # npm view <pkg> repository.url / pnpm view / yarn npm info -f repository
bun info <pkg> homepage # docs site, where upgrade guides live
gh release view v17.0.0 --repo <owner>/<repo> --json tagName,publishedAt,body
Enumerate every version crossed with your manager's row in the Capability Matrix. Only npm takes a range directly; with bun and pnpm, <pkg>@<range> resolves to the single highest match (bun info 'graphql@16' version returns 16.14.2), silently hiding everything in between.
Source order, cheapest first: node_modules/<pkg>/CHANGELOG.md (free, already on disk, but many packages ship none) -> gh release view -> the repo's CHANGELOG.md / UPGRADING.md / MIGRATION.md -> the docs site upgrade guide via WebFetch.
Extract only four things: breaking changes, migration steps, deprecations with their removal version, and additions that could replace code the project already hand-rolls. Everything else is noise.
Reference: See
references/release-notes.mdfor repo resolution, the tag-naming fallback ladder, monorepo handling, andghrecipes.
Phase 3: Migrations
Release notes bury migration requirements in prose, and the two kinds fail differently:
- Code migrations -- codemods, renamed APIs, moved or reshaped config. Failure is loud and local: the build breaks.
- Schema and data migrations -- ORM or CMS model changes needing a generated migration committed and applied in a specific order relative to the deploy. Failure is silent in development and destructive in production.
Both get reported with exact commands, a required-now or optional verdict, and for schema migrations an ordering and rollback note. Neither runs without approval.
Reference: See
references/migrations.mdfor detection patterns, per-ecosystem commands, and deploy ordering.
Phase 4: Held-Back and Suspect Packages
For each package the user did not upgrade, or suspects is unnecessary, answer both questions explicitly:
- Why is it here? Direct and imported / direct because a peer requires it / transitive only and wrongly declared / genuinely unused.
- What blocks the upgrade? Name the package and the peer range that caps it, or state that nothing does and the bump is available.
Never answer the first question from an import grep alone -- that is exactly how a required peer dependency gets deleted.
Reference: See
references/dependency-audit.mdfor the full decision tree.
Phase 5: Adoptable Features
From the notes gathered in Phase 2, surface additions the project could use, ranked by what they delete: features replacing a hand-rolled workaround first, then those removing a dependency, then performance and DX. Check the project actually contains the pattern being replaced before suggesting it -- grep for the old API. These are proposals with an effort estimate, never edits.
Phase 6: Verification Gates
Cheapest first, stopping at the first failure and attributing it before moving on:
bun install --frozen-lockfile # npm ci / pnpm install --frozen-lockfile / yarn install --immutable
bun run typecheck # or: bunx tsc --noEmit
bun run lint
bun run build
bun test # or the project's own test script
Substitute your manager's runner throughout (npm run, pnpm, yarn). Note that bun test is Bun's own runner: in a project whose tests are Jest or Vitest, the gate is bun run test, which executes the project's script. Read the actual script names from package.json rather than assuming; skip gates the project does not define and say so. Then a runtime smoke check -- boot the dev server, hit one route or entry point that exercises the upgraded packages. Type-clean and build-clean code still fails at runtime on changed initialization, config schemas and adapter APIs.
Map every failure to the package that caused it. "Build fails" is not a finding; "build fails because the config option was renamed in 16.0" is.
Phase 7: Report
Lead with the table, then the sections that need a decision:
| Package | Old -> New | Bump | Risk | Breaking | Migration | Action |
|---------|-----------|------|------|----------|-----------|--------|
| next | 15.4.2 -> 16.3.1 | major | high | yes | codemod | run codemod |
| graphql | 16.8.0 (held) | -- | -- | -- | -- | blocked by payload peer ^16.8.1 |
Then, only where non-empty: Blocked (what caps each held package), Action required (migrations and code changes, with commands), Adopt (optional features with effort), Remove (dependencies proven unnecessary, with the evidence), Security (advisories resolved or introduced), Not checked (skipped packages and untested paths).
Commit the result by what changed, never by the process that produced it -- fix: update config for renamed option, not fix: post-upgrade fixes (see the git-commit skill).
Key Gotchas
- A manager you did not detect may not be installed -- and if it is, it resolves by its own rules and can leave a second lockfile behind.
npmin particular is not present on every machine that has bun. Every capability has a native form in all four managers (Capability Matrix); reaching across is never necessary and rarely harmless. The same caution coversjqandgh, which no manager installs. bun outdatedhas no JSON output (through 1.3.x) ----jsonis silently ignored and the table prints anyway. Parse the table (Current | Update | Latest) rather than switching managers for it.bun infofails outside a project --error: Bun could not find a package.json file to install from, including for plain registry lookups. Inside a project it is the right tool.bun info <pkg> deprecatederrors when the package is healthy --error: Property deprecated not found, exit1. A missing property is an error tobun info, so the good outcome looks like a failed command. Branch on the message, not the exit status (npm viewprints nothing and exits0).bun info '<pkg>@<range>' versionreturns one version, not the range -- it resolves to the highest match, silently hiding every version in between. Enumerate withbun info <pkg> versionsplus aBun.semver.satisfiesfilter.Bun.Globbrace alternatives cannot contain/--{*,@*}/package.jsonmatches fine, but{*,@*/*}/package.jsonmatches nothing and reports no error, so a scan overnode_modulessilently returns zero packages and every check built on it reports success. Use one pattern per shape. The same silent-zero applies to dot directories:node_modules/.bunandnode_modules/.pnpmare invisible toscanunless you passdot: true.- Peer-conflict reporting is where the managers differ most --
pnpm peers checknames package, range and installed version and exits1; yarn printsYN0060at install with a hash foryarn explain peer-requirements; npm needsnpm ls --all --json->.problemsornpm install --dry-run; bun emits a non-fatal warning naming nobody. Do not assume the quality of one carries to another. npm lsat depth 0 hides peer invalidity -- a tree with a violated peer range printed clean at exit0. Usenpm ls --all --json | jq '.problems'. In npm projects only:npm install --dry-runwrites nothing (verified -- nopackage-lock.jsonappears) and prints the full requiring chain.- bun writes its banner to stderr and data to stdout --
bun audit --json | jqpipes cleanly; no stripping needed. dist-tagscan be polluted -- some packages carry dozens of canary and experimental tags. Readdist-tags.latest, never the first entry.- Release tag naming is inconsistent --
v17.0.2,17.0.2,@scope/pkg@6.0.0in package-tagged monorepos, and release names that carry more than the version (React names tagv19.2.8as19.2.8 (July 21st, 2026), so name matching fails wheretagNamematching works). Resolve by ladder, do not guess once and give up. - Canary-heavy repos drown the release list --
gh release list --repo vercel/next.jsreturns mostly prereleases; pass--exclude-pre-releases. repository.urlcan point at a renamed org -- React's metadata saysgithub.com/react/react.ghfollows the redirect, so pass it through rather than validating it by hand.- Many packages ship no changelog anywhere -- not in the tarball, not at the monorepo root. GitHub releases are then the only source, and for a few packages the docs site is.
- Yarn berry has no
outdatedcommand -- it was not carried over from v1, andyarn outdatedfails as an unknown script rather than an unknown command. Useyarn dlx tazeoryarn upgrade-interactive. - Transitive bumps never appear in
package.json-- a supply-chain incident or a breaking change in a nested dependency is visible only in the lockfile diff. - A same-day publish deserves a second look --
bun info <pkg> time --jsondates every version. Every manager now ships a cooldown for this reason (bun install --minimum-release-age=<seconds>,npm install --min-release-age=<days>, pnpm'sminimumReleaseAgesetting, yarn's default time gate), so a version the tool refuses to pick may be held back deliberately, not broken. knipand similar unused-dependency tools flag peer-required packages as unused -- that is the exact trap Critical Rule 4 exists for. Treat their output as a list of candidates to investigate, never as a removal list.- Lockfile drift outlives the upgrade -- widening a range without changing the installed version means the next clean install resolves differently. Flag range-only edits even though nothing appears to have changed.
Reference: See
references/package-managers.mdfor the per-manager command matrix Reference: Seereferences/release-notes.mdfor release-note and changelog retrieval Reference: Seereferences/migrations.mdfor codemods and schema migrations Reference: Seereferences/dependency-audit.mdfor peer ceilings and dependency justification Reference: Seereferences/allowlist.mdfor auto-approval patterns