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