Skip to content

install: keep workspace and other non-registry entries as written on bun update <name> - #38847

Open
robobun wants to merge 5 commits into
mainfrom
farm/b4f39c55/update-keeps-workspace-entries
Open

install: keep workspace and other non-registry entries as written on bun update <name>#38847
robobun wants to merge 5 commits into
mainfrom
farm/b4f39c55/update-keeps-workspace-entries

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • In a workspace whose root declares "pkg1": "workspace:^" for a member, bun update pkg1 reports (no changes) but rewrites the entry to "pkg1": "workspace:*" in package.json and bun.lock. Same for workspace:~, workspace:1.2.3 and a plain range such as ^1.0.0 that links the member; same from a member that declares it, and in every workspace with -r / --filter.
  • With --latest or an explicit spec (bun update pkg1 --latest, pkg1@^1.0.0, pkg1@latest) the entry is first replaced by latest / the spec, so the install asks the registry for the member's name. The same happens to every other non-registry entry: with "no-deps": "file:./local-no-deps" and a registry that has no-deps, bun update no-deps --latest exits 0 and leaves "no-deps": "^2.0.0" (the registry package) in package.json and bun.lock; no-deps@^1.0.0 leaves ^1.1.0, no-deps@latest leaves latest.
  • In a root that does not declare the member, bun update pkg1 adds "pkg1": "workspace:*" to the root's dependencies; a named update is documented never to add.
  • Causes, all in the named-update path (PackageJSONEditor::edit, which runs once before and once after the install):
    • The literal check at the top of the write-back loop only exempted catalog: entries. Every other kind went on to the rewrite before the install (latest / the explicit spec) and, after it, to the unconditional resolution::Tag::Workspace => b"workspace:*" arm, which is the value bun add <member>@workspace:* needs for the entry it just created.
    • Lockfile::bind_update_requests attached a request to the first row of the workspace with that name. The root's rows begin with one implicit row per workspace member (Package.rs parses the workspaces list as a dependency group ahead of dependencies), so on the root a member's name was always bound, whether or not the root declared it: that is what let the undeclared name past the editor's never-add check (package_id == INVALID_PACKAGE_ID), and for a declared entry it keyed the write-back on the member row instead of the entry's own resolution.

Fix

  • The literal check now uses the predicate the bare update already applies (edit_update_entries: only Npm and DistTag literals move): under bun update, an entry whose literal is not a registry range or tag is skipped in both passes. That covers catalog: as before plus workspace:, file: folders and tarballs, git and link: entries; bun add is unchanged. This is the rule bun update already follows for patterns (update_scope.rs skips rows resolving to a workspace) and that bun-add-catalog.test.ts pins for catalog: entries with an explicit spec; pnpm and yarn leave such entries alone on update as well. A named update still re-resolves the entry (a git entry follows its branch); only package.json is left as written.
  • The write-back arm for a resolution of kind Workspace is skipped under bun update (continue), which is what keeps a plain range or dist-tag that happened to link a member ("pkg1": "^1.0.0") as written; bun add still takes the workspace:* arm.
  • bind_update_requests skips rows with the WORKSPACE behavior, so a request is bound to the package.json entry that names it, as the tree printer already does when it decides what to print (should_print_package_install). An undeclared member stays unbound and the existing never-add check holds. The other case where the member row and the entry resolve differently (a dist-tag the registry satisfies on a member's name) does not install on main at all (DependencyLoop, noted on install: fix npm dependencies that share a workspace member's name #37248), so today this hunk is only observable as the spurious add.
  • Overlap with install: keep git, folder, tarball and workspace entries as written on bun update <name> #38827: that PR guards the --latest rewrite in this same function with is_npm(). With this change that line is no longer reached for a non-registry entry on the named path, and its tests pass either way; a note is left there.
  • Tests, all in test/cli/install/bun-update-lockfile-sync.test.ts: workspace:^ / workspace:~ / workspace:1.0.0 / ^1.0.0 with bun update pkg1; workspace:^ with pkg1 --latest, pkg1@^1.0.0, pkg1@latest; a file: entry whose name the registry has, with no-deps, no-deps --latest, no-deps@^1.0.0, no-deps@latest; from a member that declares it; -r; and the undeclared root. Each asserts package.json and bun.lock byte-identical to what bun install wrote (so a frozen reinstall would add nothing and is not run). With the two source files at their main versions, 13 of the 14 fail (rewrite, registry 404, ^2.0.0 / ^1.1.0 / latest written, or the added entry); the plain file: case already passed and guards the new route. All pass with the fix.
  • Also run with the debug build: bun-update-lockfile-sync, bun-update, bun-update-transitive, bun-add, bun-add-filter, bun-add-catalog, bun-workspaces, catalogs, lockfile-only, update_interactive_install and the update block of bun-install-registry, all passing; cargo clippy -p bun_install is clean.

Background

  • bun add / bun update <name> edit package.json in two passes around the install: edit() runs before it to put the literal to resolve into the entry (for update: the existing one, latest under --latest, or the spec given on the command line), and again after it to replace that with the resolved version in the declared style.
  • dependency::Tag::infer classifies a version literal by its text: ^1.0.0 and latest are Npm / DistTag (is_npm()), while workspace:^, catalog:, file:..., a git URL or link: each get their own tag. It is what the bare bun update uses to decide which entries it may rewrite.
  • An UpdateRequest is one command-line positional. After resolution, Lockfile::bind_update_requests attaches it to a dependency row of the current workspace by name, which sets package_id (the package that row resolved to); the second pass switches on that package's resolution. e_string points at the package.json value the first pass found for the name; a request without one is a name the file does not declare.
  • The root package's dependency rows contain one row per workspace member (behavior WORKSPACE, literal = the member's path) ahead of the root's own entries. They are what links members into node_modules; they do not correspond to anything in the root's package.json.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f206790a-894a-4733-8baa-8bcb0fb069bd

