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 = crate::source_walk::dirs_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 normalize_link_target(root: &Path, canonical_root: &Path, canonical_dir: &Path) -> PathBuf {
757 canonical_dir
758 .strip_prefix(canonical_root)
759 .map_or_else(|_| canonical_dir.to_path_buf(), |rel| root.join(rel))
760}
761
762fn collect_shallow_package_workspaces(
769 root: &Path,
770 canonical_root: &Path,
771 manifest_cache: &mut ManifestCache,
772) -> Vec<(WorkspaceInfo, Vec<String>)> {
773 let mut workspaces = Vec::new();
774 let Ok(top_entries) = std::fs::read_dir(root) else {
775 return workspaces;
776 };
777
778 for entry in top_entries.filter_map(Result::ok) {
779 let path = entry.path();
780 if !path.is_dir() || is_skip_listed_dir(&entry.file_name().to_string_lossy()) {
781 continue;
782 }
783
784 collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces, manifest_cache);
785 collect_shallow_child_workspaces(&path, canonical_root, &mut workspaces, manifest_cache);
786 }
787
788 workspaces
789}
790
791fn collect_shallow_child_workspaces(
792 parent: &Path,
793 canonical_root: &Path,
794 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
795 manifest_cache: &mut ManifestCache,
796) {
797 let Ok(child_entries) = std::fs::read_dir(parent) else {
798 return;
799 };
800 for child in child_entries.filter_map(Result::ok) {
801 let child_path = child.path();
802 if !child_path.is_dir() || is_skip_listed_dir(&child.file_name().to_string_lossy()) {
803 continue;
804 }
805
806 collect_shallow_workspace_candidate(
807 &child_path,
808 canonical_root,
809 workspaces,
810 manifest_cache,
811 );
812 }
813}
814
815fn collect_shallow_workspace_candidate(
816 dir: &Path,
817 canonical_root: &Path,
818 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
819 manifest_cache: &mut ManifestCache,
820) {
821 let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
822 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
823 return;
824 }
825
826 let Ok(Some((name, _pkg, dep_names))) =
827 load_member_package_manifest_cached(dir, manifest_cache)
828 else {
829 return;
830 };
831
832 workspaces.push((
833 WorkspaceInfo {
834 root: dir.to_path_buf(),
835 name,
836 is_internal_dependency: false,
837 },
838 dep_names,
839 ));
840}
841
842fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
848 {
849 let mut seen = rustc_hash::FxHashSet::default();
850 workspaces.retain(|(ws, _)| {
851 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
852 seen.insert(canonical)
853 });
854 }
855
856 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
857 .iter()
858 .flat_map(|(_, deps)| deps.iter().cloned())
859 .collect();
860
861 for (ws, _) in &mut *workspaces {
862 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
863 }
864}
865
866fn dir_name(dir: &Path) -> String {
868 dir.file_name()
869 .map(|n| n.to_string_lossy().to_string())
870 .unwrap_or_default()
871}
872
873#[cfg(test)]
884pub fn write_two_manifest_glob_project(root: &Path) {
885 std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
886 std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
887 std::fs::write(
888 root.join("package.json"),
889 r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
890 )
891 .unwrap();
892 std::fs::write(
893 root.join("pnpm-workspace.yaml"),
894 "packages:\n - \"pkgs/*\"\n",
895 )
896 .unwrap();
897 std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
898 std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904
905 #[test]
906 fn public_workspace_selection_supports_exact_names_and_globs() {
907 assert!(!workspace_is_public("@scope/core", &[]));
908 assert!(workspace_is_public(
909 "@scope/core",
910 &["@scope/core".to_string()]
911 ));
912 assert!(workspace_is_public(
913 "@scope/core",
914 &["@scope/*".to_string()]
915 ));
916 assert!(!workspace_is_public(
917 "@scope/core",
918 &["[invalid".to_string(), "@other/*".to_string()]
919 ));
920 }
921
922 #[test]
923 fn discover_workspaces_from_tsconfig_references() {
924 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
925 let _ = std::fs::remove_dir_all(&temp_dir);
926 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
927 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
928
929 std::fs::write(
930 temp_dir.join("tsconfig.json"),
931 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
932 )
933 .unwrap();
934
935 std::fs::write(
936 temp_dir.join("packages/core/package.json"),
937 r#"{"name": "@project/core"}"#,
938 )
939 .unwrap();
940
941 let workspaces = discover_workspaces(&temp_dir);
942 assert_eq!(workspaces.len(), 2);
943 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
944 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
945
946 let _ = std::fs::remove_dir_all(&temp_dir);
947 }
948
949 #[test]
950 fn tsconfig_references_outside_root_rejected() {
951 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
952 let _ = std::fs::remove_dir_all(&temp_dir);
953 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
954 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
955
956 std::fs::write(
957 temp_dir.join("project/tsconfig.json"),
958 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
959 )
960 .unwrap();
961
962 let workspaces = discover_workspaces(&temp_dir.join("project"));
963 assert_eq!(
964 workspaces.len(),
965 1,
966 "reference outside project root should be rejected: {workspaces:?}"
967 );
968 assert!(
969 workspaces[0]
970 .root
971 .to_string_lossy()
972 .contains("packages/core")
973 );
974
975 let _ = std::fs::remove_dir_all(&temp_dir);
976 }
977
978 #[test]
979 fn dir_name_extracts_last_component() {
980 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
981 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
982 }
983
984 #[test]
985 fn dir_name_empty_for_root_path() {
986 assert_eq!(dir_name(Path::new("/")), "");
987 }
988
989 #[test]
990 fn workspace_config_deserialize_json() {
991 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
992 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
993 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
994 }
995
996 #[test]
997 fn workspace_config_deserialize_empty_patterns() {
998 let json = r#"{"patterns": []}"#;
999 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1000 assert!(config.patterns.is_empty());
1001 }
1002
1003 #[test]
1004 fn workspace_config_default_patterns() {
1005 let json = "{}";
1006 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1007 assert!(config.patterns.is_empty());
1008 }
1009
1010 #[test]
1011 fn workspace_info_default_not_internal() {
1012 let ws = WorkspaceInfo {
1013 root: PathBuf::from("/project/packages/a"),
1014 name: "a".to_string(),
1015 is_internal_dependency: false,
1016 };
1017 assert!(!ws.is_internal_dependency);
1018 }
1019
1020 #[test]
1021 fn mark_internal_deps_detects_cross_references() {
1022 let temp_dir = tempfile::tempdir().expect("create temp dir");
1023 let pkg_a = temp_dir.path().join("a");
1024 let pkg_b = temp_dir.path().join("b");
1025 std::fs::create_dir_all(&pkg_a).unwrap();
1026 std::fs::create_dir_all(&pkg_b).unwrap();
1027
1028 let mut workspaces = vec![
1029 (
1030 WorkspaceInfo {
1031 root: pkg_a,
1032 name: "@scope/a".to_string(),
1033 is_internal_dependency: false,
1034 },
1035 vec!["@scope/b".to_string()], ),
1037 (
1038 WorkspaceInfo {
1039 root: pkg_b,
1040 name: "@scope/b".to_string(),
1041 is_internal_dependency: false,
1042 },
1043 vec!["lodash".to_string()], ),
1045 ];
1046
1047 mark_internal_dependencies(&mut workspaces);
1048
1049 let ws_a = workspaces
1050 .iter()
1051 .find(|(ws, _)| ws.name == "@scope/a")
1052 .unwrap();
1053 assert!(
1054 !ws_a.0.is_internal_dependency,
1055 "a is not depended on by others"
1056 );
1057
1058 let ws_b = workspaces
1059 .iter()
1060 .find(|(ws, _)| ws.name == "@scope/b")
1061 .unwrap();
1062 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
1063 }
1064
1065 #[test]
1066 fn mark_internal_deps_no_cross_references() {
1067 let temp_dir = tempfile::tempdir().expect("create temp dir");
1068 let pkg_a = temp_dir.path().join("a");
1069 let pkg_b = temp_dir.path().join("b");
1070 std::fs::create_dir_all(&pkg_a).unwrap();
1071 std::fs::create_dir_all(&pkg_b).unwrap();
1072
1073 let mut workspaces = vec![
1074 (
1075 WorkspaceInfo {
1076 root: pkg_a,
1077 name: "a".to_string(),
1078 is_internal_dependency: false,
1079 },
1080 vec!["react".to_string()],
1081 ),
1082 (
1083 WorkspaceInfo {
1084 root: pkg_b,
1085 name: "b".to_string(),
1086 is_internal_dependency: false,
1087 },
1088 vec!["lodash".to_string()],
1089 ),
1090 ];
1091
1092 mark_internal_dependencies(&mut workspaces);
1093
1094 assert!(!workspaces[0].0.is_internal_dependency);
1095 assert!(!workspaces[1].0.is_internal_dependency);
1096 }
1097
1098 #[test]
1099 fn mark_internal_deps_deduplicates_by_path() {
1100 let temp_dir = tempfile::tempdir().expect("create temp dir");
1101 let pkg_a = temp_dir.path().join("a");
1102 std::fs::create_dir_all(&pkg_a).unwrap();
1103
1104 let mut workspaces = vec![
1105 (
1106 WorkspaceInfo {
1107 root: pkg_a.clone(),
1108 name: "a".to_string(),
1109 is_internal_dependency: false,
1110 },
1111 vec![],
1112 ),
1113 (
1114 WorkspaceInfo {
1115 root: pkg_a,
1116 name: "a".to_string(),
1117 is_internal_dependency: false,
1118 },
1119 vec![],
1120 ),
1121 ];
1122
1123 mark_internal_dependencies(&mut workspaces);
1124 assert_eq!(
1125 workspaces.len(),
1126 1,
1127 "duplicate paths should be deduplicated"
1128 );
1129 }
1130
1131 #[test]
1132 fn collect_patterns_from_package_json() {
1133 let dir = tempfile::tempdir().expect("create temp dir");
1134 std::fs::write(
1135 dir.path().join("package.json"),
1136 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
1137 )
1138 .unwrap();
1139
1140 let patterns =
1141 collect_workspace_patterns(dir.path(), &[]).expect("valid root package.json");
1142 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
1143 }
1144
1145 #[test]
1146 fn collect_patterns_from_pnpm_workspace() {
1147 let dir = tempfile::tempdir().expect("create temp dir");
1148 std::fs::write(
1149 dir.path().join("pnpm-workspace.yaml"),
1150 "packages:\n - 'packages/*'\n - 'libs/*'\n",
1151 )
1152 .unwrap();
1153
1154 let patterns = collect_workspace_patterns(dir.path(), &[]).expect("no root package.json");
1155 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
1156 }
1157
1158 #[test]
1159 fn collect_patterns_combines_sources() {
1160 let dir = tempfile::tempdir().expect("create temp dir");
1161 std::fs::write(
1162 dir.path().join("package.json"),
1163 r#"{"workspaces": ["packages/*"]}"#,
1164 )
1165 .unwrap();
1166 std::fs::write(
1167 dir.path().join("pnpm-workspace.yaml"),
1168 "packages:\n - 'apps/*'\n",
1169 )
1170 .unwrap();
1171
1172 let patterns =
1173 collect_workspace_patterns(dir.path(), &[]).expect("valid root package.json");
1174 assert!(patterns.contains(&"packages/*".to_string()));
1175 assert!(patterns.contains(&"apps/*".to_string()));
1176 }
1177
1178 #[test]
1179 fn collect_patterns_empty_when_no_configs() {
1180 let dir = tempfile::tempdir().expect("create temp dir");
1181 let patterns = collect_workspace_patterns(dir.path(), &[]).expect("no root package.json");
1182 assert!(patterns.is_empty());
1183 }
1184
1185 #[test]
1186 fn discover_workspaces_from_package_json() {
1187 let dir = tempfile::tempdir().expect("create temp dir");
1188 let pkg_a = dir.path().join("packages").join("a");
1189 let pkg_b = dir.path().join("packages").join("b");
1190 std::fs::create_dir_all(&pkg_a).unwrap();
1191 std::fs::create_dir_all(&pkg_b).unwrap();
1192
1193 std::fs::write(
1194 dir.path().join("package.json"),
1195 r#"{"workspaces": ["packages/*"]}"#,
1196 )
1197 .unwrap();
1198 std::fs::write(
1199 pkg_a.join("package.json"),
1200 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1201 )
1202 .unwrap();
1203 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1204
1205 let workspaces = discover_workspaces(dir.path());
1206 assert_eq!(workspaces.len(), 2);
1207
1208 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1209 assert!(!ws_a.is_internal_dependency);
1210
1211 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1212 assert!(ws_b.is_internal_dependency, "b is depended on by a");
1213 }
1214
1215 #[test]
1216 fn configured_patterns_add_workspaces_beyond_the_manifest() {
1217 let dir = tempfile::tempdir().expect("create temp dir");
1218 let app = dir.path().join("apps").join("deep").join("one");
1219 std::fs::create_dir_all(&app).unwrap();
1220 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1221 std::fs::write(app.join("package.json"), r#"{"name": "@test/one"}"#).unwrap();
1222
1223 assert!(
1224 discover_workspaces(dir.path()).is_empty(),
1225 "the manifest declares no workspace"
1226 );
1227
1228 let patterns = vec!["apps/deep/*".to_string()];
1229 let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1230 assert_eq!(workspaces.len(), 1);
1231 assert_eq!(workspaces[0].name, "@test/one");
1232 }
1233
1234 #[test]
1235 fn configured_patterns_and_manifest_patterns_add_up_without_duplicates() {
1236 let dir = tempfile::tempdir().expect("create temp dir");
1237 for name in ["a", "b"] {
1238 let pkg = dir.path().join("packages").join(name);
1239 std::fs::create_dir_all(&pkg).unwrap();
1240 std::fs::write(
1241 pkg.join("package.json"),
1242 format!(r#"{{"name": "@test/{name}"}}"#),
1243 )
1244 .unwrap();
1245 }
1246 std::fs::write(
1247 dir.path().join("package.json"),
1248 r#"{"workspaces": ["packages/a"]}"#,
1249 )
1250 .unwrap();
1251
1252 let patterns = vec!["packages/*".to_string()];
1253 let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1254 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1255 names.sort_unstable();
1256 assert_eq!(names, vec!["@test/a", "@test/b"]);
1257 }
1258
1259 #[test]
1260 fn configured_patterns_keep_the_shallow_package_fallback() {
1261 let dir = tempfile::tempdir().expect("create temp dir");
1262 let shallow = dir.path().join("packages").join("a");
1263 let deep = dir.path().join("apps").join("deep").join("one");
1264 std::fs::create_dir_all(&shallow).unwrap();
1265 std::fs::create_dir_all(&deep).unwrap();
1266 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1267 std::fs::write(shallow.join("package.json"), r#"{"name": "@test/a"}"#).unwrap();
1268 std::fs::write(deep.join("package.json"), r#"{"name": "@test/one"}"#).unwrap();
1269
1270 let patterns = vec!["apps/deep/*".to_string()];
1271 let workspaces = discover_workspaces_with_patterns(dir.path(), &patterns);
1272 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1273 names.sort_unstable();
1274 assert_eq!(
1275 names,
1276 vec!["@test/a", "@test/one"],
1277 "a package found without any manifest declaration stays found"
1278 );
1279 }
1280
1281 #[test]
1282 fn discover_workspaces_empty_project() {
1283 let dir = tempfile::tempdir().expect("create temp dir");
1284 let workspaces = discover_workspaces(dir.path());
1285 assert!(workspaces.is_empty());
1286 }
1287
1288 #[test]
1289 fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1290 let dir = tempfile::tempdir().expect("create temp dir");
1291 let benchmarks = dir.path().join("benchmarks");
1292 let vscode = dir.path().join("editors").join("vscode");
1293 let deep = dir.path().join("tests").join("fixtures").join("demo");
1294 std::fs::create_dir_all(&benchmarks).unwrap();
1295 std::fs::create_dir_all(&vscode).unwrap();
1296 std::fs::create_dir_all(&deep).unwrap();
1297
1298 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1299 std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1300 std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1301
1302 let workspaces = discover_workspaces(dir.path());
1303 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1304
1305 assert!(
1306 names.contains(&"benchmarks"),
1307 "top-level nested package should be discovered: {workspaces:?}"
1308 );
1309 assert!(
1310 names.contains(&"fallow-vscode"),
1311 "second-level nested package should be discovered: {workspaces:?}"
1312 );
1313 assert!(
1314 !names.contains(&"deep-fixture"),
1315 "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1316 );
1317 }
1318
1319 #[test]
1320 fn discover_workspaces_with_negated_patterns() {
1321 let dir = tempfile::tempdir().expect("create temp dir");
1322 let pkg_a = dir.path().join("packages").join("a");
1323 let pkg_test = dir.path().join("packages").join("test-utils");
1324 std::fs::create_dir_all(&pkg_a).unwrap();
1325 std::fs::create_dir_all(&pkg_test).unwrap();
1326
1327 std::fs::write(
1328 dir.path().join("package.json"),
1329 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1330 )
1331 .unwrap();
1332 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1333 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1334
1335 let workspaces = discover_workspaces(dir.path());
1336 assert_eq!(workspaces.len(), 1);
1337 assert_eq!(workspaces[0].name, "a");
1338 }
1339
1340 #[test]
1341 fn discover_workspaces_skips_root_as_workspace() {
1342 let dir = tempfile::tempdir().expect("create temp dir");
1343 std::fs::write(
1344 dir.path().join("pnpm-workspace.yaml"),
1345 "packages:\n - '.'\n",
1346 )
1347 .unwrap();
1348 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1349
1350 let workspaces = discover_workspaces(dir.path());
1351 assert!(
1352 workspaces.is_empty(),
1353 "root directory should not be added as workspace"
1354 );
1355 }
1356
1357 #[test]
1358 fn discover_workspaces_name_fallback_to_dir_name() {
1359 let dir = tempfile::tempdir().expect("create temp dir");
1360 let pkg_a = dir.path().join("packages").join("my-app");
1361 std::fs::create_dir_all(&pkg_a).unwrap();
1362
1363 std::fs::write(
1364 dir.path().join("package.json"),
1365 r#"{"workspaces": ["packages/*"]}"#,
1366 )
1367 .unwrap();
1368 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1369
1370 let workspaces = discover_workspaces(dir.path());
1371 assert_eq!(workspaces.len(), 1);
1372 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1373 }
1374
1375 #[test]
1376 fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1377 let dir = tempfile::tempdir().expect("create temp dir");
1378 let pkg_a = dir.path().join("packages").join("a");
1379 let benchmarks = dir.path().join("benchmarks");
1380 std::fs::create_dir_all(&pkg_a).unwrap();
1381 std::fs::create_dir_all(&benchmarks).unwrap();
1382
1383 std::fs::write(
1384 dir.path().join("package.json"),
1385 r#"{"workspaces": ["packages/*"]}"#,
1386 )
1387 .unwrap();
1388 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1389 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1390
1391 let workspaces = discover_workspaces(dir.path());
1392 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1393
1394 assert_eq!(workspaces.len(), 1);
1395 assert!(names.contains(&"a"));
1396 assert!(
1397 !names.contains(&"benchmarks"),
1398 "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1399 );
1400 }
1401
1402 #[test]
1403 fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1404 let dir = tempfile::tempdir().expect("create temp dir");
1412 let theme = dir
1413 .path()
1414 .join("packages")
1415 .join("themes")
1416 .join("metrists-theme-next");
1417 std::fs::create_dir_all(&theme).unwrap();
1418 std::fs::write(
1419 dir.path().join("package.json"),
1420 r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1421 )
1422 .unwrap();
1423 std::fs::write(
1425 theme.join("package.json"),
1426 r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1427 )
1428 .unwrap();
1429
1430 let workspaces = discover_workspaces(dir.path());
1431 assert!(
1432 workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1433 "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1434 );
1435 }
1436
1437 fn write_package(dir: &Path, manifest: &str) {
1438 std::fs::create_dir_all(dir).unwrap();
1439 std::fs::write(dir.join("package.json"), manifest).unwrap();
1440 }
1441
1442 #[test]
1443 fn discover_workspaces_follows_deep_link_dependency_targets() {
1444 let dir = tempfile::tempdir().expect("create temp dir");
1448 let root = dir.path();
1449 std::fs::write(
1450 root.join("package.json"),
1451 r#"{
1452 "name": "kibana",
1453 "dependencies": {
1454 "@kbn/foo": "link:src/platform/packages/shared/kbn-foo",
1455 "@kbn/app": "link:./x-pack/solutions/search/plugins/app"
1456 },
1457 "devDependencies": {
1458 "@kbn/test": "file:src/platform/packages/private/kbn-test"
1459 }
1460 }"#,
1461 )
1462 .unwrap();
1463 write_package(
1464 &root.join("src/platform/packages/shared/kbn-foo"),
1465 r#"{"name": "@kbn/foo"}"#,
1466 );
1467 write_package(
1468 &root.join("x-pack/solutions/search/plugins/app"),
1469 r#"{"name": "@kbn/app", "dependencies": {"@kbn/foo": "link:../../../../../src/platform/packages/shared/kbn-foo"}}"#,
1470 );
1471 write_package(
1472 &root.join("src/platform/packages/private/kbn-test"),
1473 r#"{"name": "@kbn/test"}"#,
1474 );
1475
1476 let workspaces = discover_workspaces(root);
1477 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1478 names.sort_unstable();
1479 assert_eq!(names, ["@kbn/app", "@kbn/foo", "@kbn/test"]);
1480 let foo = workspaces.iter().find(|ws| ws.name == "@kbn/foo").unwrap();
1481 assert!(foo.is_internal_dependency, "{workspaces:?}");
1482 }
1483
1484 #[test]
1485 fn discover_workspaces_skips_link_targets_outside_root_or_without_manifest() {
1486 let dir = tempfile::tempdir().expect("create temp dir");
1487 let root = dir.path().join("repo");
1488 std::fs::create_dir_all(&root).unwrap();
1489 write_package(&dir.path().join("sibling"), r#"{"name": "sibling"}"#);
1490 std::fs::create_dir_all(root.join("packages/no-manifest")).unwrap();
1491 std::fs::write(root.join("vendor.tgz"), "").unwrap();
1492 std::fs::write(
1493 root.join("package.json"),
1494 r#"{
1495 "dependencies": {
1496 "sibling": "link:../sibling",
1497 "absolute": "file:/definitely/not/here",
1498 "missing": "link:packages/missing",
1499 "no-manifest": "link:packages/no-manifest",
1500 "tarball": "file:./vendor.tgz",
1501 "self": "link:.",
1502 "react": "^18.0.0"
1503 }
1504 }"#,
1505 )
1506 .unwrap();
1507
1508 let workspaces = discover_workspaces(&root);
1509 assert!(workspaces.is_empty(), "{workspaces:?}");
1510 }
1511
1512 #[test]
1513 fn discover_workspaces_skips_link_targets_that_source_discovery_skips() {
1514 let dir = tempfile::tempdir().expect("create temp dir");
1515 let root = dir.path();
1516 std::fs::write(
1517 root.join("package.json"),
1518 r#"{
1519 "dependencies": {
1520 "ylib": "file:.yalc/ylib",
1521 "nm": "link:node_modules/nm",
1522 "blib": "link:build/blib",
1523 "dlib": "link:dist/dlib",
1524 "clib": "link:packages/coverage/clib",
1525 "vlib": "link:vendor/deep/vlib",
1526 "kept": "link:libs/deep/kept"
1527 }
1528 }"#,
1529 )
1530 .unwrap();
1531 for (path, name) in [
1532 (".yalc/ylib", "ylib"),
1533 ("node_modules/nm", "nm"),
1534 ("build/blib", "blib"),
1535 ("dist/dlib", "dlib"),
1536 ("packages/coverage/clib", "clib"),
1537 ("vendor/deep/vlib", "vlib"),
1538 ("libs/deep/kept", "kept"),
1539 ] {
1540 write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1541 }
1542 let mut builder = globset::GlobSetBuilder::new();
1543 builder.add(globset::Glob::new("vendor/**").unwrap());
1544 let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
1545
1546 let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1547 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1548 assert_eq!(names, ["kept"]);
1549 }
1550
1551 fn write_gitignored_link_fixture(root: &Path) {
1552 std::fs::write(
1553 root.join("package.json"),
1554 r#"{
1555 "dependencies": {
1556 "vlib": "file:vendor/deep/vlib",
1557 "plib": "link:libs/private/plib",
1558 "kept": "link:libs/deep/kept"
1559 }
1560 }"#,
1561 )
1562 .unwrap();
1563 for (path, name) in [
1564 ("vendor/deep/vlib", "vlib"),
1565 ("libs/private/plib", "plib"),
1566 ("libs/deep/kept", "kept"),
1567 ] {
1568 write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1569 }
1570 std::fs::write(root.join(".gitignore"), "vendor/\n").unwrap();
1571 std::fs::write(root.join("libs/.gitignore"), "private/\n").unwrap();
1572 }
1573
1574 fn link_workspace_names(root: &Path) -> Vec<String> {
1575 let ignore = crate::IgnorePatternSet::default();
1576 let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1577 let mut names: Vec<String> = workspaces.into_iter().map(|ws| ws.name).collect();
1578 names.sort_unstable();
1579 names
1580 }
1581
1582 #[test]
1583 fn discover_workspaces_skips_gitignored_link_targets() {
1584 let dir = tempfile::tempdir().expect("create temp dir");
1585 let root = dir.path();
1586 std::fs::create_dir_all(root.join(".git")).unwrap();
1587 write_gitignored_link_fixture(root);
1588
1589 assert_eq!(link_workspace_names(root), ["kept"]);
1590 }
1591
1592 #[test]
1593 fn discover_workspaces_keeps_link_targets_when_gitignore_does_not_apply() {
1594 let dir = tempfile::tempdir().expect("create temp dir");
1596 let root = dir.path();
1597 write_gitignored_link_fixture(root);
1598
1599 assert_eq!(link_workspace_names(root), ["kept", "plib", "vlib"]);
1600 }
1601
1602 #[test]
1603 fn discover_workspaces_dedupes_link_targets_with_declared_workspaces() {
1604 let dir = tempfile::tempdir().expect("create temp dir");
1605 let root = dir.path();
1606 std::fs::write(
1607 root.join("package.json"),
1608 r#"{
1609 "workspaces": ["packages/*"],
1610 "dependencies": {
1611 "a": "link:packages/a",
1612 "tool": "link:tools/deep/tool"
1613 }
1614 }"#,
1615 )
1616 .unwrap();
1617 write_package(&root.join("packages/a"), r#"{"name": "a"}"#);
1618 write_package(&root.join("tools/deep/tool"), r#"{"name": "tool"}"#);
1619
1620 let workspaces = discover_workspaces(root);
1621 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1622 names.sort_unstable();
1623 assert_eq!(names, ["a", "tool"]);
1624 assert!(
1625 find_undeclared_workspaces(root, &workspaces).is_empty(),
1626 "a linked package is declared, not undeclared"
1627 );
1628 }
1629
1630 #[test]
1631 fn undeclared_workspace_detected() {
1632 let dir = tempfile::tempdir().expect("create temp dir");
1633 let pkg_a = dir.path().join("packages").join("a");
1634 let pkg_b = dir.path().join("packages").join("b");
1635 std::fs::create_dir_all(&pkg_a).unwrap();
1636 std::fs::create_dir_all(&pkg_b).unwrap();
1637
1638 std::fs::write(
1639 dir.path().join("package.json"),
1640 r#"{"workspaces": ["packages/a"]}"#,
1641 )
1642 .unwrap();
1643 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1644 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1645
1646 let declared = discover_workspaces(dir.path());
1647 assert_eq!(declared.len(), 1);
1648
1649 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1650 assert_eq!(undeclared.len(), 1);
1651 assert!(
1652 undeclared[0]
1653 .path
1654 .to_string_lossy()
1655 .replace('\\', "/")
1656 .contains("packages/b"),
1657 "should detect packages/b as undeclared: {:?}",
1658 undeclared[0].path
1659 );
1660 }
1661
1662 #[test]
1663 fn no_undeclared_when_all_covered() {
1664 let dir = tempfile::tempdir().expect("create temp dir");
1665 let pkg_a = dir.path().join("packages").join("a");
1666 std::fs::create_dir_all(&pkg_a).unwrap();
1667
1668 std::fs::write(
1669 dir.path().join("package.json"),
1670 r#"{"workspaces": ["packages/*"]}"#,
1671 )
1672 .unwrap();
1673 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1674
1675 let declared = discover_workspaces(dir.path());
1676 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1677 assert!(undeclared.is_empty());
1678 }
1679
1680 #[test]
1681 fn no_undeclared_when_no_workspace_patterns() {
1682 let dir = tempfile::tempdir().expect("create temp dir");
1683 let sub = dir.path().join("lib");
1684 std::fs::create_dir_all(&sub).unwrap();
1685
1686 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1687 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1688
1689 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1690 assert!(
1691 undeclared.is_empty(),
1692 "should skip check when no workspace patterns exist"
1693 );
1694 }
1695
1696 #[test]
1697 fn undeclared_skips_node_modules_and_hidden_dirs() {
1698 let dir = tempfile::tempdir().expect("create temp dir");
1699 let nm = dir.path().join("node_modules").join("some-pkg");
1700 let hidden = dir.path().join(".hidden");
1701 std::fs::create_dir_all(&nm).unwrap();
1702 std::fs::create_dir_all(&hidden).unwrap();
1703
1704 std::fs::write(
1705 dir.path().join("package.json"),
1706 r#"{"workspaces": ["packages/*"]}"#,
1707 )
1708 .unwrap();
1709 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1710 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1711
1712 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1713 assert!(
1714 undeclared.is_empty(),
1715 "should not flag node_modules or hidden directories"
1716 );
1717 }
1718
1719 fn build_globset(patterns: &[&str]) -> crate::IgnorePatternSet {
1720 let mut builder = globset::GlobSetBuilder::new();
1721 for pattern in patterns {
1722 builder.add(globset::Glob::new(pattern).expect("valid glob"));
1723 }
1724 crate::IgnorePatternSet::from(builder.build().expect("build globset"))
1725 }
1726
1727 #[test]
1728 fn undeclared_skips_dirs_matching_ignore_patterns() {
1729 let dir = tempfile::tempdir().expect("create temp dir");
1730 let pkg_a = dir.path().join("packages").join("a");
1731 let vitest_ref = dir.path().join("references").join("vitest");
1732 let tanstack_ref = dir.path().join("references").join("tanstack-router");
1733 std::fs::create_dir_all(&pkg_a).unwrap();
1734 std::fs::create_dir_all(&vitest_ref).unwrap();
1735 std::fs::create_dir_all(&tanstack_ref).unwrap();
1736
1737 std::fs::write(
1738 dir.path().join("package.json"),
1739 r#"{"workspaces": ["packages/*"]}"#,
1740 )
1741 .unwrap();
1742 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1743 std::fs::write(
1744 vitest_ref.join("package.json"),
1745 r#"{"name": "vitest-reference"}"#,
1746 )
1747 .unwrap();
1748 std::fs::write(
1749 tanstack_ref.join("package.json"),
1750 r#"{"name": "tanstack-reference"}"#,
1751 )
1752 .unwrap();
1753
1754 let declared = discover_workspaces(dir.path());
1755 let ignore = build_globset(&["references/*"]);
1756 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1757 assert!(
1758 undeclared.is_empty(),
1759 "references/* should be ignored: {undeclared:?}"
1760 );
1761 }
1762
1763 #[test]
1764 fn undeclared_still_reported_when_ignore_does_not_match() {
1765 let dir = tempfile::tempdir().expect("create temp dir");
1766 let pkg_b = dir.path().join("packages").join("b");
1767 std::fs::create_dir_all(&pkg_b).unwrap();
1768
1769 std::fs::write(
1770 dir.path().join("package.json"),
1771 r#"{"workspaces": ["packages/a"]}"#,
1772 )
1773 .unwrap();
1774 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1775
1776 let declared = discover_workspaces(dir.path());
1777 let ignore = build_globset(&["references/*"]);
1778 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1779 assert_eq!(
1780 undeclared.len(),
1781 1,
1782 "non-matching ignore patterns should not silence other undeclared dirs"
1783 );
1784 }
1785
1786 #[test]
1787 fn undeclared_skips_dirs_matching_package_json_glob() {
1788 let dir = tempfile::tempdir().expect("create temp dir");
1789 let pkg_a = dir.path().join("packages").join("a");
1790 let vitest_ref = dir.path().join("references").join("vitest");
1791 std::fs::create_dir_all(&pkg_a).unwrap();
1792 std::fs::create_dir_all(&vitest_ref).unwrap();
1793
1794 std::fs::write(
1795 dir.path().join("package.json"),
1796 r#"{"workspaces": ["packages/*"]}"#,
1797 )
1798 .unwrap();
1799 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1800 std::fs::write(
1801 vitest_ref.join("package.json"),
1802 r#"{"name": "vitest-reference"}"#,
1803 )
1804 .unwrap();
1805
1806 let declared = discover_workspaces(dir.path());
1807 let ignore = build_globset(&["references/*/package.json"]);
1808 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1809 assert!(
1810 undeclared.is_empty(),
1811 "package.json-suffixed glob should silence the warning: {undeclared:?}"
1812 );
1813 }
1814
1815 #[test]
1816 fn undeclared_skips_dirs_matching_doublestar_ignore() {
1817 let dir = tempfile::tempdir().expect("create temp dir");
1818 let pkg_a = dir.path().join("packages").join("a");
1819 let nested_ref = dir.path().join("references").join("vitest");
1820 std::fs::create_dir_all(&pkg_a).unwrap();
1821 std::fs::create_dir_all(&nested_ref).unwrap();
1822
1823 std::fs::write(
1824 dir.path().join("package.json"),
1825 r#"{"workspaces": ["packages/*"]}"#,
1826 )
1827 .unwrap();
1828 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1829 std::fs::write(
1830 nested_ref.join("package.json"),
1831 r#"{"name": "vitest-reference"}"#,
1832 )
1833 .unwrap();
1834
1835 let declared = discover_workspaces(dir.path());
1836 let ignore = build_globset(&["**/references/**"]);
1837 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1838 assert!(
1839 undeclared.is_empty(),
1840 "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1841 );
1842 }
1843
1844 #[test]
1845 fn malformed_workspace_package_json_emits_diagnostic() {
1846 let dir = tempfile::tempdir().expect("create temp dir");
1847 let pkg_a = dir.path().join("packages").join("a");
1848 let pkg_bad = dir.path().join("packages").join("bad");
1849 std::fs::create_dir_all(&pkg_a).unwrap();
1850 std::fs::create_dir_all(&pkg_bad).unwrap();
1851 std::fs::write(
1852 dir.path().join("package.json"),
1853 r#"{"workspaces": ["packages/*"]}"#,
1854 )
1855 .unwrap();
1856 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1857 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1858
1859 let (result, captured) = capture_workspace_warnings(|| {
1860 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1861 });
1862 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1863
1864 assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1865 assert_eq!(workspaces[0].name, "a");
1866 assert_eq!(diagnostics.len(), 1);
1867 assert!(matches!(
1868 diagnostics[0].kind,
1869 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1870 ));
1871 assert!(
1872 captured
1873 .iter()
1874 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1875 );
1876 }
1877
1878 #[test]
1879 fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1880 let dir = tempfile::tempdir().expect("create temp dir");
1881 let pkg_good = dir.path().join("packages/good");
1882 let pkg_bad = dir.path().join("packages/bad");
1883 std::fs::create_dir_all(&pkg_good).unwrap();
1884 std::fs::create_dir_all(&pkg_bad).unwrap();
1885 std::fs::write(
1886 dir.path().join("package.json"),
1887 r#"{"workspaces": ["packages/*"]}"#,
1888 )
1889 .unwrap();
1890 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1891 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad"}"#).unwrap();
1892 std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1893
1894 let result =
1895 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
1896 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1897
1898 assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1899 assert_eq!(workspaces[0].name, "good");
1900 assert_eq!(diagnostics.len(), 1);
1901 assert!(matches!(
1902 diagnostics[0].kind,
1903 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1904 ));
1905 assert!(diagnostics[0].message.contains("deno.jsonc"));
1906 }
1907
1908 #[test]
1918 fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1919 let dir = tempfile::tempdir().expect("create temp dir");
1920 let pkg_good = dir.path().join("packages").join("good");
1921 let pkg_bad = dir.path().join("packages").join("bad");
1922 std::fs::create_dir_all(&pkg_good).unwrap();
1923 std::fs::create_dir_all(&pkg_bad).unwrap();
1924 std::fs::write(
1925 dir.path().join("package.json"),
1926 r#"{"workspaces": ["packages/*"]}"#,
1927 )
1928 .unwrap();
1929 std::fs::write(
1930 dir.path().join("tsconfig.json"),
1931 r#"{"references": [{"path": "./packages/bad"}]}"#,
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"{,}").unwrap();
1936
1937 let (result, _) = capture_workspace_warnings(|| {
1938 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1939 });
1940 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1941
1942 assert!(
1943 workspaces.iter().any(|w| w.name == "good"),
1944 "valid sibling should still discover: {workspaces:?}"
1945 );
1946 assert!(
1947 workspaces.iter().any(|w| w.name == "bad"),
1948 "tsconfig reference falls back to the directory name on manifest error"
1949 );
1950 let malformed: Vec<_> = diagnostics
1951 .iter()
1952 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1953 .collect();
1954 assert_eq!(
1955 malformed.len(),
1956 1,
1957 "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
1958 );
1959 assert!(
1960 malformed[0].path.ends_with("bad"),
1961 "the diagnostic points at the malformed member"
1962 );
1963 }
1964
1965 #[test]
1976 fn manifest_cache_replays_the_same_err_on_every_visit() {
1977 let dir = tempfile::tempdir().expect("create temp dir");
1978 let member = dir.path().join("packages").join("bad");
1979 std::fs::create_dir_all(&member).unwrap();
1980 std::fs::write(member.join("package.json"), r"{,}").unwrap();
1981
1982 let mut cache = ManifestCache::default();
1983 let first = load_member_package_manifest_cached(&member, &mut cache)
1984 .expect_err("a malformed member manifest is an Err");
1985 assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
1986
1987 std::fs::remove_file(member.join("package.json")).unwrap();
1988 let replayed = load_member_package_manifest_cached(&member, &mut cache)
1989 .expect_err("the memoized Err replays instead of re-reading the directory");
1990 assert_eq!(
1991 replayed, first,
1992 "the replayed outcome is byte-identical to the first, so both \
1993 workspace sources diagnose the same member the same way"
1994 );
1995 }
1996
1997 #[test]
1998 fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1999 let dir = tempfile::tempdir().expect("create temp dir");
2000 for name in ["a", "b", "c"] {
2001 let pkg = dir.path().join("packages").join(name);
2002 std::fs::create_dir_all(&pkg).unwrap();
2003 std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
2004 }
2005 std::fs::write(
2006 dir.path().join("package.json"),
2007 r#"{"workspaces": ["packages/*"]}"#,
2008 )
2009 .unwrap();
2010
2011 let (result, _) = capture_workspace_warnings(|| {
2012 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2013 });
2014 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2015
2016 assert!(workspaces.is_empty(), "all three malformed; nothing valid");
2017 assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
2018 assert!(
2019 diagnostics
2020 .iter()
2021 .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2022 "every diagnostic should be malformed-package-json"
2023 );
2024 }
2025
2026 #[test]
2027 fn malformed_root_package_json_returns_load_error() {
2028 let dir = tempfile::tempdir().expect("create temp dir");
2029 std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
2030
2031 let result =
2032 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2033
2034 match result {
2035 Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
2036 assert!(path.ends_with("package.json"));
2037 assert!(!error.is_empty(), "underlying parse error is preserved");
2038 }
2039 other => panic!("expected MalformedRootPackageJson, got {other:?}"),
2040 }
2041 }
2042
2043 #[test]
2044 fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
2045 let dir = tempfile::tempdir().expect("create temp dir");
2046 let pkg_a = dir.path().join("packages").join("a");
2047 let cache_dir = dir.path().join("packages").join(".cache");
2048 let scratch_dir = dir.path().join("packages").join("scratch");
2049 std::fs::create_dir_all(&pkg_a).unwrap();
2050 std::fs::create_dir_all(&cache_dir).unwrap();
2051 std::fs::create_dir_all(&scratch_dir).unwrap();
2052 std::fs::write(
2053 dir.path().join("package.json"),
2054 r#"{"workspaces": ["packages/*"]}"#,
2055 )
2056 .unwrap();
2057 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2058
2059 let result =
2060 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2061 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2062
2063 assert_eq!(workspaces.len(), 1);
2064 let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
2065 assert!(
2066 kinds.contains(&"glob-matched-no-package-json"),
2067 "scratch should diagnose: {kinds:?}"
2068 );
2069 assert!(
2070 !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
2071 ".cache must be skip-listed: {diagnostics:?}"
2072 );
2073 }
2074
2075 #[test]
2076 fn glob_match_without_package_json_honors_ignore_patterns() {
2077 let dir = tempfile::tempdir().expect("create temp dir");
2078 let pkg_a = dir.path().join("packages").join("a");
2079 let legacy_dir = dir.path().join("packages").join("legacy");
2080 std::fs::create_dir_all(&pkg_a).unwrap();
2081 std::fs::create_dir_all(&legacy_dir).unwrap();
2082 std::fs::write(
2083 dir.path().join("package.json"),
2084 r#"{"workspaces": ["packages/*"]}"#,
2085 )
2086 .unwrap();
2087 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2088
2089 let mut builder = globset::GlobSetBuilder::new();
2090 builder.add(globset::Glob::new("packages/legacy").unwrap());
2091 let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
2092
2093 let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
2094 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2095
2096 assert_eq!(workspaces.len(), 1);
2097 assert!(
2098 diagnostics.is_empty(),
2099 "user-excluded path must not produce a diagnostic: {diagnostics:?}"
2100 );
2101 }
2102
2103 #[test]
2104 fn malformed_tsconfig_emits_diagnostic() {
2105 let dir = tempfile::tempdir().expect("create temp dir");
2106 std::fs::write(
2107 dir.path().join("package.json"),
2108 r#"{"workspaces": ["packages/*"]}"#,
2109 )
2110 .unwrap();
2111 std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
2112
2113 let result =
2114 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2115 let (_, diagnostics) = result.expect("root package.json is valid");
2116
2117 assert!(
2118 diagnostics
2119 .iter()
2120 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2121 "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
2122 );
2123 }
2124
2125 #[test]
2126 fn tsconfig_missing_reference_dir_emits_diagnostic() {
2127 let dir = tempfile::tempdir().expect("create temp dir");
2128 std::fs::write(
2129 dir.path().join("tsconfig.json"),
2130 r#"{"references": [{"path": "./packages/missing"}]}"#,
2131 )
2132 .unwrap();
2133
2134 let result =
2135 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2136 let (_, diagnostics) = result.expect("no package.json at root is OK");
2137
2138 assert!(
2139 diagnostics
2140 .iter()
2141 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
2142 "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
2143 );
2144 }
2145
2146 #[test]
2147 fn missing_tsconfig_is_silent() {
2148 let dir = tempfile::tempdir().expect("create temp dir");
2149
2150 let result =
2151 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2152 let (_, diagnostics) = result.expect("no root package.json is OK");
2153
2154 assert!(
2155 !diagnostics
2156 .iter()
2157 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2158 "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
2159 );
2160 }
2161
2162 #[test]
2163 fn shallow_scan_malformed_package_json_stays_silent() {
2164 let dir = tempfile::tempdir().expect("create temp dir");
2165 let scratch = dir.path().join("scratch");
2166 std::fs::create_dir_all(&scratch).unwrap();
2167 std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
2168
2169 let result =
2170 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2171 let (_, diagnostics) = result.expect("no root package.json is OK");
2172
2173 assert!(
2174 !diagnostics
2175 .iter()
2176 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2177 "shallow-scan malformed must stay silent: {diagnostics:?}"
2178 );
2179 }
2180
2181 #[test]
2182 fn mixed_valid_and_malformed_workspaces_partial_recovery() {
2183 let dir = tempfile::tempdir().expect("create temp dir");
2184 let pkg_good = dir.path().join("packages").join("good");
2185 let pkg_bad = dir.path().join("packages").join("bad");
2186 std::fs::create_dir_all(&pkg_good).unwrap();
2187 std::fs::create_dir_all(&pkg_bad).unwrap();
2188 std::fs::write(
2189 dir.path().join("package.json"),
2190 r#"{"workspaces": ["packages/*"]}"#,
2191 )
2192 .unwrap();
2193 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2194 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2195
2196 let result =
2197 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2198 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2199
2200 assert_eq!(workspaces.len(), 1);
2201 assert_eq!(workspaces[0].name, "good");
2202 assert_eq!(diagnostics.len(), 1);
2203 assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
2204 }
2205
2206 #[test]
2207 fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
2208 let dir = tempfile::tempdir().expect("create temp dir");
2209 std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
2210
2211 let workspaces = discover_workspaces(dir.path());
2212 assert!(
2213 workspaces.is_empty(),
2214 "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
2215 );
2216 }
2217
2218 #[test]
2219 fn discovers_deno_workspace_members_without_package_json() {
2220 let dir = tempfile::tempdir().expect("create temp dir");
2221 let core = dir.path().join("packages").join("core");
2222 let app = dir.path().join("apps").join("desktop");
2223 std::fs::create_dir_all(&core).unwrap();
2224 std::fs::create_dir_all(&app).unwrap();
2225
2226 std::fs::write(
2227 dir.path().join("deno.json"),
2228 r#"{
2229 "workspace": ["./apps/*", "./packages/*"],
2230 "imports": { "@std/assert": "jsr:@std/assert@1" }
2231 }"#,
2232 )
2233 .unwrap();
2234 std::fs::write(
2235 core.join("deno.json"),
2236 r#"{
2237 "name": "@fallow/core",
2238 "exports": { ".": "./mod.ts", "./result": "./result.ts" }
2239 }"#,
2240 )
2241 .unwrap();
2242 std::fs::write(
2243 app.join("deno.json"),
2244 r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
2245 )
2246 .unwrap();
2247
2248 let workspaces = discover_workspaces(dir.path());
2249 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2250 names.sort_unstable();
2251 assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
2252 assert!(
2253 workspaces
2254 .iter()
2255 .all(|w| !w.root.join("package.json").exists())
2256 );
2257 assert!(
2258 workspaces.iter().all(|w| !w.is_internal_dependency),
2259 "Deno packages need dependency evidence before inventory marks them internal"
2260 );
2261 }
2262
2263 #[test]
2264 fn malformed_root_deno_config_returns_load_error() {
2265 let dir = tempfile::tempdir().expect("create temp dir");
2266 std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
2267
2268 let result =
2269 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2270
2271 match result {
2272 Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
2273 assert!(path.ends_with("deno.jsonc"));
2274 assert!(!error.is_empty(), "underlying parse error is preserved");
2275 }
2276 other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
2277 }
2278 }
2279
2280 #[test]
2281 fn deno_and_npm_workspace_patterns_are_additive() {
2282 let dir = tempfile::tempdir().expect("create temp dir");
2283 let a = dir.path().join("packages").join("a");
2284 let b = dir.path().join("packages").join("b");
2285 std::fs::create_dir_all(&a).unwrap();
2286 std::fs::create_dir_all(&b).unwrap();
2287
2288 std::fs::write(
2289 dir.path().join("package.json"),
2290 r#"{"workspaces": ["packages/a"]}"#,
2291 )
2292 .unwrap();
2293 std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2294 std::fs::write(
2295 dir.path().join("deno.json"),
2296 r#"{"workspace": ["./packages/b"]}"#,
2297 )
2298 .unwrap();
2299 std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
2300
2301 let workspaces = discover_workspaces(dir.path());
2302 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2303 names.sort_unstable();
2304 assert_eq!(names, vec!["a", "b"]);
2305 }
2306
2307 #[test]
2313 fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
2314 let dir = tempfile::tempdir().expect("create temp dir");
2315 write_two_manifest_glob_project(dir.path());
2316
2317 let (_, diagnostics) =
2318 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2319 .expect("root package.json is valid");
2320
2321 let mut reported: Vec<(String, String)> = diagnostics
2322 .iter()
2323 .filter_map(|diagnostic| match &diagnostic.kind {
2324 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
2325 pattern.clone(),
2326 diagnostic
2327 .path
2328 .strip_prefix(dir.path())
2329 .unwrap_or(&diagnostic.path)
2330 .display()
2331 .to_string()
2332 .replace('\\', "/"),
2333 )),
2334 _ => None,
2335 })
2336 .collect();
2337 reported.sort();
2338
2339 assert_eq!(
2340 reported,
2341 vec![
2342 ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
2343 ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
2344 ],
2345 "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
2346 );
2347 }
2348
2349 #[test]
2353 fn overlapping_globs_still_report_one_directory_once_per_pattern() {
2354 let dir = tempfile::tempdir().expect("create temp dir");
2355 std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
2356 std::fs::write(
2357 dir.path().join("package.json"),
2358 r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
2359 )
2360 .unwrap();
2361 std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
2362
2363 let (_, diagnostics) =
2364 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2365 .expect("root package.json is valid");
2366
2367 let patterns: Vec<&str> = diagnostics
2368 .iter()
2369 .filter_map(|diagnostic| match &diagnostic.kind {
2370 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
2371 Some(pattern.as_str())
2372 }
2373 _ => None,
2374 })
2375 .collect();
2376
2377 assert_eq!(
2378 patterns,
2379 ["pkgs/*", "pkgs/a*"],
2380 "distinct patterns are distinct diagnostics: {diagnostics:?}"
2381 );
2382 }
2383
2384 #[test]
2390 fn workspace_entries_outside_the_root_are_skipped_before_the_walk() {
2391 let sandbox = tempfile::tempdir().expect("create temp dir");
2392 let outside = sandbox.path().join("outside");
2393 std::fs::create_dir_all(outside.join("no-manifest")).unwrap();
2394 std::fs::create_dir_all(outside.join("pkg-out")).unwrap();
2395 std::fs::write(
2396 outside.join("pkg-out/package.json"),
2397 r#"{"name":"pkg-out"}"#,
2398 )
2399 .unwrap();
2400 let root = sandbox.path().join("repo");
2401 std::fs::create_dir_all(root.join("packages/pkg-in")).unwrap();
2402 std::fs::write(
2403 root.join("packages/pkg-in/package.json"),
2404 r#"{"name":"pkg-in"}"#,
2405 )
2406 .unwrap();
2407 let absolute = format!("{}/*", outside.display()).replace('\\', "/");
2408 std::fs::write(
2409 root.join("package.json"),
2410 serde_json::json!({
2411 "name": "root",
2412 "private": true,
2413 "workspaces": ["packages/*", "../outside/*", absolute],
2414 })
2415 .to_string(),
2416 )
2417 .unwrap();
2418 std::fs::write(
2419 root.join("pnpm-workspace.yaml"),
2420 "packages:\n - packages/*\n - ../outside/**\n",
2421 )
2422 .unwrap();
2423
2424 let (workspaces, diagnostics) =
2425 discover_workspaces_with_diagnostics(&root, &crate::IgnorePatternSet::empty())
2426 .expect("root package.json is valid");
2427
2428 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
2429 assert_eq!(names, ["pkg-in"], "only the in-root package is a workspace");
2430
2431 let canonical_root = dunce::canonicalize(&root).unwrap();
2432 for diagnostic in &diagnostics {
2433 let path =
2434 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
2435 assert!(
2436 path.starts_with(&canonical_root),
2437 "no diagnostic names a path outside the root: {diagnostic:?}"
2438 );
2439 assert!(
2440 !diagnostic.message.contains("no-manifest")
2441 && !diagnostic.message.contains("pkg-out"),
2442 "no message names an outside directory: {diagnostic:?}"
2443 );
2444 }
2445
2446 let mut skipped: Vec<(String, String)> = diagnostics
2447 .iter()
2448 .filter(|diagnostic| diagnostic.kind.id() == "workspace-glob-outside-root")
2449 .map(|diagnostic| {
2450 (
2451 fallow_types::path_util::display_relative(&root, &diagnostic.path),
2452 diagnostic.message.clone(),
2453 )
2454 })
2455 .collect();
2456 skipped.sort();
2457 let files: Vec<&str> = skipped.iter().map(|(file, _)| file.as_str()).collect();
2458 assert_eq!(
2459 files,
2460 ["package.json", "package.json", "pnpm-workspace.yaml"],
2461 "one diagnostic per skipped entry, anchored at its file: {diagnostics:?}"
2462 );
2463 assert!(
2464 skipped
2465 .iter()
2466 .any(|(_, message)| message.contains("'../outside/*'")),
2467 "the message names the entry: {skipped:?}"
2468 );
2469 }
2470
2471 #[test]
2475 fn deno_entries_and_in_root_dot_dot_segments_follow_the_root_boundary() {
2476 let sandbox = tempfile::tempdir().expect("create temp dir");
2477 std::fs::create_dir_all(sandbox.path().join("outside/no-manifest")).unwrap();
2478 let root = sandbox.path().join("repo");
2479 std::fs::create_dir_all(root.join("packages/pkg-in")).unwrap();
2480 std::fs::write(
2481 root.join("packages/pkg-in/deno.json"),
2482 r#"{"name":"@x/pkg-in"}"#,
2483 )
2484 .unwrap();
2485 std::fs::write(
2486 root.join("deno.json"),
2487 r#"{"workspace":["packages/../packages/*","../outside/*","!../outside/no-manifest"]}"#,
2488 )
2489 .unwrap();
2490
2491 let (workspaces, diagnostics) =
2492 discover_workspaces_with_diagnostics(&root, &crate::IgnorePatternSet::empty())
2493 .expect("root deno.json is valid");
2494
2495 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
2496 assert_eq!(
2497 names,
2498 ["@x/pkg-in"],
2499 "the in-root `..` entry still finds its package"
2500 );
2501 let [diagnostic] = diagnostics.as_slice() else {
2502 panic!("expected one diagnostic: {diagnostics:?}");
2503 };
2504 assert_eq!(diagnostic.path, root.join("deno.json"));
2505 assert_eq!(
2506 diagnostic.kind,
2507 WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
2508 pattern: "../outside/*".to_owned()
2509 }
2510 );
2511 }
2512
2513 #[test]
2516 fn configured_pattern_outside_the_root_names_the_config_setting() {
2517 let dir = tempfile::tempdir().expect("create temp dir");
2518 std::fs::write(dir.path().join("package.json"), r#"{"name":"root"}"#).unwrap();
2519
2520 let (_, diagnostics) = discover_workspaces_with_diagnostics_and_patterns(
2521 dir.path(),
2522 &crate::IgnorePatternSet::empty(),
2523 &["../shared/*".to_owned()],
2524 )
2525 .expect("root package.json is valid");
2526
2527 let [diagnostic] = diagnostics.as_slice() else {
2528 panic!("expected one diagnostic: {diagnostics:?}");
2529 };
2530 assert_eq!(diagnostic.path, Path::new("."));
2531 assert_eq!(
2532 diagnostic.kind,
2533 WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
2534 pattern: "../shared/*".to_owned()
2535 }
2536 );
2537 assert!(
2538 diagnostic
2539 .message
2540 .contains("fallow config `workspaces.patterns`"),
2541 "{diagnostic:?}"
2542 );
2543 }
2544 mod layout_tests;
2545}