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