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 { 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: 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!( 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();