diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs index b36863e22..c267fa585 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs @@ -46,9 +46,6 @@ #![cfg(feature = "docker-e2e")] -#[allow(clippy::duplicate_mod)] -#[path = "common/fs_tree.rs"] -mod fs_tree; #[path = "common/cache_env.rs"] mod cache_env; #[path = "composer_e2e_common/mod.rs"] @@ -57,6 +54,9 @@ mod composer_e2e_common; mod docker_common; #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; +#[allow(clippy::duplicate_mod)] +#[path = "common/fs_tree.rs"] +mod fs_tree; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; 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"], &[], diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 007876d86..cb0c9bb63 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -641,10 +641,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 @@ -678,8 +680,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()); @@ -688,31 +689,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?; @@ -1340,6 +1316,47 @@ mod tests { ); } + /// #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}" + ); + } + } + } + /// #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. diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 3fbcc1f4b..8671daab2 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -31,7 +31,7 @@ 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. @@ -272,30 +272,84 @@ pub fn shared_lockfile_disabled(workspace: &str, npmrc: Option<&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 { 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 -/// not model (braces, classes, extglobs). +/// 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, `_`, 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: +/// `packages/\[a]` lists only `packages/[a]`, the matcher's separator) are +/// 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> { - match globs - .iter() - .find(|g| g.contains(['{', '}', '[', ']', '(', ')'])) - { + 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]; + // 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 == '-') + { + return false; + } + rest = &rest[open + 2 + len..]; + } + true + }; + let read = |glob: &String| { + if glob.contains(['(', ')']) { + return false; + } + 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| { + !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" )), @@ -303,25 +357,149 @@ 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. 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() { + 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 { + // 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) { + return false; + } + } + _ => {} + } + } + 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. +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 +} + +/// 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`]), /// 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 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)?; let mut candidates = std::collections::BTreeSet::new(); + let mut walked_alternatives = 0usize; for glob in globs.iter().filter(|g| !g.starts_with('!')) { - let (dirs, truncated) = expand_glob_bounded(tree, glob, MEMBER_SKIP); - if truncated { + // The tree walk reads neither braces nor classes: it walks each + // brace alternative with its classes widened ([`widen_classes`]). + // One bound covers every alternative of every glob. + let alternatives = brace_alternatives(glob).ok_or_else(|| { + format!("`packages:` glob {glob:?} uses syntax socket-patch does not read") + })?; + // A glob without a brace set is one walk, as it was before #1846: + // only the walks brace sets add count against the budget. + if alternatives.len() > 1 { + walked_alternatives += alternatives.len(); + } + if walked_alternatives > MAX_WALKED_ALTERNATIVES { return Err(format!( - "`packages:` glob {glob:?} names more than {MAX_MANIFESTS} directories, \ - more than socket-patch walks" + "`packages:` glob {glob:?} expands to more than {MAX_WALKED_ALTERNATIVES} \ + brace alternatives, more than socket-patch walks" )); } - candidates.extend(dirs); + for alternative in alternatives { + let walked = widen_classes(&alternative); + let (dirs, truncated) = expand_glob_bounded(tree, &walked, MEMBER_SKIP); + candidates.extend(dirs); + if truncated || candidates.len() >= MAX_MANIFESTS { + return Err(format!( + "`packages:` glob {glob:?} names more than {MAX_MANIFESTS} directories, \ + more than socket-patch walks" + )); + } + } } let mut dirs = Vec::new(); for dir in candidates { @@ -330,7 +508,7 @@ pub(crate) fn member_dirs(tree: &dyn DirTree, globs: &[String]) -> Result>(); + 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"]), + (&["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); + 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", + // pnpm reads `+` beside a class or brace set as a quantifier + // (`[ab]+` lists `ab`) and `\` as an escape (`\[a]` lists only + // a directory named `[a]`), also when a sequence spells them. + "packages/[ab]+", + "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}", + // A sequence across punctuation steps through path syntax. + "packages/{-..0}", + "packages/{0..Z}", + "packages/p{01..2}", + "packages/{a,*}", + "packages/{?h,x}", + "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", + ] { + 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}"); + } + } + + /// 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] @@ -1292,6 +1591,40 @@ 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"] + ); + // 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); + impl DirTree for Two { + fn is_real_dir(&self, rel: &str) -> 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] @@ -1622,11 +1955,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 8d6e1a774..157ad678a 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 @@ pub(crate) 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;