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 let kind = degradation.nesting_limit.map_or(
671 WorkspaceDiagnosticKind::SourceParseDegraded {
672 error_count: degradation.error_count,
673 panicked: degradation.panicked,
674 },
675 |limit| WorkspaceDiagnosticKind::SourceNestingTooDeep { limit },
676 );
677 WorkspaceDiagnostic::new(root, degradation.path.clone(), kind)
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 | WorkspaceDiagnosticKind::SourceNestingTooDeep { .. }
689 )
690 });
691 existing.extend(diagnostics.iter().cloned());
692 }
693 emit_diagnostics(root, &diagnostics);
694 diagnostics
695}
696
697#[must_use]
728pub fn replace_source_discovery_diagnostics(
729 root: &Path,
730 diagnostics: Vec<WorkspaceDiagnostic>,
731) -> Vec<WorkspaceDiagnostic> {
732 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
733 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
734 if let Ok(mut map) = registry.lock() {
735 let existing = map.entry(canonical).or_default();
736 existing.retain(|d| !d.kind.is_source_discovery());
737 existing.extend(diagnostics.iter().cloned());
738 }
739 diagnostics
740}
741
742pub fn clear_analysis_stage_diagnostics(root: &Path) {
754 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
755 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
756 return;
757 };
758 if let Ok(mut map) = registry.lock()
759 && let Some(existing) = map.get_mut(&canonical)
760 {
761 existing.retain(|d| !d.kind.is_analysis_stage());
762 }
763}
764
765pub fn clear_health_stage_diagnostics(root: &Path) {
776 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
777 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
778 return;
779 };
780 if let Ok(mut map) = registry.lock()
781 && let Some(existing) = map.get_mut(&canonical)
782 {
783 existing.retain(|d| !d.kind.is_health_stage());
784 }
785}
786
787#[must_use]
795pub fn health_stage_workspace_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
796 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
797 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
798 return Vec::new();
799 };
800 registry
801 .lock()
802 .ok()
803 .map(|map| {
804 map.get(&canonical).map_or_else(Vec::new, |existing| {
805 existing
806 .iter()
807 .filter(|d| d.kind.is_health_stage())
808 .cloned()
809 .collect()
810 })
811 })
812 .unwrap_or_default()
813}
814
815#[must_use]
821pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
822 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
823 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
824 return Vec::new();
825 };
826 registry
827 .lock()
828 .ok()
829 .and_then(|map| map.get(&canonical).cloned())
830 .unwrap_or_default()
831}
832
833#[must_use]
863pub fn registry_diagnostics_to_fold(root: &Path) -> Vec<WorkspaceDiagnostic> {
864 let mut diagnostics: Vec<WorkspaceDiagnostic> = workspace_diagnostics_for(root)
865 .into_iter()
866 .filter(|diagnostic| !diagnostic.kind.is_source_walk_recorded())
867 .collect();
868 diagnostics.sort_by(|left, right| {
869 left.path
870 .cmp(&right.path)
871 .then_with(|| left.kind.id().cmp(right.kind.id()))
872 .then_with(|| left.message.cmp(&right.message))
873 });
874 diagnostics
875}
876
877#[must_use]
883pub(super) fn is_skip_listed_dir(name: &str) -> bool {
884 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
885}
886
887#[must_use]
892pub(super) fn is_ignored_workspace_dir(
893 relative_dir: &Path,
894 ignore_patterns: &crate::IgnorePatternSet,
895) -> bool {
896 if ignore_patterns.is_empty() {
897 return false;
898 }
899 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
900 ignore_patterns.excludes(relative_str.as_str())
901 || ignore_patterns.excludes(format!("{relative_str}/package.json").as_str())
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907 use fallow_types::discover::FileId;
908 use fallow_types::extract::SourceReadFailure;
909
910 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
911 WorkspaceDiagnostic::new(
912 root,
913 root.join(rel_path),
914 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
915 pattern: pattern.to_owned(),
916 },
917 )
918 }
919
920 #[test]
921 fn stash_preserves_appended_skipped_large_file_across_restash() {
922 let root = Path::new("/fallow-test-1086-stash-preserve");
925 let undeclared = || {
926 WorkspaceDiagnostic::new(
927 root,
928 root.join("pkg"),
929 WorkspaceDiagnosticKind::UndeclaredWorkspace,
930 )
931 };
932 stash_workspace_diagnostics(root, vec![undeclared()]);
934 append_workspace_diagnostics(
936 root,
937 vec![WorkspaceDiagnostic::new(
938 root,
939 root.join("vendor/big.js"),
940 WorkspaceDiagnosticKind::SkippedLargeFile {
941 size_bytes: 9_999_999,
942 },
943 )],
944 );
945 stash_workspace_diagnostics(root, vec![undeclared()]);
948
949 let after = workspace_diagnostics_for(root);
950 assert_eq!(
951 after
952 .iter()
953 .filter(|d| d.kind.is_source_discovery())
954 .count(),
955 1,
956 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
957 );
958 assert_eq!(
959 after
960 .iter()
961 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
962 .count(),
963 1,
964 "the workspace-discovery diagnostic is replaced, not duplicated"
965 );
966 }
967
968 fn analysis_stage_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
969 vec![
970 WorkspaceDiagnostic::new(
971 root,
972 root.join("pnpm-workspace.yaml"),
973 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
974 error: "could not find expected ':'".to_owned(),
975 },
976 ),
977 WorkspaceDiagnostic::new(
978 root,
979 root.join("package.json"),
980 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
981 ),
982 ]
983 }
984
985 fn count_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> usize {
986 diagnostics.iter().filter(|d| d.kind.id() == id).count()
987 }
988
989 #[test]
990 fn stash_preserves_recorded_analysis_stage_diagnostics_across_restash() {
991 let root = Path::new("/fallow-test-2366-stash-preserve");
992 let undeclared = || {
993 WorkspaceDiagnostic::new(
994 root,
995 root.join("pkg"),
996 WorkspaceDiagnosticKind::UndeclaredWorkspace,
997 )
998 };
999 stash_workspace_diagnostics(root, vec![undeclared()]);
1002 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1003 stash_workspace_diagnostics(root, vec![undeclared()]);
1006
1007 let after = workspace_diagnostics_for(root);
1008 assert_eq!(
1009 count_kind(&after, "malformed-pnpm-workspace-yaml"),
1010 1,
1011 "malformed-pnpm-workspace-yaml survives the combined-mode re-stash exactly once (#2366): {after:?}"
1012 );
1013 assert_eq!(
1014 count_kind(&after, "bun-lockb-override-resolution-skipped"),
1015 1,
1016 "bun-lockb-override-resolution-skipped survives the combined-mode re-stash exactly once (#2366): {after:?}"
1017 );
1018 assert_eq!(
1019 count_kind(&after, "undeclared-workspace"),
1020 1,
1021 "the workspace-discovery diagnostic is replaced, not duplicated"
1022 );
1023 }
1024
1025 #[test]
1026 fn source_read_failures_replace_only_their_previous_parse_set() {
1027 let root = Path::new("/fallow-test-source-read-replace");
1028 stash_workspace_diagnostics(
1029 root,
1030 vec![WorkspaceDiagnostic::new(
1031 root,
1032 root.join("pkg"),
1033 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1034 )],
1035 );
1036 append_workspace_diagnostics(
1037 root,
1038 vec![WorkspaceDiagnostic::new(
1039 root,
1040 root.join("vendor/big.js"),
1041 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
1042 )],
1043 );
1044 let first = SourceReadFailure {
1045 file_id: FileId(1),
1046 path: root.join("src/first.ts"),
1047 error: "removed".to_string(),
1048 };
1049 let _ = record_source_read_failures(root, &[first]);
1050 let second = SourceReadFailure {
1051 file_id: FileId(2),
1052 path: root.join("src/second.ts"),
1053 error: "permission denied".to_string(),
1054 };
1055
1056 let _ = record_source_read_failures(root, std::slice::from_ref(&second));
1057
1058 let diagnostics = workspace_diagnostics_for(root);
1059 let source_failures: Vec<_> = diagnostics
1060 .iter()
1061 .filter(|diagnostic| {
1062 matches!(
1063 diagnostic.kind,
1064 WorkspaceDiagnosticKind::SourceReadFailure { .. }
1065 )
1066 })
1067 .collect();
1068 assert_eq!(source_failures.len(), 1);
1069 assert_eq!(source_failures[0].path, second.path);
1070 assert!(diagnostics.iter().any(|diagnostic| matches!(
1071 diagnostic.kind,
1072 WorkspaceDiagnosticKind::UndeclaredWorkspace
1073 )));
1074 assert!(diagnostics.iter().any(|diagnostic| matches!(
1075 diagnostic.kind,
1076 WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1077 )));
1078
1079 let _ = record_source_read_failures(root, &[]);
1080 assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
1081 !matches!(
1082 diagnostic.kind,
1083 WorkspaceDiagnosticKind::SourceReadFailure { .. }
1084 )
1085 }));
1086 }
1087
1088 #[test]
1089 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
1090 let root = Path::new("/fallow-test-1086-clear-stale");
1091 stash_workspace_diagnostics(
1092 root,
1093 vec![WorkspaceDiagnostic::new(
1094 root,
1095 root.join("pkg"),
1096 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1097 )],
1098 );
1099 append_workspace_diagnostics(
1100 root,
1101 vec![WorkspaceDiagnostic::new(
1102 root,
1103 root.join("vendor/big.js"),
1104 WorkspaceDiagnosticKind::SkippedLargeFile {
1105 size_bytes: 9_999_999,
1106 },
1107 )],
1108 );
1109 let replaced = replace_source_discovery_diagnostics(root, Vec::new());
1111 assert!(
1112 replaced.is_empty(),
1113 "the walk's own list is what it wrote, not what it removed"
1114 );
1115
1116 let after = workspace_diagnostics_for(root);
1117 assert!(
1118 !after.iter().any(|d| d.kind.is_source_discovery()),
1119 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
1120 );
1121 assert!(
1122 after
1123 .iter()
1124 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
1125 "the workspace-discovery diagnostic survives the source-discovery clear"
1126 );
1127 }
1128
1129 #[test]
1130 fn clear_analysis_stage_drops_stale_entries_keeps_other_kinds() {
1131 let root = Path::new("/fallow-test-2366-clear-stale");
1132 stash_workspace_diagnostics(
1133 root,
1134 vec![WorkspaceDiagnostic::new(
1135 root,
1136 root.join("pkg"),
1137 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1138 )],
1139 );
1140 append_workspace_diagnostics(
1141 root,
1142 vec![WorkspaceDiagnostic::new(
1143 root,
1144 root.join("vendor/big.js"),
1145 WorkspaceDiagnosticKind::SkippedLargeFile {
1146 size_bytes: 9_999_999,
1147 },
1148 )],
1149 );
1150 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1151 clear_analysis_stage_diagnostics(root);
1154
1155 let after = workspace_diagnostics_for(root);
1156 assert!(
1157 !after.iter().any(|d| d.kind.is_analysis_stage()),
1158 "stale analysis-stage entries are dropped on the next analyze pass (#2366): {after:?}"
1159 );
1160 assert_eq!(
1161 count_kind(&after, "undeclared-workspace"),
1162 1,
1163 "the workspace-discovery diagnostic survives the analysis-stage clear"
1164 );
1165 assert_eq!(
1166 count_kind(&after, "skipped-large-file"),
1167 1,
1168 "the source-discovery diagnostic survives the analysis-stage clear"
1169 );
1170 }
1171
1172 #[test]
1177 fn health_stage_entries_survive_a_config_reload_and_not_the_next_health_run() {
1178 let root = Path::new("/fallow-test-2689-health-stage");
1179 stash_workspace_diagnostics(
1180 root,
1181 vec![WorkspaceDiagnostic::new(
1182 root,
1183 root.join("pkg"),
1184 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1185 )],
1186 );
1187 append_workspace_diagnostics(
1188 root,
1189 vec![
1190 WorkspaceDiagnostic::new(
1191 root,
1192 root.to_path_buf(),
1193 WorkspaceDiagnosticKind::HotspotsSkipped {
1194 cause: "not-a-repository".to_owned(),
1195 },
1196 ),
1197 WorkspaceDiagnostic::new(
1198 root,
1199 root.join("coverage/coverage-final.json"),
1200 WorkspaceDiagnosticKind::CoverageAutoDetected,
1201 ),
1202 ],
1203 );
1204
1205 stash_workspace_diagnostics(
1207 root,
1208 vec![WorkspaceDiagnostic::new(
1209 root,
1210 root.join("pkg"),
1211 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1212 )],
1213 );
1214 let after_reload = workspace_diagnostics_for(root);
1215 assert_eq!(count_kind(&after_reload, "hotspots-skipped"), 1);
1216 assert_eq!(count_kind(&after_reload, "coverage-auto-detected"), 1);
1217
1218 clear_analysis_stage_diagnostics(root);
1220 assert_eq!(
1221 health_stage_workspace_diagnostics(root).len(),
1222 2,
1223 "the analysis-stage clear must leave health-stage entries alone"
1224 );
1225
1226 clear_health_stage_diagnostics(root);
1227 let after = workspace_diagnostics_for(root);
1228 assert!(
1229 !after.iter().any(|d| d.kind.is_health_stage()),
1230 "the next health run starts from nothing: {after:?}"
1231 );
1232 assert_eq!(
1233 count_kind(&after, "undeclared-workspace"),
1234 1,
1235 "the workspace-discovery diagnostic survives the health-stage clear"
1236 );
1237 }
1238
1239 fn plugin_diagnostic(root: &Path, key: &str, reason: &str) -> WorkspaceDiagnostic {
1240 WorkspaceDiagnostic::new(
1241 root,
1242 root.join("module-federation.config.ts"),
1243 WorkspaceDiagnosticKind::PluginConfigUnreadable {
1244 plugin: "module-federation".to_owned(),
1245 key: key.to_owned(),
1246 reason: reason.to_owned(),
1247 },
1248 )
1249 }
1250
1251 #[test]
1256 fn plugin_stage_entries_survive_a_config_reload_and_are_replaced_by_the_next_run() {
1257 let root = Path::new("/fallow-test-2736-plugin-stage");
1258 let undeclared = || {
1259 WorkspaceDiagnostic::new(
1260 root,
1261 root.join("pkg"),
1262 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1263 )
1264 };
1265 stash_workspace_diagnostics(root, vec![undeclared()]);
1266 let recorded = record_plugin_config_diagnostics(
1267 root,
1268 vec![plugin_diagnostic(root, "exposes", "not-object-literal")],
1269 );
1270 assert_eq!(recorded.len(), 1, "the caller gets its own copy back");
1271
1272 stash_workspace_diagnostics(root, vec![undeclared()]);
1274 let after_reload = workspace_diagnostics_for(root);
1275 assert_eq!(
1276 count_kind(&after_reload, "plugin-config-unreadable"),
1277 1,
1278 "the plugin entry survives the combined-mode re-stash exactly once: {after_reload:?}"
1279 );
1280 assert_eq!(count_kind(&after_reload, "undeclared-workspace"), 1);
1281
1282 clear_analysis_stage_diagnostics(root);
1285 assert_eq!(
1286 count_kind(&workspace_diagnostics_for(root), "plugin-config-unreadable"),
1287 1,
1288 "the analysis-stage clear must leave plugin-stage entries alone"
1289 );
1290
1291 let _ = record_plugin_config_diagnostics(root, Vec::new());
1294 let after = workspace_diagnostics_for(root);
1295 assert!(
1296 !after.iter().any(|d| d.kind.is_plugin_stage()),
1297 "each plugin run replaces the previous set: {after:?}"
1298 );
1299 assert_eq!(
1300 count_kind(&after, "undeclared-workspace"),
1301 1,
1302 "the workspace-discovery diagnostic survives the plugin replace"
1303 );
1304 }
1305
1306 #[test]
1310 fn two_unreadable_keys_in_one_config_are_recorded_and_printed_twice() {
1311 let root = Path::new("/fallow-test-2736-two-keys");
1312 let (_, captured) = capture_workspace_warnings(|| {
1313 record_plugin_config_diagnostics(
1314 root,
1315 vec![
1316 plugin_diagnostic(root, "exposes", "not-object-literal"),
1317 plugin_diagnostic(root, "remotes", "spread"),
1318 ],
1319 )
1320 });
1321 assert_eq!(
1322 captured.len(),
1323 2,
1324 "both keys reach the emitter: {captured:?}"
1325 );
1326 let stored = workspace_diagnostics_for(root);
1327 assert_eq!(
1328 count_kind(&stored, "plugin-config-unreadable"),
1329 2,
1330 "both keys are recorded: {stored:?}"
1331 );
1332
1333 let plans = plan_warnings(
1334 root,
1335 &[
1336 plugin_diagnostic(root, "exposes", "not-object-literal"),
1337 plugin_diagnostic(root, "remotes", "spread"),
1338 ],
1339 );
1340 assert_eq!(plans.len(), 2, "two distinct lines are planned: {plans:?}");
1341 assert_ne!(
1342 plans[0].dedupe_key, plans[1].dedupe_key,
1343 "the process-wide dedupe must not swallow the second key: {plans:?}"
1344 );
1345 }
1346
1347 #[test]
1351 fn the_not_modeled_kind_is_recorded_without_a_stderr_line() {
1352 let root = Path::new("/fallow-test-2736-not-modeled");
1353 let diagnostic = WorkspaceDiagnostic::new(
1354 root,
1355 root.join("nuxt.config.ts"),
1356 WorkspaceDiagnosticKind::PluginEffectNotModeled {
1357 plugin: "nuxt".to_owned(),
1358 key: "components".to_owned(),
1359 reason: "key-effect-not-modeled".to_owned(),
1360 },
1361 );
1362 let _ = record_plugin_config_diagnostics(root, vec![diagnostic.clone()]);
1363 assert_eq!(
1364 count_kind(
1365 &workspace_diagnostics_for(root),
1366 "plugin-effect-not-modeled"
1367 ),
1368 1
1369 );
1370 assert!(
1371 plan_warnings(root, &[diagnostic]).is_empty(),
1372 "a kind that does not degrade the run plans no stderr line"
1373 );
1374 }
1375
1376 #[test]
1377 fn build_glob_group_message_caps_examples_and_summarises_tail() {
1378 let root = Path::new("/project");
1379 let paths = [
1380 root.join("playground/cli"),
1381 root.join("playground/lib-types"),
1382 root.join("playground/minify"),
1383 root.join("playground/ssr"),
1384 root.join("playground/worker"),
1385 ];
1386 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1387 let message = build_glob_group_message(root, "playground/**", &refs);
1388
1389 assert!(
1390 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
1391 "count and pattern lead the message: {message}"
1392 );
1393 assert!(
1394 message.contains(
1395 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
1396 ),
1397 "three sorted examples + tail count: {message}"
1398 );
1399 assert!(
1400 message.ends_with(
1401 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
1402 ),
1403 "next-step hint preserved: {message}"
1404 );
1405 assert!(
1406 !message.contains("playground/ssr"),
1407 "tail example not named: {message}"
1408 );
1409 }
1410
1411 #[test]
1412 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
1413 let root = Path::new("/project");
1414 let paths = [root.join("packages/a"), root.join("packages/b")];
1415 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1416 let message = build_glob_group_message(root, "packages/*", &refs);
1417
1418 assert!(message.contains("matched 2 directories"), "{message}");
1419 assert!(
1420 message.contains("(e.g. packages/a, packages/b)"),
1421 "both examples named, no `and N more`: {message}"
1422 );
1423 assert!(!message.contains("more)"), "no tail clause: {message}");
1424 }
1425
1426 #[test]
1427 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
1428 let root = Path::new("/project");
1429 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
1430 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
1431 .collect();
1432
1433 let plans = plan_warnings(root, &diagnostics);
1434
1435 assert_eq!(
1436 plans.len(),
1437 1,
1438 "50 same-pattern diagnostics collapse to one plan"
1439 );
1440 assert!(
1441 plans[0]
1442 .dedupe_key
1443 .ends_with("::glob-matched-no-package-json-agg::playground/**")
1444 );
1445 assert!(plans[0].message.contains("matched 50 directories"));
1446 }
1447
1448 #[test]
1449 fn plan_warnings_keeps_distinct_patterns_separate() {
1450 let root = Path::new("/project");
1451 let diagnostics = vec![
1452 glob_diag(root, "apps/*", "apps/a"),
1453 glob_diag(root, "apps/*", "apps/b"),
1454 glob_diag(root, "packages/*", "packages/x"),
1455 glob_diag(root, "packages/*", "packages/y"),
1456 ];
1457
1458 let plans = plan_warnings(root, &diagnostics);
1459
1460 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
1461 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
1462 assert!(
1463 messages
1464 .iter()
1465 .any(|m| m.contains("Glob 'apps/*' matched 2")),
1466 "{messages:?}"
1467 );
1468 assert!(
1469 messages
1470 .iter()
1471 .any(|m| m.contains("Glob 'packages/*' matched 2")),
1472 "{messages:?}"
1473 );
1474 }
1475
1476 #[test]
1477 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
1478 let root = Path::new("/project");
1479 let diag = glob_diag(root, "packages/*", "packages/scratch");
1480
1481 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1482
1483 assert_eq!(plans.len(), 1);
1484 assert_eq!(plans[0].message, diag.message);
1485 let expected_path = Path::new("packages").join("scratch");
1491 assert!(
1492 plans[0]
1493 .dedupe_key
1494 .contains("::glob-matched-no-package-json::")
1495 && plans[0]
1496 .dedupe_key
1497 .contains(&expected_path.display().to_string())
1498 && plans[0].dedupe_key.ends_with(&diag.message),
1499 "per-instance key is `root::kind::path::message`, not the `-agg::pattern` form: {}",
1500 plans[0].dedupe_key
1501 );
1502 assert!(
1503 !plans[0].message.contains("directories"),
1504 "single match is not aggregated"
1505 );
1506 }
1507
1508 #[test]
1509 fn plan_warnings_non_glob_kinds_stay_per_instance() {
1510 let root = Path::new("/project");
1511 let diagnostics = vec![
1512 WorkspaceDiagnostic::new(
1513 root,
1514 root.join("packages/a"),
1515 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1516 ),
1517 WorkspaceDiagnostic::new(
1518 root,
1519 root.join("packages/b"),
1520 WorkspaceDiagnosticKind::MalformedPackageJson {
1521 error: "trailing comma".to_owned(),
1522 },
1523 ),
1524 ];
1525
1526 let plans = plan_warnings(root, &diagnostics);
1527
1528 assert_eq!(
1529 plans.len(),
1530 2,
1531 "each non-glob diagnostic plans its own warning"
1532 );
1533 assert!(
1534 plans
1535 .iter()
1536 .all(|p| !p.message.contains("directories with no package.json"))
1537 );
1538 }
1539
1540 #[test]
1546 fn plan_warnings_drops_the_unconfigured_check_kinds() {
1547 let root = Path::new("/project");
1548 let diagnostics = vec![
1549 WorkspaceDiagnostic::new(
1550 root,
1551 root.to_path_buf(),
1552 WorkspaceDiagnosticKind::BoundariesNotConfigured,
1553 ),
1554 WorkspaceDiagnostic::new(
1555 root,
1556 root.to_path_buf(),
1557 WorkspaceDiagnosticKind::RulePacksNotConfigured,
1558 ),
1559 ];
1560
1561 assert!(
1562 plan_warnings(root, &diagnostics).is_empty(),
1563 "an unconfigured check is not a degraded run and warns nobody"
1564 );
1565 }
1566
1567 #[test]
1571 fn plan_warnings_keeps_the_degradation_kinds_alongside_dropped_ones() {
1572 let root = Path::new("/project");
1573 let diagnostics = vec![
1574 WorkspaceDiagnostic::new(
1575 root,
1576 root.to_path_buf(),
1577 WorkspaceDiagnosticKind::BoundariesNotConfigured,
1578 ),
1579 WorkspaceDiagnostic::new(
1580 root,
1581 root.join("node_modules"),
1582 WorkspaceDiagnosticKind::NodeModulesMissing,
1583 ),
1584 WorkspaceDiagnostic::new(
1585 root,
1586 root.to_path_buf(),
1587 WorkspaceDiagnosticKind::RulePacksNotConfigured,
1588 ),
1589 ];
1590
1591 let messages: Vec<String> = plan_warnings(root, &diagnostics)
1592 .into_iter()
1593 .map(|plan| plan.message)
1594 .collect();
1595
1596 assert_eq!(
1597 messages.len(),
1598 1,
1599 "only the degradation warns: {messages:?}"
1600 );
1601 assert!(
1602 messages[0].contains("node_modules"),
1603 "the surviving line is the missing dependency tree: {messages:?}"
1604 );
1605 }
1606
1607 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
1608 WorkspaceDiagnostic::new(
1609 root,
1610 root.join(rel_path),
1611 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1612 )
1613 }
1614
1615 #[test]
1616 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
1617 let root = Path::new("/project");
1618 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
1619 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
1620 .collect();
1621
1622 let plans = plan_warnings(root, &diagnostics);
1623
1624 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
1625 assert!(
1626 plans[0]
1627 .dedupe_key
1628 .ends_with("::tsconfig-reference-dir-missing-agg")
1629 );
1630 assert!(
1631 plans[0]
1632 .message
1633 .starts_with("tsconfig.json references 30 directories that do not exist"),
1634 "{}",
1635 plans[0].message
1636 );
1637 assert!(
1638 plans[0].message.contains(
1639 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1640 packages/p02/tsconfig.json, and 27 more)"
1641 ),
1642 "three sorted examples + tail: {}",
1643 plans[0].message
1644 );
1645 assert!(
1646 plans[0]
1647 .message
1648 .ends_with("Update or remove the references, or restore the missing directories."),
1649 "{}",
1650 plans[0].message
1651 );
1652 }
1653
1654 #[test]
1655 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1656 let root = Path::new("/project");
1657 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1658
1659 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1660
1661 assert_eq!(plans.len(), 1);
1662 assert_eq!(
1663 plans[0].message, diag.message,
1664 "single miss is not aggregated"
1665 );
1666 assert!(!plans[0].message.contains("directories that do not exist"));
1667 }
1668
1669 #[test]
1670 fn plan_warnings_aggregates_missing_pattern_dirs_per_declaring_file() {
1671 let root = Path::new("/project");
1672 let missing = |pattern: &str| {
1673 WorkspaceDiagnostic::new(
1674 root,
1675 root.join("pnpm-workspace.yaml"),
1676 WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
1677 pattern: pattern.to_owned(),
1678 },
1679 )
1680 };
1681 let diagnostics = vec![
1682 missing("vite"),
1683 missing("vite/packages/*"),
1684 missing("rolldown"),
1685 ];
1686
1687 let plans = plan_warnings(root, &diagnostics);
1688
1689 let [plan] = plans.as_slice() else {
1690 panic!("expected one aggregated warning: {plans:?}");
1691 };
1692 assert!(
1693 plan.message.starts_with(
1694 "'pnpm-workspace.yaml' declares 3 workspace patterns whose directories do not \
1695 exist ('vite', 'vite/packages/*', 'rolldown')."
1696 ),
1697 "{}",
1698 plan.message
1699 );
1700 }
1701
1702 #[test]
1703 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1704 let root = Path::new("/project");
1705 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1706 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1707 .collect();
1708 diagnostics.extend(
1709 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1710 );
1711
1712 let plans = plan_warnings(root, &diagnostics);
1713
1714 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1715 assert!(
1716 plans
1717 .iter()
1718 .any(|p| p.message.contains("matched 5 directories"))
1719 );
1720 assert!(
1721 plans
1722 .iter()
1723 .any(|p| p.message.contains("references 4 directories"))
1724 );
1725 }
1726
1727 #[test]
1733 fn two_manifest_glob_warning_counts_each_directory_once() {
1734 let dir = tempfile::tempdir().expect("create temp dir");
1735 crate::workspace::write_two_manifest_glob_project(dir.path());
1736
1737 let (_, diagnostics) = crate::workspace::discover_workspaces_with_diagnostics(
1738 dir.path(),
1739 &crate::IgnorePatternSet::empty(),
1740 )
1741 .expect("root package.json is valid");
1742
1743 let messages: Vec<String> = plan_warnings(dir.path(), &diagnostics)
1744 .into_iter()
1745 .map(|plan| plan.message)
1746 .collect();
1747
1748 assert_eq!(
1749 messages,
1750 vec![
1751 "Glob 'pkgs/*' matched 2 directories with no package.json \
1752 (e.g. pkgs/aaa, pkgs/bbb). Add a package.json, narrow the \
1753 pattern, or add them to ignorePatterns."
1754 .to_owned()
1755 ],
1756 "the summary names the true directory count and each example once"
1757 );
1758 }
1759}