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