Conversation
…ith no package names `bun update` and `bun update --latest` with no package names write the resolved version range into package.json, but the lockfile's workspaces section kept the literal from the pre-resolution in-memory package.json (the string "latest" under --latest, the stale range otherwise). The very next `bun install` then rewrote bun.lock. The positional path already handles this in Lockfile::preprocess_update_requests, keyed off manager.update_requests. The no-argument path tracks its targets through manager.updating_packages instead, so nothing rewrote the root dependency literals before the lockfile was saved. Add Lockfile::preprocess_updating_packages to do the same rewrite for that path. It computes the final literal once, writes it into the old lockfile's root dependency (which clean() clones into the saved lockfile), and records it on the PackageUpdateInfo entry. The post- install package.json edit now applies that recorded literal instead of re-deriving it from the lockfile, which it can no longer do because the lockfile dependency it would read was just rewritten. The per-group Behavior is recorded on PackageUpdateInfo so the lockfile dependency is matched by name and group, not name alone. catalog: literals are skipped by the new rewrite, so `bun update` with no arguments no longer replaces a root "catalog:" entry in package.json with the resolved range.
…bun update The positional path, Lockfile::preprocess_update_requests, wrote ^<resolved> into the lockfile's root dependency regardless of the user's pin level, and for npm: aliases it dropped the alias target entirely (`bun update odd-alias` on "npm:is-odd@~1.0.0" left the lockfile saying odd-alias: "^1.0.2"). package.json got the correct literal from the twin of format_updated_version_literal that lived inline in PackageJSONEditor::edit, so the two files disagreed and the next bun install rewrote the lockfile. Route both through format_updated_version_literal: the new positional_update_literal helper uses it when `bun update <name>` recorded the original literal in updating_packages, and keeps the existing ^<resolved> for `bun add` (which records nothing). The inline twin in PackageJSONEditor::edit is replaced with a call to the shared helper; its output is byte-identical, verified against the unfixed binary.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Walkthrough
Changesbun update lockfile synchronization
Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Checked both. Neither is fixed here, so I am not adding the This PR only changes what gets written into
The causal framing in the suggestion (the lockfile writing |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/install/lockfile.rs`:
- Around line 1454-1464: The fallback version rewrite in
`format_updated_version_literal` is too broad and can rewrite a dependency from
the wrong group when `updating_packages` has a matching name but `dep.behavior`
does not intersect `entry.group_behavior`. Update
`format_updated_version_literal` so it returns `None` on behavior mismatch, and
only emit the default `^<resolved>` literal when no `updating_packages` entry
exists; then adjust both `preprocess_update_requests` loops to `continue`
whenever this helper returns `None`, keeping `PackageJSONEditor` and lockfile
updates aligned for duplicate dependency names across groups.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 883a9d71-ee5b-4447-9de0-8ac9400ff1d3
📒 Files selected for processing (4)
src/install/PackageManager.rssrc/install/PackageManager/PackageJSONEditor.rssrc/install/lockfile.rstest/cli/install/bun-update.test.ts
|
Looked at #31752's diff, not just its description: not a duplicate. #31752 fixes #31748, where a This PR fixes the root package's dependency literals in the lockfile's |
… did not touch When the same name appears in more than one dependency group, `bun update <name>` records a single updating_packages entry, from the first group PackageJSONEditor::edit scans, and only rewrites that group in package.json. positional_update_literal fell through to the default `^<resolved>` for the lockfile dependency belonging to the other group, changing its pin level while package.json kept the original, so the two files still disagreed there. Return None for that case so preprocess_update_requests leaves the dependency alone.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/install/lockfile.rs (1)
921-934: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTrim these comments to the 3-line limit.
These new comments exceed the repo’s max comment length. Keep only the invariant: the lockfile rewrite records the literal that package.json later consumes. As per coding guidelines, “Keep code comments to 3 lines max.”
Also applies to: 1194-1198, 1451-1455
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/install/lockfile.rs` around lines 921 - 934, Trim the new block comments in lockfile.rs to the repo’s 3-line limit by removing the long explanation and keeping only the core invariant. Update the comments around the logic in the relevant lockfile rewrite path so they state that the lockfile rewrite records the literal that package.json later consumes, without describing the full bun update flow or post-install timing details. Apply the same shortening pattern to the other affected comment blocks referenced near the lockfile update helpers and PackageUpdateInfo handling.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/install/lockfile.rs`:
- Around line 829-837: The positional rewrite path in lockfile updates is still
rewriting root dependencies backed by catalog: literals, unlike the no-args
path. Add the same catalog: guard used elsewhere before calling
positional_update_literal in the positional update flow so root literals remain
unchanged for bun update <name>; check the logic around
manager.updating_packages, dep, and exact_versions where the new literal is
computed.
In `@test/cli/install/bun-update.test.ts`:
- Around line 367-372: Shorten the test comment to 3 lines or fewer and keep it
behavior-focused only. Remove implementation-specific references like
edit_update_no_args and edit(), and rewrite it to describe the durable
observable behavior in bun update: when the same package name exists in multiple
dependency groups, only one matching entry is moved in package.json and bun.lock
must keep the untouched group entry aligned. Use the existing comment block in
bun-update.test.ts as the location to update.
---
Outside diff comments:
In `@src/install/lockfile.rs`:
- Around line 921-934: Trim the new block comments in lockfile.rs to the repo’s
3-line limit by removing the long explanation and keeping only the core
invariant. Update the comments around the logic in the relevant lockfile rewrite
path so they state that the lockfile rewrite records the literal that
package.json later consumes, without describing the full bun update flow or
post-install timing details. Apply the same shortening pattern to the other
affected comment blocks referenced near the lockfile update helpers and
PackageUpdateInfo handling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 30e6bffd-fccf-4b32-8afb-2e376119f5b5
📒 Files selected for processing (2)
src/install/lockfile.rstest/cli/install/bun-update.test.ts
There was a problem hiding this comment.
No issues found on the current revision — my earlier note and CodeRabbit's are both addressed. Deferring to a human reviewer since this restructures the data flow between lockfile preprocessing and PackageJSONEditor (the no-arg post-install edit now depends on preprocess_updating_packages having populated updated_version_literal), which is subtle enough in the install path to warrant a maintainer's look.
Extended reasoning...
Overview
This PR touches src/install/lockfile.rs, src/install/PackageManager.rs, src/install/PackageManager/PackageJSONEditor.rs, and adds tests in test/cli/install/bun-update.test.ts. It fixes #13388 by making bun update write the same resolved version literal into bun.lock's workspace section that it writes into package.json. The fix introduces a shared formatter (format_updated_version_literal), a new lockfile preprocessing pass for the no-argument path (preprocess_updating_packages), extends PackageUpdateInfo with group_behavior and updated_version_literal, and refactors PackageJSONEditor::edit_update_no_args to consume the precomputed literal instead of re-deriving it.
Security risks
None identified. The change is confined to how version-range strings are formatted and threaded between in-memory lockfile state and the package.json editor. No auth, network, filesystem-permission, or untrusted-input parsing surfaces are introduced.
Level of scrutiny
Moderate-to-high. While the diff is well-scoped and well-tested (6 new parameterized regression cases against a dummy registry, plus a 13-case matrix verified by the author), it is a non-trivial refactor of core package-manager state flow:
edit_update_no_args's post-install branch drops ~100 lines of inline derivation and now relies onpreprocess_updating_packageshaving already populatedentry.updated_version_literal. The PR description itself flags that re-deriving here would regressinstall.exact = truebecause the lockfile dependency it would read has just been rewritten — i.e., there is a new, load-bearing ordering dependency.- The
else if !manager.updating_packages.is_empty()inclean_with_loggeris intentionally exclusive with the positional path; the comment explains why, but a maintainer familiar with the install pipeline should confirm the call ordering holds across all entry points (workspaces,--filter, etc.). - Three rounds of bot review surfaced real edge cases (group-behavior mismatch in the positional path) that were fixed iteratively, which suggests the state space is subtle.
Other factors
- All inline review threads (mine and CodeRabbit's) are resolved as of 14f517d; the catalog-guard suggestion was correctly rejected with a verified counterexample.
- No CODEOWNERS entry covers
src/install/. - The bug-hunting system found nothing on the current revision.
- CI on bfa0679 had unrelated build-rust failures on freebsd/android; status on 14f517d is not yet reflected in the timeline.
- The removed code carried a
TODO(dylan-conway)describing exactly this fix, so a quick look from someone familiar with that TODO's intent would be ideal.
|
Closed: the whole |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Can you consolidate more of this code?
preprocess_update_requests and preprocess_updating_packages each hand-rolled the same two-pass StringBuilder protocol (size, allocate, append, re-parse, clamp) with the matching split-borrow setup, so the protocol existed twice. Collapse it into one apply_root_dependency_literals; each caller is now a single decision loop that collects (root dependency index, new literal) pairs. The two single-line cold wrappers around them become one dispatch.
|
Done in 57d9eb4.
Together with the earlier rounds, the version-literal formatting that previously existed in three byte-for-byte copies (the positional lockfile rewrite, the no-argument lockfile rewrite, and the inline block in Behavior is unchanged: all 12 One smaller spot remains if you want it gone too: |
test/cli/install/lockfile-sync.test.ts asserts, for every cell, that after
the operation package.json and the root workspace entry of bun.lock carry
the same literals, and that the next `bun install` has nothing left to save.
Dimensions: {bun add, bun remove, bun update, bun update --latest,
bun update <names>} x {dependencies, devDependencies, optionalDependencies,
peerDependencies} x {npm, npm alias, folder, link:, local tarball, remote
tarball, workspace:} x {new, same, greater, lower resolution}. On top of
that: every pin style `which_version_is_pinned` distinguishes, install.exact,
a dependency added to package.json but not yet in the lockfile, and the same
name in two dependency groups across all six group pairs.
The non-npm protocols are the negative contract. `bun update` only rewrites
npm resolutions, so those literals must come through every operation
byte-identical in both files.
260 tests, each project on its own namespace of one in-process registry so
the whole file runs concurrently. 99 fail on bun 1.4.0 (every `bun update`
flavor); the remaining 161 pin down add, remove, and non-npm behavior that
already held.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/cli/install/lockfile-sync.test.ts`:
- Around line 1-21: The top-of-file comment in lockfile-sync.test.ts is too long
and includes bug history that should not live in the test. Shorten the block
around the existing issue URL so it only states the invariant needed for the
test, and remove the matrix/history details while keeping the intent clear for
the test suite.
- Around line 23-64: This install suite is missing the standard long-running
test timeout, so add the established 5-minute default timeout at the top of this
file before the async setup runs. Use the same install-test pattern as other
`test/cli/install/*` specs so the `beforeAll`/subprocess-heavy tests don’t hit
the default Bun timeout during package manager operations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1d58b34a-0fff-4000-acbd-17552a12cc2d
📒 Files selected for processing (2)
src/install/lockfile.rstest/cli/install/lockfile-sync.test.ts
Every other test/cli/install suite calls setDefaultTimeout(5 minutes) because the package manager subprocesses it spawns exceed the default per-test timeout under a debug build. Also trim the file comment to the repo's limit.
Every spawned bun shared one BUN_INSTALL_CACHE_DIR while the suite runs its ~260 projects concurrently, and every project carries the same local tarball, so they all extract into one cache slot. On Windows that race fails the whole install: the loser of the rename gets moving "tgz-local-dep" to cache dir failed ENOTEMPTY (NtSetInformationFile()) and a concurrent reader gets ENOENT: failed opening cache/package/version dir Point each bun at a cache inside its own project directory so no two processes ever share a cache slot, and drop the now unused shared directory. Also trims the remaining comments in the file to the repo's 3-line limit.
PackageJSONEditor::edit records a default-initialized entry for a
positional target whose npm: alias version tag it cannot classify. For
a versionless scoped alias like "npm:@scope/pkg" the only @ is the
scope's, the re-check infers a non-npm tag from "scope/pkg" and bails
after get_or_put already inserted the slot, so the entry's
group_behavior stays empty. intersects(empty()) is always false, which
made positional_update_literal skip the lockfile rewrite while the
post-install package.json edit still wrote ^<resolved>:
{"foo": "npm:@isaacs/string-locale-compare"}; bun update foo
package.json: ^1.1.0 bun.lock: npm:@isaacs/string-locale-compare
An empty group means no group was recorded, not a mismatch, so it takes
the ^<resolved> fallback, matching the package.json edit.
Also replaces lockfile-sync.test.ts's hand-rolled JSONC parser with
Bun.JSONC.parse.
Lockfile::preprocess_update_requests only rewrote the root dependency
for a dist-tag request, but PackageJSONEditor::edit's post-install gate
also rewrites package.json for an Npm-range request under bun update,
so the two files disagreed and the next bun install re-saved:
{"is-odd": "~1.0.0"}; bun update is-odd@^3.0.0
package.json: ~3.0.1 (re-pinned from the original literal)
bun.lock: ^3.0.0 (the request literal)
Mirror the editor's gate: a dist-tag request always rewrites; an npm
range does so only under bun update, for a dependency whose original
literal was recorded or a non-exact request. bun add <name>@<range>
records nothing and is not the Update subcommand, so it keeps writing
the requested range into both files unchanged.
…latest-lockfile-literal
format_updated_version_literal read the alias prefix from a per-caller dep_literal argument but the pin level from entry.original_version_literal. For a positional `bun update <name>@<version>` of an `npm:` alias, the pre-install package.json edit replaces the in-memory literal with the requested version, so the lockfile caller's dep_literal had lost the `npm:<target>@` prefix and the two files disagreed. Derive both from entry.original_version_literal and delete the parameter, so every caller formats identically.
…fallback `bun update <name>@npm:<target>@<range>` for a `<name>` not yet in package.json records no `updating_packages` entry, so the lockfile rewrite took the `^<resolved>` fallback and dropped the `npm:<target>@` prefix. `clean_with_logger` then copies the rewritten root dependency back into the request, so the post-install package.json edit no longer saw an alias either and both files saved a bare range, silently losing the alias. `PackageJSONEditor::edit`'s matching no-entry branch keeps the prefix from the request literal, split at the first `@`, and the root dependency still carries that literal when the fallback runs, so mirror it there. The prefix is scoped to the no-entry arm: an existing entry that `edit` could not classify takes `edit`'s entry path, which carries no prefix, and must keep matching it.
|
Closing this one: all of the |
|
Sounds good, a single fix for the whole family is the better shape. Closing out here. In case it saves the consolidated PR a round of review, these are the cases that were not visible from the original symptom and each cost this PR a revision. All of them have a pinned literal in
|
…ilter/--catalog, nested overrides, transitive update, and workspace fixes (#38333) Brings Bun's package manager to parity with pnpm for monorepo workflows, and fixes the bugs found while checking every command against pnpm's implementation, pnpm's test suites, pnpm's open issue tracker, pnpm's docs, npm's arborist fixtures, and — for `bun update` — running real pnpm and Bun side by side on the same projects. ### What does this PR do? #### New commands - **`bun dedupe [--check]`** — collapses duplicate versions in `bun.lock` onto the smallest set that still satisfies every dependent's range, using only versions already in the lockfile, then installs. Never downgrades a direct dependency unless that is the only way to drop a version; keeps patched versions (and anything needed to reach them, and says so); refuses to run on a lockfile that is behind `package.json`. `--check` exits 1 without writing. - **`bun prune [--production | --omit=…] [--dry-run] [--filter <ws>]`** — removes everything in `node_modules` that the lockfile does not put there; `--production` leaves exactly what `bun install --production` would. Hoisted and isolated layouts, Windows junctions and shims, workspace links, bundled deps; refuses when `package.json` and `bun.lock` disagree or when `node_modules` was laid out by a different linker; understands turbo-pruned checkouts. - **`bun pm licenses [--json] [--prod|--dev] [--long] [--filter <ws>]`** — installed packages grouped by license, with a `(dev)` marker and `paths`/`license`/`description` in `--json`. - **`bun audit fix [--latest] [--dry-run] [--json]`** — moves each vulnerable package to the lowest safe version its dependents accept, per installed instance; rewrites exact pins when that is the only way; `--latest` also rewrites your own declared ranges (root, workspace, catalog) so a semver-major fix can be taken, and every blocked or unfixable item is followed by the command that resolves it (`bun audit fix --latest`, `bun audit --ignore GHSA-…`); re-audits the tree it actually installed and reports/exits from that second response (npm's `_submitQuickAudit`), so an advisory that starts at the version it moved to is not missed; works across registries; security fixes bypass `minimumReleaseAge` with an annotation. `bun audit --json` honors `--audit-level`/`--ignore` for its exit code; `--omit` is honored by `audit` and `licenses`. #### `bun update` semantics (pnpm's model) - A bare `bun update` re-resolves **transitive** packages too — every edge moves to the newest version its own range (or dist-tag) allows, per dependent, so `bun.lock` no longer stays stale after an update; overrides/catalogs changed since the last install are honored. From a workspace member or `--filter`, only what the selected workspaces reach is re-resolved; from the root, everything. - `bun update <name>` reaches any depth, matches `npm:` aliases by real name, updates in place, never adds to `package.json`, and errors on a name nothing selected depends on. `-r`/`--filter` fan a named update out across workspaces. `--latest` never downgrades a locked version that is ahead of the tag, and `update <name> --latest` also refreshes that package's own dependencies. - Plain updates keep dist-tag literals and non-caret ranges (`*`, `1.x`, `^1 || ^2`) exactly as written and only move the lockfile; `--latest` rewrites them to the resolved version as before. `bun update -i` applies only what you selected. New: positional patterns (`bun update '@types/*'`), `--dev`/`--prod`/`--no-optional`, `-L`, `bun up`. - `package.json` is written after resolution and `bun.lock`'s declared ranges, overrides and catalogs are re-derived from the final `package.json`, replacing the per-command literal rewriting; a no-op update leaves the file byte-identical. #### Overrides - **Nested overrides** (#6608): npm's nested objects, yarn's `a/b` paths and pnpm's `a>b` selectors, applied to the direct parent→child edge; **version-scoped targets** (`"lodash@<4.17.21": "4.17.21"`, the shape `pnpm audit --fix` writes), matched against the dependent's declared range as pnpm does. Rules persist inside the `overrides` section and the file is stamped `lockfileVersion: 3` **only when such rules exist** — existing lockfiles are byte-identical. Flat overrides additionally fix `$ref` to workspace-member deps, catalog-valued rules going stale, and warn on pnpm's `-` / `pkg@` forms. #### Workspaces and filters - `bun add|remove|update … --filter <ws>` (also `-F`, also `bun install <pkg> --filter`) edits the selected workspaces' `package.json` files and **links only those workspaces**, like `bun install --filter`. Filters gain pnpm's relation selectors (`foo...`, `...foo`, `foo^...`, `...^foo`) and `{dir}` subtrees, for the install family **and** `bun run --filter`; `--filter` may precede the subcommand; every command warns about patterns that match nothing; `add`/`remove` no longer select the root implicitly. - `bun add <pkg> --catalog[=name]` reuses an existing catalog entry, keeps a range an explicit version fits, catalogs the range a package already declares, decides per target, and refuses workspace names and local paths; a plain `bun add` uses a default-catalog entry when one exists. A package defined in both `catalog` and `catalogs.default` is an error. `catalog:` peers of registry packages bind to the importer's copy instead of the root catalog. - `--frozen-lockfile` / `bun ci` on turbo-pruned monorepos: pruned-away workspaces are tolerated, a survivor depending on a pruned workspace is an error, catalog subsets are accepted, and an overrides/catalogs change is a frozen failure. #### One output vocabulary Every command here prints the install family's shapes: header, glyph rows (`+`/`-`/`↑`, dedupe's `↳ name old → new`), exactly one noun-first summary line with counts and a duration (`2 duplicate versions removed, 3 packages installed (checked 5 packages) [12ms]`, `N packages removed (checked C) [t]`), no-ops that say what was checked, remedies printed as copy-pasteable command lines, warnings as `warn:`, `--silent` printing nothing, and errors with their remedy together on stderr. Transitive and named updates render as the summary's `↑` rows (once per package; `--dry-run` prints the same rows plus `N packages would be updated`); dedupe reports after the install it triggers, so lifecycle-script output never splits it. A lockfile whose bytes did not change is no longer rewritten (`Saved lockfile` only prints on a real write; `--lockfile-only` no-ops print `Done! Checked N packages (no changes)`). This came out of running every command against fixtures and comparing with `install`/`add`/`remove` (95 findings, all fixed). #### Config precedence A project's `bunfig.toml` now beats any `.npmrc` (project or user-level) for the same key (npmrc files → bunfig's set fields → CLI); npmrc-only settings such as `//host/:_authToken` still attach to bunfig-declared registries, matched by host and path regardless of how either file spells the trailing slash. #### Lockfile migration - `package-lock.json`: rebuilt around a reachability walk that derives each resolution from the entry itself. Fixes `git+https://github.com/…` resolutions being written unparseably (the next install threw the lockfile away), root `bundleDependencies` migrating to an **empty** lockfile, lockfileVersion 1 (and npm's upcoming 4) making `bun install` exit 1 instead of resolving fresh, dependency-level bundles, `dependencies`+`optionalDependencies` double edges, unreferenced entries aborting the migration, duplicate packages for identical `name@version` at nested paths, lost `optionalPeers`, lost integrity when a bundled copy was seen first, and `overrides` not being carried over. All 57 of arborist's v2/v3 fixture projects are vendored and migrated under snapshot. - `pnpm-lock.yaml` v9: bare-hash `patchedDependencies`, snapshot aliases, `catalog:default`, recorded tarball URLs, git `path:`, multi-document files, `runtime:` entries, named registries, peer-suffixed keys chosen per importer, injected workspaces, manifest-only importer deps. #### Isolated linker - An existing store entry whose dependencies re-resolved (override, dedupe, update) now has its links refreshed on the next install (measured cost below). `bun prune` builds the same store the installer builds, so stale `name@version+<peerhash>` variants left by peer bumps are removed and a kept package's real entry never is (whether it was installed with full or `--production` features); on the hoisted linker, dedupe / audit fix / update delete the nested copies whose rows they collapsed instead of leaving the old copy loadable. - Blocked entries resume through per-entry intrusive waiter lists instead of a scan of every store entry after each completion (robobun's #25983/#28425 attempted this). Measured on the reporter's repro from #25799 (2,259 store entries) and a synthetic 6,425-entry monorepo, PR build vs merge-base build: main-thread CPU in the link phase drops 0.85 → 0.33 s and 5.5 → 0.85 s (the removed work grows quadratically); wall time is unchanged with spare cores and 16% / 26% faster pinned to one CPU, the CI/Docker shape in those reports. (The minute-long installs originally reported were peer resolution, fixed before this PR's base.) - Two pre-existing leaks surfaced by the new LSan-enabled tests are fixed: the header buffer of every authenticated registry request, and the per-entry lifecycle-script lists. #### Other bug fixes `bun add x@npm:pkg` writes a range; `bun add --trust a b` no longer drops `b` when `a` was already trusted; `bun add x --dev` no longer rewrote every group in `bun.lock`; `catalog:` peer hoisting; `dependency::Version::eql` treated all `catalog:` specifiers as equal; a `file:` package whose dependencies reach itself (its own name, an `npm:` alias under its own name, two link targets depending on each other — the shapes a `package-lock.json` migration produces, and #25202's `workspace:.` self-reference) hung `bun install` forever in the hoisting tree — the migration shapes now install, and #25202's literal shape now terminates with `Workspace dependency "foo" not found` rather than installing as npm does; a peer of a `file:` package that was only placed nested was also written to `optionalPeers` in a migrated bun.lock, so the next `--frozen-lockfile` failed and a plain install rewrote the lockfile; `catalog:` literals in `bun update`; alias output in the install summary; help/completions for everything above. #### Behavior changes to note in the release notes - `bun update` moves transitive packages; `bun update <name>` no longer adds an undeclared package (exit 1); `--production`/`--prod` on update means "only update `dependencies` and `optionalDependencies`" (a group filter like `--dev`, not the install flag) and `-r`+names with no match is an error; `-i` updates only the selection. - Project `bunfig.toml` overrides any `.npmrc`. - `bun install <pkg> --filter x` edits `x` (not the root); `bun add y --filter x` no longer installs a package named `x`; `add`/`remove --filter '*'` no longer includes the root. - A plain `bun add x` in a workspace whose default catalog lists `x` writes `catalog:`; `audit fix` may rewrite exact pins; `--frozen-lockfile --lockfile-only` writes nothing; overrides/catalog changes fail frozen installs. - One-time lockfile churn after upgrading for projects with `catalog:` peers or dead `pkg@range` override rows; lockfiles that use nested/scoped overrides are v3 and unreadable by older Bun (only when opted in). Turborepo, Nx and Dependabot have been checked; the needed upstream changes are open (nrwl/nx#36666 covers v2 and v3; vercel/turborepo#13740 accepts v3 and preserves the object rows through prune — turborepo main today parses v2 and rejects v3; dependabot needs nothing). Note v2 itself only exists on the 1.4 line. - `bun audit --json` keeps npm's contract: `--audit-level`/`--ignore` decide the exit code, the JSON document is the full registry report (closes #31013 as won't-change). Automatic removal of stale `node_modules` entries on plain `bun install` (#32974) is separate from this PR: `bun prune` is the manual form, and dedupe / audit fix / update now clean up the nested copies they collapse on the hoisted linker; #32974 should reuse prune's planner, and #29512 (sbom) is sequenced after this so it can build on `reachable.rs` instead of carrying its own walk. - Deliberately kept where we differ from pnpm: root `bun update` covers the whole workspace; dependents whose ranges allow follow a moved version (one copy, not two); `--latest` works on transitive names; `--no-save` touches neither file; prune deletion failures exit 1; audit requests stay per-registry. #### Performance Measured on a 1,113-package Next/Prisma/MUI app (PR build vs a PR build of the merge base, interleaved, plus canary and 1.3.14): every hoisted cell is within noise except no-op install, +0.8 ms (+2%, identical syscalls); isolated no-op is +1.9 ms (+3.9%) — the deliberate cost of re-checking existing entries' links every install rather than persisting a stamp file. Everything added is otherwise off the plain-install path (gated on the feature being used or on a diff), and id-indexed sets are bitsets. ### How did you verify your code works? ~1,100 new or ported test cases across the install suites (designed behavior, cases ported from pnpm's suites, pinning tests for pnpm bugs this implementation is immune to, arborist's fixtures, and the CI review findings), all `toStrictEqual`; the whole `test/cli/install` directory passes locally and the existing suites are unchanged except where a pre-existing expectation was deliberately changed (each listed above). `bun update` was additionally verified with a rerunnable differential harness that runs pnpm 11 and this branch on 26 scenario families against one registry and diffs the resulting resolutions edge by edge — after this PR only the deliberate differences above remain. Ecosystem: Turborepo, Nx and Dependabot were checked against the new lockfile output. Co-authored work absorbed with credit: @kjanat's #38190 (alias handling, co-author on the commit), @charpeni's #31143 and @crystalin's #34407 (both superseded), and the tests of the earlier `bun update` PRs (#31752 by @zlotnika, #33127, #36381, #36729, #38224). robobun's #34688 (folder-dependency cycles; its tests are lifted, co-author on the commit) and #37289 (migrated optionalPeers; its test is lifted, co-author on the commit) were fixed independently here and are closed by this PR. #28422's quadratic scan is fixed here as well (already closed). Related but not closed — `bun prune` gives these a manual fix while the automatic-cleanup asks stay open: #8662, #26305, #29793, #21216, #16176. Also related: #10930, #26970, #26751. Fixes #1343 Fixes #3605 Fixes #14719 Fixes #24122 Fixes #18612 Fixes #20238 Fixes #25826 Fixes #23615 Fixes #26973 Fixes #20593 Closes #31013 Fixes #28959 Fixes #28402 Fixes #27897 Fixes #26675 Fixes #10949 Fixes #18504 Fixes #13388 Fixes #24523 Fixes #6608 Fixes #19059 Fixes #16569 Fixes #8262 Fixes #11901 Fixes #13469 Fixes #25202 Closes #29664 Closes #31143 Closes #34407 Closes #34688 Closes #37289 Closes #38190 --------- Co-authored-by: Kaj Kowalski <info@kajkowalski.nl> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Fixes #13388
bun updatewrites the resolved version range intopackage.json, butbun.lock's workspaces section kept a different string, so the very nextbun installrewrote the lockfile.With no package names, the lockfile kept the literal from the pre-resolution in-memory package.json: the string
latestunder--latest, the stale old range otherwise.With package names, the lockfile got
^<resolved>regardless of the user's pin level, and fornpm:aliases it dropped the alias target:And
bun update <name>@<version>did not rewrite the lockfile at all, so it kept the requested range whilepackage.jsongot the resolved one:Cause
The lockfile's root dependency literals are rewritten before the save, but only for requests in
manager.update_requests(CLI positionals), byLockfile::preprocess_update_requests. Three gaps:bun updatewith no package names tracks its targets throughmanager.updating_packagesinstead, which nothing preprocessed.preprocess_update_requestsitself hardcoded^<resolved>and ignored the alias prefix. TheTODO(dylan-conway)comment it carried described exactly this.preprocess_update_requestsonly rewrote dist-tag requests (bun update <name>, whose positional parses as<name>@latest). A request with an explicit range parses asNpmand was skipped, even thoughPackageJSONEditor::edit's post-install gate rewritespackage.jsonfor it.In each case
package.jsongot the correct pin-preserving literal from a separate computation inPackageJSONEditor, so the two files could not agree.Fix
One formatter,
format_updated_version_literalinlockfile.rs: the resolved version at the same pin level (^,~, exact) as the user's literal, with thenpm:<name>@prefix preserved for aliases. It is the previously inline block inPackageJSONEditor::edit, extracted. Both the pin and the prefix are derived from the recorded original package.json literal; the lockfile side previously read the prefix from the lockfile dependency's own literal, which a positionalbun update <name>@<version>has already replaced with the prefix-less request, so annpm:alias under that spelling still lost its prefix inbun.lock. Three paths go through the formatter:Lockfile::preprocess_updating_packages(new): the no-argument path, keyed offmanager.updating_packages, mirroringpreprocess_update_requests. It writes the literal into the lockfile's root dependency and also records it on thePackageUpdateInfoentry; the post-install package.json edit now applies that recorded literal instead of re-deriving it. It cannot re-derive it anymore, because the lockfile dependency it used to read was just rewritten (concretely, itsis_exact_npmguard evaluated on the rewritten dependency would regressbun updatewithinstall.exact = true).preprocess_update_requests(positional): whenupdating_packageshas an entry for the dependency (onlybun update <name>records one), use the shared formatter; otherwise keep saving^<resolved>, with the request'snpm:<target>@prefix when the request is an alias. A new dependency added throughbun update <name>@npm:<target>@<range>has no entry, andPackageJSONEditor::edit's matching no-entry branch keeps that prefix, so the lockfile must too, or the alias would be silently dropped from both files. The TODO comments are removed. Its request gate now mirrorsPackageJSONEditor::edit's post-install gate instead of accepting only dist-tags: a dist-tag request always rewrites, and anNpmrange does so only underbun update, which is what fixesbun update <name>@<version>. TheSubcommand::Updatecheck is load-bearing:bun add <name>@<range>goes through the same function with noupdating_packagesentry, already saves the requested range to both files, and relaxing the guard to acceptNpmunconditionally would have flipped its lockfile entry to the^<resolved>fallback.PackageJSONEditor::edit's post-install package.json write: the inline twin becomes a call to the helper. Its output is byte-identical.PackageUpdateInfogains agroup_behaviorfield so the lockfile dependency is matched by name and dependency group, not name alone: with the same name in two groups,bun updateonly moves one group in package.json, and the lockfile must leave the other group's entry alone too.Skipping
catalog:literals in the new rewrite also meansbun updatewith no arguments no longer replaces a root"catalog:"entry in package.json with the resolved range, consistent with the existing test "should support catalog versions in update".Verification
test/cli/install/bun-update.test.ts: 14 new cases. Three assert thatpackage.jsonandbun.lockagree on a plain and annpm:-aliased dependency afterbun update --latest,bun update, andbun update <names>, and that the followingbun installhas nothing left to save. Seven cover an explicit positional version:bun update <name>@<range>andbun update <name>@<exact>of a recorded dependency re-pin from the original literal,bun update <name>@<range>of an unrecorded one falls back to^<resolved>in both files, the twonpm:alias forms of that spelling for an existing alias (the fullnpm:<target>@<range>spec and a self-alias with a bare range, the one shape where the alias name itself resolves),bun update <name>@npm:<target>@<range>adding a new alias (which must keep thenpm:prefix in both files), andbun add <name>@<range>still keeps the requested range unchanged. Three are the two-group variant, and one covers a versionless scopednpm:alias. 12 of the 14 fail on bun 1.4.0; thebun addcase and the scoped-alias case are deliberate non-regression guards for the gate change and for the group-matching logic.A 15-case matrix (caret, tilde, exact, dist-tag, alias, two groups, root catalog, explicit positional version, a new alias via a positional update, each crossed with
--latest, plain, and positional) against the npm registry produces byte-identicalpackage.jsonoutput before and after the change, and agreeing lockfiles only after. A separate alias probe (bun update <alias>@npm:<target>@<range>, the self-alias form, andbun update <new-name>@npm:<target>@<range>) against the npm registry shows both files agreeing with no re-save.test/cli/install/lockfile-sync.test.ts(new): a 260-case matrix covering {bun add,bun remove,bun update,bun update --latest,bun update <names>} x {dependencies,devDependencies,optionalDependencies,peerDependencies} x {npm, npm alias, folder,link:, local tarball, remote tarball,workspace:} x {new, same, greater, lower resolution}, plus every pin stylewhich_version_is_pinneddistinguishes,install.exact, a dependency added topackage.jsonbut not yet in the lockfile, and the same name in two dependency groups across all six group pairs. Every cell asserts thatpackage.jsonandbun.lock's root workspace entry carry identical literals and that the nextbun installhas nothing left to save; the non-npm protocols are the negative contract (their literals must survive every operation untouched in both files). 99 of the 260 fail on bun 1.4.0, all of thembun updateflavors; the remaining 161 pin down thebun add,bun remove, and non-npm behavior that already held.