Skip to content

Commit 93c3e32

Browse files
committed
Honor pnpm workspace lockfile configuration precedence
1 parent afce769 commit 93c3e32

2 files changed

Lines changed: 178 additions & 26 deletions

File tree

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,3 +1264,68 @@ async fn hosted_scan_with_pnpm_lockfile_dir_elsewhere_refuses() {
12641264
let (code, doc) = run_hosted_json(&proj, &server.uri());
12651265
assert_refused_lock_elsewhere(code, &doc, &lock, &before, &proj);
12661266
}
1267+
1268+
/// pnpm inherits workspace-root config even when
1269+
/// invoked from a member. Absolute npmrc paths isolate inheritance; relative
1270+
/// YAML paths are resolved from the member cwd by pnpm 10.34.5 itself.
1271+
async fn assert_workspace_configured_lock_refused(case: &str) {
1272+
let server = MockServer::start().await;
1273+
mock_discovery(&server).await;
1274+
mock_reference(&server).await;
1275+
let tmp = tempfile::tempdir().unwrap();
1276+
let root = tmp.path().join("workspace");
1277+
std::fs::create_dir_all(&root).unwrap();
1278+
let member = write_pnpm_workspace(&root);
1279+
let lock_dir = if case == "root-npmrc-absolute" {
1280+
let dir = tmp.path().join("locks");
1281+
std::fs::write(
1282+
root.join(".npmrc"),
1283+
format!("lockfile-dir={}\n", dir.display()),
1284+
)
1285+
.unwrap();
1286+
dir
1287+
} else if case == "root-yaml-relative" {
1288+
std::fs::write(
1289+
root.join("pnpm-workspace.yaml"),
1290+
"packages:\n - packages/*\nlockfileDir: ../locks\n",
1291+
)
1292+
.unwrap();
1293+
root.join("packages/locks")
1294+
} else {
1295+
let dir = tmp.path().join("yaml-locks");
1296+
std::fs::write(
1297+
root.join("pnpm-workspace.yaml"),
1298+
format!(
1299+
"packages:\n - packages/*\nlockfileDir: {}\n",
1300+
dir.display()
1301+
),
1302+
)
1303+
.unwrap();
1304+
std::fs::write(member.join(".npmrc"), "lockfile-dir=../unused-locks\n").unwrap();
1305+
dir
1306+
};
1307+
std::fs::create_dir_all(&lock_dir).unwrap();
1308+
let lock = lock_dir.join("pnpm-lock.yaml");
1309+
std::fs::rename(root.join("pnpm-lock.yaml"), &lock).unwrap();
1310+
let before = std::fs::read_to_string(&lock).unwrap();
1311+
let (code, doc) = run_hosted_json(&member, &server.uri());
1312+
assert_refused_lock_elsewhere(code, &doc, &lock, &before, &member);
1313+
}
1314+
1315+
#[tokio::test]
1316+
#[serial]
1317+
async fn hosted_scan_inherits_workspace_npmrc_lockfile_dir() {
1318+
assert_workspace_configured_lock_refused("root-npmrc-absolute").await;
1319+
}
1320+
1321+
#[tokio::test]
1322+
#[serial]
1323+
async fn hosted_scan_resolves_inherited_lockfile_dir_from_cwd() {
1324+
assert_workspace_configured_lock_refused("root-yaml-relative").await;
1325+
}
1326+
1327+
#[tokio::test]
1328+
#[serial]
1329+
async fn hosted_scan_workspace_yaml_overrides_member_npmrc() {
1330+
assert_workspace_configured_lock_refused("root-yaml-precedence").await;
1331+
}

‎crates/socket-patch-core/src/hosted/governing_root.rs‎