📥 Commits

Reviewing files that changed from the base of the PR and between 39fb3c1 and 8b08ff8.

📒 Files selected for processing (3)
  • src/install/PackageManager/PackageJSONEditor.rs
  • src/install/lockfile.rs
  • test/cli/install/bun-update-lockfile-sync.test.ts

Walkthrough

bun update now preserves workspace: and catalog: dependency references during manifest and lockfile updates. Regression tests cover named, recursive, --latest, explicit-range, and workspace-member updates.

Changes

Workspace dependency update preservation

Layer / File(s) Summary
Dependency rewrite rules
src/install/PackageManager/PackageJSONEditor.rs
Named updates preserve existing catalog: and workspace: literals. Non-update operations retain the existing workspace:* replacement behavior.
Lockfile binding and regression coverage
src/install/lockfile.rs, test/cli/install/bun-update-lockfile-sync.test.ts
Workspace dependency rows are excluded when binding update requests. Tests verify unchanged manifests and lockfiles across workspace update scenarios.

Possibly related PRs

  • oven-sh/bun#32810: Related PackageJSONEditor.rs and lockfile.rs changes for preserving special dependency references during bun update.
  • oven-sh/bun#36360: Related workspace and catalog reference handling in update workflows.
  • oven-sh/bun#38333: Related workspace dependency update handling in PackageJSONEditor.rs and lockfile.rs.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: preserving workspace and other non-registry entries during named updates.
Description check ✅ Passed The description clearly explains the problem, fix, scope, and verification results, although it uses custom headings instead of the template headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on a debug build of main with a root declaring "pkg1": "workspace:^" for a packages/pkg1 member: bun update pkg1 printed (no changes) and left "pkg1": "workspace:*" in package.json and bun.lock; bun update pkg1 --latest / pkg1@^1.0.0 / pkg1@latest sent the name to the registry; with the root not declaring pkg1, bun update pkg1 added "pkg1": "workspace:*"; and a "no-deps": "file:./local-no-deps" entry became ^2.0.0 / ^1.1.0 / latest under the same three forms. All of these are cases in test/cli/install/bun-update-lockfile-sync.test.ts that fail with the two source files at their main versions and pass with this branch (head c826a98, rebased on main).

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun update <package> ignores catalog and creates a spurious root dependency at latest #32808 - This PR's unconditional remaining = 0 guard on the Subcommand::Update path stops bun update <name> from appending a spurious root dependencies entry, which is the second half of that issue. Partial fix only — the primary ask (updating catalogs.<name> in place) is not addressed here and is sibling PR install: make bun update <name> update catalog entries instead of adding a root dependency #32810's scope.

