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