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 rustc_hash::FxHashMap;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15pub use deno_json::{
16 DirManifestProbe, dir_has_deno_json, dir_has_package_manifest, is_deno_without_node_modules,
17 load_deno_import_map, load_dir_package_json, load_member_package_manifest,
18 load_root_deno_workspace_patterns, probe_dir_manifest,
19};
20#[cfg(test)]
21pub use diagnostics::capture_workspace_warnings;
22pub use diagnostics::{
23 WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
24 clear_analysis_stage_diagnostics, record_source_read_failures, record_workspace_diagnostics,
25 registry_diagnostics_to_fold, replace_source_discovery_diagnostics,
26 stash_workspace_diagnostics, workspace_diagnostics_for,
27};
28use diagnostics::{emit_diagnostics, is_skip_listed_dir};
29pub use npm_overrides::{parse_bun_package_json_resolutions, parse_npm_package_json_overrides};
30pub use package_json::{NapiConfig, PackageJson};
31pub use parsers::parse_tsconfig_root_dir;
32use parsers::{
33 expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
34 parse_tsconfig_references_with_diagnostics,
35};
36pub use pnpm_catalog::{
37 PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup,
38 parse_package_json_catalog_data, parse_pnpm_catalog_data,
39};
40pub use pnpm_overrides::{
41 MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
42 is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
43 parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
44};
45
46#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
48pub struct WorkspaceConfig {
49 #[serde(default, alias = "packages")]
56 pub patterns: Vec<String>,
57}
58
59#[derive(Debug, Clone)]
62pub struct WorkspaceInfo {
63 pub root: PathBuf,
65 pub name: String,
67 pub is_internal_dependency: bool,
69}
70
71#[must_use]
77pub fn workspace_is_public(name: &str, public_packages: &[String]) -> bool {
78 public_packages.iter().any(|pattern| {
79 name == pattern
80 || globset::Glob::new(pattern)
81 .ok()
82 .is_some_and(|glob| glob.compile_matcher().is_match(name))
83 })
84}
85
86#[must_use]
108pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
109 collect_workspaces_and_diagnostics(root, &globset::GlobSet::empty())
110 .map(|(workspaces, _)| workspaces)
111 .unwrap_or_default()
112}
113
114pub fn discover_workspaces_with_diagnostics(
144 root: &Path,
145 ignore_patterns: &globset::GlobSet,
146) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
147 let (workspaces, diagnostics) = collect_workspaces_and_diagnostics(root, ignore_patterns)?;
148
149 emit_diagnostics(root, &diagnostics);
150
151 Ok((workspaces, diagnostics))
152}
153
154fn collect_workspaces_and_diagnostics(
176 root: &Path,
177 ignore_patterns: &globset::GlobSet,
178) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
179 let mut diagnostics = Vec::new();
180 let patterns = collect_workspace_patterns(root)?;
181 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
182 let mut manifest_cache = ManifestCache::default();
183
184 let mut workspaces = expand_patterns_to_workspaces(
185 root,
186 &patterns,
187 &canonical_root,
188 ignore_patterns,
189 &mut diagnostics,
190 &mut manifest_cache,
191 );
192 workspaces.extend(collect_tsconfig_workspaces(
193 root,
194 &canonical_root,
195 ignore_patterns,
196 &mut diagnostics,
197 &mut manifest_cache,
198 ));
199 if patterns.is_empty() {
200 workspaces.extend(collect_shallow_package_workspaces(
201 root,
202 &canonical_root,
203 &mut manifest_cache,
204 ));
205 }
206
207 if !workspaces.is_empty() {
208 mark_internal_dependencies(&mut workspaces);
209 }
210 let workspaces = workspaces.into_iter().map(|(ws, _)| ws).collect();
211 Ok((
212 workspaces,
213 fallow_types::workspace::dedupe_workspace_diagnostics(diagnostics),
214 ))
215}
216
217#[must_use]
223pub fn find_undeclared_workspaces(
224 root: &Path,
225 declared: &[WorkspaceInfo],
226) -> Vec<WorkspaceDiagnostic> {
227 find_undeclared_workspaces_with_ignores(root, declared, &globset::GlobSet::empty())
228}
229
230#[must_use]
240pub fn find_undeclared_workspaces_with_ignores(
241 root: &Path,
242 declared: &[WorkspaceInfo],
243 ignore_patterns: &globset::GlobSet,
244) -> Vec<WorkspaceDiagnostic> {
245 let patterns = collect_workspace_patterns(root).unwrap_or_default();
246 if patterns.is_empty() {
247 return Vec::new();
248 }
249
250 let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
251 .iter()
252 .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
253 .collect();
254
255 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
256
257 let mut undeclared = Vec::new();
258
259 let Ok(top_entries) = std::fs::read_dir(root) else {
260 return Vec::new();
261 };
262
263 for entry in top_entries.filter_map(Result::ok) {
264 let path = entry.path();
265 if !path.is_dir() || is_undeclared_scan_skip_dir(&entry.file_name().to_string_lossy()) {
266 continue;
267 }
268
269 let mut input = UndeclaredScanInput {
270 root,
271 canonical_root: &canonical_root,
272 declared_roots: &declared_roots,
273 ignore_patterns,
274 undeclared: &mut undeclared,
275 };
276 check_undeclared(
277 &path,
278 input.root,
279 input.canonical_root,
280 input.declared_roots,
281 input.ignore_patterns,
282 input.undeclared,
283 );
284 scan_child_dirs_for_undeclared(&path, &mut input);
285 }
286
287 undeclared
288}
289
290fn is_undeclared_scan_skip_dir(name: &str) -> bool {
292 name.starts_with('.') || name == "node_modules" || name == "build"
293}
294
295struct UndeclaredScanInput<'a> {
297 root: &'a Path,
298 canonical_root: &'a Path,
299 declared_roots: &'a rustc_hash::FxHashSet<PathBuf>,
300 ignore_patterns: &'a globset::GlobSet,
301 undeclared: &'a mut Vec<WorkspaceDiagnostic>,
302}
303
304fn scan_child_dirs_for_undeclared(parent: &Path, input: &mut UndeclaredScanInput<'_>) {
306 let Ok(child_entries) = std::fs::read_dir(parent) else {
307 return;
308 };
309 for child in child_entries.filter_map(Result::ok) {
310 let child_path = child.path();
311 if !child_path.is_dir() || is_undeclared_scan_skip_dir(&child.file_name().to_string_lossy())
312 {
313 continue;
314 }
315 check_undeclared(
316 &child_path,
317 input.root,
318 input.canonical_root,
319 input.declared_roots,
320 input.ignore_patterns,
321 input.undeclared,
322 );
323 }
324}
325
326fn check_undeclared(
328 dir: &Path,
329 root: &Path,
330 canonical_root: &Path,
331 declared_roots: &rustc_hash::FxHashSet<PathBuf>,
332 ignore_patterns: &globset::GlobSet,
333 undeclared: &mut Vec<WorkspaceDiagnostic>,
334) {
335 if !dir_has_package_manifest(dir) {
336 return;
337 }
338 let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
339 if canonical == *canonical_root {
340 return;
341 }
342 if declared_roots.contains(&canonical) {
343 return;
344 }
345 let relative = dir.strip_prefix(root).unwrap_or(dir);
346 let relative_str = relative.to_string_lossy().replace('\\', "/");
347 if ignore_patterns.is_match(relative_str.as_str())
348 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
349 || ignore_patterns.is_match(format!("{relative_str}/deno.json").as_str())
350 || ignore_patterns.is_match(format!("{relative_str}/deno.jsonc").as_str())
351 {
352 return;
353 }
354 undeclared.push(WorkspaceDiagnostic::new(
355 root,
356 dir.to_path_buf(),
357 WorkspaceDiagnosticKind::UndeclaredWorkspace,
358 ));
359}
360
361fn collect_workspace_patterns(root: &Path) -> Result<Vec<String>, WorkspaceLoadError> {
364 let mut patterns = Vec::new();
365
366 let pkg_path = root.join("package.json");
367 if pkg_path.exists() {
368 match PackageJson::load(&pkg_path) {
369 Ok(pkg) => patterns.extend(pkg.workspace_patterns()),
370 Err(error) => {
371 return Err(WorkspaceLoadError::MalformedRootPackageJson {
372 path: pkg_path,
373 error,
374 });
375 }
376 }
377 }
378
379 let pnpm_workspace = root.join("pnpm-workspace.yaml");
380 if pnpm_workspace.exists()
381 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
382 {
383 patterns.extend(parse_pnpm_workspace_yaml(&content));
384 }
385
386 if let Some((_path, deno_patterns)) = load_root_deno_workspace_patterns(root)
387 .map_err(|(path, error)| WorkspaceLoadError::MalformedRootDenoConfig { path, error })?
388 {
389 patterns.extend(deno_patterns);
390 }
391
392 Ok(patterns)
393}
394
395type ManifestCache = FxHashMap<PathBuf, Result<Option<(String, PackageJson, Vec<String>)>, String>>;
403
404fn load_member_package_manifest_cached(
407 dir: &Path,
408 cache: &mut ManifestCache,
409) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
410 if let Some(cached) = cache.get(dir) {
411 return cached.clone();
412 }
413 let outcome = load_member_package_manifest(dir);
414 cache.insert(dir.to_path_buf(), outcome.clone());
415 outcome
416}
417
418fn expand_patterns_to_workspaces(
423 root: &Path,
424 patterns: &[String],
425 canonical_root: &Path,
426 ignore_patterns: &globset::GlobSet,
427 diagnostics: &mut Vec<WorkspaceDiagnostic>,
428 manifest_cache: &mut ManifestCache,
429) -> Vec<(WorkspaceInfo, Vec<String>)> {
430 if patterns.is_empty() {
431 return Vec::new();
432 }
433
434 let mut workspaces = Vec::new();
435
436 let (positive, negative): (Vec<&String>, Vec<&String>) =
437 patterns.iter().partition(|p| !p.starts_with('!'));
438 let negation_matchers: Vec<globset::GlobMatcher> = negative
439 .iter()
440 .filter_map(|p| {
441 let stripped = p.strip_prefix('!').unwrap_or(p);
442 globset::Glob::new(stripped)
443 .ok()
444 .map(|g| g.compile_matcher())
445 })
446 .collect();
447
448 for pattern in &positive {
449 let glob_pattern = if pattern.ends_with('/') {
450 format!("{pattern}*")
451 } else {
452 (*pattern).clone()
453 };
454
455 let matched_dirs = expand_workspace_glob_with_diagnostics(
456 root,
457 pattern,
458 &glob_pattern,
459 canonical_root,
460 ignore_patterns,
461 diagnostics,
462 );
463 for (dir, canonical_dir) in matched_dirs {
464 if canonical_dir == *canonical_root {
465 continue;
466 }
467 if matches_negation(root, &dir, &negation_matchers) {
468 continue;
469 }
470 register_matched_workspace(root, dir, &mut workspaces, diagnostics, manifest_cache);
471 }
472 }
473
474 workspaces
475}
476
477fn matches_negation(root: &Path, dir: &Path, negation_matchers: &[globset::GlobMatcher]) -> bool {
479 let relative = dir.strip_prefix(root).unwrap_or(dir);
480 let relative_str = relative.to_string_lossy();
481 negation_matchers
482 .iter()
483 .any(|m| m.is_match(relative_str.as_ref()))
484}
485
486fn register_matched_workspace(
490 root: &Path,
491 dir: PathBuf,
492 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
493 diagnostics: &mut Vec<WorkspaceDiagnostic>,
494 manifest_cache: &mut ManifestCache,
495) {
496 match load_member_package_manifest_cached(&dir, manifest_cache) {
497 Ok(Some((name, _pkg, dep_names))) => {
498 workspaces.push((
499 WorkspaceInfo {
500 root: dir,
501 name,
502 is_internal_dependency: false,
503 },
504 dep_names,
505 ));
506 }
507 Ok(None) => {}
508 Err(error) => {
509 let diag = WorkspaceDiagnostic::new(
510 root,
511 dir,
512 WorkspaceDiagnosticKind::MalformedPackageJson { error },
513 );
514 diagnostics.push(diag);
515 }
516 }
517}
518
519fn collect_tsconfig_workspaces(
524 root: &Path,
525 canonical_root: &Path,
526 ignore_patterns: &globset::GlobSet,
527 diagnostics: &mut Vec<WorkspaceDiagnostic>,
528 manifest_cache: &mut ManifestCache,
529) -> Vec<(WorkspaceInfo, Vec<String>)> {
530 let mut workspaces = Vec::new();
531
532 for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
533 if let Some(workspace) =
534 tsconfig_workspace_from_dir(root, dir, canonical_root, diagnostics, manifest_cache)
535 {
536 workspaces.push(workspace);
537 }
538 }
539
540 workspaces
541}
542
543fn tsconfig_workspace_from_dir(
544 root: &Path,
545 dir: PathBuf,
546 canonical_root: &Path,
547 diagnostics: &mut Vec<WorkspaceDiagnostic>,
548 manifest_cache: &mut ManifestCache,
549) -> Option<(WorkspaceInfo, Vec<String>)> {
550 let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
551 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
552 return None;
553 }
554
555 let (name, dep_names) =
556 load_tsconfig_workspace_package(root, &dir, diagnostics, manifest_cache);
557 Some((
558 WorkspaceInfo {
559 root: dir,
560 name,
561 is_internal_dependency: false,
562 },
563 dep_names,
564 ))
565}
566
567fn load_tsconfig_workspace_package(
568 root: &Path,
569 dir: &Path,
570 diagnostics: &mut Vec<WorkspaceDiagnostic>,
571 manifest_cache: &mut ManifestCache,
572) -> (String, Vec<String>) {
573 match load_member_package_manifest_cached(dir, manifest_cache) {
574 Ok(Some((name, _pkg, deps))) => (name, deps),
575 Ok(None) => (dir_name(dir), Vec::new()),
576 Err(error) => {
577 let diag = WorkspaceDiagnostic::new(
578 root,
579 dir.to_path_buf(),
580 WorkspaceDiagnosticKind::MalformedPackageJson { error },
581 );
582 diagnostics.push(diag);
583 (dir_name(dir), Vec::new())
584 }
585 }
586}
587
588fn collect_shallow_package_workspaces(
595 root: &Path,
596 canonical_root: &Path,
597 manifest_cache: &mut ManifestCache,
598) -> Vec<(WorkspaceInfo, Vec<String>)> {
599 let mut workspaces = Vec::new();
600 let Ok(top_entries) = std::fs::read_dir(root) else {
601 return workspaces;
602 };
603
604 for entry in top_entries.filter_map(Result::ok) {
605 let path = entry.path();
606 if !path.is_dir() || is_skip_listed_dir(&entry.file_name().to_string_lossy()) {
607 continue;
608 }
609
610 collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces, manifest_cache);
611 collect_shallow_child_workspaces(&path, canonical_root, &mut workspaces, manifest_cache);
612 }
613
614 workspaces
615}
616
617fn collect_shallow_child_workspaces(
618 parent: &Path,
619 canonical_root: &Path,
620 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
621 manifest_cache: &mut ManifestCache,
622) {
623 let Ok(child_entries) = std::fs::read_dir(parent) else {
624 return;
625 };
626 for child in child_entries.filter_map(Result::ok) {
627 let child_path = child.path();
628 if !child_path.is_dir() || is_skip_listed_dir(&child.file_name().to_string_lossy()) {
629 continue;
630 }
631
632 collect_shallow_workspace_candidate(
633 &child_path,
634 canonical_root,
635 workspaces,
636 manifest_cache,
637 );
638 }
639}
640
641fn collect_shallow_workspace_candidate(
642 dir: &Path,
643 canonical_root: &Path,
644 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
645 manifest_cache: &mut ManifestCache,
646) {
647 let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
648 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
649 return;
650 }
651
652 let Ok(Some((name, _pkg, dep_names))) =
653 load_member_package_manifest_cached(dir, manifest_cache)
654 else {
655 return;
656 };
657
658 workspaces.push((
659 WorkspaceInfo {
660 root: dir.to_path_buf(),
661 name,
662 is_internal_dependency: false,
663 },
664 dep_names,
665 ));
666}
667
668fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
674 {
675 let mut seen = rustc_hash::FxHashSet::default();
676 workspaces.retain(|(ws, _)| {
677 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
678 seen.insert(canonical)
679 });
680 }
681
682 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
683 .iter()
684 .flat_map(|(_, deps)| deps.iter().cloned())
685 .collect();
686
687 for (ws, _) in &mut *workspaces {
688 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
689 }
690}
691
692fn dir_name(dir: &Path) -> String {
694 dir.file_name()
695 .map(|n| n.to_string_lossy().to_string())
696 .unwrap_or_default()
697}
698
699#[cfg(test)]
706pub fn write_two_manifest_glob_project(root: &Path) {
707 std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
708 std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
709 std::fs::write(
710 root.join("package.json"),
711 r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
712 )
713 .unwrap();
714 std::fs::write(
715 root.join("pnpm-workspace.yaml"),
716 "packages:\n - \"pkgs/*\"\n",
717 )
718 .unwrap();
719 std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
720 std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 #[test]
728 fn public_workspace_selection_supports_exact_names_and_globs() {
729 assert!(!workspace_is_public("@scope/core", &[]));
730 assert!(workspace_is_public(
731 "@scope/core",
732 &["@scope/core".to_string()]
733 ));
734 assert!(workspace_is_public(
735 "@scope/core",
736 &["@scope/*".to_string()]
737 ));
738 assert!(!workspace_is_public(
739 "@scope/core",
740 &["[invalid".to_string(), "@other/*".to_string()]
741 ));
742 }
743
744 #[test]
745 fn discover_workspaces_from_tsconfig_references() {
746 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
747 let _ = std::fs::remove_dir_all(&temp_dir);
748 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
749 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
750
751 std::fs::write(
752 temp_dir.join("tsconfig.json"),
753 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
754 )
755 .unwrap();
756
757 std::fs::write(
758 temp_dir.join("packages/core/package.json"),
759 r#"{"name": "@project/core"}"#,
760 )
761 .unwrap();
762
763 let workspaces = discover_workspaces(&temp_dir);
764 assert_eq!(workspaces.len(), 2);
765 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
766 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
767
768 let _ = std::fs::remove_dir_all(&temp_dir);
769 }
770
771 #[test]
772 fn tsconfig_references_outside_root_rejected() {
773 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
774 let _ = std::fs::remove_dir_all(&temp_dir);
775 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
776 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
777
778 std::fs::write(
779 temp_dir.join("project/tsconfig.json"),
780 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
781 )
782 .unwrap();
783
784 let workspaces = discover_workspaces(&temp_dir.join("project"));
785 assert_eq!(
786 workspaces.len(),
787 1,
788 "reference outside project root should be rejected: {workspaces:?}"
789 );
790 assert!(
791 workspaces[0]
792 .root
793 .to_string_lossy()
794 .contains("packages/core")
795 );
796
797 let _ = std::fs::remove_dir_all(&temp_dir);
798 }
799
800 #[test]
801 fn dir_name_extracts_last_component() {
802 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
803 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
804 }
805
806 #[test]
807 fn dir_name_empty_for_root_path() {
808 assert_eq!(dir_name(Path::new("/")), "");
809 }
810
811 #[test]
812 fn workspace_config_deserialize_json() {
813 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
814 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
815 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
816 }
817
818 #[test]
819 fn workspace_config_deserialize_empty_patterns() {
820 let json = r#"{"patterns": []}"#;
821 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
822 assert!(config.patterns.is_empty());
823 }
824
825 #[test]
826 fn workspace_config_default_patterns() {
827 let json = "{}";
828 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
829 assert!(config.patterns.is_empty());
830 }
831
832 #[test]
833 fn workspace_info_default_not_internal() {
834 let ws = WorkspaceInfo {
835 root: PathBuf::from("/project/packages/a"),
836 name: "a".to_string(),
837 is_internal_dependency: false,
838 };
839 assert!(!ws.is_internal_dependency);
840 }
841
842 #[test]
843 fn mark_internal_deps_detects_cross_references() {
844 let temp_dir = tempfile::tempdir().expect("create temp dir");
845 let pkg_a = temp_dir.path().join("a");
846 let pkg_b = temp_dir.path().join("b");
847 std::fs::create_dir_all(&pkg_a).unwrap();
848 std::fs::create_dir_all(&pkg_b).unwrap();
849
850 let mut workspaces = vec![
851 (
852 WorkspaceInfo {
853 root: pkg_a,
854 name: "@scope/a".to_string(),
855 is_internal_dependency: false,
856 },
857 vec!["@scope/b".to_string()], ),
859 (
860 WorkspaceInfo {
861 root: pkg_b,
862 name: "@scope/b".to_string(),
863 is_internal_dependency: false,
864 },
865 vec!["lodash".to_string()], ),
867 ];
868
869 mark_internal_dependencies(&mut workspaces);
870
871 let ws_a = workspaces
872 .iter()
873 .find(|(ws, _)| ws.name == "@scope/a")
874 .unwrap();
875 assert!(
876 !ws_a.0.is_internal_dependency,
877 "a is not depended on by others"
878 );
879
880 let ws_b = workspaces
881 .iter()
882 .find(|(ws, _)| ws.name == "@scope/b")
883 .unwrap();
884 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
885 }
886
887 #[test]
888 fn mark_internal_deps_no_cross_references() {
889 let temp_dir = tempfile::tempdir().expect("create temp dir");
890 let pkg_a = temp_dir.path().join("a");
891 let pkg_b = temp_dir.path().join("b");
892 std::fs::create_dir_all(&pkg_a).unwrap();
893 std::fs::create_dir_all(&pkg_b).unwrap();
894
895 let mut workspaces = vec![
896 (
897 WorkspaceInfo {
898 root: pkg_a,
899 name: "a".to_string(),
900 is_internal_dependency: false,
901 },
902 vec!["react".to_string()],
903 ),
904 (
905 WorkspaceInfo {
906 root: pkg_b,
907 name: "b".to_string(),
908 is_internal_dependency: false,
909 },
910 vec!["lodash".to_string()],
911 ),
912 ];
913
914 mark_internal_dependencies(&mut workspaces);
915
916 assert!(!workspaces[0].0.is_internal_dependency);
917 assert!(!workspaces[1].0.is_internal_dependency);
918 }
919
920 #[test]
921 fn mark_internal_deps_deduplicates_by_path() {
922 let temp_dir = tempfile::tempdir().expect("create temp dir");
923 let pkg_a = temp_dir.path().join("a");
924 std::fs::create_dir_all(&pkg_a).unwrap();
925
926 let mut workspaces = vec![
927 (
928 WorkspaceInfo {
929 root: pkg_a.clone(),
930 name: "a".to_string(),
931 is_internal_dependency: false,
932 },
933 vec![],
934 ),
935 (
936 WorkspaceInfo {
937 root: pkg_a,
938 name: "a".to_string(),
939 is_internal_dependency: false,
940 },
941 vec![],
942 ),
943 ];
944
945 mark_internal_dependencies(&mut workspaces);
946 assert_eq!(
947 workspaces.len(),
948 1,
949 "duplicate paths should be deduplicated"
950 );
951 }
952
953 #[test]
954 fn collect_patterns_from_package_json() {
955 let dir = tempfile::tempdir().expect("create temp dir");
956 std::fs::write(
957 dir.path().join("package.json"),
958 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
959 )
960 .unwrap();
961
962 let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
963 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
964 }
965
966 #[test]
967 fn collect_patterns_from_pnpm_workspace() {
968 let dir = tempfile::tempdir().expect("create temp dir");
969 std::fs::write(
970 dir.path().join("pnpm-workspace.yaml"),
971 "packages:\n - 'packages/*'\n - 'libs/*'\n",
972 )
973 .unwrap();
974
975 let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
976 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
977 }
978
979 #[test]
980 fn collect_patterns_combines_sources() {
981 let dir = tempfile::tempdir().expect("create temp dir");
982 std::fs::write(
983 dir.path().join("package.json"),
984 r#"{"workspaces": ["packages/*"]}"#,
985 )
986 .unwrap();
987 std::fs::write(
988 dir.path().join("pnpm-workspace.yaml"),
989 "packages:\n - 'apps/*'\n",
990 )
991 .unwrap();
992
993 let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
994 assert!(patterns.contains(&"packages/*".to_string()));
995 assert!(patterns.contains(&"apps/*".to_string()));
996 }
997
998 #[test]
999 fn collect_patterns_empty_when_no_configs() {
1000 let dir = tempfile::tempdir().expect("create temp dir");
1001 let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
1002 assert!(patterns.is_empty());
1003 }
1004
1005 #[test]
1006 fn discover_workspaces_from_package_json() {
1007 let dir = tempfile::tempdir().expect("create temp dir");
1008 let pkg_a = dir.path().join("packages").join("a");
1009 let pkg_b = dir.path().join("packages").join("b");
1010 std::fs::create_dir_all(&pkg_a).unwrap();
1011 std::fs::create_dir_all(&pkg_b).unwrap();
1012
1013 std::fs::write(
1014 dir.path().join("package.json"),
1015 r#"{"workspaces": ["packages/*"]}"#,
1016 )
1017 .unwrap();
1018 std::fs::write(
1019 pkg_a.join("package.json"),
1020 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1021 )
1022 .unwrap();
1023 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1024
1025 let workspaces = discover_workspaces(dir.path());
1026 assert_eq!(workspaces.len(), 2);
1027
1028 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1029 assert!(!ws_a.is_internal_dependency);
1030
1031 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1032 assert!(ws_b.is_internal_dependency, "b is depended on by a");
1033 }
1034
1035 #[test]
1036 fn discover_workspaces_empty_project() {
1037 let dir = tempfile::tempdir().expect("create temp dir");
1038 let workspaces = discover_workspaces(dir.path());
1039 assert!(workspaces.is_empty());
1040 }
1041
1042 #[test]
1043 fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1044 let dir = tempfile::tempdir().expect("create temp dir");
1045 let benchmarks = dir.path().join("benchmarks");
1046 let vscode = dir.path().join("editors").join("vscode");
1047 let deep = dir.path().join("tests").join("fixtures").join("demo");
1048 std::fs::create_dir_all(&benchmarks).unwrap();
1049 std::fs::create_dir_all(&vscode).unwrap();
1050 std::fs::create_dir_all(&deep).unwrap();
1051
1052 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1053 std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1054 std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1055
1056 let workspaces = discover_workspaces(dir.path());
1057 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1058
1059 assert!(
1060 names.contains(&"benchmarks"),
1061 "top-level nested package should be discovered: {workspaces:?}"
1062 );
1063 assert!(
1064 names.contains(&"fallow-vscode"),
1065 "second-level nested package should be discovered: {workspaces:?}"
1066 );
1067 assert!(
1068 !names.contains(&"deep-fixture"),
1069 "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1070 );
1071 }
1072
1073 #[test]
1074 fn discover_workspaces_with_negated_patterns() {
1075 let dir = tempfile::tempdir().expect("create temp dir");
1076 let pkg_a = dir.path().join("packages").join("a");
1077 let pkg_test = dir.path().join("packages").join("test-utils");
1078 std::fs::create_dir_all(&pkg_a).unwrap();
1079 std::fs::create_dir_all(&pkg_test).unwrap();
1080
1081 std::fs::write(
1082 dir.path().join("package.json"),
1083 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1084 )
1085 .unwrap();
1086 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1087 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1088
1089 let workspaces = discover_workspaces(dir.path());
1090 assert_eq!(workspaces.len(), 1);
1091 assert_eq!(workspaces[0].name, "a");
1092 }
1093
1094 #[test]
1095 fn discover_workspaces_skips_root_as_workspace() {
1096 let dir = tempfile::tempdir().expect("create temp dir");
1097 std::fs::write(
1098 dir.path().join("pnpm-workspace.yaml"),
1099 "packages:\n - '.'\n",
1100 )
1101 .unwrap();
1102 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1103
1104 let workspaces = discover_workspaces(dir.path());
1105 assert!(
1106 workspaces.is_empty(),
1107 "root directory should not be added as workspace"
1108 );
1109 }
1110
1111 #[test]
1112 fn discover_workspaces_name_fallback_to_dir_name() {
1113 let dir = tempfile::tempdir().expect("create temp dir");
1114 let pkg_a = dir.path().join("packages").join("my-app");
1115 std::fs::create_dir_all(&pkg_a).unwrap();
1116
1117 std::fs::write(
1118 dir.path().join("package.json"),
1119 r#"{"workspaces": ["packages/*"]}"#,
1120 )
1121 .unwrap();
1122 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1123
1124 let workspaces = discover_workspaces(dir.path());
1125 assert_eq!(workspaces.len(), 1);
1126 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1127 }
1128
1129 #[test]
1130 fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1131 let dir = tempfile::tempdir().expect("create temp dir");
1132 let pkg_a = dir.path().join("packages").join("a");
1133 let benchmarks = dir.path().join("benchmarks");
1134 std::fs::create_dir_all(&pkg_a).unwrap();
1135 std::fs::create_dir_all(&benchmarks).unwrap();
1136
1137 std::fs::write(
1138 dir.path().join("package.json"),
1139 r#"{"workspaces": ["packages/*"]}"#,
1140 )
1141 .unwrap();
1142 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1143 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1144
1145 let workspaces = discover_workspaces(dir.path());
1146 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1147
1148 assert_eq!(workspaces.len(), 1);
1149 assert!(names.contains(&"a"));
1150 assert!(
1151 !names.contains(&"benchmarks"),
1152 "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1153 );
1154 }
1155
1156 #[test]
1157 fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1158 let dir = tempfile::tempdir().expect("create temp dir");
1166 let theme = dir
1167 .path()
1168 .join("packages")
1169 .join("themes")
1170 .join("metrists-theme-next");
1171 std::fs::create_dir_all(&theme).unwrap();
1172 std::fs::write(
1173 dir.path().join("package.json"),
1174 r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1175 )
1176 .unwrap();
1177 std::fs::write(
1179 theme.join("package.json"),
1180 r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1181 )
1182 .unwrap();
1183
1184 let workspaces = discover_workspaces(dir.path());
1185 assert!(
1186 workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1187 "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1188 );
1189 }
1190
1191 #[test]
1192 fn undeclared_workspace_detected() {
1193 let dir = tempfile::tempdir().expect("create temp dir");
1194 let pkg_a = dir.path().join("packages").join("a");
1195 let pkg_b = dir.path().join("packages").join("b");
1196 std::fs::create_dir_all(&pkg_a).unwrap();
1197 std::fs::create_dir_all(&pkg_b).unwrap();
1198
1199 std::fs::write(
1200 dir.path().join("package.json"),
1201 r#"{"workspaces": ["packages/a"]}"#,
1202 )
1203 .unwrap();
1204 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1205 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1206
1207 let declared = discover_workspaces(dir.path());
1208 assert_eq!(declared.len(), 1);
1209
1210 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1211 assert_eq!(undeclared.len(), 1);
1212 assert!(
1213 undeclared[0]
1214 .path
1215 .to_string_lossy()
1216 .replace('\\', "/")
1217 .contains("packages/b"),
1218 "should detect packages/b as undeclared: {:?}",
1219 undeclared[0].path
1220 );
1221 }
1222
1223 #[test]
1224 fn no_undeclared_when_all_covered() {
1225 let dir = tempfile::tempdir().expect("create temp dir");
1226 let pkg_a = dir.path().join("packages").join("a");
1227 std::fs::create_dir_all(&pkg_a).unwrap();
1228
1229 std::fs::write(
1230 dir.path().join("package.json"),
1231 r#"{"workspaces": ["packages/*"]}"#,
1232 )
1233 .unwrap();
1234 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1235
1236 let declared = discover_workspaces(dir.path());
1237 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1238 assert!(undeclared.is_empty());
1239 }
1240
1241 #[test]
1242 fn no_undeclared_when_no_workspace_patterns() {
1243 let dir = tempfile::tempdir().expect("create temp dir");
1244 let sub = dir.path().join("lib");
1245 std::fs::create_dir_all(&sub).unwrap();
1246
1247 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1248 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1249
1250 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1251 assert!(
1252 undeclared.is_empty(),
1253 "should skip check when no workspace patterns exist"
1254 );
1255 }
1256
1257 #[test]
1258 fn undeclared_skips_node_modules_and_hidden_dirs() {
1259 let dir = tempfile::tempdir().expect("create temp dir");
1260 let nm = dir.path().join("node_modules").join("some-pkg");
1261 let hidden = dir.path().join(".hidden");
1262 std::fs::create_dir_all(&nm).unwrap();
1263 std::fs::create_dir_all(&hidden).unwrap();
1264
1265 std::fs::write(
1266 dir.path().join("package.json"),
1267 r#"{"workspaces": ["packages/*"]}"#,
1268 )
1269 .unwrap();
1270 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1271 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1272
1273 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1274 assert!(
1275 undeclared.is_empty(),
1276 "should not flag node_modules or hidden directories"
1277 );
1278 }
1279
1280 fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1281 let mut builder = globset::GlobSetBuilder::new();
1282 for pattern in patterns {
1283 builder.add(globset::Glob::new(pattern).expect("valid glob"));
1284 }
1285 builder.build().expect("build globset")
1286 }
1287
1288 #[test]
1289 fn undeclared_skips_dirs_matching_ignore_patterns() {
1290 let dir = tempfile::tempdir().expect("create temp dir");
1291 let pkg_a = dir.path().join("packages").join("a");
1292 let vitest_ref = dir.path().join("references").join("vitest");
1293 let tanstack_ref = dir.path().join("references").join("tanstack-router");
1294 std::fs::create_dir_all(&pkg_a).unwrap();
1295 std::fs::create_dir_all(&vitest_ref).unwrap();
1296 std::fs::create_dir_all(&tanstack_ref).unwrap();
1297
1298 std::fs::write(
1299 dir.path().join("package.json"),
1300 r#"{"workspaces": ["packages/*"]}"#,
1301 )
1302 .unwrap();
1303 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1304 std::fs::write(
1305 vitest_ref.join("package.json"),
1306 r#"{"name": "vitest-reference"}"#,
1307 )
1308 .unwrap();
1309 std::fs::write(
1310 tanstack_ref.join("package.json"),
1311 r#"{"name": "tanstack-reference"}"#,
1312 )
1313 .unwrap();
1314
1315 let declared = discover_workspaces(dir.path());
1316 let ignore = build_globset(&["references/*"]);
1317 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1318 assert!(
1319 undeclared.is_empty(),
1320 "references/* should be ignored: {undeclared:?}"
1321 );
1322 }
1323
1324 #[test]
1325 fn undeclared_still_reported_when_ignore_does_not_match() {
1326 let dir = tempfile::tempdir().expect("create temp dir");
1327 let pkg_b = dir.path().join("packages").join("b");
1328 std::fs::create_dir_all(&pkg_b).unwrap();
1329
1330 std::fs::write(
1331 dir.path().join("package.json"),
1332 r#"{"workspaces": ["packages/a"]}"#,
1333 )
1334 .unwrap();
1335 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1336
1337 let declared = discover_workspaces(dir.path());
1338 let ignore = build_globset(&["references/*"]);
1339 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1340 assert_eq!(
1341 undeclared.len(),
1342 1,
1343 "non-matching ignore patterns should not silence other undeclared dirs"
1344 );
1345 }
1346
1347 #[test]
1348 fn undeclared_skips_dirs_matching_package_json_glob() {
1349 let dir = tempfile::tempdir().expect("create temp dir");
1350 let pkg_a = dir.path().join("packages").join("a");
1351 let vitest_ref = dir.path().join("references").join("vitest");
1352 std::fs::create_dir_all(&pkg_a).unwrap();
1353 std::fs::create_dir_all(&vitest_ref).unwrap();
1354
1355 std::fs::write(
1356 dir.path().join("package.json"),
1357 r#"{"workspaces": ["packages/*"]}"#,
1358 )
1359 .unwrap();
1360 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1361 std::fs::write(
1362 vitest_ref.join("package.json"),
1363 r#"{"name": "vitest-reference"}"#,
1364 )
1365 .unwrap();
1366
1367 let declared = discover_workspaces(dir.path());
1368 let ignore = build_globset(&["references/*/package.json"]);
1369 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1370 assert!(
1371 undeclared.is_empty(),
1372 "package.json-suffixed glob should silence the warning: {undeclared:?}"
1373 );
1374 }
1375
1376 #[test]
1377 fn undeclared_skips_dirs_matching_doublestar_ignore() {
1378 let dir = tempfile::tempdir().expect("create temp dir");
1379 let pkg_a = dir.path().join("packages").join("a");
1380 let nested_ref = dir.path().join("references").join("vitest");
1381 std::fs::create_dir_all(&pkg_a).unwrap();
1382 std::fs::create_dir_all(&nested_ref).unwrap();
1383
1384 std::fs::write(
1385 dir.path().join("package.json"),
1386 r#"{"workspaces": ["packages/*"]}"#,
1387 )
1388 .unwrap();
1389 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1390 std::fs::write(
1391 nested_ref.join("package.json"),
1392 r#"{"name": "vitest-reference"}"#,
1393 )
1394 .unwrap();
1395
1396 let declared = discover_workspaces(dir.path());
1397 let ignore = build_globset(&["**/references/**"]);
1398 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1399 assert!(
1400 undeclared.is_empty(),
1401 "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1402 );
1403 }
1404
1405 #[test]
1406 fn malformed_workspace_package_json_emits_diagnostic() {
1407 let dir = tempfile::tempdir().expect("create temp dir");
1408 let pkg_a = dir.path().join("packages").join("a");
1409 let pkg_bad = dir.path().join("packages").join("bad");
1410 std::fs::create_dir_all(&pkg_a).unwrap();
1411 std::fs::create_dir_all(&pkg_bad).unwrap();
1412 std::fs::write(
1413 dir.path().join("package.json"),
1414 r#"{"workspaces": ["packages/*"]}"#,
1415 )
1416 .unwrap();
1417 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1418 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1419
1420 let (result, captured) = capture_workspace_warnings(|| {
1421 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1422 });
1423 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1424
1425 assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1426 assert_eq!(workspaces[0].name, "a");
1427 assert_eq!(diagnostics.len(), 1);
1428 assert!(matches!(
1429 diagnostics[0].kind,
1430 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1431 ));
1432 assert!(
1433 captured
1434 .iter()
1435 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1436 );
1437 }
1438
1439 #[test]
1440 fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1441 let dir = tempfile::tempdir().expect("create temp dir");
1442 let pkg_good = dir.path().join("packages/good");
1443 let pkg_bad = dir.path().join("packages/bad");
1444 std::fs::create_dir_all(&pkg_good).unwrap();
1445 std::fs::create_dir_all(&pkg_bad).unwrap();
1446 std::fs::write(
1447 dir.path().join("package.json"),
1448 r#"{"workspaces": ["packages/*"]}"#,
1449 )
1450 .unwrap();
1451 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1452 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad"}"#).unwrap();
1453 std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1454
1455 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1456 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1457
1458 assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1459 assert_eq!(workspaces[0].name, "good");
1460 assert_eq!(diagnostics.len(), 1);
1461 assert!(matches!(
1462 diagnostics[0].kind,
1463 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1464 ));
1465 assert!(diagnostics[0].message.contains("deno.jsonc"));
1466 }
1467
1468 #[test]
1478 fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1479 let dir = tempfile::tempdir().expect("create temp dir");
1480 let pkg_good = dir.path().join("packages").join("good");
1481 let pkg_bad = dir.path().join("packages").join("bad");
1482 std::fs::create_dir_all(&pkg_good).unwrap();
1483 std::fs::create_dir_all(&pkg_bad).unwrap();
1484 std::fs::write(
1485 dir.path().join("package.json"),
1486 r#"{"workspaces": ["packages/*"]}"#,
1487 )
1488 .unwrap();
1489 std::fs::write(
1490 dir.path().join("tsconfig.json"),
1491 r#"{"references": [{"path": "./packages/bad"}]}"#,
1492 )
1493 .unwrap();
1494 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1495 std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1496
1497 let (result, _) = capture_workspace_warnings(|| {
1498 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1499 });
1500 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1501
1502 assert!(
1503 workspaces.iter().any(|w| w.name == "good"),
1504 "valid sibling should still discover: {workspaces:?}"
1505 );
1506 assert!(
1507 workspaces.iter().any(|w| w.name == "bad"),
1508 "tsconfig reference falls back to the directory name on manifest error"
1509 );
1510 let malformed: Vec<_> = diagnostics
1511 .iter()
1512 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1513 .collect();
1514 assert_eq!(
1515 malformed.len(),
1516 1,
1517 "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
1518 );
1519 assert!(
1520 malformed[0].path.ends_with("bad"),
1521 "the diagnostic points at the malformed member"
1522 );
1523 }
1524
1525 #[test]
1536 fn manifest_cache_replays_the_same_err_on_every_visit() {
1537 let dir = tempfile::tempdir().expect("create temp dir");
1538 let member = dir.path().join("packages").join("bad");
1539 std::fs::create_dir_all(&member).unwrap();
1540 std::fs::write(member.join("package.json"), r"{,}").unwrap();
1541
1542 let mut cache = ManifestCache::default();
1543 let first = load_member_package_manifest_cached(&member, &mut cache)
1544 .expect_err("a malformed member manifest is an Err");
1545 assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
1546
1547 std::fs::remove_file(member.join("package.json")).unwrap();
1548 let replayed = load_member_package_manifest_cached(&member, &mut cache)
1549 .expect_err("the memoized Err replays instead of re-reading the directory");
1550 assert_eq!(
1551 replayed, first,
1552 "the replayed outcome is byte-identical to the first, so both \
1553 workspace sources diagnose the same member the same way"
1554 );
1555 }
1556
1557 #[test]
1558 fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1559 let dir = tempfile::tempdir().expect("create temp dir");
1560 for name in ["a", "b", "c"] {
1561 let pkg = dir.path().join("packages").join(name);
1562 std::fs::create_dir_all(&pkg).unwrap();
1563 std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1564 }
1565 std::fs::write(
1566 dir.path().join("package.json"),
1567 r#"{"workspaces": ["packages/*"]}"#,
1568 )
1569 .unwrap();
1570
1571 let (result, _) = capture_workspace_warnings(|| {
1572 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1573 });
1574 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1575
1576 assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1577 assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1578 assert!(
1579 diagnostics
1580 .iter()
1581 .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1582 "every diagnostic should be malformed-package-json"
1583 );
1584 }
1585
1586 #[test]
1587 fn malformed_root_package_json_returns_load_error() {
1588 let dir = tempfile::tempdir().expect("create temp dir");
1589 std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1590
1591 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1592
1593 match result {
1594 Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1595 assert!(path.ends_with("package.json"));
1596 assert!(!error.is_empty(), "underlying parse error is preserved");
1597 }
1598 other => panic!("expected MalformedRootPackageJson, got {other:?}"),
1599 }
1600 }
1601
1602 #[test]
1603 fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1604 let dir = tempfile::tempdir().expect("create temp dir");
1605 let pkg_a = dir.path().join("packages").join("a");
1606 let cache_dir = dir.path().join("packages").join(".cache");
1607 let scratch_dir = dir.path().join("packages").join("scratch");
1608 std::fs::create_dir_all(&pkg_a).unwrap();
1609 std::fs::create_dir_all(&cache_dir).unwrap();
1610 std::fs::create_dir_all(&scratch_dir).unwrap();
1611 std::fs::write(
1612 dir.path().join("package.json"),
1613 r#"{"workspaces": ["packages/*"]}"#,
1614 )
1615 .unwrap();
1616 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1617
1618 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1619 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1620
1621 assert_eq!(workspaces.len(), 1);
1622 let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1623 assert!(
1624 kinds.contains(&"glob-matched-no-package-json"),
1625 "scratch should diagnose: {kinds:?}"
1626 );
1627 assert!(
1628 !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1629 ".cache must be skip-listed: {diagnostics:?}"
1630 );
1631 }
1632
1633 #[test]
1634 fn glob_match_without_package_json_honors_ignore_patterns() {
1635 let dir = tempfile::tempdir().expect("create temp dir");
1636 let pkg_a = dir.path().join("packages").join("a");
1637 let legacy_dir = dir.path().join("packages").join("legacy");
1638 std::fs::create_dir_all(&pkg_a).unwrap();
1639 std::fs::create_dir_all(&legacy_dir).unwrap();
1640 std::fs::write(
1641 dir.path().join("package.json"),
1642 r#"{"workspaces": ["packages/*"]}"#,
1643 )
1644 .unwrap();
1645 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1646
1647 let mut builder = globset::GlobSetBuilder::new();
1648 builder.add(globset::Glob::new("packages/legacy").unwrap());
1649 let ignore = builder.build().unwrap();
1650
1651 let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1652 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1653
1654 assert_eq!(workspaces.len(), 1);
1655 assert!(
1656 diagnostics.is_empty(),
1657 "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1658 );
1659 }
1660
1661 #[test]
1662 fn malformed_tsconfig_emits_diagnostic() {
1663 let dir = tempfile::tempdir().expect("create temp dir");
1664 std::fs::write(
1665 dir.path().join("package.json"),
1666 r#"{"workspaces": ["packages/*"]}"#,
1667 )
1668 .unwrap();
1669 std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1670
1671 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1672 let (_, diagnostics) = result.expect("root package.json is valid");
1673
1674 assert!(
1675 diagnostics
1676 .iter()
1677 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1678 "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1679 );
1680 }
1681
1682 #[test]
1683 fn tsconfig_missing_reference_dir_emits_diagnostic() {
1684 let dir = tempfile::tempdir().expect("create temp dir");
1685 std::fs::write(
1686 dir.path().join("tsconfig.json"),
1687 r#"{"references": [{"path": "./packages/missing"}]}"#,
1688 )
1689 .unwrap();
1690
1691 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1692 let (_, diagnostics) = result.expect("no package.json at root is OK");
1693
1694 assert!(
1695 diagnostics
1696 .iter()
1697 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1698 "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1699 );
1700 }
1701
1702 #[test]
1703 fn missing_tsconfig_is_silent() {
1704 let dir = tempfile::tempdir().expect("create temp dir");
1705
1706 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1707 let (_, diagnostics) = result.expect("no root package.json is OK");
1708
1709 assert!(
1710 !diagnostics
1711 .iter()
1712 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1713 "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1714 );
1715 }
1716
1717 #[test]
1718 fn shallow_scan_malformed_package_json_stays_silent() {
1719 let dir = tempfile::tempdir().expect("create temp dir");
1720 let scratch = dir.path().join("scratch");
1721 std::fs::create_dir_all(&scratch).unwrap();
1722 std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1723
1724 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1725 let (_, diagnostics) = result.expect("no root package.json is OK");
1726
1727 assert!(
1728 !diagnostics
1729 .iter()
1730 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1731 "shallow-scan malformed must stay silent: {diagnostics:?}"
1732 );
1733 }
1734
1735 #[test]
1736 fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1737 let dir = tempfile::tempdir().expect("create temp dir");
1738 let pkg_good = dir.path().join("packages").join("good");
1739 let pkg_bad = dir.path().join("packages").join("bad");
1740 std::fs::create_dir_all(&pkg_good).unwrap();
1741 std::fs::create_dir_all(&pkg_bad).unwrap();
1742 std::fs::write(
1743 dir.path().join("package.json"),
1744 r#"{"workspaces": ["packages/*"]}"#,
1745 )
1746 .unwrap();
1747 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1748 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1749
1750 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1751 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1752
1753 assert_eq!(workspaces.len(), 1);
1754 assert_eq!(workspaces[0].name, "good");
1755 assert_eq!(diagnostics.len(), 1);
1756 assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1757 }
1758
1759 #[test]
1760 fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1761 let dir = tempfile::tempdir().expect("create temp dir");
1762 std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1763
1764 let workspaces = discover_workspaces(dir.path());
1765 assert!(
1766 workspaces.is_empty(),
1767 "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1768 );
1769 }
1770
1771 #[test]
1772 fn discovers_deno_workspace_members_without_package_json() {
1773 let dir = tempfile::tempdir().expect("create temp dir");
1774 let core = dir.path().join("packages").join("core");
1775 let app = dir.path().join("apps").join("desktop");
1776 std::fs::create_dir_all(&core).unwrap();
1777 std::fs::create_dir_all(&app).unwrap();
1778
1779 std::fs::write(
1780 dir.path().join("deno.json"),
1781 r#"{
1782 "workspace": ["./apps/*", "./packages/*"],
1783 "imports": { "@std/assert": "jsr:@std/assert@1" }
1784 }"#,
1785 )
1786 .unwrap();
1787 std::fs::write(
1788 core.join("deno.json"),
1789 r#"{
1790 "name": "@fallow/core",
1791 "exports": { ".": "./mod.ts", "./result": "./result.ts" }
1792 }"#,
1793 )
1794 .unwrap();
1795 std::fs::write(
1796 app.join("deno.json"),
1797 r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
1798 )
1799 .unwrap();
1800
1801 let workspaces = discover_workspaces(dir.path());
1802 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1803 names.sort_unstable();
1804 assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
1805 assert!(
1806 workspaces
1807 .iter()
1808 .all(|w| !w.root.join("package.json").exists())
1809 );
1810 assert!(
1811 workspaces.iter().all(|w| !w.is_internal_dependency),
1812 "Deno packages need dependency evidence before inventory marks them internal"
1813 );
1814 }
1815
1816 #[test]
1817 fn malformed_root_deno_config_returns_load_error() {
1818 let dir = tempfile::tempdir().expect("create temp dir");
1819 std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
1820
1821 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1822
1823 match result {
1824 Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
1825 assert!(path.ends_with("deno.jsonc"));
1826 assert!(!error.is_empty(), "underlying parse error is preserved");
1827 }
1828 other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
1829 }
1830 }
1831
1832 #[test]
1833 fn deno_and_npm_workspace_patterns_are_additive() {
1834 let dir = tempfile::tempdir().expect("create temp dir");
1835 let a = dir.path().join("packages").join("a");
1836 let b = dir.path().join("packages").join("b");
1837 std::fs::create_dir_all(&a).unwrap();
1838 std::fs::create_dir_all(&b).unwrap();
1839
1840 std::fs::write(
1841 dir.path().join("package.json"),
1842 r#"{"workspaces": ["packages/a"]}"#,
1843 )
1844 .unwrap();
1845 std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1846 std::fs::write(
1847 dir.path().join("deno.json"),
1848 r#"{"workspace": ["./packages/b"]}"#,
1849 )
1850 .unwrap();
1851 std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
1852
1853 let workspaces = discover_workspaces(dir.path());
1854 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1855 names.sort_unstable();
1856 assert_eq!(names, vec!["a", "b"]);
1857 }
1858
1859 #[test]
1865 fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
1866 let dir = tempfile::tempdir().expect("create temp dir");
1867 write_two_manifest_glob_project(dir.path());
1868
1869 let (_, diagnostics) =
1870 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1871 .expect("root package.json is valid");
1872
1873 let mut reported: Vec<(String, String)> = diagnostics
1874 .iter()
1875 .filter_map(|diagnostic| match &diagnostic.kind {
1876 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
1877 pattern.clone(),
1878 diagnostic
1879 .path
1880 .strip_prefix(dir.path())
1881 .unwrap_or(&diagnostic.path)
1882 .display()
1883 .to_string()
1884 .replace('\\', "/"),
1885 )),
1886 _ => None,
1887 })
1888 .collect();
1889 reported.sort();
1890
1891 assert_eq!(
1892 reported,
1893 vec![
1894 ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
1895 ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
1896 ],
1897 "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
1898 );
1899 }
1900
1901 #[test]
1905 fn overlapping_globs_still_report_one_directory_once_per_pattern() {
1906 let dir = tempfile::tempdir().expect("create temp dir");
1907 std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
1908 std::fs::write(
1909 dir.path().join("package.json"),
1910 r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
1911 )
1912 .unwrap();
1913 std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
1914
1915 let (_, diagnostics) =
1916 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1917 .expect("root package.json is valid");
1918
1919 let patterns: Vec<&str> = diagnostics
1920 .iter()
1921 .filter_map(|diagnostic| match &diagnostic.kind {
1922 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1923 Some(pattern.as_str())
1924 }
1925 _ => None,
1926 })
1927 .collect();
1928
1929 assert_eq!(
1930 patterns,
1931 ["pkgs/*", "pkgs/a*"],
1932 "distinct patterns are distinct diagnostics: {diagnostics:?}"
1933 );
1934 }
1935}