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