1use std::path::{Path, PathBuf};
22use std::sync::{Mutex, OnceLock};
23
24use rustc_hash::{FxHashMap, FxHashSet};
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
34#[serde(tag = "kind", rename_all = "kebab-case")]
35pub enum WorkspaceDiagnosticKind {
36 UndeclaredWorkspace,
41 MalformedPackageJson {
44 error: String,
46 },
47 GlobMatchedNoPackageJson {
51 pattern: String,
53 },
54 MalformedTsconfig {
57 error: String,
59 },
60 TsconfigReferenceDirMissing,
63 SkippedLargeFile {
71 size_bytes: u64,
73 },
74 SkippedMinifiedFile {
80 size_bytes: u64,
82 },
83}
84
85impl WorkspaceDiagnosticKind {
86 #[must_use]
88 pub const fn id(&self) -> &'static str {
89 match self {
90 Self::UndeclaredWorkspace => "undeclared-workspace",
91 Self::MalformedPackageJson { .. } => "malformed-package-json",
92 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
93 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
94 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
95 Self::SkippedLargeFile { .. } => "skipped-large-file",
96 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
97 }
98 }
99
100 #[must_use]
109 pub const fn is_source_discovery(&self) -> bool {
110 matches!(
111 self,
112 Self::SkippedLargeFile { .. } | Self::SkippedMinifiedFile { .. }
113 )
114 }
115}
116
117#[must_use]
120fn format_size_mb(bytes: u64) -> String {
121 #[expect(
122 clippy::cast_precision_loss,
123 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
124 )]
125 let mb = bytes as f64 / (1024.0 * 1024.0);
126 format!("{mb:.1} MB")
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
136pub struct WorkspaceDiagnostic {
137 pub path: PathBuf,
139 #[serde(flatten)]
141 pub kind: WorkspaceDiagnosticKind,
142 pub message: String,
145}
146
147impl WorkspaceDiagnostic {
148 #[must_use]
162 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
163 let kind = normalise_payload_paths(root, kind);
164 let message = render_message(root, &path, &kind);
165 Self {
166 path,
167 kind,
168 message,
169 }
170 }
171}
172
173fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
178 let root_str = root.display().to_string();
179 let root_alt = root_str.replace('\\', "/");
180 let normalise = |text: String| -> String {
181 let stripped = text
182 .replace(&format!("{root_str}/"), "")
183 .replace(&format!("{root_alt}/"), "");
184 stripped
185 .replace(&format!("{root_str}\\"), "")
186 .replace(&format!("{root_alt}\\"), "")
187 };
188 match kind {
189 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
190 WorkspaceDiagnosticKind::MalformedPackageJson {
191 error: normalise(error),
192 }
193 }
194 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
195 WorkspaceDiagnosticKind::MalformedTsconfig {
196 error: normalise(error),
197 }
198 }
199 other => other,
200 }
201}
202
203fn display_relative(root: &Path, path: &Path) -> String {
208 path.strip_prefix(root)
209 .unwrap_or(path)
210 .display()
211 .to_string()
212 .replace('\\', "/")
213}
214
215fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
216 let display = display_relative(root, path);
217 match kind {
218 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
219 "Directory '{display}' contains package.json but is not declared as a workspace. \
220 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
221 ),
222 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
223 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
224 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
225 ),
226 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
227 "Glob '{pattern}' matched '{display}' but no package.json is present. \
228 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
229 ),
230 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
231 "tsconfig.json at '{display}' failed to parse ({error}); \
232 project references will be ignored. Fix the JSON syntax."
233 ),
234 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
235 "tsconfig.json references '{display}' but the directory does not exist. \
236 Update or remove the reference, or restore the missing directory."
237 ),
238 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
239 "Skipped '{display}' ({size}): exceeds the max file size limit. \
240 Its imports and exports are not analyzed. Raise the limit with \
241 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
242 to ignorePatterns.",
243 size = format_size_mb(*size_bytes)
244 ),
245 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
246 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
247 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
248 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
249 should be analyzed.",
250 size = format_size_mb(*size_bytes)
251 ),
252 }
253}
254
255#[derive(Debug, Clone)]
262pub enum WorkspaceLoadError {
263 MalformedRootPackageJson { path: PathBuf, error: String },
265}
266
267impl std::fmt::Display for WorkspaceLoadError {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 match self {
270 Self::MalformedRootPackageJson { path, error } => write!(
271 f,
272 "root package.json at '{}' is not valid JSON ({error}). \
273 Fix the syntax before re-running fallow.",
274 path.display()
275 ),
276 }
277 }
278}
279
280impl std::error::Error for WorkspaceLoadError {}
281
282const GLOB_EXAMPLE_CAP: usize = 3;
286
287fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
294 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
295 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
296}
297
298fn should_emit(key: String) -> bool {
303 warned_keys().lock().map_or(true, |mut set| set.insert(key))
304}
305
306#[derive(Debug, PartialEq, Eq)]
311struct PlannedWarning {
312 dedupe_key: String,
313 message: String,
314}
315
316fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
331 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
332 let per_instance = |diag: &WorkspaceDiagnostic| PlannedWarning {
333 dedupe_key: format!(
334 "{}::{}::{}",
335 canonical.display(),
336 diag.kind.id(),
337 diag.path.display()
338 ),
339 message: diag.message.clone(),
340 };
341
342 let mut plans: Vec<PlannedWarning> = Vec::new();
343 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
344 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
345 for diag in diagnostics {
346 match &diag.kind {
347 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
348 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
349 Some((_, group)) => group.push(diag),
350 None => glob_groups.push((pattern.as_str(), vec![diag])),
351 }
352 }
353 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
354 _ => plans.push(per_instance(diag)),
355 }
356 }
357
358 for (pattern, group) in glob_groups {
359 if let [only] = group.as_slice() {
360 plans.push(per_instance(only));
361 continue;
362 }
363 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
364 plans.push(PlannedWarning {
365 dedupe_key: format!(
366 "{}::glob-matched-no-package-json-agg::{pattern}",
367 canonical.display()
368 ),
369 message: build_glob_group_message(root, pattern, &paths),
370 });
371 }
372
373 if let [only] = tsconfig_ref_misses.as_slice() {
374 plans.push(per_instance(only));
375 } else if !tsconfig_ref_misses.is_empty() {
376 let paths: Vec<&Path> = tsconfig_ref_misses
377 .iter()
378 .map(|d| d.path.as_path())
379 .collect();
380 plans.push(PlannedWarning {
381 dedupe_key: format!(
382 "{}::tsconfig-reference-dir-missing-agg",
383 canonical.display()
384 ),
385 message: build_tsconfig_refs_message(root, &paths),
386 });
387 }
388
389 plans
390}
391
392pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
401 #[cfg(test)]
402 for diag in diagnostics {
403 capture_diag(diag);
404 }
405
406 for plan in plan_warnings(root, diagnostics) {
407 if should_emit(plan.dedupe_key) {
408 tracing::warn!("fallow: {}", plan.message);
409 }
410 }
411}
412
413fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
418 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
419 examples.sort();
420 let count = examples.len();
421 let shown = examples
422 .iter()
423 .take(GLOB_EXAMPLE_CAP)
424 .cloned()
425 .collect::<Vec<_>>()
426 .join(", ");
427 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
428 let listed = if remaining > 0 {
429 format!("{shown}, and {remaining} more")
430 } else {
431 shown
432 };
433 (listed, count)
434}
435
436fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
439 let (listed, count) = summarize_examples(root, paths);
440 format!(
441 "Glob '{pattern}' matched {count} directories with no package.json \
442 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
443 them to ignorePatterns."
444 )
445}
446
447fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
451 let (listed, count) = summarize_examples(root, paths);
452 format!(
453 "tsconfig.json references {count} directories that do not exist \
454 (e.g. {listed}). Update or remove the references, or restore the \
455 missing directories."
456 )
457}
458
459thread_local! {
460 #[cfg(test)]
467 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
468 const { std::cell::RefCell::new(None) };
469}
470
471#[cfg(test)]
477fn capture_diag(diag: &WorkspaceDiagnostic) {
478 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
479 if let Some(buf) = cell.borrow_mut().as_mut() {
480 buf.push(diag.clone());
481 }
482 });
483}
484
485#[cfg(test)]
493#[must_use]
494pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
495 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
496 *cell.borrow_mut() = Some(Vec::new());
497 });
498 let result = body();
499 let findings =
500 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
501 (result, findings)
502}
503
504static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
515 OnceLock::new();
516
517pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
532 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
533 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
534 if let Ok(mut map) = registry.lock() {
535 let mut combined = diagnostics;
536 if let Some(existing) = map.get(&canonical) {
537 combined.extend(
538 existing
539 .iter()
540 .filter(|d| d.kind.is_source_discovery())
541 .cloned(),
542 );
543 }
544 map.insert(canonical, combined);
545 }
546}
547
548pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
557 if additions.is_empty() {
558 return;
559 }
560 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
561 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
562 if let Ok(mut map) = registry.lock() {
563 let existing = map.entry(canonical).or_default();
564 let mut seen: FxHashSet<(String, String)> = existing
565 .iter()
566 .map(|d| {
567 (
568 d.kind.id().to_owned(),
569 dunce::canonicalize(&d.path)
570 .unwrap_or_else(|_| d.path.clone())
571 .display()
572 .to_string(),
573 )
574 })
575 .collect();
576 for addition in additions {
577 let key = (
578 addition.kind.id().to_owned(),
579 dunce::canonicalize(&addition.path)
580 .unwrap_or_else(|_| addition.path.clone())
581 .display()
582 .to_string(),
583 );
584 if seen.insert(key) {
585 existing.push(addition);
586 }
587 }
588 }
589}
590
591pub fn clear_source_discovery_diagnostics(root: &Path) {
604 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
605 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
606 return;
607 };
608 if let Ok(mut map) = registry.lock()
609 && let Some(existing) = map.get_mut(&canonical)
610 {
611 existing.retain(|d| !d.kind.is_source_discovery());
612 }
613}
614
615#[must_use]
621pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
622 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
623 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
624 return Vec::new();
625 };
626 registry
627 .lock()
628 .ok()
629 .and_then(|map| map.get(&canonical).cloned())
630 .unwrap_or_default()
631}
632
633#[must_use]
639pub(super) fn is_skip_listed_dir(name: &str) -> bool {
640 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
641}
642
643#[must_use]
648pub(super) fn is_ignored_workspace_dir(
649 relative_dir: &Path,
650 ignore_patterns: &globset::GlobSet,
651) -> bool {
652 if ignore_patterns.is_empty() {
653 return false;
654 }
655 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
656 ignore_patterns.is_match(relative_str.as_str())
657 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663
664 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
665 WorkspaceDiagnostic::new(
666 root,
667 root.join(rel_path),
668 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
669 pattern: pattern.to_owned(),
670 },
671 )
672 }
673
674 #[test]
675 fn skipped_large_file_diagnostic_id_and_message() {
676 let root = Path::new("/project");
677 let diag = WorkspaceDiagnostic::new(
678 root,
679 root.join("src/vendor/app.bundle.js"),
680 WorkspaceDiagnosticKind::SkippedLargeFile {
681 size_bytes: 6 * 1024 * 1024,
682 },
683 );
684 assert_eq!(diag.kind.id(), "skipped-large-file");
685 assert!(
686 diag.message.contains("src/vendor/app.bundle.js"),
687 "message names the project-relative path: {}",
688 diag.message
689 );
690 assert!(
691 diag.message.contains("6.0 MB"),
692 "message reports the size: {}",
693 diag.message
694 );
695 assert!(
696 diag.message.contains("--max-file-size"),
697 "message names the override flag: {}",
698 diag.message
699 );
700 }
701
702 #[test]
703 fn skipped_minified_file_diagnostic_id_and_message() {
704 let root = Path::new("/project");
705 let diag = WorkspaceDiagnostic::new(
706 root,
707 root.join("src/assets/index-abc123.js"),
708 WorkspaceDiagnosticKind::SkippedMinifiedFile {
709 size_bytes: 2 * 1024 * 1024,
710 },
711 );
712 assert_eq!(diag.kind.id(), "skipped-minified-file");
713 assert!(
714 diag.message.contains("src/assets/index-abc123.js"),
715 "message names the project-relative path: {}",
716 diag.message
717 );
718 assert!(
719 diag.message.contains("2.0 MB"),
720 "message reports the size: {}",
721 diag.message
722 );
723 assert!(
724 diag.message.contains("--max-file-size 0"),
725 "message names the opt-out: {}",
726 diag.message
727 );
728 }
729
730 #[test]
731 fn format_size_mb_one_decimal() {
732 assert_eq!(format_size_mb(0), "0.0 MB");
733 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
734 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
735 }
736
737 #[test]
738 fn stash_preserves_appended_skipped_large_file_across_restash() {
739 let root = Path::new("/fallow-test-1086-stash-preserve");
742 let undeclared = || {
743 WorkspaceDiagnostic::new(
744 root,
745 root.join("pkg"),
746 WorkspaceDiagnosticKind::UndeclaredWorkspace,
747 )
748 };
749 stash_workspace_diagnostics(root, vec![undeclared()]);
751 append_workspace_diagnostics(
753 root,
754 vec![WorkspaceDiagnostic::new(
755 root,
756 root.join("vendor/big.js"),
757 WorkspaceDiagnosticKind::SkippedLargeFile {
758 size_bytes: 9_999_999,
759 },
760 )],
761 );
762 stash_workspace_diagnostics(root, vec![undeclared()]);
765
766 let after = workspace_diagnostics_for(root);
767 assert_eq!(
768 after
769 .iter()
770 .filter(|d| d.kind.is_source_discovery())
771 .count(),
772 1,
773 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
774 );
775 assert_eq!(
776 after
777 .iter()
778 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
779 .count(),
780 1,
781 "the workspace-discovery diagnostic is replaced, not duplicated"
782 );
783 }
784
785 #[test]
786 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
787 let root = Path::new("/fallow-test-1086-clear-stale");
788 stash_workspace_diagnostics(
789 root,
790 vec![WorkspaceDiagnostic::new(
791 root,
792 root.join("pkg"),
793 WorkspaceDiagnosticKind::UndeclaredWorkspace,
794 )],
795 );
796 append_workspace_diagnostics(
797 root,
798 vec![WorkspaceDiagnostic::new(
799 root,
800 root.join("vendor/big.js"),
801 WorkspaceDiagnosticKind::SkippedLargeFile {
802 size_bytes: 9_999_999,
803 },
804 )],
805 );
806 clear_source_discovery_diagnostics(root);
808
809 let after = workspace_diagnostics_for(root);
810 assert!(
811 !after.iter().any(|d| d.kind.is_source_discovery()),
812 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
813 );
814 assert!(
815 after
816 .iter()
817 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
818 "the workspace-discovery diagnostic survives the source-discovery clear"
819 );
820 }
821
822 #[test]
823 fn build_glob_group_message_caps_examples_and_summarises_tail() {
824 let root = Path::new("/project");
825 let paths = [
826 root.join("playground/cli"),
827 root.join("playground/lib-types"),
828 root.join("playground/minify"),
829 root.join("playground/ssr"),
830 root.join("playground/worker"),
831 ];
832 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
833 let message = build_glob_group_message(root, "playground/**", &refs);
834
835 assert!(
836 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
837 "count and pattern lead the message: {message}"
838 );
839 assert!(
840 message.contains(
841 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
842 ),
843 "three sorted examples + tail count: {message}"
844 );
845 assert!(
846 message.ends_with(
847 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
848 ),
849 "next-step hint preserved: {message}"
850 );
851 assert!(
852 !message.contains("playground/ssr"),
853 "tail example not named: {message}"
854 );
855 }
856
857 #[test]
858 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
859 let root = Path::new("/project");
860 let paths = [root.join("packages/a"), root.join("packages/b")];
861 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
862 let message = build_glob_group_message(root, "packages/*", &refs);
863
864 assert!(message.contains("matched 2 directories"), "{message}");
865 assert!(
866 message.contains("(e.g. packages/a, packages/b)"),
867 "both examples named, no `and N more`: {message}"
868 );
869 assert!(!message.contains("more)"), "no tail clause: {message}");
870 }
871
872 #[test]
873 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
874 let root = Path::new("/project");
875 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
876 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
877 .collect();
878
879 let plans = plan_warnings(root, &diagnostics);
880
881 assert_eq!(
882 plans.len(),
883 1,
884 "50 same-pattern diagnostics collapse to one plan"
885 );
886 assert!(
887 plans[0]
888 .dedupe_key
889 .ends_with("::glob-matched-no-package-json-agg::playground/**")
890 );
891 assert!(plans[0].message.contains("matched 50 directories"));
892 }
893
894 #[test]
895 fn plan_warnings_keeps_distinct_patterns_separate() {
896 let root = Path::new("/project");
897 let diagnostics = vec![
898 glob_diag(root, "apps/*", "apps/a"),
899 glob_diag(root, "apps/*", "apps/b"),
900 glob_diag(root, "packages/*", "packages/x"),
901 glob_diag(root, "packages/*", "packages/y"),
902 ];
903
904 let plans = plan_warnings(root, &diagnostics);
905
906 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
907 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
908 assert!(
909 messages
910 .iter()
911 .any(|m| m.contains("Glob 'apps/*' matched 2")),
912 "{messages:?}"
913 );
914 assert!(
915 messages
916 .iter()
917 .any(|m| m.contains("Glob 'packages/*' matched 2")),
918 "{messages:?}"
919 );
920 }
921
922 #[test]
923 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
924 let root = Path::new("/project");
925 let diag = glob_diag(root, "packages/*", "packages/scratch");
926
927 let plans = plan_warnings(root, std::slice::from_ref(&diag));
928
929 assert_eq!(plans.len(), 1);
930 assert_eq!(plans[0].message, diag.message);
931 assert!(
932 plans[0]
933 .dedupe_key
934 .contains("::glob-matched-no-package-json::")
935 && plans[0].dedupe_key.ends_with("packages/scratch"),
936 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
937 plans[0].dedupe_key
938 );
939 assert!(
940 !plans[0].message.contains("directories"),
941 "single match is not aggregated"
942 );
943 }
944
945 #[test]
946 fn plan_warnings_non_glob_kinds_stay_per_instance() {
947 let root = Path::new("/project");
948 let diagnostics = vec![
949 WorkspaceDiagnostic::new(
950 root,
951 root.join("packages/a"),
952 WorkspaceDiagnosticKind::UndeclaredWorkspace,
953 ),
954 WorkspaceDiagnostic::new(
955 root,
956 root.join("packages/b"),
957 WorkspaceDiagnosticKind::MalformedPackageJson {
958 error: "trailing comma".to_owned(),
959 },
960 ),
961 ];
962
963 let plans = plan_warnings(root, &diagnostics);
964
965 assert_eq!(
966 plans.len(),
967 2,
968 "each non-glob diagnostic plans its own warning"
969 );
970 assert!(
971 plans
972 .iter()
973 .all(|p| !p.message.contains("directories with no package.json"))
974 );
975 }
976
977 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
978 WorkspaceDiagnostic::new(
979 root,
980 root.join(rel_path),
981 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
982 )
983 }
984
985 #[test]
986 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
987 let root = Path::new("/project");
988 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
989 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
990 .collect();
991
992 let plans = plan_warnings(root, &diagnostics);
993
994 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
995 assert!(
996 plans[0]
997 .dedupe_key
998 .ends_with("::tsconfig-reference-dir-missing-agg")
999 );
1000 assert!(
1001 plans[0]
1002 .message
1003 .starts_with("tsconfig.json references 30 directories that do not exist"),
1004 "{}",
1005 plans[0].message
1006 );
1007 assert!(
1008 plans[0].message.contains(
1009 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1010 packages/p02/tsconfig.json, and 27 more)"
1011 ),
1012 "three sorted examples + tail: {}",
1013 plans[0].message
1014 );
1015 assert!(
1016 plans[0]
1017 .message
1018 .ends_with("Update or remove the references, or restore the missing directories."),
1019 "{}",
1020 plans[0].message
1021 );
1022 }
1023
1024 #[test]
1025 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1026 let root = Path::new("/project");
1027 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1028
1029 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1030
1031 assert_eq!(plans.len(), 1);
1032 assert_eq!(
1033 plans[0].message, diag.message,
1034 "single miss is not aggregated"
1035 );
1036 assert!(!plans[0].message.contains("directories that do not exist"));
1037 }
1038
1039 #[test]
1040 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1041 let root = Path::new("/project");
1042 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1043 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1044 .collect();
1045 diagnostics.extend(
1046 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1047 );
1048
1049 let plans = plan_warnings(root, &diagnostics);
1050
1051 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1052 assert!(
1053 plans
1054 .iter()
1055 .any(|p| p.message.contains("matched 5 directories"))
1056 );
1057 assert!(
1058 plans
1059 .iter()
1060 .any(|p| p.message.contains("references 4 directories"))
1061 );
1062 }
1063}