If this is helpful, copy the block below into the PR description to auto-close these issues on merge. Since #32808 is only partially addressed, Ref #32808 may be more accurate than Fixes.

Fixes #32808

🤖 Generated with Claude Code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Not linking #32808 from this PR. Its scenario (bun update <name> from the root for a name that only exists in a catalog) never reaches the code changed here: on current main the root has no dependency row for such a name, so the request is rejected before the editor runs (error: "no-deps" is not a dependency of this workspace, exit 1, nothing written; checked with this branch's build). The remaining = 0 change only matters for names that are bound to one of the root's implicit workspace member rows, which is the case this PR is about. Updating the catalog entry itself, the main ask of that issue, is unrelated to this change.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. install: keep git, folder, tarball and workspace entries as written on bun update <name> #38827 - Same file, same PackageJSONEditor::edit function and same test file, stopping bun update <name> from rewriting workspace/non-registry entries — just on the --latest pre-install pass instead of the post-install write-back.
  2. install: make bun update <name> update catalog entries instead of adding a root dependency #32810 - Competing fix for the same never-add accounting in edit: it decrements remaining per request to stop a named bun update appending an undeclared root dependency, where this PR forces remaining = 0.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Neither is a duplicate:

Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/lockfile.rs Outdated
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in 8b08ff8 (comments shortened in 89d1bf3) after the review finding that the pass before the install still rewrote a workspace: entry to latest or to an explicit spec, which the new continue would then have left in place. Both passes now skip workspace: entries through the same literal check that already keeps catalog: references, so bun update pkg1 --latest, bun update pkg1@^1.0.0 and bun update pkg1@latest leave "pkg1": "workspace:^" untouched; those three are now test cases (against main they fail with a registry 404 or with the entry rewritten). On the @<range> variant with the member's version satisfying the range, the description in the review was right as well; it is covered by the same check.

Two other changes in the same push: bind_update_requests no longer binds a request to the root's implicit workspace member row, which is what made the undeclared name pass the never-add check, so the earlier remaining = 0 rewrite is reverted and that block is back to what main has. The PR description is updated for the current shape.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the concern from my earlier review is addressed: both editor passes now skip workspace: literals via the same Tag::infer check that already guards catalog:, so --latest / @<spec> no longer rewrite the entry before the after-install continue fires.

Checked that bun add <member>@workspace:* still reaches the b"workspace:*" arm (subcommand guard), and that skipping behavior.is_workspace() rows in bind_update_requests still binds bun add to the newly-created dependencies row after the before-install reparse. The skip mirrors tree_printer.rs:214 and dedupe.rs:589 for the same implicit rows.

Extended reasoning...

Overview

Three small changes to the named-update path of the package manager: (1) PackageJSONEditor::edit extends the existing catalog: literal skip to also match workspace: via matches!(Tag::infer(...), Catalog | Workspace), so neither the before-install nor after-install pass rewrites a workspace: entry under bun update <name>; (2) a new resolution::Tag::Workspace if subcommand == Update => continue arm before the unconditional b"workspace:*" write-back, which keeps a plain range that linked a member (e.g. ^1.0.0) as written while leaving bun add on the existing arm; (3) Lockfile::bind_update_requests skips rows with behavior.is_workspace() — the root's implicit workspaces-glob rows — so a request binds to the actual package.json entry (or stays unbound if the root does not declare the member, which the existing never-add check then honors). Ten test cases cover workspace:^/~/1.0.0/plain range, --latest, @<range>, @<tag>, from a member, -r, and the undeclared root.

Security risks

None. This is package.json/bun.lock editing logic for bun update <name>; no untrusted input parsing, network, or filesystem-path handling changes.

Level of scrutiny

Moderate — bun install/update is user-facing and the edit() two-pass state machine is intricate, but each change here is a narrow continue that follows the exact shape already used for catalog: in the same function, and the bind_update_requests skip mirrors tree_printer.rs:214 for the same implicit rows. The subcommand guards keep bun add on its existing paths.

