From 78ccde10a9001aa01b24a31270ad08418f02cd95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:24:06 +0000 Subject: [PATCH 01/12] Start fix for #1846 Assisted-by: Claude Code:claude-opus-5-5 From 77724bc1a29fcc775f0aab5c1737bc1405cbf4a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:37:44 +0000 Subject: [PATCH 02/12] Skip comments when reading pnpm lock importers The check for "does this pnpm lock list workspace members" counted a `#`-commented importer line as a member, and a column-0 comment ended the importers section early. It now skips comments, accepts tab-led lines, and gains a whole-text entry point so the second, hand-split copy in pnpm_workspace can go (#1846). Assisted-by: Claude Code:claude-opus-5-5 --- .../src/formats/pnpm/workspace.rs | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-core/src/formats/pnpm/workspace.rs b/crates/socket-patch-core/src/formats/pnpm/workspace.rs index ede03b140..996ed10d5 100644 --- a/crates/socket-patch-core/src/formats/pnpm/workspace.rs +++ b/crates/socket-patch-core/src/formats/pnpm/workspace.rs @@ -153,12 +153,28 @@ pub(crate) fn is_keyless(text: &str) -> bool { /// importers comes from pnpm <= 10.4, and the root-only /// [`PACKAGES_SCAFFOLD`] would drop those members. pub(crate) fn lock_has_member_importers(lines: &[String]) -> bool { - // CRLF-blind: a Windows checkout's lock reads like its LF twin. + member_importers(lines.iter().map(String::as_str)) +} + +/// [`lock_has_member_importers`] over a whole lock's text. A root lock +/// that lists member importers is a SHARED workspace lock: pnpm installs +/// every member from it, whatever the settings say now, so member locks +/// beside it are stale leftovers. +pub(crate) fn lock_text_has_member_importers(lock: &str) -> bool { + member_importers(lock.lines()) +} + +fn member_importers<'a>(lines: impl IntoIterator) -> bool { + // CRLF-blind: a Windows checkout's lock reads like its LF twin. A + // comment is no key and, even at column 0, does not end the section. let mut in_importers = false; for line in lines { let line = line.strip_suffix('\r').unwrap_or(line); - if !line.is_empty() && !line.starts_with(' ') { - in_importers = line == "importers:"; + if line.trim_start().starts_with('#') { + continue; + } + if !line.is_empty() && !line.starts_with([' ', '\t']) { + in_importers = line.trim_end() == "importers:"; } else if in_importers && super::lines::parse_key_line(line, 2).is_some_and(|(key, _, _)| key != ".") { @@ -504,6 +520,37 @@ mod tests { .map(|l| format!("{l}\r")) .collect(); assert!(lock_has_member_importers(&crlf)); + // #1846: a comment is no importer, and a column-0 comment does not + // end the section. + assert!(!lock_has_member_importers(&lock( + " .:\n dependencies: {}\n # sub: {}\n# other: {}\n" + ))); + assert!(lock_has_member_importers(&lock( + " .: {}\n# note\n sub: {}\n" + ))); + } + + /// The text entry point reads a whole lock the way the line one does + /// (#1846: it replaced `pnpm_workspace::root_lock_lists_members`). + #[test] + fn lock_text_member_importers_read_every_lock_shape() { + let root_only = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n a:\n specifier: 1.0.0\n version: 1.0.0\n\npackages:\n\n a@1.0.0:\n resolution: {integrity: sha512-x}\n"; + assert!(!lock_text_has_member_importers(root_only)); + assert!(!lock_text_has_member_importers( + "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n" + )); + assert!(!lock_text_has_member_importers( + "lockfileVersion: 5.4\n\ndependencies:\n a: 1.0.0\n" + )); + assert!(!lock_text_has_member_importers( + "lockfileVersion: '9.0'\nimporters:\n .: {}\n # packages/a: {}\n" + )); + assert!(lock_text_has_member_importers( + "lockfileVersion: '9.0'\r\n\r\nimporters:\r\n\r\n .: {}\r\n\r\n packages/a:\r\n dependencies: {}\r\n" + )); + assert!(lock_text_has_member_importers( + "lockfileVersion: '6.0'\nimporters:\n '.': {}\n 'packages/a': {}\n" + )); } #[test] From 4b0f952cace302bd1affb6ca263aa7a4d799fcb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:37:44 +0000 Subject: [PATCH 03/12] Read pnpm brace and class workspace globs A pnpm-workspace.yaml listing members as `packages/{a,b}` or `packages/[ab]*` was refused by every non-hosted membership check, or (when deciding which workspace file governs a project) treated as listing every directory, even though pnpm installs exactly those members and the shared glob matcher already reads that syntax. Hosted mode kept its own second reader to work around the refusal. Now one reader answers for all callers: brace sets and plain classes of letters, digits, `_` and `-` match as pnpm 10.28 reads them, and the member walk expands braces and widens classes before narrowing. Syntax pnpm reads its own way stays refused: `[!a]` (pnpm reads it as `[a]`), `[^a]` and `[.]` (they match a leading dot), POSIX classes and extglob groups. The hosted fallback reader and the duplicate lock-importer detector are gone (#1846). Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 79 ++++--- .../src/utils/pnpm_workspace.rs | 223 ++++++++++++------ .../src/utils/workspace_globs.rs | 8 + 3 files changed, 211 insertions(+), 99 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 8d3d37075..a295bf54f 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -624,10 +624,12 @@ async fn lockless_npm_project(base: &Path, mut dir: PathBuf) -> Option /// /// Unlike the hosted refusals, this errs toward "not a member": a member's /// copies are in its workspace's lock, so they never get this far, while a -/// project wrongly kept the scan root's fails that root's run. pnpm globs -/// the pnpm matcher does not model (braces, classes) go through the -/// shared workspace grammar; a `pnpm-workspace.yaml` that cannot be read, -/// which pnpm cannot install from either, lists nothing. +/// project wrongly kept the scan root's fails that root's run. So a +/// `pnpm-workspace.yaml` that cannot be read, which pnpm cannot install +/// from either, or whose `packages:` use glob syntax the pnpm reader does +/// not model ([`lists_member`]), lists nothing. +/// +/// [`lists_member`]: crate::utils::pnpm_workspace::lists_member async fn listing_workspace(base: &Path, dir: &Path) -> Option { let lists = |patterns: &[String], rel: &[String]| { WorkspacesDialect::ALL @@ -661,8 +663,7 @@ async fn listing_workspace(base: &Path, dir: &Path) -> Option { .ok() .and_then(|yaml| read_package_globs(&yaml).ok().flatten()) .is_some_and(|globs| { - crate::utils::pnpm_workspace::lists_member(&globs, &rel) - .unwrap_or_else(|_| pnpm_lists_unmodeled(&globs, &rel)) + crate::utils::pnpm_workspace::lists_member(&globs, &rel).unwrap_or(false) }); if listed { return Some(ancestor.to_path_buf()); @@ -671,31 +672,6 @@ async fn listing_workspace(base: &Path, dir: &Path) -> Option { None } -/// Whether pnpm lists `rel` under `packages:` globs that use syntax its -/// own matcher here does not model (braces, classes): the shared workspace -/// grammar matches each glob, read pnpm's way. A `!` glob excludes whatever -/// its order, wildcards never match a dot directory, and `node_modules`, -/// `test` and `tests` directories are never members. -fn pnpm_lists_unmodeled(globs: &[String], rel: &[String]) -> bool { - use crate::utils::workspace_globs::Negation; - const PNPM: WorkspacesDialect = WorkspacesDialect { - negation: Negation::LastMatchWins, - extglob: false, - picomatch: false, - dot: false, - }; - if rel - .iter() - .any(|seg| crate::utils::pnpm_workspace::MEMBER_SKIP.contains(&seg.as_str())) - { - return false; - } - let matches = |glob: &str| workspaces_include_as(&[glob.to_string()], rel, PNPM); - let (negated, listed): (Vec<&String>, Vec<&String>) = - globs.iter().partition(|glob| glob.starts_with('!')); - listed.iter().any(|glob| matches(glob)) && !negated.iter().any(|glob| matches(&glob[1..])) -} - /// The hosted refusal for [`member_stray_lock`]. async fn member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { let stray = member_stray_lock(root).await?; @@ -1359,6 +1335,47 @@ mod tests { /// #1554: a project installed without a lock (`package-lock=false`, /// `npm install --no-package-lock`) is independent too, unless a /// workspace lists it, whether or not that workspace was ever locked. + /// #1846: hosted reads pnpm brace and class globs through the same + /// reader as `pnpm_workspace::member_dirs`, so both name the same + /// members; syntax that reader refuses lists nothing here. + #[tokio::test] + async fn pnpm_brace_and_class_globs_list_what_member_dirs_lists() { + use crate::utils::cargo_workspace::DiskTree; + let dirs = ["a", "b", "c", "ab"]; + for (glob, listed) in [ + ("packages/{a,b}", &["packages/a", "packages/b"][..]), + ( + "packages/[ab]*", + &["packages/a", "packages/ab", "packages/b"], + ), + ("packages/[!a]*", &[]), + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write(root, "package.json", r#"{"name":"root"}"#); + write(root, PNPM_WORKSPACE, &format!("packages:\n - '{glob}'\n")); + for dir in dirs { + write(root, &format!("packages/{dir}/package.json"), "{}"); + } + let globs = vec![glob.to_string()]; + let members = crate::utils::pnpm_workspace::member_dirs(&DiskTree(root), &globs); + if listed.is_empty() { + assert!(members.is_err(), "{glob}"); + } else { + assert_eq!(members.unwrap(), listed, "{glob}"); + } + for dir in dirs { + let rel = format!("packages/{dir}"); + let expected = listed.contains(&rel.as_str()).then(|| root.to_path_buf()); + assert_eq!( + listing_workspace(root, &root.join(&rel)).await, + expected, + "{glob} {rel}" + ); + } + } + } + #[tokio::test] async fn nested_npm_project_finds_a_lockless_project() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index b12928665..f6070a868 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -26,12 +26,12 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use crate::formats::pnpm::workspace::read_package_globs; +use crate::formats::pnpm::workspace::{lock_text_has_member_importers, read_package_globs}; use crate::utils::cargo_workspace::{ expand_glob_bounded, DirTree, DiskTree, MemoryTree, MAX_MANIFESTS, }; use crate::utils::fs::{read_regular_to_string, read_regular_to_string_sync}; -use crate::utils::workspace_globs::{glob_matches_no_dot, split_negation}; +use crate::utils::workspace_globs::{brace_alternatives, glob_matches_no_dot, split_negation}; use crate::vendor::lock_inventory::view::ProjectView; /// pnpm's workspace and settings file. @@ -262,37 +262,6 @@ pub fn shared_lockfile_disabled(workspace: &str, npmrc: Option<&str>) -> bool { .is_some_and(|setting| setting.as_bool() == Some(false)) } -/// Whether a root lock is a SHARED workspace lock: its `importers:` names a -/// project other than the root (`.`). Such a lock is what pnpm installs -/// every member from, whatever the settings say now, so member locks -/// beside it are stale leftovers. -pub fn root_lock_lists_members(lock: &str) -> bool { - let mut in_importers = false; - for line in lock.lines() { - let line = line.strip_suffix('\r').unwrap_or(line); - if !line.starts_with([' ', '\t']) && !line.trim().is_empty() { - in_importers = line.trim_end() == "importers:"; - continue; - } - if !in_importers { - continue; - } - // An importer key sits at exactly two spaces. - let Some(key) = line - .strip_prefix(" ") - .filter(|k| !k.starts_with([' ', '#'])) - else { - continue; - }; - let key = key.split(':').next().unwrap_or("").trim(); - let key = key.trim_matches(['\'', '"']); - if !key.is_empty() && key != "." { - return true; - } - } - false -} - /// Whether pnpm's project finder lists the directory at `rel` (relative /// to the workspace root, one entry per component) under `globs` /// (`pnpm-workspace.yaml` `packages:`, as [`read_package_globs`] reads them), as @@ -303,8 +272,9 @@ pub fn root_lock_lists_members(lock: &str) -> bool { /// `.`, so `**` does not list `.github/actions/demo` (see /// [`glob_matches_no_dot`]). The root itself (`rel` empty) is never one. /// -/// `Err` when a pattern uses glob syntax the shared matcher does not model -/// (braces, classes, extglobs): the caller must not guess either way. +/// Brace sets and plain character classes match as pnpm reads them +/// (#1846). `Err` when a pattern uses glob syntax the shared matcher does +/// not read pnpm's way ([`modeled`]): the caller must not guess either way. /// Both membership questions, "is this directory a member" and "which /// members' locks does pnpm install from", answer through here. pub(crate) fn lists_member(globs: &[String], rel: &[String]) -> Result { @@ -321,11 +291,35 @@ pub(crate) fn lists_member(globs: &[String], rel: &[String]) -> Result Result<(), String> { + let plain_classes = |glob: &str| { + let mut rest = glob; + while let Some(open) = rest.find('[') { + let Some(len) = rest[open + 1..].find(']') else { + return false; + }; + let body = &rest[open + 1..open + 1 + len]; + if body.is_empty() + || body.starts_with(['!', '^']) + || !body + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return false; + } + rest = &rest[open + 2 + len..]; + } + true + }; match globs .iter() - .find(|g| g.contains(['{', '}', '[', ']', '(', ')'])) + .find(|g| g.contains(['(', ')']) || brace_alternatives(g).is_none() || !plain_classes(g)) { Some(glob) => Err(format!( "`packages:` glob {glob:?} uses syntax socket-patch does not read" @@ -334,6 +328,24 @@ fn modeled(globs: &[String]) -> Result<(), String> { } } +/// `glob` with each character class (closed and plain, as [`modeled`] +/// leaves them) widened to `?`, which the tree walk reads: every +/// directory the class matches, and others [`lists_member`] then drops. +fn widen_classes(glob: &str) -> String { + let mut out = String::with_capacity(glob.len()); + let mut rest = glob; + while let Some(open) = rest.find('[') { + let close = rest[open..] + .find(']') + .map_or(rest.len(), |len| open + len + 1); + out.push_str(&rest[..open]); + out.push('?'); + rest = &rest[close..]; + } + out.push_str(rest); + out +} + /// The workspace member directories `globs` name (see [`lists_member`]), /// root-relative and sorted. The positive globs are expanded against the /// tree for candidates, and [`lists_member`] decides each one, so this @@ -345,14 +357,19 @@ pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result) -> Members { return Members::Shared; } let root_lock = view.read_text(PNPM_LOCK).await.ok(); - if root_lock.as_deref().is_some_and(root_lock_lists_members) { + if root_lock + .as_deref() + .is_some_and(lock_text_has_member_importers) + { return Members::Shared; } let globs = match read_package_globs(&workspace) { @@ -1215,24 +1235,6 @@ mod tests { } } - #[test] - fn a_root_lock_listing_member_importers_is_shared() { - let root_only = "lockfileVersion: '9.0'\n\nimporters:\n\n .:\n dependencies:\n a:\n specifier: 1.0.0\n version: 1.0.0\n\npackages:\n\n a@1.0.0:\n resolution: {integrity: sha512-x}\n"; - assert!(!root_lock_lists_members(root_only)); - assert!(!root_lock_lists_members( - "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n" - )); - assert!(!root_lock_lists_members( - "lockfileVersion: 5.4\n\ndependencies:\n a: 1.0.0\n" - )); - assert!(root_lock_lists_members( - "lockfileVersion: '9.0'\r\n\r\nimporters:\r\n\r\n .: {}\r\n\r\n packages/a:\r\n dependencies: {}\r\n" - )); - assert!(root_lock_lists_members( - "lockfileVersion: '6.0'\nimporters:\n '.': {}\n 'packages/a': {}\n" - )); - } - #[test] fn member_dirs_expand_globs_minus_negations_and_ignored_trees() { let tmp = tempfile::tempdir().unwrap(); @@ -1290,7 +1292,74 @@ mod tests { member_dirs(&DiskTree(root), &globs(&["packages/.*"])), Ok(vec!["packages/.hidden".to_string()]) ); - assert!(member_dirs(&DiskTree(root), &globs(&["packages/{a,b}"])).is_err()); + assert!(member_dirs(&DiskTree(root), &globs(&["packages/@(a|b)"])).is_err()); + } + + /// #1846: brace sets and plain character classes list what pnpm lists + /// (probed on pnpm 10.28.0 with `pnpm list -r`), in both trees. Syntax + /// pnpm reads its own way is refused, never guessed: it reads `[!a]` + /// as `[a]`, lets `[^a]` and `[.]` match a leading dot, and reads POSIX + /// classes and extglob groups the shared matcher does not model here. + #[test] + fn member_dirs_read_braces_and_classes_as_pnpm_does() { + let dirs = ["a", "b", "c", "ab", "x", ".h"]; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut project = crate::vendor::lock_inventory::MemoryProject::new(); + for dir in dirs { + std::fs::create_dir_all(root.join("packages").join(dir)).unwrap(); + project.insert_text(format!("packages/{dir}/package.json"), "{}"); + } + let globs = |g: &[&str]| g.iter().map(|g| g.to_string()).collect::>(); + for (patterns, expected) in [ + (&["packages/{a,b}"][..], &["a", "b"][..]), + (&["packages/[ab]*"], &["a", "ab", "b"]), + (&["packages/{a..c}"], &["a", "b", "c"]), + (&["packages/[a-b]"], &["a", "b"]), + (&["packages/{a,.h}"], &[".h", "a"]), + (&["packages/*", "!packages/[ab]"], &["ab", "c", "x"]), + (&["packages/*", "!packages/{a,c}"], &["ab", "b", "x"]), + (&["packages/{[ab],x}"], &["a", "b", "x"]), + ] { + let expected: Vec = expected.iter().map(|d| format!("packages/{d}")).collect(); + let patterns = globs(patterns); + assert_eq!( + member_dirs(&DiskTree(root), &patterns).as_ref(), + Ok(&expected), + "{patterns:?}" + ); + assert_eq!( + member_dirs(&MemoryTree(&project), &patterns).as_ref(), + Ok(&expected), + "{patterns:?}" + ); + for dir in dirs { + let rel = vec!["packages".to_string(), dir.to_string()]; + let listed = expected.contains(&format!("packages/{dir}")); + assert_eq!( + lists_member(&patterns, &rel), + Ok(listed), + "{patterns:?} {dir}" + ); + } + } + for unmodeled in [ + "packages/[!a]*", + "packages/[^a]*", + "packages/[.]h", + "packages/[[:alpha:]]", + "packages/@(a|b)", + "packages/(a|b)", + "packages/[ab", + ] { + let patterns = globs(&[unmodeled]); + assert!( + member_dirs(&DiskTree(root), &patterns).is_err(), + "{unmodeled}" + ); + let rel = vec!["packages".to_string(), "a".to_string()]; + assert!(lists_member(&patterns, &rel).is_err(), "{unmodeled}"); + } } #[test] @@ -1646,11 +1715,29 @@ mod tests { assert_eq!(governed("packages/.hidden"), None); write(&root, PNPM_WORKSPACE, "packages:\n - '.github/**'\n"); assert_eq!(governed(".github/actions/demo"), file); - // Glob syntax this matcher does not model (braces, classes, - // extglobs) and a file that does not parse fail closed: governed. + // Brace sets and character classes are read (#1846). + write( + &root, + PNPM_WORKSPACE, + "packages:\n - '{apps,packages}/*'\n", + ); + assert_eq!(governed("examples/demo"), None); + write( + &root, + PNPM_WORKSPACE, + "packages:\n - '{examples,packages}/*'\n", + ); + assert_eq!(governed("examples/demo"), file); + write(&root, PNPM_WORKSPACE, "packages:\n - '[ab]*/*'\n"); + assert_eq!(governed("examples/demo"), None); + write(&root, PNPM_WORKSPACE, "packages:\n - '[e-f]xamples/*'\n"); + assert_eq!(governed("examples/demo"), file); + // Glob syntax this matcher does not model (extglobs, negated and + // POSIX classes) and a file that does not parse fail closed: + // governed. for ws in [ - "packages:\n - 'packages/{a,b}'\n", - "packages:\n - 'packages/[ab]'\n", + "packages:\n - '[!a]*/*'\n", + "packages:\n - '[[:alpha:]]*/*'\n", "packages:\n - '+(examples|packages)/*'\n", "packages: [unclosed\n", "packages:\n - 1\n - [nested]\n", diff --git a/crates/socket-patch-core/src/utils/workspace_globs.rs b/crates/socket-patch-core/src/utils/workspace_globs.rs index 94d1d4ff6..9ed242e53 100644 --- a/crates/socket-patch-core/src/utils/workspace_globs.rs +++ b/crates/socket-patch-core/src/utils/workspace_globs.rs @@ -524,6 +524,14 @@ fn expand_braces(pattern: &str) -> Vec { out } +/// [`expand_braces`] for a caller that walks each alternative itself: +/// `None` when the pattern has more alternatives than the matcher expands, +/// so a walk over the ones it returned would miss some. +pub(crate) fn brace_alternatives(pattern: &str) -> Option> { + let alternatives = expand_braces(pattern.trim()); + (alternatives.len() < MAX_BRACE_EXPANSIONS).then_some(alternatives) +} + fn expand_braces_into(pattern: &str, out: &mut Vec) { if out.len() >= MAX_BRACE_EXPANSIONS { return; From 9ed90e74a2f86d6ff4aec43df75c745f86970b40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 22:45:22 +0000 Subject: [PATCH 04/12] Use an extglob as the unmodeled pnpm glob The hosted/in-memory parity test used `packages/{a,b}` as its example of a pnpm glob socket-patch cannot read. Brace sets are now read (#1846), so the case uses an extglob group, which is still refused. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/hosted_memory_parity.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/tests/hosted_memory_parity.rs b/crates/socket-patch-cli/tests/hosted_memory_parity.rs index 48a964fae..1e168ff14 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_parity.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_parity.rs @@ -1616,7 +1616,8 @@ async fn an_unconfirmed_pnpm_member_keeps_its_lock() { let server = pnpm_server().await; let per_member = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n"; let unmodeled = pnpm_workspace( - "packages:\n - 'packages/{a,b}'\nsharedWorkspaceLockfile: false\n", + // Brace sets and classes are read (#1846); an extglob is not. + "packages:\n - 'packages/@(a|b)'\nsharedWorkspaceLockfile: false\n", Some(PNPM_ROOT_ONLY_LOCK), &["packages/a"], &[], From acf9d23b22117866e3636c34aaf4eabe204f8013 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:04:19 +0000 Subject: [PATCH 05/12] Refuse pnpm globs pnpm reads as regex or escape Review on #1846: beside a class or brace set, pnpm reads `+` as a quantifier (`packages/[ab]+` lists `packages/ab`) and `\` as an escape (`packages/\[a]` lists only a directory named `[a]`), while the shared matcher reads `+` literally and `\` as a separator. Those globs are refused again, in each brace alternative too, instead of answering "not a member". Globs without classes or braces read as before. The member walk now applies one directory bound across every brace alternative, so a brace glob can no longer collect many times more candidates than the limit. Each candidate also skips the repeated syntax check. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/hosted/governing_root.rs | 6 +- .../src/utils/pnpm_workspace.rs | 74 ++++++++++++++++--- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index a295bf54f..bde88005c 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -1332,9 +1332,6 @@ mod tests { ); } - /// #1554: a project installed without a lock (`package-lock=false`, - /// `npm install --no-package-lock`) is independent too, unless a - /// workspace lists it, whether or not that workspace was ever locked. /// #1846: hosted reads pnpm brace and class globs through the same /// reader as `pnpm_workspace::member_dirs`, so both name the same /// members; syntax that reader refuses lists nothing here. @@ -1376,6 +1373,9 @@ mod tests { } } + /// #1554: a project installed without a lock (`package-lock=false`, + /// `npm install --no-package-lock`) is independent too, unless a + /// workspace lists it, whether or not that workspace was ever locked. #[tokio::test] async fn nested_npm_project_finds_a_lockless_project() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index f6070a868..8cd7639bc 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -279,15 +279,20 @@ pub fn shared_lockfile_disabled(workspace: &str, npmrc: Option<&str>) -> bool { /// members' locks does pnpm install from", answer through here. pub(crate) fn lists_member(globs: &[String], rel: &[String]) -> Result { modeled(globs)?; + Ok(lists_modeled_member(globs, rel)) +} + +/// [`lists_member`] over globs [`modeled`] already accepted. +fn lists_modeled_member(globs: &[String], rel: &[String]) -> bool { if rel.is_empty() || rel.iter().any(|seg| MEMBER_SKIP.contains(&seg.as_str())) { - return Ok(false); + return false; } let (negated, listed): (Vec<_>, Vec<_>) = globs .iter() .map(|g| split_negation(g)) .partition(|(negated, _)| *negated); - Ok(listed.iter().any(|(_, g)| glob_matches_no_dot(g, rel)) - && !negated.iter().any(|(_, g)| glob_matches_no_dot(g, rel))) + listed.iter().any(|(_, g)| glob_matches_no_dot(g, rel)) + && !negated.iter().any(|(_, g)| glob_matches_no_dot(g, rel)) } /// `Err` naming the first glob that uses syntax the shared matcher does @@ -296,7 +301,11 @@ pub(crate) fn lists_member(globs: &[String], rel: &[String]) -> Result Result<(), String> { let plain_classes = |glob: &str| { let mut rest = glob; @@ -317,10 +326,21 @@ fn modeled(globs: &[String]) -> Result<(), String> { } true }; - match globs - .iter() - .find(|g| g.contains(['(', ')']) || brace_alternatives(g).is_none() || !plain_classes(g)) - { + let read = |glob: &String| { + if glob.contains(['(', ')']) { + return false; + } + if !glob.contains(['[', ']', '{', '}']) { + return true; + } + !glob.contains(['+', '\\']) + && brace_alternatives(glob).is_some_and(|alternatives| { + alternatives.iter().all(|alternative| { + !alternative.contains(['(', ')', '+', '\\']) && plain_classes(alternative) + }) + }) + }; + match globs.iter().find(|g| !read(g)) { Some(glob) => Err(format!( "`packages:` glob {glob:?} uses syntax socket-patch does not read" )), @@ -359,16 +379,20 @@ pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result= MAX_MANIFESTS { return Err(format!( "`packages:` glob {glob:?} names more than {MAX_MANIFESTS} directories, \ more than socket-patch walks" )); } - candidates.extend(dirs); } } let mut dirs = Vec::new(); @@ -378,7 +402,7 @@ pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result bool { + !rel.is_empty() + } + fn child_dirs(&self, rel: &str) -> Option> { + Some(match rel { + "" => vec!["a".to_string(), "b".to_string()], + "a" | "b" => (0..self.0).map(|i| format!("p{i:05}")).collect(), + _ => Vec::new(), + }) + } + } + let globs = vec!["{a,b}/*".to_string()]; + let half = MAX_MANIFESTS / 2 + 1; + assert!(member_dirs(&Two(half), &globs).is_err()); + assert_eq!(member_dirs(&Two(10), &globs).unwrap().len(), 20); } #[test] From f9b15ba3eeec7c3afd07b9169294f1916b5fc240 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:21:56 +0000 Subject: [PATCH 06/12] Refuse pnpm class ranges that start at a dash A class range from `-` (as in `packages/[--9]h`) spans `.` and `/`, so pnpm can match a hidden directory that the shared matcher never does. Such classes are refused now. A literal `-` (`[-a]`) and ranges between letters, digits and `_` stay readable. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/utils/pnpm_workspace.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 8cd7639bc..5304c6906 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -298,8 +298,8 @@ fn lists_modeled_member(globs: &[String], rel: &[String]) -> bool { /// `Err` naming the first glob that uses syntax the shared matcher does /// not read the way pnpm does (probed on pnpm 10.28.0, #1846): extglob and /// regex groups, more brace alternatives than the matcher expands, and any -/// character class but a closed one of letters, digits, `_` and `-` -/// ranges. pnpm reads `[!a]` as `[a]`, lets `[^a]` and `[.]` match a +/// character class but a closed one of letters, digits, `_`, a literal +/// `-` and ranges not starting at `-`. pnpm reads `[!a]` as `[a]`, lets `[^a]` and `[.]` match a /// leading `.` that the matcher keeps wildcards off, and reads POSIX /// classes. Beside a class or brace set, a `+` (pnpm's quantifier: /// `packages/[ab]+` lists `packages/ab`) and a `\` (pnpm's escape: @@ -314,8 +314,11 @@ fn modeled(globs: &[String]) -> Result<(), String> { return false; }; let body = &rest[open + 1..open + 1 + len]; + // Every allowed character sorts above `/` except `-`, so only a + // range starting at `-` (`[--9]`) could match `.` or `/`. if body.is_empty() || body.starts_with(['!', '^']) + || body.contains("--") || !body .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') @@ -1344,6 +1347,7 @@ mod tests { (&["packages/*", "!packages/[ab]"], &["ab", "c", "x"]), (&["packages/*", "!packages/{a,c}"], &["ab", "b", "x"]), (&["packages/{[ab],x}"], &["a", "b", "x"]), + (&["packages/[-a]"], &["a"]), ] { let expected: Vec = expected.iter().map(|d| format!("packages/{d}")).collect(); let patterns = globs(patterns); @@ -1382,6 +1386,8 @@ mod tests { "packages/{a,b}+", "packages/\\[a]", "packages/{Z..a}", + // A range from `-` spans `.` and `/`. + "packages/[--9]h", ] { let patterns = globs(&[unmodeled]); assert!( From a7ee39348636b51da3ffe8bda0c89bac6ae751ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 01:26:50 +0000 Subject: [PATCH 07/12] Refuse pnpm brace shapes that pnpm reads differently pnpm 10.28.0 reads braces as picomatch does, not as brace-expansion does. So modeled() accepted three shapes whose members differ from what pnpm lists: - a stepped or multi-character sequence: `p{1..10}` lists p0 and p1 under pnpm, not p10; - a `*` or `?` inside braces: `{a,*}` lists dot directories under pnpm; - an empty alternative: `packages/{a,}` listed `packages` itself. Refuse these shapes, as the rest of modeled() already does for syntax pnpm reads its own way, instead of guessing a member set that would reach member_dirs and the hosted governing root. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/utils/pnpm_workspace.rs | 69 ++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 5304c6906..057c11e88 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -304,8 +304,10 @@ fn lists_modeled_member(globs: &[String], rel: &[String]) -> bool { /// classes. Beside a class or brace set, a `+` (pnpm's quantifier: /// `packages/[ab]+` lists `packages/ab`) and a `\` (pnpm's escape: /// `packages/\[a]` lists only `packages/[a]`, the matcher's separator) are -/// refused too, in the glob and in each brace alternative. A glob with -/// none of `[ ] { }` reads as it did before #1846. +/// refused too, in the glob and in each brace alternative. pnpm reads +/// braces as picomatch does, not as the matcher's brace expansion, where +/// [`plain_braces`] refuses. A glob with none of `[ ] { }` reads as it did +/// before #1846. fn modeled(globs: &[String]) -> Result<(), String> { let plain_classes = |glob: &str| { let mut rest = glob; @@ -337,6 +339,7 @@ fn modeled(globs: &[String]) -> Result<(), String> { return true; } !glob.contains(['+', '\\']) + && plain_braces(glob) && brace_alternatives(glob).is_some_and(|alternatives| { alternatives.iter().all(|alternative| { !alternative.contains(['(', ')', '+', '\\']) && plain_classes(alternative) @@ -351,6 +354,55 @@ fn modeled(globs: &[String]) -> Result<(), String> { } } +/// Whether every `{...}` group in `glob` expands to what pnpm lists +/// (probed on pnpm 10.28.0). pnpm reads a sequence with a step or a +/// multi-character end as a class (`{1..10}` lists `0` and `1`, not `10`), +/// lets a `*` or `?` inside braces match a leading `.` (`{a,*}` lists +/// `.h`), and drops an empty alternative (`packages/{a,}` lists only +/// `packages/a`, not `packages`): all three are refused. +fn plain_braces(glob: &str) -> bool { + let mut opens = Vec::new(); + for (i, c) in glob.char_indices() { + match c { + '{' => opens.push(i), + '}' => { + let Some(open) = opens.pop() else { + continue; + }; + let body = &glob[open + 1..i]; + if body.contains(['*', '?']) { + return false; + } + let mut alternatives = Vec::new(); + let (mut depth, mut start) = (0usize, 0usize); + for (j, c) in body.char_indices() { + match c { + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + alternatives.push(&body[start..j]); + start = j + 1; + } + _ => {} + } + } + alternatives.push(&body[start..]); + if alternatives.len() > 1 { + if alternatives.iter().any(|a| a.is_empty()) { + return false; + } + } else if body.contains("..") + && !body.split("..").map(|end| end.chars().count()).eq([1, 1]) + { + return false; + } + } + _ => {} + } + } + true +} + /// `glob` with each character class (closed and plain, as [`modeled`] /// leaves them) widened to `?`, which the tree walk reads: every /// directory the class matches, and others [`lists_member`] then drops. @@ -1348,6 +1400,8 @@ mod tests { (&["packages/*", "!packages/{a,c}"], &["ab", "b", "x"]), (&["packages/{[ab],x}"], &["a", "b", "x"]), (&["packages/[-a]"], &["a"]), + (&["packages/{c..a}"], &["a", "b", "c"]), + (&["packages/{a,{b,c}}"], &["a", "b", "c"]), ] { let expected: Vec = expected.iter().map(|d| format!("packages/{d}")).collect(); let patterns = globs(patterns); @@ -1386,6 +1440,17 @@ mod tests { "packages/{a,b}+", "packages/\\[a]", "packages/{Z..a}", + // pnpm reads a stepped or multi-character sequence as a class, + // lets a wildcard inside braces match `.h`, and drops an empty + // alternative instead of listing `packages` itself. + "packages/{a..e..2}", + "packages/p{1..10}", + "packages/p{01..2}", + "packages/{a,*}", + "packages/{?h,x}", + "packages/{a,}", + "packages/{,a}", + "packages/{a,{b,}}", // A range from `-` spans `.` and `/`. "packages/[--9]h", ] { From 216160678c891df77e1ad4977b2f969851625039 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:41:45 +0000 Subject: [PATCH 08/12] Cap the brace alternatives pnpm member_dirs walks member_dirs walks the tree once for each brace alternative, and the directory bound only counts matches, so an alternative that matches nothing walks the whole tree without ever reaching it. A scanned repo's pnpm-workspace.yaml could list up to 4095 alternatives (for example **/{a..z}{a..z}{a..e}) and stall the scan for minutes on a modest tree. Allow at most 64 alternatives over all globs and answer Err beyond that, which callers already treat as no answer. Refs #1846 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011f9aeRR24V93EPLyuXR8tp --- .../src/utils/pnpm_workspace.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 057c11e88..24668330e 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -421,16 +421,25 @@ fn widen_classes(glob: &str) -> String { out } +/// How many brace alternatives [`member_dirs`] walks, over all its globs. +/// Each alternative is a walk of the tree, so this bounds the walks a +/// scanned repo's `pnpm-workspace.yaml` can ask for. +const MAX_WALKED_ALTERNATIVES: usize = 64; + /// The workspace member directories `globs` name (see [`lists_member`]), /// root-relative and sorted. The positive globs are expanded against the /// tree for candidates, and [`lists_member`] decides each one, so this /// lists exactly the existing directories it calls members. A directory /// reached through a symbolic link is not a member. `Err` when a glob /// names more than [`MAX_MANIFESTS`] directories: the bounded walk stopped -/// before listing them all, and part of the members is no answer. +/// before listing them all, and part of the members is no answer. `Err` +/// too when the globs expand to more than [`MAX_WALKED_ALTERNATIVES`] +/// brace alternatives: each one is a walk of the tree, and an alternative +/// that matches nothing never reaches the directory bound. pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result, String> { modeled(globs)?; let mut candidates = std::collections::BTreeSet::new(); + let mut walked_alternatives = 0usize; for glob in globs.iter().filter(|g| !g.starts_with('!')) { // The tree walk reads neither braces nor classes: it walks each // brace alternative with its classes widened ([`widen_classes`]). @@ -438,6 +447,13 @@ pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result MAX_WALKED_ALTERNATIVES { + return Err(format!( + "`packages:` glob {glob:?} expands to more than {MAX_WALKED_ALTERNATIVES} \ + brace alternatives, more than socket-patch walks" + )); + } for alternative in alternatives { let walked = widen_classes(&alternative); let (dirs, truncated) = expand_glob_bounded(tree, &walked, MEMBER_SKIP); @@ -1487,6 +1503,17 @@ mod tests { assert!(err.contains("packages/**"), "{err}"); let listed = member_dirs(&Wide(10), &globs).unwrap(); assert_eq!(listed.len(), 11, "packages itself plus its ten children"); + // Every brace alternative is a walk, and one that matches nothing + // never reaches the directory bound: past the alternatives budget + // the answer is an error before any walk. + let many = vec!["**/{a..z}{a..z}".to_string()]; + let err = member_dirs(&Wide(10), &many).unwrap_err(); + assert!(err.contains("brace alternatives"), "{err}"); + let few = vec!["packages/{p00001,p00002}".to_string()]; + assert_eq!( + member_dirs(&Wide(10), &few).unwrap(), + vec!["packages/p00001", "packages/p00002"] + ); // One bound covers every brace alternative: each of `a/*` and // `b/*` stays under it, together they do not (#1846). struct Two(usize); From d52f3b621cbac38dec83b32ef0481720d7285d34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:59:55 +0000 Subject: [PATCH 09/12] Count only brace walks against pnpm walk cap The cap on how many tree walks a pnpm-workspace.yaml can ask for also counted globs without braces. A workspace listing more than 64 plain `packages:` entries, which large monorepos do, failed member-lock discovery, though main walks those without a cap. Only the extra walks brace sets add now count; a plain glob is one walk, as before. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/utils/pnpm_workspace.rs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 24668330e..7b82acfcc 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -421,9 +421,10 @@ fn widen_classes(glob: &str) -> String { out } -/// How many brace alternatives [`member_dirs`] walks, over all its globs. -/// Each alternative is a walk of the tree, so this bounds the walks a -/// scanned repo's `pnpm-workspace.yaml` can ask for. +/// How many brace alternatives [`member_dirs`] walks, over all its globs +/// that hold a brace set. Each alternative is a walk of the tree, so this +/// bounds the walks a scanned repo's `pnpm-workspace.yaml` can add with +/// braces; a glob without one is a single walk and is not counted. const MAX_WALKED_ALTERNATIVES: usize = 64; /// The workspace member directories `globs` name (see [`lists_member`]), @@ -433,8 +434,8 @@ const MAX_WALKED_ALTERNATIVES: usize = 64; /// reached through a symbolic link is not a member. `Err` when a glob /// names more than [`MAX_MANIFESTS`] directories: the bounded walk stopped /// before listing them all, and part of the members is no answer. `Err` -/// too when the globs expand to more than [`MAX_WALKED_ALTERNATIVES`] -/// brace alternatives: each one is a walk of the tree, and an alternative +/// too when the brace sets expand to more than [`MAX_WALKED_ALTERNATIVES`] +/// alternatives: each one is a walk of the tree, and an alternative /// that matches nothing never reaches the directory bound. pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result, String> { modeled(globs)?; @@ -447,7 +448,11 @@ pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result 1 { + walked_alternatives += alternatives.len(); + } if walked_alternatives > MAX_WALKED_ALTERNATIVES { return Err(format!( "`packages:` glob {glob:?} expands to more than {MAX_WALKED_ALTERNATIVES} \ @@ -1514,6 +1519,10 @@ mod tests { member_dirs(&Wide(10), &few).unwrap(), vec!["packages/p00001", "packages/p00002"] ); + // Globs without braces are one walk each and never count against + // the alternatives budget, however many the workspace lists. + let plain: Vec = (0..100).map(|i| format!("packages/p{i:05}")).collect(); + assert_eq!(member_dirs(&Wide(100), &plain).unwrap().len(), 100); // One bound covers every brace alternative: each of `a/*` and // `b/*` stays under it, together they do not (#1846). struct Two(usize); From 5ca5e0e9d599ab6e2d487ade31c5d089f28df6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:15:56 +0000 Subject: [PATCH 10/12] Refuse pnpm brace ranges across punctuation A brace sequence with single-character ends that are not the same kind stepped through punctuation: `packages/{-..0}` expanded through `.` and `/`, listing `packages` itself as a member, and `{0..Z}` through `?`, which the walk then read as a wildcard. A sequence now needs both ends to be digits, both lowercase or both uppercase. Found in the final review of #1863. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/utils/pnpm_workspace.rs | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 7b82acfcc..8aac91d1e 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -359,7 +359,10 @@ fn modeled(globs: &[String]) -> Result<(), String> { /// multi-character end as a class (`{1..10}` lists `0` and `1`, not `10`), /// lets a `*` or `?` inside braces match a leading `.` (`{a,*}` lists /// `.h`), and drops an empty alternative (`packages/{a,}` lists only -/// `packages/a`, not `packages`): all three are refused. +/// `packages/a`, not `packages`): all three are refused. So is a sequence +/// whose ends are not both digits, both lowercase or both uppercase: it +/// would step through punctuation (`{-..0}` through `.` and `/`, `{0..Z}` +/// through `?`), which the walk reads as path syntax. fn plain_braces(glob: &str) -> bool { let mut opens = Vec::new(); for (i, c) in glob.char_indices() { @@ -391,9 +394,7 @@ fn plain_braces(glob: &str) -> bool { if alternatives.iter().any(|a| a.is_empty()) { return false; } - } else if body.contains("..") - && !body.split("..").map(|end| end.chars().count()).eq([1, 1]) - { + } else if body.contains("..") && !same_kind_range(body) { return false; } } @@ -403,6 +404,26 @@ fn plain_braces(glob: &str) -> bool { true } +/// Whether a `{x..y}` body is a single-character sequence whose ends are +/// both digits, both lowercase or both uppercase ASCII letters. +fn same_kind_range(body: &str) -> bool { + let ends: Vec<&str> = body.split("..").collect(); + let [from, to] = ends.as_slice() else { + return false; + }; + let (mut from, mut to) = (from.chars(), to.chars()); + match (from.next(), from.next(), to.next(), to.next()) { + (Some(a), None, Some(b), None) => [ + char::is_ascii_digit, + char::is_ascii_lowercase, + char::is_ascii_uppercase, + ] + .iter() + .any(|kind| kind(&a) && kind(&b)), + _ => false, + } +} + /// `glob` with each character class (closed and plain, as [`modeled`] /// leaves them) widened to `?`, which the tree walk reads: every /// directory the class matches, and others [`lists_member`] then drops. @@ -1466,6 +1487,9 @@ mod tests { // alternative instead of listing `packages` itself. "packages/{a..e..2}", "packages/p{1..10}", + // A sequence across punctuation steps through path syntax. + "packages/{-..0}", + "packages/{0..Z}", "packages/p{01..2}", "packages/{a,*}", "packages/{?h,x}", From 032b9603af30ace16dd89c6bc8d8c977e9786ce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 09:38:59 +0000 Subject: [PATCH 11/12] Refuse pnpm brace sets pnpm reads as sequences or class members pnpm 10.28 expands a `..` inside a comma brace set as a sequence (`packages/{a,b..c}` lists `,` `0` `A` `[` ...), and reads braces inside a class as class members (`packages/[a{b,c}]` lists `,` `{` `}`). modeled() accepted both and listed a different member set, where the rule is to refuse syntax we don't read the way pnpm does. Refuse any alternative holding `..` beside a comma, and check classes on the raw glob too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011f9aeRocMg7S8GHB43bJsX --- .../src/utils/pnpm_workspace.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 8aac91d1e..bc6f3e4cc 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -338,7 +338,10 @@ fn modeled(globs: &[String]) -> Result<(), String> { if !glob.contains(['[', ']', '{', '}']) { return true; } + // Classes are checked on the raw glob too: pnpm reads braces inside + // a class as class members (`[a{b,c}]` lists `,` and `{`). !glob.contains(['+', '\\']) + && plain_classes(glob) && plain_braces(glob) && brace_alternatives(glob).is_some_and(|alternatives| { alternatives.iter().all(|alternative| { @@ -391,7 +394,13 @@ fn plain_braces(glob: &str) -> bool { } alternatives.push(&body[start..]); if alternatives.len() > 1 { - if alternatives.iter().any(|a| a.is_empty()) { + // pnpm expands a `..` beside a comma as a sequence too + // (`{a,b..c}` lists `,` and `A`), so only a lone + // same-kind sequence is read. + if alternatives + .iter() + .any(|a| a.is_empty() || a.contains("..")) + { return false; } } else if body.contains("..") && !same_kind_range(body) { @@ -1496,6 +1505,12 @@ mod tests { "packages/{a,}", "packages/{,a}", "packages/{a,{b,}}", + // A `..` beside a comma is a sequence to pnpm, and braces inside + // a class are class members. + "packages/{a,b..c}", + "packages/{a,..}", + "packages/{a,b..c,x}", + "packages/[a{b,c}]", // A range from `-` spans `.` and `/`. "packages/[--9]h", ] { From 8a311af5ea4fe5c9a46e4afee62958012ec11551 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:51:38 +0000 Subject: [PATCH 12/12] Test pnpm brace groups without a comma stay literal pnpm 10.28 keeps a brace group with no top-level comma or `..` literal, also around a nested set: `packages/{app-{web,api}}` lists only a directory named `{app-web}`, never `app-web`. The shared matcher reads it the same way; this pins that. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/utils/pnpm_workspace.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index fabfcc7c9..8671daab2 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -1546,6 +1546,28 @@ mod tests { } } + /// A brace group without a top-level comma or `..` stays literal, also + /// around a nested set, as pnpm 10.28.0 reads it: + /// `packages/{app-{web,api}}` lists only `packages/{app-web}`, never + /// `packages/app-web`, and `packages/{a}` lists `packages/{a}`. + #[test] + fn member_dirs_keep_a_commaless_brace_group_literal() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in ["app-web", "app-api", "{app-web}", "a", "{a}"] { + std::fs::create_dir_all(root.join("packages").join(dir)).unwrap(); + } + let globs = |g: &str| vec![g.to_string()]; + assert_eq!( + member_dirs(&DiskTree(root), &globs("packages/{app-{web,api}}")), + Ok(vec!["packages/{app-web}".to_string()]) + ); + assert_eq!( + member_dirs(&DiskTree(root), &globs("packages/{a}")), + Ok(vec!["packages/{a}".to_string()]) + ); + } + #[test] fn member_dirs_refuse_a_glob_the_bounded_walk_cannot_finish() { // More directories below `packages/` than the walk visits: the