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