Skip to content

Commit e02c7ff

Browse files
Fix unquoted scoped name in pnpm 7/8 vendored lock (#956) (#961)
* Start fix for #956 Assisted-by: Claude Code:claude-opus-5-5 * Quote scoped names in pnpm 7/8 vendored locks Vendoring a scoped package (@scope/pkg) into a pnpm 7 (lock 5.4) or pnpm 8 (lock 6.0) project wrote `name: @scope/pkg` into the rekeyed packages entry. A bare `@` cannot start a YAML scalar, so pnpm refused the whole lock with ERR_PNPM_BROKEN_LOCKFILE: every frozen install failed after a scan that reported success, and lock-only VEX kept attesting not_affected from a lock pnpm could not read. The name is now written through the shared YAML scalar quoting, which gives `name: '@scope/pkg'`, byte-identical to what pnpm 7.33.7 and 8.15.9 serialize themselves for the same override. Tests: a byte-exact unit oracle captured from real pnpm 7/8 for @isaacs/string-locale-compare (vendor, in-sync re-run, revert), and scoped real-pnpm lifecycle legs (frozen install, moved checkout, manifest-less VEX, revert) in e2e_vendor_pnpm_build, also run in the pinned pnpm 7/8 matrix. Fixes #956. Assisted-by: Claude Code:claude-opus-5-5 * Skip scoped legacy leg on pnpm 8.0.0-8.1.0 pnpm 8.0.0 and 8.1.0 refuse their own lock for a scoped file: tarball override under --frozen-lockfile (ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY on the key they just wrote); 8.1.1 fixed it. Measured with real pnpm on Node 16: the lock pnpm itself writes fails the same way, so no vendored scoped lock can pass there. The pinned matrix keeps the unscoped leg on those versions and runs the scoped leg everywhere else. Assisted-by: Claude Code:claude-opus-5-5 * Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. (cherry picked from commit 659ac2c) Ported from #878 so this PR's CI is green while main's digest guard test is red; it no-ops once #878 lands. Assisted-by: Claude Code:claude-opus-5-5 * Re-vendor rewrites a stale unquoted scoped name edit_packages treated a packages entry as in sync once its file: key and resolution matched, without looking at name:. A lock vendored by a release before the #956 fix still carries `name: @scope/pkg`, which pnpm 7/8 can't load, so a later vendor reported the package already vendored and left the lock broken. The in-sync check now also requires the canonical quoted name: line, so the old spelling is rewritten like any other stale wiring. The new test revendor_heals_an_unquoted_scoped_name fails without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UepoBazrbnBjy7HkD9YVJN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd7dd92 commit e02c7ff

2 files changed

Lines changed: 305 additions & 12 deletions

File tree

‎crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs‎

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ fn absolutizes_file_overrides(pm: &str) -> bool {
149149
matches!(parts.as_slice(), [9, 0, patch] if *patch <= 4)
150150
}
151151

152+
/// pnpm 8.0.0-8.1.0 refuse their OWN lock for a scoped `file:` tarball
153+
/// override under `--frozen-lockfile` (ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY
154+
/// on the `file:` key they just wrote; measured 2026-10-06: 8.1.0 refuses,
155+
/// 8.1.1 accepts), so no vendored scoped lock can pass there.
156+
fn refuses_own_scoped_file_override(pm: &str) -> bool {
157+
let Some(v) = pm.strip_prefix("pnpm@") else {
158+
return false;
159+
};
160+
let parts: Vec<u32> = v.split('.').filter_map(|p| p.parse().ok()).collect();
161+
matches!(parts.as_slice(), [8, 0, _] | [8, 1, 0])
162+
}
163+
152164
fn has_corepack_pm(pm: &str) -> bool {
153165
// Isolated too: this probe is what actually downloads the package manager
154166
// the first time, and corepack stores it under `COREPACK_HOME`.
@@ -888,9 +900,13 @@ fn assert_manifestless_vendored_vex(
888900
let state = fresh.join(".socket/vendor/state.json");
889901
let lock_wired = std::fs::read(&lock).expect("fresh checkout lock");
890902
let pkg_wired = std::fs::read(fresh.join("package.json")).unwrap();
903+
let (dep, version) = purl
904+
.strip_prefix("pkg:npm/")
905+
.and_then(|nv| nv.rsplit_once('@'))
906+
.expect("an npm purl");
891907
assert!(
892908
fresh
893-
.join(format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"))
909+
.join(format!(".socket/vendor/npm/{UUID}/{dep}-{version}.tgz"))
894910
.is_file(),
895911
"[{tag}] the committed tarball must travel with the checkout"
896912
);
@@ -1025,7 +1041,7 @@ fn assert_manifestless_vendored_vex(
10251041
);
10261042
assert!(plain.status.success(), "[{tag}] plain install: {plain:?}");
10271043
assert_eq!(
1028-
std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(),
1044+
std::fs::read(fresh.join("node_modules").join(dep).join("index.js")).unwrap(),
10291045
patched,
10301046
"[{tag}] a plain install must re-apply the overrides (vendored bytes)"
10311047
);
@@ -1062,8 +1078,30 @@ async fn pnpm_pinned_matrix_vendored_lifecycle_and_manifestless_vex() {
10621078
run_pnpm_capstone(&pm, VendorDriver::VendorCli).await;
10631079
run_pnpm_capstone(&pm, VendorDriver::GetUuid).await;
10641080
}
1065-
7 => off_runtime(|| run_legacy_capstone(&pm, "lockfileVersion: 5.4", "proj")),
1066-
8 => off_runtime(|| run_legacy_capstone(&pm, "lockfileVersion: '6.0'", "proj")),
1081+
7 => off_runtime(|| {
1082+
run_legacy_capstone(&pm, "lockfileVersion: 5.4", "proj");
1083+
run_legacy_capstone_for(
1084+
&pm,
1085+
"lockfileVersion: 5.4",
1086+
"proj",
1087+
SCOPED_DEP,
1088+
SCOPED_DEP_VERSION,
1089+
);
1090+
}),
1091+
8 => off_runtime(|| {
1092+
run_legacy_capstone(&pm, "lockfileVersion: '6.0'", "proj");
1093+
if refuses_own_scoped_file_override(&pm) {
1094+
println!("SKIP scoped legacy leg ({pm}): pnpm refuses its own scoped file: lock");
1095+
return;
1096+
}
1097+
run_legacy_capstone_for(
1098+
&pm,
1099+
"lockfileVersion: '6.0'",
1100+
"proj",
1101+
SCOPED_DEP,
1102+
SCOPED_DEP_VERSION,
1103+
);
1104+
}),
10671105
_ => off_runtime(|| run_unsupported_lock_refusal(&pm)),
10681106
}
10691107
}
@@ -1637,6 +1675,43 @@ fn pnpm8_real_lifecycle_under_yaml_indicator_paths() {
16371675
}
16381676
}
16391677

1678+
/// #956: a scoped package's rekeyed packages entry must keep the lock
1679+
/// loadable. An unquoted `name: @scope/pkg` is invalid YAML, so every
1680+
/// frozen install failed with ERR_PNPM_BROKEN_LOCKFILE after a successful
1681+
/// vendor.
1682+
const SCOPED_DEP: &str = "@isaacs/string-locale-compare";
1683+
const SCOPED_DEP_VERSION: &str = "1.1.0";
1684+
1685+
#[test]
1686+
fn pnpm7_real_lifecycle_scoped_package() {
1687+
if !has_corepack_pm(PNPM_LEGACY_7) {
1688+
println!("SKIP: `corepack {PNPM_LEGACY_7}` unavailable");
1689+
return;
1690+
}
1691+
run_legacy_capstone_for(
1692+
PNPM_LEGACY_7,
1693+
"lockfileVersion: 5.4",
1694+
"proj",
1695+
SCOPED_DEP,
1696+
SCOPED_DEP_VERSION,
1697+
);
1698+
}
1699+
1700+
#[test]
1701+
fn pnpm8_real_lifecycle_scoped_package() {
1702+
if !has_corepack_pm(PNPM_LEGACY_8) {
1703+
println!("SKIP: `corepack {PNPM_LEGACY_8}` unavailable");
1704+
return;
1705+
}
1706+
run_legacy_capstone_for(
1707+
PNPM_LEGACY_8,
1708+
"lockfileVersion: '6.0'",
1709+
"proj",
1710+
SCOPED_DEP,
1711+
SCOPED_DEP_VERSION,
1712+
);
1713+
}
1714+
16401715
/// Full lifecycle against the REAL pinned legacy pnpm, spike-proven flags:
16411716
///
16421717
/// 1. online fixture install (skip when the registry is unreachable);
@@ -1653,14 +1728,19 @@ fn pnpm8_real_lifecycle_under_yaml_indicator_paths() {
16531728
/// 5. idempotent re-vendor (byte-stable, already_vendored);
16541729
/// 6. revert restores both files byte-identical and removes .socket/vendor.
16551730
fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) {
1731+
run_legacy_capstone_for(pm, lock_head, proj_dir, DEP, DEP_VERSION);
1732+
}
1733+
1734+
/// [`run_legacy_capstone`] for any registry package `dep@version`.
1735+
fn run_legacy_capstone_for(pm: &str, lock_head: &str, proj_dir: &str, dep: &str, version: &str) {
16561736
let tmp = tempfile::tempdir().unwrap();
16571737
let proj = tmp.path().join(proj_dir);
16581738
std::fs::create_dir_all(&proj).unwrap();
16591739
let pkg_doc = serde_json::json!({
16601740
"name": "pnpm-legacy-capstone",
16611741
"version": "0.0.0",
16621742
"private": true,
1663-
"dependencies": { DEP: DEP_VERSION },
1743+
"dependencies": { dep: version },
16641744
});
16651745
std::fs::write(
16661746
proj.join("package.json"),
@@ -1688,10 +1768,10 @@ fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) {
16881768
return;
16891769
}
16901770

1691-
let installed_index = proj.join("node_modules").join(DEP).join("index.js");
1771+
let installed_index = proj.join("node_modules").join(dep).join("index.js");
16921772
let orig = std::fs::read(&installed_index).expect("installed index.js");
16931773
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
1694-
let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}");
1774+
let purl = format!("pkg:npm/{dep}@{version}");
16951775
stage_patch(&proj, &purl, "package/index.js", &orig, &patched);
16961776

16971777
let lock_path = proj.join("pnpm-lock.yaml");
@@ -1722,18 +1802,24 @@ fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) {
17221802
let env = parse_envelope(&stdout);
17231803
assert_eq!(env["status"], "success", "envelope: {env}");
17241804
assert_eq!(env["summary"]["applied"], 1, "{env}");
1725-
let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz");
1805+
let tgz_rel = format!(".socket/vendor/npm/{UUID}/{dep}-{version}.tgz");
17261806
assert!(proj.join(&tgz_rel).is_file());
17271807
assert!(
17281808
!proj.join("pnpm-workspace.yaml").exists(),
17291809
"legacy wiring must not create pnpm-workspace.yaml ({pm})"
17301810
);
17311811
let lock_after = std::fs::read_to_string(&lock_path).unwrap();
1812+
// pnpm single-quotes an `@`-leading (scoped) key.
1813+
let override_key = if dep.starts_with('@') {
1814+
format!("'{dep}@{version}'")
1815+
} else {
1816+
format!("{dep}@{version}")
1817+
};
17321818
let abs = socket_patch_core::vendor::pnpm_lock_legacy::normalize_canonical_root(
17331819
&std::fs::canonicalize(&proj).unwrap().display().to_string(),
17341820
);
17351821
assert!(
1736-
lock_after.contains(&format!("{DEP}@{DEP_VERSION}: file:{tgz_rel}")),
1822+
lock_after.contains(&format!("{override_key}: file:{tgz_rel}")),
17371823
"lock overrides must point at the vendored tarball ({pm}):\n{lock_after}"
17381824
);
17391825
assert!(
@@ -1857,7 +1943,7 @@ fn run_legacy_capstone(pm: &str, lock_head: &str, proj_dir: &str) {
18571943
String::from_utf8_lossy(&plain.stderr),
18581944
);
18591945
let fresh_installed =
1860-
std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap();
1946+
std::fs::read(fresh.join("node_modules").join(dep).join("index.js")).unwrap();
18611947
assert_eq!(
18621948
fresh_installed, patched,
18631949
"moved-checkout install must land the patched bytes ({pm})"

0 commit comments

Comments
 (0)