1mod diagnostics;
2mod package_json;
3mod parsers;
4mod pnpm_catalog;
5mod pnpm_overrides;
6
7use std::path::{Path, PathBuf};
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12#[cfg(test)]
13pub use diagnostics::capture_workspace_warnings;
14pub use diagnostics::{
15 WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
16 stash_workspace_diagnostics, workspace_diagnostics_for,
17};
18use diagnostics::{emit_warn, is_skip_listed_dir};
19pub use package_json::PackageJson;
23pub use parsers::parse_tsconfig_root_dir;
24use parsers::{
25 expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
26 parse_tsconfig_references_with_diagnostics,
27};
28pub use pnpm_catalog::{
29 PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup, parse_pnpm_catalog_data,
30};
31pub use pnpm_overrides::{
32 MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
33 is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
34 parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
35};
36
37#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
39pub struct WorkspaceConfig {
40 #[serde(default)]
42 pub patterns: Vec<String>,
43}
44
45#[derive(Debug, Clone)]
47pub struct WorkspaceInfo {
48 pub root: PathBuf,
50 pub name: String,
52 pub is_internal_dependency: bool,
54}
55
56#[must_use]
77pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
78 collect_workspaces_and_diagnostics(root, &globset::GlobSet::empty())
79 .map(|(workspaces, _)| workspaces)
80 .unwrap_or_default()
81}
82
83pub fn discover_workspaces_with_diagnostics(
113 root: &Path,
114 ignore_patterns: &globset::GlobSet,
115) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
116 let (workspaces, diagnostics) = collect_workspaces_and_diagnostics(root, ignore_patterns)?;
117
118 for diag in &diagnostics {
128 emit_warn(root, diag);
129 }
130
131 Ok((workspaces, diagnostics))
132}
133
134fn collect_workspaces_and_diagnostics(
143 root: &Path,
144 ignore_patterns: &globset::GlobSet,
145) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
146 let mut diagnostics = Vec::new();
147 let patterns = collect_workspace_patterns(root)?;
148 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
149
150 let mut workspaces = expand_patterns_to_workspaces(
151 root,
152 &patterns,
153 &canonical_root,
154 ignore_patterns,
155 &mut diagnostics,
156 );
157 workspaces.extend(collect_tsconfig_workspaces(
158 root,
159 &canonical_root,
160 ignore_patterns,
161 &mut diagnostics,
162 ));
163 if patterns.is_empty() {
164 workspaces.extend(collect_shallow_package_workspaces(root, &canonical_root));
165 }
166
167 if !workspaces.is_empty() {
168 mark_internal_dependencies(&mut workspaces);
169 }
170 let workspaces = workspaces.into_iter().map(|(ws, _)| ws).collect();
171 Ok((workspaces, diagnostics))
172}
173
174#[must_use]
180pub fn find_undeclared_workspaces(
181 root: &Path,
182 declared: &[WorkspaceInfo],
183) -> Vec<WorkspaceDiagnostic> {
184 find_undeclared_workspaces_with_ignores(root, declared, &globset::GlobSet::empty())
185}
186
187#[must_use]
197pub fn find_undeclared_workspaces_with_ignores(
198 root: &Path,
199 declared: &[WorkspaceInfo],
200 ignore_patterns: &globset::GlobSet,
201) -> Vec<WorkspaceDiagnostic> {
202 let patterns = collect_workspace_patterns(root).unwrap_or_default();
208 if patterns.is_empty() {
209 return Vec::new();
210 }
211
212 let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
213 .iter()
214 .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
215 .collect();
216
217 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
218
219 let mut undeclared = Vec::new();
220
221 let Ok(top_entries) = std::fs::read_dir(root) else {
223 return Vec::new();
224 };
225
226 for entry in top_entries.filter_map(Result::ok) {
227 let path = entry.path();
228 if !path.is_dir() {
229 continue;
230 }
231
232 let name = entry.file_name();
233 let name_str = name.to_string_lossy();
234 if name_str.starts_with('.') || name_str == "node_modules" || name_str == "build" {
235 continue;
236 }
237
238 check_undeclared(
240 &path,
241 root,
242 &canonical_root,
243 &declared_roots,
244 ignore_patterns,
245 &mut undeclared,
246 );
247
248 let Ok(child_entries) = std::fs::read_dir(&path) else {
250 continue;
251 };
252 for child in child_entries.filter_map(Result::ok) {
253 let child_path = child.path();
254 if !child_path.is_dir() {
255 continue;
256 }
257 let child_name = child.file_name();
258 let child_name_str = child_name.to_string_lossy();
259 if child_name_str.starts_with('.')
260 || child_name_str == "node_modules"
261 || child_name_str == "build"
262 {
263 continue;
264 }
265 check_undeclared(
266 &child_path,
267 root,
268 &canonical_root,
269 &declared_roots,
270 ignore_patterns,
271 &mut undeclared,
272 );
273 }
274 }
275
276 undeclared
277}
278
279fn check_undeclared(
281 dir: &Path,
282 root: &Path,
283 canonical_root: &Path,
284 declared_roots: &rustc_hash::FxHashSet<PathBuf>,
285 ignore_patterns: &globset::GlobSet,
286 undeclared: &mut Vec<WorkspaceDiagnostic>,
287) {
288 if !dir.join("package.json").exists() {
289 return;
290 }
291 let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
292 if canonical == *canonical_root {
294 return;
295 }
296 if declared_roots.contains(&canonical) {
297 return;
298 }
299 let relative = dir.strip_prefix(root).unwrap_or(dir);
300 let relative_str = relative.to_string_lossy().replace('\\', "/");
304 if ignore_patterns.is_match(relative_str.as_str())
305 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
306 {
307 return;
308 }
309 undeclared.push(WorkspaceDiagnostic::new(
310 root,
311 dir.to_path_buf(),
312 WorkspaceDiagnosticKind::UndeclaredWorkspace,
313 ));
314}
315
316fn collect_workspace_patterns(root: &Path) -> Result<Vec<String>, WorkspaceLoadError> {
318 let mut patterns = Vec::new();
319
320 let pkg_path = root.join("package.json");
327 if pkg_path.exists() {
328 match PackageJson::load(&pkg_path) {
329 Ok(pkg) => patterns.extend(pkg.workspace_patterns()),
330 Err(error) => {
331 return Err(WorkspaceLoadError::MalformedRootPackageJson {
332 path: pkg_path,
333 error,
334 });
335 }
336 }
337 }
338
339 let pnpm_workspace = root.join("pnpm-workspace.yaml");
343 if pnpm_workspace.exists()
344 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
345 {
346 patterns.extend(parse_pnpm_workspace_yaml(&content));
347 }
348
349 Ok(patterns)
350}
351
352fn expand_patterns_to_workspaces(
357 root: &Path,
358 patterns: &[String],
359 canonical_root: &Path,
360 ignore_patterns: &globset::GlobSet,
361 diagnostics: &mut Vec<WorkspaceDiagnostic>,
362) -> Vec<(WorkspaceInfo, Vec<String>)> {
363 if patterns.is_empty() {
364 return Vec::new();
365 }
366
367 let mut workspaces = Vec::new();
368
369 let (positive, negative): (Vec<&String>, Vec<&String>) =
373 patterns.iter().partition(|p| !p.starts_with('!'));
374 let negation_matchers: Vec<globset::GlobMatcher> = negative
375 .iter()
376 .filter_map(|p| {
377 let stripped = p.strip_prefix('!').unwrap_or(p);
378 globset::Glob::new(stripped)
379 .ok()
380 .map(|g| g.compile_matcher())
381 })
382 .collect();
383
384 for pattern in &positive {
385 let glob_pattern = if pattern.ends_with('/') {
390 format!("{pattern}*")
391 } else if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('{') {
392 (*pattern).clone()
394 } else {
395 (*pattern).clone()
396 };
397
398 let matched_dirs = expand_workspace_glob_with_diagnostics(
403 root,
404 pattern,
405 &glob_pattern,
406 canonical_root,
407 ignore_patterns,
408 diagnostics,
409 );
410 for (dir, canonical_dir) in matched_dirs {
411 if canonical_dir == *canonical_root {
414 continue;
415 }
416
417 let relative = dir.strip_prefix(root).unwrap_or(&dir);
420 let relative_str = relative.to_string_lossy();
421 if negation_matchers
422 .iter()
423 .any(|m| m.is_match(relative_str.as_ref()))
424 {
425 continue;
426 }
427
428 let ws_pkg_path = dir.join("package.json");
434 match PackageJson::load(&ws_pkg_path) {
435 Ok(pkg) => {
436 let dep_names = pkg.all_dependency_names();
437 let name = pkg.name.unwrap_or_else(|| {
438 dir.file_name()
439 .map(|n| n.to_string_lossy().to_string())
440 .unwrap_or_default()
441 });
442 workspaces.push((
443 WorkspaceInfo {
444 root: dir,
445 name,
446 is_internal_dependency: false,
447 },
448 dep_names,
449 ));
450 }
451 Err(error) => {
452 let diag = WorkspaceDiagnostic::new(
453 root,
454 dir.clone(),
455 WorkspaceDiagnosticKind::MalformedPackageJson { error },
456 );
457 diagnostics.push(diag);
458 }
459 }
460 }
461 }
462
463 workspaces
464}
465
466fn collect_tsconfig_workspaces(
471 root: &Path,
472 canonical_root: &Path,
473 ignore_patterns: &globset::GlobSet,
474 diagnostics: &mut Vec<WorkspaceDiagnostic>,
475) -> Vec<(WorkspaceInfo, Vec<String>)> {
476 let mut workspaces = Vec::new();
477
478 for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
479 let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
480 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
482 continue;
483 }
484
485 let ws_pkg_path = dir.join("package.json");
490 let (name, dep_names) = if ws_pkg_path.exists() {
491 match PackageJson::load(&ws_pkg_path) {
492 Ok(pkg) => {
493 let deps = pkg.all_dependency_names();
494 let n = pkg.name.unwrap_or_else(|| dir_name(&dir));
495 (n, deps)
496 }
497 Err(error) => {
498 let diag = WorkspaceDiagnostic::new(
499 root,
500 dir.clone(),
501 WorkspaceDiagnosticKind::MalformedPackageJson { error },
502 );
503 diagnostics.push(diag);
504 (dir_name(&dir), Vec::new())
505 }
506 }
507 } else {
508 (dir_name(&dir), Vec::new())
512 };
513
514 workspaces.push((
515 WorkspaceInfo {
516 root: dir,
517 name,
518 is_internal_dependency: false,
519 },
520 dep_names,
521 ));
522 }
523
524 workspaces
525}
526
527fn collect_shallow_package_workspaces(
534 root: &Path,
535 canonical_root: &Path,
536) -> Vec<(WorkspaceInfo, Vec<String>)> {
537 let mut workspaces = Vec::new();
538 let Ok(top_entries) = std::fs::read_dir(root) else {
539 return workspaces;
540 };
541
542 for entry in top_entries.filter_map(Result::ok) {
543 let path = entry.path();
544 if !path.is_dir() || should_skip_workspace_scan_dir(&entry.file_name().to_string_lossy()) {
545 continue;
546 }
547
548 collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces);
549
550 let Ok(child_entries) = std::fs::read_dir(&path) else {
551 continue;
552 };
553 for child in child_entries.filter_map(Result::ok) {
554 let child_path = child.path();
555 if !child_path.is_dir()
556 || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
557 {
558 continue;
559 }
560
561 collect_shallow_workspace_candidate(&child_path, canonical_root, &mut workspaces);
562 }
563 }
564
565 workspaces
566}
567
568fn collect_shallow_workspace_candidate(
569 dir: &Path,
570 canonical_root: &Path,
571 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
572) {
573 let pkg_path = dir.join("package.json");
574 if !pkg_path.exists() {
575 return;
576 }
577
578 let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
579 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
580 return;
581 }
582
583 let Ok(pkg) = PackageJson::load(&pkg_path) else {
584 return;
585 };
586 let dep_names = pkg.all_dependency_names();
587 let name = pkg.name.unwrap_or_else(|| dir_name(dir));
588
589 workspaces.push((
590 WorkspaceInfo {
591 root: dir.to_path_buf(),
592 name,
593 is_internal_dependency: false,
594 },
595 dep_names,
596 ));
597}
598
599fn should_skip_workspace_scan_dir(name: &str) -> bool {
600 is_skip_listed_dir(name)
606}
607
608fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
614 {
616 let mut seen = rustc_hash::FxHashSet::default();
617 workspaces.retain(|(ws, _)| {
618 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
619 seen.insert(canonical)
620 });
621 }
622
623 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
627 .iter()
628 .flat_map(|(_, deps)| deps.iter().cloned())
629 .collect();
630 for (ws, _) in &mut *workspaces {
631 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
632 }
633}
634
635fn dir_name(dir: &Path) -> String {
637 dir.file_name()
638 .map(|n| n.to_string_lossy().to_string())
639 .unwrap_or_default()
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn discover_workspaces_from_tsconfig_references() {
648 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
649 let _ = std::fs::remove_dir_all(&temp_dir);
650 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
651 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
652
653 std::fs::write(
655 temp_dir.join("tsconfig.json"),
656 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
657 )
658 .unwrap();
659
660 std::fs::write(
662 temp_dir.join("packages/core/package.json"),
663 r#"{"name": "@project/core"}"#,
664 )
665 .unwrap();
666
667 let workspaces = discover_workspaces(&temp_dir);
669 assert_eq!(workspaces.len(), 2);
670 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
671 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
672
673 let _ = std::fs::remove_dir_all(&temp_dir);
674 }
675
676 #[test]
677 fn tsconfig_references_outside_root_rejected() {
678 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
679 let _ = std::fs::remove_dir_all(&temp_dir);
680 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
681 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
683
684 std::fs::write(
685 temp_dir.join("project/tsconfig.json"),
686 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
687 )
688 .unwrap();
689
690 let workspaces = discover_workspaces(&temp_dir.join("project"));
692 assert_eq!(
693 workspaces.len(),
694 1,
695 "reference outside project root should be rejected: {workspaces:?}"
696 );
697 assert!(
698 workspaces[0]
699 .root
700 .to_string_lossy()
701 .contains("packages/core")
702 );
703
704 let _ = std::fs::remove_dir_all(&temp_dir);
705 }
706
707 #[test]
710 fn dir_name_extracts_last_component() {
711 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
712 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
713 }
714
715 #[test]
716 fn dir_name_empty_for_root_path() {
717 assert_eq!(dir_name(Path::new("/")), "");
719 }
720
721 #[test]
724 fn workspace_config_deserialize_json() {
725 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
726 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
727 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
728 }
729
730 #[test]
731 fn workspace_config_deserialize_empty_patterns() {
732 let json = r#"{"patterns": []}"#;
733 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
734 assert!(config.patterns.is_empty());
735 }
736
737 #[test]
738 fn workspace_config_default_patterns() {
739 let json = "{}";
740 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
741 assert!(config.patterns.is_empty());
742 }
743
744 #[test]
747 fn workspace_info_default_not_internal() {
748 let ws = WorkspaceInfo {
749 root: PathBuf::from("/project/packages/a"),
750 name: "a".to_string(),
751 is_internal_dependency: false,
752 };
753 assert!(!ws.is_internal_dependency);
754 }
755
756 #[test]
759 fn mark_internal_deps_detects_cross_references() {
760 let temp_dir = tempfile::tempdir().expect("create temp dir");
761 let pkg_a = temp_dir.path().join("a");
762 let pkg_b = temp_dir.path().join("b");
763 std::fs::create_dir_all(&pkg_a).unwrap();
764 std::fs::create_dir_all(&pkg_b).unwrap();
765
766 let mut workspaces = vec![
767 (
768 WorkspaceInfo {
769 root: pkg_a,
770 name: "@scope/a".to_string(),
771 is_internal_dependency: false,
772 },
773 vec!["@scope/b".to_string()], ),
775 (
776 WorkspaceInfo {
777 root: pkg_b,
778 name: "@scope/b".to_string(),
779 is_internal_dependency: false,
780 },
781 vec!["lodash".to_string()], ),
783 ];
784
785 mark_internal_dependencies(&mut workspaces);
786
787 let ws_a = workspaces
789 .iter()
790 .find(|(ws, _)| ws.name == "@scope/a")
791 .unwrap();
792 assert!(
793 !ws_a.0.is_internal_dependency,
794 "a is not depended on by others"
795 );
796
797 let ws_b = workspaces
798 .iter()
799 .find(|(ws, _)| ws.name == "@scope/b")
800 .unwrap();
801 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
802 }
803
804 #[test]
805 fn mark_internal_deps_no_cross_references() {
806 let temp_dir = tempfile::tempdir().expect("create temp dir");
807 let pkg_a = temp_dir.path().join("a");
808 let pkg_b = temp_dir.path().join("b");
809 std::fs::create_dir_all(&pkg_a).unwrap();
810 std::fs::create_dir_all(&pkg_b).unwrap();
811
812 let mut workspaces = vec![
813 (
814 WorkspaceInfo {
815 root: pkg_a,
816 name: "a".to_string(),
817 is_internal_dependency: false,
818 },
819 vec!["react".to_string()],
820 ),
821 (
822 WorkspaceInfo {
823 root: pkg_b,
824 name: "b".to_string(),
825 is_internal_dependency: false,
826 },
827 vec!["lodash".to_string()],
828 ),
829 ];
830
831 mark_internal_dependencies(&mut workspaces);
832
833 assert!(!workspaces[0].0.is_internal_dependency);
834 assert!(!workspaces[1].0.is_internal_dependency);
835 }
836
837 #[test]
838 fn mark_internal_deps_deduplicates_by_path() {
839 let temp_dir = tempfile::tempdir().expect("create temp dir");
840 let pkg_a = temp_dir.path().join("a");
841 std::fs::create_dir_all(&pkg_a).unwrap();
842
843 let mut workspaces = vec![
844 (
845 WorkspaceInfo {
846 root: pkg_a.clone(),
847 name: "a".to_string(),
848 is_internal_dependency: false,
849 },
850 vec![],
851 ),
852 (
853 WorkspaceInfo {
854 root: pkg_a,
855 name: "a".to_string(),
856 is_internal_dependency: false,
857 },
858 vec![],
859 ),
860 ];
861
862 mark_internal_dependencies(&mut workspaces);
863 assert_eq!(
864 workspaces.len(),
865 1,
866 "duplicate paths should be deduplicated"
867 );
868 }
869
870 #[test]
873 fn collect_patterns_from_package_json() {
874 let dir = tempfile::tempdir().expect("create temp dir");
875 std::fs::write(
876 dir.path().join("package.json"),
877 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
878 )
879 .unwrap();
880
881 let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
882 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
883 }
884
885 #[test]
886 fn collect_patterns_from_pnpm_workspace() {
887 let dir = tempfile::tempdir().expect("create temp dir");
888 std::fs::write(
889 dir.path().join("pnpm-workspace.yaml"),
890 "packages:\n - 'packages/*'\n - 'libs/*'\n",
891 )
892 .unwrap();
893
894 let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
895 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
896 }
897
898 #[test]
899 fn collect_patterns_combines_sources() {
900 let dir = tempfile::tempdir().expect("create temp dir");
901 std::fs::write(
902 dir.path().join("package.json"),
903 r#"{"workspaces": ["packages/*"]}"#,
904 )
905 .unwrap();
906 std::fs::write(
907 dir.path().join("pnpm-workspace.yaml"),
908 "packages:\n - 'apps/*'\n",
909 )
910 .unwrap();
911
912 let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
913 assert!(patterns.contains(&"packages/*".to_string()));
914 assert!(patterns.contains(&"apps/*".to_string()));
915 }
916
917 #[test]
918 fn collect_patterns_empty_when_no_configs() {
919 let dir = tempfile::tempdir().expect("create temp dir");
920 let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
921 assert!(patterns.is_empty());
922 }
923
924 #[test]
927 fn discover_workspaces_from_package_json() {
928 let dir = tempfile::tempdir().expect("create temp dir");
929 let pkg_a = dir.path().join("packages").join("a");
930 let pkg_b = dir.path().join("packages").join("b");
931 std::fs::create_dir_all(&pkg_a).unwrap();
932 std::fs::create_dir_all(&pkg_b).unwrap();
933
934 std::fs::write(
935 dir.path().join("package.json"),
936 r#"{"workspaces": ["packages/*"]}"#,
937 )
938 .unwrap();
939 std::fs::write(
940 pkg_a.join("package.json"),
941 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
942 )
943 .unwrap();
944 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
945
946 let workspaces = discover_workspaces(dir.path());
947 assert_eq!(workspaces.len(), 2);
948
949 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
950 assert!(!ws_a.is_internal_dependency);
951
952 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
953 assert!(ws_b.is_internal_dependency, "b is depended on by a");
954 }
955
956 #[test]
957 fn discover_workspaces_empty_project() {
958 let dir = tempfile::tempdir().expect("create temp dir");
959 let workspaces = discover_workspaces(dir.path());
960 assert!(workspaces.is_empty());
961 }
962
963 #[test]
964 fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
965 let dir = tempfile::tempdir().expect("create temp dir");
966 let benchmarks = dir.path().join("benchmarks");
967 let vscode = dir.path().join("editors").join("vscode");
968 let deep = dir.path().join("tests").join("fixtures").join("demo");
969 std::fs::create_dir_all(&benchmarks).unwrap();
970 std::fs::create_dir_all(&vscode).unwrap();
971 std::fs::create_dir_all(&deep).unwrap();
972
973 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
974 std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
975 std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
976
977 let workspaces = discover_workspaces(dir.path());
978 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
979
980 assert!(
981 names.contains(&"benchmarks"),
982 "top-level nested package should be discovered: {workspaces:?}"
983 );
984 assert!(
985 names.contains(&"fallow-vscode"),
986 "second-level nested package should be discovered: {workspaces:?}"
987 );
988 assert!(
989 !names.contains(&"deep-fixture"),
990 "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
991 );
992 }
993
994 #[test]
995 fn discover_workspaces_with_negated_patterns() {
996 let dir = tempfile::tempdir().expect("create temp dir");
997 let pkg_a = dir.path().join("packages").join("a");
998 let pkg_test = dir.path().join("packages").join("test-utils");
999 std::fs::create_dir_all(&pkg_a).unwrap();
1000 std::fs::create_dir_all(&pkg_test).unwrap();
1001
1002 std::fs::write(
1003 dir.path().join("package.json"),
1004 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1005 )
1006 .unwrap();
1007 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1008 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1009
1010 let workspaces = discover_workspaces(dir.path());
1011 assert_eq!(workspaces.len(), 1);
1012 assert_eq!(workspaces[0].name, "a");
1013 }
1014
1015 #[test]
1016 fn discover_workspaces_skips_root_as_workspace() {
1017 let dir = tempfile::tempdir().expect("create temp dir");
1018 std::fs::write(
1020 dir.path().join("pnpm-workspace.yaml"),
1021 "packages:\n - '.'\n",
1022 )
1023 .unwrap();
1024 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1025
1026 let workspaces = discover_workspaces(dir.path());
1027 assert!(
1028 workspaces.is_empty(),
1029 "root directory should not be added as workspace"
1030 );
1031 }
1032
1033 #[test]
1034 fn discover_workspaces_name_fallback_to_dir_name() {
1035 let dir = tempfile::tempdir().expect("create temp dir");
1036 let pkg_a = dir.path().join("packages").join("my-app");
1037 std::fs::create_dir_all(&pkg_a).unwrap();
1038
1039 std::fs::write(
1040 dir.path().join("package.json"),
1041 r#"{"workspaces": ["packages/*"]}"#,
1042 )
1043 .unwrap();
1044 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1046
1047 let workspaces = discover_workspaces(dir.path());
1048 assert_eq!(workspaces.len(), 1);
1049 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1050 }
1051
1052 #[test]
1053 fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1054 let dir = tempfile::tempdir().expect("create temp dir");
1055 let pkg_a = dir.path().join("packages").join("a");
1056 let benchmarks = dir.path().join("benchmarks");
1057 std::fs::create_dir_all(&pkg_a).unwrap();
1058 std::fs::create_dir_all(&benchmarks).unwrap();
1059
1060 std::fs::write(
1061 dir.path().join("package.json"),
1062 r#"{"workspaces": ["packages/*"]}"#,
1063 )
1064 .unwrap();
1065 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1066 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1067
1068 let workspaces = discover_workspaces(dir.path());
1069 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1070
1071 assert_eq!(workspaces.len(), 1);
1072 assert!(names.contains(&"a"));
1073 assert!(
1074 !names.contains(&"benchmarks"),
1075 "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1076 );
1077 }
1078
1079 #[test]
1082 fn undeclared_workspace_detected() {
1083 let dir = tempfile::tempdir().expect("create temp dir");
1084 let pkg_a = dir.path().join("packages").join("a");
1085 let pkg_b = dir.path().join("packages").join("b");
1086 std::fs::create_dir_all(&pkg_a).unwrap();
1087 std::fs::create_dir_all(&pkg_b).unwrap();
1088
1089 std::fs::write(
1091 dir.path().join("package.json"),
1092 r#"{"workspaces": ["packages/a"]}"#,
1093 )
1094 .unwrap();
1095 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1096 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1097
1098 let declared = discover_workspaces(dir.path());
1099 assert_eq!(declared.len(), 1);
1100
1101 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1102 assert_eq!(undeclared.len(), 1);
1103 assert!(
1104 undeclared[0]
1105 .path
1106 .to_string_lossy()
1107 .replace('\\', "/")
1108 .contains("packages/b"),
1109 "should detect packages/b as undeclared: {:?}",
1110 undeclared[0].path
1111 );
1112 }
1113
1114 #[test]
1115 fn no_undeclared_when_all_covered() {
1116 let dir = tempfile::tempdir().expect("create temp dir");
1117 let pkg_a = dir.path().join("packages").join("a");
1118 std::fs::create_dir_all(&pkg_a).unwrap();
1119
1120 std::fs::write(
1121 dir.path().join("package.json"),
1122 r#"{"workspaces": ["packages/*"]}"#,
1123 )
1124 .unwrap();
1125 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1126
1127 let declared = discover_workspaces(dir.path());
1128 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1129 assert!(undeclared.is_empty());
1130 }
1131
1132 #[test]
1133 fn no_undeclared_when_no_workspace_patterns() {
1134 let dir = tempfile::tempdir().expect("create temp dir");
1135 let sub = dir.path().join("lib");
1136 std::fs::create_dir_all(&sub).unwrap();
1137
1138 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1140 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1141
1142 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1143 assert!(
1144 undeclared.is_empty(),
1145 "should skip check when no workspace patterns exist"
1146 );
1147 }
1148
1149 #[test]
1150 fn undeclared_skips_node_modules_and_hidden_dirs() {
1151 let dir = tempfile::tempdir().expect("create temp dir");
1152 let nm = dir.path().join("node_modules").join("some-pkg");
1153 let hidden = dir.path().join(".hidden");
1154 std::fs::create_dir_all(&nm).unwrap();
1155 std::fs::create_dir_all(&hidden).unwrap();
1156
1157 std::fs::write(
1158 dir.path().join("package.json"),
1159 r#"{"workspaces": ["packages/*"]}"#,
1160 )
1161 .unwrap();
1162 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1164 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1165
1166 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1167 assert!(
1168 undeclared.is_empty(),
1169 "should not flag node_modules or hidden directories"
1170 );
1171 }
1172
1173 fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1174 let mut builder = globset::GlobSetBuilder::new();
1175 for pattern in patterns {
1176 builder.add(globset::Glob::new(pattern).expect("valid glob"));
1177 }
1178 builder.build().expect("build globset")
1179 }
1180
1181 #[test]
1182 fn undeclared_skips_dirs_matching_ignore_patterns() {
1183 let dir = tempfile::tempdir().expect("create temp dir");
1186 let pkg_a = dir.path().join("packages").join("a");
1187 let vitest_ref = dir.path().join("references").join("vitest");
1188 let tanstack_ref = dir.path().join("references").join("tanstack-router");
1189 std::fs::create_dir_all(&pkg_a).unwrap();
1190 std::fs::create_dir_all(&vitest_ref).unwrap();
1191 std::fs::create_dir_all(&tanstack_ref).unwrap();
1192
1193 std::fs::write(
1194 dir.path().join("package.json"),
1195 r#"{"workspaces": ["packages/*"]}"#,
1196 )
1197 .unwrap();
1198 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1199 std::fs::write(
1200 vitest_ref.join("package.json"),
1201 r#"{"name": "vitest-reference"}"#,
1202 )
1203 .unwrap();
1204 std::fs::write(
1205 tanstack_ref.join("package.json"),
1206 r#"{"name": "tanstack-reference"}"#,
1207 )
1208 .unwrap();
1209
1210 let declared = discover_workspaces(dir.path());
1211 let ignore = build_globset(&["references/*"]);
1212 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1213 assert!(
1214 undeclared.is_empty(),
1215 "references/* should be ignored: {undeclared:?}"
1216 );
1217 }
1218
1219 #[test]
1220 fn undeclared_still_reported_when_ignore_does_not_match() {
1221 let dir = tempfile::tempdir().expect("create temp dir");
1222 let pkg_b = dir.path().join("packages").join("b");
1223 std::fs::create_dir_all(&pkg_b).unwrap();
1224
1225 std::fs::write(
1226 dir.path().join("package.json"),
1227 r#"{"workspaces": ["packages/a"]}"#,
1228 )
1229 .unwrap();
1230 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1231
1232 let declared = discover_workspaces(dir.path());
1233 let ignore = build_globset(&["references/*"]);
1235 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1236 assert_eq!(
1237 undeclared.len(),
1238 1,
1239 "non-matching ignore patterns should not silence other undeclared dirs"
1240 );
1241 }
1242
1243 #[test]
1244 fn undeclared_skips_dirs_matching_package_json_glob() {
1245 let dir = tempfile::tempdir().expect("create temp dir");
1249 let pkg_a = dir.path().join("packages").join("a");
1250 let vitest_ref = dir.path().join("references").join("vitest");
1251 std::fs::create_dir_all(&pkg_a).unwrap();
1252 std::fs::create_dir_all(&vitest_ref).unwrap();
1253
1254 std::fs::write(
1255 dir.path().join("package.json"),
1256 r#"{"workspaces": ["packages/*"]}"#,
1257 )
1258 .unwrap();
1259 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1260 std::fs::write(
1261 vitest_ref.join("package.json"),
1262 r#"{"name": "vitest-reference"}"#,
1263 )
1264 .unwrap();
1265
1266 let declared = discover_workspaces(dir.path());
1267 let ignore = build_globset(&["references/*/package.json"]);
1268 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1269 assert!(
1270 undeclared.is_empty(),
1271 "package.json-suffixed glob should silence the warning: {undeclared:?}"
1272 );
1273 }
1274
1275 #[test]
1276 fn undeclared_skips_dirs_matching_doublestar_ignore() {
1277 let dir = tempfile::tempdir().expect("create temp dir");
1279 let pkg_a = dir.path().join("packages").join("a");
1280 let nested_ref = dir.path().join("references").join("vitest");
1281 std::fs::create_dir_all(&pkg_a).unwrap();
1282 std::fs::create_dir_all(&nested_ref).unwrap();
1283
1284 std::fs::write(
1285 dir.path().join("package.json"),
1286 r#"{"workspaces": ["packages/*"]}"#,
1287 )
1288 .unwrap();
1289 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1290 std::fs::write(
1291 nested_ref.join("package.json"),
1292 r#"{"name": "vitest-reference"}"#,
1293 )
1294 .unwrap();
1295
1296 let declared = discover_workspaces(dir.path());
1297 let ignore = build_globset(&["**/references/**"]);
1298 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1299 assert!(
1300 undeclared.is_empty(),
1301 "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1302 );
1303 }
1304
1305 #[test]
1308 fn malformed_workspace_package_json_emits_diagnostic() {
1309 let dir = tempfile::tempdir().expect("create temp dir");
1310 let pkg_a = dir.path().join("packages").join("a");
1311 let pkg_bad = dir.path().join("packages").join("bad");
1312 std::fs::create_dir_all(&pkg_a).unwrap();
1313 std::fs::create_dir_all(&pkg_bad).unwrap();
1314 std::fs::write(
1315 dir.path().join("package.json"),
1316 r#"{"workspaces": ["packages/*"]}"#,
1317 )
1318 .unwrap();
1319 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1320 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1322
1323 let (result, captured) = capture_workspace_warnings(|| {
1324 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1325 });
1326 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1327
1328 assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1329 assert_eq!(workspaces[0].name, "a");
1330 assert_eq!(diagnostics.len(), 1);
1331 assert!(matches!(
1332 diagnostics[0].kind,
1333 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1334 ));
1335 assert!(
1336 captured
1337 .iter()
1338 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1339 );
1340 }
1341
1342 #[test]
1343 fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1344 let dir = tempfile::tempdir().expect("create temp dir");
1345 for name in ["a", "b", "c"] {
1346 let pkg = dir.path().join("packages").join(name);
1347 std::fs::create_dir_all(&pkg).unwrap();
1348 std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1349 }
1350 std::fs::write(
1351 dir.path().join("package.json"),
1352 r#"{"workspaces": ["packages/*"]}"#,
1353 )
1354 .unwrap();
1355
1356 let (result, _) = capture_workspace_warnings(|| {
1357 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1358 });
1359 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1360
1361 assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1362 assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1363 assert!(
1364 diagnostics
1365 .iter()
1366 .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1367 "every diagnostic should be malformed-package-json"
1368 );
1369 }
1370
1371 #[test]
1372 fn malformed_root_package_json_returns_load_error() {
1373 let dir = tempfile::tempdir().expect("create temp dir");
1374 std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1375
1376 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1377
1378 match result {
1379 Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1380 assert!(path.ends_with("package.json"));
1381 assert!(!error.is_empty(), "underlying parse error is preserved");
1382 }
1383 Ok(_) => panic!("expected MalformedRootPackageJson"),
1384 }
1385 }
1386
1387 #[test]
1388 fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1389 let dir = tempfile::tempdir().expect("create temp dir");
1390 let pkg_a = dir.path().join("packages").join("a");
1391 let cache_dir = dir.path().join("packages").join(".cache");
1392 let scratch_dir = dir.path().join("packages").join("scratch");
1393 std::fs::create_dir_all(&pkg_a).unwrap();
1394 std::fs::create_dir_all(&cache_dir).unwrap();
1395 std::fs::create_dir_all(&scratch_dir).unwrap();
1396 std::fs::write(
1397 dir.path().join("package.json"),
1398 r#"{"workspaces": ["packages/*"]}"#,
1399 )
1400 .unwrap();
1401 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1402 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1405 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1406
1407 assert_eq!(workspaces.len(), 1);
1408 let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1411 assert!(
1412 kinds.contains(&"glob-matched-no-package-json"),
1413 "scratch should diagnose: {kinds:?}"
1414 );
1415 assert!(
1416 !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1417 ".cache must be skip-listed: {diagnostics:?}"
1418 );
1419 }
1420
1421 #[test]
1422 fn glob_match_without_package_json_honors_ignore_patterns() {
1423 let dir = tempfile::tempdir().expect("create temp dir");
1424 let pkg_a = dir.path().join("packages").join("a");
1425 let legacy_dir = dir.path().join("packages").join("legacy");
1426 std::fs::create_dir_all(&pkg_a).unwrap();
1427 std::fs::create_dir_all(&legacy_dir).unwrap();
1428 std::fs::write(
1429 dir.path().join("package.json"),
1430 r#"{"workspaces": ["packages/*"]}"#,
1431 )
1432 .unwrap();
1433 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1434
1435 let mut builder = globset::GlobSetBuilder::new();
1436 builder.add(globset::Glob::new("packages/legacy").unwrap());
1437 let ignore = builder.build().unwrap();
1438
1439 let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1440 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1441
1442 assert_eq!(workspaces.len(), 1);
1443 assert!(
1444 diagnostics.is_empty(),
1445 "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1446 );
1447 }
1448
1449 #[test]
1450 fn malformed_tsconfig_emits_diagnostic() {
1451 let dir = tempfile::tempdir().expect("create temp dir");
1452 std::fs::write(
1453 dir.path().join("package.json"),
1454 r#"{"workspaces": ["packages/*"]}"#,
1455 )
1456 .unwrap();
1457 std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1460
1461 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1462 let (_, diagnostics) = result.expect("root package.json is valid");
1463
1464 assert!(
1465 diagnostics
1466 .iter()
1467 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1468 "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1469 );
1470 }
1471
1472 #[test]
1473 fn tsconfig_missing_reference_dir_emits_diagnostic() {
1474 let dir = tempfile::tempdir().expect("create temp dir");
1475 std::fs::write(
1476 dir.path().join("tsconfig.json"),
1477 r#"{"references": [{"path": "./packages/missing"}]}"#,
1478 )
1479 .unwrap();
1480
1481 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1482 let (_, diagnostics) = result.expect("no package.json at root is OK");
1483
1484 assert!(
1485 diagnostics
1486 .iter()
1487 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1488 "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1489 );
1490 }
1491
1492 #[test]
1493 fn missing_tsconfig_is_silent() {
1494 let dir = tempfile::tempdir().expect("create temp dir");
1495 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1498 let (_, diagnostics) = result.expect("no root package.json is OK");
1499
1500 assert!(
1501 !diagnostics
1502 .iter()
1503 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1504 "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1505 );
1506 }
1507
1508 #[test]
1509 fn shallow_scan_malformed_package_json_stays_silent() {
1510 let dir = tempfile::tempdir().expect("create temp dir");
1515 let scratch = dir.path().join("scratch");
1516 std::fs::create_dir_all(&scratch).unwrap();
1517 std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1518
1519 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1520 let (_, diagnostics) = result.expect("no root package.json is OK");
1521
1522 assert!(
1523 !diagnostics
1524 .iter()
1525 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1526 "shallow-scan malformed must stay silent: {diagnostics:?}"
1527 );
1528 }
1529
1530 #[test]
1531 fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1532 let dir = tempfile::tempdir().expect("create temp dir");
1533 let pkg_good = dir.path().join("packages").join("good");
1534 let pkg_bad = dir.path().join("packages").join("bad");
1535 std::fs::create_dir_all(&pkg_good).unwrap();
1536 std::fs::create_dir_all(&pkg_bad).unwrap();
1537 std::fs::write(
1538 dir.path().join("package.json"),
1539 r#"{"workspaces": ["packages/*"]}"#,
1540 )
1541 .unwrap();
1542 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1543 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1544
1545 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1546 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1547
1548 assert_eq!(workspaces.len(), 1);
1549 assert_eq!(workspaces[0].name, "good");
1550 assert_eq!(diagnostics.len(), 1);
1551 assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1552 }
1553
1554 #[test]
1555 fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1556 let dir = tempfile::tempdir().expect("create temp dir");
1560 std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1561
1562 let workspaces = discover_workspaces(dir.path());
1563 assert!(
1564 workspaces.is_empty(),
1565 "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1566 );
1567 }
1568}