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