@@ -10,15 +10,63 @@ use std::{
1010 io:: { self , Read } ,
1111} ;
1212
13- use vite_glob :: GlobPatternSet ;
13+ use path_clean :: PathClean ;
1414#[ cfg( test) ]
1515use vite_path:: AbsolutePathBuf ;
1616use vite_path:: { AbsolutePath , RelativePathBuf } ;
1717use vite_str:: Str ;
18- use wax:: { Glob , walk:: Entry as _} ;
18+ use wax:: { Glob , Program as _} ;
19+
20+ /// A negative glob pattern resolved to an absolute base directory and optional variant.
21+ ///
22+ /// For example, `../shared/dist/**` relative to `/ws/packages/app` resolves to:
23+ /// - `resolved_base`: `/ws/packages/shared/dist`
24+ /// - `variant`: `Some(Glob("**"))`
25+ #[ expect( clippy:: disallowed_types, reason = "path_clean returns std::path::PathBuf" ) ]
26+ pub struct ResolvedNegativeGlob {
27+ resolved_base : std:: path:: PathBuf ,
28+ variant : Option < Glob < ' static > > ,
29+ }
30+
31+ /// Resolve negative globs relative to `base_dir`, handling `..` components.
32+ ///
33+ /// Uses [`Glob::partition`] to split each pattern into an invariant base and an
34+ /// optional variant, then resolves the base with [`path_clean`] to normalize `..`.
35+ pub fn resolve_negative_globs (
36+ patterns : & std:: collections:: BTreeSet < Str > ,
37+ base_dir : & AbsolutePath ,
38+ ) -> anyhow:: Result < Vec < ResolvedNegativeGlob > > {
39+ patterns
40+ . iter ( )
41+ . map ( |pattern| {
42+ let glob = Glob :: new ( pattern. as_str ( ) ) ?. into_owned ( ) ;
43+ let ( base_pathbuf, variant) = glob. partition ( ) ;
44+ let base_str = base_pathbuf. to_str ( ) . unwrap_or ( "." ) ;
45+ let resolved_base = if base_str. is_empty ( ) {
46+ base_dir. as_path ( ) . to_path_buf ( )
47+ } else {
48+ base_dir. join ( base_str) . as_path ( ) . clean ( )
49+ } ;
50+ Ok ( ResolvedNegativeGlob { resolved_base, variant : variant. map ( Glob :: into_owned) } )
51+ } )
52+ . collect ( )
53+ }
54+
55+ /// Check if an absolute path is excluded by any of the resolved negative globs.
56+ #[ expect( clippy:: disallowed_types, reason = "matching against std::path::Path from wax walker" ) ]
57+ pub fn is_excluded ( path : & std:: path:: Path , negatives : & [ ResolvedNegativeGlob ] ) -> bool {
58+ negatives. iter ( ) . any ( |neg| {
59+ path. strip_prefix ( & neg. resolved_base ) . ok ( ) . is_some_and ( |remainder| {
60+ neg. variant . as_ref ( ) . map_or ( remainder. as_os_str ( ) . is_empty ( ) , |v| v. is_match ( remainder) )
61+ } )
62+ } )
63+ }
1964
2065/// Compute globbed inputs by walking positive glob patterns and filtering with negative patterns.
2166///
67+ /// Glob patterns may contain `..` to reference files outside the package directory
68+ /// (e.g., `../shared/src/**` to include a sibling package's source files).
69+ ///
2270/// # Arguments
2371/// * `base_dir` - The package directory where the task is defined (globs are relative to this)
2472/// * `workspace_root` - The workspace root for computing relative paths in the result
@@ -40,10 +88,7 @@ use wax::{Glob, walk::Entry as _};
4088/// )?;
4189/// // Returns: { "packages/foo/src/index.ts" => 0x1234..., ... }
4290/// ```
43- #[ expect(
44- clippy:: disallowed_methods,
45- reason = "str::replace needed for path normalization; cow_replace unavailable in this crate"
46- ) ]
91+ #[ expect( clippy:: disallowed_types, reason = "path_clean and wax walker return std::path types" ) ]
4792pub fn compute_globbed_inputs (
4893 base_dir : & AbsolutePath ,
4994 workspace_root : & AbsolutePath ,
@@ -55,44 +100,44 @@ pub fn compute_globbed_inputs(
55100 return Ok ( BTreeMap :: new ( ) ) ;
56101 }
57102
58- // Build negative pattern matcher if there are negative patterns
59- let negative_patterns: Vec < Str > = negative_globs. iter ( ) . cloned ( ) . collect ( ) ;
60- let negative_matcher = if negative_patterns. is_empty ( ) {
61- None
62- } else {
63- Some ( GlobPatternSet :: new ( & negative_patterns) ?)
64- } ;
103+ // Resolve negative globs, normalizing `..` components
104+ let resolved_negatives = resolve_negative_globs ( negative_globs, base_dir) ?;
65105
66106 let mut result = BTreeMap :: new ( ) ;
67107
68- // Walk each positive glob pattern
108+ // Walk each positive glob pattern, resolving `..` via partition + path_clean
69109 for pattern in positive_globs {
70- let glob = Glob :: new ( pattern. as_str ( ) ) ?;
71- for entry in glob. walk ( base_dir. as_path ( ) ) {
72- let Ok ( entry) = entry else {
73- continue ;
74- } ;
110+ let glob = Glob :: new ( pattern. as_str ( ) ) ?. into_owned ( ) ;
111+ let ( base_pathbuf, variant) = glob. partition ( ) ;
112+ let base_str = base_pathbuf. to_str ( ) . unwrap_or ( "." ) ;
113+ let resolved_base = if base_str. is_empty ( ) {
114+ base_dir. as_path ( ) . to_path_buf ( )
115+ } else {
116+ base_dir. join ( base_str) . as_path ( ) . clean ( )
117+ } ;
118+
119+ let entries: Box < dyn Iterator < Item = std:: path:: PathBuf > > = match variant {
120+ Some ( variant_glob) => Box :: new (
121+ variant_glob
122+ . walk ( & resolved_base)
123+ . filter_map ( Result :: ok)
124+ . map ( wax:: walk:: Entry :: into_path) ,
125+ ) ,
126+ None => {
127+ // No wildcard: exact file path
128+ Box :: new ( std:: iter:: once ( resolved_base) )
129+ }
130+ } ;
75131
132+ for absolute_path in entries {
76133 // Skip non-files
77- if !entry . file_type ( ) . is_file ( ) {
134+ if !absolute_path . is_file ( ) {
78135 continue ;
79136 }
80137
81- let absolute_path = entry. path ( ) ;
82-
83- // Compute path relative to base_dir for negative pattern matching
84- let Ok ( relative_to_base) = absolute_path. strip_prefix ( base_dir. as_path ( ) ) else {
85- continue ; // Skip if path is outside base_dir
86- } ;
87-
88- // Apply negative patterns (relative to base_dir)
89- if let Some ( ref matcher) = negative_matcher {
90- // Convert to forward slashes for consistent matching
91- let path_str = relative_to_base. to_string_lossy ( ) ;
92- let normalized = path_str. replace ( '\\' , "/" ) ;
93- if matcher. is_match ( & normalized) {
94- continue ;
95- }
138+ // Apply negative patterns
139+ if is_excluded ( & absolute_path, & resolved_negatives) {
140+ continue ;
96141 }
97142
98143 // Compute path relative to workspace_root for the result
@@ -105,7 +150,7 @@ pub fn compute_globbed_inputs(
105150 } ;
106151
107152 // Hash file content
108- match hash_file_content ( absolute_path) {
153+ match hash_file_content ( & absolute_path) {
109154 Ok ( hash) => {
110155 result. insert ( relative_to_workspace, hash) ;
111156 }
@@ -376,6 +421,62 @@ mod tests {
376421 assert ! ( result. is_empty( ) ) ;
377422 }
378423
424+ /// Creates a workspace with a sibling package for testing `..` globs
425+ fn create_workspace_with_sibling ( ) -> ( TempDir , AbsolutePathBuf , AbsolutePathBuf ) {
426+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
427+ let workspace_root = AbsolutePathBuf :: new ( temp_dir. path ( ) . to_path_buf ( ) ) . unwrap ( ) ;
428+
429+ // Create sub-pkg
430+ let sub_pkg = workspace_root. join ( "packages/sub-pkg" ) ;
431+ fs:: create_dir_all ( sub_pkg. join ( "src" ) ) . unwrap ( ) ;
432+ fs:: write ( sub_pkg. join ( "src/main.ts" ) , "export const sub = 1;" ) . unwrap ( ) ;
433+
434+ // Create sibling shared package
435+ let shared = workspace_root. join ( "packages/shared" ) ;
436+ fs:: create_dir_all ( shared. join ( "src" ) ) . unwrap ( ) ;
437+ fs:: create_dir_all ( shared. join ( "dist" ) ) . unwrap ( ) ;
438+ fs:: write ( shared. join ( "src/utils.ts" ) , "export const shared = 1;" ) . unwrap ( ) ;
439+ fs:: write ( shared. join ( "dist/output.js" ) , "// output" ) . unwrap ( ) ;
440+
441+ let sub_pkg_abs = AbsolutePathBuf :: new ( sub_pkg. into_path_buf ( ) ) . unwrap ( ) ;
442+ ( temp_dir, workspace_root, sub_pkg_abs)
443+ }
444+
445+ #[ test]
446+ fn test_dotdot_positive_glob_matches_sibling_package ( ) {
447+ let ( _temp, workspace, sub_pkg) = create_workspace_with_sibling ( ) ;
448+ let positive: std:: collections:: BTreeSet < Str > =
449+ std:: iter:: once ( "../shared/src/**" . into ( ) ) . collect ( ) ;
450+ let negative = std:: collections:: BTreeSet :: new ( ) ;
451+
452+ let result = compute_globbed_inputs ( & sub_pkg, & workspace, & positive, & negative) . unwrap ( ) ;
453+ eprintln ! ( "dotdot positive result: {result:#?}" ) ;
454+ assert ! (
455+ result. contains_key( & RelativePathBuf :: new( "packages/shared/src/utils.ts" ) . unwrap( ) ) ,
456+ "should find sibling package file via ../shared/src/**"
457+ ) ;
458+ }
459+
460+ #[ test]
461+ fn test_dotdot_negative_glob_excludes_from_sibling ( ) {
462+ let ( _temp, workspace, sub_pkg) = create_workspace_with_sibling ( ) ;
463+ let positive: std:: collections:: BTreeSet < Str > =
464+ std:: iter:: once ( "../shared/**" . into ( ) ) . collect ( ) ;
465+ let negative: std:: collections:: BTreeSet < Str > =
466+ std:: iter:: once ( "../shared/dist/**" . into ( ) ) . collect ( ) ;
467+
468+ let result = compute_globbed_inputs ( & sub_pkg, & workspace, & positive, & negative) . unwrap ( ) ;
469+ eprintln ! ( "dotdot negative result: {result:#?}" ) ;
470+ assert ! (
471+ result. contains_key( & RelativePathBuf :: new( "packages/shared/src/utils.ts" ) . unwrap( ) ) ,
472+ "should include non-excluded sibling file"
473+ ) ;
474+ assert ! (
475+ !result. contains_key( & RelativePathBuf :: new( "packages/shared/dist/output.js" ) . unwrap( ) ) ,
476+ "should exclude dist via ../shared/dist/**"
477+ ) ;
478+ }
479+
379480 #[ test]
380481 fn test_overlapping_positive_globs_deduplicates ( ) {
381482 let ( _temp, workspace, package) = create_test_workspace ( ) ;
0 commit comments