1use std::path::{Path, PathBuf};
24use std::sync::{Mutex, OnceLock};
25
26use rustc_hash::{FxHashMap, FxHashSet};
27
28use fallow_types::ANALYSIS_DIAGNOSTIC_LOG_TARGET;
29use fallow_types::path_util::display_relative;
30pub use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
31
32#[derive(Debug, Clone)]
39pub enum WorkspaceLoadError {
40 MalformedRootPackageJson {
42 path: PathBuf,
44 error: String,
46 },
47 MalformedRootDenoConfig {
49 path: PathBuf,
51 error: String,
53 },
54}
55
56impl std::fmt::Display for WorkspaceLoadError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::MalformedRootPackageJson { path, error } => write!(
60 f,
61 "root package.json at '{}' is not valid JSON ({error}). \
62 Fix the syntax before re-running fallow.",
63 path.display()
64 ),
65 Self::MalformedRootDenoConfig { path, error } => write!(
66 f,
67 "root Deno config at '{}' is not valid JSONC ({error}). \
68 Fix the syntax before re-running fallow.",
69 path.display()
70 ),
71 }
72 }
73}
74
75impl std::error::Error for WorkspaceLoadError {}
76
77const GLOB_EXAMPLE_CAP: usize = 3;
81
82fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
89 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
90 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
91}
92
93fn should_emit(key: String) -> bool {
98 warned_keys().lock().map_or(true, |mut set| set.insert(key))
99}
100
101#[derive(Debug, PartialEq, Eq)]
106struct PlannedWarning {
107 dedupe_key: String,
108 message: String,
109}
110
111struct WarningGroups<'a> {
112 plans: Vec<PlannedWarning>,
113 glob_groups: Vec<(&'a str, Vec<&'a WorkspaceDiagnostic>)>,
114 tsconfig_ref_misses: Vec<&'a WorkspaceDiagnostic>,
115 missing_dir_groups: Vec<(&'a Path, Vec<&'a WorkspaceDiagnostic>)>,
116}
117
118fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
139 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
140 let WarningGroups {
141 mut plans,
142 glob_groups,
143 tsconfig_ref_misses,
144 missing_dir_groups,
145 } = group_warning_diagnostics(diagnostics, &canonical);
146
147 for (pattern, group) in glob_groups {
148 if let [only] = group.as_slice() {
149 plans.push(per_instance_warning(&canonical, only));
150 continue;
151 }
152 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
153 plans.push(PlannedWarning {
154 dedupe_key: format!(
155 "{}::glob-matched-no-package-json-agg::{pattern}",
156 canonical.display()
157 ),
158 message: build_glob_group_message(root, pattern, &paths),
159 });
160 }
161
162 if let [only] = tsconfig_ref_misses.as_slice() {
163 plans.push(per_instance_warning(&canonical, only));
164 } else if !tsconfig_ref_misses.is_empty() {
165 let paths: Vec<&Path> = tsconfig_ref_misses
166 .iter()
167 .map(|d| d.path.as_path())
168 .collect();
169 plans.push(PlannedWarning {
170 dedupe_key: format!(
171 "{}::tsconfig-reference-dir-missing-agg",
172 canonical.display()
173 ),
174 message: build_tsconfig_refs_message(root, &paths),
175 });
176 }
177
178 for (source, group) in missing_dir_groups {
179 if let [only] = group.as_slice() {
180 plans.push(per_instance_warning(&canonical, only));
181 continue;
182 }
183 plans.push(PlannedWarning {
184 dedupe_key: format!(
185 "{}::workspace-pattern-dir-missing-agg::{}",
186 canonical.display(),
187 source.display()
188 ),
189 message: build_missing_dir_group_message(root, source, &group),
190 });
191 }
192
193 plans
194}
195
196fn group_warning_diagnostics<'a>(
197 diagnostics: &'a [WorkspaceDiagnostic],
198 canonical: &Path,
199) -> WarningGroups<'a> {
200 let mut plans: Vec<PlannedWarning> = Vec::new();
201 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
202 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
203 let mut missing_dir_groups: Vec<(&Path, Vec<&WorkspaceDiagnostic>)> = Vec::new();
204 for diag in diagnostics {
205 if !diag.kind.warns_on_stderr() {
206 continue;
207 }
208 match &diag.kind {
209 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
210 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
211 Some((_, group)) => group.push(diag),
212 None => glob_groups.push((pattern.as_str(), vec![diag])),
213 }
214 }
215 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
216 WorkspaceDiagnosticKind::WorkspacePatternDirMissing { .. } => {
217 let source = diag.path.as_path();
218 match missing_dir_groups.iter_mut().find(|(p, _)| *p == source) {
219 Some((_, group)) => group.push(diag),
220 None => missing_dir_groups.push((source, vec![diag])),
221 }
222 }
223 _ => plans.push(per_instance_warning(canonical, diag)),
224 }
225 }
226 WarningGroups {
227 plans,
228 glob_groups,
229 tsconfig_ref_misses,
230 missing_dir_groups,
231 }
232}
233
234fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
248 PlannedWarning {
249 dedupe_key: format!(
250 "{}::{}::{}::{}",
251 canonical.display(),
252 diag.kind.id(),
253 diag.path.display(),
254 diag.message
255 ),
256 message: diag.message.clone(),
257 }
258}
259
260pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
269 #[cfg(test)]
270 for diag in diagnostics {
271 capture_diag(diag);
272 }
273
274 for plan in plan_warnings(root, diagnostics) {
275 if should_emit(plan.dedupe_key) {
276 tracing::warn!(target: ANALYSIS_DIAGNOSTIC_LOG_TARGET, "fallow: {}", plan.message);
277 }
278 }
279}
280
281fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
286 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
287 examples.sort();
288 let count = examples.len();
289 let shown = examples
290 .iter()
291 .take(GLOB_EXAMPLE_CAP)
292 .cloned()
293 .collect::<Vec<_>>()
294 .join(", ");
295 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
296 let listed = if remaining > 0 {
297 format!("{shown}, and {remaining} more")
298 } else {
299 shown
300 };
301 (listed, count)
302}
303
304fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
307 let (listed, count) = summarize_examples(root, paths);
308 format!(
309 "Glob '{pattern}' matched {count} directories with no package.json \
310 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
311 them to ignorePatterns."
312 )
313}
314
315fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
319 let (listed, count) = summarize_examples(root, paths);
320 format!(
321 "tsconfig.json references {count} directories that do not exist \
322 (e.g. {listed}). Update or remove the references, or restore the \
323 missing directories."
324 )
325}
326
327fn build_missing_dir_group_message(
330 root: &Path,
331 source: &Path,
332 group: &[&WorkspaceDiagnostic],
333) -> String {
334 let display = display_relative(root, source);
335 let source = if display.is_empty() || display == "." {
336 "The fallow config `workspaces.patterns`".to_owned()
337 } else {
338 format!("'{display}'")
339 };
340 let patterns: Vec<String> = group
341 .iter()
342 .filter_map(|diag| match &diag.kind {
343 WorkspaceDiagnosticKind::WorkspacePatternDirMissing { pattern } => {
344 Some(format!("'{pattern}'"))
345 }
346 _ => None,
347 })
348 .collect();
349 format!(
350 "{source} declares {count} workspace patterns whose directories do not exist ({list}). \
351 Fallow analyzes no package from them. Unused catalog entries are not reported in \
352 this run, because packages in those directories can use them. Create the \
353 directories before the run, or remove the patterns.",
354 count = patterns.len(),
355 list = patterns.join(", ")
356 )
357}
358
359thread_local! {
360 #[cfg(test)]
367 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
368 const { std::cell::RefCell::new(None) };
369}
370
371#[cfg(test)]
377fn capture_diag(diag: &WorkspaceDiagnostic) {
378 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
379 if let Some(buf) = cell.borrow_mut().as_mut() {
380 buf.push(diag.clone());
381 }
382 });
383}
384
385#[cfg(test)]
393#[must_use]
394pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
395 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
396 *cell.borrow_mut() = Some(Vec::new());
397 });
398 let result = body();
399 let findings =
400 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
401 (result, findings)
402}
403
404static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
415 OnceLock::new();
416
417pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
449 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
450 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
451 if let Ok(mut map) = registry.lock() {
452 let preserved = map.get(&canonical).map_or_else(Vec::new, |existing| {
453 existing
454 .iter()
455 .filter(|d| {
456 d.kind.is_source_discovery()
457 || d.kind.is_analysis_stage()
458 || d.kind.is_health_stage()
459 || d.kind.is_plugin_stage()
460 })
461 .cloned()
462 .collect()
463 });
464 map.insert(
465 canonical,
466 fallow_types::workspace::merge_workspace_diagnostics(diagnostics, preserved),
467 );
468 }
469}
470
471pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
486 if additions.is_empty() {
487 return;
488 }
489 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
490 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
491 if let Ok(mut map) = registry.lock() {
492 let existing = map.entry(canonical).or_default();
493 let mut seen: FxHashSet<(String, String, String)> = existing
494 .iter()
495 .map(|d| {
496 (
497 d.kind.id().to_owned(),
498 dunce::canonicalize(&d.path)
499 .unwrap_or_else(|_| d.path.clone())
500 .display()
501 .to_string(),
502 d.message.clone(),
503 )
504 })
505 .collect();
506 for addition in additions {
507 let key = (
508 addition.kind.id().to_owned(),
509 dunce::canonicalize(&addition.path)
510 .unwrap_or_else(|_| addition.path.clone())
511 .display()
512 .to_string(),
513 addition.message.clone(),
514 );
515 if seen.insert(key) {
516 existing.push(addition);
517 }
518 }
519 }
520}
521
522pub fn record_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
528 if diagnostics.is_empty() {
529 return;
530 }
531 emit_diagnostics(root, &diagnostics);
532 append_workspace_diagnostics(root, diagnostics);
533}
534
535#[must_use]
552pub fn record_plugin_config_diagnostics(
553 root: &Path,
554 diagnostics: Vec<WorkspaceDiagnostic>,
555) -> Vec<WorkspaceDiagnostic> {
556 let diagnostics = fallow_types::workspace::dedupe_workspace_diagnostics(diagnostics);
557 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
558 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
559 if let Ok(mut map) = registry.lock() {
560 let existing = map.entry(canonical).or_default();
561 existing.retain(|diagnostic| !diagnostic.kind.is_plugin_stage());
562 existing.extend(diagnostics.iter().cloned());
563 }
564 emit_diagnostics(root, &diagnostics);
565 diagnostics
566}
567
568#[must_use]
575pub fn record_source_read_failures(
576 root: &Path,
577 failures: &[fallow_types::extract::SourceReadFailure],
578) -> Vec<WorkspaceDiagnostic> {
579 let diagnostics: Vec<WorkspaceDiagnostic> = failures
580 .iter()
581 .map(|failure| {
582 WorkspaceDiagnostic::new(
583 root,
584 failure.path.clone(),
585 WorkspaceDiagnosticKind::SourceReadFailure {
586 error: failure.error.clone(),
587 },
588 )
589 })
590 .collect();
591 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
592 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
593 if let Ok(mut map) = registry.lock() {
594 let existing = map.entry(canonical).or_default();
595 existing.retain(|diagnostic| {
596 !matches!(
597 diagnostic.kind,
598 WorkspaceDiagnosticKind::SourceReadFailure { .. }
599 )
600 });
601 existing.extend(diagnostics.iter().cloned());
602 }
603 emit_diagnostics(root, &diagnostics);
604 diagnostics
605}
606
607#[must_use]
613pub fn node_modules_missing(root: &Path) -> bool {
614 !root.join("node_modules").is_dir() && !super::is_deno_without_node_modules(root)
615}
616
617#[must_use]
620pub fn node_modules_state(root: &Path) -> fallow_types::workspace::NodeModulesState {
621 if node_modules_missing(root) {
622 fallow_types::workspace::NodeModulesState::Missing
623 } else {
624 fallow_types::workspace::NodeModulesState::Present
625 }
626}
627
628#[must_use]
637pub fn missing_node_modules_diagnostic(root: &Path) -> Option<WorkspaceDiagnostic> {
638 node_modules_missing(root).then(|| {
639 WorkspaceDiagnostic::new(
640 root,
641 root.join("node_modules"),
642 WorkspaceDiagnosticKind::NodeModulesMissing,
643 )
644 })
645}
646
647#[must_use]
657pub fn record_source_parse_degradations(
658 config: &crate::ResolvedConfig,
659 degradations: &[fallow_types::extract::SourceParseDegradation],
660) -> Vec<WorkspaceDiagnostic> {
661 let root = config.root.as_path();
662 let diagnostics: Vec<WorkspaceDiagnostic> = degradations
663 .iter()
664 .filter(|degradation| {
665 !config
666 .ignore_findings
667 .is_ignored_path(root, °radation.path)
668 })
669 .map(|degradation| {
670 WorkspaceDiagnostic::new(
671 root,
672 degradation.path.clone(),
673 WorkspaceDiagnosticKind::SourceParseDegraded {
674 error_count: degradation.error_count,
675 panicked: degradation.panicked,
676 },
677 )
678 })
679 .collect();
680 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
681 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
682 if let Ok(mut map) = registry.lock() {
683 let existing = map.entry(canonical).or_default();
684 existing.retain(|diagnostic| {
685 !matches!(
686 diagnostic.kind,
687 WorkspaceDiagnosticKind::SourceParseDegraded { .. }
688 )
689 });
690 existing.extend(diagnostics.iter().cloned());
691 }
692 emit_diagnostics(root, &diagnostics);
693 diagnostics
694}
695
696#[must_use]
727pub fn replace_source_discovery_diagnostics(
728 root: &Path,
729 diagnostics: Vec<WorkspaceDiagnostic>,
730) -> Vec<WorkspaceDiagnostic> {
731 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
732 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
733 if let Ok(mut map) = registry.lock() {
734 let existing = map.entry(canonical).or_default();
735 existing.retain(|d| !d.kind.is_source_discovery());
736 existing.extend(diagnostics.iter().cloned());
737 }
738 diagnostics
739}
740
741pub fn clear_analysis_stage_diagnostics(root: &Path) {
753 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
754 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
755 return;
756 };
757 if let Ok(mut map) = registry.lock()
758 && let Some(existing) = map.get_mut(&canonical)
759 {
760 existing.retain(|d| !d.kind.is_analysis_stage());
761 }
762}
763
764pub fn clear_health_stage_diagnostics(root: &Path) {
775 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
776 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
777 return;
778 };
779 if let Ok(mut map) = registry.lock()
780 && let Some(existing) = map.get_mut(&canonical)
781 {
782 existing.retain(|d| !d.kind.is_health_stage());
783 }
784}
785
786#[must_use]
794pub fn health_stage_workspace_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
795 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
796 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
797 return Vec::new();
798 };
799 registry
800 .lock()
801 .ok()
802 .map(|map| {
803 map.get(&canonical).map_or_else(Vec::new, |existing| {
804 existing
805 .iter()
806 .filter(|d| d.kind.is_health_stage())
807 .cloned()
808 .collect()
809 })
810 })
811 .unwrap_or_default()
812}
813
814#[must_use]
820pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
821 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
822 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
823 return Vec::new();
824 };
825 registry
826 .lock()
827 .ok()
828 .and_then(|map| map.get(&canonical).cloned())
829 .unwrap_or_default()
830}
831
832#[must_use]
862pub fn registry_diagnostics_to_fold(root: &Path) -> Vec<WorkspaceDiagnostic> {
863 let mut diagnostics: Vec<WorkspaceDiagnostic> = workspace_diagnostics_for(root)
864 .into_iter()
865 .filter(|diagnostic| !diagnostic.kind.is_source_walk_recorded())
866 .collect();
867 diagnostics.sort_by(|left, right| {
868 left.path
869 .cmp(&right.path)
870 .then_with(|| left.kind.id().cmp(right.kind.id()))
871 .then_with(|| left.message.cmp(&right.message))
872 });
873 diagnostics
874}
875
876#[must_use]
882pub(super) fn is_skip_listed_dir(name: &str) -> bool {
883 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
884}
885
886#[must_use]
891pub(super) fn is_ignored_workspace_dir(
892 relative_dir: &Path,
893 ignore_patterns: &crate::IgnorePatternSet,
894) -> bool {
895 if ignore_patterns.is_empty() {
896 return false;
897 }
898 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
899 ignore_patterns.is_match(relative_str.as_str())
900 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use fallow_types::discover::FileId;
907 use fallow_types::extract::SourceReadFailure;
908
909 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
910 WorkspaceDiagnostic::new(
911 root,
912 root.join(rel_path),
913 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
914 pattern: pattern.to_owned(),
915 },
916 )
917 }
918
919 #[test]
920 fn stash_preserves_appended_skipped_large_file_across_restash() {
921 let root = Path::new("/fallow-test-1086-stash-preserve");
924 let undeclared = || {
925 WorkspaceDiagnostic::new(
926 root,
927 root.join("pkg"),
928 WorkspaceDiagnosticKind::UndeclaredWorkspace,
929 )
930 };
931 stash_workspace_diagnostics(root, vec![undeclared()]);
933 append_workspace_diagnostics(
935 root,
936 vec![WorkspaceDiagnostic::new(
937 root,
938 root.join("vendor/big.js"),
939 WorkspaceDiagnosticKind::SkippedLargeFile {
940 size_bytes: 9_999_999,
941 },
942 )],
943 );
944 stash_workspace_diagnostics(root, vec![undeclared()]);
947
948 let after = workspace_diagnostics_for(root);
949 assert_eq!(
950 after
951 .iter()
952 .filter(|d| d.kind.is_source_discovery())
953 .count(),
954 1,
955 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
956 );
957 assert_eq!(
958 after
959 .iter()
960 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
961 .count(),
962 1,
963 "the workspace-discovery diagnostic is replaced, not duplicated"
964 );
965 }
966
967 fn analysis_stage_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
968 vec![
969 WorkspaceDiagnostic::new(
970 root,
971 root.join("pnpm-workspace.yaml"),
972 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
973 error: "could not find expected ':'".to_owned(),
974 },
975 ),
976 WorkspaceDiagnostic::new(
977 root,
978 root.join("package.json"),
979 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
980 ),
981 ]
982 }
983
984 fn count_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> usize {
985 diagnostics.iter().filter(|d| d.kind.id() == id).count()
986 }
987
988 #[test]
989 fn stash_preserves_recorded_analysis_stage_diagnostics_across_restash() {
990 let root = Path::new("/fallow-test-2366-stash-preserve");
991 let undeclared = || {
992 WorkspaceDiagnostic::new(
993 root,
994 root.join("pkg"),
995 WorkspaceDiagnosticKind::UndeclaredWorkspace,
996 )
997 };
998 stash_workspace_diagnostics(root, vec![undeclared()]);
1001 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1002 stash_workspace_diagnostics(root, vec![undeclared()]);
1005
1006 let after = workspace_diagnostics_for(root);
1007 assert_eq!(
1008 count_kind(&after, "malformed-pnpm-workspace-yaml"),
1009 1,
1010 "malformed-pnpm-workspace-yaml survives the combined-mode re-stash exactly once (#2366): {after:?}"
1011 );
1012 assert_eq!(
1013 count_kind(&after, "bun-lockb-override-resolution-skipped"),
1014 1,
1015 "bun-lockb-override-resolution-skipped survives the combined-mode re-stash exactly once (#2366): {after:?}"
1016 );
1017 assert_eq!(
1018 count_kind(&after, "undeclared-workspace"),
1019 1,
1020 "the workspace-discovery diagnostic is replaced, not duplicated"
1021 );
1022 }
1023
1024 #[test]
1025 fn source_read_failures_replace_only_their_previous_parse_set() {
1026 let root = Path::new("/fallow-test-source-read-replace");
1027 stash_workspace_diagnostics(
1028 root,
1029 vec![WorkspaceDiagnostic::new(
1030 root,
1031 root.join("pkg"),
1032 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1033 )],
1034 );
1035 append_workspace_diagnostics(
1036 root,
1037 vec![WorkspaceDiagnostic::new(
1038 root,
1039 root.join("vendor/big.js"),
1040 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
1041 )],
1042 );
1043 let first = SourceReadFailure {
1044 file_id: FileId(1),
1045 path: root.join("src/first.ts"),
1046 error: "removed".to_string(),
1047 };
1048 let _ = record_source_read_failures(root, &[first]);
1049 let second = SourceReadFailure {
1050 file_id: FileId(2),
1051 path: root.join("src/second.ts"),
1052 error: "permission denied".to_string(),
1053 };
1054
1055 let _ = record_source_read_failures(root, std::slice::from_ref(&second));
1056
1057 let diagnostics = workspace_diagnostics_for(root);
1058 let source_failures: Vec<_> = diagnostics
1059 .iter()
1060 .filter(|diagnostic| {
1061 matches!(
1062 diagnostic.kind,
1063 WorkspaceDiagnosticKind::SourceReadFailure { .. }
1064 )
1065 })
1066 .collect();
1067 assert_eq!(source_failures.len(), 1);
1068 assert_eq!(source_failures[0].path, second.path);
1069 assert!(diagnostics.iter().any(|diagnostic| matches!(
1070 diagnostic.kind,
1071 WorkspaceDiagnosticKind::UndeclaredWorkspace
1072 )));
1073 assert!(diagnostics.iter().any(|diagnostic| matches!(
1074 diagnostic.kind,
1075 WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1076 )));
1077
1078 let _ = record_source_read_failures(root, &[]);
1079 assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
1080 !matches!(
1081 diagnostic.kind,
1082 WorkspaceDiagnosticKind::SourceReadFailure { .. }
1083 )
1084 }));
1085 }
1086
1087 #[test]
1088 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
1089 let root = Path::new("/fallow-test-1086-clear-stale");
1090 stash_workspace_diagnostics(
1091 root,
1092 vec![WorkspaceDiagnostic::new(
1093 root,
1094 root.join("pkg"),
1095 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1096 )],
1097 );
1098 append_workspace_diagnostics(
1099 root,
1100 vec![WorkspaceDiagnostic::new(
1101 root,
1102 root.join("vendor/big.js"),
1103 WorkspaceDiagnosticKind::SkippedLargeFile {
1104 size_bytes: 9_999_999,
1105 },
1106 )],
1107 );
1108 let replaced = replace_source_discovery_diagnostics(root, Vec::new());
1110 assert!(
1111 replaced.is_empty(),
1112 "the walk's own list is what it wrote, not what it removed"
1113 );
1114
1115 let after = workspace_diagnostics_for(root);
1116 assert!(
1117 !after.iter().any(|d| d.kind.is_source_discovery()),
1118 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
1119 );
1120 assert!(
1121 after
1122 .iter()
1123 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
1124 "the workspace-discovery diagnostic survives the source-discovery clear"
1125 );
1126 }
1127
1128 #[test]
1129 fn clear_analysis_stage_drops_stale_entries_keeps_other_kinds() {
1130 let root = Path::new("/fallow-test-2366-clear-stale");
1131 stash_workspace_diagnostics(
1132 root,
1133 vec![WorkspaceDiagnostic::new(
1134 root,
1135 root.join("pkg"),
1136 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1137 )],
1138 );
1139 append_workspace_diagnostics(
1140 root,
1141 vec![WorkspaceDiagnostic::new(
1142 root,
1143 root.join("vendor/big.js"),
1144 WorkspaceDiagnosticKind::SkippedLargeFile {
1145 size_bytes: 9_999_999,
1146 },
1147 )],
1148 );
1149 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1150 clear_analysis_stage_diagnostics(root);
1153
1154 let after = workspace_diagnostics_for(root);
1155 assert!(
1156 !after.iter().any(|d| d.kind.is_analysis_stage()),
1157 "stale analysis-stage entries are dropped on the next analyze pass (#2366): {after:?}"
1158 );
1159 assert_eq!(
1160 count_kind(&after, "undeclared-workspace"),
1161 1,
1162 "the workspace-discovery diagnostic survives the analysis-stage clear"
1163 );
1164 assert_eq!(
1165 count_kind(&after, "skipped-large-file"),
1166 1,
1167 "the source-discovery diagnostic survives the analysis-stage clear"
1168 );
1169 }
1170
1171 #[test]
1176 fn health_stage_entries_survive_a_config_reload_and_not_the_next_health_run() {
1177 let root = Path::new("/fallow-test-2689-health-stage");
1178 stash_workspace_diagnostics(
1179 root,
1180 vec![WorkspaceDiagnostic::new(
1181 root,
1182 root.join("pkg"),
1183 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1184 )],
1185 );
1186 append_workspace_diagnostics(
1187 root,
1188 vec![
1189 WorkspaceDiagnostic::new(
1190 root,
1191 root.to_path_buf(),
1192 WorkspaceDiagnosticKind::HotspotsSkipped {
1193 cause: "not-a-repository".to_owned(),
1194 },
1195 ),
1196 WorkspaceDiagnostic::new(
1197 root,
1198 root.join("coverage/coverage-final.json"),
1199 WorkspaceDiagnosticKind::CoverageAutoDetected,
1200 ),
1201 ],
1202 );
1203
1204 stash_workspace_diagnostics(
1206 root,
1207 vec![WorkspaceDiagnostic::new(
1208 root,
1209 root.join("pkg"),
1210 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1211 )],
1212 );
1213 let after_reload = workspace_diagnostics_for(root);
1214 assert_eq!(count_kind(&after_reload, "hotspots-skipped"), 1);
1215 assert_eq!(count_kind(&after_reload, "coverage-auto-detected"), 1);
1216
1217 clear_analysis_stage_diagnostics(root);
1219 assert_eq!(
1220 health_stage_workspace_diagnostics(root).len(),
1221 2,
1222 "the analysis-stage clear must leave health-stage entries alone"
1223 );
1224
1225 clear_health_stage_diagnostics(root);
1226 let after = workspace_diagnostics_for(root);
1227 assert!(
1228 !after.iter().any(|d| d.kind.is_health_stage()),
1229 "the next health run starts from nothing: {after:?}"
1230 );
1231 assert_eq!(
1232 count_kind(&after, "undeclared-workspace"),
1233 1,
1234 "the workspace-discovery diagnostic survives the health-stage clear"
1235 );
1236 }
1237
1238 fn plugin_diagnostic(root: &Path, key: &str, reason: &str) -> WorkspaceDiagnostic {
1239 WorkspaceDiagnostic::new(
1240 root,
1241 root.join("module-federation.config.ts"),
1242 WorkspaceDiagnosticKind::PluginConfigUnreadable {
1243 plugin: "module-federation".to_owned(),
1244 key: key.to_owned(),
1245 reason: reason.to_owned(),
1246 },
1247 )
1248 }
1249
1250 #[test]
1255 fn plugin_stage_entries_survive_a_config_reload_and_are_replaced_by_the_next_run() {
1256 let root = Path::new("/fallow-test-2736-plugin-stage");
1257 let undeclared = || {
1258 WorkspaceDiagnostic::new(
1259 root,
1260 root.join("pkg"),
1261 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1262 )
1263 };
1264 stash_workspace_diagnostics(root, vec![undeclared()]);
1265 let recorded = record_plugin_config_diagnostics(
1266 root,
1267 vec![plugin_diagnostic(root, "exposes", "not-object-literal")],
1268 );
1269 assert_eq!(recorded.len(), 1, "the caller gets its own copy back");
1270
1271 stash_workspace_diagnostics(root, vec![undeclared()]);
1273 let after_reload = workspace_diagnostics_for(root);
1274 assert_eq!(
1275 count_kind(&after_reload, "plugin-config-unreadable"),
1276 1,
1277 "the plugin entry survives the combined-mode re-stash exactly once: {after_reload:?}"
1278 );
1279 assert_eq!(count_kind(&after_reload, "undeclared-workspace"), 1);
1280
1281 clear_analysis_stage_diagnostics(root);
1284 assert_eq!(
1285 count_kind(&workspace_diagnostics_for(root), "plugin-config-unreadable"),
1286 1,
1287 "the analysis-stage clear must leave plugin-stage entries alone"
1288 );
1289
1290 let _ = record_plugin_config_diagnostics(root, Vec::new());
1293 let after = workspace_diagnostics_for(root);
1294 assert!(
1295 !after.iter().any(|d| d.kind.is_plugin_stage()),
1296 "each plugin run replaces the previous set: {after:?}"
1297 );
1298 assert_eq!(
1299 count_kind(&after, "undeclared-workspace"),
1300 1,
1301 "the workspace-discovery diagnostic survives the plugin replace"
1302 );
1303 }
1304
1305 #[test]
1309 fn two_unreadable_keys_in_one_config_are_recorded_and_printed_twice() {
1310 let root = Path::new("/fallow-test-2736-two-keys");
1311 let (_, captured) = capture_workspace_warnings(|| {
1312 record_plugin_config_diagnostics(
1313 root,
1314 vec![
1315 plugin_diagnostic(root, "exposes", "not-object-literal"),
1316 plugin_diagnostic(root, "remotes", "spread"),
1317 ],
1318 )
1319 });
1320 assert_eq!(
1321 captured.len(),
1322 2,
1323 "both keys reach the emitter: {captured:?}"
1324 );
1325 let stored = workspace_diagnostics_for(root);
1326 assert_eq!(
1327 count_kind(&stored, "plugin-config-unreadable"),
1328 2,
1329 "both keys are recorded: {stored:?}"
1330 );
1331
1332 let plans = plan_warnings(
1333 root,
1334 &[
1335 plugin_diagnostic(root, "exposes", "not-object-literal"),
1336 plugin_diagnostic(root, "remotes", "spread"),
1337 ],
1338 );
1339 assert_eq!(plans.len(), 2, "two distinct lines are planned: {plans:?}");
1340 assert_ne!(
1341 plans[0].dedupe_key, plans[1].dedupe_key,
1342 "the process-wide dedupe must not swallow the second key: {plans:?}"
1343 );
1344 }
1345
1346 #[test]
1350 fn the_not_modeled_kind_is_recorded_without_a_stderr_line() {
1351 let root = Path::new("/fallow-test-2736-not-modeled");
1352 let diagnostic = WorkspaceDiagnostic::new(
1353 root,
1354 root.join("nuxt.config.ts"),
1355 WorkspaceDiagnosticKind::PluginEffectNotModeled {
1356 plugin: "nuxt".to_owned(),
1357 key: "components".to_owned(),
1358 reason: "key-effect-not-modeled".to_owned(),
1359 },
1360 );
1361 let _ = record_plugin_config_diagnostics(root, vec![diagnostic.clone()]);
1362 assert_eq!(
1363 count_kind(
1364 &workspace_diagnostics_for(root),
1365 "plugin-effect-not-modeled"
1366 ),
1367 1
1368 );
1369 assert!(
1370 plan_warnings(root, &[diagnostic]).is_empty(),
1371 "a kind that does not degrade the run plans no stderr line"
1372 );
1373 }
1374
1375 #[test]
1376 fn build_glob_group_message_caps_examples_and_summarises_tail() {
1377 let root = Path::new("/project");
1378 let paths = [
1379 root.join("playground/cli"),
1380 root.join("playground/lib-types"),
1381 root.join("playground/minify"),
1382 root.join("playground/ssr"),
1383 root.join("playground/worker"),
1384 ];
1385 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1386 let message = build_glob_group_message(root, "playground/**", &refs);
1387
1388 assert!(
1389 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
1390 "count and pattern lead the message: {message}"
1391 );
1392 assert!(
1393 message.contains(
1394 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
1395 ),
1396 "three sorted examples + tail count: {message}"
1397 );
1398 assert!(
1399 message.ends_with(
1400 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
1401 ),
1402 "next-step hint preserved: {message}"
1403 );
1404 assert!(
1405 !message.contains("playground/ssr"),
1406 "tail example not named: {message}"
1407 );
1408 }
1409
1410 #[test]
1411 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
1412 let root = Path::new("/project");
1413 let paths = [root.join("packages/a"), root.join("packages/b")];
1414 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1415 let message = build_glob_group_message(root, "packages/*", &refs);
1416
1417 assert!(message.contains("matched 2 directories"), "{message}");
1418 assert!(
1419 message.contains("(e.g. packages/a, packages/b)"),
1420 "both examples named, no `and N more`: {message}"
1421 );
1422 assert!(!message.contains("more)"), "no tail clause: {message}");
1423 }
1424
1425 #[test]
1426 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
1427 let root = Path::new("/project");
1428 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
1429 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
1430 .collect();
1431
1432 let plans = plan_warnings(root, &diagnostics);
1433
1434 assert_eq!(
1435 plans.len(),
1436 1,
1437 "50 same-pattern diagnostics collapse to one plan"
1438 );
1439 assert!(
1440 plans[0]
1441 .dedupe_key
1442 .ends_with("::glob-matched-no-package-json-agg::playground/**")
1443 );
1444 assert!(plans[0].message.contains("matched 50 directories"));
1445 }
1446
1447 #[test]
1448 fn plan_warnings_keeps_distinct_patterns_separate() {
1449 let root = Path::new("/project");
1450 let diagnostics = vec![
1451 glob_diag(root, "apps/*", "apps/a"),
1452 glob_diag(root, "apps/*", "apps/b"),
1453 glob_diag(root, "packages/*", "packages/x"),
1454 glob_diag(root, "packages/*", "packages/y"),
1455 ];
1456
1457 let plans = plan_warnings(root, &diagnostics);
1458
1459 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
1460 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
1461 assert!(
1462 messages
1463 .iter()
1464 .any(|m| m.contains("Glob 'apps/*' matched 2")),
1465 "{messages:?}"
1466 );
1467 assert!(
1468 messages
1469 .iter()
1470 .any(|m| m.contains("Glob 'packages/*' matched 2")),
1471 "{messages:?}"
1472 );
1473 }
1474
1475 #[test]
1476 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
1477 let root = Path::new("/project");
1478 let diag = glob_diag(root, "packages/*", "packages/scratch");
1479
1480 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1481
1482 assert_eq!(plans.len(), 1);
1483 assert_eq!(plans[0].message, diag.message);
1484 let expected_path = Path::new("packages").join("scratch");
1490 assert!(
1491 plans[0]
1492 .dedupe_key
1493 .contains("::glob-matched-no-package-json::")
1494 && plans[0]
1495 .dedupe_key
1496 .contains(&expected_path.display().to_string())
1497 && plans[0].dedupe_key.ends_with(&diag.message),
1498 "per-instance key is `root::kind::path::message`, not the `-agg::pattern` form: {}",
1499 plans[0].dedupe_key
1500 );
1501 assert!(
1502 !plans[0].message.contains("directories"),
1503 "single match is not aggregated"
1504 );
1505 }
1506
1507 #[test]
1508 fn plan_warnings_non_glob_kinds_stay_per_instance() {
1509 let root = Path::new("/project");
1510 let diagnostics = vec![
1511 WorkspaceDiagnostic::new(
1512 root,
1513 root.join("packages/a"),
1514 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1515 ),
1516 WorkspaceDiagnostic::new(
1517 root,
1518 root.join("packages/b"),
1519 WorkspaceDiagnosticKind::MalformedPackageJson {
1520 error: "trailing comma".to_owned(),
1521 },
1522 ),
1523 ];
1524
1525 let plans = plan_warnings(root, &diagnostics);
1526
1527 assert_eq!(
1528 plans.len(),
1529 2,
1530 "each non-glob diagnostic plans its own warning"
1531 );
1532 assert!(
1533 plans
1534 .iter()
1535 .all(|p| !p.message.contains("directories with no package.json"))
1536 );
1537 }
1538
1539 #[test]
1545 fn plan_warnings_drops_the_unconfigured_check_kinds() {
1546 let root = Path::new("/project");
1547 let diagnostics = vec![
1548 WorkspaceDiagnostic::new(
1549 root,
1550 root.to_path_buf(),
1551 WorkspaceDiagnosticKind::BoundariesNotConfigured,
1552 ),
1553 WorkspaceDiagnostic::new(
1554 root,
1555 root.to_path_buf(),
1556 WorkspaceDiagnosticKind::RulePacksNotConfigured,
1557 ),
1558 ];
1559
1560 assert!(
1561 plan_warnings(root, &diagnostics).is_empty(),
1562 "an unconfigured check is not a degraded run and warns nobody"
1563 );
1564 }
1565
1566 #[test]
1570 fn plan_warnings_keeps_the_degradation_kinds_alongside_dropped_ones() {
1571 let root = Path::new("/project");
1572 let diagnostics = vec![
1573 WorkspaceDiagnostic::new(
1574 root,
1575 root.to_path_buf(),
1576 WorkspaceDiagnosticKind::BoundariesNotConfigured,
1577 ),
1578 WorkspaceDiagnostic::new(
1579 root,
1580 root.join("node_modules"),
1581 WorkspaceDiagnosticKind::NodeModulesMissing,
1582 ),
1583 WorkspaceDiagnostic::new(
1584 root,
1585 root.to_path_buf(),
1586 WorkspaceDiagnosticKind::RulePacksNotConfigured,
1587 ),
1588 ];
1589
1590 let messages: Vec<String> = plan_warnings(root, &diagnostics)
1591 .into_iter()
1592 .map(|plan| plan.message)
1593 .collect();
1594
1595 assert_eq!(
1596 messages.len(),
1597 1,
1598 "only the degradation warns: {messages:?}"
1599 );
1600 assert!(
1601 messages[0].contains("node_modules"),
1602 "the surviving line is the missing dependency tree: {messages:?}"
1603 );
1604 }
1605
1606 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
1607 WorkspaceDiagnostic::new(
1608 root,
1609 root.join(rel_path),
1610 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1611 )
1612 }
1613
1614 #[test]
1615 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
1616 let root = Path::new("/project");
1617 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
1618 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
1619 .collect();
1620
1621 let plans = plan_warnings(root, &diagnostics);
1622
1623 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
1624 assert!(
1625 plans[0]
1626 .dedupe_key
1627 .ends_with("::tsconfig-reference-dir-missing-agg")
1628 );
1629 assert!(
1630 plans[0]
1631 .message
1632 .starts_with("tsconfig.json references 30 directories that do not exist"),
1633 "{}",
1634 plans[0].message
1635 );
1636 assert!(
1637 plans[0].message.contains(
1638 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1639 packages/p02/tsconfig.json, and 27 more)"
1640 ),
1641 "three sorted examples + tail: {}",
1642 plans[0].message
1643 );
1644 assert!(
1645 plans[0]
1646 .message
1647 .ends_with("Update or remove the references, or restore the missing directories."),
1648 "{}",
1649 plans[0].message
1650 );
1651 }
1652
1653 #[test]
1654 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1655 let root = Path::new("/project");
1656 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1657
1658 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1659
1660 assert_eq!(plans.len(), 1);
1661 assert_eq!(
1662 plans[0].message, diag.message,
1663 "single miss is not aggregated"
1664 );
1665 assert!(!plans[0].message.contains("directories that do not exist"));
1666 }
1667
1668 #[test]
1669 fn plan_warnings_aggregates_missing_pattern_dirs_per_declaring_file() {
1670 let root = Path::new("/project");
1671 let missing = |pattern: &str| {
1672 WorkspaceDiagnostic::new(
1673 root,
1674 root.join("pnpm-workspace.yaml"),
1675 WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
1676 pattern: pattern.to_owned(),
1677 },
1678 )
1679 };
1680 let diagnostics = vec![
1681 missing("vite"),
1682 missing("vite/packages/*"),
1683 missing("rolldown"),
1684 ];
1685
1686 let plans = plan_warnings(root, &diagnostics);
1687
1688 let [plan] = plans.as_slice() else {
1689 panic!("expected one aggregated warning: {plans:?}");
1690 };
1691 assert!(
1692 plan.message.starts_with(
1693 "'pnpm-workspace.yaml' declares 3 workspace patterns whose directories do not \
1694 exist ('vite', 'vite/packages/*', 'rolldown')."
1695 ),
1696 "{}",
1697 plan.message
1698 );
1699 }
1700
1701 #[test]
1702 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1703 let root = Path::new("/project");
1704 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1705 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1706 .collect();
1707 diagnostics.extend(
1708 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1709 );
1710
1711 let plans = plan_warnings(root, &diagnostics);
1712
1713 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1714 assert!(
1715 plans
1716 .iter()
1717 .any(|p| p.message.contains("matched 5 directories"))
1718 );
1719 assert!(
1720 plans
1721 .iter()
1722 .any(|p| p.message.contains("references 4 directories"))
1723 );
1724 }
1725
1726 #[test]
1732 fn two_manifest_glob_warning_counts_each_directory_once() {
1733 let dir = tempfile::tempdir().expect("create temp dir");
1734 crate::workspace::write_two_manifest_glob_project(dir.path());
1735
1736 let (_, diagnostics) = crate::workspace::discover_workspaces_with_diagnostics(
1737 dir.path(),
1738 &crate::IgnorePatternSet::empty(),
1739 )
1740 .expect("root package.json is valid");
1741
1742 let messages: Vec<String> = plan_warnings(dir.path(), &diagnostics)
1743 .into_iter()
1744 .map(|plan| plan.message)
1745 .collect();
1746
1747 assert_eq!(
1748 messages,
1749 vec![
1750 "Glob 'pkgs/*' matched 2 directories with no package.json \
1751 (e.g. pkgs/aaa, pkgs/bbb). Add a package.json, narrow the \
1752 pattern, or add them to ignorePatterns."
1753 .to_owned()
1754 ],
1755 "the summary names the true directory count and each example once"
1756 );
1757 }
1758}