pm ls: list a workspace the root also depends on once

Merged
RO
3mo ago
+152-6

Problem

  • bun pm ls without --all prints a root dependency once per entry the root has for it. A workspace a that is also in the root's devDependencies prints twice under a node_modules (1) header. So does a name declared in two groups (typescript in #8283's example).
  • Cause: src/runtime/cli/package_manager_command.rs:644 prints the root's dependency list as is: one entry per declaration plus one per workspace member. node_modules has one folder per name.

Fix

  • Stable sort the root's entries by name and drop the later entries of a name (dedup_by). --trusted filters the result.
  • Correct because one name is one node_modules/<name> folder, the same collapse Tree::hoist_dependency applies. The list is in the tree builder's order, so the entry kept is the one the tree kept. An alias has its own name and keeps its row. The listing does not read the stored tree, so an older bun.lockb prints as before (Notes).
  • Verified: test/cli/install/bun-pm.test.ts. Three new tests (bun.lock, bun.lockb, --trusted) fail on the released bun. One more pins that a root optional peer bound to a dependency's copy stays listed. On this repo's lockfile the only change is the removed @types/bun row.

Background

  • Each package owns one range of buffers.dependencies. The root's range holds its declared dependencies plus one Behavior::WORKSPACE entry per workspace member, sorted by group and name. Entries of one name share one folder.
  • The hoisted tree (buffers.trees, buffers.hoisted_dependencies) models node_modules: one tree per folder, one entry per name. --all prints it and the header (N) counts its entries. The default listing prints only the root's range.
Notes

Repro without a registry:

mkdir -p d/packages/a && cd d
echo '{"name":"root","workspaces":["packages/*"],"devDependencies":{"a":"workspace:*"}}' > package.json
echo '{"name":"a","version":"1.0.0"}' > packages/a/package.json
bun install && bun pm ls

