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