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