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