Other factors

My earlier review on the first revision flagged that the after-install continue alone would leave a --latest/@<spec>-rewritten literal in place; 8b08ff8 addressed that by moving workspace: into the pre-existing literal check that runs before the unresolved branch, and added the three explicit-spec cases to the test matrix. I traced bun add pkg1@workspace:* through the new code: after the before-install pass creates the dependencies row and reparses, bind_update_requests matches that row (its behavior is not WORKSPACE), resolution.tag == Workspace with subcommand == Add falls through to b"workspace:*" — the existing test for that command (bun add <workspace>@workspace:*) is in the same file. The comment-cop feedback on comment length was resolved in 89d1bf3. The PR description reports the wider install/add/update/workspace suites passing on the debug build.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Aug 14th, 2026

@robobun, your commit 89d1bf3 is building: #97339

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 AM PT - Aug 15th, 2026

@robobun, your commit 2776a09 has some failures in Build #97936 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38847

That installs a local version of the PR into your bun-38847 executable, so you can run:

bun-38847 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up on an overlap: #38827 changes the same per-entry check in PackageJSONEditor::edit (the catalog: one that runs in both passes), widening it to !Tag::infer(entry).is_npm() so that git, folder, tarball and workspace: entries are all kept as written under a named update. That covers the workspace: literal cases here (workspace:^ / ~ / 1.0.0, with and without --latest or an explicit spec), so whichever PR lands second will conflict on that one hunk and can drop or fold its version of it. The other two parts of this PR, the continue for a Workspace resolution in the write-back (a plain range that links a member) and the bind_update_requests skip of the root's implicit member rows, are not touched by #38827 and are still needed, as are the tests here.

The named update path wrote "workspace:*" over any entry that resolved to
a workspace member ("workspace:^", "workspace:~", "workspace:1.2.3", a
plain range linked to the member), and added such an entry to a root that
did not declare the member, because the root's dependency rows include one
row per workspace member and the never-add check was keyed on whether the
request had been bound to a row. Under update, skip the write-back for
workspace resolutions and never create a slot; bun add still writes
"workspace:*".
…d requests to the declared row

The pass that runs before the install rewrote a workspace: entry to
"latest" (--latest) or to an explicit <name>@<range>, so skipping it only
after the install was not enough. Extend the literal check that already
keeps catalog: references to workspace: ranges, which covers both passes.

bind_update_requests now skips the root's implicit workspaces rows, so a
request is bound to the package.json entry that names it: an undeclared
member stays unbound and is not added, and the write-back is keyed on the
entry's own resolution rather than the member's. The earlier remaining
rewrite is no longer needed and is reverted.
Use the predicate the bare update already applies (Npm or DistTag
literals move, nothing else) instead of listing catalog: and workspace:,
so a folder, tarball or git entry named on the command line is no longer
replaced by the registry package under --latest or an explicit spec.
@robobun
robobun force-pushed the farm/b4f39c55/update-keeps-workspace-entries branch from 89d1bf3 to c826a98 Compare August 15, 2026 10:34
@robobun robobun changed the title install: keep workspace member entries as written on bun update <name> install: keep workspace and other non-registry entries as written on bun update <name> Aug 15, 2026
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c826a98 (rebased on current main). Following up on the self-review: the literal check that exempted catalog: and, as of the previous push, workspace: entries now uses the predicate the bare update already applies (!Tag::infer(...).is_npm()), so file:, tarball, git and link: entries named on the command line are kept as written too. On main, with a registry that has no-deps, "no-deps": "file:./local-no-deps" became ^2.0.0 under bun update no-deps --latest, ^1.1.0 under no-deps@^1.0.0 and latest under no-deps@latest, all with exit 0; those three plus the plain form are new cases. The bind_update_requests and resolution-arm changes are unchanged; description updated.

