1mod deno_json;
2mod diagnostics;
3mod missing_pattern_dirs;
4mod nested_project;
5mod npm_overrides;
6mod package_json;
7mod parsers;
8mod pnpm_catalog;
9mod pnpm_overrides;
10mod tsconfig;
11mod undeclared;
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use rustc_hash::FxHashMap;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20pub use deno_json::{
21 DirManifestProbe, dir_has_deno_json, dir_has_package_manifest, is_deno_without_node_modules,
22 load_deno_import_map, load_dir_package_json, load_member_package_manifest,
23 load_root_deno_workspace_patterns, probe_dir_manifest,
24};
25#[cfg(test)]
26pub use diagnostics::capture_workspace_warnings;
27pub use diagnostics::{
28 WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
29 clear_analysis_stage_diagnostics, clear_health_stage_diagnostics,
30 health_stage_workspace_diagnostics, missing_node_modules_diagnostic, node_modules_missing,
31 node_modules_state, record_plugin_config_diagnostics, record_source_parse_degradations,
32 record_source_read_failures, record_workspace_diagnostics, registry_diagnostics_to_fold,
33 replace_source_discovery_diagnostics, stash_workspace_diagnostics, workspace_diagnostics_for,
34};
35use diagnostics::{emit_diagnostics, is_ignored_workspace_dir, is_skip_listed_dir};
36pub use missing_pattern_dirs::has_missing_workspace_pattern_dirs;
37use missing_pattern_dirs::missing_dir_patterns;
38pub use nested_project::is_independent_pnpm_project;
39use nested_project::{NestedProjectScan, collect_nested_project_workspaces};
40pub use npm_overrides::{parse_bun_package_json_resolutions, parse_npm_package_json_overrides};
41pub use package_json::{NapiConfig, PackageJson};
42pub use parsers::parse_tsconfig_root_dir;
43use parsers::{
44 expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
45 parse_tsconfig_references_with_diagnostics,
46};
47pub use pnpm_catalog::{
48 PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup,
49 parse_package_json_catalog_data, parse_pnpm_catalog_data,
50};
51pub use pnpm_overrides::{
52 MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
53 is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
54 parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
55};
56pub use tsconfig::{TsconfigOutputMap, TsconfigOutputResolution, tsconfig_linked_files};
57pub use undeclared::{find_undeclared_workspaces, find_undeclared_workspaces_with_ignores};
58
59#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
61pub struct WorkspaceConfig {
62 #[serde(default, alias = "packages")]
69 pub patterns: Vec<String>,
70 #[serde(
72 default,
73 rename = "changedSince",
74 skip_serializing_if = "BTreeMap::is_empty"
75 )]
76 pub changed_since: BTreeMap<String, String>,
77}
78
79#[derive(Debug, Clone)]
82pub struct WorkspaceInfo {
83 pub root: PathBuf,
85 pub name: String,
87 pub is_internal_dependency: bool,
89}
90
91#[must_use]
97pub fn workspace_is_public(name: &str, public_packages: &[String]) -> bool {
98 public_packages.iter().any(|pattern| {
99 name == pattern
100 || globset::Glob::new(pattern).is_ok_and(|glob| glob.compile_matcher().is_match(name))
101 })
102}
103
104#[must_use]
128pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
129 discover_workspaces_with_patterns(root, &[])
130}
131
132#[must_use]
135pub fn discover_workspaces_with_patterns(
136 root: &Path,
137 extra_patterns: &[String],
138) -> Vec<WorkspaceInfo> {
139 collect_workspaces_and_diagnostics(root, &crate::IgnorePatternSet::empty(), extra_patterns)
140 .map(|(workspaces, _)| workspaces)
141 .unwrap_or_default()
142}
143
144pub fn discover_workspaces_with_diagnostics(
174 root: &Path,
175 ignore_patterns: &crate::IgnorePatternSet,
176) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
177 discover_workspaces_with_diagnostics_and_patterns(root, ignore_patterns, &[])
178}
179
180pub fn discover_workspaces_with_diagnostics_and_patterns(
188 root: &Path,
189 ignore_patterns: &crate::IgnorePatternSet,
190 extra_patterns: &[String],
191) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
192 let (workspaces, diagnostics) =
193 collect_workspaces_and_diagnostics(root, ignore_patterns, extra_patterns)?;
194
195 emit_diagnostics(root, &diagnostics);
196
197 Ok((workspaces, diagnostics))
198}
199
200fn collect_workspaces_and_diagnostics(
222 root: &Path,
223 ignore_patterns: &crate::IgnorePatternSet,
224 extra_patterns: &[String],
225) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
226 let mut diagnostics = Vec::new();
227 let manifest_declares_workspaces = !collect_declared_patterns(root, &[])?.is_empty();
228 let declared = collect_declared_patterns(root, extra_patterns)?;
229 diagnostics.extend(missing_dir_patterns(root, &declared).map(|declared| {
230 pattern_source_diagnostic(
231 root,
232 declared.source.clone(),
233 WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
234 pattern: declared.pattern.clone(),
235 },
236 )
237 }));
238 let patterns = split_outside_root_patterns(root, declared, &mut diagnostics);
239 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
240 let mut manifest_cache = ManifestCache::default();
241
242 let mut workspaces = collect_declared_workspaces(
243 root,
244 &patterns,
245 &canonical_root,
246 ignore_patterns,
247 &mut diagnostics,
248 &mut manifest_cache,
249 );
250 workspaces.extend(collect_link_dependency_workspaces(
251 root,
252 &canonical_root,
253 ignore_patterns,
254 &mut diagnostics,
255 &mut manifest_cache,
256 ));
257 if manifest_declares_workspaces {
258 collect_nested_project_workspaces(
259 &NestedProjectScan {
260 root,
261 canonical_root: &canonical_root,
262 ignore_patterns,
263 },
264 &mut workspaces,
265 &mut diagnostics,
266 &mut manifest_cache,
267 );
268 } else {
269 workspaces.extend(collect_shallow_package_workspaces(
270 root,
271 &canonical_root,
272 &mut manifest_cache,
273 ));
274 }
275
276 if !workspaces.is_empty() {
277 mark_internal_dependencies(&mut workspaces);
278 }
279 let workspaces = workspaces.into_iter().map(|(ws, _)| ws).collect();
280 Ok((
281 workspaces,
282 fallow_types::workspace::dedupe_workspace_diagnostics(diagnostics),
283 ))
284}
285
286fn collect_declared_workspaces(
289 root: &Path,
290 patterns: &[String],
291 canonical_root: &Path,
292 ignore_patterns: &crate::IgnorePatternSet,
293 diagnostics: &mut Vec<WorkspaceDiagnostic>,
294 manifest_cache: &mut ManifestCache,
295) -> Vec<(WorkspaceInfo, Vec<String>)> {
296 let mut workspaces = expand_patterns_to_workspaces(
297 root,
298 patterns,
299 canonical_root,
300 ignore_patterns,
301 diagnostics,
302 manifest_cache,
303 );
304 workspaces.extend(collect_tsconfig_workspaces(
305 root,
306 canonical_root,
307 ignore_patterns,
308 diagnostics,
309 manifest_cache,
310 ));
311 workspaces
312}
313
314#[must_use]
324pub fn link_only_workspace_dependencies(
325 root: &Path,
326 ignore_patterns: &crate::IgnorePatternSet,
327 workspaces: &[WorkspaceInfo],
328 extra_patterns: &[String],
329) -> rustc_hash::FxHashSet<String> {
330 let Ok(root_pkg) = PackageJson::load(&root.join("package.json")) else {
331 return rustc_hash::FxHashSet::default();
332 };
333 let links = root_pkg.local_link_dependencies();
334 if links.is_empty() || workspaces.is_empty() {
335 return rustc_hash::FxHashSet::default();
336 }
337 let canonical = |path: &Path| dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
338 let workspace_roots: rustc_hash::FxHashSet<PathBuf> =
339 workspaces.iter().map(|ws| canonical(&ws.root)).collect();
340 let linked: Vec<(String, PathBuf)> = links
341 .into_iter()
342 .map(|(name, target)| (name, canonical(&root.join(target))))
343 .filter(|(_, dir)| workspace_roots.contains(dir))
344 .collect();
345 if linked.is_empty() {
346 return rustc_hash::FxHashSet::default();
347 }
348
349 let patterns = collect_workspace_patterns(root, extra_patterns).unwrap_or_default();
350 let declared: rustc_hash::FxHashSet<PathBuf> = collect_declared_workspaces(
351 root,
352 &patterns,
353 &canonical(root),
354 ignore_patterns,
355 &mut Vec::new(),
356 &mut ManifestCache::default(),
357 )
358 .into_iter()
359 .map(|(ws, _)| canonical(&ws.root))
360 .collect();
361 linked
362 .into_iter()
363 .filter(|(_, dir)| !declared.contains(dir))
364 .map(|(name, _)| name)
365 .collect()
366}
367
368fn pattern_source_diagnostic(
373 root: &Path,
374 source: PathBuf,
375 kind: WorkspaceDiagnosticKind,
376) -> WorkspaceDiagnostic {
377 let anchored_at_root = source == root;
378 let diagnostic = WorkspaceDiagnostic::new(root, source, kind);
379 if anchored_at_root {
380 diagnostic.into_root_relative(root)
381 } else {
382 diagnostic
383 }
384}
385
386struct DeclaredPattern {
391 pattern: String,
392 source: PathBuf,
393}
394
395fn collect_workspace_patterns(
403 root: &Path,
404 extra_patterns: &[String],
405) -> Result<Vec<String>, WorkspaceLoadError> {
406 let declared = collect_declared_patterns(root, extra_patterns)?;
407 Ok(split_outside_root_patterns(root, declared, &mut Vec::new()))
408}
409
410fn collect_declared_patterns(
413 root: &Path,
414 extra_patterns: &[String],
415) -> Result<Vec<DeclaredPattern>, WorkspaceLoadError> {
416 let mut patterns = Vec::new();
417 let mut push_all = |source: &Path, entries: Vec<String>| {
418 patterns.extend(entries.into_iter().map(|pattern| DeclaredPattern {
419 pattern,
420 source: source.to_path_buf(),
421 }));
422 };
423
424 let pkg_path = root.join("package.json");
425 if pkg_path.exists() {
426 match PackageJson::load(&pkg_path) {
427 Ok(pkg) => push_all(&pkg_path, pkg.workspace_patterns()),
428 Err(error) => {
429 return Err(WorkspaceLoadError::MalformedRootPackageJson {
430 path: pkg_path,
431 error,
432 });
433 }
434 }
435 }
436
437 let pnpm_workspace = root.join("pnpm-workspace.yaml");
438 if pnpm_workspace.exists()
439 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
440 {
441 push_all(&pnpm_workspace, parse_pnpm_workspace_yaml(&content));
442 }
443
444 if let Some((deno_path, deno_patterns)) = load_root_deno_workspace_patterns(root)
445 .map_err(|(path, error)| WorkspaceLoadError::MalformedRootDenoConfig { path, error })?
446 {
447 push_all(&deno_path, deno_patterns);
448 }
449
450 for pattern in extra_patterns {
451 if !patterns.iter().any(|declared| declared.pattern == *pattern) {
452 patterns.push(DeclaredPattern {
453 pattern: pattern.clone(),
454 source: root.to_path_buf(),
455 });
456 }
457 }
458
459 Ok(patterns)
460}
461
462fn split_outside_root_patterns(
470 root: &Path,
471 declared: Vec<DeclaredPattern>,
472 diagnostics: &mut Vec<WorkspaceDiagnostic>,
473) -> Vec<String> {
474 let mut patterns = Vec::with_capacity(declared.len());
475 for DeclaredPattern { pattern, source } in declared {
476 if !pattern.starts_with('!') && crate::glob_validation::escapes_project_root(&pattern) {
477 diagnostics.push(pattern_source_diagnostic(
478 root,
479 source,
480 WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot { pattern },
481 ));
482 continue;
483 }
484 patterns.push(pattern);
485 }
486 patterns
487}
488
489type ManifestCache = FxHashMap<PathBuf, Result<Option<(String, PackageJson, Vec<String>)>, String>>;
497
498fn load_member_package_manifest_cached(
501 dir: &Path,
502 cache: &mut ManifestCache,
503) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
504 if let Some(cached) = cache.get(dir) {
505 return cached.clone();
506 }
507 let outcome = load_member_package_manifest(dir);
508 cache.insert(dir.to_path_buf(), outcome.clone());
509 outcome
510}
511
512fn expand_patterns_to_workspaces(
517 root: &Path,
518 patterns: &[String],
519 canonical_root: &Path,
520 ignore_patterns: &crate::IgnorePatternSet,
521 diagnostics: &mut Vec<WorkspaceDiagnostic>,
522 manifest_cache: &mut ManifestCache,
523) -> Vec<(WorkspaceInfo, Vec<String>)> {
524 if patterns.is_empty() {
525 return Vec::new();
526 }
527
528 let mut workspaces = Vec::new();
529
530 let (positive, negative): (Vec<&String>, Vec<&String>) =
531 patterns.iter().partition(|p| !p.starts_with('!'));
532 let negation_matchers: Vec<globset::GlobMatcher> = negative
533 .iter()
534 .filter_map(|p| {
535 let stripped = p.strip_prefix('!').unwrap_or(p);
536 globset::Glob::new(stripped)
537 .ok()
538 .map(|g| g.compile_matcher())
539 })
540 .collect();
541
542 for pattern in &positive {
543 let glob_pattern = if pattern.ends_with('/') {
544 format!("{pattern}*")
545 } else {
546 (*pattern).clone()
547 };
548
549 let matched_dirs = expand_workspace_glob_with_diagnostics(
550 root,
551 pattern,
552 &glob_pattern,
553 canonical_root,
554 ignore_patterns,
555 diagnostics,
556 );
557 for (dir, canonical_dir) in matched_dirs {
558 if canonical_dir == *canonical_root {
559 continue;
560 }
561 if matches_negation(root, &dir, &negation_matchers) {
562 continue;
563 }
564 register_matched_workspace(root, dir, &mut workspaces, diagnostics, manifest_cache);
565 }
566 }
567
568 workspaces
569}
570
571fn matches_negation(root: &Path, dir: &Path, negation_matchers: &[globset::GlobMatcher]) -> bool {
573 let relative = dir.strip_prefix(root).unwrap_or(dir);
574 let relative_str = relative.to_string_lossy();
575 negation_matchers
576 .iter()
577 .any(|m| m.is_match(relative_str.as_ref()))
578}
579
580fn register_matched_workspace(
584 root: &Path,
585 dir: PathBuf,
586 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
587 diagnostics: &mut Vec<WorkspaceDiagnostic>,
588 manifest_cache: &mut ManifestCache,
589) {
590 match load_member_package_manifest_cached(&dir, manifest_cache) {
591 Ok(Some((name, _pkg, dep_names))) => {
592 workspaces.push((
593 WorkspaceInfo {
594 root: dir,
595 name,
596 is_internal_dependency: false,
597 },
598 dep_names,
599 ));
600 }
601 Ok(None) => {}
602 Err(error) => {
603 let diag = WorkspaceDiagnostic::new(
604 root,
605 dir,
606 WorkspaceDiagnosticKind::MalformedPackageJson { error },
607 );
608 diagnostics.push(diag);
609 }
610 }
611}
612
613fn collect_tsconfig_workspaces(
618 root: &Path,
619 canonical_root: &Path,
620 ignore_patterns: &crate::IgnorePatternSet,
621 diagnostics: &mut Vec<WorkspaceDiagnostic>,
622 manifest_cache: &mut ManifestCache,
623) -> Vec<(WorkspaceInfo, Vec<String>)> {
624 let mut workspaces = Vec::new();
625
626 for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
627 if let Some(workspace) =
628 tsconfig_workspace_from_dir(root, dir, canonical_root, diagnostics, manifest_cache)
629 {
630 workspaces.push(workspace);
631 }
632 }
633
634 workspaces
635}
636
637fn tsconfig_workspace_from_dir(
638 root: &Path,
639 dir: PathBuf,
640 canonical_root: &Path,
641 diagnostics: &mut Vec<WorkspaceDiagnostic>,
642 manifest_cache: &mut ManifestCache,
643) -> Option<(WorkspaceInfo, Vec<String>)> {
644 let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
645 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
646 return None;
647 }
648
649 let (name, dep_names) =
650 load_tsconfig_workspace_package(root, &dir, diagnostics, manifest_cache);
651 Some((
652 WorkspaceInfo {
653 root: dir,
654 name,
655 is_internal_dependency: false,
656 },
657 dep_names,
658 ))
659}
660
661fn load_tsconfig_workspace_package(
662 root: &Path,
663 dir: &Path,
664 diagnostics: &mut Vec<WorkspaceDiagnostic>,
665 manifest_cache: &mut ManifestCache,
666) -> (String, Vec<String>) {
667 match load_member_package_manifest_cached(dir, manifest_cache) {
668 Ok(Some((name, _pkg, deps))) => (name, deps),
669 Ok(None) => (dir_name(dir), Vec::new()),
670 Err(error) => {
671 let diag = WorkspaceDiagnostic::new(
672 root,
673 dir.to_path_buf(),
674 WorkspaceDiagnosticKind::MalformedPackageJson { error },
675 );
676 diagnostics.push(diag);
677 (dir_name(dir), Vec::new())
678 }
679 }
680}
681
682fn collect_link_dependency_workspaces(
697 root: &Path,
698 canonical_root: &Path,
699 ignore_patterns: &crate::IgnorePatternSet,
700 diagnostics: &mut Vec<WorkspaceDiagnostic>,
701 manifest_cache: &mut ManifestCache,
702) -> Vec<(WorkspaceInfo, Vec<String>)> {
703 let Ok(root_pkg) = PackageJson::load(&root.join("package.json")) else {
704 return Vec::new();
705 };
706
707 let candidates: Vec<PathBuf> = root_pkg
708 .local_link_dependencies()
709 .into_iter()
710 .filter_map(|(_name, target)| dunce::canonicalize(root.join(target)).ok())
711 .filter(|canonical_dir| {
712 *canonical_dir != *canonical_root
713 && canonical_dir.starts_with(canonical_root)
714 && canonical_dir.is_dir()
715 && !is_skipped_link_target(canonical_root, canonical_dir, ignore_patterns)
716 })
717 .collect();
718 let walked = link_targets_reached_by_source_walk(canonical_root, &candidates);
719
720 let mut workspaces = Vec::new();
721 for canonical_dir in candidates {
722 if !walked.contains(&canonical_dir) {
723 continue;
724 }
725 let dir = normalize_link_target(root, canonical_root, &canonical_dir);
726 register_matched_workspace(root, dir, &mut workspaces, diagnostics, manifest_cache);
727 }
728 workspaces
729}
730
731fn is_skipped_link_target(
739 canonical_root: &Path,
740 canonical_dir: &Path,
741 ignore_patterns: &crate::IgnorePatternSet,
742) -> bool {
743 let Ok(relative) = canonical_dir.strip_prefix(canonical_root) else {
744 return true;
745 };
746 relative
747 .components()
748 .any(|component| is_skip_listed_dir(&component.as_os_str().to_string_lossy()))
749 || is_ignored_workspace_dir(relative, ignore_patterns)
750}
751
752fn link_targets_reached_by_source_walk(
763 canonical_root: &Path,
764 targets: &[PathBuf],
765) -> rustc_hash::FxHashSet<PathBuf> {
766 let mut reached = rustc_hash::FxHashSet::default();
767 if targets.is_empty() {
768 return reached;
769 }
770 let mut on_path: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
771 for target in targets {
772 for ancestor in target.ancestors() {
773 if ancestor == canonical_root || !on_path.insert(ancestor.to_path_buf()) {
774 break;
775 }
776 }
777 }
778 let max_depth = targets
779 .iter()
780 .filter_map(|target| target.strip_prefix(canonical_root).ok())
781 .map(|relative| relative.components().count())
782 .max();
783 let mut builder = crate::source_walk_builder(canonical_root);
784 builder
785 .max_depth(max_depth)
786 .filter_entry(move |entry| entry.depth() == 0 || on_path.contains(entry.path()));
787 let targets: rustc_hash::FxHashSet<&Path> = targets.iter().map(PathBuf::as_path).collect();
788 for entry in builder.build().flatten() {
789 if targets.contains(entry.path()) {
790 reached.insert(entry.into_path());
791 }
792 }
793 reached
794}
795
796fn normalize_link_target(root: &Path, canonical_root: &Path, canonical_dir: &Path) -> PathBuf {
801 canonical_dir
802 .strip_prefix(canonical_root)
803 .map_or_else(|_| canonical_dir.to_path_buf(), |rel| root.join(rel))
804}
805
806fn collect_shallow_package_workspaces(
813 root: &Path,
814 canonical_root: &Path,
815 manifest_cache: &mut ManifestCache,
816) -> Vec<(WorkspaceInfo, Vec<String>)> {
817 let mut workspaces = Vec::new();
818 let Ok(top_entries) = std::fs::read_dir(root) else {
819 return workspaces;
820 };
821
822 for entry in top_entries.filter_map(Result::ok) {
823 let path = entry.path();
824 if !path.is_dir() || is_skip_listed_dir(&entry.file_name().to_string_lossy()) {
825 continue;
826 }
827
828 collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces, manifest_cache);
829 collect_shallow_child_workspaces(&path, canonical_root, &mut workspaces, manifest_cache);
830 }
831
832 workspaces
833}
834
835fn collect_shallow_child_workspaces(
836 parent: &Path,
837 canonical_root: &Path,
838 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
839 manifest_cache: &mut ManifestCache,
840) {
841 let Ok(child_entries) = std::fs::read_dir(parent) else {
842 return;
843 };
844 for child in child_entries.filter_map(Result::ok) {
845 let child_path = child.path();
846 if !child_path.is_dir() || is_skip_listed_dir(&child.file_name().to_string_lossy()) {
847 continue;
848 }
849
850 collect_shallow_workspace_candidate(
851 &child_path,
852 canonical_root,
853 workspaces,
854 manifest_cache,
855 );
856 }
857}
858
859fn collect_shallow_workspace_candidate(
860 dir: &Path,
861 canonical_root: &Path,
862 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
863 manifest_cache: &mut ManifestCache,
864) {
865 let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
866 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
867 return;
868 }
869
870 let Ok(Some((name, _pkg, dep_names))) =
871 load_member_package_manifest_cached(dir, manifest_cache)
872 else {
873 return;
874 };
875
876 workspaces.push((
877 WorkspaceInfo {
878 root: dir.to_path_buf(),
879 name,
880 is_internal_dependency: false,
881 },
882 dep_names,
883 ));
884}
885
886fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
892 {
893 let mut seen = rustc_hash::FxHashSet::default();
894 workspaces.retain(|(ws, _)| {
895 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
896 seen.insert(canonical)
897 });
898 }
899
900 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
901 .iter()
902 .flat_map(|(_, deps)| deps.iter().cloned())
903 .collect();
904
905 for (ws, _) in &mut *workspaces {
906 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
907 }
908}
909
910fn dir_name(dir: &Path) -> String {
912 dir.file_name()
913 .map(|n| n.to_string_lossy().to_string())
914 .unwrap_or_default()
915}
916
917#[cfg(test)]
928pub fn write_two_manifest_glob_project(root: &Path) {
929 std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
930 std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
931 std::fs::write(
932 root.join("package.json"),
933 r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
934 )
935 .unwrap();
936 std::fs::write(
937 root.join("pnpm-workspace.yaml"),
938 "packages:\n - \"pkgs/*\"\n",
939 )
940 .unwrap();
941 std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
942 std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948
949 #[test]
950 fn public_workspace_selection_supports_exact_names_and_globs() {
951 assert!(!workspace_is_public("@scope/core", &[]));
952 assert!(workspace_is_public(
953 "@scope/core",
954 &["@scope/core".to_string()]
955 ));
956 assert!(workspace_is_public(
957 "@scope/core",
958 &["@scope/*".to_string()]
959 ));
960 assert!(!workspace_is_public(
961 "@scope/core",
962 &["[invalid".to_string(), "@other/*".to_string()]
963 ));
964 }
965
966 #[test]
967 fn discover_workspaces_from_tsconfig_references() {
968 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
969 let _ = std::fs::remove_dir_all(&temp_dir);
970 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
971 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
972
973 std::fs::write(
974 temp_dir.join("tsconfig.json"),
975 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
976 )
977 .unwrap();
978
979 std::fs::write(
980 temp_dir.join("packages/core/package.json"),
981 r#"{"name": "@project/core"}"#,
982 )
983 .unwrap();
984
985 let workspaces = discover_workspaces(&temp_dir);
986 assert_eq!(workspaces.len(), 2);
987 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
988 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
989
990 let _ = std::fs::remove_dir_all(&temp_dir);
991 }
992
993 #[test]
994 fn tsconfig_references_outside_root_rejected() {
995 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
996 let _ = std::fs::remove_dir_all(&temp_dir);
997 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
998 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
999
1000 std::fs::write(
1001 temp_dir.join("project/tsconfig.json"),
1002 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
1003 )
1004 .unwrap();
1005
1006 let workspaces = discover_workspaces(&temp_dir.join("project"));
1007 assert_eq!(
1008 workspaces.len(),
1009 1,
1010 "reference outside project root should be rejected: {workspaces:?}"
1011 );
1012 assert!(
1013 workspaces[0]
1014 .root
1015 .to_string_lossy()
1016 .contains("packages/core")
1017 );
1018
1019 let _ = std::fs::remove_dir_all(&temp_dir);
1020 }
1021
1022 #[test]
1023 fn dir_name_extracts_last_component() {
1024 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
1025 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
1026 }
1027
1028 #[test]
1029 fn dir_name_empty_for_root_path() {
1030 assert_eq!(dir_name(Path::new("/")), "");
1031 }
1032
1033 #[test]
1034 fn workspace_config_deserialize_json() {
1035 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
1036 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1037 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
1038 }
1039
1040 #[test]
1041 fn workspace_config_deserialize_empty_patterns() {
1042 let json = r#"{"patterns": []}"#;
1043 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1044 assert!(config.patterns.is_empty());
1045 }
1046
1047 #[test]
1048 fn workspace_config_default_patterns() {
1049 let json = "{}";
1050 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1051 assert!(config.patterns.is_empty());
1052 }
1053
1054 #[test]
1055 fn workspace_info_default_not_internal() {
1056 let ws = WorkspaceInfo {
1057 root: PathBuf::from("/project/packages/a"),
1058 name: "a".to_string(),
1059 is_internal_dependency: false,
1060 };
1061 assert!(!ws.is_internal_dependency);
1062 }
1063
1064 #[test]
1065 fn mark_internal_deps_detects_cross_references() {
1066 let temp_dir = tempfile::tempdir().expect("create temp dir");
1067 let pkg_a = temp_dir.path().join("a");
1068 let pkg_b = temp_dir.path().join("b");
1069 std::fs::create_dir_all(&pkg_a).unwrap();
1070 std::fs::create_dir_all(&pkg_b).unwrap();
1071
1072 let mut workspaces = vec![
1073 (
1074 WorkspaceInfo {
1075 root: pkg_a,
1076 name: "@scope/a".to_string(),
1077 is_internal_dependency: false,
1078 },
1079 vec!["@scope/b".to_string()], ),
1081 (
1082 WorkspaceInfo {
1083 root: pkg_b,
1084 name: "@scope/b".to_string(),
1085 is_internal_dependency: false,
1086 },
1087 vec!["lodash".to_string()], ),
1089 ];
1090
1091 mark_internal_dependencies(&mut workspaces);
1092
1093 let ws_a = workspaces
1094 .iter()
1095 .find(|(ws, _)| ws.name == "@scope/a")
1096 .unwrap();
1097 assert!(
1098 !ws_a.0.is_internal_dependency,
1099 "a is not depended on by others"
1100 );
1101
1102 let ws_b = workspaces
1103 .iter()
1104 .find(|(ws, _)| ws.name == "@scope/b")
1105 .unwrap();
1106 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
1107 }
1108
1109 #[test]
1110 fn mark_internal_deps_no_cross_references() {
1111 let temp_dir = tempfile::tempdir().expect("create temp dir");
1112 let pkg_a = temp_dir.path().join("a");
1113 let pkg_b = temp_dir.path().join("b");
1114 std::fs::create_dir_all(&pkg_a).unwrap();
1115 std::fs::create_dir_all(&pkg_b).unwrap();
1116
1117 let mut workspaces = vec![
1118 (
1119 WorkspaceInfo {
1120 root: pkg_a,
1121 name: "a".to_string(),
1122 is_internal_dependency: false,
1123 },
1124 vec!["react".to_string()],
1125 ),
1126 (
1127 WorkspaceInfo {
1128 root: pkg_b,
1129 name: "b".to_string(),
1130 is_internal_dependency: false,
1131 },
1132 vec!["lodash".to_string()],
1133 ),
1134 ];
1135
1136 mark_internal_dependencies(&mut workspaces);
1137
1138 assert!(!workspaces[0].0.is_internal_dependency);
1139 assert!(!workspaces[1].0.is_internal_dependency);
1140 }
1141
1142 #[test]
1143 fn mark_internal_deps_deduplicates_by_path() {
1144 let temp_dir = tempfile::tempdir().expect("create temp dir");
1145 let pkg_a = temp_dir.path().join("a");
1146 std::fs::create_dir_all(&pkg_a).unwrap();
1147
1148 let mut workspaces = vec![
1149 (
1150 WorkspaceInfo {
1151 root: pkg_a.clone(),
1152 name: "a".to_string(),
1153 is_internal_dependency: false,
1154 },
1155 vec![],
1156 ),
1157 (
1158 WorkspaceInfo {
1159 root: pkg_a,
1160 name: "a".to_string(),
1161 is_internal_dependency: false,
1162 },
1163 vec![],
1164 ),
1165 ];
1166
1167 mark_internal_dependencies(&mut workspaces);
1168 assert_eq!(
1169 workspaces.len(),
1170 1,
1171 "duplicate paths should be deduplicated"
1172 );
1173 }
1174
1175 #[test]
1176 fn collect_patterns_from_package_json() {
1177 let dir = tempfile::tempdir().expect("create temp dir");
1178 std::fs::write(
1179 dir.path().join("package.json"),
1180 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
1181 )
1182 .unwrap();
1183
1184 let patterns =
1185 collect_workspace_patterns(dir.path(), &[]).expect("valid root package.json");
1186 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
1187 }
1188
1189 #[test]
1190 fn collect_patterns_from_pnpm_workspace() {
1191 let dir = tempfile::tempdir().expect("create temp dir");
1192 std::fs::write(
1193 dir.path().join("pnpm-workspace.yaml"),
1194 "packages:\n - 'packages/*'\n - 'libs/*'\n",
1195 )
1196 .unwrap();
1197
1198 let patterns = collect_workspace_patterns(dir.path(), &[]).expect("no root package.json");
1199 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
1200 }
1201
1202 #[test]
1203 fn collect_patterns_combines_sources() {
1204 let dir = tempfile::tempdir().expect("create temp dir");
1205 std::fs::write(
1206 dir.path().join("package.json"),
1207 r#"{"workspaces": ["packages/*"]}"#,
1208 )
1209 .unwrap();
1210 std::fs::write(
1211 dir.path().join("pnpm-workspace.yaml"),
1212 "packages:\n - 'apps/*'\n",
1213 )
1214 .unwrap();
1215
1216 let patterns =
1217 collect_workspace_patterns(dir.path(), &[]).expect("valid root package.json");
1218 assert!(patterns.contains(&"packages/*".to_string()));
1219 assert!(patterns.contains(&"apps/*".to_string()));
1220 }
1221
1222 #[test]
1223 fn collect_patterns_empty_when_no_configs() {
1224 let dir = tempfile::tempdir().expect("create temp dir");
1225 let patterns = collect_workspace_patterns(dir.path(), &[]).expect("no root package.json");
1226 assert!(patterns.is_empty());
1227 }
1228
1229 #[test]
1230 fn discover_workspaces_from_package_json() {
1231 let dir = tempfile::tempdir().expect("create temp dir");
1232 let pkg_a = dir.path().join("packages").join("a");
1233 let pkg_b = dir.path().join("packages").join("b");
1234 std::fs::create_dir_all(&pkg_a).unwrap();
1235 std::fs::create_dir_all(&pkg_b).unwrap();
1236
1237 std::fs::write(
1238 dir.path().join("package.json"),
1239 r#"{"workspaces": ["packages/*"]}"#,
1240 )
1241 .unwrap();
1242 std::fs::write(
1243 pkg_a.join("package.json"),
1244 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1245 )
1246 .unwrap();
1247 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1248
1249 let workspaces = discover_workspaces(dir.path());
1250 assert_eq!(workspaces.len(), 2);
1251
1252 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1253 assert!(!ws_a.is_internal_dependency);
1254
1255 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1256 assert!(ws_b.is_internal_dependency, "b is depended on by a");
1257 }
1258
1259 #[test]
1260 fn configured_patterns_add_workspaces_beyond_the_manifest() {
1261 let dir = tempfile::tempdir().expect("create temp dir");
1262 let app = dir.path().join("apps").join("deep").join("one");
1263 std::fs::create_dir_all(&app).unwrap();
1264 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1265 std::fs::write(app.join("package.json"), r#"{"name": "@test/one"}"#).unwrap();
1266
1267 assert!(
1268 discover_workspaces(dir.path()).is_empty(),
1269 "the manifest declares no workspace"
1270 );
1271
1272 let patterns = vec!["apps/deep/*".to_string()];
1273 let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1274 assert_eq!(workspaces.len(), 1);
1275 assert_eq!(workspaces[0].name, "@test/one");
1276 }
1277
1278 #[test]
1279 fn configured_patterns_and_manifest_patterns_add_up_without_duplicates() {
1280 let dir = tempfile::tempdir().expect("create temp dir");
1281 for name in ["a", "b"] {
1282 let pkg = dir.path().join("packages").join(name);
1283 std::fs::create_dir_all(&pkg).unwrap();
1284 std::fs::write(
1285 pkg.join("package.json"),
1286 format!(r#"{{"name": "@test/{name}"}}"#),
1287 )
1288 .unwrap();
1289 }
1290 std::fs::write(
1291 dir.path().join("package.json"),
1292 r#"{"workspaces": ["packages/a"]}"#,
1293 )
1294 .unwrap();
1295
1296 let patterns = vec!["packages/*".to_string()];
1297 let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1298 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1299 names.sort_unstable();
1300 assert_eq!(names, vec!["@test/a", "@test/b"]);
1301 }
1302
1303 #[test]
1304 fn configured_patterns_keep_the_shallow_package_fallback() {
1305 let dir = tempfile::tempdir().expect("create temp dir");
1306 let shallow = dir.path().join("packages").join("a");
1307 let deep = dir.path().join("apps").join("deep").join("one");
1308 std::fs::create_dir_all(&shallow).unwrap();
1309 std::fs::create_dir_all(&deep).unwrap();
1310 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1311 std::fs::write(shallow.join("package.json"), r#"{"name": "@test/a"}"#).unwrap();
1312 std::fs::write(deep.join("package.json"), r#"{"name": "@test/one"}"#).unwrap();
1313
1314 let patterns = vec!["apps/deep/*".to_string()];
1315 let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1316 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1317 names.sort_unstable();
1318 assert_eq!(
1319 names,
1320 vec!["@test/a", "@test/one"],
1321 "a package found without any manifest declaration stays found"
1322 );
1323 }
1324
1325 #[test]
1326 fn discover_workspaces_empty_project() {
1327 let dir = tempfile::tempdir().expect("create temp dir");
1328 let workspaces = discover_workspaces(dir.path());
1329 assert!(workspaces.is_empty());
1330 }
1331
1332 #[test]
1333 fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1334 let dir = tempfile::tempdir().expect("create temp dir");
1335 let benchmarks = dir.path().join("benchmarks");
1336 let vscode = dir.path().join("editors").join("vscode");
1337 let deep = dir.path().join("tests").join("fixtures").join("demo");
1338 std::fs::create_dir_all(&benchmarks).unwrap();
1339 std::fs::create_dir_all(&vscode).unwrap();
1340 std::fs::create_dir_all(&deep).unwrap();
1341
1342 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1343 std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1344 std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1345
1346 let workspaces = discover_workspaces(dir.path());
1347 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1348
1349 assert!(
1350 names.contains(&"benchmarks"),
1351 "top-level nested package should be discovered: {workspaces:?}"
1352 );
1353 assert!(
1354 names.contains(&"fallow-vscode"),
1355 "second-level nested package should be discovered: {workspaces:?}"
1356 );
1357 assert!(
1358 !names.contains(&"deep-fixture"),
1359 "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1360 );
1361 }
1362
1363 #[test]
1364 fn discover_workspaces_with_negated_patterns() {
1365 let dir = tempfile::tempdir().expect("create temp dir");
1366 let pkg_a = dir.path().join("packages").join("a");
1367 let pkg_test = dir.path().join("packages").join("test-utils");
1368 std::fs::create_dir_all(&pkg_a).unwrap();
1369 std::fs::create_dir_all(&pkg_test).unwrap();
1370
1371 std::fs::write(
1372 dir.path().join("package.json"),
1373 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1374 )
1375 .unwrap();
1376 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1377 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1378
1379 let workspaces = discover_workspaces(dir.path());
1380 assert_eq!(workspaces.len(), 1);
1381 assert_eq!(workspaces[0].name, "a");
1382 }
1383
1384 #[test]
1385 fn discover_workspaces_skips_root_as_workspace() {
1386 let dir = tempfile::tempdir().expect("create temp dir");
1387 std::fs::write(
1388 dir.path().join("pnpm-workspace.yaml"),
1389 "packages:\n - '.'\n",
1390 )
1391 .unwrap();
1392 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1393
1394 let workspaces = discover_workspaces(dir.path());
1395 assert!(
1396 workspaces.is_empty(),
1397 "root directory should not be added as workspace"
1398 );
1399 }
1400
1401 #[test]
1402 fn discover_workspaces_name_fallback_to_dir_name() {
1403 let dir = tempfile::tempdir().expect("create temp dir");
1404 let pkg_a = dir.path().join("packages").join("my-app");
1405 std::fs::create_dir_all(&pkg_a).unwrap();
1406
1407 std::fs::write(
1408 dir.path().join("package.json"),
1409 r#"{"workspaces": ["packages/*"]}"#,
1410 )
1411 .unwrap();
1412 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1413
1414 let workspaces = discover_workspaces(dir.path());
1415 assert_eq!(workspaces.len(), 1);
1416 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1417 }
1418
1419 #[test]
1420 fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1421 let dir = tempfile::tempdir().expect("create temp dir");
1422 let pkg_a = dir.path().join("packages").join("a");
1423 let benchmarks = dir.path().join("benchmarks");
1424 std::fs::create_dir_all(&pkg_a).unwrap();
1425 std::fs::create_dir_all(&benchmarks).unwrap();
1426
1427 std::fs::write(
1428 dir.path().join("package.json"),
1429 r#"{"workspaces": ["packages/*"]}"#,
1430 )
1431 .unwrap();
1432 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1433 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1434
1435 let workspaces = discover_workspaces(dir.path());
1436 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1437
1438 assert_eq!(workspaces.len(), 1);
1439 assert!(names.contains(&"a"));
1440 assert!(
1441 !names.contains(&"benchmarks"),
1442 "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1443 );
1444 }
1445
1446 #[test]
1447 fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1448 let dir = tempfile::tempdir().expect("create temp dir");
1456 let theme = dir
1457 .path()
1458 .join("packages")
1459 .join("themes")
1460 .join("metrists-theme-next");
1461 std::fs::create_dir_all(&theme).unwrap();
1462 std::fs::write(
1463 dir.path().join("package.json"),
1464 r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1465 )
1466 .unwrap();
1467 std::fs::write(
1469 theme.join("package.json"),
1470 r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1471 )
1472 .unwrap();
1473
1474 let workspaces = discover_workspaces(dir.path());
1475 assert!(
1476 workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1477 "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1478 );
1479 }
1480
1481 fn write_package(dir: &Path, manifest: &str) {
1482 std::fs::create_dir_all(dir).unwrap();
1483 std::fs::write(dir.join("package.json"), manifest).unwrap();
1484 }
1485
1486 #[test]
1487 fn discover_workspaces_follows_deep_link_dependency_targets() {
1488 let dir = tempfile::tempdir().expect("create temp dir");
1492 let root = dir.path();
1493 std::fs::write(
1494 root.join("package.json"),
1495 r#"{
1496 "name": "kibana",
1497 "dependencies": {
1498 "@kbn/foo": "link:src/platform/packages/shared/kbn-foo",
1499 "@kbn/app": "link:./x-pack/solutions/search/plugins/app"
1500 },
1501 "devDependencies": {
1502 "@kbn/test": "file:src/platform/packages/private/kbn-test"
1503 }
1504 }"#,
1505 )
1506 .unwrap();
1507 write_package(
1508 &root.join("src/platform/packages/shared/kbn-foo"),
1509 r#"{"name": "@kbn/foo"}"#,
1510 );
1511 write_package(
1512 &root.join("x-pack/solutions/search/plugins/app"),
1513 r#"{"name": "@kbn/app", "dependencies": {"@kbn/foo": "link:../../../../../src/platform/packages/shared/kbn-foo"}}"#,
1514 );
1515 write_package(
1516 &root.join("src/platform/packages/private/kbn-test"),
1517 r#"{"name": "@kbn/test"}"#,
1518 );
1519
1520 let workspaces = discover_workspaces(root);
1521 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1522 names.sort_unstable();
1523 assert_eq!(names, ["@kbn/app", "@kbn/foo", "@kbn/test"]);
1524 let foo = workspaces.iter().find(|ws| ws.name == "@kbn/foo").unwrap();
1525 assert!(foo.is_internal_dependency, "{workspaces:?}");
1526 }
1527
1528 #[test]
1529 fn discover_workspaces_skips_link_targets_outside_root_or_without_manifest() {
1530 let dir = tempfile::tempdir().expect("create temp dir");
1531 let root = dir.path().join("repo");
1532 std::fs::create_dir_all(&root).unwrap();
1533 write_package(&dir.path().join("sibling"), r#"{"name": "sibling"}"#);
1534 std::fs::create_dir_all(root.join("packages/no-manifest")).unwrap();
1535 std::fs::write(root.join("vendor.tgz"), "").unwrap();
1536 std::fs::write(
1537 root.join("package.json"),
1538 r#"{
1539 "dependencies": {
1540 "sibling": "link:../sibling",
1541 "absolute": "file:/definitely/not/here",
1542 "missing": "link:packages/missing",
1543 "no-manifest": "link:packages/no-manifest",
1544 "tarball": "file:./vendor.tgz",
1545 "self": "link:.",
1546 "react": "^18.0.0"
1547 }
1548 }"#,
1549 )
1550 .unwrap();
1551
1552 let workspaces = discover_workspaces(&root);
1553 assert!(workspaces.is_empty(), "{workspaces:?}");
1554 }
1555
1556 #[test]
1557 fn discover_workspaces_skips_link_targets_that_source_discovery_skips() {
1558 let dir = tempfile::tempdir().expect("create temp dir");
1559 let root = dir.path();
1560 std::fs::write(
1561 root.join("package.json"),
1562 r#"{
1563 "dependencies": {
1564 "ylib": "file:.yalc/ylib",
1565 "nm": "link:node_modules/nm",
1566 "blib": "link:build/blib",
1567 "dlib": "link:dist/dlib",
1568 "clib": "link:packages/coverage/clib",
1569 "vlib": "link:vendor/deep/vlib",
1570 "kept": "link:libs/deep/kept"
1571 }
1572 }"#,
1573 )
1574 .unwrap();
1575 for (path, name) in [
1576 (".yalc/ylib", "ylib"),
1577 ("node_modules/nm", "nm"),
1578 ("build/blib", "blib"),
1579 ("dist/dlib", "dlib"),
1580 ("packages/coverage/clib", "clib"),
1581 ("vendor/deep/vlib", "vlib"),
1582 ("libs/deep/kept", "kept"),
1583 ] {
1584 write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1585 }
1586 let mut builder = globset::GlobSetBuilder::new();
1587 builder.add(globset::Glob::new("vendor/**").unwrap());
1588 let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
1589
1590 let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1591 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1592 assert_eq!(names, ["kept"]);
1593 }
1594
1595 fn write_gitignored_link_fixture(root: &Path) {
1596 std::fs::write(
1597 root.join("package.json"),
1598 r#"{
1599 "dependencies": {
1600 "vlib": "file:vendor/deep/vlib",
1601 "plib": "link:libs/private/plib",
1602 "kept": "link:libs/deep/kept"
1603 }
1604 }"#,
1605 )
1606 .unwrap();
1607 for (path, name) in [
1608 ("vendor/deep/vlib", "vlib"),
1609 ("libs/private/plib", "plib"),
1610 ("libs/deep/kept", "kept"),
1611 ] {
1612 write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1613 }
1614 std::fs::write(root.join(".gitignore"), "vendor/\n").unwrap();
1615 std::fs::write(root.join("libs/.gitignore"), "private/\n").unwrap();
1616 }
1617
1618 fn link_workspace_names(root: &Path) -> Vec<String> {
1619 let ignore = crate::IgnorePatternSet::default();
1620 let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1621 let mut names: Vec<String> = workspaces.into_iter().map(|ws| ws.name).collect();
1622 names.sort_unstable();
1623 names
1624 }
1625
1626 #[test]
1627 fn discover_workspaces_skips_gitignored_link_targets() {
1628 let dir = tempfile::tempdir().expect("create temp dir");
1629 let root = dir.path();
1630 std::fs::create_dir_all(root.join(".git")).unwrap();
1631 write_gitignored_link_fixture(root);
1632
1633 assert_eq!(link_workspace_names(root), ["kept"]);
1634 }
1635
1636 #[test]
1637 fn discover_workspaces_keeps_link_targets_when_gitignore_does_not_apply() {
1638 let dir = tempfile::tempdir().expect("create temp dir");
1640 let root = dir.path();
1641 write_gitignored_link_fixture(root);
1642
1643 assert_eq!(link_workspace_names(root), ["kept", "plib", "vlib"]);
1644 }
1645
1646 #[test]
1647 fn discover_workspaces_dedupes_link_targets_with_declared_workspaces() {
1648 let dir = tempfile::tempdir().expect("create temp dir");
1649 let root = dir.path();
1650 std::fs::write(
1651 root.join("package.json"),
1652 r#"{
1653 "workspaces": ["packages/*"],
1654 "dependencies": {
1655 "a": "link:packages/a",
1656 "tool": "link:tools/deep/tool"
1657 }
1658 }"#,
1659 )
1660 .unwrap();
1661 write_package(&root.join("packages/a"), r#"{"name": "a"}"#);
1662 write_package(&root.join("tools/deep/tool"), r#"{"name": "tool"}"#);
1663
1664 let workspaces = discover_workspaces(root);
1665 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1666 names.sort_unstable();
1667 assert_eq!(names, ["a", "tool"]);
1668 assert!(
1669 find_undeclared_workspaces(root, &workspaces).is_empty(),
1670 "a linked package is declared, not undeclared"
1671 );
1672 }
1673
1674 #[test]
1675 fn undeclared_workspace_detected() {
1676 let dir = tempfile::tempdir().expect("create temp dir");
1677 let pkg_a = dir.path().join("packages").join("a");
1678 let pkg_b = dir.path().join("packages").join("b");
1679 std::fs::create_dir_all(&pkg_a).unwrap();
1680 std::fs::create_dir_all(&pkg_b).unwrap();
1681
1682 std::fs::write(
1683 dir.path().join("package.json"),
1684 r#"{"workspaces": ["packages/a"]}"#,
1685 )
1686 .unwrap();
1687 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1688 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1689
1690 let declared = discover_workspaces(dir.path());
1691 assert_eq!(declared.len(), 1);
1692
1693 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1694 assert_eq!(undeclared.len(), 1);
1695 assert!(
1696 undeclared[0]
1697 .path
1698 .to_string_lossy()
1699 .replace('\\', "/")
1700 .contains("packages/b"),
1701 "should detect packages/b as undeclared: {:?}",
1702 undeclared[0].path
1703 );
1704 }
1705
1706 #[test]
1707 fn no_undeclared_when_all_covered() {
1708 let dir = tempfile::tempdir().expect("create temp dir");
1709 let pkg_a = dir.path().join("packages").join("a");
1710 std::fs::create_dir_all(&pkg_a).unwrap();
1711
1712 std::fs::write(
1713 dir.path().join("package.json"),
1714 r#"{"workspaces": ["packages/*"]}"#,
1715 )
1716 .unwrap();
1717 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1718
1719 let declared = discover_workspaces(dir.path());
1720 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1721 assert!(undeclared.is_empty());
1722 }
1723
1724 #[test]
1725 fn no_undeclared_when_no_workspace_patterns() {
1726 let dir = tempfile::tempdir().expect("create temp dir");
1727 let sub = dir.path().join("lib");
1728 std::fs::create_dir_all(&sub).unwrap();
1729
1730 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1731 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1732
1733 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1734 assert!(
1735 undeclared.is_empty(),
1736 "should skip check when no workspace patterns exist"
1737 );
1738 }
1739
1740 #[test]
1741 fn undeclared_skips_node_modules_and_hidden_dirs() {
1742 let dir = tempfile::tempdir().expect("create temp dir");
1743 let nm = dir.path().join("node_modules").join("some-pkg");
1744 let hidden = dir.path().join(".hidden");
1745 std::fs::create_dir_all(&nm).unwrap();
1746 std::fs::create_dir_all(&hidden).unwrap();
1747
1748 std::fs::write(
1749 dir.path().join("package.json"),
1750 r#"{"workspaces": ["packages/*"]}"#,
1751 )
1752 .unwrap();
1753 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1754 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1755
1756 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1757 assert!(
1758 undeclared.is_empty(),
1759 "should not flag node_modules or hidden directories"
1760 );
1761 }
1762
1763 fn build_globset(patterns: &[&str]) -> crate::IgnorePatternSet {
1764 let mut builder = globset::GlobSetBuilder::new();
1765 for pattern in patterns {
1766 builder.add(globset::Glob::new(pattern).expect("valid glob"));
1767 }
1768 crate::IgnorePatternSet::from(builder.build().expect("build globset"))
1769 }
1770
1771 #[test]
1772 fn undeclared_skips_dirs_matching_ignore_patterns() {
1773 let dir = tempfile::tempdir().expect("create temp dir");
1774 let pkg_a = dir.path().join("packages").join("a");
1775 let vitest_ref = dir.path().join("references").join("vitest");
1776 let tanstack_ref = dir.path().join("references").join("tanstack-router");
1777 std::fs::create_dir_all(&pkg_a).unwrap();
1778 std::fs::create_dir_all(&vitest_ref).unwrap();
1779 std::fs::create_dir_all(&tanstack_ref).unwrap();
1780
1781 std::fs::write(
1782 dir.path().join("package.json"),
1783 r#"{"workspaces": ["packages/*"]}"#,
1784 )
1785 .unwrap();
1786 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1787 std::fs::write(
1788 vitest_ref.join("package.json"),
1789 r#"{"name": "vitest-reference"}"#,
1790 )
1791 .unwrap();
1792 std::fs::write(
1793 tanstack_ref.join("package.json"),
1794 r#"{"name": "tanstack-reference"}"#,
1795 )
1796 .unwrap();
1797
1798 let declared = discover_workspaces(dir.path());
1799 let ignore = build_globset(&["references/*"]);
1800 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1801 assert!(
1802 undeclared.is_empty(),
1803 "references/* should be ignored: {undeclared:?}"
1804 );
1805 }
1806
1807 #[test]
1808 fn undeclared_still_reported_when_ignore_does_not_match() {
1809 let dir = tempfile::tempdir().expect("create temp dir");
1810 let pkg_b = dir.path().join("packages").join("b");
1811 std::fs::create_dir_all(&pkg_b).unwrap();
1812
1813 std::fs::write(
1814 dir.path().join("package.json"),
1815 r#"{"workspaces": ["packages/a"]}"#,
1816 )
1817 .unwrap();
1818 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1819
1820 let declared = discover_workspaces(dir.path());
1821 let ignore = build_globset(&["references/*"]);
1822 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1823 assert_eq!(
1824 undeclared.len(),
1825 1,
1826 "non-matching ignore patterns should not silence other undeclared dirs"
1827 );
1828 }
1829
1830 #[test]
1831 fn undeclared_skips_dirs_matching_package_json_glob() {
1832 let dir = tempfile::tempdir().expect("create temp dir");
1833 let pkg_a = dir.path().join("packages").join("a");
1834 let vitest_ref = dir.path().join("references").join("vitest");
1835 std::fs::create_dir_all(&pkg_a).unwrap();
1836 std::fs::create_dir_all(&vitest_ref).unwrap();
1837
1838 std::fs::write(
1839 dir.path().join("package.json"),
1840 r#"{"workspaces": ["packages/*"]}"#,
1841 )
1842 .unwrap();
1843 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1844 std::fs::write(
1845 vitest_ref.join("package.json"),
1846 r#"{"name": "vitest-reference"}"#,
1847 )
1848 .unwrap();
1849
1850 let declared = discover_workspaces(dir.path());
1851 let ignore = build_globset(&["references/*/package.json"]);
1852 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1853 assert!(
1854 undeclared.is_empty(),
1855 "package.json-suffixed glob should silence the warning: {undeclared:?}"
1856 );
1857 }
1858
1859 #[test]
1860 fn undeclared_skips_dirs_matching_doublestar_ignore() {
1861 let dir = tempfile::tempdir().expect("create temp dir");
1862 let pkg_a = dir.path().join("packages").join("a");
1863 let nested_ref = dir.path().join("references").join("vitest");
1864 std::fs::create_dir_all(&pkg_a).unwrap();
1865 std::fs::create_dir_all(&nested_ref).unwrap();
1866
1867 std::fs::write(
1868 dir.path().join("package.json"),
1869 r#"{"workspaces": ["packages/*"]}"#,
1870 )
1871 .unwrap();
1872 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1873 std::fs::write(
1874 nested_ref.join("package.json"),
1875 r#"{"name": "vitest-reference"}"#,
1876 )
1877 .unwrap();
1878
1879 let declared = discover_workspaces(dir.path());
1880 let ignore = build_globset(&["**/references/**"]);
1881 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1882 assert!(
1883 undeclared.is_empty(),
1884 "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1885 );
1886 }
1887
1888 #[test]
1889 fn malformed_workspace_package_json_emits_diagnostic() {
1890 let dir = tempfile::tempdir().expect("create temp dir");
1891 let pkg_a = dir.path().join("packages").join("a");
1892 let pkg_bad = dir.path().join("packages").join("bad");
1893 std::fs::create_dir_all(&pkg_a).unwrap();
1894 std::fs::create_dir_all(&pkg_bad).unwrap();
1895 std::fs::write(
1896 dir.path().join("package.json"),
1897 r#"{"workspaces": ["packages/*"]}"#,
1898 )
1899 .unwrap();
1900 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1901 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1902
1903 let (result, captured) = capture_workspace_warnings(|| {
1904 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1905 });
1906 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1907
1908 assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1909 assert_eq!(workspaces[0].name, "a");
1910 assert_eq!(diagnostics.len(), 1);
1911 assert!(matches!(
1912 diagnostics[0].kind,
1913 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1914 ));
1915 assert!(
1916 captured
1917 .iter()
1918 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1919 );
1920 }
1921
1922 #[test]
1923 fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1924 let dir = tempfile::tempdir().expect("create temp dir");
1925 let pkg_good = dir.path().join("packages/good");
1926 let pkg_bad = dir.path().join("packages/bad");
1927 std::fs::create_dir_all(&pkg_good).unwrap();
1928 std::fs::create_dir_all(&pkg_bad).unwrap();
1929 std::fs::write(
1930 dir.path().join("package.json"),
1931 r#"{"workspaces": ["packages/*"]}"#,
1932 )
1933 .unwrap();
1934 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1935 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad"}"#).unwrap();
1936 std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1937
1938 let result =
1939 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
1940 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1941
1942 assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1943 assert_eq!(workspaces[0].name, "good");
1944 assert_eq!(diagnostics.len(), 1);
1945 assert!(matches!(
1946 diagnostics[0].kind,
1947 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1948 ));
1949 assert!(diagnostics[0].message.contains("deno.jsonc"));
1950 }
1951
1952 #[test]
1962 fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1963 let dir = tempfile::tempdir().expect("create temp dir");
1964 let pkg_good = dir.path().join("packages").join("good");
1965 let pkg_bad = dir.path().join("packages").join("bad");
1966 std::fs::create_dir_all(&pkg_good).unwrap();
1967 std::fs::create_dir_all(&pkg_bad).unwrap();
1968 std::fs::write(
1969 dir.path().join("package.json"),
1970 r#"{"workspaces": ["packages/*"]}"#,
1971 )
1972 .unwrap();
1973 std::fs::write(
1974 dir.path().join("tsconfig.json"),
1975 r#"{"references": [{"path": "./packages/bad"}]}"#,
1976 )
1977 .unwrap();
1978 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1979 std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1980
1981 let (result, _) = capture_workspace_warnings(|| {
1982 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1983 });
1984 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1985
1986 assert!(
1987 workspaces.iter().any(|w| w.name == "good"),
1988 "valid sibling should still discover: {workspaces:?}"
1989 );
1990 assert!(
1991 workspaces.iter().any(|w| w.name == "bad"),
1992 "tsconfig reference falls back to the directory name on manifest error"
1993 );
1994 let malformed: Vec<_> = diagnostics
1995 .iter()
1996 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1997 .collect();
1998 assert_eq!(
1999 malformed.len(),
2000 1,
2001 "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
2002 );
2003 assert!(
2004 malformed[0].path.ends_with("bad"),
2005 "the diagnostic points at the malformed member"
2006 );
2007 }
2008
2009 #[test]
2020 fn manifest_cache_replays_the_same_err_on_every_visit() {
2021 let dir = tempfile::tempdir().expect("create temp dir");
2022 let member = dir.path().join("packages").join("bad");
2023 std::fs::create_dir_all(&member).unwrap();
2024 std::fs::write(member.join("package.json"), r"{,}").unwrap();
2025
2026 let mut cache = ManifestCache::default();
2027 let first = load_member_package_manifest_cached(&member, &mut cache)
2028 .expect_err("a malformed member manifest is an Err");
2029 assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
2030
2031 std::fs::remove_file(member.join("package.json")).unwrap();
2032 let replayed = load_member_package_manifest_cached(&member, &mut cache)
2033 .expect_err("the memoized Err replays instead of re-reading the directory");
2034 assert_eq!(
2035 replayed, first,
2036 "the replayed outcome is byte-identical to the first, so both \
2037 workspace sources diagnose the same member the same way"
2038 );
2039 }
2040
2041 #[test]
2042 fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
2043 let dir = tempfile::tempdir().expect("create temp dir");
2044 for name in ["a", "b", "c"] {
2045 let pkg = dir.path().join("packages").join(name);
2046 std::fs::create_dir_all(&pkg).unwrap();
2047 std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
2048 }
2049 std::fs::write(
2050 dir.path().join("package.json"),
2051 r#"{"workspaces": ["packages/*"]}"#,
2052 )
2053 .unwrap();
2054
2055 let (result, _) = capture_workspace_warnings(|| {
2056 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2057 });
2058 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2059
2060 assert!(workspaces.is_empty(), "all three malformed; nothing valid");
2061 assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
2062 assert!(
2063 diagnostics
2064 .iter()
2065 .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2066 "every diagnostic should be malformed-package-json"
2067 );
2068 }
2069
2070 #[test]
2071 fn malformed_root_package_json_returns_load_error() {
2072 let dir = tempfile::tempdir().expect("create temp dir");
2073 std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
2074
2075 let result =
2076 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2077
2078 match result {
2079 Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
2080 assert!(path.ends_with("package.json"));
2081 assert!(!error.is_empty(), "underlying parse error is preserved");
2082 }
2083 other => panic!("expected MalformedRootPackageJson, got {other:?}"),
2084 }
2085 }
2086
2087 #[test]
2088 fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
2089 let dir = tempfile::tempdir().expect("create temp dir");
2090 let pkg_a = dir.path().join("packages").join("a");
2091 let cache_dir = dir.path().join("packages").join(".cache");
2092 let scratch_dir = dir.path().join("packages").join("scratch");
2093 std::fs::create_dir_all(&pkg_a).unwrap();
2094 std::fs::create_dir_all(&cache_dir).unwrap();
2095 std::fs::create_dir_all(&scratch_dir).unwrap();
2096 std::fs::write(
2097 dir.path().join("package.json"),
2098 r#"{"workspaces": ["packages/*"]}"#,
2099 )
2100 .unwrap();
2101 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2102
2103 let result =
2104 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2105 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2106
2107 assert_eq!(workspaces.len(), 1);
2108 let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
2109 assert!(
2110 kinds.contains(&"glob-matched-no-package-json"),
2111 "scratch should diagnose: {kinds:?}"
2112 );
2113 assert!(
2114 !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
2115 ".cache must be skip-listed: {diagnostics:?}"
2116 );
2117 }
2118
2119 #[test]
2120 fn glob_match_without_package_json_honors_ignore_patterns() {
2121 let dir = tempfile::tempdir().expect("create temp dir");
2122 let pkg_a = dir.path().join("packages").join("a");
2123 let legacy_dir = dir.path().join("packages").join("legacy");
2124 std::fs::create_dir_all(&pkg_a).unwrap();
2125 std::fs::create_dir_all(&legacy_dir).unwrap();
2126 std::fs::write(
2127 dir.path().join("package.json"),
2128 r#"{"workspaces": ["packages/*"]}"#,
2129 )
2130 .unwrap();
2131 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2132
2133 let mut builder = globset::GlobSetBuilder::new();
2134 builder.add(globset::Glob::new("packages/legacy").unwrap());
2135 let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
2136
2137 let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
2138 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2139
2140 assert_eq!(workspaces.len(), 1);
2141 assert!(
2142 diagnostics.is_empty(),
2143 "user-excluded path must not produce a diagnostic: {diagnostics:?}"
2144 );
2145 }
2146
2147 #[test]
2148 fn malformed_tsconfig_emits_diagnostic() {
2149 let dir = tempfile::tempdir().expect("create temp dir");
2150 std::fs::write(
2151 dir.path().join("package.json"),
2152 r#"{"workspaces": ["packages/*"]}"#,
2153 )
2154 .unwrap();
2155 std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
2156
2157 let result =
2158 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2159 let (_, diagnostics) = result.expect("root package.json is valid");
2160
2161 assert!(
2162 diagnostics
2163 .iter()
2164 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2165 "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
2166 );
2167 }
2168
2169 #[test]
2170 fn tsconfig_missing_reference_dir_emits_diagnostic() {
2171 let dir = tempfile::tempdir().expect("create temp dir");
2172 std::fs::write(
2173 dir.path().join("tsconfig.json"),
2174 r#"{"references": [{"path": "./packages/missing"}]}"#,
2175 )
2176 .unwrap();
2177
2178 let result =
2179 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2180 let (_, diagnostics) = result.expect("no package.json at root is OK");
2181
2182 assert!(
2183 diagnostics
2184 .iter()
2185 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
2186 "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
2187 );
2188 }
2189
2190 #[test]
2191 fn missing_tsconfig_is_silent() {
2192 let dir = tempfile::tempdir().expect("create temp dir");
2193
2194 let result =
2195 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2196 let (_, diagnostics) = result.expect("no root package.json is OK");
2197
2198 assert!(
2199 !diagnostics
2200 .iter()
2201 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2202 "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
2203 );
2204 }
2205
2206 #[test]
2207 fn shallow_scan_malformed_package_json_stays_silent() {
2208 let dir = tempfile::tempdir().expect("create temp dir");
2209 let scratch = dir.path().join("scratch");
2210 std::fs::create_dir_all(&scratch).unwrap();
2211 std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
2212
2213 let result =
2214 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2215 let (_, diagnostics) = result.expect("no root package.json is OK");
2216
2217 assert!(
2218 !diagnostics
2219 .iter()
2220 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2221 "shallow-scan malformed must stay silent: {diagnostics:?}"
2222 );
2223 }
2224
2225 #[test]
2226 fn mixed_valid_and_malformed_workspaces_partial_recovery() {
2227 let dir = tempfile::tempdir().expect("create temp dir");
2228 let pkg_good = dir.path().join("packages").join("good");
2229 let pkg_bad = dir.path().join("packages").join("bad");
2230 std::fs::create_dir_all(&pkg_good).unwrap();
2231 std::fs::create_dir_all(&pkg_bad).unwrap();
2232 std::fs::write(
2233 dir.path().join("package.json"),
2234 r#"{"workspaces": ["packages/*"]}"#,
2235 )
2236 .unwrap();
2237 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2238 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2239
2240 let result =
2241 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2242 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2243
2244 assert_eq!(workspaces.len(), 1);
2245 assert_eq!(workspaces[0].name, "good");
2246 assert_eq!(diagnostics.len(), 1);
2247 assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
2248 }
2249
2250 #[test]
2251 fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
2252 let dir = tempfile::tempdir().expect("create temp dir");
2253 std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
2254
2255 let workspaces = discover_workspaces(dir.path());
2256 assert!(
2257 workspaces.is_empty(),
2258 "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
2259 );
2260 }
2261
2262 #[test]
2263 fn discovers_deno_workspace_members_without_package_json() {
2264 let dir = tempfile::tempdir().expect("create temp dir");
2265 let core = dir.path().join("packages").join("core");
2266 let app = dir.path().join("apps").join("desktop");
2267 std::fs::create_dir_all(&core).unwrap();
2268 std::fs::create_dir_all(&app).unwrap();
2269
2270 std::fs::write(
2271 dir.path().join("deno.json"),
2272 r#"{
2273 "workspace": ["./apps/*", "./packages/*"],
2274 "imports": { "@std/assert": "jsr:@std/assert@1" }
2275 }"#,
2276 )
2277 .unwrap();
2278 std::fs::write(
2279 core.join("deno.json"),
2280 r#"{
2281 "name": "@fallow/core",
2282 "exports": { ".": "./mod.ts", "./result": "./result.ts" }
2283 }"#,
2284 )
2285 .unwrap();
2286 std::fs::write(
2287 app.join("deno.json"),
2288 r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
2289 )
2290 .unwrap();
2291
2292 let workspaces = discover_workspaces(dir.path());
2293 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2294 names.sort_unstable();
2295 assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
2296 assert!(
2297 workspaces
2298 .iter()
2299 .all(|w| !w.root.join("package.json").exists())
2300 );
2301 assert!(
2302 workspaces.iter().all(|w| !w.is_internal_dependency),
2303 "Deno packages need dependency evidence before inventory marks them internal"
2304 );
2305 }
2306
2307 #[test]
2308 fn malformed_root_deno_config_returns_load_error() {
2309 let dir = tempfile::tempdir().expect("create temp dir");
2310 std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
2311
2312 let result =
2313 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2314
2315 match result {
2316 Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
2317 assert!(path.ends_with("deno.jsonc"));
2318 assert!(!error.is_empty(), "underlying parse error is preserved");
2319 }
2320 other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
2321 }
2322 }
2323
2324 #[test]
2325 fn deno_and_npm_workspace_patterns_are_additive() {
2326 let dir = tempfile::tempdir().expect("create temp dir");
2327 let a = dir.path().join("packages").join("a");
2328 let b = dir.path().join("packages").join("b");
2329 std::fs::create_dir_all(&a).unwrap();
2330 std::fs::create_dir_all(&b).unwrap();
2331
2332 std::fs::write(
2333 dir.path().join("package.json"),
2334 r#"{"workspaces": ["packages/a"]}"#,
2335 )
2336 .unwrap();
2337 std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2338 std::fs::write(
2339 dir.path().join("deno.json"),
2340 r#"{"workspace": ["./packages/b"]}"#,
2341 )
2342 .unwrap();
2343 std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
2344
2345 let workspaces = discover_workspaces(dir.path());
2346 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2347 names.sort_unstable();
2348 assert_eq!(names, vec!["a", "b"]);
2349 }
2350
2351 #[test]
2357 fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
2358 let dir = tempfile::tempdir().expect("create temp dir");
2359 write_two_manifest_glob_project(dir.path());
2360
2361 let (_, diagnostics) =
2362 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2363 .expect("root package.json is valid");
2364
2365 let mut reported: Vec<(String, String)> = diagnostics
2366 .iter()
2367 .filter_map(|diagnostic| match &diagnostic.kind {
2368 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
2369 pattern.clone(),
2370 diagnostic
2371 .path
2372 .strip_prefix(dir.path())
2373 .unwrap_or(&diagnostic.path)
2374 .display()
2375 .to_string()
2376 .replace('\\', "/"),
2377 )),
2378 _ => None,
2379 })
2380 .collect();
2381 reported.sort();
2382
2383 assert_eq!(
2384 reported,
2385 vec![
2386 ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
2387 ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
2388 ],
2389 "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
2390 );
2391 }
2392
2393 #[test]
2397 fn overlapping_globs_still_report_one_directory_once_per_pattern() {
2398 let dir = tempfile::tempdir().expect("create temp dir");
2399 std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
2400 std::fs::write(
2401 dir.path().join("package.json"),
2402 r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
2403 )
2404 .unwrap();
2405 std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
2406
2407 let (_, diagnostics) =
2408 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2409 .expect("root package.json is valid");
2410
2411 let patterns: Vec<&str> = diagnostics
2412 .iter()
2413 .filter_map(|diagnostic| match &diagnostic.kind {
2414 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
2415 Some(pattern.as_str())
2416 }
2417 _ => None,
2418 })
2419 .collect();
2420
2421 assert_eq!(
2422 patterns,
2423 ["pkgs/*", "pkgs/a*"],
2424 "distinct patterns are distinct diagnostics: {diagnostics:?}"
2425 );
2426 }
2427
2428 #[test]
2434 fn workspace_entries_outside_the_root_are_skipped_before_the_walk() {
2435 let sandbox = tempfile::tempdir().expect("create temp dir");
2436 let outside = sandbox.path().join("outside");
2437 std::fs::create_dir_all(outside.join("no-manifest")).unwrap();
2438 std::fs::create_dir_all(outside.join("pkg-out")).unwrap();
2439 std::fs::write(
2440 outside.join("pkg-out/package.json"),
2441 r#"{"name":"pkg-out"}"#,
2442 )
2443 .unwrap();
2444 let root = sandbox.path().join("repo");
2445 std::fs::create_dir_all(root.join("packages/pkg-in")).unwrap();
2446 std::fs::write(
2447 root.join("packages/pkg-in/package.json"),
2448 r#"{"name":"pkg-in"}"#,
2449 )
2450 .unwrap();
2451 let absolute = format!("{}/*", outside.display()).replace('\\', "/");
2452 std::fs::write(
2453 root.join("package.json"),
2454 serde_json::json!({
2455 "name": "root",
2456 "private": true,
2457 "workspaces": ["packages/*", "../outside/*", absolute],
2458 })
2459 .to_string(),
2460 )
2461 .unwrap();
2462 std::fs::write(
2463 root.join("pnpm-workspace.yaml"),
2464 "packages:\n - packages/*\n - ../outside/**\n",
2465 )
2466 .unwrap();
2467
2468 let (workspaces, diagnostics) =
2469 discover_workspaces_with_diagnostics(&root, &crate::IgnorePatternSet::empty())
2470 .expect("root package.json is valid");
2471
2472 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
2473 assert_eq!(names, ["pkg-in"], "only the in-root package is a workspace");
2474
2475 let canonical_root = dunce::canonicalize(&root).unwrap();
2476 for diagnostic in &diagnostics {
2477 let path =
2478 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
2479 assert!(
2480 path.starts_with(&canonical_root),
2481 "no diagnostic names a path outside the root: {diagnostic:?}"
2482 );
2483 assert!(
2484 !diagnostic.message.contains("no-manifest")
2485 && !diagnostic.message.contains("pkg-out"),
2486 "no message names an outside directory: {diagnostic:?}"
2487 );
2488 }
2489
2490 let mut skipped: Vec<(String, String)> = diagnostics
2491 .iter()
2492 .filter(|diagnostic| diagnostic.kind.id() == "workspace-glob-outside-root")
2493 .map(|diagnostic| {
2494 (
2495 fallow_types::path_util::display_relative(&root, &diagnostic.path),
2496 diagnostic.message.clone(),
2497 )
2498 })
2499 .collect();
2500 skipped.sort();
2501 let files: Vec<&str> = skipped.iter().map(|(file, _)| file.as_str()).collect();
2502 assert_eq!(
2503 files,
2504 ["package.json", "package.json", "pnpm-workspace.yaml"],
2505 "one diagnostic per skipped entry, anchored at its file: {diagnostics:?}"
2506 );
2507 assert!(
2508 skipped
2509 .iter()
2510 .any(|(_, message)| message.contains("'../outside/*'")),
2511 "the message names the entry: {skipped:?}"
2512 );
2513 }
2514
2515 #[test]
2519 fn deno_entries_and_in_root_dot_dot_segments_follow_the_root_boundary() {
2520 let sandbox = tempfile::tempdir().expect("create temp dir");
2521 std::fs::create_dir_all(sandbox.path().join("outside/no-manifest")).unwrap();
2522 let root = sandbox.path().join("repo");
2523 std::fs::create_dir_all(root.join("packages/pkg-in")).unwrap();
2524 std::fs::write(
2525 root.join("packages/pkg-in/deno.json"),
2526 r#"{"name":"@x/pkg-in"}"#,
2527 )
2528 .unwrap();
2529 std::fs::write(
2530 root.join("deno.json"),
2531 r#"{"workspace":["packages/../packages/*","../outside/*","!../outside/no-manifest"]}"#,
2532 )
2533 .unwrap();
2534
2535 let (workspaces, diagnostics) =
2536 discover_workspaces_with_diagnostics(&root, &crate::IgnorePatternSet::empty())
2537 .expect("root deno.json is valid");
2538
2539 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
2540 assert_eq!(
2541 names,
2542 ["@x/pkg-in"],
2543 "the in-root `..` entry still finds its package"
2544 );
2545 let [diagnostic] = diagnostics.as_slice() else {
2546 panic!("expected one diagnostic: {diagnostics:?}");
2547 };
2548 assert_eq!(diagnostic.path, root.join("deno.json"));
2549 assert_eq!(
2550 diagnostic.kind,
2551 WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
2552 pattern: "../outside/*".to_owned()
2553 }
2554 );
2555 }
2556
2557 #[test]
2560 fn configured_pattern_outside_the_root_names_the_config_setting() {
2561 let dir = tempfile::tempdir().expect("create temp dir");
2562 std::fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#).unwrap();
2563
2564 let (_, diagnostics) = discover_workspaces_with_diagnostics_and_patterns(
2565 dir.path(),
2566 &crate::IgnorePatternSet::empty(),
2567 &["../shared/*".to_owned()],
2568 )
2569 .expect("root package.json is valid");
2570
2571 let [diagnostic] = diagnostics.as_slice() else {
2572 panic!("expected one diagnostic: {diagnostics:?}");
2573 };
2574 assert_eq!(diagnostic.path, Path::new("."));
2575 assert_eq!(
2576 diagnostic.kind,
2577 WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
2578 pattern: "../shared/*".to_owned()
2579 }
2580 );
2581 assert!(
2582 diagnostic
2583 .message
2584 .contains("fallow config `workspaces.patterns`"),
2585 "{diagnostic:?}"
2586 );
2587 }
2588 mod layout_tests;
2589}