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() || should_skip_workspace_scan_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()
629 || should_skip_workspace_scan_dir(&child.file_name().to_string_lossy())
630 {
631 continue;
632 }
633
634 collect_shallow_workspace_candidate(
635 &child_path,
636 canonical_root,
637 workspaces,
638 manifest_cache,
639 );
640 }
641}
642
643fn collect_shallow_workspace_candidate(
644 dir: &Path,
645 canonical_root: &Path,
646 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
647 manifest_cache: &mut ManifestCache,
648) {
649 let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
650 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
651 return;
652 }
653
654 let Ok(Some((name, _pkg, dep_names))) =
655 load_member_package_manifest_cached(dir, manifest_cache)
656 else {
657 return;
658 };
659
660 workspaces.push((
661 WorkspaceInfo {
662 root: dir.to_path_buf(),
663 name,
664 is_internal_dependency: false,
665 },
666 dep_names,
667 ));
668}
669
670fn should_skip_workspace_scan_dir(name: &str) -> bool {
671 is_skip_listed_dir(name)
672}
673
674fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
680 {
681 let mut seen = rustc_hash::FxHashSet::default();
682 workspaces.retain(|(ws, _)| {
683 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
684 seen.insert(canonical)
685 });
686 }
687
688 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
689 .iter()
690 .flat_map(|(_, deps)| deps.iter().cloned())
691 .collect();
692
693 for (ws, _) in &mut *workspaces {
694 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
695 }
696}
697
698fn dir_name(dir: &Path) -> String {
700 dir.file_name()
701 .map(|n| n.to_string_lossy().to_string())
702 .unwrap_or_default()
703}
704
705#[cfg(test)]
712pub fn write_two_manifest_glob_project(root: &Path) {
713 std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
714 std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
715 std::fs::write(
716 root.join("package.json"),
717 r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
718 )
719 .unwrap();
720 std::fs::write(
721 root.join("pnpm-workspace.yaml"),
722 "packages:\n - \"pkgs/*\"\n",
723 )
724 .unwrap();
725 std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
726 std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn public_workspace_selection_supports_exact_names_and_globs() {
735 assert!(!workspace_is_public("@scope/core", &[]));
736 assert!(workspace_is_public(
737 "@scope/core",
738 &["@scope/core".to_string()]
739 ));
740 assert!(workspace_is_public(
741 "@scope/core",
742 &["@scope/*".to_string()]
743 ));
744 assert!(!workspace_is_public(
745 "@scope/core",
746 &["[invalid".to_string(), "@other/*".to_string()]
747 ));
748 }
749
750 #[test]
751 fn discover_workspaces_from_tsconfig_references() {
752 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
753 let _ = std::fs::remove_dir_all(&temp_dir);
754 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
755 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
756
757 std::fs::write(
758 temp_dir.join("tsconfig.json"),
759 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
760 )
761 .unwrap();
762
763 std::fs::write(
764 temp_dir.join("packages/core/package.json"),
765 r#"{"name": "@project/core"}"#,
766 )
767 .unwrap();
768
769 let workspaces = discover_workspaces(&temp_dir);
770 assert_eq!(workspaces.len(), 2);
771 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
772 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
773
774 let _ = std::fs::remove_dir_all(&temp_dir);
775 }
776
777 #[test]
778 fn tsconfig_references_outside_root_rejected() {
779 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
780 let _ = std::fs::remove_dir_all(&temp_dir);
781 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
782 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
783
784 std::fs::write(
785 temp_dir.join("project/tsconfig.json"),
786 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
787 )
788 .unwrap();
789
790 let workspaces = discover_workspaces(&temp_dir.join("project"));
791 assert_eq!(
792 workspaces.len(),
793 1,
794 "reference outside project root should be rejected: {workspaces:?}"
795 );
796 assert!(
797 workspaces[0]
798 .root
799 .to_string_lossy()
800 .contains("packages/core")
801 );
802
803 let _ = std::fs::remove_dir_all(&temp_dir);
804 }
805
806 #[test]
807 fn dir_name_extracts_last_component() {
808 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
809 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
810 }
811
812 #[test]
813 fn dir_name_empty_for_root_path() {
814 assert_eq!(dir_name(Path::new("/")), "");
815 }
816
817 #[test]
818 fn workspace_config_deserialize_json() {
819 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
820 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
821 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
822 }
823
824 #[test]
825 fn workspace_config_deserialize_empty_patterns() {
826 let json = r#"{"patterns": []}"#;
827 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
828 assert!(config.patterns.is_empty());
829 }
830
831 #[test]
832 fn workspace_config_default_patterns() {
833 let json = "{}";
834 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
835 assert!(config.patterns.is_empty());
836 }
837
838 #[test]
839 fn workspace_info_default_not_internal() {
840 let ws = WorkspaceInfo {
841 root: PathBuf::from("/project/packages/a"),
842 name: "a".to_string(),
843 is_internal_dependency: false,
844 };
845 assert!(!ws.is_internal_dependency);
846 }
847
848 #[test]
849 fn mark_internal_deps_detects_cross_references() {
850 let temp_dir = tempfile::tempdir().expect("create temp dir");
851 let pkg_a = temp_dir.path().join("a");
852 let pkg_b = temp_dir.path().join("b");
853 std::fs::create_dir_all(&pkg_a).unwrap();
854 std::fs::create_dir_all(&pkg_b).unwrap();
855
856 let mut workspaces = vec![
857 (
858 WorkspaceInfo {
859 root: pkg_a,
860 name: "@scope/a".to_string(),
861 is_internal_dependency: false,
862 },
863 vec!["@scope/b".to_string()], ),
865 (
866 WorkspaceInfo {
867 root: pkg_b,
868 name: "@scope/b".to_string(),
869 is_internal_dependency: false,
870 },
871 vec!["lodash".to_string()], ),
873 ];
874
875 mark_internal_dependencies(&mut workspaces);
876
877 let ws_a = workspaces
878 .iter()
879 .find(|(ws, _)| ws.name == "@scope/a")
880 .unwrap();
881 assert!(
882 !ws_a.0.is_internal_dependency,
883 "a is not depended on by others"
884 );
885
886 let ws_b = workspaces
887 .iter()
888 .find(|(ws, _)| ws.name == "@scope/b")
889 .unwrap();
890 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
891 }
892
893 #[test]
894 fn mark_internal_deps_no_cross_references() {
895 let temp_dir = tempfile::tempdir().expect("create temp dir");
896 let pkg_a = temp_dir.path().join("a");
897 let pkg_b = temp_dir.path().join("b");
898 std::fs::create_dir_all(&pkg_a).unwrap();
899 std::fs::create_dir_all(&pkg_b).unwrap();
900
901 let mut workspaces = vec![
902 (
903 WorkspaceInfo {
904 root: pkg_a,
905 name: "a".to_string(),
906 is_internal_dependency: false,
907 },
908 vec!["react".to_string()],
909 ),
910 (
911 WorkspaceInfo {
912 root: pkg_b,
913 name: "b".to_string(),
914 is_internal_dependency: false,
915 },
916 vec!["lodash".to_string()],
917 ),
918 ];
919
920 mark_internal_dependencies(&mut workspaces);
921
922 assert!(!workspaces[0].0.is_internal_dependency);
923 assert!(!workspaces[1].0.is_internal_dependency);
924 }
925
926 #[test]
927 fn mark_internal_deps_deduplicates_by_path() {
928 let temp_dir = tempfile::tempdir().expect("create temp dir");
929 let pkg_a = temp_dir.path().join("a");
930 std::fs::create_dir_all(&pkg_a).unwrap();
931
932 let mut workspaces = vec![
933 (
934 WorkspaceInfo {
935 root: pkg_a.clone(),
936 name: "a".to_string(),
937 is_internal_dependency: false,
938 },
939 vec![],
940 ),
941 (
942 WorkspaceInfo {
943 root: pkg_a,
944 name: "a".to_string(),
945 is_internal_dependency: false,
946 },
947 vec![],
948 ),
949 ];
950
951 mark_internal_dependencies(&mut workspaces);
952 assert_eq!(
953 workspaces.len(),
954 1,
955 "duplicate paths should be deduplicated"
956 );
957 }
958
959 #[test]
960 fn collect_patterns_from_package_json() {
961 let dir = tempfile::tempdir().expect("create temp dir");
962 std::fs::write(
963 dir.path().join("package.json"),
964 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
965 )
966 .unwrap();
967
968 let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
969 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
970 }
971
972 #[test]
973 fn collect_patterns_from_pnpm_workspace() {
974 let dir = tempfile::tempdir().expect("create temp dir");
975 std::fs::write(
976 dir.path().join("pnpm-workspace.yaml"),
977 "packages:\n - 'packages/*'\n - 'libs/*'\n",
978 )
979 .unwrap();
980
981 let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
982 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
983 }
984
985 #[test]
986 fn collect_patterns_combines_sources() {
987 let dir = tempfile::tempdir().expect("create temp dir");
988 std::fs::write(
989 dir.path().join("package.json"),
990 r#"{"workspaces": ["packages/*"]}"#,
991 )
992 .unwrap();
993 std::fs::write(
994 dir.path().join("pnpm-workspace.yaml"),
995 "packages:\n - 'apps/*'\n",
996 )
997 .unwrap();
998
999 let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
1000 assert!(patterns.contains(&"packages/*".to_string()));
1001 assert!(patterns.contains(&"apps/*".to_string()));
1002 }
1003
1004 #[test]
1005 fn collect_patterns_empty_when_no_configs() {
1006 let dir = tempfile::tempdir().expect("create temp dir");
1007 let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
1008 assert!(patterns.is_empty());
1009 }
1010
1011 #[test]
1012 fn discover_workspaces_from_package_json() {
1013 let dir = tempfile::tempdir().expect("create temp dir");
1014 let pkg_a = dir.path().join("packages").join("a");
1015 let pkg_b = dir.path().join("packages").join("b");
1016 std::fs::create_dir_all(&pkg_a).unwrap();
1017 std::fs::create_dir_all(&pkg_b).unwrap();
1018
1019 std::fs::write(
1020 dir.path().join("package.json"),
1021 r#"{"workspaces": ["packages/*"]}"#,
1022 )
1023 .unwrap();
1024 std::fs::write(
1025 pkg_a.join("package.json"),
1026 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1027 )
1028 .unwrap();
1029 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1030
1031 let workspaces = discover_workspaces(dir.path());
1032 assert_eq!(workspaces.len(), 2);
1033
1034 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1035 assert!(!ws_a.is_internal_dependency);
1036
1037 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1038 assert!(ws_b.is_internal_dependency, "b is depended on by a");
1039 }
1040
1041 #[test]
1042 fn discover_workspaces_empty_project() {
1043 let dir = tempfile::tempdir().expect("create temp dir");
1044 let workspaces = discover_workspaces(dir.path());
1045 assert!(workspaces.is_empty());
1046 }
1047
1048 #[test]
1049 fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1050 let dir = tempfile::tempdir().expect("create temp dir");
1051 let benchmarks = dir.path().join("benchmarks");
1052 let vscode = dir.path().join("editors").join("vscode");
1053 let deep = dir.path().join("tests").join("fixtures").join("demo");
1054 std::fs::create_dir_all(&benchmarks).unwrap();
1055 std::fs::create_dir_all(&vscode).unwrap();
1056 std::fs::create_dir_all(&deep).unwrap();
1057
1058 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1059 std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1060 std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1061
1062 let workspaces = discover_workspaces(dir.path());
1063 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1064
1065 assert!(
1066 names.contains(&"benchmarks"),
1067 "top-level nested package should be discovered: {workspaces:?}"
1068 );
1069 assert!(
1070 names.contains(&"fallow-vscode"),
1071 "second-level nested package should be discovered: {workspaces:?}"
1072 );
1073 assert!(
1074 !names.contains(&"deep-fixture"),
1075 "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1076 );
1077 }
1078
1079 #[test]
1080 fn discover_workspaces_with_negated_patterns() {
1081 let dir = tempfile::tempdir().expect("create temp dir");
1082 let pkg_a = dir.path().join("packages").join("a");
1083 let pkg_test = dir.path().join("packages").join("test-utils");
1084 std::fs::create_dir_all(&pkg_a).unwrap();
1085 std::fs::create_dir_all(&pkg_test).unwrap();
1086
1087 std::fs::write(
1088 dir.path().join("package.json"),
1089 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1090 )
1091 .unwrap();
1092 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1093 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1094
1095 let workspaces = discover_workspaces(dir.path());
1096 assert_eq!(workspaces.len(), 1);
1097 assert_eq!(workspaces[0].name, "a");
1098 }
1099
1100 #[test]
1101 fn discover_workspaces_skips_root_as_workspace() {
1102 let dir = tempfile::tempdir().expect("create temp dir");
1103 std::fs::write(
1104 dir.path().join("pnpm-workspace.yaml"),
1105 "packages:\n - '.'\n",
1106 )
1107 .unwrap();
1108 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1109
1110 let workspaces = discover_workspaces(dir.path());
1111 assert!(
1112 workspaces.is_empty(),
1113 "root directory should not be added as workspace"
1114 );
1115 }
1116
1117 #[test]
1118 fn discover_workspaces_name_fallback_to_dir_name() {
1119 let dir = tempfile::tempdir().expect("create temp dir");
1120 let pkg_a = dir.path().join("packages").join("my-app");
1121 std::fs::create_dir_all(&pkg_a).unwrap();
1122
1123 std::fs::write(
1124 dir.path().join("package.json"),
1125 r#"{"workspaces": ["packages/*"]}"#,
1126 )
1127 .unwrap();
1128 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1129
1130 let workspaces = discover_workspaces(dir.path());
1131 assert_eq!(workspaces.len(), 1);
1132 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1133 }
1134
1135 #[test]
1136 fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1137 let dir = tempfile::tempdir().expect("create temp dir");
1138 let pkg_a = dir.path().join("packages").join("a");
1139 let benchmarks = dir.path().join("benchmarks");
1140 std::fs::create_dir_all(&pkg_a).unwrap();
1141 std::fs::create_dir_all(&benchmarks).unwrap();
1142
1143 std::fs::write(
1144 dir.path().join("package.json"),
1145 r#"{"workspaces": ["packages/*"]}"#,
1146 )
1147 .unwrap();
1148 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1149 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1150
1151 let workspaces = discover_workspaces(dir.path());
1152 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1153
1154 assert_eq!(workspaces.len(), 1);
1155 assert!(names.contains(&"a"));
1156 assert!(
1157 !names.contains(&"benchmarks"),
1158 "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1159 );
1160 }
1161
1162 #[test]
1163 fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1164 let dir = tempfile::tempdir().expect("create temp dir");
1172 let theme = dir
1173 .path()
1174 .join("packages")
1175 .join("themes")
1176 .join("metrists-theme-next");
1177 std::fs::create_dir_all(&theme).unwrap();
1178 std::fs::write(
1179 dir.path().join("package.json"),
1180 r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1181 )
1182 .unwrap();
1183 std::fs::write(
1185 theme.join("package.json"),
1186 r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1187 )
1188 .unwrap();
1189
1190 let workspaces = discover_workspaces(dir.path());
1191 assert!(
1192 workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1193 "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1194 );
1195 }
1196
1197 #[test]
1198 fn undeclared_workspace_detected() {
1199 let dir = tempfile::tempdir().expect("create temp dir");
1200 let pkg_a = dir.path().join("packages").join("a");
1201 let pkg_b = dir.path().join("packages").join("b");
1202 std::fs::create_dir_all(&pkg_a).unwrap();
1203 std::fs::create_dir_all(&pkg_b).unwrap();
1204
1205 std::fs::write(
1206 dir.path().join("package.json"),
1207 r#"{"workspaces": ["packages/a"]}"#,
1208 )
1209 .unwrap();
1210 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1211 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1212
1213 let declared = discover_workspaces(dir.path());
1214 assert_eq!(declared.len(), 1);
1215
1216 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1217 assert_eq!(undeclared.len(), 1);
1218 assert!(
1219 undeclared[0]
1220 .path
1221 .to_string_lossy()
1222 .replace('\\', "/")
1223 .contains("packages/b"),
1224 "should detect packages/b as undeclared: {:?}",
1225 undeclared[0].path
1226 );
1227 }
1228
1229 #[test]
1230 fn no_undeclared_when_all_covered() {
1231 let dir = tempfile::tempdir().expect("create temp dir");
1232 let pkg_a = dir.path().join("packages").join("a");
1233 std::fs::create_dir_all(&pkg_a).unwrap();
1234
1235 std::fs::write(
1236 dir.path().join("package.json"),
1237 r#"{"workspaces": ["packages/*"]}"#,
1238 )
1239 .unwrap();
1240 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1241
1242 let declared = discover_workspaces(dir.path());
1243 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1244 assert!(undeclared.is_empty());
1245 }
1246
1247 #[test]
1248 fn no_undeclared_when_no_workspace_patterns() {
1249 let dir = tempfile::tempdir().expect("create temp dir");
1250 let sub = dir.path().join("lib");
1251 std::fs::create_dir_all(&sub).unwrap();
1252
1253 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1254 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1255
1256 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1257 assert!(
1258 undeclared.is_empty(),
1259 "should skip check when no workspace patterns exist"
1260 );
1261 }
1262
1263 #[test]
1264 fn undeclared_skips_node_modules_and_hidden_dirs() {
1265 let dir = tempfile::tempdir().expect("create temp dir");
1266 let nm = dir.path().join("node_modules").join("some-pkg");
1267 let hidden = dir.path().join(".hidden");
1268 std::fs::create_dir_all(&nm).unwrap();
1269 std::fs::create_dir_all(&hidden).unwrap();
1270
1271 std::fs::write(
1272 dir.path().join("package.json"),
1273 r#"{"workspaces": ["packages/*"]}"#,
1274 )
1275 .unwrap();
1276 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1277 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1278
1279 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1280 assert!(
1281 undeclared.is_empty(),
1282 "should not flag node_modules or hidden directories"
1283 );
1284 }
1285
1286 fn build_globset(patterns: &[&str]) -> globset::GlobSet {
1287 let mut builder = globset::GlobSetBuilder::new();
1288 for pattern in patterns {
1289 builder.add(globset::Glob::new(pattern).expect("valid glob"));
1290 }
1291 builder.build().expect("build globset")
1292 }
1293
1294 #[test]
1295 fn undeclared_skips_dirs_matching_ignore_patterns() {
1296 let dir = tempfile::tempdir().expect("create temp dir");
1297 let pkg_a = dir.path().join("packages").join("a");
1298 let vitest_ref = dir.path().join("references").join("vitest");
1299 let tanstack_ref = dir.path().join("references").join("tanstack-router");
1300 std::fs::create_dir_all(&pkg_a).unwrap();
1301 std::fs::create_dir_all(&vitest_ref).unwrap();
1302 std::fs::create_dir_all(&tanstack_ref).unwrap();
1303
1304 std::fs::write(
1305 dir.path().join("package.json"),
1306 r#"{"workspaces": ["packages/*"]}"#,
1307 )
1308 .unwrap();
1309 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1310 std::fs::write(
1311 vitest_ref.join("package.json"),
1312 r#"{"name": "vitest-reference"}"#,
1313 )
1314 .unwrap();
1315 std::fs::write(
1316 tanstack_ref.join("package.json"),
1317 r#"{"name": "tanstack-reference"}"#,
1318 )
1319 .unwrap();
1320
1321 let declared = discover_workspaces(dir.path());
1322 let ignore = build_globset(&["references/*"]);
1323 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1324 assert!(
1325 undeclared.is_empty(),
1326 "references/* should be ignored: {undeclared:?}"
1327 );
1328 }
1329
1330 #[test]
1331 fn undeclared_still_reported_when_ignore_does_not_match() {
1332 let dir = tempfile::tempdir().expect("create temp dir");
1333 let pkg_b = dir.path().join("packages").join("b");
1334 std::fs::create_dir_all(&pkg_b).unwrap();
1335
1336 std::fs::write(
1337 dir.path().join("package.json"),
1338 r#"{"workspaces": ["packages/a"]}"#,
1339 )
1340 .unwrap();
1341 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1342
1343 let declared = discover_workspaces(dir.path());
1344 let ignore = build_globset(&["references/*"]);
1345 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1346 assert_eq!(
1347 undeclared.len(),
1348 1,
1349 "non-matching ignore patterns should not silence other undeclared dirs"
1350 );
1351 }
1352
1353 #[test]
1354 fn undeclared_skips_dirs_matching_package_json_glob() {
1355 let dir = tempfile::tempdir().expect("create temp dir");
1356 let pkg_a = dir.path().join("packages").join("a");
1357 let vitest_ref = dir.path().join("references").join("vitest");
1358 std::fs::create_dir_all(&pkg_a).unwrap();
1359 std::fs::create_dir_all(&vitest_ref).unwrap();
1360
1361 std::fs::write(
1362 dir.path().join("package.json"),
1363 r#"{"workspaces": ["packages/*"]}"#,
1364 )
1365 .unwrap();
1366 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1367 std::fs::write(
1368 vitest_ref.join("package.json"),
1369 r#"{"name": "vitest-reference"}"#,
1370 )
1371 .unwrap();
1372
1373 let declared = discover_workspaces(dir.path());
1374 let ignore = build_globset(&["references/*/package.json"]);
1375 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1376 assert!(
1377 undeclared.is_empty(),
1378 "package.json-suffixed glob should silence the warning: {undeclared:?}"
1379 );
1380 }
1381
1382 #[test]
1383 fn undeclared_skips_dirs_matching_doublestar_ignore() {
1384 let dir = tempfile::tempdir().expect("create temp dir");
1385 let pkg_a = dir.path().join("packages").join("a");
1386 let nested_ref = dir.path().join("references").join("vitest");
1387 std::fs::create_dir_all(&pkg_a).unwrap();
1388 std::fs::create_dir_all(&nested_ref).unwrap();
1389
1390 std::fs::write(
1391 dir.path().join("package.json"),
1392 r#"{"workspaces": ["packages/*"]}"#,
1393 )
1394 .unwrap();
1395 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1396 std::fs::write(
1397 nested_ref.join("package.json"),
1398 r#"{"name": "vitest-reference"}"#,
1399 )
1400 .unwrap();
1401
1402 let declared = discover_workspaces(dir.path());
1403 let ignore = build_globset(&["**/references/**"]);
1404 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1405 assert!(
1406 undeclared.is_empty(),
1407 "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1408 );
1409 }
1410
1411 #[test]
1412 fn malformed_workspace_package_json_emits_diagnostic() {
1413 let dir = tempfile::tempdir().expect("create temp dir");
1414 let pkg_a = dir.path().join("packages").join("a");
1415 let pkg_bad = dir.path().join("packages").join("bad");
1416 std::fs::create_dir_all(&pkg_a).unwrap();
1417 std::fs::create_dir_all(&pkg_bad).unwrap();
1418 std::fs::write(
1419 dir.path().join("package.json"),
1420 r#"{"workspaces": ["packages/*"]}"#,
1421 )
1422 .unwrap();
1423 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1424 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1425
1426 let (result, captured) = capture_workspace_warnings(|| {
1427 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1428 });
1429 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1430
1431 assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1432 assert_eq!(workspaces[0].name, "a");
1433 assert_eq!(diagnostics.len(), 1);
1434 assert!(matches!(
1435 diagnostics[0].kind,
1436 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1437 ));
1438 assert!(
1439 captured
1440 .iter()
1441 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1442 );
1443 }
1444
1445 #[test]
1446 fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1447 let dir = tempfile::tempdir().expect("create temp dir");
1448 let pkg_good = dir.path().join("packages/good");
1449 let pkg_bad = dir.path().join("packages/bad");
1450 std::fs::create_dir_all(&pkg_good).unwrap();
1451 std::fs::create_dir_all(&pkg_bad).unwrap();
1452 std::fs::write(
1453 dir.path().join("package.json"),
1454 r#"{"workspaces": ["packages/*"]}"#,
1455 )
1456 .unwrap();
1457 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1458 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad"}"#).unwrap();
1459 std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1460
1461 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1462 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1463
1464 assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1465 assert_eq!(workspaces[0].name, "good");
1466 assert_eq!(diagnostics.len(), 1);
1467 assert!(matches!(
1468 diagnostics[0].kind,
1469 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1470 ));
1471 assert!(diagnostics[0].message.contains("deno.jsonc"));
1472 }
1473
1474 #[test]
1484 fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1485 let dir = tempfile::tempdir().expect("create temp dir");
1486 let pkg_good = dir.path().join("packages").join("good");
1487 let pkg_bad = dir.path().join("packages").join("bad");
1488 std::fs::create_dir_all(&pkg_good).unwrap();
1489 std::fs::create_dir_all(&pkg_bad).unwrap();
1490 std::fs::write(
1491 dir.path().join("package.json"),
1492 r#"{"workspaces": ["packages/*"]}"#,
1493 )
1494 .unwrap();
1495 std::fs::write(
1496 dir.path().join("tsconfig.json"),
1497 r#"{"references": [{"path": "./packages/bad"}]}"#,
1498 )
1499 .unwrap();
1500 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1501 std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1502
1503 let (result, _) = capture_workspace_warnings(|| {
1504 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1505 });
1506 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1507
1508 assert!(
1509 workspaces.iter().any(|w| w.name == "good"),
1510 "valid sibling should still discover: {workspaces:?}"
1511 );
1512 assert!(
1513 workspaces.iter().any(|w| w.name == "bad"),
1514 "tsconfig reference falls back to the directory name on manifest error"
1515 );
1516 let malformed: Vec<_> = diagnostics
1517 .iter()
1518 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1519 .collect();
1520 assert_eq!(
1521 malformed.len(),
1522 1,
1523 "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
1524 );
1525 assert!(
1526 malformed[0].path.ends_with("bad"),
1527 "the diagnostic points at the malformed member"
1528 );
1529 }
1530
1531 #[test]
1542 fn manifest_cache_replays_the_same_err_on_every_visit() {
1543 let dir = tempfile::tempdir().expect("create temp dir");
1544 let member = dir.path().join("packages").join("bad");
1545 std::fs::create_dir_all(&member).unwrap();
1546 std::fs::write(member.join("package.json"), r"{,}").unwrap();
1547
1548 let mut cache = ManifestCache::default();
1549 let first = load_member_package_manifest_cached(&member, &mut cache)
1550 .expect_err("a malformed member manifest is an Err");
1551 assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
1552
1553 std::fs::remove_file(member.join("package.json")).unwrap();
1554 let replayed = load_member_package_manifest_cached(&member, &mut cache)
1555 .expect_err("the memoized Err replays instead of re-reading the directory");
1556 assert_eq!(
1557 replayed, first,
1558 "the replayed outcome is byte-identical to the first, so both \
1559 workspace sources diagnose the same member the same way"
1560 );
1561 }
1562
1563 #[test]
1564 fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1565 let dir = tempfile::tempdir().expect("create temp dir");
1566 for name in ["a", "b", "c"] {
1567 let pkg = dir.path().join("packages").join(name);
1568 std::fs::create_dir_all(&pkg).unwrap();
1569 std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1570 }
1571 std::fs::write(
1572 dir.path().join("package.json"),
1573 r#"{"workspaces": ["packages/*"]}"#,
1574 )
1575 .unwrap();
1576
1577 let (result, _) = capture_workspace_warnings(|| {
1578 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1579 });
1580 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1581
1582 assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1583 assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1584 assert!(
1585 diagnostics
1586 .iter()
1587 .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1588 "every diagnostic should be malformed-package-json"
1589 );
1590 }
1591
1592 #[test]
1593 fn malformed_root_package_json_returns_load_error() {
1594 let dir = tempfile::tempdir().expect("create temp dir");
1595 std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1596
1597 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1598
1599 match result {
1600 Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1601 assert!(path.ends_with("package.json"));
1602 assert!(!error.is_empty(), "underlying parse error is preserved");
1603 }
1604 other => panic!("expected MalformedRootPackageJson, got {other:?}"),
1605 }
1606 }
1607
1608 #[test]
1609 fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1610 let dir = tempfile::tempdir().expect("create temp dir");
1611 let pkg_a = dir.path().join("packages").join("a");
1612 let cache_dir = dir.path().join("packages").join(".cache");
1613 let scratch_dir = dir.path().join("packages").join("scratch");
1614 std::fs::create_dir_all(&pkg_a).unwrap();
1615 std::fs::create_dir_all(&cache_dir).unwrap();
1616 std::fs::create_dir_all(&scratch_dir).unwrap();
1617 std::fs::write(
1618 dir.path().join("package.json"),
1619 r#"{"workspaces": ["packages/*"]}"#,
1620 )
1621 .unwrap();
1622 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1623
1624 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1625 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1626
1627 assert_eq!(workspaces.len(), 1);
1628 let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1629 assert!(
1630 kinds.contains(&"glob-matched-no-package-json"),
1631 "scratch should diagnose: {kinds:?}"
1632 );
1633 assert!(
1634 !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1635 ".cache must be skip-listed: {diagnostics:?}"
1636 );
1637 }
1638
1639 #[test]
1640 fn glob_match_without_package_json_honors_ignore_patterns() {
1641 let dir = tempfile::tempdir().expect("create temp dir");
1642 let pkg_a = dir.path().join("packages").join("a");
1643 let legacy_dir = dir.path().join("packages").join("legacy");
1644 std::fs::create_dir_all(&pkg_a).unwrap();
1645 std::fs::create_dir_all(&legacy_dir).unwrap();
1646 std::fs::write(
1647 dir.path().join("package.json"),
1648 r#"{"workspaces": ["packages/*"]}"#,
1649 )
1650 .unwrap();
1651 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1652
1653 let mut builder = globset::GlobSetBuilder::new();
1654 builder.add(globset::Glob::new("packages/legacy").unwrap());
1655 let ignore = builder.build().unwrap();
1656
1657 let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1658 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1659
1660 assert_eq!(workspaces.len(), 1);
1661 assert!(
1662 diagnostics.is_empty(),
1663 "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1664 );
1665 }
1666
1667 #[test]
1668 fn malformed_tsconfig_emits_diagnostic() {
1669 let dir = tempfile::tempdir().expect("create temp dir");
1670 std::fs::write(
1671 dir.path().join("package.json"),
1672 r#"{"workspaces": ["packages/*"]}"#,
1673 )
1674 .unwrap();
1675 std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1676
1677 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1678 let (_, diagnostics) = result.expect("root package.json is valid");
1679
1680 assert!(
1681 diagnostics
1682 .iter()
1683 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1684 "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1685 );
1686 }
1687
1688 #[test]
1689 fn tsconfig_missing_reference_dir_emits_diagnostic() {
1690 let dir = tempfile::tempdir().expect("create temp dir");
1691 std::fs::write(
1692 dir.path().join("tsconfig.json"),
1693 r#"{"references": [{"path": "./packages/missing"}]}"#,
1694 )
1695 .unwrap();
1696
1697 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1698 let (_, diagnostics) = result.expect("no package.json at root is OK");
1699
1700 assert!(
1701 diagnostics
1702 .iter()
1703 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1704 "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1705 );
1706 }
1707
1708 #[test]
1709 fn missing_tsconfig_is_silent() {
1710 let dir = tempfile::tempdir().expect("create temp dir");
1711
1712 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1713 let (_, diagnostics) = result.expect("no root package.json is OK");
1714
1715 assert!(
1716 !diagnostics
1717 .iter()
1718 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1719 "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1720 );
1721 }
1722
1723 #[test]
1724 fn shallow_scan_malformed_package_json_stays_silent() {
1725 let dir = tempfile::tempdir().expect("create temp dir");
1726 let scratch = dir.path().join("scratch");
1727 std::fs::create_dir_all(&scratch).unwrap();
1728 std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1729
1730 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1731 let (_, diagnostics) = result.expect("no root package.json is OK");
1732
1733 assert!(
1734 !diagnostics
1735 .iter()
1736 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1737 "shallow-scan malformed must stay silent: {diagnostics:?}"
1738 );
1739 }
1740
1741 #[test]
1742 fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1743 let dir = tempfile::tempdir().expect("create temp dir");
1744 let pkg_good = dir.path().join("packages").join("good");
1745 let pkg_bad = dir.path().join("packages").join("bad");
1746 std::fs::create_dir_all(&pkg_good).unwrap();
1747 std::fs::create_dir_all(&pkg_bad).unwrap();
1748 std::fs::write(
1749 dir.path().join("package.json"),
1750 r#"{"workspaces": ["packages/*"]}"#,
1751 )
1752 .unwrap();
1753 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1754 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1755
1756 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1757 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1758
1759 assert_eq!(workspaces.len(), 1);
1760 assert_eq!(workspaces[0].name, "good");
1761 assert_eq!(diagnostics.len(), 1);
1762 assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1763 }
1764
1765 #[test]
1766 fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1767 let dir = tempfile::tempdir().expect("create temp dir");
1768 std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1769
1770 let workspaces = discover_workspaces(dir.path());
1771 assert!(
1772 workspaces.is_empty(),
1773 "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1774 );
1775 }
1776
1777 #[test]
1778 fn discovers_deno_workspace_members_without_package_json() {
1779 let dir = tempfile::tempdir().expect("create temp dir");
1780 let core = dir.path().join("packages").join("core");
1781 let app = dir.path().join("apps").join("desktop");
1782 std::fs::create_dir_all(&core).unwrap();
1783 std::fs::create_dir_all(&app).unwrap();
1784
1785 std::fs::write(
1786 dir.path().join("deno.json"),
1787 r#"{
1788 "workspace": ["./apps/*", "./packages/*"],
1789 "imports": { "@std/assert": "jsr:@std/assert@1" }
1790 }"#,
1791 )
1792 .unwrap();
1793 std::fs::write(
1794 core.join("deno.json"),
1795 r#"{
1796 "name": "@fallow/core",
1797 "exports": { ".": "./mod.ts", "./result": "./result.ts" }
1798 }"#,
1799 )
1800 .unwrap();
1801 std::fs::write(
1802 app.join("deno.json"),
1803 r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
1804 )
1805 .unwrap();
1806
1807 let workspaces = discover_workspaces(dir.path());
1808 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1809 names.sort_unstable();
1810 assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
1811 assert!(
1812 workspaces
1813 .iter()
1814 .all(|w| !w.root.join("package.json").exists())
1815 );
1816 assert!(
1817 workspaces.iter().all(|w| !w.is_internal_dependency),
1818 "Deno packages need dependency evidence before inventory marks them internal"
1819 );
1820 }
1821
1822 #[test]
1823 fn malformed_root_deno_config_returns_load_error() {
1824 let dir = tempfile::tempdir().expect("create temp dir");
1825 std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
1826
1827 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1828
1829 match result {
1830 Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
1831 assert!(path.ends_with("deno.jsonc"));
1832 assert!(!error.is_empty(), "underlying parse error is preserved");
1833 }
1834 other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
1835 }
1836 }
1837
1838 #[test]
1839 fn deno_and_npm_workspace_patterns_are_additive() {
1840 let dir = tempfile::tempdir().expect("create temp dir");
1841 let a = dir.path().join("packages").join("a");
1842 let b = dir.path().join("packages").join("b");
1843 std::fs::create_dir_all(&a).unwrap();
1844 std::fs::create_dir_all(&b).unwrap();
1845
1846 std::fs::write(
1847 dir.path().join("package.json"),
1848 r#"{"workspaces": ["packages/a"]}"#,
1849 )
1850 .unwrap();
1851 std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1852 std::fs::write(
1853 dir.path().join("deno.json"),
1854 r#"{"workspace": ["./packages/b"]}"#,
1855 )
1856 .unwrap();
1857 std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
1858
1859 let workspaces = discover_workspaces(dir.path());
1860 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1861 names.sort_unstable();
1862 assert_eq!(names, vec!["a", "b"]);
1863 }
1864
1865 #[test]
1871 fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
1872 let dir = tempfile::tempdir().expect("create temp dir");
1873 write_two_manifest_glob_project(dir.path());
1874
1875 let (_, diagnostics) =
1876 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1877 .expect("root package.json is valid");
1878
1879 let mut reported: Vec<(String, String)> = diagnostics
1880 .iter()
1881 .filter_map(|diagnostic| match &diagnostic.kind {
1882 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
1883 pattern.clone(),
1884 diagnostic
1885 .path
1886 .strip_prefix(dir.path())
1887 .unwrap_or(&diagnostic.path)
1888 .display()
1889 .to_string()
1890 .replace('\\', "/"),
1891 )),
1892 _ => None,
1893 })
1894 .collect();
1895 reported.sort();
1896
1897 assert_eq!(
1898 reported,
1899 vec![
1900 ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
1901 ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
1902 ],
1903 "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
1904 );
1905 }
1906
1907 #[test]
1911 fn overlapping_globs_still_report_one_directory_once_per_pattern() {
1912 let dir = tempfile::tempdir().expect("create temp dir");
1913 std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
1914 std::fs::write(
1915 dir.path().join("package.json"),
1916 r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
1917 )
1918 .unwrap();
1919 std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
1920
1921 let (_, diagnostics) =
1922 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1923 .expect("root package.json is valid");
1924
1925 let patterns: Vec<&str> = diagnostics
1926 .iter()
1927 .filter_map(|diagnostic| match &diagnostic.kind {
1928 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1929 Some(pattern.as_str())
1930 }
1931 _ => None,
1932 })
1933 .collect();
1934
1935 assert_eq!(
1936 patterns,
1937 ["pkgs/*", "pkgs/a*"],
1938 "distinct patterns are distinct diagnostics: {diagnostics:?}"
1939 );
1940 }
1941}