1mod deno_json;
2mod diagnostics;
3mod npm_overrides;
4mod package_json;
5mod parsers;
6mod pnpm_catalog;
7mod pnpm_overrides;
8mod tsconfig;
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use rustc_hash::FxHashMap;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17pub use deno_json::{
18 DirManifestProbe, dir_has_deno_json, dir_has_package_manifest, is_deno_without_node_modules,
19 load_deno_import_map, load_dir_package_json, load_member_package_manifest,
20 load_root_deno_workspace_patterns, probe_dir_manifest,
21};
22#[cfg(test)]
23pub use diagnostics::capture_workspace_warnings;
24pub use diagnostics::{
25 WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
26 clear_analysis_stage_diagnostics, clear_health_stage_diagnostics,
27 health_stage_workspace_diagnostics, missing_node_modules_diagnostic, node_modules_missing,
28 record_plugin_config_diagnostics, record_source_parse_degradations,
29 record_source_read_failures, record_workspace_diagnostics, registry_diagnostics_to_fold,
30 replace_source_discovery_diagnostics, stash_workspace_diagnostics, workspace_diagnostics_for,
31};
32use diagnostics::{emit_diagnostics, is_ignored_workspace_dir, is_skip_listed_dir};
33pub use npm_overrides::{parse_bun_package_json_resolutions, parse_npm_package_json_overrides};
34pub use package_json::{NapiConfig, PackageJson};
35pub use parsers::parse_tsconfig_root_dir;
36use parsers::{
37 expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
38 parse_tsconfig_references_with_diagnostics,
39};
40pub use pnpm_catalog::{
41 PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup,
42 parse_package_json_catalog_data, parse_pnpm_catalog_data,
43};
44pub use pnpm_overrides::{
45 MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
46 is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
47 parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
48};
49pub use tsconfig::{TsconfigOutputMap, TsconfigOutputResolution};
50
51#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
53pub struct WorkspaceConfig {
54 #[serde(default, alias = "packages")]
61 pub patterns: Vec<String>,
62 #[serde(
64 default,
65 rename = "changedSince",
66 skip_serializing_if = "BTreeMap::is_empty"
67 )]
68 pub changed_since: BTreeMap<String, String>,
69}
70
71#[derive(Debug, Clone)]
74pub struct WorkspaceInfo {
75 pub root: PathBuf,
77 pub name: String,
79 pub is_internal_dependency: bool,
81}
82
83#[must_use]
89pub fn workspace_is_public(name: &str, public_packages: &[String]) -> bool {
90 public_packages.iter().any(|pattern| {
91 name == pattern
92 || globset::Glob::new(pattern).is_ok_and(|glob| glob.compile_matcher().is_match(name))
93 })
94}
95
96#[must_use]
120pub fn discover_workspaces(root: &Path) -> Vec<WorkspaceInfo> {
121 collect_workspaces_and_diagnostics(root, &crate::IgnorePatternSet::empty())
122 .map(|(workspaces, _)| workspaces)
123 .unwrap_or_default()
124}
125
126pub fn discover_workspaces_with_diagnostics(
156 root: &Path,
157 ignore_patterns: &crate::IgnorePatternSet,
158) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
159 let (workspaces, diagnostics) = collect_workspaces_and_diagnostics(root, ignore_patterns)?;
160
161 emit_diagnostics(root, &diagnostics);
162
163 Ok((workspaces, diagnostics))
164}
165
166fn collect_workspaces_and_diagnostics(
188 root: &Path,
189 ignore_patterns: &crate::IgnorePatternSet,
190) -> Result<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>), WorkspaceLoadError> {
191 let mut diagnostics = Vec::new();
192 let patterns = collect_workspace_patterns(root)?;
193 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
194 let mut manifest_cache = ManifestCache::default();
195
196 let mut workspaces = collect_declared_workspaces(
197 root,
198 &patterns,
199 &canonical_root,
200 ignore_patterns,
201 &mut diagnostics,
202 &mut manifest_cache,
203 );
204 workspaces.extend(collect_link_dependency_workspaces(
205 root,
206 &canonical_root,
207 ignore_patterns,
208 &mut diagnostics,
209 &mut manifest_cache,
210 ));
211 if patterns.is_empty() {
212 workspaces.extend(collect_shallow_package_workspaces(
213 root,
214 &canonical_root,
215 &mut manifest_cache,
216 ));
217 }
218
219 if !workspaces.is_empty() {
220 mark_internal_dependencies(&mut workspaces);
221 }
222 let workspaces = workspaces.into_iter().map(|(ws, _)| ws).collect();
223 Ok((
224 workspaces,
225 fallow_types::workspace::dedupe_workspace_diagnostics(diagnostics),
226 ))
227}
228
229fn collect_declared_workspaces(
232 root: &Path,
233 patterns: &[String],
234 canonical_root: &Path,
235 ignore_patterns: &crate::IgnorePatternSet,
236 diagnostics: &mut Vec<WorkspaceDiagnostic>,
237 manifest_cache: &mut ManifestCache,
238) -> Vec<(WorkspaceInfo, Vec<String>)> {
239 let mut workspaces = expand_patterns_to_workspaces(
240 root,
241 patterns,
242 canonical_root,
243 ignore_patterns,
244 diagnostics,
245 manifest_cache,
246 );
247 workspaces.extend(collect_tsconfig_workspaces(
248 root,
249 canonical_root,
250 ignore_patterns,
251 diagnostics,
252 manifest_cache,
253 ));
254 workspaces
255}
256
257#[must_use]
267pub fn link_only_workspace_dependencies(
268 root: &Path,
269 ignore_patterns: &crate::IgnorePatternSet,
270 workspaces: &[WorkspaceInfo],
271) -> rustc_hash::FxHashSet<String> {
272 let Ok(root_pkg) = PackageJson::load(&root.join("package.json")) else {
273 return rustc_hash::FxHashSet::default();
274 };
275 let links = root_pkg.local_link_dependencies();
276 if links.is_empty() || workspaces.is_empty() {
277 return rustc_hash::FxHashSet::default();
278 }
279 let canonical = |path: &Path| dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
280 let workspace_roots: rustc_hash::FxHashSet<PathBuf> =
281 workspaces.iter().map(|ws| canonical(&ws.root)).collect();
282 let linked: Vec<(String, PathBuf)> = links
283 .into_iter()
284 .map(|(name, target)| (name, canonical(&root.join(target))))
285 .filter(|(_, dir)| workspace_roots.contains(dir))
286 .collect();
287 if linked.is_empty() {
288 return rustc_hash::FxHashSet::default();
289 }
290
291 let patterns = collect_workspace_patterns(root).unwrap_or_default();
292 let declared: rustc_hash::FxHashSet<PathBuf> = collect_declared_workspaces(
293 root,
294 &patterns,
295 &canonical(root),
296 ignore_patterns,
297 &mut Vec::new(),
298 &mut ManifestCache::default(),
299 )
300 .into_iter()
301 .map(|(ws, _)| canonical(&ws.root))
302 .collect();
303 linked
304 .into_iter()
305 .filter(|(_, dir)| !declared.contains(dir))
306 .map(|(name, _)| name)
307 .collect()
308}
309
310#[must_use]
316pub fn find_undeclared_workspaces(
317 root: &Path,
318 declared: &[WorkspaceInfo],
319) -> Vec<WorkspaceDiagnostic> {
320 find_undeclared_workspaces_with_ignores(root, declared, &crate::IgnorePatternSet::empty())
321}
322
323#[must_use]
333pub fn find_undeclared_workspaces_with_ignores(
334 root: &Path,
335 declared: &[WorkspaceInfo],
336 ignore_patterns: &crate::IgnorePatternSet,
337) -> Vec<WorkspaceDiagnostic> {
338 let patterns = collect_workspace_patterns(root).unwrap_or_default();
339 if patterns.is_empty() {
340 return Vec::new();
341 }
342
343 let declared_roots: rustc_hash::FxHashSet<PathBuf> = declared
344 .iter()
345 .map(|w| dunce::canonicalize(&w.root).unwrap_or_else(|_| w.root.clone()))
346 .collect();
347
348 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
349
350 let mut undeclared = Vec::new();
351
352 let Ok(top_entries) = std::fs::read_dir(root) else {
353 return Vec::new();
354 };
355
356 for entry in top_entries.filter_map(Result::ok) {
357 let path = entry.path();
358 if !path.is_dir() || is_undeclared_scan_skip_dir(&entry.file_name().to_string_lossy()) {
359 continue;
360 }
361
362 let mut input = UndeclaredScanInput {
363 root,
364 canonical_root: &canonical_root,
365 declared_roots: &declared_roots,
366 ignore_patterns,
367 undeclared: &mut undeclared,
368 };
369 check_undeclared(
370 &path,
371 input.root,
372 input.canonical_root,
373 input.declared_roots,
374 input.ignore_patterns,
375 input.undeclared,
376 );
377 scan_child_dirs_for_undeclared(&path, &mut input);
378 }
379
380 undeclared
381}
382
383fn is_undeclared_scan_skip_dir(name: &str) -> bool {
385 name.starts_with('.') || name == "node_modules" || name == "build"
386}
387
388struct UndeclaredScanInput<'a> {
390 root: &'a Path,
391 canonical_root: &'a Path,
392 declared_roots: &'a rustc_hash::FxHashSet<PathBuf>,
393 ignore_patterns: &'a crate::IgnorePatternSet,
394 undeclared: &'a mut Vec<WorkspaceDiagnostic>,
395}
396
397fn scan_child_dirs_for_undeclared(parent: &Path, input: &mut UndeclaredScanInput<'_>) {
399 let Ok(child_entries) = std::fs::read_dir(parent) else {
400 return;
401 };
402 for child in child_entries.filter_map(Result::ok) {
403 let child_path = child.path();
404 if !child_path.is_dir() || is_undeclared_scan_skip_dir(&child.file_name().to_string_lossy())
405 {
406 continue;
407 }
408 check_undeclared(
409 &child_path,
410 input.root,
411 input.canonical_root,
412 input.declared_roots,
413 input.ignore_patterns,
414 input.undeclared,
415 );
416 }
417}
418
419fn check_undeclared(
421 dir: &Path,
422 root: &Path,
423 canonical_root: &Path,
424 declared_roots: &rustc_hash::FxHashSet<PathBuf>,
425 ignore_patterns: &crate::IgnorePatternSet,
426 undeclared: &mut Vec<WorkspaceDiagnostic>,
427) {
428 if !dir_has_package_manifest(dir) {
429 return;
430 }
431 let canonical = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
432 if canonical == *canonical_root {
433 return;
434 }
435 if declared_roots.contains(&canonical) {
436 return;
437 }
438 let relative = dir.strip_prefix(root).unwrap_or(dir);
439 let relative_str = relative.to_string_lossy().replace('\\', "/");
440 if ignore_patterns.is_match(relative_str.as_str())
441 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
442 || ignore_patterns.is_match(format!("{relative_str}/deno.json").as_str())
443 || ignore_patterns.is_match(format!("{relative_str}/deno.jsonc").as_str())
444 {
445 return;
446 }
447 undeclared.push(WorkspaceDiagnostic::new(
448 root,
449 dir.to_path_buf(),
450 WorkspaceDiagnosticKind::UndeclaredWorkspace,
451 ));
452}
453
454fn collect_workspace_patterns(root: &Path) -> Result<Vec<String>, WorkspaceLoadError> {
457 let mut patterns = Vec::new();
458
459 let pkg_path = root.join("package.json");
460 if pkg_path.exists() {
461 match PackageJson::load(&pkg_path) {
462 Ok(pkg) => patterns.extend(pkg.workspace_patterns()),
463 Err(error) => {
464 return Err(WorkspaceLoadError::MalformedRootPackageJson {
465 path: pkg_path,
466 error,
467 });
468 }
469 }
470 }
471
472 let pnpm_workspace = root.join("pnpm-workspace.yaml");
473 if pnpm_workspace.exists()
474 && let Ok(content) = std::fs::read_to_string(&pnpm_workspace)
475 {
476 patterns.extend(parse_pnpm_workspace_yaml(&content));
477 }
478
479 if let Some((_path, deno_patterns)) = load_root_deno_workspace_patterns(root)
480 .map_err(|(path, error)| WorkspaceLoadError::MalformedRootDenoConfig { path, error })?
481 {
482 patterns.extend(deno_patterns);
483 }
484
485 Ok(patterns)
486}
487
488type ManifestCache = FxHashMap<PathBuf, Result<Option<(String, PackageJson, Vec<String>)>, String>>;
496
497fn load_member_package_manifest_cached(
500 dir: &Path,
501 cache: &mut ManifestCache,
502) -> Result<Option<(String, PackageJson, Vec<String>)>, String> {
503 if let Some(cached) = cache.get(dir) {
504 return cached.clone();
505 }
506 let outcome = load_member_package_manifest(dir);
507 cache.insert(dir.to_path_buf(), outcome.clone());
508 outcome
509}
510
511fn expand_patterns_to_workspaces(
516 root: &Path,
517 patterns: &[String],
518 canonical_root: &Path,
519 ignore_patterns: &crate::IgnorePatternSet,
520 diagnostics: &mut Vec<WorkspaceDiagnostic>,
521 manifest_cache: &mut ManifestCache,
522) -> Vec<(WorkspaceInfo, Vec<String>)> {
523 if patterns.is_empty() {
524 return Vec::new();
525 }
526
527 let mut workspaces = Vec::new();
528
529 let (positive, negative): (Vec<&String>, Vec<&String>) =
530 patterns.iter().partition(|p| !p.starts_with('!'));
531 let negation_matchers: Vec<globset::GlobMatcher> = negative
532 .iter()
533 .filter_map(|p| {
534 let stripped = p.strip_prefix('!').unwrap_or(p);
535 globset::Glob::new(stripped)
536 .ok()
537 .map(|g| g.compile_matcher())
538 })
539 .collect();
540
541 for pattern in &positive {
542 let glob_pattern = if pattern.ends_with('/') {
543 format!("{pattern}*")
544 } else {
545 (*pattern).clone()
546 };
547
548 let matched_dirs = expand_workspace_glob_with_diagnostics(
549 root,
550 pattern,
551 &glob_pattern,
552 canonical_root,
553 ignore_patterns,
554 diagnostics,
555 );
556 for (dir, canonical_dir) in matched_dirs {
557 if canonical_dir == *canonical_root {
558 continue;
559 }
560 if matches_negation(root, &dir, &negation_matchers) {
561 continue;
562 }
563 register_matched_workspace(root, dir, &mut workspaces, diagnostics, manifest_cache);
564 }
565 }
566
567 workspaces
568}
569
570fn matches_negation(root: &Path, dir: &Path, negation_matchers: &[globset::GlobMatcher]) -> bool {
572 let relative = dir.strip_prefix(root).unwrap_or(dir);
573 let relative_str = relative.to_string_lossy();
574 negation_matchers
575 .iter()
576 .any(|m| m.is_match(relative_str.as_ref()))
577}
578
579fn register_matched_workspace(
583 root: &Path,
584 dir: PathBuf,
585 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
586 diagnostics: &mut Vec<WorkspaceDiagnostic>,
587 manifest_cache: &mut ManifestCache,
588) {
589 match load_member_package_manifest_cached(&dir, manifest_cache) {
590 Ok(Some((name, _pkg, dep_names))) => {
591 workspaces.push((
592 WorkspaceInfo {
593 root: dir,
594 name,
595 is_internal_dependency: false,
596 },
597 dep_names,
598 ));
599 }
600 Ok(None) => {}
601 Err(error) => {
602 let diag = WorkspaceDiagnostic::new(
603 root,
604 dir,
605 WorkspaceDiagnosticKind::MalformedPackageJson { error },
606 );
607 diagnostics.push(diag);
608 }
609 }
610}
611
612fn collect_tsconfig_workspaces(
617 root: &Path,
618 canonical_root: &Path,
619 ignore_patterns: &crate::IgnorePatternSet,
620 diagnostics: &mut Vec<WorkspaceDiagnostic>,
621 manifest_cache: &mut ManifestCache,
622) -> Vec<(WorkspaceInfo, Vec<String>)> {
623 let mut workspaces = Vec::new();
624
625 for dir in parse_tsconfig_references_with_diagnostics(root, ignore_patterns, diagnostics) {
626 if let Some(workspace) =
627 tsconfig_workspace_from_dir(root, dir, canonical_root, diagnostics, manifest_cache)
628 {
629 workspaces.push(workspace);
630 }
631 }
632
633 workspaces
634}
635
636fn tsconfig_workspace_from_dir(
637 root: &Path,
638 dir: PathBuf,
639 canonical_root: &Path,
640 diagnostics: &mut Vec<WorkspaceDiagnostic>,
641 manifest_cache: &mut ManifestCache,
642) -> Option<(WorkspaceInfo, Vec<String>)> {
643 let canonical_dir = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
644 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
645 return None;
646 }
647
648 let (name, dep_names) =
649 load_tsconfig_workspace_package(root, &dir, diagnostics, manifest_cache);
650 Some((
651 WorkspaceInfo {
652 root: dir,
653 name,
654 is_internal_dependency: false,
655 },
656 dep_names,
657 ))
658}
659
660fn load_tsconfig_workspace_package(
661 root: &Path,
662 dir: &Path,
663 diagnostics: &mut Vec<WorkspaceDiagnostic>,
664 manifest_cache: &mut ManifestCache,
665) -> (String, Vec<String>) {
666 match load_member_package_manifest_cached(dir, manifest_cache) {
667 Ok(Some((name, _pkg, deps))) => (name, deps),
668 Ok(None) => (dir_name(dir), Vec::new()),
669 Err(error) => {
670 let diag = WorkspaceDiagnostic::new(
671 root,
672 dir.to_path_buf(),
673 WorkspaceDiagnosticKind::MalformedPackageJson { error },
674 );
675 diagnostics.push(diag);
676 (dir_name(dir), Vec::new())
677 }
678 }
679}
680
681fn collect_link_dependency_workspaces(
696 root: &Path,
697 canonical_root: &Path,
698 ignore_patterns: &crate::IgnorePatternSet,
699 diagnostics: &mut Vec<WorkspaceDiagnostic>,
700 manifest_cache: &mut ManifestCache,
701) -> Vec<(WorkspaceInfo, Vec<String>)> {
702 let Ok(root_pkg) = PackageJson::load(&root.join("package.json")) else {
703 return Vec::new();
704 };
705
706 let candidates: Vec<PathBuf> = root_pkg
707 .local_link_dependencies()
708 .into_iter()
709 .filter_map(|(_name, target)| dunce::canonicalize(root.join(target)).ok())
710 .filter(|canonical_dir| {
711 *canonical_dir != *canonical_root
712 && canonical_dir.starts_with(canonical_root)
713 && canonical_dir.is_dir()
714 && !is_skipped_link_target(canonical_root, canonical_dir, ignore_patterns)
715 })
716 .collect();
717 let walked = link_targets_reached_by_source_walk(canonical_root, &candidates);
718
719 let mut workspaces = Vec::new();
720 for canonical_dir in candidates {
721 if !walked.contains(&canonical_dir) {
722 continue;
723 }
724 let dir = normalize_link_target(root, canonical_root, &canonical_dir);
725 register_matched_workspace(root, dir, &mut workspaces, diagnostics, manifest_cache);
726 }
727 workspaces
728}
729
730fn is_skipped_link_target(
738 canonical_root: &Path,
739 canonical_dir: &Path,
740 ignore_patterns: &crate::IgnorePatternSet,
741) -> bool {
742 let Ok(relative) = canonical_dir.strip_prefix(canonical_root) else {
743 return true;
744 };
745 relative
746 .components()
747 .any(|component| is_skip_listed_dir(&component.as_os_str().to_string_lossy()))
748 || is_ignored_workspace_dir(relative, ignore_patterns)
749}
750
751fn link_targets_reached_by_source_walk(
762 canonical_root: &Path,
763 targets: &[PathBuf],
764) -> rustc_hash::FxHashSet<PathBuf> {
765 let mut reached = rustc_hash::FxHashSet::default();
766 if targets.is_empty() {
767 return reached;
768 }
769 let mut on_path: rustc_hash::FxHashSet<PathBuf> = rustc_hash::FxHashSet::default();
770 for target in targets {
771 for ancestor in target.ancestors() {
772 if ancestor == canonical_root || !on_path.insert(ancestor.to_path_buf()) {
773 break;
774 }
775 }
776 }
777 let max_depth = targets
778 .iter()
779 .filter_map(|target| target.strip_prefix(canonical_root).ok())
780 .map(|relative| relative.components().count())
781 .max();
782 let mut builder = crate::source_walk_builder(canonical_root);
783 builder
784 .max_depth(max_depth)
785 .filter_entry(move |entry| entry.depth() == 0 || on_path.contains(entry.path()));
786 let targets: rustc_hash::FxHashSet<&Path> = targets.iter().map(PathBuf::as_path).collect();
787 for entry in builder.build().flatten() {
788 if targets.contains(entry.path()) {
789 reached.insert(entry.into_path());
790 }
791 }
792 reached
793}
794
795fn normalize_link_target(root: &Path, canonical_root: &Path, canonical_dir: &Path) -> PathBuf {
800 canonical_dir
801 .strip_prefix(canonical_root)
802 .map_or_else(|_| canonical_dir.to_path_buf(), |rel| root.join(rel))
803}
804
805fn collect_shallow_package_workspaces(
812 root: &Path,
813 canonical_root: &Path,
814 manifest_cache: &mut ManifestCache,
815) -> Vec<(WorkspaceInfo, Vec<String>)> {
816 let mut workspaces = Vec::new();
817 let Ok(top_entries) = std::fs::read_dir(root) else {
818 return workspaces;
819 };
820
821 for entry in top_entries.filter_map(Result::ok) {
822 let path = entry.path();
823 if !path.is_dir() || is_skip_listed_dir(&entry.file_name().to_string_lossy()) {
824 continue;
825 }
826
827 collect_shallow_workspace_candidate(&path, canonical_root, &mut workspaces, manifest_cache);
828 collect_shallow_child_workspaces(&path, canonical_root, &mut workspaces, manifest_cache);
829 }
830
831 workspaces
832}
833
834fn collect_shallow_child_workspaces(
835 parent: &Path,
836 canonical_root: &Path,
837 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
838 manifest_cache: &mut ManifestCache,
839) {
840 let Ok(child_entries) = std::fs::read_dir(parent) else {
841 return;
842 };
843 for child in child_entries.filter_map(Result::ok) {
844 let child_path = child.path();
845 if !child_path.is_dir() || is_skip_listed_dir(&child.file_name().to_string_lossy()) {
846 continue;
847 }
848
849 collect_shallow_workspace_candidate(
850 &child_path,
851 canonical_root,
852 workspaces,
853 manifest_cache,
854 );
855 }
856}
857
858fn collect_shallow_workspace_candidate(
859 dir: &Path,
860 canonical_root: &Path,
861 workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
862 manifest_cache: &mut ManifestCache,
863) {
864 let canonical_dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
865 if canonical_dir == *canonical_root || !canonical_dir.starts_with(canonical_root) {
866 return;
867 }
868
869 let Ok(Some((name, _pkg, dep_names))) =
870 load_member_package_manifest_cached(dir, manifest_cache)
871 else {
872 return;
873 };
874
875 workspaces.push((
876 WorkspaceInfo {
877 root: dir.to_path_buf(),
878 name,
879 is_internal_dependency: false,
880 },
881 dep_names,
882 ));
883}
884
885fn mark_internal_dependencies(workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>) {
891 {
892 let mut seen = rustc_hash::FxHashSet::default();
893 workspaces.retain(|(ws, _)| {
894 let canonical = dunce::canonicalize(&ws.root).unwrap_or_else(|_| ws.root.clone());
895 seen.insert(canonical)
896 });
897 }
898
899 let all_dep_names: rustc_hash::FxHashSet<String> = workspaces
900 .iter()
901 .flat_map(|(_, deps)| deps.iter().cloned())
902 .collect();
903
904 for (ws, _) in &mut *workspaces {
905 ws.is_internal_dependency = all_dep_names.contains(&ws.name);
906 }
907}
908
909fn dir_name(dir: &Path) -> String {
911 dir.file_name()
912 .map(|n| n.to_string_lossy().to_string())
913 .unwrap_or_default()
914}
915
916#[cfg(test)]
927pub fn write_two_manifest_glob_project(root: &Path) {
928 std::fs::create_dir_all(root.join("pkgs/aaa")).unwrap();
929 std::fs::create_dir_all(root.join("pkgs/bbb")).unwrap();
930 std::fs::write(
931 root.join("package.json"),
932 r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
933 )
934 .unwrap();
935 std::fs::write(
936 root.join("pnpm-workspace.yaml"),
937 "packages:\n - \"pkgs/*\"\n",
938 )
939 .unwrap();
940 std::fs::write(root.join("pkgs/aaa/readme.txt"), "no package.json here\n").unwrap();
941 std::fs::write(root.join("pkgs/bbb/readme.txt"), "no package.json here\n").unwrap();
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 #[test]
949 fn public_workspace_selection_supports_exact_names_and_globs() {
950 assert!(!workspace_is_public("@scope/core", &[]));
951 assert!(workspace_is_public(
952 "@scope/core",
953 &["@scope/core".to_string()]
954 ));
955 assert!(workspace_is_public(
956 "@scope/core",
957 &["@scope/*".to_string()]
958 ));
959 assert!(!workspace_is_public(
960 "@scope/core",
961 &["[invalid".to_string(), "@other/*".to_string()]
962 ));
963 }
964
965 #[test]
966 fn discover_workspaces_from_tsconfig_references() {
967 let temp_dir = std::env::temp_dir().join("fallow-test-ws-tsconfig-refs");
968 let _ = std::fs::remove_dir_all(&temp_dir);
969 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
970 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
971
972 std::fs::write(
973 temp_dir.join("tsconfig.json"),
974 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/ui"}]}"#,
975 )
976 .unwrap();
977
978 std::fs::write(
979 temp_dir.join("packages/core/package.json"),
980 r#"{"name": "@project/core"}"#,
981 )
982 .unwrap();
983
984 let workspaces = discover_workspaces(&temp_dir);
985 assert_eq!(workspaces.len(), 2);
986 assert!(workspaces.iter().any(|ws| ws.name == "@project/core"));
987 assert!(workspaces.iter().any(|ws| ws.name == "ui"));
988
989 let _ = std::fs::remove_dir_all(&temp_dir);
990 }
991
992 #[test]
993 fn tsconfig_references_outside_root_rejected() {
994 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-outside");
995 let _ = std::fs::remove_dir_all(&temp_dir);
996 std::fs::create_dir_all(temp_dir.join("project/packages/core")).unwrap();
997 std::fs::create_dir_all(temp_dir.join("outside")).unwrap();
998
999 std::fs::write(
1000 temp_dir.join("project/tsconfig.json"),
1001 r#"{"references": [{"path": "./packages/core"}, {"path": "../outside"}]}"#,
1002 )
1003 .unwrap();
1004
1005 let workspaces = discover_workspaces(&temp_dir.join("project"));
1006 assert_eq!(
1007 workspaces.len(),
1008 1,
1009 "reference outside project root should be rejected: {workspaces:?}"
1010 );
1011 assert!(
1012 workspaces[0]
1013 .root
1014 .to_string_lossy()
1015 .contains("packages/core")
1016 );
1017
1018 let _ = std::fs::remove_dir_all(&temp_dir);
1019 }
1020
1021 #[test]
1022 fn dir_name_extracts_last_component() {
1023 assert_eq!(dir_name(Path::new("/project/packages/core")), "core");
1024 assert_eq!(dir_name(Path::new("/my-app")), "my-app");
1025 }
1026
1027 #[test]
1028 fn dir_name_empty_for_root_path() {
1029 assert_eq!(dir_name(Path::new("/")), "");
1030 }
1031
1032 #[test]
1033 fn workspace_config_deserialize_json() {
1034 let json = r#"{"patterns": ["packages/*", "apps/*"]}"#;
1035 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1036 assert_eq!(config.patterns, vec!["packages/*", "apps/*"]);
1037 }
1038
1039 #[test]
1040 fn workspace_config_deserialize_empty_patterns() {
1041 let json = r#"{"patterns": []}"#;
1042 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1043 assert!(config.patterns.is_empty());
1044 }
1045
1046 #[test]
1047 fn workspace_config_default_patterns() {
1048 let json = "{}";
1049 let config: WorkspaceConfig = serde_json::from_str(json).unwrap();
1050 assert!(config.patterns.is_empty());
1051 }
1052
1053 #[test]
1054 fn workspace_info_default_not_internal() {
1055 let ws = WorkspaceInfo {
1056 root: PathBuf::from("/project/packages/a"),
1057 name: "a".to_string(),
1058 is_internal_dependency: false,
1059 };
1060 assert!(!ws.is_internal_dependency);
1061 }
1062
1063 #[test]
1064 fn mark_internal_deps_detects_cross_references() {
1065 let temp_dir = tempfile::tempdir().expect("create temp dir");
1066 let pkg_a = temp_dir.path().join("a");
1067 let pkg_b = temp_dir.path().join("b");
1068 std::fs::create_dir_all(&pkg_a).unwrap();
1069 std::fs::create_dir_all(&pkg_b).unwrap();
1070
1071 let mut workspaces = vec![
1072 (
1073 WorkspaceInfo {
1074 root: pkg_a,
1075 name: "@scope/a".to_string(),
1076 is_internal_dependency: false,
1077 },
1078 vec!["@scope/b".to_string()], ),
1080 (
1081 WorkspaceInfo {
1082 root: pkg_b,
1083 name: "@scope/b".to_string(),
1084 is_internal_dependency: false,
1085 },
1086 vec!["lodash".to_string()], ),
1088 ];
1089
1090 mark_internal_dependencies(&mut workspaces);
1091
1092 let ws_a = workspaces
1093 .iter()
1094 .find(|(ws, _)| ws.name == "@scope/a")
1095 .unwrap();
1096 assert!(
1097 !ws_a.0.is_internal_dependency,
1098 "a is not depended on by others"
1099 );
1100
1101 let ws_b = workspaces
1102 .iter()
1103 .find(|(ws, _)| ws.name == "@scope/b")
1104 .unwrap();
1105 assert!(ws_b.0.is_internal_dependency, "b is depended on by a");
1106 }
1107
1108 #[test]
1109 fn mark_internal_deps_no_cross_references() {
1110 let temp_dir = tempfile::tempdir().expect("create temp dir");
1111 let pkg_a = temp_dir.path().join("a");
1112 let pkg_b = temp_dir.path().join("b");
1113 std::fs::create_dir_all(&pkg_a).unwrap();
1114 std::fs::create_dir_all(&pkg_b).unwrap();
1115
1116 let mut workspaces = vec![
1117 (
1118 WorkspaceInfo {
1119 root: pkg_a,
1120 name: "a".to_string(),
1121 is_internal_dependency: false,
1122 },
1123 vec!["react".to_string()],
1124 ),
1125 (
1126 WorkspaceInfo {
1127 root: pkg_b,
1128 name: "b".to_string(),
1129 is_internal_dependency: false,
1130 },
1131 vec!["lodash".to_string()],
1132 ),
1133 ];
1134
1135 mark_internal_dependencies(&mut workspaces);
1136
1137 assert!(!workspaces[0].0.is_internal_dependency);
1138 assert!(!workspaces[1].0.is_internal_dependency);
1139 }
1140
1141 #[test]
1142 fn mark_internal_deps_deduplicates_by_path() {
1143 let temp_dir = tempfile::tempdir().expect("create temp dir");
1144 let pkg_a = temp_dir.path().join("a");
1145 std::fs::create_dir_all(&pkg_a).unwrap();
1146
1147 let mut workspaces = vec![
1148 (
1149 WorkspaceInfo {
1150 root: pkg_a.clone(),
1151 name: "a".to_string(),
1152 is_internal_dependency: false,
1153 },
1154 vec![],
1155 ),
1156 (
1157 WorkspaceInfo {
1158 root: pkg_a,
1159 name: "a".to_string(),
1160 is_internal_dependency: false,
1161 },
1162 vec![],
1163 ),
1164 ];
1165
1166 mark_internal_dependencies(&mut workspaces);
1167 assert_eq!(
1168 workspaces.len(),
1169 1,
1170 "duplicate paths should be deduplicated"
1171 );
1172 }
1173
1174 #[test]
1175 fn collect_patterns_from_package_json() {
1176 let dir = tempfile::tempdir().expect("create temp dir");
1177 std::fs::write(
1178 dir.path().join("package.json"),
1179 r#"{"workspaces": ["packages/*", "apps/*"]}"#,
1180 )
1181 .unwrap();
1182
1183 let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
1184 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
1185 }
1186
1187 #[test]
1188 fn collect_patterns_from_pnpm_workspace() {
1189 let dir = tempfile::tempdir().expect("create temp dir");
1190 std::fs::write(
1191 dir.path().join("pnpm-workspace.yaml"),
1192 "packages:\n - 'packages/*'\n - 'libs/*'\n",
1193 )
1194 .unwrap();
1195
1196 let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
1197 assert_eq!(patterns, vec!["packages/*", "libs/*"]);
1198 }
1199
1200 #[test]
1201 fn collect_patterns_combines_sources() {
1202 let dir = tempfile::tempdir().expect("create temp dir");
1203 std::fs::write(
1204 dir.path().join("package.json"),
1205 r#"{"workspaces": ["packages/*"]}"#,
1206 )
1207 .unwrap();
1208 std::fs::write(
1209 dir.path().join("pnpm-workspace.yaml"),
1210 "packages:\n - 'apps/*'\n",
1211 )
1212 .unwrap();
1213
1214 let patterns = collect_workspace_patterns(dir.path()).expect("valid root package.json");
1215 assert!(patterns.contains(&"packages/*".to_string()));
1216 assert!(patterns.contains(&"apps/*".to_string()));
1217 }
1218
1219 #[test]
1220 fn collect_patterns_empty_when_no_configs() {
1221 let dir = tempfile::tempdir().expect("create temp dir");
1222 let patterns = collect_workspace_patterns(dir.path()).expect("no root package.json");
1223 assert!(patterns.is_empty());
1224 }
1225
1226 #[test]
1227 fn discover_workspaces_from_package_json() {
1228 let dir = tempfile::tempdir().expect("create temp dir");
1229 let pkg_a = dir.path().join("packages").join("a");
1230 let pkg_b = dir.path().join("packages").join("b");
1231 std::fs::create_dir_all(&pkg_a).unwrap();
1232 std::fs::create_dir_all(&pkg_b).unwrap();
1233
1234 std::fs::write(
1235 dir.path().join("package.json"),
1236 r#"{"workspaces": ["packages/*"]}"#,
1237 )
1238 .unwrap();
1239 std::fs::write(
1240 pkg_a.join("package.json"),
1241 r#"{"name": "@test/a", "dependencies": {"@test/b": "workspace:*"}}"#,
1242 )
1243 .unwrap();
1244 std::fs::write(pkg_b.join("package.json"), r#"{"name": "@test/b"}"#).unwrap();
1245
1246 let workspaces = discover_workspaces(dir.path());
1247 assert_eq!(workspaces.len(), 2);
1248
1249 let ws_a = workspaces.iter().find(|ws| ws.name == "@test/a").unwrap();
1250 assert!(!ws_a.is_internal_dependency);
1251
1252 let ws_b = workspaces.iter().find(|ws| ws.name == "@test/b").unwrap();
1253 assert!(ws_b.is_internal_dependency, "b is depended on by a");
1254 }
1255
1256 #[test]
1257 fn discover_workspaces_empty_project() {
1258 let dir = tempfile::tempdir().expect("create temp dir");
1259 let workspaces = discover_workspaces(dir.path());
1260 assert!(workspaces.is_empty());
1261 }
1262
1263 #[test]
1264 fn discover_workspaces_falls_back_to_shallow_packages_without_workspace_config() {
1265 let dir = tempfile::tempdir().expect("create temp dir");
1266 let benchmarks = dir.path().join("benchmarks");
1267 let vscode = dir.path().join("editors").join("vscode");
1268 let deep = dir.path().join("tests").join("fixtures").join("demo");
1269 std::fs::create_dir_all(&benchmarks).unwrap();
1270 std::fs::create_dir_all(&vscode).unwrap();
1271 std::fs::create_dir_all(&deep).unwrap();
1272
1273 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1274 std::fs::write(vscode.join("package.json"), r#"{"name": "fallow-vscode"}"#).unwrap();
1275 std::fs::write(deep.join("package.json"), r#"{"name": "deep-fixture"}"#).unwrap();
1276
1277 let workspaces = discover_workspaces(dir.path());
1278 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1279
1280 assert!(
1281 names.contains(&"benchmarks"),
1282 "top-level nested package should be discovered: {workspaces:?}"
1283 );
1284 assert!(
1285 names.contains(&"fallow-vscode"),
1286 "second-level nested package should be discovered: {workspaces:?}"
1287 );
1288 assert!(
1289 !names.contains(&"deep-fixture"),
1290 "fallback should stay shallow and skip deep fixtures: {workspaces:?}"
1291 );
1292 }
1293
1294 #[test]
1295 fn discover_workspaces_with_negated_patterns() {
1296 let dir = tempfile::tempdir().expect("create temp dir");
1297 let pkg_a = dir.path().join("packages").join("a");
1298 let pkg_test = dir.path().join("packages").join("test-utils");
1299 std::fs::create_dir_all(&pkg_a).unwrap();
1300 std::fs::create_dir_all(&pkg_test).unwrap();
1301
1302 std::fs::write(
1303 dir.path().join("package.json"),
1304 r#"{"workspaces": ["packages/*", "!packages/test-*"]}"#,
1305 )
1306 .unwrap();
1307 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1308 std::fs::write(pkg_test.join("package.json"), r#"{"name": "test-utils"}"#).unwrap();
1309
1310 let workspaces = discover_workspaces(dir.path());
1311 assert_eq!(workspaces.len(), 1);
1312 assert_eq!(workspaces[0].name, "a");
1313 }
1314
1315 #[test]
1316 fn discover_workspaces_skips_root_as_workspace() {
1317 let dir = tempfile::tempdir().expect("create temp dir");
1318 std::fs::write(
1319 dir.path().join("pnpm-workspace.yaml"),
1320 "packages:\n - '.'\n",
1321 )
1322 .unwrap();
1323 std::fs::write(dir.path().join("package.json"), r#"{"name": "root"}"#).unwrap();
1324
1325 let workspaces = discover_workspaces(dir.path());
1326 assert!(
1327 workspaces.is_empty(),
1328 "root directory should not be added as workspace"
1329 );
1330 }
1331
1332 #[test]
1333 fn discover_workspaces_name_fallback_to_dir_name() {
1334 let dir = tempfile::tempdir().expect("create temp dir");
1335 let pkg_a = dir.path().join("packages").join("my-app");
1336 std::fs::create_dir_all(&pkg_a).unwrap();
1337
1338 std::fs::write(
1339 dir.path().join("package.json"),
1340 r#"{"workspaces": ["packages/*"]}"#,
1341 )
1342 .unwrap();
1343 std::fs::write(pkg_a.join("package.json"), "{}").unwrap();
1344
1345 let workspaces = discover_workspaces(dir.path());
1346 assert_eq!(workspaces.len(), 1);
1347 assert_eq!(workspaces[0].name, "my-app", "should fall back to dir name");
1348 }
1349
1350 #[test]
1351 fn discover_workspaces_explicit_patterns_disable_shallow_fallback() {
1352 let dir = tempfile::tempdir().expect("create temp dir");
1353 let pkg_a = dir.path().join("packages").join("a");
1354 let benchmarks = dir.path().join("benchmarks");
1355 std::fs::create_dir_all(&pkg_a).unwrap();
1356 std::fs::create_dir_all(&benchmarks).unwrap();
1357
1358 std::fs::write(
1359 dir.path().join("package.json"),
1360 r#"{"workspaces": ["packages/*"]}"#,
1361 )
1362 .unwrap();
1363 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1364 std::fs::write(benchmarks.join("package.json"), r#"{"name": "benchmarks"}"#).unwrap();
1365
1366 let workspaces = discover_workspaces(dir.path());
1367 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1368
1369 assert_eq!(workspaces.len(), 1);
1370 assert!(names.contains(&"a"));
1371 assert!(
1372 !names.contains(&"benchmarks"),
1373 "explicit workspace config should keep undeclared packages out: {workspaces:?}"
1374 );
1375 }
1376
1377 #[test]
1378 fn discover_workspaces_recovers_package_under_bare_glob_intermediate() {
1379 let dir = tempfile::tempdir().expect("create temp dir");
1387 let theme = dir
1388 .path()
1389 .join("packages")
1390 .join("themes")
1391 .join("metrists-theme-next");
1392 std::fs::create_dir_all(&theme).unwrap();
1393 std::fs::write(
1394 dir.path().join("package.json"),
1395 r#"{"name": "metrists-monorepo", "workspaces": ["./packages/*", "./themes/*"]}"#,
1396 )
1397 .unwrap();
1398 std::fs::write(
1400 theme.join("package.json"),
1401 r#"{"name": "metrists-theme-next", "dependencies": {"react": "^18"}}"#,
1402 )
1403 .unwrap();
1404
1405 let workspaces = discover_workspaces(dir.path());
1406 assert!(
1407 workspaces.iter().any(|ws| ws.name == "metrists-theme-next"),
1408 "deep package under a bare glob-matched intermediate must be discovered: {workspaces:?}"
1409 );
1410 }
1411
1412 fn write_package(dir: &Path, manifest: &str) {
1413 std::fs::create_dir_all(dir).unwrap();
1414 std::fs::write(dir.join("package.json"), manifest).unwrap();
1415 }
1416
1417 #[test]
1418 fn discover_workspaces_follows_deep_link_dependency_targets() {
1419 let dir = tempfile::tempdir().expect("create temp dir");
1423 let root = dir.path();
1424 std::fs::write(
1425 root.join("package.json"),
1426 r#"{
1427 "name": "kibana",
1428 "dependencies": {
1429 "@kbn/foo": "link:src/platform/packages/shared/kbn-foo",
1430 "@kbn/app": "link:./x-pack/solutions/search/plugins/app"
1431 },
1432 "devDependencies": {
1433 "@kbn/test": "file:src/platform/packages/private/kbn-test"
1434 }
1435 }"#,
1436 )
1437 .unwrap();
1438 write_package(
1439 &root.join("src/platform/packages/shared/kbn-foo"),
1440 r#"{"name": "@kbn/foo"}"#,
1441 );
1442 write_package(
1443 &root.join("x-pack/solutions/search/plugins/app"),
1444 r#"{"name": "@kbn/app", "dependencies": {"@kbn/foo": "link:../../../../../src/platform/packages/shared/kbn-foo"}}"#,
1445 );
1446 write_package(
1447 &root.join("src/platform/packages/private/kbn-test"),
1448 r#"{"name": "@kbn/test"}"#,
1449 );
1450
1451 let workspaces = discover_workspaces(root);
1452 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1453 names.sort_unstable();
1454 assert_eq!(names, ["@kbn/app", "@kbn/foo", "@kbn/test"]);
1455 let foo = workspaces.iter().find(|ws| ws.name == "@kbn/foo").unwrap();
1456 assert!(foo.is_internal_dependency, "{workspaces:?}");
1457 }
1458
1459 #[test]
1460 fn discover_workspaces_skips_link_targets_outside_root_or_without_manifest() {
1461 let dir = tempfile::tempdir().expect("create temp dir");
1462 let root = dir.path().join("repo");
1463 std::fs::create_dir_all(&root).unwrap();
1464 write_package(&dir.path().join("sibling"), r#"{"name": "sibling"}"#);
1465 std::fs::create_dir_all(root.join("packages/no-manifest")).unwrap();
1466 std::fs::write(root.join("vendor.tgz"), "").unwrap();
1467 std::fs::write(
1468 root.join("package.json"),
1469 r#"{
1470 "dependencies": {
1471 "sibling": "link:../sibling",
1472 "absolute": "file:/definitely/not/here",
1473 "missing": "link:packages/missing",
1474 "no-manifest": "link:packages/no-manifest",
1475 "tarball": "file:./vendor.tgz",
1476 "self": "link:.",
1477 "react": "^18.0.0"
1478 }
1479 }"#,
1480 )
1481 .unwrap();
1482
1483 let workspaces = discover_workspaces(&root);
1484 assert!(workspaces.is_empty(), "{workspaces:?}");
1485 }
1486
1487 #[test]
1488 fn discover_workspaces_skips_link_targets_that_source_discovery_skips() {
1489 let dir = tempfile::tempdir().expect("create temp dir");
1490 let root = dir.path();
1491 std::fs::write(
1492 root.join("package.json"),
1493 r#"{
1494 "dependencies": {
1495 "ylib": "file:.yalc/ylib",
1496 "nm": "link:node_modules/nm",
1497 "blib": "link:build/blib",
1498 "dlib": "link:dist/dlib",
1499 "clib": "link:packages/coverage/clib",
1500 "vlib": "link:vendor/deep/vlib",
1501 "kept": "link:libs/deep/kept"
1502 }
1503 }"#,
1504 )
1505 .unwrap();
1506 for (path, name) in [
1507 (".yalc/ylib", "ylib"),
1508 ("node_modules/nm", "nm"),
1509 ("build/blib", "blib"),
1510 ("dist/dlib", "dlib"),
1511 ("packages/coverage/clib", "clib"),
1512 ("vendor/deep/vlib", "vlib"),
1513 ("libs/deep/kept", "kept"),
1514 ] {
1515 write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1516 }
1517 let mut builder = globset::GlobSetBuilder::new();
1518 builder.add(globset::Glob::new("vendor/**").unwrap());
1519 let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
1520
1521 let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1522 let names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1523 assert_eq!(names, ["kept"]);
1524 }
1525
1526 fn write_gitignored_link_fixture(root: &Path) {
1527 std::fs::write(
1528 root.join("package.json"),
1529 r#"{
1530 "dependencies": {
1531 "vlib": "file:vendor/deep/vlib",
1532 "plib": "link:libs/private/plib",
1533 "kept": "link:libs/deep/kept"
1534 }
1535 }"#,
1536 )
1537 .unwrap();
1538 for (path, name) in [
1539 ("vendor/deep/vlib", "vlib"),
1540 ("libs/private/plib", "plib"),
1541 ("libs/deep/kept", "kept"),
1542 ] {
1543 write_package(&root.join(path), &format!(r#"{{"name": "{name}"}}"#));
1544 }
1545 std::fs::write(root.join(".gitignore"), "vendor/\n").unwrap();
1546 std::fs::write(root.join("libs/.gitignore"), "private/\n").unwrap();
1547 }
1548
1549 fn link_workspace_names(root: &Path) -> Vec<String> {
1550 let ignore = crate::IgnorePatternSet::default();
1551 let (workspaces, _) = discover_workspaces_with_diagnostics(root, &ignore).unwrap();
1552 let mut names: Vec<String> = workspaces.into_iter().map(|ws| ws.name).collect();
1553 names.sort_unstable();
1554 names
1555 }
1556
1557 #[test]
1558 fn discover_workspaces_skips_gitignored_link_targets() {
1559 let dir = tempfile::tempdir().expect("create temp dir");
1560 let root = dir.path();
1561 std::fs::create_dir_all(root.join(".git")).unwrap();
1562 write_gitignored_link_fixture(root);
1563
1564 assert_eq!(link_workspace_names(root), ["kept"]);
1565 }
1566
1567 #[test]
1568 fn discover_workspaces_keeps_link_targets_when_gitignore_does_not_apply() {
1569 let dir = tempfile::tempdir().expect("create temp dir");
1571 let root = dir.path();
1572 write_gitignored_link_fixture(root);
1573
1574 assert_eq!(link_workspace_names(root), ["kept", "plib", "vlib"]);
1575 }
1576
1577 #[test]
1578 fn discover_workspaces_dedupes_link_targets_with_declared_workspaces() {
1579 let dir = tempfile::tempdir().expect("create temp dir");
1580 let root = dir.path();
1581 std::fs::write(
1582 root.join("package.json"),
1583 r#"{
1584 "workspaces": ["packages/*"],
1585 "dependencies": {
1586 "a": "link:packages/a",
1587 "tool": "link:tools/deep/tool"
1588 }
1589 }"#,
1590 )
1591 .unwrap();
1592 write_package(&root.join("packages/a"), r#"{"name": "a"}"#);
1593 write_package(&root.join("tools/deep/tool"), r#"{"name": "tool"}"#);
1594
1595 let workspaces = discover_workspaces(root);
1596 let mut names: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
1597 names.sort_unstable();
1598 assert_eq!(names, ["a", "tool"]);
1599 assert!(
1600 find_undeclared_workspaces(root, &workspaces).is_empty(),
1601 "a linked package is declared, not undeclared"
1602 );
1603 }
1604
1605 #[test]
1606 fn undeclared_workspace_detected() {
1607 let dir = tempfile::tempdir().expect("create temp dir");
1608 let pkg_a = dir.path().join("packages").join("a");
1609 let pkg_b = dir.path().join("packages").join("b");
1610 std::fs::create_dir_all(&pkg_a).unwrap();
1611 std::fs::create_dir_all(&pkg_b).unwrap();
1612
1613 std::fs::write(
1614 dir.path().join("package.json"),
1615 r#"{"workspaces": ["packages/a"]}"#,
1616 )
1617 .unwrap();
1618 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1619 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1620
1621 let declared = discover_workspaces(dir.path());
1622 assert_eq!(declared.len(), 1);
1623
1624 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1625 assert_eq!(undeclared.len(), 1);
1626 assert!(
1627 undeclared[0]
1628 .path
1629 .to_string_lossy()
1630 .replace('\\', "/")
1631 .contains("packages/b"),
1632 "should detect packages/b as undeclared: {:?}",
1633 undeclared[0].path
1634 );
1635 }
1636
1637 #[test]
1638 fn no_undeclared_when_all_covered() {
1639 let dir = tempfile::tempdir().expect("create temp dir");
1640 let pkg_a = dir.path().join("packages").join("a");
1641 std::fs::create_dir_all(&pkg_a).unwrap();
1642
1643 std::fs::write(
1644 dir.path().join("package.json"),
1645 r#"{"workspaces": ["packages/*"]}"#,
1646 )
1647 .unwrap();
1648 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1649
1650 let declared = discover_workspaces(dir.path());
1651 let undeclared = find_undeclared_workspaces(dir.path(), &declared);
1652 assert!(undeclared.is_empty());
1653 }
1654
1655 #[test]
1656 fn no_undeclared_when_no_workspace_patterns() {
1657 let dir = tempfile::tempdir().expect("create temp dir");
1658 let sub = dir.path().join("lib");
1659 std::fs::create_dir_all(&sub).unwrap();
1660
1661 std::fs::write(dir.path().join("package.json"), r#"{"name": "app"}"#).unwrap();
1662 std::fs::write(sub.join("package.json"), r#"{"name": "lib"}"#).unwrap();
1663
1664 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1665 assert!(
1666 undeclared.is_empty(),
1667 "should skip check when no workspace patterns exist"
1668 );
1669 }
1670
1671 #[test]
1672 fn undeclared_skips_node_modules_and_hidden_dirs() {
1673 let dir = tempfile::tempdir().expect("create temp dir");
1674 let nm = dir.path().join("node_modules").join("some-pkg");
1675 let hidden = dir.path().join(".hidden");
1676 std::fs::create_dir_all(&nm).unwrap();
1677 std::fs::create_dir_all(&hidden).unwrap();
1678
1679 std::fs::write(
1680 dir.path().join("package.json"),
1681 r#"{"workspaces": ["packages/*"]}"#,
1682 )
1683 .unwrap();
1684 std::fs::write(nm.join("package.json"), r#"{"name": "nm-pkg"}"#).unwrap();
1685 std::fs::write(hidden.join("package.json"), r#"{"name": "hidden"}"#).unwrap();
1686
1687 let undeclared = find_undeclared_workspaces(dir.path(), &[]);
1688 assert!(
1689 undeclared.is_empty(),
1690 "should not flag node_modules or hidden directories"
1691 );
1692 }
1693
1694 fn build_globset(patterns: &[&str]) -> crate::IgnorePatternSet {
1695 let mut builder = globset::GlobSetBuilder::new();
1696 for pattern in patterns {
1697 builder.add(globset::Glob::new(pattern).expect("valid glob"));
1698 }
1699 crate::IgnorePatternSet::from(builder.build().expect("build globset"))
1700 }
1701
1702 #[test]
1703 fn undeclared_skips_dirs_matching_ignore_patterns() {
1704 let dir = tempfile::tempdir().expect("create temp dir");
1705 let pkg_a = dir.path().join("packages").join("a");
1706 let vitest_ref = dir.path().join("references").join("vitest");
1707 let tanstack_ref = dir.path().join("references").join("tanstack-router");
1708 std::fs::create_dir_all(&pkg_a).unwrap();
1709 std::fs::create_dir_all(&vitest_ref).unwrap();
1710 std::fs::create_dir_all(&tanstack_ref).unwrap();
1711
1712 std::fs::write(
1713 dir.path().join("package.json"),
1714 r#"{"workspaces": ["packages/*"]}"#,
1715 )
1716 .unwrap();
1717 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1718 std::fs::write(
1719 vitest_ref.join("package.json"),
1720 r#"{"name": "vitest-reference"}"#,
1721 )
1722 .unwrap();
1723 std::fs::write(
1724 tanstack_ref.join("package.json"),
1725 r#"{"name": "tanstack-reference"}"#,
1726 )
1727 .unwrap();
1728
1729 let declared = discover_workspaces(dir.path());
1730 let ignore = build_globset(&["references/*"]);
1731 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1732 assert!(
1733 undeclared.is_empty(),
1734 "references/* should be ignored: {undeclared:?}"
1735 );
1736 }
1737
1738 #[test]
1739 fn undeclared_still_reported_when_ignore_does_not_match() {
1740 let dir = tempfile::tempdir().expect("create temp dir");
1741 let pkg_b = dir.path().join("packages").join("b");
1742 std::fs::create_dir_all(&pkg_b).unwrap();
1743
1744 std::fs::write(
1745 dir.path().join("package.json"),
1746 r#"{"workspaces": ["packages/a"]}"#,
1747 )
1748 .unwrap();
1749 std::fs::write(pkg_b.join("package.json"), r#"{"name": "b"}"#).unwrap();
1750
1751 let declared = discover_workspaces(dir.path());
1752 let ignore = build_globset(&["references/*"]);
1753 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1754 assert_eq!(
1755 undeclared.len(),
1756 1,
1757 "non-matching ignore patterns should not silence other undeclared dirs"
1758 );
1759 }
1760
1761 #[test]
1762 fn undeclared_skips_dirs_matching_package_json_glob() {
1763 let dir = tempfile::tempdir().expect("create temp dir");
1764 let pkg_a = dir.path().join("packages").join("a");
1765 let vitest_ref = dir.path().join("references").join("vitest");
1766 std::fs::create_dir_all(&pkg_a).unwrap();
1767 std::fs::create_dir_all(&vitest_ref).unwrap();
1768
1769 std::fs::write(
1770 dir.path().join("package.json"),
1771 r#"{"workspaces": ["packages/*"]}"#,
1772 )
1773 .unwrap();
1774 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1775 std::fs::write(
1776 vitest_ref.join("package.json"),
1777 r#"{"name": "vitest-reference"}"#,
1778 )
1779 .unwrap();
1780
1781 let declared = discover_workspaces(dir.path());
1782 let ignore = build_globset(&["references/*/package.json"]);
1783 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1784 assert!(
1785 undeclared.is_empty(),
1786 "package.json-suffixed glob should silence the warning: {undeclared:?}"
1787 );
1788 }
1789
1790 #[test]
1791 fn undeclared_skips_dirs_matching_doublestar_ignore() {
1792 let dir = tempfile::tempdir().expect("create temp dir");
1793 let pkg_a = dir.path().join("packages").join("a");
1794 let nested_ref = dir.path().join("references").join("vitest");
1795 std::fs::create_dir_all(&pkg_a).unwrap();
1796 std::fs::create_dir_all(&nested_ref).unwrap();
1797
1798 std::fs::write(
1799 dir.path().join("package.json"),
1800 r#"{"workspaces": ["packages/*"]}"#,
1801 )
1802 .unwrap();
1803 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1804 std::fs::write(
1805 nested_ref.join("package.json"),
1806 r#"{"name": "vitest-reference"}"#,
1807 )
1808 .unwrap();
1809
1810 let declared = discover_workspaces(dir.path());
1811 let ignore = build_globset(&["**/references/**"]);
1812 let undeclared = find_undeclared_workspaces_with_ignores(dir.path(), &declared, &ignore);
1813 assert!(
1814 undeclared.is_empty(),
1815 "**/references/** should ignore nested package.json dirs: {undeclared:?}"
1816 );
1817 }
1818
1819 #[test]
1820 fn malformed_workspace_package_json_emits_diagnostic() {
1821 let dir = tempfile::tempdir().expect("create temp dir");
1822 let pkg_a = dir.path().join("packages").join("a");
1823 let pkg_bad = dir.path().join("packages").join("bad");
1824 std::fs::create_dir_all(&pkg_a).unwrap();
1825 std::fs::create_dir_all(&pkg_bad).unwrap();
1826 std::fs::write(
1827 dir.path().join("package.json"),
1828 r#"{"workspaces": ["packages/*"]}"#,
1829 )
1830 .unwrap();
1831 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1832 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad",}"#).unwrap();
1833
1834 let (result, captured) = capture_workspace_warnings(|| {
1835 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1836 });
1837 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1838
1839 assert_eq!(workspaces.len(), 1, "the valid workspace still discovers");
1840 assert_eq!(workspaces[0].name, "a");
1841 assert_eq!(diagnostics.len(), 1);
1842 assert!(matches!(
1843 diagnostics[0].kind,
1844 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1845 ));
1846 assert!(
1847 captured
1848 .iter()
1849 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1850 );
1851 }
1852
1853 #[test]
1854 fn malformed_bridge_deno_config_emits_recoverable_member_diagnostic() {
1855 let dir = tempfile::tempdir().expect("create temp dir");
1856 let pkg_good = dir.path().join("packages/good");
1857 let pkg_bad = dir.path().join("packages/bad");
1858 std::fs::create_dir_all(&pkg_good).unwrap();
1859 std::fs::create_dir_all(&pkg_bad).unwrap();
1860 std::fs::write(
1861 dir.path().join("package.json"),
1862 r#"{"workspaces": ["packages/*"]}"#,
1863 )
1864 .unwrap();
1865 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1866 std::fs::write(pkg_bad.join("package.json"), r#"{"name": "bad"}"#).unwrap();
1867 std::fs::write(pkg_bad.join("deno.jsonc"), "{ imports: [ }").unwrap();
1868
1869 let result =
1870 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
1871 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1872
1873 assert_eq!(workspaces.len(), 1, "valid sibling should still discover");
1874 assert_eq!(workspaces[0].name, "good");
1875 assert_eq!(diagnostics.len(), 1);
1876 assert!(matches!(
1877 diagnostics[0].kind,
1878 WorkspaceDiagnosticKind::MalformedPackageJson { .. }
1879 ));
1880 assert!(diagnostics[0].message.contains("deno.jsonc"));
1881 }
1882
1883 #[test]
1893 fn malformed_member_reached_via_two_sources_is_diagnosed_once() {
1894 let dir = tempfile::tempdir().expect("create temp dir");
1895 let pkg_good = dir.path().join("packages").join("good");
1896 let pkg_bad = dir.path().join("packages").join("bad");
1897 std::fs::create_dir_all(&pkg_good).unwrap();
1898 std::fs::create_dir_all(&pkg_bad).unwrap();
1899 std::fs::write(
1900 dir.path().join("package.json"),
1901 r#"{"workspaces": ["packages/*"]}"#,
1902 )
1903 .unwrap();
1904 std::fs::write(
1905 dir.path().join("tsconfig.json"),
1906 r#"{"references": [{"path": "./packages/bad"}]}"#,
1907 )
1908 .unwrap();
1909 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1910 std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1911
1912 let (result, _) = capture_workspace_warnings(|| {
1913 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1914 });
1915 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1916
1917 assert!(
1918 workspaces.iter().any(|w| w.name == "good"),
1919 "valid sibling should still discover: {workspaces:?}"
1920 );
1921 assert!(
1922 workspaces.iter().any(|w| w.name == "bad"),
1923 "tsconfig reference falls back to the directory name on manifest error"
1924 );
1925 let malformed: Vec<_> = diagnostics
1926 .iter()
1927 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1928 .collect();
1929 assert_eq!(
1930 malformed.len(),
1931 1,
1932 "the replayed Err is one diagnostic, not one per source: {diagnostics:?}"
1933 );
1934 assert!(
1935 malformed[0].path.ends_with("bad"),
1936 "the diagnostic points at the malformed member"
1937 );
1938 }
1939
1940 #[test]
1951 fn manifest_cache_replays_the_same_err_on_every_visit() {
1952 let dir = tempfile::tempdir().expect("create temp dir");
1953 let member = dir.path().join("packages").join("bad");
1954 std::fs::create_dir_all(&member).unwrap();
1955 std::fs::write(member.join("package.json"), r"{,}").unwrap();
1956
1957 let mut cache = ManifestCache::default();
1958 let first = load_member_package_manifest_cached(&member, &mut cache)
1959 .expect_err("a malformed member manifest is an Err");
1960 assert_eq!(cache.len(), 1, "the outcome is memoized: {cache:?}");
1961
1962 std::fs::remove_file(member.join("package.json")).unwrap();
1963 let replayed = load_member_package_manifest_cached(&member, &mut cache)
1964 .expect_err("the memoized Err replays instead of re-reading the directory");
1965 assert_eq!(
1966 replayed, first,
1967 "the replayed outcome is byte-identical to the first, so both \
1968 workspace sources diagnose the same member the same way"
1969 );
1970 }
1971
1972 #[test]
1973 fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1974 let dir = tempfile::tempdir().expect("create temp dir");
1975 for name in ["a", "b", "c"] {
1976 let pkg = dir.path().join("packages").join(name);
1977 std::fs::create_dir_all(&pkg).unwrap();
1978 std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1979 }
1980 std::fs::write(
1981 dir.path().join("package.json"),
1982 r#"{"workspaces": ["packages/*"]}"#,
1983 )
1984 .unwrap();
1985
1986 let (result, _) = capture_workspace_warnings(|| {
1987 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
1988 });
1989 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1990
1991 assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1992 assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1993 assert!(
1994 diagnostics
1995 .iter()
1996 .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1997 "every diagnostic should be malformed-package-json"
1998 );
1999 }
2000
2001 #[test]
2002 fn malformed_root_package_json_returns_load_error() {
2003 let dir = tempfile::tempdir().expect("create temp dir");
2004 std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
2005
2006 let result =
2007 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2008
2009 match result {
2010 Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
2011 assert!(path.ends_with("package.json"));
2012 assert!(!error.is_empty(), "underlying parse error is preserved");
2013 }
2014 other => panic!("expected MalformedRootPackageJson, got {other:?}"),
2015 }
2016 }
2017
2018 #[test]
2019 fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
2020 let dir = tempfile::tempdir().expect("create temp dir");
2021 let pkg_a = dir.path().join("packages").join("a");
2022 let cache_dir = dir.path().join("packages").join(".cache");
2023 let scratch_dir = dir.path().join("packages").join("scratch");
2024 std::fs::create_dir_all(&pkg_a).unwrap();
2025 std::fs::create_dir_all(&cache_dir).unwrap();
2026 std::fs::create_dir_all(&scratch_dir).unwrap();
2027 std::fs::write(
2028 dir.path().join("package.json"),
2029 r#"{"workspaces": ["packages/*"]}"#,
2030 )
2031 .unwrap();
2032 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2033
2034 let result =
2035 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2036 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2037
2038 assert_eq!(workspaces.len(), 1);
2039 let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
2040 assert!(
2041 kinds.contains(&"glob-matched-no-package-json"),
2042 "scratch should diagnose: {kinds:?}"
2043 );
2044 assert!(
2045 !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
2046 ".cache must be skip-listed: {diagnostics:?}"
2047 );
2048 }
2049
2050 #[test]
2051 fn glob_match_without_package_json_honors_ignore_patterns() {
2052 let dir = tempfile::tempdir().expect("create temp dir");
2053 let pkg_a = dir.path().join("packages").join("a");
2054 let legacy_dir = dir.path().join("packages").join("legacy");
2055 std::fs::create_dir_all(&pkg_a).unwrap();
2056 std::fs::create_dir_all(&legacy_dir).unwrap();
2057 std::fs::write(
2058 dir.path().join("package.json"),
2059 r#"{"workspaces": ["packages/*"]}"#,
2060 )
2061 .unwrap();
2062 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2063
2064 let mut builder = globset::GlobSetBuilder::new();
2065 builder.add(globset::Glob::new("packages/legacy").unwrap());
2066 let ignore = crate::IgnorePatternSet::from(builder.build().unwrap());
2067
2068 let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
2069 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2070
2071 assert_eq!(workspaces.len(), 1);
2072 assert!(
2073 diagnostics.is_empty(),
2074 "user-excluded path must not produce a diagnostic: {diagnostics:?}"
2075 );
2076 }
2077
2078 #[test]
2079 fn malformed_tsconfig_emits_diagnostic() {
2080 let dir = tempfile::tempdir().expect("create temp dir");
2081 std::fs::write(
2082 dir.path().join("package.json"),
2083 r#"{"workspaces": ["packages/*"]}"#,
2084 )
2085 .unwrap();
2086 std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
2087
2088 let result =
2089 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2090 let (_, diagnostics) = result.expect("root package.json is valid");
2091
2092 assert!(
2093 diagnostics
2094 .iter()
2095 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2096 "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
2097 );
2098 }
2099
2100 #[test]
2101 fn tsconfig_missing_reference_dir_emits_diagnostic() {
2102 let dir = tempfile::tempdir().expect("create temp dir");
2103 std::fs::write(
2104 dir.path().join("tsconfig.json"),
2105 r#"{"references": [{"path": "./packages/missing"}]}"#,
2106 )
2107 .unwrap();
2108
2109 let result =
2110 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2111 let (_, diagnostics) = result.expect("no package.json at root is OK");
2112
2113 assert!(
2114 diagnostics
2115 .iter()
2116 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
2117 "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
2118 );
2119 }
2120
2121 #[test]
2122 fn missing_tsconfig_is_silent() {
2123 let dir = tempfile::tempdir().expect("create temp dir");
2124
2125 let result =
2126 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2127 let (_, diagnostics) = result.expect("no root package.json is OK");
2128
2129 assert!(
2130 !diagnostics
2131 .iter()
2132 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
2133 "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
2134 );
2135 }
2136
2137 #[test]
2138 fn shallow_scan_malformed_package_json_stays_silent() {
2139 let dir = tempfile::tempdir().expect("create temp dir");
2140 let scratch = dir.path().join("scratch");
2141 std::fs::create_dir_all(&scratch).unwrap();
2142 std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
2143
2144 let result =
2145 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2146 let (_, diagnostics) = result.expect("no root package.json is OK");
2147
2148 assert!(
2149 !diagnostics
2150 .iter()
2151 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
2152 "shallow-scan malformed must stay silent: {diagnostics:?}"
2153 );
2154 }
2155
2156 #[test]
2157 fn mixed_valid_and_malformed_workspaces_partial_recovery() {
2158 let dir = tempfile::tempdir().expect("create temp dir");
2159 let pkg_good = dir.path().join("packages").join("good");
2160 let pkg_bad = dir.path().join("packages").join("bad");
2161 std::fs::create_dir_all(&pkg_good).unwrap();
2162 std::fs::create_dir_all(&pkg_bad).unwrap();
2163 std::fs::write(
2164 dir.path().join("package.json"),
2165 r#"{"workspaces": ["packages/*"]}"#,
2166 )
2167 .unwrap();
2168 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
2169 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
2170
2171 let result =
2172 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2173 let (workspaces, diagnostics) = result.expect("root package.json is valid");
2174
2175 assert_eq!(workspaces.len(), 1);
2176 assert_eq!(workspaces[0].name, "good");
2177 assert_eq!(diagnostics.len(), 1);
2178 assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
2179 }
2180
2181 #[test]
2182 fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
2183 let dir = tempfile::tempdir().expect("create temp dir");
2184 std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
2185
2186 let workspaces = discover_workspaces(dir.path());
2187 assert!(
2188 workspaces.is_empty(),
2189 "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
2190 );
2191 }
2192
2193 #[test]
2194 fn discovers_deno_workspace_members_without_package_json() {
2195 let dir = tempfile::tempdir().expect("create temp dir");
2196 let core = dir.path().join("packages").join("core");
2197 let app = dir.path().join("apps").join("desktop");
2198 std::fs::create_dir_all(&core).unwrap();
2199 std::fs::create_dir_all(&app).unwrap();
2200
2201 std::fs::write(
2202 dir.path().join("deno.json"),
2203 r#"{
2204 "workspace": ["./apps/*", "./packages/*"],
2205 "imports": { "@std/assert": "jsr:@std/assert@1" }
2206 }"#,
2207 )
2208 .unwrap();
2209 std::fs::write(
2210 core.join("deno.json"),
2211 r#"{
2212 "name": "@fallow/core",
2213 "exports": { ".": "./mod.ts", "./result": "./result.ts" }
2214 }"#,
2215 )
2216 .unwrap();
2217 std::fs::write(
2218 app.join("deno.json"),
2219 r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
2220 )
2221 .unwrap();
2222
2223 let workspaces = discover_workspaces(dir.path());
2224 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2225 names.sort_unstable();
2226 assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
2227 assert!(
2228 workspaces
2229 .iter()
2230 .all(|w| !w.root.join("package.json").exists())
2231 );
2232 assert!(
2233 workspaces.iter().all(|w| !w.is_internal_dependency),
2234 "Deno packages need dependency evidence before inventory marks them internal"
2235 );
2236 }
2237
2238 #[test]
2239 fn malformed_root_deno_config_returns_load_error() {
2240 let dir = tempfile::tempdir().expect("create temp dir");
2241 std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
2242
2243 let result =
2244 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty());
2245
2246 match result {
2247 Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
2248 assert!(path.ends_with("deno.jsonc"));
2249 assert!(!error.is_empty(), "underlying parse error is preserved");
2250 }
2251 other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
2252 }
2253 }
2254
2255 #[test]
2256 fn deno_and_npm_workspace_patterns_are_additive() {
2257 let dir = tempfile::tempdir().expect("create temp dir");
2258 let a = dir.path().join("packages").join("a");
2259 let b = dir.path().join("packages").join("b");
2260 std::fs::create_dir_all(&a).unwrap();
2261 std::fs::create_dir_all(&b).unwrap();
2262
2263 std::fs::write(
2264 dir.path().join("package.json"),
2265 r#"{"workspaces": ["packages/a"]}"#,
2266 )
2267 .unwrap();
2268 std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
2269 std::fs::write(
2270 dir.path().join("deno.json"),
2271 r#"{"workspace": ["./packages/b"]}"#,
2272 )
2273 .unwrap();
2274 std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
2275
2276 let workspaces = discover_workspaces(dir.path());
2277 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
2278 names.sort_unstable();
2279 assert_eq!(names, vec!["a", "b"]);
2280 }
2281
2282 #[test]
2288 fn one_glob_declared_in_two_manifests_reports_each_directory_once() {
2289 let dir = tempfile::tempdir().expect("create temp dir");
2290 write_two_manifest_glob_project(dir.path());
2291
2292 let (_, diagnostics) =
2293 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2294 .expect("root package.json is valid");
2295
2296 let mut reported: Vec<(String, String)> = diagnostics
2297 .iter()
2298 .filter_map(|diagnostic| match &diagnostic.kind {
2299 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => Some((
2300 pattern.clone(),
2301 diagnostic
2302 .path
2303 .strip_prefix(dir.path())
2304 .unwrap_or(&diagnostic.path)
2305 .display()
2306 .to_string()
2307 .replace('\\', "/"),
2308 )),
2309 _ => None,
2310 })
2311 .collect();
2312 reported.sort();
2313
2314 assert_eq!(
2315 reported,
2316 vec![
2317 ("pkgs/*".to_owned(), "pkgs/aaa".to_owned()),
2318 ("pkgs/*".to_owned(), "pkgs/bbb".to_owned()),
2319 ],
2320 "one glob spelled two ways is one diagnostic per directory: {diagnostics:?}"
2321 );
2322 }
2323
2324 #[test]
2328 fn overlapping_globs_still_report_one_directory_once_per_pattern() {
2329 let dir = tempfile::tempdir().expect("create temp dir");
2330 std::fs::create_dir_all(dir.path().join("pkgs/aaa")).unwrap();
2331 std::fs::write(
2332 dir.path().join("package.json"),
2333 r#"{"name":"overlap-root","private":true,"workspaces":["pkgs/*","pkgs/a*"]}"#,
2334 )
2335 .unwrap();
2336 std::fs::write(dir.path().join("pkgs/aaa/readme.txt"), "no package.json\n").unwrap();
2337
2338 let (_, diagnostics) =
2339 discover_workspaces_with_diagnostics(dir.path(), &crate::IgnorePatternSet::empty())
2340 .expect("root package.json is valid");
2341
2342 let patterns: Vec<&str> = diagnostics
2343 .iter()
2344 .filter_map(|diagnostic| match &diagnostic.kind {
2345 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
2346 Some(pattern.as_str())
2347 }
2348 _ => None,
2349 })
2350 .collect();
2351
2352 assert_eq!(
2353 patterns,
2354 ["pkgs/*", "pkgs/a*"],
2355 "distinct patterns are distinct diagnostics: {diagnostics:?}"
2356 );
2357 }
2358}