From d77cffece1047b861bf6c893142a1a4b15d65cc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:24:59 +0000 Subject: [PATCH 1/5] Start fix for #636, #670 Assisted-by: Claude Code:claude-opus-5-5 From d46b5fa94f7989a6781396c8190fdce022ba6dbb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:34:51 +0000 Subject: [PATCH 2/5] Fix revert leaving vendored pnpm/uv scaffolding When two or more packages were vendored into a pnpm or uv project, only the first ledger entry remembered that vendoring created the shared pnpm.overrides / pnpm table, pnpm-workspace.yaml or [tool.uv.sources] table. A full vendor --revert, rollback or remove then left that scaffolding behind unless the first package happened to be reverted last, leaving the checkout dirty. The ledger now shares those "created by vendoring" flags across every entry that wires the same files, when it is written and when it is read, so whichever entry empties the scaffold removes it. Ledgers written by earlier releases are repaired on read. Scaffolding that still holds anything else, such as user keys, is kept as before. Fixes #636, #670 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/vendor/pnpm_lock.rs | 192 ++++++++++++++++++ .../socket-patch-core/src/vendor/pypi_uv.rs | 175 ++++++++++++++++ crates/socket-patch-core/src/vendor/state.rs | 179 +++++++++++++++- 3 files changed, 542 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 1b08ba52d..9a654b927 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -8860,4 +8860,196 @@ snapshots: } } } + + // ── two packages sharing created scaffolding (#636) ────────────────── + + const TWO_PKG: &str = r#"{ + "name": "fx", + "version": "1.0.0", + "private": true, + "dependencies": { + "left-pad": "1.3.0", + "is-number": "7.0.0" + } +} +"#; + + const TWO_LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + is-number: + specifier: 7.0.0 + version: 7.0.0 + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + is-number@7.0.0: {} + + left-pad@1.3.0: {} +"; + + const IS_NUMBER_UUID: &str = "4d5e6f70-8a9b-4c0d-9e1f-2a3b4c5d6e7f"; + const IS_NUMBER: &str = "pkg:npm/is-number@7.0.0"; + const LEFT_PAD: &str = "pkg:npm/left-pad@1.3.0"; + + /// Vendor is-number then left-pad (is-number creates every scaffold), + /// persisting the ledger after each the way the vendor loop does. + async fn vendor_two(pkg_json: &str, workspace: Option<&str>) -> Fixture { + let fx = fixture_with(pkg_json, TWO_LOCK).await; + if let Some(ws) = workspace { + tokio::fs::write(fx.root().join(PNPM_WORKSPACE), ws) + .await + .unwrap(); + } + let is_number = fx.root().join("node_modules/is-number"); + tokio::fs::create_dir_all(&is_number).await.unwrap(); + tokio::fs::write( + is_number.join("package.json"), + br#"{"name":"is-number","version":"7.0.0"}"#, + ) + .await + .unwrap(); + tokio::fs::write(is_number.join("index.js"), ORIG_INDEX) + .await + .unwrap(); + let mut is_number_record = fx.record.clone(); + is_number_record.uuid = IS_NUMBER_UUID.to_string(); + + let blobs = fx.root().join(".socket/blobs"); + let sources = PatchSources::blobs_only(&blobs); + let mut state = crate::vendor::state::VendorState::new(); + for (purl, dir, record) in [ + (IS_NUMBER, is_number.clone(), &is_number_record), + (LEFT_PAD, fx.installed(), &fx.record), + ] { + let outcome = crate::vendor::test_support::vendor_pnpm( + purl, + &dir, + fx.root(), + record, + &sources, + "2026-06-09T00:00:00Z", + false, + false, + None, + ) + .await; + let (result, entry, _) = expect_done(outcome); + assert!(result.success, "{purl}: {:?}", result.error); + state.entries.insert(purl.to_string(), entry.unwrap()); + crate::vendor::state::save_state(fx.root(), &state) + .await + .unwrap(); + } + fx + } + + /// Revert `order` one entry at a time from a freshly loaded ledger, + /// saving after each removal (`vendor --revert`, `rollback` and + /// successive `remove` runs all persist per entry). + async fn revert_in_order(fx: &Fixture, order: [&str; 2]) { + for key in order { + let mut state = crate::vendor::state::load_state(fx.root()).await.unwrap(); + let entry = state.entries.get(key).cloned().unwrap(); + let outcome = revert_pnpm(&entry, fx.root(), false).await; + assert!(outcome.success, "{key}: {:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{key}: {:?}", outcome.warnings); + state.entries.remove(key); + crate::vendor::state::save_state(fx.root(), &state) + .await + .unwrap(); + } + } + + /// #636: the creator (is-number, first in purl order) reverted first + /// must not leave `"pnpm": { "overrides": {} }` or the scaffolded + /// pnpm-workspace.yaml behind once left-pad empties them. + #[tokio::test] + async fn revert_two_packages_creator_first_removes_created_scaffold() { + let fx = vendor_two(TWO_PKG, None).await; + assert!(fx.root().join(PNPM_WORKSPACE).exists()); + + revert_in_order(&fx, [IS_NUMBER, LEFT_PAD]).await; + assert_eq!(fx.read(PACKAGE_JSON).await, TWO_PKG); + assert_eq!(fx.read(PNPM_LOCK).await, TWO_LOCK); + assert!( + !fx.root().join(PNPM_WORKSPACE).exists(), + "scaffolded workspace file left behind" + ); + } + + /// #636, `remove` in the other order: clean before the fix too. + #[tokio::test] + async fn revert_two_packages_creator_last_removes_created_scaffold() { + let fx = vendor_two(TWO_PKG, None).await; + + revert_in_order(&fx, [LEFT_PAD, IS_NUMBER]).await; + assert_eq!(fx.read(PACKAGE_JSON).await, TWO_PKG); + assert!(!fx.root().join(PNPM_WORKSPACE).exists()); + } + + /// The `overrides:` section vendoring added to the user's own + /// pnpm-workspace.yaml goes too, in creator-first order; the file and + /// the user's keys stay. + #[tokio::test] + async fn revert_two_packages_removes_created_workspace_overrides() { + let ws = "packages:\n - '.'\n"; + let fx = vendor_two(TWO_PKG, Some(ws)).await; + + revert_in_order(&fx, [IS_NUMBER, LEFT_PAD]).await; + assert_eq!(fx.read(PNPM_WORKSPACE).await, ws); + assert_eq!(fx.read(PACKAGE_JSON).await, TWO_PKG); + } + + /// A ledger written before the flags were shared (only the creator + /// flagged) is repaired on load, so it unwinds cleanly too. + #[tokio::test] + async fn revert_two_packages_repairs_a_creator_only_ledger() { + let fx = vendor_two(TWO_PKG, None).await; + let path = fx.root().join(".socket/vendor/state.json"); + let mut ledger: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + ledger["entries"][LEFT_PAD]["pnpm"] = serde_json::json!({}); + tokio::fs::write(&path, serde_json::to_vec_pretty(&ledger).unwrap()) + .await + .unwrap(); + + revert_in_order(&fx, [IS_NUMBER, LEFT_PAD]).await; + assert_eq!(fx.read(PACKAGE_JSON).await, TWO_PKG); + assert!(!fx.root().join(PNPM_WORKSPACE).exists()); + } + + /// A user's own `pnpm` table keeps its keys; only the overrides table + /// vendoring created goes. + #[tokio::test] + async fn revert_two_packages_keeps_a_user_pnpm_table() { + let pkg = TWO_PKG.replace( + " }\n}\n", + " },\n \"pnpm\": {\n \"onlyBuiltDependencies\": []\n }\n}\n", + ); + let fx = vendor_two(&pkg, None).await; + + revert_in_order(&fx, [IS_NUMBER, LEFT_PAD]).await; + assert_eq!(fx.read(PACKAGE_JSON).await, pkg); + } } diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 3b602ba6b..f5db97568 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -2491,6 +2491,181 @@ wheels = [ ); } + // ── two packages sharing a created [tool.uv.sources] (#670) ──────── + + const TWO_REGISTRY_PYPROJECT: &str = r#"[project] +name = "proj" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = ["idna==3.7", "six==1.16.0"] +"#; + + const TWO_REGISTRY_LOCK: &str = r#"version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "idna" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/ed/f86a79a07470cb07819390452f178b3bef1d375f2ec021ecfc709fc7cf07/idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", size = 189575, upload-time = "2024-04-11T03:34:43.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0", size = 66836, upload-time = "2024-04-11T03:34:41.447Z" }, +] + +[[package]] +name = "proj" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "idna" }, + { name = "six" }, +] + +[package.metadata] +requires-dist = [ + { name = "idna", specifier = "==3.7" }, + { name = "six", specifier = "==1.16.0" }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041, upload-time = "2021-05-05T14:18:18.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, +] +"#; + + const IDNA_UUID: &str = "2c7d1e5f-3b4a-4c6d-9e8f-0a1b2c3d4e5f"; + const IDNA_SHA: &str = "82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"; + + /// Vendor idna then six into one project, persisting each entry the + /// way the vendor loop does; returns the ledger keys in wiring order + /// (idna, the entry that created `[tool.uv.sources]`, first). + async fn vendor_two(root: &Path) -> [&'static str; 2] { + let idna_rel = format!(".socket/vendor/pypi/{IDNA_UUID}/idna-3.7-py3-none-any.whl"); + let mut state = crate::vendor::state::VendorState::new(); + for (key, name, version, rel, wheel, sha, uuid) in [ + ( + "pkg:pypi/idna@3.7", + "idna", + "3.7", + idna_rel.as_str(), + "idna-3.7-py3-none-any.whl", + IDNA_SHA, + IDNA_UUID, + ), + ( + "pkg:pypi/six@1.16.0", + "six", + "1.16.0", + REL_WHEEL, + WHEEL_NAME, + WHEEL_SHA, + UUID, + ), + ] { + let p = load_uv_project(root).await.unwrap(); + let (wiring, meta, _) = wire_uv(&p, root, name, version, rel, wheel, sha, uuid) + .await + .unwrap(); + let mut entry = entry_for(wiring, meta); + entry.base_purl = key.into(); + entry.uuid = uuid.into(); + entry.artifact.path = rel.into(); + entry.artifact.sha256 = sha.into(); + state.entries.insert(key.into(), entry); + crate::vendor::state::save_state(root, &state) + .await + .unwrap(); + } + ["pkg:pypi/idna@3.7", "pkg:pypi/six@1.16.0"] + } + + /// Revert `order` one ledger entry at a time, each from a freshly + /// loaded ledger and saved after its removal — the shape of both + /// `vendor --revert` (per-entry save) and successive `remove` runs. + async fn revert_in_order(root: &Path, order: [&str; 2]) { + for key in order { + let mut state = crate::vendor::state::load_state(root).await.unwrap(); + let entry = state.entries.get(key).cloned().unwrap(); + let outcome = revert_uv(&entry, root, false).await; + assert!(outcome.success, "{key}: {:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{key}: {:?}", outcome.warnings); + state.entries.remove(key); + crate::vendor::state::save_state(root, &state) + .await + .unwrap(); + } + } + + /// #670: the entry that created `[tool.uv.sources]` reverted FIRST + /// (purl order, as `vendor --revert` does) must not leave the empty + /// header behind once the second entry empties it. + #[tokio::test] + async fn revert_two_packages_creator_first_removes_created_sources_table() { + let tmp = write_pair(TWO_REGISTRY_PYPROJECT, TWO_REGISTRY_LOCK).await; + let [idna, six] = vendor_two(tmp.path()).await; + let (wired, _) = read_pair(tmp.path()).await; + assert!(wired.contains("[tool.uv.sources]"), "{wired}"); + + revert_in_order(tmp.path(), [idna, six]).await; + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, TWO_REGISTRY_PYPROJECT, "no empty header left"); + assert_eq!(lock, TWO_REGISTRY_LOCK); + } + + /// #670, `remove` in the other order: already clean before the fix, + /// and must stay so. + #[tokio::test] + async fn revert_two_packages_creator_last_removes_created_sources_table() { + let tmp = write_pair(TWO_REGISTRY_PYPROJECT, TWO_REGISTRY_LOCK).await; + let [idna, six] = vendor_two(tmp.path()).await; + + revert_in_order(tmp.path(), [six, idna]).await; + let (pyproject, lock) = read_pair(tmp.path()).await; + assert_eq!(pyproject, TWO_REGISTRY_PYPROJECT); + assert_eq!(lock, TWO_REGISTRY_LOCK); + } + + /// A ledger written before the flags were shared (only the creator + /// flagged) is repaired on load, so it unwinds cleanly too. + #[tokio::test] + async fn revert_two_packages_repairs_a_creator_only_ledger() { + let tmp = write_pair(TWO_REGISTRY_PYPROJECT, TWO_REGISTRY_LOCK).await; + let [idna, six] = vendor_two(tmp.path()).await; + let path = tmp.path().join(".socket/vendor/state.json"); + let mut ledger: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + let six_uv = &mut ledger["entries"][six]["uv"]; + six_uv + .as_object_mut() + .unwrap() + .remove("createdSourcesTable"); + tokio::fs::write(&path, serde_json::to_vec_pretty(&ledger).unwrap()) + .await + .unwrap(); + + revert_in_order(tmp.path(), [idna, six]).await; + let (pyproject, _) = read_pair(tmp.path()).await; + assert_eq!(pyproject, TWO_REGISTRY_PYPROJECT); + } + + /// A `[tool.uv.sources]` the user wrote is never ours, however many + /// packages are vendored into it. + #[tokio::test] + async fn revert_two_packages_keeps_a_user_sources_table() { + let user = format!("{TWO_REGISTRY_PYPROJECT}\n[tool.uv.sources]\n"); + let tmp = write_pair(&user, TWO_REGISTRY_LOCK).await; + let [idna, six] = vendor_two(tmp.path()).await; + + revert_in_order(tmp.path(), [idna, six]).await; + let (pyproject, _) = read_pair(tmp.path()).await; + assert_eq!(pyproject, user); + } + /// wire_uv must refuse an in-sync pair (defensive parity with the /// poetry/pdm/pipenv backends): re-wiring would append a SECOND `path` /// key to the requires-dist entry (duplicate-key TOML — the lock stops diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index 63f676467..a1a850061 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -364,6 +364,70 @@ impl VendorState { .flatten() .collect() } + + /// The created-scaffold flags every entry should carry: the union + /// over the entries that wire the same project-root files (every uv + /// entry shares `pyproject.toml`'s `[tool.uv.sources]`; every pnpm + /// entry shares `package.json` and `pnpm-workspace.yaml`). + fn shared_scaffold_flags(&self) -> (bool, PnpmMeta) { + let mut uv = false; + let mut pnpm = PnpmMeta::default(); + for entry in self.entries.values() { + if let Some(m) = &entry.uv { + uv |= m.created_sources_table; + } + if let Some(m) = &entry.pnpm { + pnpm.created_overrides_table |= m.created_overrides_table; + pnpm.created_pnpm_table |= m.created_pnpm_table; + pnpm.created_workspace_file |= m.created_workspace_file; + pnpm.created_workspace_overrides |= m.created_workspace_overrides; + } + } + (uv, pnpm) + } + + /// Whether every entry already carries [`Self::shared_scaffold_flags`]. + fn scaffold_flags_shared(&self) -> bool { + let (uv, pnpm) = self.shared_scaffold_flags(); + self.entries.values().all(|entry| { + entry + .uv + .as_ref() + .is_none_or(|m| m.created_sources_table == uv) + && entry.pnpm.as_ref().is_none_or(|m| *m == pnpm) + }) + } + + /// Make "vendor created this table/file" a property of the shared + /// scaffold rather than of the one entry that happened to be wired + /// first (#636, #670). Each later package finds the scaffold already + /// there and records `false`, and revert removes an emptied scaffold + /// only when the entry it reverts carries the flag, so a residue was + /// left unless the creator was reverted last. With the flags shared, + /// whichever entry empties the scaffold removes it, in any order; + /// revert still keeps a scaffold that holds anything else. + pub fn share_scaffold_flags(&mut self) { + let (uv, pnpm) = self.shared_scaffold_flags(); + for entry in self.entries.values_mut() { + if let Some(m) = entry.uv.as_mut() { + m.created_sources_table = uv; + } + if let Some(m) = entry.pnpm.as_mut() { + *m = pnpm.clone(); + } + } + } + + /// `self` with [`Self::share_scaffold_flags`] applied, copied only + /// when that changes something. + fn with_shared_scaffold_flags(state: Arc) -> Arc { + if state.scaffold_flags_shared() { + return state; + } + let mut owned = (*state).clone(); + owned.share_scaffold_flags(); + Arc::new(owned) + } } /// Whether `purl` is vendor-owned according to `keys`, a @@ -594,7 +658,9 @@ fn state_path(project_root: &Path) -> PathBuf { pub async fn load_state(project_root: &Path) -> std::io::Result { let path = state_path(project_root); if let Some(state) = crate::utils::group_commit::read_value::(&path) { - return Ok((*state).clone()); + let mut state = (*state).clone(); + state.share_scaffold_flags(); + return Ok(state); } match read_regular_to_bytes(&path).await { Ok(bytes) => parse_state(&bytes, &path), @@ -603,9 +669,17 @@ pub async fn load_state(project_root: &Path) -> std::io::Result { } } -/// The ledger bytes as a [`VendorState`]; see [`load_state`] for the +/// The ledger bytes as a [`VendorState`], created-scaffold flags shared +/// across entries ([`VendorState::share_scaffold_flags`], which also +/// repairs a ledger written before they were); see [`load_state`] for the /// `mode`-tagged exception. fn parse_state(bytes: &[u8], path: &Path) -> std::io::Result { + let mut state = parse_state_raw(bytes, path)?; + state.share_scaffold_flags(); + Ok(state) +} + +fn parse_state_raw(bytes: &[u8], path: &Path) -> std::io::Result { if super::ledger_snapshots::may_have_snapshots(bytes) { return parse_snapshot_state(bytes, path); } @@ -659,7 +733,7 @@ static STATE_MEMO: ParseMemo = ParseMemo::new(); pub(crate) async fn load_state_shared(project_root: &Path) -> std::io::Result> { let path = state_path(project_root); if let Some(state) = crate::utils::group_commit::read_value::(&path) { - return Ok(state); + return Ok(VendorState::with_shared_scaffold_flags(state)); } match read_regular_to_bytes(&path).await { Ok(bytes) => STATE_MEMO.parse(&bytes, || parse_state(&bytes, &path)), @@ -736,7 +810,17 @@ pub async fn save_state_shared( /// `super::ledger_snapshots`); a ledger without one keeps its version-1 /// form. fn ledger_value(state: &VendorState) -> std::io::Result { - let mut ledger = serde_json::to_value(state).map_err(std::io::Error::other)?; + // A package wired this run beside one that created a shared scaffold + // records `false`; persist the shared flags so a later run's revert + // order cannot matter. + let mut ledger = if state.scaffold_flags_shared() { + serde_json::to_value(state) + } else { + let mut shared = state.clone(); + shared.share_scaffold_flags(); + serde_json::to_value(&shared) + } + .map_err(std::io::Error::other)?; super::ledger_snapshots::encode(&mut ledger); Ok(ledger) } @@ -1439,6 +1523,93 @@ mod tests { ); } + /// #636 / #670: the created-scaffold flags the first package recorded + /// are shared with every entry wiring the same files — on load (which + /// repairs a ledger written before) and on save — so revert order no + /// longer decides whether the emptied scaffold is removed. + #[tokio::test] + async fn created_scaffold_flags_are_shared_across_entries() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let with = |flavor: &str, uv: Option, pnpm: Option| { + let mut e = sample_entry(); + e.flavor = Some(flavor.into()); + e.uv = uv; + e.pnpm = pnpm; + e + }; + let uv_meta = |created| UvMeta { + dep_class: "direct".into(), + original_specifier: None, + created_sources_table: created, + lock_revision: None, + }; + let creator = PnpmMeta { + created_overrides_table: true, + created_pnpm_table: true, + created_workspace_file: true, + created_workspace_overrides: false, + }; + let mut state = VendorState::new(); + for (key, entry) in [ + ("pkg:npm/a@1.0.0", with("pnpm", None, Some(creator.clone()))), + ( + "pkg:npm/b@1.0.0", + with("pnpm", None, Some(PnpmMeta::default())), + ), + ("pkg:pypi/c@1.0.0", with("uv", Some(uv_meta(true)), None)), + ("pkg:pypi/d@1.0.0", with("uv", Some(uv_meta(false)), None)), + ("pkg:cargo/e@1.0.0", sample_entry()), + ] { + state.entries.insert(key.into(), entry); + } + + save_state(root, &state).await.unwrap(); + let text = tokio::fs::read_to_string(root.join(VENDOR_STATE_REL)) + .await + .unwrap(); + assert_eq!(text.matches("\"createdSourcesTable\": true").count(), 2); + assert_eq!(text.matches("\"createdOverridesTable\": true").count(), 2); + + // A ledger with only the creator flagged (as older releases wrote + // it) loads with the flags shared. + let mut legacy: serde_json::Value = serde_json::from_str(&text).unwrap(); + legacy["entries"]["pkg:npm/b@1.0.0"]["pnpm"] = serde_json::json!({}); + legacy["entries"]["pkg:pypi/d@1.0.0"]["uv"] + .as_object_mut() + .unwrap() + .remove("createdSourcesTable"); + tokio::fs::write( + root.join(VENDOR_STATE_REL), + serde_json::to_vec_pretty(&legacy).unwrap(), + ) + .await + .unwrap(); + let loaded = load_state(root).await.unwrap(); + assert_eq!(loaded.entries["pkg:npm/b@1.0.0"].pnpm, Some(creator)); + assert!( + loaded.entries["pkg:pypi/d@1.0.0"] + .uv + .as_ref() + .unwrap() + .created_sources_table + ); + assert!(loaded.entries["pkg:cargo/e@1.0.0"].pnpm.is_none()); + assert!(loaded.entries["pkg:cargo/e@1.0.0"].uv.is_none()); + + // Nothing flagged stays unflagged. + let mut plain = VendorState::new(); + plain.entries.insert( + "pkg:npm/a@1.0.0".into(), + with("pnpm", None, Some(PnpmMeta::default())), + ); + plain.share_scaffold_flags(); + assert_eq!( + plain.entries["pkg:npm/a@1.0.0"].pnpm, + Some(PnpmMeta::default()) + ); + } + #[test] fn v2_meta_empty_inner_fields_do_not_serialize() { let pnpm = serde_json::to_string(&PnpmMeta::default()).unwrap(); From 8cd72d721ae6ed75786eae093372bad4c71f2883 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:40:43 +0000 Subject: [PATCH 3/5] Test pnpm two-package revert through the CLI Vendors two packages into a pnpm 9.0-lock project with no pnpm table and no workspace file, then runs vendor --revert end to end, checking package.json, the lock and pnpm-workspace.yaml all come back exact (#636). Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_pnpm_build.rs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index 51bc2de52..954733619 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -1297,6 +1297,134 @@ fn pnpm8_lock_v60_hermetic_splice_idempotency_and_revert() { run_legacy_hermetic(PNPM8_LOCK, PNPM8_AFTER_TEMPLATE, "6.0"); } +/// #636: two packages vendored into a project with no `pnpm` table and no +/// pnpm-workspace.yaml, then `vendor --revert` (purl order: is-number, the +/// entry that created both, goes first). The lock was already byte-exact; +/// package.json kept `"pnpm": { "overrides": {} }` and the scaffolded +/// workspace file stayed behind. Both must now come back byte-identical. +#[test] +fn pnpm9_two_packages_vendor_revert_removes_created_scaffold() { + const LOCK: &str = "lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + is-number: + specifier: 7.0.0 + version: 7.0.0 + left-pad: + specifier: 1.3.0 + version: 1.3.0 + +packages: + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + is-number@7.0.0: {} + + left-pad@1.3.0: {} +"; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + let orig = b"module.exports = function (x) { return x; };\n".to_vec(); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let mut patches = serde_json::Map::new(); + for (name, version, uuid) in [ + ("left-pad", "1.3.0", UUID), + ("is-number", "7.0.0", "2b3c4d5e-6f70-4a1b-8c2d-0123456789ac"), + ] { + let dir = proj.join("node_modules").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("package.json"), + format!("{{\"name\":\"{name}\",\"version\":\"{version}\"}}\n"), + ) + .unwrap(); + std::fs::write(dir.join("index.js"), &orig).unwrap(); + patches.insert( + format!("pkg:npm/{name}@{version}"), + serde_json::json!({ + "uuid": uuid, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(&orig), + "afterHash": git_sha256(&patched), + }}, + "vulnerabilities": {}, + "description": "two-package scaffold patch", + "license": "MIT", + "tier": "free", + }), + ); + } + let socket = proj.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_string_pretty(&serde_json::json!({ "patches": patches })).unwrap(), + ) + .unwrap(); + std::fs::write(socket.join("blobs").join(git_sha256(&patched)), &patched).unwrap(); + + let pkg_before = "{\n \"name\": \"fx\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \ + \"dependencies\": {\n \"left-pad\": \"1.3.0\",\n \ + \"is-number\": \"7.0.0\"\n }\n}\n"; + std::fs::write(proj.join("package.json"), pkg_before).unwrap(); + std::fs::write(proj.join("pnpm-lock.yaml"), LOCK).unwrap(); + let cwd = proj.to_str().unwrap(); + + let (code, stdout, stderr) = + run_socket(&proj, &["vendor", "--json", "--offline", "--cwd", cwd]); + assert_eq!(code, 0, "vendor.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env = parse_envelope(&stdout); + assert_eq!( + env["summary"]["applied"], 2, + "both packages vendored: {env}" + ); + assert!( + proj.join("pnpm-workspace.yaml").is_file(), + "a 9.0 lock mirrors overrides into a scaffolded workspace file" + ); + + let (code, stdout, stderr) = run_socket( + &proj, + &["vendor", "--revert", "--json", "--offline", "--cwd", cwd], + ); + assert_eq!(code, 0, "revert.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let renv = parse_envelope(&stdout); + assert_eq!( + renv["summary"]["removed"], 2, + "both entries reverted: {renv}" + ); + assert_eq!( + std::fs::read_to_string(proj.join("package.json")).unwrap(), + pkg_before, + "no empty pnpm.overrides left behind" + ); + assert_eq!( + std::fs::read_to_string(proj.join("pnpm-lock.yaml")).unwrap(), + LOCK + ); + assert!( + !proj.join("pnpm-workspace.yaml").exists(), + "the scaffolded pnpm-workspace.yaml must be deleted" + ); + assert!(!proj.join(".socket/vendor").exists()); +} + /// The tarball's SRI (`sha512-`), the integrity spelling pnpm locks /// record. fn tarball_integrity(tgz: &Path) -> String { From 8212cc8a6bd74074c26b523b1a0322c4bc5cf700 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:53:49 +0000 Subject: [PATCH 4/5] Update pnpm legacy-ledger golden to clean revert The pnpm legacy-ledger fixture recorded what the earlier binary left after reverting two vendored packages: an empty pnpm.overrides in package.json and the scaffolded pnpm-workspace.yaml (#636). Reverting that same ledger now removes both, so the golden is the restored package.json (still re-indented, as before) and no workspace file. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/fixtures/legacy-ledgers/pnpm/reverted/package.json | 3 --- .../fixtures/legacy-ledgers/pnpm/reverted/pnpm-workspace.yaml | 3 --- 2 files changed, 6 deletions(-) delete mode 100644 crates/socket-patch-cli/tests/fixtures/legacy-ledgers/pnpm/reverted/pnpm-workspace.yaml diff --git a/crates/socket-patch-cli/tests/fixtures/legacy-ledgers/pnpm/reverted/package.json b/crates/socket-patch-cli/tests/fixtures/legacy-ledgers/pnpm/reverted/package.json index 0c21e32ea..439d81d55 100644 --- a/crates/socket-patch-cli/tests/fixtures/legacy-ledgers/pnpm/reverted/package.json +++ b/crates/socket-patch-cli/tests/fixtures/legacy-ledgers/pnpm/reverted/package.json @@ -5,8 +5,5 @@ "dependencies": { "alpha": "1.0.0", "beta": "1.0.0" - }, - "pnpm": { - "overrides": {} } } diff --git a/crates/socket-patch-cli/tests/fixtures/legacy-ledgers/pnpm/reverted/pnpm-workspace.yaml b/crates/socket-patch-cli/tests/fixtures/legacy-ledgers/pnpm/reverted/pnpm-workspace.yaml deleted file mode 100644 index 36659cdc6..000000000 --- a/crates/socket-patch-cli/tests/fixtures/legacy-ledgers/pnpm/reverted/pnpm-workspace.yaml +++ /dev/null @@ -1,3 +0,0 @@ -packages: - - '.' -overrides: From 0167c0b8ec5dd323172137a2eb4eabae2008c7a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:54:36 +0000 Subject: [PATCH 5/5] Assert pnpm group-commit revert drops scaffold The group-commit round-trip test exempted pnpm from the byte-exact check because a two-package revert left the emptied pnpm.overrides and the scaffolded pnpm-workspace.yaml behind (#636). Now that both are removed, say why pnpm is still exempt (the fixture's minified package.json comes back re-indented) and assert the scaffold is gone. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/vendor_group_commit_e2e.rs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/tests/vendor_group_commit_e2e.rs b/crates/socket-patch-cli/tests/vendor_group_commit_e2e.rs index 728b0e70d..7759a18d6 100644 --- a/crates/socket-patch-cli/tests/vendor_group_commit_e2e.rs +++ b/crates/socket-patch-cli/tests/vendor_group_commit_e2e.rs @@ -113,12 +113,22 @@ fn group_commit_ends_where_per_package_commits_end_for_every_ecosystem() { masked_tree(&oracle.root), "{eco}: same tree after --revert" ); - // Two ecosystems keep scaffolding their revert does not remove when - // TWO packages were vendored (the emptied pnpm override tables and - // workspace file; the catch-all `` nuget adds - // to a config that had none). That predates the group commit — the - // oracle leaves the same bytes, asserted just above — so only the - // others are held to a byte-exact round trip here. + // Two ecosystems do not round-trip byte for byte when TWO packages + // were vendored: pnpm rewrites the fixture's minified package.json + // with an indent, and nuget keeps the catch-all + // `` it adds to a config that had none. That + // predates the group commit — the oracle leaves the same bytes, + // asserted just above — so only the others are held to a + // byte-exact round trip here. pnpm's created scaffold itself is + // gone (#636): no emptied `pnpm.overrides`, no workspace file. + if *eco == "pnpm" { + assert!( + !grouped.root.join("pnpm-workspace.yaml").exists(), + "pnpm: the scaffolded workspace file is removed" + ); + let pkg = std::fs::read_to_string(grouped.root.join("package.json")).unwrap(); + assert!(!pkg.contains("\"pnpm\""), "pnpm: {pkg}"); + } if !["pnpm", "nuget"].contains(eco) { let after = masked_tree(&grouped.root); assert!(