diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index f56f2e289..14fa7489c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -1056,3 +1056,90 @@ fn run_revert_after_lock_change(change: LockChange) { "the reverted project installs the registry bytes" ); } + +/// #783: a user's parent-scoped `resolutions` selector for the patched +/// package (`pkg-a/left-pad`) is an override vendoring must not stack its +/// own bare pin beside. Vendoring used to report success and leave a lock +/// every `yarn install --immutable` rejected (YN0028). It now refuses with +/// `vendor_override_conflict` and writes nothing, so the project still +/// installs immutably. +#[test] +fn issue_783_parent_scoped_resolution_refuses_vendoring() { + let pm = yarn_berry(); + if !has_corepack_pm(pm) { + skip!("SKIP e2e_vendor_yarn_berry_build (#783): `corepack {pm}` unavailable"); + return; + } + for selector in ["pkg-a/left-pad", "pkg-a/left-pad@^1.3.0"] { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + let global = tmp.path().join("yarn-global"); + let env = [("YARN_GLOBAL_FOLDER", global.to_str().unwrap())]; + std::fs::create_dir_all(proj.join("packages/pkg-a")).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"root","private":true,"workspaces":["packages/*"],"resolutions":{{"{selector}":"{DEP_VERSION}"}}}}"# + ), + ) + .unwrap(); + std::fs::write( + proj.join("packages/pkg-a/package.json"), + format!(r#"{{"name":"pkg-a","version":"1.0.0","dependencies":{{"{DEP}":"^{DEP_VERSION}"}}}}"#), + ) + .unwrap(); + std::fs::write( + proj.join(".yarnrc.yml"), + "nodeLinker: node-modules\nenableGlobalCache: false\n", + ) + .unwrap(); + let out = corepack(&proj, pm, &["install"], &env); + if !out.status.success() { + skip!( + "SKIP e2e_vendor_yarn_berry_build (#783 {selector}): fixture install failed:\n{}", + yarn_berry_common::yarn_output(&out) + ); + continue; + } + let pkg_before = std::fs::read(proj.join("package.json")).unwrap(); + let lock_before = std::fs::read(proj.join("yarn.lock")).unwrap(); + + let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")).unwrap(); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + stage_patch( + &proj, + &format!("pkg:npm/{DEP}@{DEP_VERSION}"), + "package/index.js", + &orig, + &patched, + ); + let cwd = proj.to_str().unwrap(); + let (code, stdout, stderr) = + run_socket(&proj, &["vendor", "--json", "--offline", "--cwd", cwd]); + assert_eq!( + code, 1, + "{selector}: vendor must refuse.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains("vendor_override_conflict"), + "{selector}: refusal code.\nstdout:\n{stdout}" + ); + assert_eq!( + std::fs::read(proj.join("package.json")).unwrap(), + pkg_before, + "{selector}: package.json untouched" + ); + assert_eq!( + std::fs::read(proj.join("yarn.lock")).unwrap(), + lock_before, + "{selector}: yarn.lock untouched" + ); + + let out = corepack(&proj, pm, &["install", "--immutable"], &env); + assert!( + out.status.success(), + "{selector}: the project must still install immutably:\n{}", + yarn_berry_common::yarn_output(&out) + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 271afdee2..20a6f0333 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -589,7 +589,8 @@ fn root_workspace_gate(blocks: &[LockBlock]) -> Result, name: &str, @@ -604,17 +605,20 @@ fn resolutions_gate( ))); }; for (selector, value) in res_obj { - let sel_name = split_pattern(selector) - .map(|(n, _)| n) - .unwrap_or(selector.as_str()); - if sel_name != name { + // The package the selector overrides, through any parent scope + // (`pkg-a/left-pad`, `**/left-pad`): yarn applies those before + // our bare pin, so they conflict just like a bare entry (#783). + if crate::formats::yarn::patterns::resolution_selector_target(selector) != Some(name) { continue; } // Our own (possibly stale-uuid) entry is fine to overwrite; a - // user-authored override is never clobbered silently. - let ours = value - .as_str() - .is_some_and(|v| parse_vendor_path(v).is_some_and(|p| p.eco == "npm")); + // user-authored override is never clobbered silently. Vendoring + // only ever writes the bare `name` key, so a parent-scoped + // selector pointing into .socket/vendor is not ours. + let ours = selector == name + && value + .as_str() + .is_some_and(|v| parse_vendor_path(v).is_some_and(|p| p.eco == "npm")); if ours { continue; } @@ -626,9 +630,9 @@ fn resolutions_gate( "vendor_override_conflict", format!( "{PACKAGE_JSON} already has a resolutions entry for `{selector}` \ - ({value}); vendor will not overwrite a user-authored override (an \ - exact-version pin `\"{name}\": \"{version}\"` is taken over \ - automatically)" + ({value}); vendor will not overwrite a user-authored override (only \ + a bare exact-version pin `\"{name}\": \"{version}\"` is taken over \ + automatically; parent-scoped selectors always refuse)" ), ))); } @@ -2302,6 +2306,56 @@ __metadata: assert_eq!(tokio::fs::read(fx.pkg_path()).await.unwrap(), fx.pkg_bytes); } + /// #783: a parent-scoped or glob selector that overrides the target + /// (`pkg-a/left-pad`, `pkg-a/left-pad@^1.3.0`, `/left-pad`, + /// `@scope/parent/left-pad`, `**/left-pad`) is a user-authored override + /// too. Yarn applies it before our bare pin, so vendoring beside it + /// leaves a lock `--immutable` rejects: refuse and write nothing. A + /// selector naming the target only as a PARENT overrides another + /// package and still vendors. + #[tokio::test] + async fn parent_scoped_resolutions_selector_is_refused() { + for selector in [ + "pkg-a/left-pad", + "pkg-a/left-pad@^1.3.0", + "vendor-spike/left-pad", + "@scope/parent/left-pad", + "**/left-pad", + ] { + let pkg = B3_BEFORE_PKG.replace( + " }\n}", + &format!(" }},\n \"resolutions\": {{\n \"{selector}\": \"1.3.0\"\n }}\n}}"), + ); + assert_ne!(pkg, B3_BEFORE_PKG, "the fixture edit must hit"); + let fx = fixture_with(&pkg, B3_BEFORE_LOCK).await; + let detail = expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + assert!(detail.contains(selector), "{selector}: {detail}"); + fx.assert_untouched().await; + } + + // Vendoring only writes the bare key, so a parent-scoped selector + // pointing into .socket/vendor (hand-copied, or another uuid) is + // not ours to overwrite: it still refuses. + let pkg = B3_BEFORE_PKG.replace( + " }\n}", + &format!( + " }},\n \"resolutions\": {{\n \"pkg-a/left-pad\": \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz\"\n }}\n}}" + ), + ); + let fx = fixture_with(&pkg, B3_BEFORE_LOCK).await; + expect_refused(fx.vendor(false).await, "vendor_override_conflict"); + fx.assert_untouched().await; + + let pkg = B3_BEFORE_PKG.replace( + " }\n}", + " },\n \"resolutions\": {\n \"left-pad/is-odd\": \"1.0.0\"\n }\n}", + ); + let fx = fixture_with(&pkg, B3_BEFORE_LOCK).await; + let (result, entry, _) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_some()); + } + /// A user-authored BARE-name pin to the exact version being vendored is /// taken over: the value moves to our spec, the wiring records the pin /// as `original`, and revert restores it (table kept). Range-keyed