1mod deno_json;
2mod diagnostics;
3mod npm_overrides;
4mod package_json;
5mod parsers;
6mod pnpm_catalog;
7mod pnpm_overrides;
8
9use std::path::{Path, PathBuf};
10
11use rustc_hash::FxHashMap;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15pub use deno_json::{
16 DirManifestProbe, dir_has_deno_json, dir_has_package_manifest, is_deno_without_node_modules,
17 load_deno_import_map, load_dir_package_json, load_member_package_manifest,
18 load_root_deno_workspace_patterns, probe_dir_manifest,
19};
20#[cfg(test)]
21pub use diagnostics::capture_workspace_warnings;
22pub use diagnostics::{
23 WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceLoadError, append_workspace_diagnostics,
24 clear_source_discovery_diagnostics, record_source_read_failures, record_workspace_diagnostics,
25 stash_workspace_diagnostics, workspace_diagnostics_for,
26};
27use diagnostics::{emit_diagnostics, is_skip_listed_dir};
28pub use npm_overrides::parse_npm_package_json_overrides;
29pub use package_json::{NapiConfig, PackageJson};
30pub use parsers::parse_tsconfig_root_dir;
31use parsers::{
32 expand_workspace_glob_with_diagnostics, parse_pnpm_workspace_yaml,
33 parse_tsconfig_references_with_diagnostics,
34};
35pub use pnpm_catalog::{
36 PnpmCatalog, PnpmCatalogData, PnpmCatalogEntry, PnpmCatalogGroup,
37 parse_package_json_catalog_data, parse_pnpm_catalog_data,
38};
39pub use pnpm_overrides::{
40 MisconfigReason, OverrideSource, ParsedOverrideKey, PnpmOverrideData, PnpmOverrideEntry,
41 is_valid_override_value, override_misconfig_reason, override_source_label, parse_override_key,
42 parse_pnpm_package_json_overrides, parse_pnpm_workspace_overrides,
43};
44
45#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
47pub struct WorkspaceConfig {
48 #[serde(default, alias = "packages")]
55 pub patterns: Vec<String>,
56}
57
58#[derive(Debug, Clone)]
61pub struct WorkspaceInfo {
62 pub root: PathBuf,
64 pub name: String,
66 pub is_internal_dependency: bool,
68}
69
70#[must_use]
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]
1405 fn malformed_member_reached_via_two_sources_diagnosed_per_source() {
1406 let dir = tempfile::tempdir().expect("create temp dir");
1407 let pkg_good = dir.path().join("packages").join("good");
1408 let pkg_bad = dir.path().join("packages").join("bad");
1409 std::fs::create_dir_all(&pkg_good).unwrap();
1410 std::fs::create_dir_all(&pkg_bad).unwrap();
1411 std::fs::write(
1412 dir.path().join("package.json"),
1413 r#"{"workspaces": ["packages/*"]}"#,
1414 )
1415 .unwrap();
1416 std::fs::write(
1417 dir.path().join("tsconfig.json"),
1418 r#"{"references": [{"path": "./packages/bad"}]}"#,
1419 )
1420 .unwrap();
1421 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1422 std::fs::write(pkg_bad.join("package.json"), r"{,}").unwrap();
1423
1424 let (result, _) = capture_workspace_warnings(|| {
1425 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1426 });
1427 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1428
1429 assert!(
1430 workspaces.iter().any(|w| w.name == "good"),
1431 "valid sibling should still discover: {workspaces:?}"
1432 );
1433 assert!(
1434 workspaces.iter().any(|w| w.name == "bad"),
1435 "tsconfig reference falls back to the directory name on manifest error"
1436 );
1437 let malformed: Vec<_> = diagnostics
1438 .iter()
1439 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. }))
1440 .collect();
1441 assert_eq!(
1442 malformed.len(),
1443 2,
1444 "cached Err replays once per workspace source: {diagnostics:?}"
1445 );
1446 assert!(
1447 malformed.iter().all(|d| d.path.ends_with("bad")),
1448 "both diagnostics point at the malformed member"
1449 );
1450 }
1451
1452 #[test]
1453 fn multiple_malformed_workspace_package_jsons_all_diagnosed() {
1454 let dir = tempfile::tempdir().expect("create temp dir");
1455 for name in ["a", "b", "c"] {
1456 let pkg = dir.path().join("packages").join(name);
1457 std::fs::create_dir_all(&pkg).unwrap();
1458 std::fs::write(pkg.join("package.json"), r"{,}").unwrap();
1459 }
1460 std::fs::write(
1461 dir.path().join("package.json"),
1462 r#"{"workspaces": ["packages/*"]}"#,
1463 )
1464 .unwrap();
1465
1466 let (result, _) = capture_workspace_warnings(|| {
1467 discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty())
1468 });
1469 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1470
1471 assert!(workspaces.is_empty(), "all three malformed; nothing valid");
1472 assert_eq!(diagnostics.len(), 3, "each malformed workspace surfaces");
1473 assert!(
1474 diagnostics
1475 .iter()
1476 .all(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1477 "every diagnostic should be malformed-package-json"
1478 );
1479 }
1480
1481 #[test]
1482 fn malformed_root_package_json_returns_load_error() {
1483 let dir = tempfile::tempdir().expect("create temp dir");
1484 std::fs::write(dir.path().join("package.json"), "this is not json").unwrap();
1485
1486 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1487
1488 match result {
1489 Err(WorkspaceLoadError::MalformedRootPackageJson { path, error }) => {
1490 assert!(path.ends_with("package.json"));
1491 assert!(!error.is_empty(), "underlying parse error is preserved");
1492 }
1493 other => panic!("expected MalformedRootPackageJson, got {other:?}"),
1494 }
1495 }
1496
1497 #[test]
1498 fn glob_match_without_package_json_emits_diagnostic_unless_skip_listed() {
1499 let dir = tempfile::tempdir().expect("create temp dir");
1500 let pkg_a = dir.path().join("packages").join("a");
1501 let cache_dir = dir.path().join("packages").join(".cache");
1502 let scratch_dir = dir.path().join("packages").join("scratch");
1503 std::fs::create_dir_all(&pkg_a).unwrap();
1504 std::fs::create_dir_all(&cache_dir).unwrap();
1505 std::fs::create_dir_all(&scratch_dir).unwrap();
1506 std::fs::write(
1507 dir.path().join("package.json"),
1508 r#"{"workspaces": ["packages/*"]}"#,
1509 )
1510 .unwrap();
1511 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1512
1513 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1514 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1515
1516 assert_eq!(workspaces.len(), 1);
1517 let kinds: Vec<&str> = diagnostics.iter().map(|d| d.kind.id()).collect();
1518 assert!(
1519 kinds.contains(&"glob-matched-no-package-json"),
1520 "scratch should diagnose: {kinds:?}"
1521 );
1522 assert!(
1523 !diagnostics.iter().any(|d| d.path.ends_with(".cache")),
1524 ".cache must be skip-listed: {diagnostics:?}"
1525 );
1526 }
1527
1528 #[test]
1529 fn glob_match_without_package_json_honors_ignore_patterns() {
1530 let dir = tempfile::tempdir().expect("create temp dir");
1531 let pkg_a = dir.path().join("packages").join("a");
1532 let legacy_dir = dir.path().join("packages").join("legacy");
1533 std::fs::create_dir_all(&pkg_a).unwrap();
1534 std::fs::create_dir_all(&legacy_dir).unwrap();
1535 std::fs::write(
1536 dir.path().join("package.json"),
1537 r#"{"workspaces": ["packages/*"]}"#,
1538 )
1539 .unwrap();
1540 std::fs::write(pkg_a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1541
1542 let mut builder = globset::GlobSetBuilder::new();
1543 builder.add(globset::Glob::new("packages/legacy").unwrap());
1544 let ignore = builder.build().unwrap();
1545
1546 let result = discover_workspaces_with_diagnostics(dir.path(), &ignore);
1547 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1548
1549 assert_eq!(workspaces.len(), 1);
1550 assert!(
1551 diagnostics.is_empty(),
1552 "user-excluded path must not produce a diagnostic: {diagnostics:?}"
1553 );
1554 }
1555
1556 #[test]
1557 fn malformed_tsconfig_emits_diagnostic() {
1558 let dir = tempfile::tempdir().expect("create temp dir");
1559 std::fs::write(
1560 dir.path().join("package.json"),
1561 r#"{"workspaces": ["packages/*"]}"#,
1562 )
1563 .unwrap();
1564 std::fs::write(dir.path().join("tsconfig.json"), r#"{"references": [,,,]}"#).unwrap();
1565
1566 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1567 let (_, diagnostics) = result.expect("root package.json is valid");
1568
1569 assert!(
1570 diagnostics
1571 .iter()
1572 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1573 "expected MalformedTsconfig diagnostic; got: {diagnostics:?}"
1574 );
1575 }
1576
1577 #[test]
1578 fn tsconfig_missing_reference_dir_emits_diagnostic() {
1579 let dir = tempfile::tempdir().expect("create temp dir");
1580 std::fs::write(
1581 dir.path().join("tsconfig.json"),
1582 r#"{"references": [{"path": "./packages/missing"}]}"#,
1583 )
1584 .unwrap();
1585
1586 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1587 let (_, diagnostics) = result.expect("no package.json at root is OK");
1588
1589 assert!(
1590 diagnostics
1591 .iter()
1592 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::TsconfigReferenceDirMissing)),
1593 "expected TsconfigReferenceDirMissing; got: {diagnostics:?}"
1594 );
1595 }
1596
1597 #[test]
1598 fn missing_tsconfig_is_silent() {
1599 let dir = tempfile::tempdir().expect("create temp dir");
1600
1601 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1602 let (_, diagnostics) = result.expect("no root package.json is OK");
1603
1604 assert!(
1605 !diagnostics
1606 .iter()
1607 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedTsconfig { .. })),
1608 "missing tsconfig must not produce MalformedTsconfig: {diagnostics:?}"
1609 );
1610 }
1611
1612 #[test]
1613 fn shallow_scan_malformed_package_json_stays_silent() {
1614 let dir = tempfile::tempdir().expect("create temp dir");
1615 let scratch = dir.path().join("scratch");
1616 std::fs::create_dir_all(&scratch).unwrap();
1617 std::fs::write(scratch.join("package.json"), r"{not valid json}").unwrap();
1618
1619 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1620 let (_, diagnostics) = result.expect("no root package.json is OK");
1621
1622 assert!(
1623 !diagnostics
1624 .iter()
1625 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::MalformedPackageJson { .. })),
1626 "shallow-scan malformed must stay silent: {diagnostics:?}"
1627 );
1628 }
1629
1630 #[test]
1631 fn mixed_valid_and_malformed_workspaces_partial_recovery() {
1632 let dir = tempfile::tempdir().expect("create temp dir");
1633 let pkg_good = dir.path().join("packages").join("good");
1634 let pkg_bad = dir.path().join("packages").join("bad");
1635 std::fs::create_dir_all(&pkg_good).unwrap();
1636 std::fs::create_dir_all(&pkg_bad).unwrap();
1637 std::fs::write(
1638 dir.path().join("package.json"),
1639 r#"{"workspaces": ["packages/*"]}"#,
1640 )
1641 .unwrap();
1642 std::fs::write(pkg_good.join("package.json"), r#"{"name": "good"}"#).unwrap();
1643 std::fs::write(pkg_bad.join("package.json"), r"{,").unwrap();
1644
1645 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1646 let (workspaces, diagnostics) = result.expect("root package.json is valid");
1647
1648 assert_eq!(workspaces.len(), 1);
1649 assert_eq!(workspaces[0].name, "good");
1650 assert_eq!(diagnostics.len(), 1);
1651 assert_eq!(diagnostics[0].kind.id(), "malformed-package-json");
1652 }
1653
1654 #[test]
1655 fn discover_workspaces_back_compat_drops_diagnostics_and_errors() {
1656 let dir = tempfile::tempdir().expect("create temp dir");
1657 std::fs::write(dir.path().join("package.json"), r"{bad json").unwrap();
1658
1659 let workspaces = discover_workspaces(dir.path());
1660 assert!(
1661 workspaces.is_empty(),
1662 "back-compat wrapper returns empty on root-malformed: {workspaces:?}"
1663 );
1664 }
1665
1666 #[test]
1667 fn discovers_deno_workspace_members_without_package_json() {
1668 let dir = tempfile::tempdir().expect("create temp dir");
1669 let core = dir.path().join("packages").join("core");
1670 let app = dir.path().join("apps").join("desktop");
1671 std::fs::create_dir_all(&core).unwrap();
1672 std::fs::create_dir_all(&app).unwrap();
1673
1674 std::fs::write(
1675 dir.path().join("deno.json"),
1676 r#"{
1677 "workspace": ["./apps/*", "./packages/*"],
1678 "imports": { "@std/assert": "jsr:@std/assert@1" }
1679 }"#,
1680 )
1681 .unwrap();
1682 std::fs::write(
1683 core.join("deno.json"),
1684 r#"{
1685 "name": "@fallow/core",
1686 "exports": { ".": "./mod.ts", "./result": "./result.ts" }
1687 }"#,
1688 )
1689 .unwrap();
1690 std::fs::write(
1691 app.join("deno.json"),
1692 r#"{ "name": "@fallow/desktop", "exports": { ".": "./main.ts" } }"#,
1693 )
1694 .unwrap();
1695
1696 let workspaces = discover_workspaces(dir.path());
1697 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1698 names.sort_unstable();
1699 assert_eq!(names, vec!["@fallow/core", "@fallow/desktop"]);
1700 assert!(
1701 workspaces
1702 .iter()
1703 .all(|w| !w.root.join("package.json").exists())
1704 );
1705 assert!(
1706 workspaces.iter().all(|w| !w.is_internal_dependency),
1707 "Deno packages need dependency evidence before inventory marks them internal"
1708 );
1709 }
1710
1711 #[test]
1712 fn malformed_root_deno_config_returns_load_error() {
1713 let dir = tempfile::tempdir().expect("create temp dir");
1714 std::fs::write(dir.path().join("deno.jsonc"), "{ workspace: [ }").unwrap();
1715
1716 let result = discover_workspaces_with_diagnostics(dir.path(), &globset::GlobSet::empty());
1717
1718 match result {
1719 Err(WorkspaceLoadError::MalformedRootDenoConfig { path, error }) => {
1720 assert!(path.ends_with("deno.jsonc"));
1721 assert!(!error.is_empty(), "underlying parse error is preserved");
1722 }
1723 other => panic!("expected MalformedRootDenoConfig, got {other:?}"),
1724 }
1725 }
1726
1727 #[test]
1728 fn deno_and_npm_workspace_patterns_are_additive() {
1729 let dir = tempfile::tempdir().expect("create temp dir");
1730 let a = dir.path().join("packages").join("a");
1731 let b = dir.path().join("packages").join("b");
1732 std::fs::create_dir_all(&a).unwrap();
1733 std::fs::create_dir_all(&b).unwrap();
1734
1735 std::fs::write(
1736 dir.path().join("package.json"),
1737 r#"{"workspaces": ["packages/a"]}"#,
1738 )
1739 .unwrap();
1740 std::fs::write(a.join("package.json"), r#"{"name": "a"}"#).unwrap();
1741 std::fs::write(
1742 dir.path().join("deno.json"),
1743 r#"{"workspace": ["./packages/b"]}"#,
1744 )
1745 .unwrap();
1746 std::fs::write(b.join("deno.json"), r#"{"name": "b"}"#).unwrap();
1747
1748 let workspaces = discover_workspaces(dir.path());
1749 let mut names: Vec<_> = workspaces.iter().map(|w| w.name.as_str()).collect();
1750 names.sort_unstable();
1751 assert_eq!(names, vec!["a", "b"]);
1752 }
1753}