Both files are asserted identical to what bun install just wrote, so the
reinstall cannot add anything; it only adds a process per case.
Comment on lines +1454 to +1455
// A range that linked a workspace member has nothing to move to; `workspace:*` is what `bun add` writes.
resolution::Tag::Workspace if manager.subcommand == Subcommand::Update => continue,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The Workspace if Update => continue arm relies on the before-install pass leaving the entry byte-identical, but for a plain range like "pkg1": "^1.0.0" + bun update pkg1@1.0.0 the !is_npm() skip does not fire and the before-install pass rewrites to "1.0.0", so the ^ is dropped on disk — the registry path restores it via updating_packages[name].original_version_literal, this arm does not. On main the same input wrote workspace:* (also not kept-as-written), so this is a lateral change on a narrow input rather than a regression; the test.each covers ["^1.0.0", ["pkg1"]] and ["workspace:^", ["pkg1@^1.0.0"]] but not the cross-product ["^1.0.0", ["pkg1@1.0.0"]].

Extended reasoning...

What the bug is

The new after-install arm

resolution::Tag::Workspace if manager.subcommand == Subcommand::Update => continue,

assumes the before-install pass left the entry untouched. That now holds for workspace: / file: / catalog: literals via the widened !dependency::Tag::infer(e_string.data.slice()).is_npm() skip, but not for a plain npm range that links a member. With root "pkg1": "^1.0.0" (member pkg1 at 1.0.0) and bun update pkg1@1.0.0:

  • Before-install: Tag::infer("^1.0.0") == Npm, so is_npm() is true and the !is_npm() skip does not fire. In the unresolved branch, request.version.tag == Npm (the positional pkg1@1.0.0 parses as an npm range), so the Some(existing) if request.version.tag != Npm && !explicit_dist_tag guard is bypassed and version_literal = requested = "1.0.0". with_alias_of("^1.0.0", "1.0.0") returns "1.0.0" (split_npm_alias("^1.0.0") is None). e_string.data becomes "1.0.0". Meanwhile the 'add_packages_to_update block does capture updating_packages["pkg1"].original_version_literal = "^1.0.0" (the tag check there is == Npm, which passes).
  • Install: "1.0.0" is satisfied by the member's version, so the row resolves to the workspace member. bind_update_requests (with the PR's new is_workspace() skip) binds the request to the root's dependencies row, whose resolved package's resolution.tag == Workspace.
  • After-install: the scan re-binds e_string to the "1.0.0" node. Tag::infer("1.0.0").is_npm() is true, so the skip again does not fire. resolutions[package_id].tag == Workspace and subcommand == Update → the new arm fires and continues, leaving "1.0.0" in place.

Result: "pkg1": "^1.0.0""pkg1": "1.0.0" in package.json and bun.lock. The declared ^ operator is dropped.

Why the existing code does not prevent it

The !is_npm() skip is the fix for the previous review round's finding (the workspace:^ + @<spec> case), and it works for every literal whose Tag::infer is not Npm / DistTag — but a plain range is exactly Npm, so it falls through. The Some(existing) if tag != Npm && !explicit_dist_tag guard keeps the existing literal only for a bare bun update pkg1 (which parses as an empty DistTag), which is why the tested ["^1.0.0", ["pkg1"]] case passes. An explicit @<npm-range> sets request.version.tag == Npm and takes the requested literal.

Inconsistency with the registry path

For the equivalent registry input — "no-deps": "^1.0.0" + bun update no-deps@1.0.1 — the resolution::Tag::Npm arm consults manager.updating_packages.get(request.name) and calls updated_version_literal(&entry.original_version_literal, resolved, …), producing "^1.0.1" (the existing test "bun update <name>@<version> -r keeps each workspace's operator" asserts exactly this). The Workspace arm never reads updating_packages, so original_version_literal = "^1.0.0" — which is populated — is ignored.

