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