1use std::path::{Path, PathBuf};
23use std::sync::{Mutex, OnceLock};
24
25use rustc_hash::{FxHashMap, FxHashSet};
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
35#[serde(tag = "kind", rename_all = "kebab-case")]
36pub enum WorkspaceDiagnosticKind {
37 UndeclaredWorkspace,
42 MalformedPackageJson {
45 error: String,
47 },
48 GlobMatchedNoPackageJson {
52 pattern: String,
54 },
55 MalformedTsconfig {
58 error: String,
60 },
61 TsconfigReferenceDirMissing,
64 SkippedLargeFile {
72 size_bytes: u64,
74 },
75}
76
77impl WorkspaceDiagnosticKind {
78 #[must_use]
80 pub const fn id(&self) -> &'static str {
81 match self {
82 Self::UndeclaredWorkspace => "undeclared-workspace",
83 Self::MalformedPackageJson { .. } => "malformed-package-json",
84 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
85 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
86 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
87 Self::SkippedLargeFile { .. } => "skipped-large-file",
88 }
89 }
90
91 #[must_use]
100 pub const fn is_source_discovery(&self) -> bool {
101 matches!(self, Self::SkippedLargeFile { .. })
102 }
103}
104
105#[must_use]
108fn format_size_mb(bytes: u64) -> String {
109 #[expect(
110 clippy::cast_precision_loss,
111 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
112 )]
113 let mb = bytes as f64 / (1024.0 * 1024.0);
114 format!("{mb:.1} MB")
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
124pub struct WorkspaceDiagnostic {
125 pub path: PathBuf,
127 #[serde(flatten)]
129 pub kind: WorkspaceDiagnosticKind,
130 pub message: String,
133}
134
135impl WorkspaceDiagnostic {
136 #[must_use]
150 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
151 let kind = normalise_payload_paths(root, kind);
152 let message = render_message(root, &path, &kind);
153 Self {
154 path,
155 kind,
156 message,
157 }
158 }
159}
160
161fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
166 let root_str = root.display().to_string();
167 let root_alt = root_str.replace('\\', "/");
168 let normalise = |text: String| -> String {
169 let stripped = text
170 .replace(&format!("{root_str}/"), "")
171 .replace(&format!("{root_alt}/"), "");
172 stripped
173 .replace(&format!("{root_str}\\"), "")
174 .replace(&format!("{root_alt}\\"), "")
175 };
176 match kind {
177 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
178 WorkspaceDiagnosticKind::MalformedPackageJson {
179 error: normalise(error),
180 }
181 }
182 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
183 WorkspaceDiagnosticKind::MalformedTsconfig {
184 error: normalise(error),
185 }
186 }
187 other => other,
188 }
189}
190
191fn display_relative(root: &Path, path: &Path) -> String {
196 path.strip_prefix(root)
197 .unwrap_or(path)
198 .display()
199 .to_string()
200 .replace('\\', "/")
201}
202
203fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
204 let display = display_relative(root, path);
205 match kind {
206 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
207 "Directory '{display}' contains package.json but is not declared as a workspace. \
208 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
209 ),
210 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
211 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
212 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
213 ),
214 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
215 "Glob '{pattern}' matched '{display}' but no package.json is present. \
216 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
217 ),
218 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
219 "tsconfig.json at '{display}' failed to parse ({error}); \
220 project references will be ignored. Fix the JSON syntax."
221 ),
222 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
223 "tsconfig.json references '{display}' but the directory does not exist. \
224 Update or remove the reference, or restore the missing directory."
225 ),
226 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
227 "Skipped '{display}' ({size}): exceeds the max file size limit. \
228 Its imports and exports are not analyzed. Raise the limit with \
229 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
230 to ignorePatterns.",
231 size = format_size_mb(*size_bytes)
232 ),
233 }
234}
235
236#[derive(Debug, Clone)]
243pub enum WorkspaceLoadError {
244 MalformedRootPackageJson { path: PathBuf, error: String },
246}
247
248impl std::fmt::Display for WorkspaceLoadError {
249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 match self {
251 Self::MalformedRootPackageJson { path, error } => write!(
252 f,
253 "root package.json at '{}' is not valid JSON ({error}). \
254 Fix the syntax before re-running fallow.",
255 path.display()
256 ),
257 }
258 }
259}
260
261impl std::error::Error for WorkspaceLoadError {}
262
263const GLOB_EXAMPLE_CAP: usize = 3;
267
268fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
275 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
276 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
277}
278
279fn should_emit(key: String) -> bool {
284 warned_keys().lock().map_or(true, |mut set| set.insert(key))
285}
286
287#[derive(Debug, PartialEq, Eq)]
292struct PlannedWarning {
293 dedupe_key: String,
294 message: String,
295}
296
297fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
312 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
313 let per_instance = |diag: &WorkspaceDiagnostic| PlannedWarning {
314 dedupe_key: format!(
315 "{}::{}::{}",
316 canonical.display(),
317 diag.kind.id(),
318 diag.path.display()
319 ),
320 message: diag.message.clone(),
321 };
322
323 let mut plans: Vec<PlannedWarning> = Vec::new();
324 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
325 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
326 for diag in diagnostics {
327 match &diag.kind {
328 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
329 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
330 Some((_, group)) => group.push(diag),
331 None => glob_groups.push((pattern.as_str(), vec![diag])),
332 }
333 }
334 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
335 _ => plans.push(per_instance(diag)),
336 }
337 }
338
339 for (pattern, group) in glob_groups {
340 if let [only] = group.as_slice() {
341 plans.push(per_instance(only));
342 continue;
343 }
344 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
345 plans.push(PlannedWarning {
346 dedupe_key: format!(
347 "{}::glob-matched-no-package-json-agg::{pattern}",
348 canonical.display()
349 ),
350 message: build_glob_group_message(root, pattern, &paths),
351 });
352 }
353
354 if let [only] = tsconfig_ref_misses.as_slice() {
355 plans.push(per_instance(only));
356 } else if !tsconfig_ref_misses.is_empty() {
357 let paths: Vec<&Path> = tsconfig_ref_misses
358 .iter()
359 .map(|d| d.path.as_path())
360 .collect();
361 plans.push(PlannedWarning {
362 dedupe_key: format!(
363 "{}::tsconfig-reference-dir-missing-agg",
364 canonical.display()
365 ),
366 message: build_tsconfig_refs_message(root, &paths),
367 });
368 }
369
370 plans
371}
372
373pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
382 #[cfg(test)]
383 for diag in diagnostics {
384 capture_diag(diag);
385 }
386
387 for plan in plan_warnings(root, diagnostics) {
388 if should_emit(plan.dedupe_key) {
389 tracing::warn!("fallow: {}", plan.message);
390 }
391 }
392}
393
394fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
399 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
400 examples.sort();
401 let count = examples.len();
402 let shown = examples
403 .iter()
404 .take(GLOB_EXAMPLE_CAP)
405 .cloned()
406 .collect::<Vec<_>>()
407 .join(", ");
408 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
409 let listed = if remaining > 0 {
410 format!("{shown}, and {remaining} more")
411 } else {
412 shown
413 };
414 (listed, count)
415}
416
417fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
420 let (listed, count) = summarize_examples(root, paths);
421 format!(
422 "Glob '{pattern}' matched {count} directories with no package.json \
423 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
424 them to ignorePatterns."
425 )
426}
427
428fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
432 let (listed, count) = summarize_examples(root, paths);
433 format!(
434 "tsconfig.json references {count} directories that do not exist \
435 (e.g. {listed}). Update or remove the references, or restore the \
436 missing directories."
437 )
438}
439
440thread_local! {
441 #[cfg(test)]
448 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
449 const { std::cell::RefCell::new(None) };
450}
451
452#[cfg(test)]
458fn capture_diag(diag: &WorkspaceDiagnostic) {
459 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
460 if let Some(buf) = cell.borrow_mut().as_mut() {
461 buf.push(diag.clone());
462 }
463 });
464}
465
466#[cfg(test)]
474#[must_use]
475pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
476 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
477 *cell.borrow_mut() = Some(Vec::new());
478 });
479 let result = body();
480 let findings =
481 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
482 (result, findings)
483}
484
485static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
496 OnceLock::new();
497
498pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
513 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
514 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
515 if let Ok(mut map) = registry.lock() {
516 let mut combined = diagnostics;
517 if let Some(existing) = map.get(&canonical) {
518 combined.extend(
519 existing
520 .iter()
521 .filter(|d| d.kind.is_source_discovery())
522 .cloned(),
523 );
524 }
525 map.insert(canonical, combined);
526 }
527}
528
529pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
538 if additions.is_empty() {
539 return;
540 }
541 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
542 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
543 if let Ok(mut map) = registry.lock() {
544 let existing = map.entry(canonical).or_default();
545 let mut seen: FxHashSet<(String, String)> = existing
546 .iter()
547 .map(|d| {
548 (
549 d.kind.id().to_owned(),
550 dunce::canonicalize(&d.path)
551 .unwrap_or_else(|_| d.path.clone())
552 .display()
553 .to_string(),
554 )
555 })
556 .collect();
557 for addition in additions {
558 let key = (
559 addition.kind.id().to_owned(),
560 dunce::canonicalize(&addition.path)
561 .unwrap_or_else(|_| addition.path.clone())
562 .display()
563 .to_string(),
564 );
565 if seen.insert(key) {
566 existing.push(addition);
567 }
568 }
569 }
570}
571
572pub fn clear_source_discovery_diagnostics(root: &Path) {
585 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
586 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
587 return;
588 };
589 if let Ok(mut map) = registry.lock()
590 && let Some(existing) = map.get_mut(&canonical)
591 {
592 existing.retain(|d| !d.kind.is_source_discovery());
593 }
594}
595
596#[must_use]
602pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
603 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
604 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
605 return Vec::new();
606 };
607 registry
608 .lock()
609 .ok()
610 .and_then(|map| map.get(&canonical).cloned())
611 .unwrap_or_default()
612}
613
614#[must_use]
620pub(super) fn is_skip_listed_dir(name: &str) -> bool {
621 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
622}
623
624#[must_use]
629pub(super) fn is_ignored_workspace_dir(
630 relative_dir: &Path,
631 ignore_patterns: &globset::GlobSet,
632) -> bool {
633 if ignore_patterns.is_empty() {
634 return false;
635 }
636 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
637 ignore_patterns.is_match(relative_str.as_str())
638 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
646 WorkspaceDiagnostic::new(
647 root,
648 root.join(rel_path),
649 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
650 pattern: pattern.to_owned(),
651 },
652 )
653 }
654
655 #[test]
656 fn skipped_large_file_diagnostic_id_and_message() {
657 let root = Path::new("/project");
658 let diag = WorkspaceDiagnostic::new(
659 root,
660 root.join("src/vendor/app.bundle.js"),
661 WorkspaceDiagnosticKind::SkippedLargeFile {
662 size_bytes: 6 * 1024 * 1024,
663 },
664 );
665 assert_eq!(diag.kind.id(), "skipped-large-file");
666 assert!(
667 diag.message.contains("src/vendor/app.bundle.js"),
668 "message names the project-relative path: {}",
669 diag.message
670 );
671 assert!(
672 diag.message.contains("6.0 MB"),
673 "message reports the size: {}",
674 diag.message
675 );
676 assert!(
677 diag.message.contains("--max-file-size"),
678 "message names the override flag: {}",
679 diag.message
680 );
681 }
682
683 #[test]
684 fn format_size_mb_one_decimal() {
685 assert_eq!(format_size_mb(0), "0.0 MB");
686 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
687 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
688 }
689
690 #[test]
691 fn stash_preserves_appended_skipped_large_file_across_restash() {
692 let root = Path::new("/fallow-test-1086-stash-preserve");
695 let undeclared = || {
696 WorkspaceDiagnostic::new(
697 root,
698 root.join("pkg"),
699 WorkspaceDiagnosticKind::UndeclaredWorkspace,
700 )
701 };
702 stash_workspace_diagnostics(root, vec![undeclared()]);
704 append_workspace_diagnostics(
706 root,
707 vec![WorkspaceDiagnostic::new(
708 root,
709 root.join("vendor/big.js"),
710 WorkspaceDiagnosticKind::SkippedLargeFile {
711 size_bytes: 9_999_999,
712 },
713 )],
714 );
715 stash_workspace_diagnostics(root, vec![undeclared()]);
718
719 let after = workspace_diagnostics_for(root);
720 assert_eq!(
721 after
722 .iter()
723 .filter(|d| d.kind.is_source_discovery())
724 .count(),
725 1,
726 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
727 );
728 assert_eq!(
729 after
730 .iter()
731 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
732 .count(),
733 1,
734 "the workspace-discovery diagnostic is replaced, not duplicated"
735 );
736 }
737
738 #[test]
739 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
740 let root = Path::new("/fallow-test-1086-clear-stale");
741 stash_workspace_diagnostics(
742 root,
743 vec![WorkspaceDiagnostic::new(
744 root,
745 root.join("pkg"),
746 WorkspaceDiagnosticKind::UndeclaredWorkspace,
747 )],
748 );
749 append_workspace_diagnostics(
750 root,
751 vec![WorkspaceDiagnostic::new(
752 root,
753 root.join("vendor/big.js"),
754 WorkspaceDiagnosticKind::SkippedLargeFile {
755 size_bytes: 9_999_999,
756 },
757 )],
758 );
759 clear_source_discovery_diagnostics(root);
761
762 let after = workspace_diagnostics_for(root);
763 assert!(
764 !after.iter().any(|d| d.kind.is_source_discovery()),
765 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
766 );
767 assert!(
768 after
769 .iter()
770 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
771 "the workspace-discovery diagnostic survives the source-discovery clear"
772 );
773 }
774
775 #[test]
776 fn build_glob_group_message_caps_examples_and_summarises_tail() {
777 let root = Path::new("/project");
778 let paths = [
779 root.join("playground/cli"),
780 root.join("playground/lib-types"),
781 root.join("playground/minify"),
782 root.join("playground/ssr"),
783 root.join("playground/worker"),
784 ];
785 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
786 let message = build_glob_group_message(root, "playground/**", &refs);
787
788 assert!(
789 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
790 "count and pattern lead the message: {message}"
791 );
792 assert!(
793 message.contains(
794 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
795 ),
796 "three sorted examples + tail count: {message}"
797 );
798 assert!(
799 message.ends_with(
800 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
801 ),
802 "next-step hint preserved: {message}"
803 );
804 assert!(
805 !message.contains("playground/ssr"),
806 "tail example not named: {message}"
807 );
808 }
809
810 #[test]
811 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
812 let root = Path::new("/project");
813 let paths = [root.join("packages/a"), root.join("packages/b")];
814 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
815 let message = build_glob_group_message(root, "packages/*", &refs);
816
817 assert!(message.contains("matched 2 directories"), "{message}");
818 assert!(
819 message.contains("(e.g. packages/a, packages/b)"),
820 "both examples named, no `and N more`: {message}"
821 );
822 assert!(!message.contains("more)"), "no tail clause: {message}");
823 }
824
825 #[test]
826 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
827 let root = Path::new("/project");
828 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
829 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
830 .collect();
831
832 let plans = plan_warnings(root, &diagnostics);
833
834 assert_eq!(
835 plans.len(),
836 1,
837 "50 same-pattern diagnostics collapse to one plan"
838 );
839 assert!(
840 plans[0]
841 .dedupe_key
842 .ends_with("::glob-matched-no-package-json-agg::playground/**")
843 );
844 assert!(plans[0].message.contains("matched 50 directories"));
845 }
846
847 #[test]
848 fn plan_warnings_keeps_distinct_patterns_separate() {
849 let root = Path::new("/project");
850 let diagnostics = vec![
851 glob_diag(root, "apps/*", "apps/a"),
852 glob_diag(root, "apps/*", "apps/b"),
853 glob_diag(root, "packages/*", "packages/x"),
854 glob_diag(root, "packages/*", "packages/y"),
855 ];
856
857 let plans = plan_warnings(root, &diagnostics);
858
859 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
860 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
861 assert!(
862 messages
863 .iter()
864 .any(|m| m.contains("Glob 'apps/*' matched 2")),
865 "{messages:?}"
866 );
867 assert!(
868 messages
869 .iter()
870 .any(|m| m.contains("Glob 'packages/*' matched 2")),
871 "{messages:?}"
872 );
873 }
874
875 #[test]
876 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
877 let root = Path::new("/project");
878 let diag = glob_diag(root, "packages/*", "packages/scratch");
879
880 let plans = plan_warnings(root, std::slice::from_ref(&diag));
881
882 assert_eq!(plans.len(), 1);
883 assert_eq!(plans[0].message, diag.message);
884 assert!(
885 plans[0]
886 .dedupe_key
887 .contains("::glob-matched-no-package-json::")
888 && plans[0].dedupe_key.ends_with("packages/scratch"),
889 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
890 plans[0].dedupe_key
891 );
892 assert!(
893 !plans[0].message.contains("directories"),
894 "single match is not aggregated"
895 );
896 }
897
898 #[test]
899 fn plan_warnings_non_glob_kinds_stay_per_instance() {
900 let root = Path::new("/project");
901 let diagnostics = vec![
902 WorkspaceDiagnostic::new(
903 root,
904 root.join("packages/a"),
905 WorkspaceDiagnosticKind::UndeclaredWorkspace,
906 ),
907 WorkspaceDiagnostic::new(
908 root,
909 root.join("packages/b"),
910 WorkspaceDiagnosticKind::MalformedPackageJson {
911 error: "trailing comma".to_owned(),
912 },
913 ),
914 ];
915
916 let plans = plan_warnings(root, &diagnostics);
917
918 assert_eq!(
919 plans.len(),
920 2,
921 "each non-glob diagnostic plans its own warning"
922 );
923 assert!(
924 plans
925 .iter()
926 .all(|p| !p.message.contains("directories with no package.json"))
927 );
928 }
929
930 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
931 WorkspaceDiagnostic::new(
932 root,
933 root.join(rel_path),
934 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
935 )
936 }
937
938 #[test]
939 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
940 let root = Path::new("/project");
941 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
942 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
943 .collect();
944
945 let plans = plan_warnings(root, &diagnostics);
946
947 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
948 assert!(
949 plans[0]
950 .dedupe_key
951 .ends_with("::tsconfig-reference-dir-missing-agg")
952 );
953 assert!(
954 plans[0]
955 .message
956 .starts_with("tsconfig.json references 30 directories that do not exist"),
957 "{}",
958 plans[0].message
959 );
960 assert!(
961 plans[0].message.contains(
962 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
963 packages/p02/tsconfig.json, and 27 more)"
964 ),
965 "three sorted examples + tail: {}",
966 plans[0].message
967 );
968 assert!(
969 plans[0]
970 .message
971 .ends_with("Update or remove the references, or restore the missing directories."),
972 "{}",
973 plans[0].message
974 );
975 }
976
977 #[test]
978 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
979 let root = Path::new("/project");
980 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
981
982 let plans = plan_warnings(root, std::slice::from_ref(&diag));
983
984 assert_eq!(plans.len(), 1);
985 assert_eq!(
986 plans[0].message, diag.message,
987 "single miss is not aggregated"
988 );
989 assert!(!plans[0].message.contains("directories that do not exist"));
990 }
991
992 #[test]
993 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
994 let root = Path::new("/project");
995 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
996 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
997 .collect();
998 diagnostics.extend(
999 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1000 );
1001
1002 let plans = plan_warnings(root, &diagnostics);
1003
1004 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1005 assert!(
1006 plans
1007 .iter()
1008 .any(|p| p.message.contains("matched 5 directories"))
1009 );
1010 assert!(
1011 plans
1012 .iter()
1013 .any(|p| p.message.contains("references 4 directories"))
1014 );
1015 }
1016}