Impact

  • On main the same input wrote "workspace:*" — also not kept as written, but at least always linking the member. With this PR the entry becomes an exact/narrower npm range, which stops matching the member the next time its version is bumped past it. So on this specific narrow input the outcome is arguably worse than main's, but it is not a regression of previously-correct behavior — main was differently broken here.
  • The scenario is narrow: it requires (a) a plain npm range declaring a workspace member (uncommon vs. workspace:), (b) an explicit @<npm-range> on that member's name, and (c) the member's version satisfying that range. The --latest and @latest variants of the plain-range case rewrite to "latest" before install, which then fails (registry 404 or DependencyLoop per install: fix npm dependencies that share a workspace member's name #37248) and never reaches disk, so only @<npm-range> with a satisfied range is observable.
  • The user did explicitly type @1.0.0, so writing "1.0.0" is defensible from their perspective — it is just inconsistent with how the same command treats registry packages.

Test gap

The PR's test.each covers ["^1.0.0", ["pkg1"]] (bare) and ["workspace:^", ["pkg1@^1.0.0"]] / ["workspace:^", ["pkg1@latest"]] (explicit spec on a workspace: literal), but not the cross-product ["^1.0.0", ["pkg1@1.0.0"]] — the one variant where the plain-range literal meets an explicit @<spec>. Per REVIEW.md's "Cover the variant matrix, not just the repro", that row belongs in the table.

How to fix

Have the Workspace if Update arm restore updating_packages[name].original_version_literal when present instead of a bare continue, mirroring how the Npm arm uses that map:

resolution::Tag::Workspace if manager.subcommand == Subcommand::Update => {
    if let Some(entry) = manager.updating_packages.get(request.name) {
        arena_dup(arena, &entry.original_version_literal)
    } else {
        continue;
    }
}

Alternatively, also skip the before-install rewrite when the entry's existing lockfile row resolves to a workspace — but that requires the lockfile in the before-install pass, which currently only sees resolutions == &[].

Step-by-step proof

  1. Root package.json: {"name": "root", "workspaces": ["packages/*"], "dependencies": {"pkg1": "^1.0.0"}}; packages/pkg1/package.json: {"name": "pkg1", "version": "1.0.0"}. bun install.
  2. bun update pkg1@1.0.0.
  3. Before-install edit(): scan sets request.e_string → the "^1.0.0" node. Tag::infer("^1.0.0") = Npm (discriminant < 3), is_npm() = true → skip does not fire. Unresolved branch: existing = Some("^1.0.0"), requested = "1.0.0", request.version.tag == Npm, explicit_dist_tag = false → guard tag != Npm && !explicit_dist_tag is falseversion_literal = "1.0.0". with_alias_of("^1.0.0", "1.0.0") = "1.0.0". e_string.data = "1.0.0".
  4. Install: root's "pkg1": "1.0.0" matches member pkg1@1.0.0 → linked as workspace. bind_update_requests: implicit WORKSPACE row skipped by the PR's new dep.behavior.is_workspace() → continue; the dependencies row matches → request.package_id = member; resolutions[member].tag == Workspace.
  5. After-install edit(): e_string"1.0.0". Tag::infer("1.0.0").is_npm() = true → skip does not fire. Match on resolutions[package_id].tag: Workspace and subcommand == Updatecontinue. e_string.data stays "1.0.0".
  6. flush sees "1.0.0" != "^1.0.0" on disk → writes "pkg1": "1.0.0" to package.json and bun.lock.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Since 2776a09 this PR carries the same !is_npm() widening that #38827 had, plus the two workspace-specific hunks, so it now fixes everything #38827 was opened for. To avoid two PRs with the same hunk, I am closing #38827 in favour of this one.

Coverage that #38827 had and this PR does not yet, in case you want to fold it in (all of it is in c2f2216, same test file, and fails with the editor from main):

  • a git entry named after a registry package (gitRepo() / setupNonRegistryDeps() helpers: "no-deps": "git+file://..." while the registry has no-deps@2.0.0), which is the shape the original report used, plus a local tarball entry (file:./tgz-dep-1.0.0.tgz)
  • the bun update "*" --latest route (patterns expand to the same named path)
  • bun update no-deps -r --latest with the root declaring no-deps from git and a member declaring it from the registry: the member must still move to ~2.0.0 while the root entry stays, which guards against the check suppressing too much on the filtered path
  • a dep-with-tags: "pre-2" entry in the same fixture as a positive control that --latest still rewrites dist-tags, and expectInSync (--frozen-lockfile round-trip) after each case
  • one docs line in docs/pm/cli/update.mdx under "How package.json is rewritten", next to the catalog: bullet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant