Skip to content

install: keep the workspace: spelling given to bun add <member>@workspace:<range> - #38866

Open
robobun wants to merge 6 commits into
mainfrom
farm/1d5b456d/add-keeps-workspace-spelling
Open

install: keep the workspace: spelling given to bun add <member>@workspace:<range>#38866
robobun wants to merge 6 commits into
mainfrom
farm/1d5b456d/add-keeps-workspace-spelling

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • In a workspace, bun add pkg1@workspace:^ (pkg1 being a member) installs fine ("installed pkg1@workspace:packages/pkg1") but writes "pkg1": "workspace:*" into package.json, and from there into bun.lock. Same for workspace:~, workspace:1.0.0, workspace:^1.0.0; from the root, from another member, and with --filter. The spelling matters: bun pm pack / bun publish turn workspace:^ into ^1.0.0, workspace:~ into ~1.0.0 and workspace:* into 1.0.0 (docs/pm/workspaces.mdx), so the published range silently changes. pnpm and yarn keep the spelling as given. Found while checking pnpm parity; no issue is filed for it.
  • Cause: the write-back PackageJSONEditor::edit runs after the install (src/install/PackageManager/PackageJSONEditor.rs, the match on the resolved package's resolution tag) has an unconditional resolution::Tag::Workspace => b"workspace:*" arm. It dates from fix(install): workspace version added to package.json #11241, which replaced the member's path that the generic arm used to write; at that point the arm had nothing better to write, because neither place that once held the typed spelling still does:
    • request.version.literal is the literal of the lockfile row the request was bound to, not the request. Between the two passes Lockfile::bind_update_requests binds the request to the first matching row of the workspace being edited. On the root that is today the implicit row created for each member of the workspaces list (literal packages/pkg1). And when the entry already existed, it is the row of the lockfile the install started with: dependency::Version::eql compares two workspace rows by the path they resolve to, so changing workspace:* (or a plain 1.0.0 that linked the member) to workspace:~ is no diff, the old rows are kept, and the bound literal is the previous spelling. sync_lockfile corrects the rows from package.json only after this write-back has run.
    • The entry itself held it (the before-install pass wrote workspace:^ there), but the after-install pass counts a bound request whose name already sits in the target group as replacing and rebuilds that property with an empty value (the // we set it later slot) before the match runs.

Fix

  • The rebuilt property starts out holding the literal the entry declared (after the install, that is what the before-install pass wrote, i.e. the request as typed) instead of "". Every arm of the write-back overwrites it except the new case below, so on its own this changes nothing; it only makes the declared text reachable where the decision is made. An entry that lives in another dependency group is edited in place and already held it.
  • The workspace arm keeps the entry when its text uses the workspace: protocol (dependency::Tag::infer(...) == Workspace) and writes workspace:* otherwise.
  • Why the entry's text and not the bound row: it is the only value that is the request as typed in every case above (fresh entry, entry in another group, entry that previously resolved to the same member, root or member, --filter). It is also safe to keep as is: the root's package.json containing exactly this literal is what the install just parsed and resolved. An arm reading request.version.literal instead was tried (with the binding fix from install: keep workspace and other non-registry entries as written on bun update <name> #38847 applied) and fails the two existing-entry tests below, writing the previous spelling back.
  • Why workspace:* stays the fallback: bun add pkg1@1.0.0, bun add pkg1@^1.0.0, a folder path, or a bare name the registry cannot satisfy all link the member without saying so; their entry text is a range or a path, which infer does not classify as Workspace, so they are saved exactly as before. bun add pkg1@workspace:* still produces workspace:*.
  • install: keep workspace and other non-registry entries as written on bun update <name> #38847 (open) is the bun update <member> side of the same arm (skips the write-back under Subcommand::Update, and stops binding to the root's implicit rows); this PR is the bun add side. The two touch adjacent lines of the match and compose in either order; this change does not depend on the binding fix. Until install: keep workspace and other non-registry entries as written on bun update <name> #38847 lands, bun update <member> on a workspace: entry incidentally keeps it too, which is the behavior install: keep workspace and other non-registry entries as written on bun update <name> #38847 tests.
  • Tests, in the bun add block of test/cli/install/bun-update-lockfile-sync.test.ts: workspace:^ / ~ / 1.0.0 / ^1.0.0 from the root, -d without a lockfile, from another member, replacing an existing workspace:* entry and an existing 1.0.0 entry (both keep bun.lock's old row through the install), an entry listed in devDependencies (the edit-in-place path), --filter targeting a member and the root, plus pkg1@1.0.0 and pkg1@^1.0.0 pinning workspace:*. The ten spelling tests fail on the unfixed build (package.json has workspace:*) and pass with the fix; each asserts package.json and bun.lock and reinstalls with --frozen-lockfile.
  • Also run with the debug build: bun-update-lockfile-sync, bun-add, bun-add-filter, bun-add-catalog, bun-workspaces, bun-update, catalogs, all passing. cargo clippy -p bun_install is clean.

Background

  • bun add edits package.json in two passes around the install: edit() runs before it to put the requested literal in place (so the install resolves it) and again afterwards to replace that literal with what should be saved (^1.2.3 for a dist-tag, the literal itself for git/folder/tarball, workspace:* for a workspace member). The second pass is where this change lives.
  • An UpdateRequest is one CLI positional. After resolution, bind_update_requests attaches it to a dependency row of the workspace being edited, setting package_id (what it resolved to) and version (that row's literal and tag).
  • When a lockfile exists, the install diffs the freshly parsed root against the lockfile's root and, if nothing differs, keeps the lockfile's rows as they are. Two workspace rows differ only if they resolve to different members, so a respelled entry is "no diff".
  • e_string is the request's pointer at the string node of its package.json entry. In the after-install pass an entry already in the target group is rebuilt (re-keyed with the resolved name) and e_string points at the rebuilt node; an entry found in another group is pointed at directly.
  • dependency::Tag::infer classifies a version literal by its text; any workspace: prefix is Tag::Workspace, a range like 1.0.0 is Npm, ./x is Folder, an empty string is DistTag.

…pace:<range>

bun add pkg1@workspace:^ (or ~, or a version) wrote "workspace:*" into
package.json and bun.lock. The write-back that runs after the install
replaces the entry's value before deciding what to save, and the request
itself is bound to the root's implicit row for the member, so the typed
spelling was gone by the time the workspace arm ran. Carry the declared
literal into the rebuilt entry and keep it when it uses the workspace:
protocol; anything else that linked a member still saves workspace:*.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2c4dd196-81b5-453d-a83f-614dad263850

📥 Commits

Reviewing files that changed from the base of the PR and between 0def731 and 1c30726.

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

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The change is complete as of 4f1e346 (1c30726 is an empty re-run commit); no further pushes are planned. CI on that head (build 97849) has every lane that ran passing; the two macOS 14 aarch64 test lanes have been sitting unscheduled on every build today, not just this one.

Reproduced on a debug build of main (39fb3c1): root {"workspaces":["packages/*"]} with member packages/pkg1, then bun add pkg1@workspace:^ writes "pkg1": "workspace:*" into package.json and bun.lock; same for workspace:~, workspace:1.0.0, from another member, and with --filter. The ten spelling tests in test/cli/install/bun-update-lockfile-sync.test.ts fail on that build and pass with this branch.

Shape note: a version of the fix that reads the bound lockfile row's literal (on top of #38847's binding fix) was tried and fails the two "replaces an existing entry" tests, because a respelled entry that resolves to the same member is no diff to the install and the old row is kept; hence the fix reads the entry's own text. Details in the PR body.

Related: #38847 covers the bun update <member> side of the same match arm; this PR is the bun add side only and does not depend on it.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. install: keep workspace and other non-registry entries as written on bun update <name> #38847 - Modifies the exact same resolution::Tag::Workspace => b"workspace:*" write-back arm in PackageJSONEditor::edit for the same bug (a workspace: entry being clobbered to workspace:*), just on the bun update <member> path instead of bun add, and adds its tests to the same test/cli/install/bun-update-lockfile-sync.test.ts.
  2. install: keep git, folder, tarball and workspace entries as written on bun update <name> #38827 - Same file and function, and the same "keep the declared literal instead of rewriting it, decided via dependency::Tag::infer on the entry's text" fix — applied to the pre-install --latest rewrite for git/folder/tarball/workspace entries rather than the post-install workspace write-back.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of either, though both are neighbors in the same function:

The nine spelling tests added here fail on main and would fail the same way with either of those PRs applied.

@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 — narrow, well-reasoned fix with thorough test coverage.

Checked that seeding declared from the existing property value only reaches the resolution::Tag::Workspace arm: the before-install pass never has replacing entries (package_id == INVALID_PACKAGE_ID), Subcommand::Update never counts entries as replacing, and every other match arm (Npm, catch-all) plus the uninitialized branch overwrite the value without reading it. Also confirmed edit()'s changed return is discarded by add_catalog::edit_target, so the case where the workspace literal is kept in place (comparison at the end sees equal slices) doesn't skip a needed write. dependency::Tag::infer returns Tag::Workspace for any workspace: prefix (dependency.rs:1044), and the negative-case tests pin 1.0.0/^1.0.0 still saving workspace:*.

Extended reasoning...

Overview

Two changes in PackageJSONEditor::edit (src/install/PackageManager/PackageJSONEditor.rs): (1) when rebuilding a replaced dependency entry, seed the new value node with the entry's declared literal (arena-duped) instead of b""; (2) the resolution::Tag::Workspace write-back arm now keeps the entry text when dependency::Tag::infer classifies it as Workspace, otherwise falls back to workspace:*. Eleven new tests in test/cli/install/bun-update-lockfile-sync.test.ts cover workspace:^/~/1.0.0/^1.0.0 from the root, from a member, with --filter, with -d and no lockfile, replacing an existing entry in the target group, editing in place in devDependencies, plus the negative case that a plain semver range still saves workspace:*.

Security risks

None. This is package.json string formatting for workspace references; no untrusted input parsing, no filesystem/network changes beyond what bun add already does.

Level of scrutiny

Medium. edit() is on the bun add/update/link write-back path and mistakes surface directly in user package.json files. I traced whether change (1) could leak into any other arm: it cannot, because replacing is only incremented when package_id != INVALID_PACKAGE_ID && subcommand != Update, i.e., only in the after-install pass for add/link, where the match then runs and every non-Workspace arm computes new_literal without reading e_string.data. The before-install uninitialized branch reads e_string only under Subcommand::Update, which never rebuilds. I also checked that the changed bool going false when the literal is kept in place is harmless — the sole caller add_catalog::edit_target discards it and returns Result<(), _>.

Other factors

The PR description explains why request.version.literal cannot be used directly (bind_update_requests rebinds it to the member's path on the root) and why the entry text is the right source. The arena_dup on declared matches the file's convention for every other E::EString::init site. Tests assert both package.json and bun.lock and each finishes with a --frozen-lockfile reinstall, so a saved literal that the next install would reject would fail the test. The description reports the nine spelling tests fail on the unfixed build and that six related suites pass with the debug build. This composes cleanly with the open #38847 (the bun update side) since both branch on distinct predicates inside the same arm.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:53 AM PT - Aug 15th, 2026

@robobun, your commit 1c30726 is building: #97849

Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated

@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 — focused fix that preserves the user's workspace: spelling in the after-install write-back, with thorough test coverage.

What was reviewed:

  • Traced the declared seeding through every write-back arm — confirmed it's only non-empty on the after-install "replacing" path, and every arm other than Workspace overwrites (or byte-equals) it, so no side effects on npm/git/tarball entries.
  • Checked dependency::Tag::infer — any workspace: prefix classifies as Workspace; ranges, paths, and empty strings hit the workspace:* fallback, preserving prior behavior (covered by the two negative tests).
  • Verified the changed flag is still set via remaining != 0 even when the self-compare skips the write; arena_dup keeps the captured literal on the process-lifetime arena.
  • Tests cover the variant matrix: root/member/--filter, -d, replacing an existing entry, devDependencies edit-in-place, plus --frozen-lockfile round-trip.
Extended reasoning...

Overview

Two-file change: src/install/PackageManager/PackageJSONEditor.rs (~15 lines in edit()) and eleven new tests in test/cli/install/bun-update-lockfile-sync.test.ts. The fix makes bun add <member>@workspace:<range> write the range as typed (workspace:^, workspace:~, workspace:1.0.0) instead of unconditionally saving workspace:*. It does this by (a) seeding the rebuilt property with the declared literal instead of "" so the value survives to the write-back, and (b) branching the resolution::Tag::Workspace arm on dependency::Tag::infer(e_string.data.slice()) — keep if already workspace:-prefixed, else workspace:*.

Security risks

None. This is the package.json write-back for bun add; the input is a CLI positional the user typed, and the install has already resolved it. No parsing of untrusted network data, no path handling, no auth.

Level of scrutiny

Medium — package manager write-back touches user files, but the change is small and mechanically verifiable. I traced the declared capture through all four write-back arms (unresolved/before-install, Npm, Workspace, catch-all) and confirmed the PR's claim that every arm except the new Workspace-keep case overwrites it: the before-install branch always reaches the if e_string.data.slice() != version_literal write (and declared is only non-empty in the after-install "replacing" path anyway, since replacing requires package_id != INVALID); the Npm and catch-all arms compute a fresh literal and byte-compare. The changed return value is already forced true by remaining != 0 before the rebuild loop, so the skipped self-compare in the keep case doesn't lose the write signal. arena_dup copies the Store-backed literal into the process-lifetime arena, so no lifetime issue.

Other factors

Test coverage is strong: nine spelling tests across root/member/--filter/-d/devDependencies/replacing, each asserting package.json + bun.lock + --frozen-lockfile reinstall, plus two negative tests (pkg1@1.0.0 and pkg1@^1.0.0 still save workspace:*). The author confirmed the nine tests fail on main and ran the adjacent suites (bun-add, bun-add-filter, bun-add-catalog, bun-workspaces, bun-update, catalogs). The comment-cop bot flagged earlier long comments; the author shortened them to one-liners in the final two commits. No CODEOWNERS coverage on src/install/. Composes with the open #38847 (bun update side of the same arm) — that PR guards on Subcommand::Update and would match before this arm.

@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 — targeted fix that preserves the user's workspace: spelling in bun add, falling back to the old workspace:* for non-workspace literals.

What was reviewed:

  • Traced the declared initialization: only the after-install replacing rebuild path sees a non-empty value (before-install has package_id == INVALID so replacing = 0); every other write-back arm overwrites it, so it's inert outside the workspace arm.
  • Verified dependency::Tag::infer returns Workspace for any workspace: prefix and Npm/Folder for the fallback cases the tests pin.
  • Checked the changed return value is discarded at every call site (add_catalog::edit_target), so the no-op comparison when the literal is kept doesn't skip a write.
  • Tests cover the variant matrix (four spellings, root/member/--filter, fresh/existing entry, same/other group, no-lockfile) and each reinstalls with --frozen-lockfile.
Extended reasoning...

Overview

Two hunks in src/install/PackageManager/PackageJSONEditor.rs::edit: (1) the rebuilt property slot for a replacing entry now starts out holding the entry's previous literal (arena-duped) instead of "", and (2) the resolution::Tag::Workspace write-back arm keeps e_string.data.slice() when dependency::Tag::infer classifies it as Workspace, otherwise falls back to the old b"workspace:*". Twelve new test cases in test/cli/install/bun-update-lockfile-sync.test.ts under the existing bun add describe block.

Security risks

None. This is package.json write-back formatting; no untrusted input parsing, no filesystem traversal, no network. The literal being kept is what the user typed on the CLI and what the install just successfully resolved.

Level of scrutiny

Medium — bun install write-back touches users' package.json, so a wrong value would be user-visible and annoying to undo. But the change is strictly narrowing: the new arm either keeps exactly what the before-install pass wrote (which the install just parsed and accepted) or produces the identical old output. I traced that (a) before-install never hits the replacing rebuild (package_id is still INVALID, so fresh slots stay "" and existing entries are edited in place), (b) the Subcommand::Update catalog check and !e_string.is_blank() guard in the unresolved branch are both gated on Update, which never reaches the non-empty declared path, and (c) edit()'s bool return is discarded at every caller so the equal-slice comparison is harmless. dependency::Tag::infer on workspace: at src/install/dependency.rs:1044 confirms the classification.

Other factors

Test coverage is thorough per REVIEW.md's variant-matrix rule: four spellings × root, -d without a lockfile, from another member, replacing an existing workspace:* and 1.0.0 entry (the case that ruled out reading the bound row), an entry in devDependencies (edit-in-place path), --filter targeting two workspaces, and two negative tests pinning the workspace:* fallback for plain ranges. Each asserts both package.json and bun.lock and follows with --frozen-lockfile. The PR body documents why the alternative (reading request.version.literal) fails those two tests. All three comment-cop threads are resolved (comments cut to one line). No CODEOWNERS coverage on these files. Composes with the adjacent open PR #38847 (bun update side) in either order.

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