From 36f7a287b309b453dd05cb01ccd8dbe1c9fbb95b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:24:03 +0000 Subject: [PATCH 1/4] Start fix for #1521 Assisted-by: Claude Code:claude-opus-5-5 From 8dfd5b2aff429e0fb632f7443c438903b8f015aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:37:00 +0000 Subject: [PATCH 2/4] Honour Bun 1.3.0/1.3.1 `!` workspace exclusions Bun 1.3.0 and 1.3.1 already skip workspace members excluded with a `!` pattern, but their bun.lock / bun.lockb carry no config-version tag, so socket-patch read them as older Bun and still counted the excluded member as part of the root workspace. Hosted scans from that member were refused (redirect_workspace_lockfile_elsewhere), vendored scans failed with vendor_lockfile_missing, and the suggested root run pinned nothing. For an untagged Bun lock, an excluded directory now counts as a member only when the root lock actually lists it: Bun before 1.3 lists every member it matched, while 1.3.0/1.3.1 leave an excluded one out. An unreadable lock keeps the old, wider reading. Adds real 1.2.23, 1.3.0 and 1.3.1 binary locks as fixtures. Fixes #1521 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 125 +++++++++++++++++- .../src/vendor/bun_lock_text.rs | 8 +- .../tests/fixtures/bun-lockb/README.md | 4 + .../bun-lockb/negated-workspace/1.2.23.lockb | Bin 0 -> 1904 bytes .../bun-lockb/negated-workspace/1.3.0.lockb | Bin 0 -> 1520 bytes .../bun-lockb/negated-workspace/1.3.1.lockb | Bin 0 -> 1520 bytes .../bun-lockb/negated-workspace/bunfig.toml | 2 + .../bun-lockb/negated-workspace/package.json | 1 + .../negated-workspace/packages/a/package.json | 1 + .../negated-workspace/packages/b/package.json | 1 + .../negated-workspace/provenance.json | 19 +++ 11 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.0.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.1.lockb create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/bunfig.toml create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/a/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/b/package.json create mode 100644 crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/provenance.json diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 360816407..dd7d53ea5 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -32,10 +32,10 @@ use crate::utils::fs::{ }; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::workspace_globs::{workspaces_include, workspaces_include_as, WorkspacesDialect}; -use crate::vendor::bun_lockb; use crate::vendor::cargo::NOT_WORKSPACE_ROOT; use crate::vendor::cargo_manifest; use crate::vendor::lock_inventory::ProjectView; +use crate::vendor::{bun_lock_text, bun_lockb}; use super::engine::{Candidate, Refusal}; use super::guidance::{ @@ -524,7 +524,7 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat let found = !locks.is_empty(); let mut governing = Vec::new(); for lock in locks { - if workspaces_include_as(&patterns, &rel, lock_dialect(ancestor, lock).await) { + if lock_lists_member(ancestor, lock, &patterns, &rel).await { governing.push(lock); } } @@ -546,15 +546,63 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat None } +/// Whether the package manager that reads `lock` at `root` lists the +/// member `rel` under the root's `workspaces` `patterns`. An untagged Bun +/// lock (see [`lock_dialect`]) is ambiguous: Bun 1.3.0 and 1.3.1 already +/// honour `!` but write no config version (#1521). Only a directory that +/// a `!` pattern excludes reads differently, and for it the lock's own +/// workspace list decides: Bun before 1.3 lists every member it matched, +/// 1.3.0/1.3.1 never list an excluded one. A list that cannot be read +/// keeps the wider pre-1.3 reading. +async fn lock_lists_member(root: &Path, lock: &str, patterns: &[String], rel: &[String]) -> bool { + let dialect = lock_dialect(root, lock).await; + if !workspaces_include_as(patterns, rel, dialect) { + return false; + } + if dialect != WorkspacesDialect::BUN_PRE_1_3 + || workspaces_include_as(patterns, rel, WorkspacesDialect::BUN) + { + return true; + } + let dir = rel.join("/"); + bun_workspace_dirs(root, lock) + .await + .is_none_or(|dirs| dirs.contains(&dir)) +} + +/// The member directories the Bun lock `lock` at `root` lists, relative to +/// `root` with `/` separators; the text lock when both exist, as Bun +/// reads it. `None` when the lock is unreadable or not in Bun's shape. +async fn bun_workspace_dirs(root: &Path, lock: &str) -> Option> { + if lock == BUN_LOCK || root.join(BUN_LOCK).is_file() { + let text = read_regular_to_string(&root.join(BUN_LOCK)).await.ok()?; + let lines: Vec = strip_bom(&text).split('\n').map(str::to_string).collect(); + return bun_lock_text::listed_workspace_dirs(&lines); + } + let path = root.join(BUN_LOCKB); + tokio::task::spawn_blocking(move || { + use std::io::Read; + let (mut file, _) = open_regular_file_sync(&path).ok()?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).ok()?; + bun_lockb::BunLockb::parse(&bytes) + .and_then(|lock| lock.workspace_paths()) + .ok() + }) + .await + .ok() + .flatten() +} + /// The `package.json` `workspaces` dialect of the package manager that /// reads `lock` at `root` (#1097). yarn classic and berry tell apart by /// berry's `__metadata` key ([`is_berry_lock`]; an empty or unreadable /// `yarn.lock` reads as classic, the wider set), and Bun 1.3+ by the /// top-level `configVersion` it writes into `bun.lock`, or the trailing /// `cNfGvRsN` extension that carries it in a binary `bun.lockb` (#1492; -/// a lock without it, as Bun 1.3.0 and earlier write, reads as older Bun, -/// the wider set). Only the head of a text lock is read: both keys come -/// first. +/// a lock without it, as Bun 1.3.1 and earlier write, reads as older Bun, +/// the wider set, which [`lock_lists_member`] narrows). Only the head of +/// a text lock is read: both keys come first. async fn lock_dialect(root: &Path, lock: &str) -> WorkspacesDialect { match lock { YARN_LOCK => { @@ -1387,7 +1435,7 @@ mod tests { write( tmp.path(), "bun.lock", - "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"configVersion\": \"1\",\n },\n },\n}\n", + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"configVersion\": \"1\",\n },\n \"packages/b\": {\n \"name\": \"b\",\n },\n },\n}\n", ); write(tmp.path(), "packages/b/package.json", "{}"); assert_eq!( @@ -1396,6 +1444,67 @@ mod tests { ); } + /// #1521: Bun 1.3.0 and 1.3.1 honour `!` but tag neither lock with a + /// config version, so an untagged lock is either theirs or older + /// Bun's. The lock's own workspace list tells them apart: Bun 1.2 + /// lists the `!`-excluded member, 1.3.0/1.3.1 leave it out, and then + /// the member's own lock is the one Bun reads. These are the locks + /// each release writes for the same `["packages/*","!packages/b"]` + /// root (`tests/fixtures/bun-lockb/negated-workspace`). + #[tokio::test] + async fn untagged_bun_lock_reads_its_workspace_list() { + const TEXT_1_2_23: &str = "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"root\",\n },\n \"packages/a\": {\n \"name\": \"a\",\n \"version\": \"1.0.0\",\n },\n \"packages/b\": {\n \"name\": \"b\",\n \"version\": \"1.0.0\",\n },\n },\n \"packages\": {\n \"a\": [\"a@workspace:packages/a\"],\n\n \"b\": [\"b@workspace:packages/b\"],\n }\n}\n"; + // Bun 1.3.1 writes the same bytes. + const TEXT_1_3_0: &str = "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"root\",\n },\n \"packages/a\": {\n \"name\": \"a\",\n \"version\": \"1.0.0\",\n },\n },\n \"packages\": {\n \"a\": [\"a@workspace:packages/a\"],\n }\n}\n"; + const BINARY_1_2_23: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb"); + const BINARY_1_3_0: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/negated-workspace/1.3.0.lockb"); + const BINARY_1_3_1: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/negated-workspace/1.3.1.lockb"); + let refused = Some(WORKSPACE_LOCKFILE_ELSEWHERE); + for (name, lock, expected) in [ + ("bun.lock", TEXT_1_2_23.as_bytes(), refused), + ("bun.lock", TEXT_1_3_0.as_bytes(), None), + ("bun.lockb", BINARY_1_2_23, refused), + ("bun.lockb", BINARY_1_3_0, None), + ("bun.lockb", BINARY_1_3_1, None), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*","!packages/b"]}"#, + ); + std::fs::write(tmp.path().join(name), lock).unwrap(); + write(tmp.path(), "packages/a/package.json", "{}"); + write(tmp.path(), "packages/b/package.json", "{}"); + let member = tmp.path().join("packages/b"); + assert_eq!( + code(&member, "npm").await.as_deref(), + expected, + "{name} of {} bytes", + lock.len() + ); + // The member's own lock is a stray only when the root's Bun + // lists the member. + std::fs::write(member.join(name), lock).unwrap(); + assert_eq!( + member_stray_lock(&member).await.is_some(), + expected.is_some(), + "{name} of {} bytes", + lock.len() + ); + // A member no `!` pattern excludes is governed either way. + assert_eq!( + code(&tmp.path().join("packages/a"), "npm").await.as_deref(), + refused, + "{name} of {} bytes", + lock.len() + ); + } + } + /// #1492: Bun 1.2+ still writes a binary `bun.lockb` under /// `saveTextLockfile = false`, and Bun 1.3.x+ marks it with a /// `cNfGvRsN` config-version tag (the twin of the text lock's @@ -1406,7 +1515,9 @@ mod tests { #[tokio::test] async fn bun_lockb_with_a_config_version_tag_honours_negated_workspaces() { const BUN_1_3_14: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.3.14/bun.lockb"); - const BUN_1_2_23: &[u8] = include_bytes!("../../tests/fixtures/bun-lockb/1.2.23/bun.lockb"); + // The lock Bun 1.2.23 writes for this root: it lists `packages/b`. + const BUN_1_2_23: &[u8] = + include_bytes!("../../tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb"); // A workspace root's lock: the tag follows its workspace and // catalog extensions. const BUN_1_4_2_WORKSPACE: &[u8] = diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 9fcafc4e8..e0b464ac6 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -669,9 +669,15 @@ fn parse_workspaces_section(lines: &[String]) -> Option { /// the project root), in lock order. Empty when the section is absent or /// not in bun's emitted shape. pub(crate) fn workspace_member_dirs(lines: &[String]) -> Vec { + listed_workspace_dirs(lines).unwrap_or_default() +} + +/// [`workspace_member_dirs`], but `None` when the section is absent or not +/// in bun's emitted shape, so a caller can tell "lists no such member" +/// from "could not read the list". +pub(crate) fn listed_workspace_dirs(lines: &[String]) -> Option> { parse_workspaces_section(lines) .map(|section| section.members.into_iter().map(|(dir, _)| dir).collect()) - .unwrap_or_default() } /// Whether a `workspaces` key is a plain relative directory (`""` is the diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md index 0caf38abe..b35380ad4 100644 --- a/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/README.md @@ -18,6 +18,10 @@ and an empty `BUN_INSTALL_CACHE_DIR` to regenerate. Bun 1.2+ fixtures include installed packages), whose `sCoPdOvR` extension Bun writes after `cNfGvRsN`, and a `!` workspace pattern (#1492); written on Linux x64. +- `negated-workspace/.lockb`: the root lock Bun 1.2.23, 1.3.0 + and 1.3.1 write for a `["packages/*","!packages/b"]` workspace, on Linux + x64. 1.2.23 lists `packages/b`, while 1.3.0 and 1.3.1 (identical bytes) + leave it out, and neither carries the `cNfGvRsN` tag (#1521). - `0.8.1-production` / `1.0.0-production`: a production dependency and a different version aliased in dev dependencies, pinning production-filter string-pool reconstruction on early binary readers. diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.2.23.lockb new file mode 100644 index 0000000000000000000000000000000000000000..cf31c383f79000d61901cc0d4b3b97c388ce02c1 GIT binary patch literal 1904 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6ODV1_p*szmO$X z3hNZo?oFC-X_dg{&7YH_r2pQY{`WR(x~}L!-Un@&BV%U_h*4U;vuO$N({yszxK33-Sxq0974H*i|H3N4ka! z(2Vf}N^{v{<`tJD=H%$Xa-?2JQEIB4f{}qjab{I&ewvPgi9%vdW_n(J8YnG;z`y?x z0Aho}TYv?`VqlPf(p)yB#zuBPeK<@2IokuOHh@SyAbUXi8ldX8!1YBNnZ=e`09|Yb zx6csa;zXbx5P;=x1#k%h%1KEO2?kgRpuh(5A4rCg6(R+rxonD4bIVeT^js@SQj78u zbAn5XGV{{WxONJL2p3quU0?t;%mqpV0|)3>Lu_sZ*;Vf$6FUV9xS0ss;r@pC@&;6o2ec%G`z TEK*W}yBCy3U}*&|I7k=(sBY#) literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.0.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.0.lockb new file mode 100644 index 0000000000000000000000000000000000000000..f9fa3e0fe94bd0565a7dd34eccf848d946c15449 GIT binary patch literal 1520 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6ODV1_lP3m3hsp zUUhFdJO7x?<5yWO{1aA1H3%B9n{1Z|5M-WkE`b%O2nd)U6axnu-2mlx!4xnQ<>!}x z1QVe$N>Um7dNwJacyR4PYnsNlOh%|UrIZJf0bC3W4M<#)nIJbIy9<^7kN+RCB*~^@ z(Ev1$kpW^ZRgI>qVW{pV*=WWS&@^!eN^{v{<`tJD=H%$X5~E&7QEIB4f{}qjab{I& zewvPgi9%vdW_n(J8Zb&382ff9Fhtm80k?~T1!OD(gAkMkdL8I2LtL%``Xwk%6Jy zzsS2dAkjGhYD5IWC^Hi~1q--Y2wQ<#Vg6bGReb}l3+kJ&)S}|d{5(A<9a344s!?iY zqG_jKip?V=rz5EIIiMwx0F(v>03KH(+#LZ`4=c&wu@DT73}_rcDC9sui)OGZC@p7T IF+oil0F%+rk^lez literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.1.lockb b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/1.3.1.lockb new file mode 100644 index 0000000000000000000000000000000000000000..f9fa3e0fe94bd0565a7dd34eccf848d946c15449 GIT binary patch literal 1520 zcmY#Z)GsYA(of3F(@)JSQ%EY!;{sycoc!eMw9K4T-L(9o+{6;yG6ODV1_lP3m3hsp zUUhFdJO7x?<5yWO{1aA1H3%B9n{1Z|5M-WkE`b%O2nd)U6axnu-2mlx!4xnQ<>!}x z1QVe$N>Um7dNwJacyR4PYnsNlOh%|UrIZJf0bC3W4M<#)nIJbIy9<^7kN+RCB*~^@ z(Ev1$kpW^ZRgI>qVW{pV*=WWS&@^!eN^{v{<`tJD=H%$X5~E&7QEIB4f{}qjab{I& zewvPgi9%vdW_n(J8Zb&382ff9Fhtm80k?~T1!OD(gAkMkdL8I2LtL%``Xwk%6Jy zzsS2dAkjGhYD5IWC^Hi~1q--Y2wQ<#Vg6bGReb}l3+kJ&)S}|d{5(A<9a344s!?iY zqG_jKip?V=rz5EIIiMwx0F(v>03KH(+#LZ`4=c&wu@DT73}_rcDC9sui)OGZC@p7T IF+oil0F%+rk^lez literal 0 HcmV?d00001 diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/bunfig.toml b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/bunfig.toml new file mode 100644 index 000000000..30e63e0e1 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/bunfig.toml @@ -0,0 +1,2 @@ +[install] +saveTextLockfile = false diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/package.json new file mode 100644 index 000000000..d26667bce --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/package.json @@ -0,0 +1 @@ +{"name":"root","private":true,"workspaces":["packages/*","!packages/b"]} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/a/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/a/package.json new file mode 100644 index 000000000..008d7d1fb --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/a/package.json @@ -0,0 +1 @@ +{"name":"a","version":"1.0.0"} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/b/package.json b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/b/package.json new file mode 100644 index 000000000..363a869fd --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/packages/b/package.json @@ -0,0 +1 @@ +{"name":"b","version":"1.0.0"} diff --git a/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/provenance.json b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/provenance.json new file mode 100644 index 000000000..deded3b71 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/bun-lockb/negated-workspace/provenance.json @@ -0,0 +1,19 @@ +{ + "os": "linux", + "arch": "x86_64", + "command": "bun install --ignore-scripts", + "locks": { + "1.2.23.lockb": { + "bun": "1.2.23", + "sha256": "e78d558895f6ba02e99c8bacbe2bea75a1b116be5dc8b7d881343c66f417dadb" + }, + "1.3.0.lockb": { + "bun": "1.3.0", + "sha256": "e766af5798b6f3f9c63b8cd0045f678b1556c15bd27624cf48c35dbad1f1708f" + }, + "1.3.1.lockb": { + "bun": "1.3.1", + "sha256": "e766af5798b6f3f9c63b8cd0045f678b1556c15bd27624cf48c35dbad1f1708f" + } + } +} From 97c8023a03d8f648aa8845d67697b4e29b6bfdd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 06:51:21 +0000 Subject: [PATCH 3/4] Pin the #1521 test to each lock's workspace list Asserts the verdict comes from the members each real lock lists rather than from a lock that failed to read, and records the new negated-workspace fixture in the VEX discovery golden corpus. Refs #1521 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 9 +++++++ .../vex-discover-golden/bun-lockb.json | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index dd7d53ea5..e36d20970 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -1477,6 +1477,15 @@ mod tests { r#"{"private":true,"workspaces":["packages/*","!packages/b"]}"#, ); std::fs::write(tmp.path().join(name), lock).unwrap(); + // The verdict comes from the list each lock holds, not from a + // lock that failed to read. + let listed = bun_workspace_dirs(tmp.path(), name).await; + assert_eq!( + listed.map(|dirs| dirs.contains(&"packages/b".to_string())), + Some(expected.is_some()), + "{name} of {} bytes", + lock.len() + ); write(tmp.path(), "packages/a/package.json", "{}"); write(tmp.path(), "packages/b/package.json", "{}"); let member = tmp.path().join("packages/b"); diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json index c1e41b0b7..057e40f64 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/bun-lockb.json @@ -479,6 +479,30 @@ "elsewhere": [], "live_claims": [] }, + "bun-lockb/negated-workspace": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "bun-lockb/negated-workspace/packages/a": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, + "bun-lockb/negated-workspace/packages/b": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "bun-lockb/prerelease-pair-0.8.1": { "refs": [], "diagnostics": [], From ea8578d11b9d21b73d05128b691ab8f8ae08105d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:02:05 +0000 Subject: [PATCH 4/4] Read the Bun version once per untagged lock An untagged Bun lock that lists any `!`-excluded directory comes from Bun before 1.3, so every excluded directory under it now counts as a member, including one added since the last install. Workspace keys spelled with a `./` prefix or backslashes are normalized before the comparison, the binary lock is read through the shared file helper, and the dialect docs describe the 1.3.0/1.3.1 case. Refs #1521 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 101 ++++++++++++++---- .../src/utils/workspace_globs.rs | 8 +- 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index e36d20970..480abc4dc 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -28,7 +28,8 @@ use crate::formats::yarn::berry_gates::YARN_LOCK; use crate::formats::yarn::is_berry_lock; use crate::patch::redirect::npmrc::npmrc_top_level_value; use crate::utils::fs::{ - open_regular_file, open_regular_file_sync, read_regular_to_string, read_regular_to_string_sync, + open_regular_file, open_regular_file_sync, read_regular_to_bytes, read_regular_to_string, + read_regular_to_string_sync, }; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::workspace_globs::{workspaces_include, workspaces_include_as, WorkspacesDialect}; @@ -552,8 +553,9 @@ async fn package_json_workspace_root(root: &Path) -> Option<(PathBuf, Vec<&'stat /// honour `!` but write no config version (#1521). Only a directory that /// a `!` pattern excludes reads differently, and for it the lock's own /// workspace list decides: Bun before 1.3 lists every member it matched, -/// 1.3.0/1.3.1 never list an excluded one. A list that cannot be read -/// keeps the wider pre-1.3 reading. +/// so a lock that lists the member, or any other `!`-excluded directory, +/// is older Bun's; 1.3.0/1.3.1 never list an excluded one. A list that +/// cannot be read keeps the wider pre-1.3 reading. async fn lock_lists_member(root: &Path, lock: &str, patterns: &[String], rel: &[String]) -> bool { let dialect = lock_dialect(root, lock).await; if !workspaces_include_as(patterns, rel, dialect) { @@ -564,34 +566,44 @@ async fn lock_lists_member(root: &Path, lock: &str, patterns: &[String], rel: &[ { return true; } - let dir = rel.join("/"); - bun_workspace_dirs(root, lock) - .await - .is_none_or(|dirs| dirs.contains(&dir)) + let Some(listed) = bun_workspace_dirs(root, lock).await else { + return true; + }; + listed.iter().any(|dir| { + let dir: Vec = dir.split('/').map(str::to_string).collect(); + dir == rel + || (workspaces_include_as(patterns, &dir, WorkspacesDialect::BUN_PRE_1_3) + && !workspaces_include_as(patterns, &dir, WorkspacesDialect::BUN)) + }) } /// The member directories the Bun lock `lock` at `root` lists, relative to -/// `root` with `/` separators; the text lock when both exist, as Bun -/// reads it. `None` when the lock is unreadable or not in Bun's shape. +/// `root` as `/`-separated plain components (a `./` prefix or `\` +/// separators normalized, the root `""` left out); the text lock when both exist, as +/// Bun reads it. `None` when the lock is unreadable or not in Bun's shape. async fn bun_workspace_dirs(root: &Path, lock: &str) -> Option> { - if lock == BUN_LOCK || root.join(BUN_LOCK).is_file() { + let listed = if lock == BUN_LOCK || root.join(BUN_LOCK).is_file() { let text = read_regular_to_string(&root.join(BUN_LOCK)).await.ok()?; let lines: Vec = strip_bom(&text).split('\n').map(str::to_string).collect(); - return bun_lock_text::listed_workspace_dirs(&lines); - } - let path = root.join(BUN_LOCKB); - tokio::task::spawn_blocking(move || { - use std::io::Read; - let (mut file, _) = open_regular_file_sync(&path).ok()?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes).ok()?; + bun_lock_text::listed_workspace_dirs(&lines)? + } else { + let bytes = read_regular_to_bytes(&root.join(BUN_LOCKB)).await.ok()?; bun_lockb::BunLockb::parse(&bytes) .and_then(|lock| lock.workspace_paths()) - .ok() - }) - .await - .ok() - .flatten() + .ok()? + }; + Some( + listed + .iter() + .map(|dir| { + dir.split(['/', '\\']) + .filter(|part| !part.is_empty() && *part != ".") + .collect::>() + .join("/") + }) + .filter(|dir| !dir.is_empty()) + .collect(), + ) } /// The `package.json` `workspaces` dialect of the package manager that @@ -1514,6 +1526,49 @@ mod tests { } } + /// #1521 (review): the version is read once per lock. An untagged lock + /// that lists one `!`-excluded directory is older Bun's, so another + /// excluded directory added since its last install is a member too; + /// and a member key spelled `./packages/b` is the same directory. + #[tokio::test] + async fn untagged_bun_lock_listing_any_excluded_dir_is_pre_1_3() { + let lock = |members: &[&str]| { + let mut text = String::from( + "{\n \"lockfileVersion\": 1,\n \"workspaces\": {\n \"\": {\n \"name\": \"root\",\n },\n", + ); + for member in members { + text.push_str(&format!( + " \"{member}\": {{\n \"name\": \"m\",\n }},\n" + )); + } + text.push_str(" },\n \"packages\": {\n }\n}\n"); + text + }; + let refused = Some(WORKSPACE_LOCKFILE_ELSEWHERE); + for (members, member, expected) in [ + (&["packages/a", "packages/b"][..], "packages/c", refused), + (&["packages/a", "./packages/b"][..], "packages/b", refused), + (&["packages/a", "./packages/b"][..], "packages/c", refused), + (&["packages/a"][..], "packages/c", None), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "package.json", + r#"{"private":true,"workspaces":["packages/*","!packages/b","!packages/c"]}"#, + ); + write(tmp.path(), "bun.lock", &lock(members)); + for dir in ["a", "b", "c"] { + write(tmp.path(), &format!("packages/{dir}/package.json"), "{}"); + } + assert_eq!( + code(&tmp.path().join(member), "npm").await.as_deref(), + expected, + "{member} under a lock listing {members:?}" + ); + } + } + /// #1492: Bun 1.2+ still writes a binary `bun.lockb` under /// `saveTextLockfile = false`, and Bun 1.3.x+ marks it with a /// `cNfGvRsN` config-version tag (the twin of the text lock's diff --git a/crates/socket-patch-core/src/utils/workspace_globs.rs b/crates/socket-patch-core/src/utils/workspace_globs.rs index d0bb0bc93..896a74fab 100644 --- a/crates/socket-patch-core/src/utils/workspace_globs.rs +++ b/crates/socket-patch-core/src/utils/workspace_globs.rs @@ -69,13 +69,17 @@ impl WorkspacesDialect { extglob: true, picomatch: true, }; - /// Bun 1.3 and later (a `bun.lock` with `configVersion`). + /// Bun 1.3 and later (a `bun.lock` with `configVersion`, or an + /// untagged 1.3.0/1.3.1 lock that leaves an excluded member out, + /// #1521). pub(crate) const BUN: Self = Self { negation: Negation::LastMatchWins, extglob: false, picomatch: false, }; - /// Bun before 1.3: it lists `!`-negated directories anyway. + /// Bun before 1.3: it lists `!`-negated directories anyway. An + /// untagged lock reads this way until its workspace list shows it + /// is 1.3.0/1.3.1's (`hosted::governing_root`, #1521). pub(crate) const BUN_PRE_1_3: Self = Self { negation: Negation::Ignored, extglob: false,