1use std::path::{Path, PathBuf};
24use std::sync::{Mutex, OnceLock};
25
26use rustc_hash::{FxHashMap, FxHashSet};
27
28use fallow_types::path_util::display_relative;
29pub use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
30
31#[derive(Debug, Clone)]
38pub enum WorkspaceLoadError {
39 MalformedRootPackageJson {
41 path: PathBuf,
43 error: String,
45 },
46 MalformedRootDenoConfig {
48 path: PathBuf,
50 error: String,
52 },
53}
54
55impl std::fmt::Display for WorkspaceLoadError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 Self::MalformedRootPackageJson { path, error } => write!(
59 f,
60 "root package.json at '{}' is not valid JSON ({error}). \
61 Fix the syntax before re-running fallow.",
62 path.display()
63 ),
64 Self::MalformedRootDenoConfig { path, error } => write!(
65 f,
66 "root Deno config at '{}' is not valid JSONC ({error}). \
67 Fix the syntax before re-running fallow.",
68 path.display()
69 ),
70 }
71 }
72}
73
74impl std::error::Error for WorkspaceLoadError {}
75
76const GLOB_EXAMPLE_CAP: usize = 3;
80
81fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
88 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
89 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
90}
91
92fn should_emit(key: String) -> bool {
97 warned_keys().lock().map_or(true, |mut set| set.insert(key))
98}
99
100#[derive(Debug, PartialEq, Eq)]
105struct PlannedWarning {
106 dedupe_key: String,
107 message: String,
108}
109
110struct WarningGroups<'a> {
111 plans: Vec<PlannedWarning>,
112 glob_groups: Vec<(&'a str, Vec<&'a WorkspaceDiagnostic>)>,
113 tsconfig_ref_misses: Vec<&'a WorkspaceDiagnostic>,
114}
115
116fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
135 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
136 let WarningGroups {
137 mut plans,
138 glob_groups,
139 tsconfig_ref_misses,
140 } = group_warning_diagnostics(diagnostics, &canonical);
141
142 for (pattern, group) in glob_groups {
143 if let [only] = group.as_slice() {
144 plans.push(per_instance_warning(&canonical, only));
145 continue;
146 }
147 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
148 plans.push(PlannedWarning {
149 dedupe_key: format!(
150 "{}::glob-matched-no-package-json-agg::{pattern}",
151 canonical.display()
152 ),
153 message: build_glob_group_message(root, pattern, &paths),
154 });
155 }
156
157 if let [only] = tsconfig_ref_misses.as_slice() {
158 plans.push(per_instance_warning(&canonical, only));
159 } else if !tsconfig_ref_misses.is_empty() {
160 let paths: Vec<&Path> = tsconfig_ref_misses
161 .iter()
162 .map(|d| d.path.as_path())
163 .collect();
164 plans.push(PlannedWarning {
165 dedupe_key: format!(
166 "{}::tsconfig-reference-dir-missing-agg",
167 canonical.display()
168 ),
169 message: build_tsconfig_refs_message(root, &paths),
170 });
171 }
172
173 plans
174}
175
176fn group_warning_diagnostics<'a>(
177 diagnostics: &'a [WorkspaceDiagnostic],
178 canonical: &Path,
179) -> WarningGroups<'a> {
180 let mut plans: Vec<PlannedWarning> = Vec::new();
181 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
182 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
183 for diag in diagnostics {
184 if !diag.kind.warns_on_stderr() {
185 continue;
186 }
187 match &diag.kind {
188 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
189 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
190 Some((_, group)) => group.push(diag),
191 None => glob_groups.push((pattern.as_str(), vec![diag])),
192 }
193 }
194 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
195 _ => plans.push(per_instance_warning(canonical, diag)),
196 }
197 }
198 WarningGroups {
199 plans,
200 glob_groups,
201 tsconfig_ref_misses,
202 }
203}
204
205fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
219 PlannedWarning {
220 dedupe_key: format!(
221 "{}::{}::{}::{}",
222 canonical.display(),
223 diag.kind.id(),
224 diag.path.display(),
225 diag.message
226 ),
227 message: diag.message.clone(),
228 }
229}
230
231pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
240 #[cfg(test)]
241 for diag in diagnostics {
242 capture_diag(diag);
243 }
244
245 for plan in plan_warnings(root, diagnostics) {
246 if should_emit(plan.dedupe_key) {
247 tracing::warn!("fallow: {}", plan.message);
248 }
249 }
250}
251
252fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
257 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
258 examples.sort();
259 let count = examples.len();
260 let shown = examples
261 .iter()
262 .take(GLOB_EXAMPLE_CAP)
263 .cloned()
264 .collect::<Vec<_>>()
265 .join(", ");
266 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
267 let listed = if remaining > 0 {
268 format!("{shown}, and {remaining} more")
269 } else {
270 shown
271 };
272 (listed, count)
273}
274
275fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
278 let (listed, count) = summarize_examples(root, paths);
279 format!(
280 "Glob '{pattern}' matched {count} directories with no package.json \
281 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
282 them to ignorePatterns."
283 )
284}
285
286fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
290 let (listed, count) = summarize_examples(root, paths);
291 format!(
292 "tsconfig.json references {count} directories that do not exist \
293 (e.g. {listed}). Update or remove the references, or restore the \
294 missing directories."
295 )
296}
297
298thread_local! {
299 #[cfg(test)]
306 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
307 const { std::cell::RefCell::new(None) };
308}
309
310#[cfg(test)]
316fn capture_diag(diag: &WorkspaceDiagnostic) {
317 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
318 if let Some(buf) = cell.borrow_mut().as_mut() {
319 buf.push(diag.clone());
320 }
321 });
322}
323
324#[cfg(test)]
332#[must_use]
333pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
334 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
335 *cell.borrow_mut() = Some(Vec::new());
336 });
337 let result = body();
338 let findings =
339 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
340 (result, findings)
341}
342
343static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
354 OnceLock::new();
355
356pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
388 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
389 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
390 if let Ok(mut map) = registry.lock() {
391 let preserved = map.get(&canonical).map_or_else(Vec::new, |existing| {
392 existing
393 .iter()
394 .filter(|d| {
395 d.kind.is_source_discovery()
396 || d.kind.is_analysis_stage()
397 || d.kind.is_health_stage()
398 || d.kind.is_plugin_stage()
399 })
400 .cloned()
401 .collect()
402 });
403 map.insert(
404 canonical,
405 fallow_types::workspace::merge_workspace_diagnostics(diagnostics, preserved),
406 );
407 }
408}
409
410pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
425 if additions.is_empty() {
426 return;
427 }
428 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
429 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
430 if let Ok(mut map) = registry.lock() {
431 let existing = map.entry(canonical).or_default();
432 let mut seen: FxHashSet<(String, String, String)> = existing
433 .iter()
434 .map(|d| {
435 (
436 d.kind.id().to_owned(),
437 dunce::canonicalize(&d.path)
438 .unwrap_or_else(|_| d.path.clone())
439 .display()
440 .to_string(),
441 d.message.clone(),
442 )
443 })
444 .collect();
445 for addition in additions {
446 let key = (
447 addition.kind.id().to_owned(),
448 dunce::canonicalize(&addition.path)
449 .unwrap_or_else(|_| addition.path.clone())
450 .display()
451 .to_string(),
452 addition.message.clone(),
453 );
454 if seen.insert(key) {
455 existing.push(addition);
456 }
457 }
458 }
459}
460
461pub fn record_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
467 if diagnostics.is_empty() {
468 return;
469 }
470 emit_diagnostics(root, &diagnostics);
471 append_workspace_diagnostics(root, diagnostics);
472}
473
474#[must_use]
491pub fn record_plugin_config_diagnostics(
492 root: &Path,
493 diagnostics: Vec<WorkspaceDiagnostic>,
494) -> Vec<WorkspaceDiagnostic> {
495 let diagnostics = fallow_types::workspace::dedupe_workspace_diagnostics(diagnostics);
496 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
497 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
498 if let Ok(mut map) = registry.lock() {
499 let existing = map.entry(canonical).or_default();
500 existing.retain(|diagnostic| !diagnostic.kind.is_plugin_stage());
501 existing.extend(diagnostics.iter().cloned());
502 }
503 emit_diagnostics(root, &diagnostics);
504 diagnostics
505}
506
507#[must_use]
514pub fn record_source_read_failures(
515 root: &Path,
516 failures: &[fallow_types::extract::SourceReadFailure],
517) -> Vec<WorkspaceDiagnostic> {
518 let diagnostics: Vec<WorkspaceDiagnostic> = failures
519 .iter()
520 .map(|failure| {
521 WorkspaceDiagnostic::new(
522 root,
523 failure.path.clone(),
524 WorkspaceDiagnosticKind::SourceReadFailure {
525 error: failure.error.clone(),
526 },
527 )
528 })
529 .collect();
530 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
531 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
532 if let Ok(mut map) = registry.lock() {
533 let existing = map.entry(canonical).or_default();
534 existing.retain(|diagnostic| {
535 !matches!(
536 diagnostic.kind,
537 WorkspaceDiagnosticKind::SourceReadFailure { .. }
538 )
539 });
540 existing.extend(diagnostics.iter().cloned());
541 }
542 emit_diagnostics(root, &diagnostics);
543 diagnostics
544}
545
546#[must_use]
552pub fn node_modules_missing(root: &Path) -> bool {
553 !root.join("node_modules").is_dir() && !super::is_deno_without_node_modules(root)
554}
555
556#[must_use]
565pub fn missing_node_modules_diagnostic(root: &Path) -> Option<WorkspaceDiagnostic> {
566 node_modules_missing(root).then(|| {
567 WorkspaceDiagnostic::new(
568 root,
569 root.join("node_modules"),
570 WorkspaceDiagnosticKind::NodeModulesMissing,
571 )
572 })
573}
574
575#[must_use]
585pub fn record_source_parse_degradations(
586 root: &Path,
587 degradations: &[fallow_types::extract::SourceParseDegradation],
588) -> Vec<WorkspaceDiagnostic> {
589 let diagnostics: Vec<WorkspaceDiagnostic> = degradations
590 .iter()
591 .map(|degradation| {
592 WorkspaceDiagnostic::new(
593 root,
594 degradation.path.clone(),
595 WorkspaceDiagnosticKind::SourceParseDegraded {
596 error_count: degradation.error_count,
597 panicked: degradation.panicked,
598 },
599 )
600 })
601 .collect();
602 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
603 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
604 if let Ok(mut map) = registry.lock() {
605 let existing = map.entry(canonical).or_default();
606 existing.retain(|diagnostic| {
607 !matches!(
608 diagnostic.kind,
609 WorkspaceDiagnosticKind::SourceParseDegraded { .. }
610 )
611 });
612 existing.extend(diagnostics.iter().cloned());
613 }
614 emit_diagnostics(root, &diagnostics);
615 diagnostics
616}
617
618#[must_use]
649pub fn replace_source_discovery_diagnostics(
650 root: &Path,
651 diagnostics: Vec<WorkspaceDiagnostic>,
652) -> Vec<WorkspaceDiagnostic> {
653 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
654 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
655 if let Ok(mut map) = registry.lock() {
656 let existing = map.entry(canonical).or_default();
657 existing.retain(|d| !d.kind.is_source_discovery());
658 existing.extend(diagnostics.iter().cloned());
659 }
660 diagnostics
661}
662
663pub fn clear_analysis_stage_diagnostics(root: &Path) {
675 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
676 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
677 return;
678 };
679 if let Ok(mut map) = registry.lock()
680 && let Some(existing) = map.get_mut(&canonical)
681 {
682 existing.retain(|d| !d.kind.is_analysis_stage());
683 }
684}
685
686pub fn clear_health_stage_diagnostics(root: &Path) {
697 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
698 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
699 return;
700 };
701 if let Ok(mut map) = registry.lock()
702 && let Some(existing) = map.get_mut(&canonical)
703 {
704 existing.retain(|d| !d.kind.is_health_stage());
705 }
706}
707
708#[must_use]
716pub fn health_stage_workspace_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
717 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
718 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
719 return Vec::new();
720 };
721 registry
722 .lock()
723 .ok()
724 .map(|map| {
725 map.get(&canonical).map_or_else(Vec::new, |existing| {
726 existing
727 .iter()
728 .filter(|d| d.kind.is_health_stage())
729 .cloned()
730 .collect()
731 })
732 })
733 .unwrap_or_default()
734}
735
736#[must_use]
742pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
743 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
744 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
745 return Vec::new();
746 };
747 registry
748 .lock()
749 .ok()
750 .and_then(|map| map.get(&canonical).cloned())
751 .unwrap_or_default()
752}
753
754#[must_use]
784pub fn registry_diagnostics_to_fold(root: &Path) -> Vec<WorkspaceDiagnostic> {
785 let mut diagnostics: Vec<WorkspaceDiagnostic> = workspace_diagnostics_for(root)
786 .into_iter()
787 .filter(|diagnostic| !diagnostic.kind.is_source_walk_recorded())
788 .collect();
789 diagnostics.sort_by(|left, right| {
790 left.path
791 .cmp(&right.path)
792 .then_with(|| left.kind.id().cmp(right.kind.id()))
793 .then_with(|| left.message.cmp(&right.message))
794 });
795 diagnostics
796}
797
798#[must_use]
804pub(super) fn is_skip_listed_dir(name: &str) -> bool {
805 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
806}
807
808#[must_use]
813pub(super) fn is_ignored_workspace_dir(
814 relative_dir: &Path,
815 ignore_patterns: &globset::GlobSet,
816) -> bool {
817 if ignore_patterns.is_empty() {
818 return false;
819 }
820 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
821 ignore_patterns.is_match(relative_str.as_str())
822 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828 use fallow_types::discover::FileId;
829 use fallow_types::extract::SourceReadFailure;
830
831 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
832 WorkspaceDiagnostic::new(
833 root,
834 root.join(rel_path),
835 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
836 pattern: pattern.to_owned(),
837 },
838 )
839 }
840
841 #[test]
842 fn stash_preserves_appended_skipped_large_file_across_restash() {
843 let root = Path::new("/fallow-test-1086-stash-preserve");
846 let undeclared = || {
847 WorkspaceDiagnostic::new(
848 root,
849 root.join("pkg"),
850 WorkspaceDiagnosticKind::UndeclaredWorkspace,
851 )
852 };
853 stash_workspace_diagnostics(root, vec![undeclared()]);
855 append_workspace_diagnostics(
857 root,
858 vec![WorkspaceDiagnostic::new(
859 root,
860 root.join("vendor/big.js"),
861 WorkspaceDiagnosticKind::SkippedLargeFile {
862 size_bytes: 9_999_999,
863 },
864 )],
865 );
866 stash_workspace_diagnostics(root, vec![undeclared()]);
869
870 let after = workspace_diagnostics_for(root);
871 assert_eq!(
872 after
873 .iter()
874 .filter(|d| d.kind.is_source_discovery())
875 .count(),
876 1,
877 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
878 );
879 assert_eq!(
880 after
881 .iter()
882 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
883 .count(),
884 1,
885 "the workspace-discovery diagnostic is replaced, not duplicated"
886 );
887 }
888
889 fn analysis_stage_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
890 vec![
891 WorkspaceDiagnostic::new(
892 root,
893 root.join("pnpm-workspace.yaml"),
894 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
895 error: "could not find expected ':'".to_owned(),
896 },
897 ),
898 WorkspaceDiagnostic::new(
899 root,
900 root.join("package.json"),
901 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
902 ),
903 ]
904 }
905
906 fn count_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> usize {
907 diagnostics.iter().filter(|d| d.kind.id() == id).count()
908 }
909
910 #[test]
911 fn stash_preserves_recorded_analysis_stage_diagnostics_across_restash() {
912 let root = Path::new("/fallow-test-2366-stash-preserve");
913 let undeclared = || {
914 WorkspaceDiagnostic::new(
915 root,
916 root.join("pkg"),
917 WorkspaceDiagnosticKind::UndeclaredWorkspace,
918 )
919 };
920 stash_workspace_diagnostics(root, vec![undeclared()]);
923 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
924 stash_workspace_diagnostics(root, vec![undeclared()]);
927
928 let after = workspace_diagnostics_for(root);
929 assert_eq!(
930 count_kind(&after, "malformed-pnpm-workspace-yaml"),
931 1,
932 "malformed-pnpm-workspace-yaml survives the combined-mode re-stash exactly once (#2366): {after:?}"
933 );
934 assert_eq!(
935 count_kind(&after, "bun-lockb-override-resolution-skipped"),
936 1,
937 "bun-lockb-override-resolution-skipped survives the combined-mode re-stash exactly once (#2366): {after:?}"
938 );
939 assert_eq!(
940 count_kind(&after, "undeclared-workspace"),
941 1,
942 "the workspace-discovery diagnostic is replaced, not duplicated"
943 );
944 }
945
946 #[test]
947 fn source_read_failures_replace_only_their_previous_parse_set() {
948 let root = Path::new("/fallow-test-source-read-replace");
949 stash_workspace_diagnostics(
950 root,
951 vec![WorkspaceDiagnostic::new(
952 root,
953 root.join("pkg"),
954 WorkspaceDiagnosticKind::UndeclaredWorkspace,
955 )],
956 );
957 append_workspace_diagnostics(
958 root,
959 vec![WorkspaceDiagnostic::new(
960 root,
961 root.join("vendor/big.js"),
962 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
963 )],
964 );
965 let first = SourceReadFailure {
966 file_id: FileId(1),
967 path: root.join("src/first.ts"),
968 error: "removed".to_string(),
969 };
970 let _ = record_source_read_failures(root, &[first]);
971 let second = SourceReadFailure {
972 file_id: FileId(2),
973 path: root.join("src/second.ts"),
974 error: "permission denied".to_string(),
975 };
976
977 let _ = record_source_read_failures(root, std::slice::from_ref(&second));
978
979 let diagnostics = workspace_diagnostics_for(root);
980 let source_failures: Vec<_> = diagnostics
981 .iter()
982 .filter(|diagnostic| {
983 matches!(
984 diagnostic.kind,
985 WorkspaceDiagnosticKind::SourceReadFailure { .. }
986 )
987 })
988 .collect();
989 assert_eq!(source_failures.len(), 1);
990 assert_eq!(source_failures[0].path, second.path);
991 assert!(diagnostics.iter().any(|diagnostic| matches!(
992 diagnostic.kind,
993 WorkspaceDiagnosticKind::UndeclaredWorkspace
994 )));
995 assert!(diagnostics.iter().any(|diagnostic| matches!(
996 diagnostic.kind,
997 WorkspaceDiagnosticKind::SkippedLargeFile { .. }
998 )));
999
1000 let _ = record_source_read_failures(root, &[]);
1001 assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
1002 !matches!(
1003 diagnostic.kind,
1004 WorkspaceDiagnosticKind::SourceReadFailure { .. }
1005 )
1006 }));
1007 }
1008
1009 #[test]
1010 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
1011 let root = Path::new("/fallow-test-1086-clear-stale");
1012 stash_workspace_diagnostics(
1013 root,
1014 vec![WorkspaceDiagnostic::new(
1015 root,
1016 root.join("pkg"),
1017 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1018 )],
1019 );
1020 append_workspace_diagnostics(
1021 root,
1022 vec![WorkspaceDiagnostic::new(
1023 root,
1024 root.join("vendor/big.js"),
1025 WorkspaceDiagnosticKind::SkippedLargeFile {
1026 size_bytes: 9_999_999,
1027 },
1028 )],
1029 );
1030 let replaced = replace_source_discovery_diagnostics(root, Vec::new());
1032 assert!(
1033 replaced.is_empty(),
1034 "the walk's own list is what it wrote, not what it removed"
1035 );
1036
1037 let after = workspace_diagnostics_for(root);
1038 assert!(
1039 !after.iter().any(|d| d.kind.is_source_discovery()),
1040 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
1041 );
1042 assert!(
1043 after
1044 .iter()
1045 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
1046 "the workspace-discovery diagnostic survives the source-discovery clear"
1047 );
1048 }
1049
1050 #[test]
1051 fn clear_analysis_stage_drops_stale_entries_keeps_other_kinds() {
1052 let root = Path::new("/fallow-test-2366-clear-stale");
1053 stash_workspace_diagnostics(
1054 root,
1055 vec![WorkspaceDiagnostic::new(
1056 root,
1057 root.join("pkg"),
1058 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1059 )],
1060 );
1061 append_workspace_diagnostics(
1062 root,
1063 vec![WorkspaceDiagnostic::new(
1064 root,
1065 root.join("vendor/big.js"),
1066 WorkspaceDiagnosticKind::SkippedLargeFile {
1067 size_bytes: 9_999_999,
1068 },
1069 )],
1070 );
1071 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1072 clear_analysis_stage_diagnostics(root);
1075
1076 let after = workspace_diagnostics_for(root);
1077 assert!(
1078 !after.iter().any(|d| d.kind.is_analysis_stage()),
1079 "stale analysis-stage entries are dropped on the next analyze pass (#2366): {after:?}"
1080 );
1081 assert_eq!(
1082 count_kind(&after, "undeclared-workspace"),
1083 1,
1084 "the workspace-discovery diagnostic survives the analysis-stage clear"
1085 );
1086 assert_eq!(
1087 count_kind(&after, "skipped-large-file"),
1088 1,
1089 "the source-discovery diagnostic survives the analysis-stage clear"
1090 );
1091 }
1092
1093 #[test]
1098 fn health_stage_entries_survive_a_config_reload_and_not_the_next_health_run() {
1099 let root = Path::new("/fallow-test-2689-health-stage");
1100 stash_workspace_diagnostics(
1101 root,
1102 vec![WorkspaceDiagnostic::new(
1103 root,
1104 root.join("pkg"),
1105 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1106 )],
1107 );
1108 append_workspace_diagnostics(
1109 root,
1110 vec![
1111 WorkspaceDiagnostic::new(
1112 root,
1113 root.to_path_buf(),
1114 WorkspaceDiagnosticKind::HotspotsSkipped {
1115 cause: "not-a-repository".to_owned(),
1116 },
1117 ),
1118 WorkspaceDiagnostic::new(
1119 root,
1120 root.join("coverage/coverage-final.json"),
1121 WorkspaceDiagnosticKind::CoverageAutoDetected,
1122 ),
1123 ],
1124 );
1125
1126 stash_workspace_diagnostics(
1128 root,
1129 vec![WorkspaceDiagnostic::new(
1130 root,
1131 root.join("pkg"),
1132 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1133 )],
1134 );
1135 let after_reload = workspace_diagnostics_for(root);
1136 assert_eq!(count_kind(&after_reload, "hotspots-skipped"), 1);
1137 assert_eq!(count_kind(&after_reload, "coverage-auto-detected"), 1);
1138
1139 clear_analysis_stage_diagnostics(root);
1141 assert_eq!(
1142 health_stage_workspace_diagnostics(root).len(),
1143 2,
1144 "the analysis-stage clear must leave health-stage entries alone"
1145 );
1146
1147 clear_health_stage_diagnostics(root);
1148 let after = workspace_diagnostics_for(root);
1149 assert!(
1150 !after.iter().any(|d| d.kind.is_health_stage()),
1151 "the next health run starts from nothing: {after:?}"
1152 );
1153 assert_eq!(
1154 count_kind(&after, "undeclared-workspace"),
1155 1,
1156 "the workspace-discovery diagnostic survives the health-stage clear"
1157 );
1158 }
1159
1160 fn plugin_diagnostic(root: &Path, key: &str, reason: &str) -> WorkspaceDiagnostic {
1161 WorkspaceDiagnostic::new(
1162 root,
1163 root.join("module-federation.config.ts"),
1164 WorkspaceDiagnosticKind::PluginConfigUnreadable {
1165 plugin: "module-federation".to_owned(),
1166 key: key.to_owned(),
1167 reason: reason.to_owned(),
1168 },
1169 )
1170 }
1171
1172 #[test]
1177 fn plugin_stage_entries_survive_a_config_reload_and_are_replaced_by_the_next_run() {
1178 let root = Path::new("/fallow-test-2736-plugin-stage");
1179 let undeclared = || {
1180 WorkspaceDiagnostic::new(
1181 root,
1182 root.join("pkg"),
1183 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1184 )
1185 };
1186 stash_workspace_diagnostics(root, vec![undeclared()]);
1187 let recorded = record_plugin_config_diagnostics(
1188 root,
1189 vec![plugin_diagnostic(root, "exposes", "not-object-literal")],
1190 );
1191 assert_eq!(recorded.len(), 1, "the caller gets its own copy back");
1192
1193 stash_workspace_diagnostics(root, vec![undeclared()]);
1195 let after_reload = workspace_diagnostics_for(root);
1196 assert_eq!(
1197 count_kind(&after_reload, "plugin-config-unreadable"),
1198 1,
1199 "the plugin entry survives the combined-mode re-stash exactly once: {after_reload:?}"
1200 );
1201 assert_eq!(count_kind(&after_reload, "undeclared-workspace"), 1);
1202
1203 clear_analysis_stage_diagnostics(root);
1206 assert_eq!(
1207 count_kind(&workspace_diagnostics_for(root), "plugin-config-unreadable"),
1208 1,
1209 "the analysis-stage clear must leave plugin-stage entries alone"
1210 );
1211
1212 let _ = record_plugin_config_diagnostics(root, Vec::new());
1215 let after = workspace_diagnostics_for(root);
1216 assert!(
1217 !after.iter().any(|d| d.kind.is_plugin_stage()),
1218 "each plugin run replaces the previous set: {after:?}"
1219 );
1220 assert_eq!(
1221 count_kind(&after, "undeclared-workspace"),
1222 1,
1223 "the workspace-discovery diagnostic survives the plugin replace"
1224 );
1225 }
1226
1227 #[test]
1231 fn two_unreadable_keys_in_one_config_are_recorded_and_printed_twice() {
1232 let root = Path::new("/fallow-test-2736-two-keys");
1233 let (_, captured) = capture_workspace_warnings(|| {
1234 record_plugin_config_diagnostics(
1235 root,
1236 vec![
1237 plugin_diagnostic(root, "exposes", "not-object-literal"),
1238 plugin_diagnostic(root, "remotes", "spread"),
1239 ],
1240 )
1241 });
1242 assert_eq!(
1243 captured.len(),
1244 2,
1245 "both keys reach the emitter: {captured:?}"
1246 );
1247 let stored = workspace_diagnostics_for(root);
1248 assert_eq!(
1249 count_kind(&stored, "plugin-config-unreadable"),
1250 2,
1251 "both keys are recorded: {stored:?}"
1252 );
1253
1254 let plans = plan_warnings(
1255 root,
1256 &[
1257 plugin_diagnostic(root, "exposes", "not-object-literal"),
1258 plugin_diagnostic(root, "remotes", "spread"),
1259 ],
1260 );
1261 assert_eq!(plans.len(), 2, "two distinct lines are planned: {plans:?}");
1262 assert_ne!(
1263 plans[0].dedupe_key, plans[1].dedupe_key,
1264 "the process-wide dedupe must not swallow the second key: {plans:?}"
1265 );
1266 }
1267
1268 #[test]
1272 fn the_not_modeled_kind_is_recorded_without_a_stderr_line() {
1273 let root = Path::new("/fallow-test-2736-not-modeled");
1274 let diagnostic = WorkspaceDiagnostic::new(
1275 root,
1276 root.join("nuxt.config.ts"),
1277 WorkspaceDiagnosticKind::PluginEffectNotModeled {
1278 plugin: "nuxt".to_owned(),
1279 key: "components".to_owned(),
1280 reason: "key-effect-not-modeled".to_owned(),
1281 },
1282 );
1283 let _ = record_plugin_config_diagnostics(root, vec![diagnostic.clone()]);
1284 assert_eq!(
1285 count_kind(
1286 &workspace_diagnostics_for(root),
1287 "plugin-effect-not-modeled"
1288 ),
1289 1
1290 );
1291 assert!(
1292 plan_warnings(root, &[diagnostic]).is_empty(),
1293 "a kind that does not degrade the run plans no stderr line"
1294 );
1295 }
1296
1297 #[test]
1298 fn build_glob_group_message_caps_examples_and_summarises_tail() {
1299 let root = Path::new("/project");
1300 let paths = [
1301 root.join("playground/cli"),
1302 root.join("playground/lib-types"),
1303 root.join("playground/minify"),
1304 root.join("playground/ssr"),
1305 root.join("playground/worker"),
1306 ];
1307 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1308 let message = build_glob_group_message(root, "playground/**", &refs);
1309
1310 assert!(
1311 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
1312 "count and pattern lead the message: {message}"
1313 );
1314 assert!(
1315 message.contains(
1316 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
1317 ),
1318 "three sorted examples + tail count: {message}"
1319 );
1320 assert!(
1321 message.ends_with(
1322 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
1323 ),
1324 "next-step hint preserved: {message}"
1325 );
1326 assert!(
1327 !message.contains("playground/ssr"),
1328 "tail example not named: {message}"
1329 );
1330 }
1331
1332 #[test]
1333 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
1334 let root = Path::new("/project");
1335 let paths = [root.join("packages/a"), root.join("packages/b")];
1336 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1337 let message = build_glob_group_message(root, "packages/*", &refs);
1338
1339 assert!(message.contains("matched 2 directories"), "{message}");
1340 assert!(
1341 message.contains("(e.g. packages/a, packages/b)"),
1342 "both examples named, no `and N more`: {message}"
1343 );
1344 assert!(!message.contains("more)"), "no tail clause: {message}");
1345 }
1346
1347 #[test]
1348 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
1349 let root = Path::new("/project");
1350 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
1351 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
1352 .collect();
1353
1354 let plans = plan_warnings(root, &diagnostics);
1355
1356 assert_eq!(
1357 plans.len(),
1358 1,
1359 "50 same-pattern diagnostics collapse to one plan"
1360 );
1361 assert!(
1362 plans[0]
1363 .dedupe_key
1364 .ends_with("::glob-matched-no-package-json-agg::playground/**")
1365 );
1366 assert!(plans[0].message.contains("matched 50 directories"));
1367 }
1368
1369 #[test]
1370 fn plan_warnings_keeps_distinct_patterns_separate() {
1371 let root = Path::new("/project");
1372 let diagnostics = vec![
1373 glob_diag(root, "apps/*", "apps/a"),
1374 glob_diag(root, "apps/*", "apps/b"),
1375 glob_diag(root, "packages/*", "packages/x"),
1376 glob_diag(root, "packages/*", "packages/y"),
1377 ];
1378
1379 let plans = plan_warnings(root, &diagnostics);
1380
1381 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
1382 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
1383 assert!(
1384 messages
1385 .iter()
1386 .any(|m| m.contains("Glob 'apps/*' matched 2")),
1387 "{messages:?}"
1388 );
1389 assert!(
1390 messages
1391 .iter()
1392 .any(|m| m.contains("Glob 'packages/*' matched 2")),
1393 "{messages:?}"
1394 );
1395 }
1396
1397 #[test]
1398 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
1399 let root = Path::new("/project");
1400 let diag = glob_diag(root, "packages/*", "packages/scratch");
1401
1402 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1403
1404 assert_eq!(plans.len(), 1);
1405 assert_eq!(plans[0].message, diag.message);
1406 let expected_path = Path::new("packages").join("scratch");
1412 assert!(
1413 plans[0]
1414 .dedupe_key
1415 .contains("::glob-matched-no-package-json::")
1416 && plans[0]
1417 .dedupe_key
1418 .contains(&expected_path.display().to_string())
1419 && plans[0].dedupe_key.ends_with(&diag.message),
1420 "per-instance key is `root::kind::path::message`, not the `-agg::pattern` form: {}",
1421 plans[0].dedupe_key
1422 );
1423 assert!(
1424 !plans[0].message.contains("directories"),
1425 "single match is not aggregated"
1426 );
1427 }
1428
1429 #[test]
1430 fn plan_warnings_non_glob_kinds_stay_per_instance() {
1431 let root = Path::new("/project");
1432 let diagnostics = vec![
1433 WorkspaceDiagnostic::new(
1434 root,
1435 root.join("packages/a"),
1436 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1437 ),
1438 WorkspaceDiagnostic::new(
1439 root,
1440 root.join("packages/b"),
1441 WorkspaceDiagnosticKind::MalformedPackageJson {
1442 error: "trailing comma".to_owned(),
1443 },
1444 ),
1445 ];
1446
1447 let plans = plan_warnings(root, &diagnostics);
1448
1449 assert_eq!(
1450 plans.len(),
1451 2,
1452 "each non-glob diagnostic plans its own warning"
1453 );
1454 assert!(
1455 plans
1456 .iter()
1457 .all(|p| !p.message.contains("directories with no package.json"))
1458 );
1459 }
1460
1461 #[test]
1467 fn plan_warnings_drops_the_unconfigured_check_kinds() {
1468 let root = Path::new("/project");
1469 let diagnostics = vec![
1470 WorkspaceDiagnostic::new(
1471 root,
1472 root.to_path_buf(),
1473 WorkspaceDiagnosticKind::BoundariesNotConfigured,
1474 ),
1475 WorkspaceDiagnostic::new(
1476 root,
1477 root.to_path_buf(),
1478 WorkspaceDiagnosticKind::RulePacksNotConfigured,
1479 ),
1480 ];
1481
1482 assert!(
1483 plan_warnings(root, &diagnostics).is_empty(),
1484 "an unconfigured check is not a degraded run and warns nobody"
1485 );
1486 }
1487
1488 #[test]
1492 fn plan_warnings_keeps_the_degradation_kinds_alongside_dropped_ones() {
1493 let root = Path::new("/project");
1494 let diagnostics = vec![
1495 WorkspaceDiagnostic::new(
1496 root,
1497 root.to_path_buf(),
1498 WorkspaceDiagnosticKind::BoundariesNotConfigured,
1499 ),
1500 WorkspaceDiagnostic::new(
1501 root,
1502 root.join("node_modules"),
1503 WorkspaceDiagnosticKind::NodeModulesMissing,
1504 ),
1505 WorkspaceDiagnostic::new(
1506 root,
1507 root.to_path_buf(),
1508 WorkspaceDiagnosticKind::RulePacksNotConfigured,
1509 ),
1510 ];
1511
1512 let messages: Vec<String> = plan_warnings(root, &diagnostics)
1513 .into_iter()
1514 .map(|plan| plan.message)
1515 .collect();
1516
1517 assert_eq!(
1518 messages.len(),
1519 1,
1520 "only the degradation warns: {messages:?}"
1521 );
1522 assert!(
1523 messages[0].contains("node_modules"),
1524 "the surviving line is the missing dependency tree: {messages:?}"
1525 );
1526 }
1527
1528 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
1529 WorkspaceDiagnostic::new(
1530 root,
1531 root.join(rel_path),
1532 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1533 )
1534 }
1535
1536 #[test]
1537 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
1538 let root = Path::new("/project");
1539 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
1540 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
1541 .collect();
1542
1543 let plans = plan_warnings(root, &diagnostics);
1544
1545 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
1546 assert!(
1547 plans[0]
1548 .dedupe_key
1549 .ends_with("::tsconfig-reference-dir-missing-agg")
1550 );
1551 assert!(
1552 plans[0]
1553 .message
1554 .starts_with("tsconfig.json references 30 directories that do not exist"),
1555 "{}",
1556 plans[0].message
1557 );
1558 assert!(
1559 plans[0].message.contains(
1560 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1561 packages/p02/tsconfig.json, and 27 more)"
1562 ),
1563 "three sorted examples + tail: {}",
1564 plans[0].message
1565 );
1566 assert!(
1567 plans[0]
1568 .message
1569 .ends_with("Update or remove the references, or restore the missing directories."),
1570 "{}",
1571 plans[0].message
1572 );
1573 }
1574
1575 #[test]
1576 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1577 let root = Path::new("/project");
1578 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1579
1580 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1581
1582 assert_eq!(plans.len(), 1);
1583 assert_eq!(
1584 plans[0].message, diag.message,
1585 "single miss is not aggregated"
1586 );
1587 assert!(!plans[0].message.contains("directories that do not exist"));
1588 }
1589
1590 #[test]
1591 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1592 let root = Path::new("/project");
1593 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1594 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1595 .collect();
1596 diagnostics.extend(
1597 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1598 );
1599
1600 let plans = plan_warnings(root, &diagnostics);
1601
1602 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1603 assert!(
1604 plans
1605 .iter()
1606 .any(|p| p.message.contains("matched 5 directories"))
1607 );
1608 assert!(
1609 plans
1610 .iter()
1611 .any(|p| p.message.contains("references 4 directories"))
1612 );
1613 }
1614
1615 #[test]
1621 fn two_manifest_glob_warning_counts_each_directory_once() {
1622 let dir = tempfile::tempdir().expect("create temp dir");
1623 crate::workspace::write_two_manifest_glob_project(dir.path());
1624
1625 let (_, diagnostics) = crate::workspace::discover_workspaces_with_diagnostics(
1626 dir.path(),
1627 &globset::GlobSet::empty(),
1628 )
1629 .expect("root package.json is valid");
1630
1631 let messages: Vec<String> = plan_warnings(dir.path(), &diagnostics)
1632 .into_iter()
1633 .map(|plan| plan.message)
1634 .collect();
1635
1636 assert_eq!(
1637 messages,
1638 vec![
1639 "Glob 'pkgs/*' matched 2 directories with no package.json \
1640 (e.g. pkgs/aaa, pkgs/bbb). Add a package.json, narrow the \
1641 pattern, or add them to ignorePatterns."
1642 .to_owned()
1643 ],
1644 "the summary names the true directory count and each example once"
1645 );
1646 }
1647}