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