Released bun prints a@workspace:packages/a on two lines, three when a is also in dependencies. bun pm ls --all prints it once. bun pm ls --trusted with trustedDependencies: ["a"] prints it twice. In this repo bun pm ls prints @types/bun@workspace:packages/@types/bun twice.

  • The first version of this PR printed the tree's root folder filtered to the root's id range. A self-review found a case it drops: a root optional peer that a dependency provides. When the tree builder binds that peer late, it stores the folder entry under the provider's dependency id (Tree.rs, ResolveReplace). bun 1.4.0 re-hoists on load and on a second pass stores the root's own id, but a bun.lockb written by bun 1.3.x keeps the provider's id, and bun does not rewrite a lockfile that needs no changes. Checked with a bun.lockb written by bun 1.3.14 from the optional-peer-hoist-provider and -target registry fixtures: the tree version listed only the provider, the current version lists both, as 1.3.14 and the released 1.4.0 do. The new optional peer test covers the fresh lockfile shape of this case.
  • Dedupe by package id was rejected: bar and bar-alias: npm:bar share one package id but are two folders. The test keeps both rows.
  • The --trusted test uses bun.lock only. bun.lockb stores only hashes of trustedDependencies, and has_trusted_dependency does not trust a hash alone since #31339, so --trusted lists nothing from a bun.lockb. Released bun does the same. Not related to this change.
  • A real conflict (workspace a plus "a": "file:..." at the root) does not reach pm ls today: bun install writes a duplicate package path and the lockfile fails to load (the area of #33156).
  • #8283 asks for grouped output and --json. This PR removes the duplicate row in its example and nothing else, so it does not close that issue.
  • Open PRs that touch this branch and do not fix this: #38923 (filter by what is on disk), #38952 (header wording). The fold branch of #39403 has the same loop.
  • Suites run with the debug build: test/cli/install/bun-pm.test.ts (22 pass), bun-install-lifecycle-scripts.test.ts -t trusted (55 pass), bun-install-registry.test.ts -t "migration is out of sync", test/regression/issue/24502. cargo fmt --check is clean. bun pm ls --all on this repo is unchanged byte for byte.

[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-pm.test.ts
bun test v1.4.0 (6e906e468)

test/cli/install/bun-pm.test.ts:
(pass) should list top-level dependency [386.82ms]
(pass) should list all dependencies [333.90ms]
(pass) should list top-level aliased dependency [329.02ms]
(pass) should list aliased dependencies [346.24ms]
(pass) should list only trusted dependencies with --trusted [464.07ms]
(pass) should list only trusted dependencies with --all --trusted [339.68ms]
(pass) should list trusted transitive dependencies under untrusted parents with --all --trusted (isolated) [356.01ms]
(pass) should list nothing with --trusted when no dependencies are trusted [346.58ms]
597 |   expect(await exists(join(package_dir, lockfile))).toBeTrue();
598 |   urls.length = 0;
599 | 
600 |   const [stdout, stderr, exitCode] = await spawnAndCollect("pm", "ls");
601 |   expect(stderr).toBe("");
602 |   expect(normalizeBunSnapshot(stdout, package_dir)).toMatchInlineSnapshot(`
                                                          ^
error: expect(received).toMatchInlineSnaps
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (6e906e468)

test/cli/install/bun-pm.test.ts:
(pass) should list top-level dependency [17.59ms]
(pass) should list all dependencies [15.04ms]
(pass) should list top-level aliased dependency [18.13ms]
(pass) should list aliased dependencies [26.89ms]
(pass) should list only trusted dependencies with --trusted [29.53ms]
(pass) should list only trusted dependencies with --all --trusted [30.36ms]
(pass) should list trusted transitive dependencies under untrusted parents with --all --trusted (isolated) [19.13ms]
(pass) should list nothing with --trusted when no dependencies are trusted [20.57ms]
597 |   expect(await exists(join(package_dir, lockfile))).toBeTrue();
598 |   urls.length = 0;
599 | 
600 |   const [stdout, stderr, exitCode] = await spawnAndCollect("pm", "ls");
601 |   expect(stderr).toBe("");
602 |   expect(normalizeBunSnapshot(stdout, package_dir)).toMatchInlineSnapshot(`
                                                          ^
error: expect(received).toMatchInlineSnapshot(expected)

  
  "<dir> node_modules (5)
  ├── bar@0.0.2
+ ├── bar@0.0.2
  ├── bar-alias@0.0.2
  ├── ws-once@workspace:packages/ws-once
+
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-pm.test.ts
bun test v1.4.0 (6e906e468)

test/cli/install/bun-pm.test.ts:
(pass) should list top-level dependency [381.86ms]
(pass) should list all dependencies [333.18ms]
(pass) should list top-level aliased dependency [351.49ms]
(pass) should list aliased dependencies [347.67ms]
(pass) should list only trusted dependencies with --trusted [463.21ms]
(pass) should list only trusted dependencies with --all --trusted [348.99ms]
(pass) should list trusted transitive dependencies under untrusted parents with --all --trusted (isolated) [321.16ms]
(pass) should list nothing with --trusted when no dependencies are trusted [309.86ms]
(pass) should list a workspace the root also depends on once (bun.lock) [541.88ms]
(pass) should list a workspace the root also depends on once (bun.lockb) [314.14ms]
(pass) should list a trusted workspace the root also depends on once with --trusted [315.39ms]
(pass) should list a root optional peer that a dependency provides [328.74ms]
(pass) should remove all cache [465.82ms]
(pass) bun pm m
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 683ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

   Compiling bun_runtime v0.0.0 (/workspace/bun/src/runtime)
   Compiling bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
    Finished `release` profile [optimized + debuginfo] target(s) in 4m 36s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.0-canary.1+b263001b0
[build] done
bun test v1.4.0-canary.1 (b263001b0)

test/cli/install/bun-pm.test.ts:
(pass) should list top-level dependency [15.40ms]
(pass) should list all dependencies [11.19ms]
(pass) should list top-level aliased dependency [9.40ms]
(pass) should list aliased dependencies [9.71ms]
(pass) should list only trusted dependencies with --trusted [10.18ms]
(pass) should list on
... (truncated)
diff hotspot
src/runtime/cli/package_manager_command.rs |   8 +-
 test/cli/install/bun-pm.test.ts            | 150 ++++++++++++++++++++++++++++-
 2 files changed, 152 insertions(+), 6 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
src/runtime/cli/package_manager_command.rs      5      4      0
test/cli/install/bun-pm.test.ts                10      9      0

root cause · written by the author bot

The root package's dependency list receives one entry per workspace member plus one per declared dependency, so a workspace that the root also declares as a dependency has two entries resolving to the same package, and the default bun pm ls loop printed each of them. The fix sorts the root's entries by name with a stable sort and then collapses adjacent entries with the same name, so each package is printed once, while the --trusted filtering in the same branch is unaffected.