Lines changed: 113 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,10 @@ async fn cargo_member_refusal(root: &Path) -> Option<Refusal> {
9999
/// The `pnpm-lock.yaml` pnpm reads for a project directory that holds no
100100
/// npm-family lock of its own, when it lives elsewhere and exists:
101101
///
102-
/// 1. a `lockfile-dir` in the project's `.npmrc`, or `lockfileDir` in its
103-
/// `pnpm-workspace.yaml`, naming another directory;
104-
/// 2. otherwise the nearest ancestor holding `pnpm-workspace.yaml` (pnpm's
105-
/// workspace root lookup), when the project has none of its own.
102+
/// The nearest `pnpm-workspace.yaml` supplies `lockfileDir`, ahead of the
103+
/// project's `.npmrc` and then the workspace root's `.npmrc`. A configured
104+
/// relative directory is resolved from the invocation cwd, as pnpm does;
105+
/// without an override, the workspace's lock lives at its root.
106106
async fn pnpm_lock_elsewhere(root: &Path) -> Option<PathBuf> {
107107
let has_own_lock = OWN_LOCKS
108108
.iter()
@@ -117,33 +117,51 @@ async fn pnpm_lock_elsewhere(root: &Path) -> Option<PathBuf> {
117117
.await
118118
.unwrap_or_else(|_| root.to_path_buf());
119119

120-
let workspace_yaml = read_regular_to_string(&root.join(PNPM_WORKSPACE))
121-
.await
122-
.ok();
123-
let configured = match read_regular_to_string(&root.join(".npmrc")).await {
124-
Ok(npmrc) => npmrc_top_level_value(&npmrc, "lockfile-dir"),
125-
Err(_) => None,
120+
let mut workspace = None;
121+
for ancestor in canonical.ancestors() {
122+
let path = ancestor.join(PNPM_WORKSPACE);
123+
if let Ok(yaml) = read_regular_to_string(&path).await {
124+
workspace = Some((ancestor.to_path_buf(), yaml));
125+
break;
126+
}
127+
if ancestor == canonical && path.exists() {
128+
// An unreadable local workspace file still bounds the project.
129+
break;
130+
}
131+
}
132+
133+
// Native pnpm 10: workspace YAML beats both npmrc files; the member's
134+
// npmrc beats the workspace root's. A member inherits root npmrc settings
135+
// even when its own directory has no pnpm-workspace.yaml.
136+
let mut configured = workspace
137+
.as_ref()
138+
.and_then(|(_, yaml)| workspace_lockfile_dir(yaml));
139+
if configured.is_none() {
140+
configured = npmrc_lockfile_dir(&canonical).await;
141+
}
142+
if configured.is_none() {
143+
if let Some((workspace_root, _)) = &workspace {
144+
if workspace_root != &canonical {
145+
configured = npmrc_lockfile_dir(workspace_root).await;
146+
}
147+
}
126148
}
127-
.or_else(|| workspace_yaml.as_deref().and_then(workspace_lockfile_dir));
128149
if let Some(dir) = configured {
150+
// Even an inherited relative override is based on the invocation
151+
// directory, not on the directory containing the setting.
129152
return lock_elsewhere(&canonical, &canonical, &dir).await;
130153
}
131-
if workspace_yaml.is_some() || root.join(PNPM_WORKSPACE).exists() {
132-
// The project is its own workspace root.
133-
return None;
134-
}
135-
for ancestor in canonical.ancestors().skip(1) {
136-
let Ok(yaml) = read_regular_to_string(&ancestor.join(PNPM_WORKSPACE)).await else {
137-
continue;
138-
};
139-
// The workspace root may relocate the lock with its own
140-
// `lockfileDir`, relative to the root.
141-
let dir = workspace_lockfile_dir(&yaml).unwrap_or_else(|| ".".to_string());
142-
return lock_elsewhere(&canonical, ancestor, &dir).await;
154+
if let Some((workspace_root, _)) = workspace {
155+
return lock_elsewhere(&canonical, &workspace_root, ".").await;
143156
}
144157
None
145158
}
146159

160+
async fn npmrc_lockfile_dir(root: &Path) -> Option<String> {
161+
let npmrc = read_regular_to_string(&root.join(".npmrc")).await.ok()?;
162+
npmrc_top_level_value(&npmrc, "lockfile-dir")
163+
}
164+
147165
/// `<base>/<dir>/pnpm-lock.yaml` when it exists and `<base>/<dir>` is not
148166
/// the project directory itself.
149167
async fn lock_elsewhere(project: &Path, base: &Path, dir: &str) -> Option<PathBuf> {
@@ -286,12 +304,16 @@ mod tests {
286304
assert_eq!(code(tmp.path(), "npm").await, None);
287305
}
288306

289-
/// A workspace root that relocates its lock with `lockfileDir` still
290-
/// governs its members (Bugbot on #598).
307+
/// An inherited relative `lockfileDir` is resolved from the member cwd,
308+
/// verified with native pnpm, rather than from the workspace root.
291309
#[tokio::test]
292310
async fn pnpm_member_of_workspace_with_relocated_lock_is_refused() {
293311
let tmp = tempfile::tempdir().unwrap();
294-
write(tmp.path(), "pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
312+
write(
313+
tmp.path(),
314+
"ws/packages/pnpm-lock.yaml",
315+
"lockfileVersion: '9.0'\n",
316+
);
295317
write(
296318
tmp.path(),
297319
"ws/pnpm-workspace.yaml",
@@ -305,6 +327,71 @@ mod tests {
305327
);
306328
}
307329

330+
#[tokio::test]
331+
async fn pnpm_config_precedence_matches_native_workspace_install() {
332+
let tmp = tempfile::tempdir().unwrap();
333+
let root = tmp.path().join("ws");
334+
let member = root.join("packages/a");
335+
write(&root, "packages/a/package.json", "{}");
336+
write(&root, PNPM_LOCK, "lockfileVersion: '9.0'\n");
337+
for dir in ["yaml-locks", "root-rc-locks", "member-rc-locks"] {
338+
write(
339+
tmp.path(),
340+
&format!("{dir}/{PNPM_LOCK}"),
341+
"lockfileVersion: '9.0'\n",
342+
);
343+
}
344+
let yaml_lock = tmp.path().join("yaml-locks");
345+
let root_rc_lock = tmp.path().join("root-rc-locks");
346+
let member_rc_lock = tmp.path().join("member-rc-locks");
347+
write(
348+
&root,
349+
PNPM_WORKSPACE,
350+
&format!(
351+
"packages:\n - packages/*\nlockfileDir: '{}'\n",
352+
yaml_lock.display()
353+
),
354+
);
355+
write(
356+
&root,
357+
".npmrc",
358+
&format!("lockfile-dir={}\n", root_rc_lock.display()),
359+
);
360+
write(
361+
&member,
362+
".npmrc",
363+
&format!("lockfile-dir={}\n", member_rc_lock.display()),
364+
);
365+
for expected in [&yaml_lock, &member_rc_lock, &root_rc_lock, &root] {
366+
let found = pnpm_lock_elsewhere(&member).await.expect("governing lock");
367+
assert_eq!(
368+
std::fs::canonicalize(found).unwrap(),
369+
std::fs::canonicalize(expected.join(PNPM_LOCK)).unwrap()
370+
);
371+
if expected == &yaml_lock {
372+
write(&root, PNPM_WORKSPACE, "packages:\n - packages/*\n");
373+
} else if expected == &member_rc_lock {
374+
std::fs::remove_file(member.join(".npmrc")).unwrap();
375+
} else if expected == &root_rc_lock {
376+
std::fs::remove_file(root.join(".npmrc")).unwrap();
377+
}
378+
}
379+
}
380+
381+
#[tokio::test]
382+
async fn pnpm_member_own_workspace_bounds_ancestor_lookup() {
383+
let tmp = tempfile::tempdir().unwrap();
384+
write(tmp.path(), PNPM_WORKSPACE, "packages:\n - packages/*\n");
385+
write(tmp.path(), PNPM_LOCK, "lockfileVersion: '9.0'\n");
386+
write(tmp.path(), "packages/a/package.json", "{}");
387+
write(
388+
tmp.path(),
389+
"packages/a/pnpm-workspace.yaml",
390+
"packages: []\n",
391+
);
392+
assert_eq!(code(&tmp.path().join("packages/a"), "npm").await, None);
393+
}
394+
308395
/// #417: a cargo workspace member is refused with the vendored code; the
309396
/// root and a standalone crate are not.
310397
#[tokio::test]

0 commit comments

Comments
 (0)