1use std::path::{Path, PathBuf};
22use std::sync::{Mutex, OnceLock};
23
24use rustc_hash::{FxHashMap, FxHashSet};
25
26pub use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
27
28fn display_relative(root: &Path, path: &Path) -> String {
35 path.strip_prefix(root)
36 .unwrap_or(path)
37 .display()
38 .to_string()
39 .replace('\\', "/")
40}
41
42#[derive(Debug, Clone)]
49pub enum WorkspaceLoadError {
50 MalformedRootPackageJson { path: PathBuf, error: String },
52}
53
54impl std::fmt::Display for WorkspaceLoadError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 Self::MalformedRootPackageJson { path, error } => write!(
58 f,
59 "root package.json at '{}' is not valid JSON ({error}). \
60 Fix the syntax before re-running fallow.",
61 path.display()
62 ),
63 }
64 }
65}
66
67impl std::error::Error for WorkspaceLoadError {}
68
69const GLOB_EXAMPLE_CAP: usize = 3;
73
74fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
81 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
82 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
83}
84
85fn should_emit(key: String) -> bool {
90 warned_keys().lock().map_or(true, |mut set| set.insert(key))
91}
92
93#[derive(Debug, PartialEq, Eq)]
98struct PlannedWarning {
99 dedupe_key: String,
100 message: String,
101}
102
103struct WarningGroups<'a> {
104 plans: Vec<PlannedWarning>,
105 glob_groups: Vec<(&'a str, Vec<&'a WorkspaceDiagnostic>)>,
106 tsconfig_ref_misses: Vec<&'a WorkspaceDiagnostic>,
107}
108
109fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
124 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
125 let WarningGroups {
126 mut plans,
127 glob_groups,
128 tsconfig_ref_misses,
129 } = group_warning_diagnostics(diagnostics, &canonical);
130
131 for (pattern, group) in glob_groups {
132 if let [only] = group.as_slice() {
133 plans.push(per_instance_warning(&canonical, only));
134 continue;
135 }
136 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
137 plans.push(PlannedWarning {
138 dedupe_key: format!(
139 "{}::glob-matched-no-package-json-agg::{pattern}",
140 canonical.display()
141 ),
142 message: build_glob_group_message(root, pattern, &paths),
143 });
144 }
145
146 if let [only] = tsconfig_ref_misses.as_slice() {
147 plans.push(per_instance_warning(&canonical, only));
148 } else if !tsconfig_ref_misses.is_empty() {
149 let paths: Vec<&Path> = tsconfig_ref_misses
150 .iter()
151 .map(|d| d.path.as_path())
152 .collect();
153 plans.push(PlannedWarning {
154 dedupe_key: format!(
155 "{}::tsconfig-reference-dir-missing-agg",
156 canonical.display()
157 ),
158 message: build_tsconfig_refs_message(root, &paths),
159 });
160 }
161
162 plans
163}
164
165fn group_warning_diagnostics<'a>(
166 diagnostics: &'a [WorkspaceDiagnostic],
167 canonical: &Path,
168) -> WarningGroups<'a> {
169 let mut plans: Vec<PlannedWarning> = Vec::new();
170 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
171 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
172 for diag in diagnostics {
173 match &diag.kind {
174 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
175 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
176 Some((_, group)) => group.push(diag),
177 None => glob_groups.push((pattern.as_str(), vec![diag])),
178 }
179 }
180 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
181 _ => plans.push(per_instance_warning(canonical, diag)),
182 }
183 }
184 WarningGroups {
185 plans,
186 glob_groups,
187 tsconfig_ref_misses,
188 }
189}
190
191fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
192 PlannedWarning {
193 dedupe_key: format!(
194 "{}::{}::{}",
195 canonical.display(),
196 diag.kind.id(),
197 diag.path.display()
198 ),
199 message: diag.message.clone(),
200 }
201}
202
203pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
212 #[cfg(test)]
213 for diag in diagnostics {
214 capture_diag(diag);
215 }
216
217 for plan in plan_warnings(root, diagnostics) {
218 if should_emit(plan.dedupe_key) {
219 tracing::warn!("fallow: {}", plan.message);
220 }
221 }
222}
223
224fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
229 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
230 examples.sort();
231 let count = examples.len();
232 let shown = examples
233 .iter()
234 .take(GLOB_EXAMPLE_CAP)
235 .cloned()
236 .collect::<Vec<_>>()
237 .join(", ");
238 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
239 let listed = if remaining > 0 {
240 format!("{shown}, and {remaining} more")
241 } else {
242 shown
243 };
244 (listed, count)
245}
246
247fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
250 let (listed, count) = summarize_examples(root, paths);
251 format!(
252 "Glob '{pattern}' matched {count} directories with no package.json \
253 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
254 them to ignorePatterns."
255 )
256}
257
258fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
262 let (listed, count) = summarize_examples(root, paths);
263 format!(
264 "tsconfig.json references {count} directories that do not exist \
265 (e.g. {listed}). Update or remove the references, or restore the \
266 missing directories."
267 )
268}
269
270thread_local! {
271 #[cfg(test)]
278 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
279 const { std::cell::RefCell::new(None) };
280}
281
282#[cfg(test)]
288fn capture_diag(diag: &WorkspaceDiagnostic) {
289 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
290 if let Some(buf) = cell.borrow_mut().as_mut() {
291 buf.push(diag.clone());
292 }
293 });
294}
295
296#[cfg(test)]
304#[must_use]
305pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
306 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
307 *cell.borrow_mut() = Some(Vec::new());
308 });
309 let result = body();
310 let findings =
311 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
312 (result, findings)
313}
314
315static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
326 OnceLock::new();
327
328pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
343 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
344 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
345 if let Ok(mut map) = registry.lock() {
346 let mut combined = diagnostics;
347 if let Some(existing) = map.get(&canonical) {
348 combined.extend(
349 existing
350 .iter()
351 .filter(|d| d.kind.is_source_discovery())
352 .cloned(),
353 );
354 }
355 map.insert(canonical, combined);
356 }
357}
358
359pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
368 if additions.is_empty() {
369 return;
370 }
371 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
372 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
373 if let Ok(mut map) = registry.lock() {
374 let existing = map.entry(canonical).or_default();
375 let mut seen: FxHashSet<(String, String)> = existing
376 .iter()
377 .map(|d| {
378 (
379 d.kind.id().to_owned(),
380 dunce::canonicalize(&d.path)
381 .unwrap_or_else(|_| d.path.clone())
382 .display()
383 .to_string(),
384 )
385 })
386 .collect();
387 for addition in additions {
388 let key = (
389 addition.kind.id().to_owned(),
390 dunce::canonicalize(&addition.path)
391 .unwrap_or_else(|_| addition.path.clone())
392 .display()
393 .to_string(),
394 );
395 if seen.insert(key) {
396 existing.push(addition);
397 }
398 }
399 }
400}
401
402#[must_use]
409pub fn record_source_read_failures(
410 root: &Path,
411 failures: &[fallow_types::extract::SourceReadFailure],
412) -> Vec<WorkspaceDiagnostic> {
413 let diagnostics: Vec<WorkspaceDiagnostic> = failures
414 .iter()
415 .map(|failure| {
416 WorkspaceDiagnostic::new(
417 root,
418 failure.path.clone(),
419 WorkspaceDiagnosticKind::SourceReadFailure {
420 error: failure.error.clone(),
421 },
422 )
423 })
424 .collect();
425 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
426 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
427 if let Ok(mut map) = registry.lock() {
428 let existing = map.entry(canonical).or_default();
429 existing.retain(|diagnostic| {
430 !matches!(
431 diagnostic.kind,
432 WorkspaceDiagnosticKind::SourceReadFailure { .. }
433 )
434 });
435 existing.extend(diagnostics.iter().cloned());
436 }
437 emit_diagnostics(root, &diagnostics);
438 diagnostics
439}
440
441pub fn clear_source_discovery_diagnostics(root: &Path) {
454 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
455 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
456 return;
457 };
458 if let Ok(mut map) = registry.lock()
459 && let Some(existing) = map.get_mut(&canonical)
460 {
461 existing.retain(|d| !d.kind.is_source_discovery());
462 }
463}
464
465#[must_use]
471pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
472 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
473 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
474 return Vec::new();
475 };
476 registry
477 .lock()
478 .ok()
479 .and_then(|map| map.get(&canonical).cloned())
480 .unwrap_or_default()
481}
482
483#[must_use]
489pub(super) fn is_skip_listed_dir(name: &str) -> bool {
490 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
491}
492
493#[must_use]
498pub(super) fn is_ignored_workspace_dir(
499 relative_dir: &Path,
500 ignore_patterns: &globset::GlobSet,
501) -> bool {
502 if ignore_patterns.is_empty() {
503 return false;
504 }
505 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
506 ignore_patterns.is_match(relative_str.as_str())
507 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use fallow_types::discover::FileId;
514 use fallow_types::extract::SourceReadFailure;
515
516 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
517 WorkspaceDiagnostic::new(
518 root,
519 root.join(rel_path),
520 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
521 pattern: pattern.to_owned(),
522 },
523 )
524 }
525
526 #[test]
527 fn skipped_large_file_diagnostic_id_and_message() {
528 let root = Path::new("/project");
529 let diag = WorkspaceDiagnostic::new(
530 root,
531 root.join("src/vendor/app.bundle.js"),
532 WorkspaceDiagnosticKind::SkippedLargeFile {
533 size_bytes: 6 * 1024 * 1024,
534 },
535 );
536 assert_eq!(diag.kind.id(), "skipped-large-file");
537 assert!(
538 diag.message.contains("src/vendor/app.bundle.js"),
539 "message names the project-relative path: {}",
540 diag.message
541 );
542 assert!(
543 diag.message.contains("6.0 MB"),
544 "message reports the size: {}",
545 diag.message
546 );
547 assert!(
548 diag.message.contains("--max-file-size"),
549 "message names the override flag: {}",
550 diag.message
551 );
552 }
553
554 #[test]
555 fn skipped_minified_file_diagnostic_id_and_message() {
556 let root = Path::new("/project");
557 let diag = WorkspaceDiagnostic::new(
558 root,
559 root.join("src/assets/index-abc123.js"),
560 WorkspaceDiagnosticKind::SkippedMinifiedFile {
561 size_bytes: 2 * 1024 * 1024,
562 },
563 );
564 assert_eq!(diag.kind.id(), "skipped-minified-file");
565 assert!(
566 diag.message.contains("src/assets/index-abc123.js"),
567 "message names the project-relative path: {}",
568 diag.message
569 );
570 assert!(
571 diag.message.contains("2.0 MB"),
572 "message reports the size: {}",
573 diag.message
574 );
575 assert!(
576 diag.message.contains("--max-file-size 0"),
577 "message names the opt-out: {}",
578 diag.message
579 );
580 }
581
582 #[test]
583 fn stash_preserves_appended_skipped_large_file_across_restash() {
584 let root = Path::new("/fallow-test-1086-stash-preserve");
587 let undeclared = || {
588 WorkspaceDiagnostic::new(
589 root,
590 root.join("pkg"),
591 WorkspaceDiagnosticKind::UndeclaredWorkspace,
592 )
593 };
594 stash_workspace_diagnostics(root, vec![undeclared()]);
596 append_workspace_diagnostics(
598 root,
599 vec![WorkspaceDiagnostic::new(
600 root,
601 root.join("vendor/big.js"),
602 WorkspaceDiagnosticKind::SkippedLargeFile {
603 size_bytes: 9_999_999,
604 },
605 )],
606 );
607 stash_workspace_diagnostics(root, vec![undeclared()]);
610
611 let after = workspace_diagnostics_for(root);
612 assert_eq!(
613 after
614 .iter()
615 .filter(|d| d.kind.is_source_discovery())
616 .count(),
617 1,
618 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
619 );
620 assert_eq!(
621 after
622 .iter()
623 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
624 .count(),
625 1,
626 "the workspace-discovery diagnostic is replaced, not duplicated"
627 );
628 }
629
630 #[test]
631 fn source_read_failures_replace_only_their_previous_parse_set() {
632 let root = Path::new("/fallow-test-source-read-replace");
633 stash_workspace_diagnostics(
634 root,
635 vec![WorkspaceDiagnostic::new(
636 root,
637 root.join("pkg"),
638 WorkspaceDiagnosticKind::UndeclaredWorkspace,
639 )],
640 );
641 append_workspace_diagnostics(
642 root,
643 vec![WorkspaceDiagnostic::new(
644 root,
645 root.join("vendor/big.js"),
646 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
647 )],
648 );
649 let first = SourceReadFailure {
650 file_id: FileId(1),
651 path: root.join("src/first.ts"),
652 error: "removed".to_string(),
653 };
654 let _ = record_source_read_failures(root, &[first]);
655 let second = SourceReadFailure {
656 file_id: FileId(2),
657 path: root.join("src/second.ts"),
658 error: "permission denied".to_string(),
659 };
660
661 let _ = record_source_read_failures(root, std::slice::from_ref(&second));
662
663 let diagnostics = workspace_diagnostics_for(root);
664 let source_failures: Vec<_> = diagnostics
665 .iter()
666 .filter(|diagnostic| {
667 matches!(
668 diagnostic.kind,
669 WorkspaceDiagnosticKind::SourceReadFailure { .. }
670 )
671 })
672 .collect();
673 assert_eq!(source_failures.len(), 1);
674 assert_eq!(source_failures[0].path, second.path);
675 assert!(diagnostics.iter().any(|diagnostic| matches!(
676 diagnostic.kind,
677 WorkspaceDiagnosticKind::UndeclaredWorkspace
678 )));
679 assert!(diagnostics.iter().any(|diagnostic| matches!(
680 diagnostic.kind,
681 WorkspaceDiagnosticKind::SkippedLargeFile { .. }
682 )));
683
684 let _ = record_source_read_failures(root, &[]);
685 assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
686 !matches!(
687 diagnostic.kind,
688 WorkspaceDiagnosticKind::SourceReadFailure { .. }
689 )
690 }));
691 }
692
693 #[test]
694 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
695 let root = Path::new("/fallow-test-1086-clear-stale");
696 stash_workspace_diagnostics(
697 root,
698 vec![WorkspaceDiagnostic::new(
699 root,
700 root.join("pkg"),
701 WorkspaceDiagnosticKind::UndeclaredWorkspace,
702 )],
703 );
704 append_workspace_diagnostics(
705 root,
706 vec![WorkspaceDiagnostic::new(
707 root,
708 root.join("vendor/big.js"),
709 WorkspaceDiagnosticKind::SkippedLargeFile {
710 size_bytes: 9_999_999,
711 },
712 )],
713 );
714 clear_source_discovery_diagnostics(root);
716
717 let after = workspace_diagnostics_for(root);
718 assert!(
719 !after.iter().any(|d| d.kind.is_source_discovery()),
720 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
721 );
722 assert!(
723 after
724 .iter()
725 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
726 "the workspace-discovery diagnostic survives the source-discovery clear"
727 );
728 }
729
730 #[test]
731 fn build_glob_group_message_caps_examples_and_summarises_tail() {
732 let root = Path::new("/project");
733 let paths = [
734 root.join("playground/cli"),
735 root.join("playground/lib-types"),
736 root.join("playground/minify"),
737 root.join("playground/ssr"),
738 root.join("playground/worker"),
739 ];
740 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
741 let message = build_glob_group_message(root, "playground/**", &refs);
742
743 assert!(
744 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
745 "count and pattern lead the message: {message}"
746 );
747 assert!(
748 message.contains(
749 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
750 ),
751 "three sorted examples + tail count: {message}"
752 );
753 assert!(
754 message.ends_with(
755 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
756 ),
757 "next-step hint preserved: {message}"
758 );
759 assert!(
760 !message.contains("playground/ssr"),
761 "tail example not named: {message}"
762 );
763 }
764
765 #[test]
766 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
767 let root = Path::new("/project");
768 let paths = [root.join("packages/a"), root.join("packages/b")];
769 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
770 let message = build_glob_group_message(root, "packages/*", &refs);
771
772 assert!(message.contains("matched 2 directories"), "{message}");
773 assert!(
774 message.contains("(e.g. packages/a, packages/b)"),
775 "both examples named, no `and N more`: {message}"
776 );
777 assert!(!message.contains("more)"), "no tail clause: {message}");
778 }
779
780 #[test]
781 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
782 let root = Path::new("/project");
783 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
784 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
785 .collect();
786
787 let plans = plan_warnings(root, &diagnostics);
788
789 assert_eq!(
790 plans.len(),
791 1,
792 "50 same-pattern diagnostics collapse to one plan"
793 );
794 assert!(
795 plans[0]
796 .dedupe_key
797 .ends_with("::glob-matched-no-package-json-agg::playground/**")
798 );
799 assert!(plans[0].message.contains("matched 50 directories"));
800 }
801
802 #[test]
803 fn plan_warnings_keeps_distinct_patterns_separate() {
804 let root = Path::new("/project");
805 let diagnostics = vec![
806 glob_diag(root, "apps/*", "apps/a"),
807 glob_diag(root, "apps/*", "apps/b"),
808 glob_diag(root, "packages/*", "packages/x"),
809 glob_diag(root, "packages/*", "packages/y"),
810 ];
811
812 let plans = plan_warnings(root, &diagnostics);
813
814 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
815 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
816 assert!(
817 messages
818 .iter()
819 .any(|m| m.contains("Glob 'apps/*' matched 2")),
820 "{messages:?}"
821 );
822 assert!(
823 messages
824 .iter()
825 .any(|m| m.contains("Glob 'packages/*' matched 2")),
826 "{messages:?}"
827 );
828 }
829
830 #[test]
831 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
832 let root = Path::new("/project");
833 let diag = glob_diag(root, "packages/*", "packages/scratch");
834
835 let plans = plan_warnings(root, std::slice::from_ref(&diag));
836
837 assert_eq!(plans.len(), 1);
838 assert_eq!(plans[0].message, diag.message);
839 assert!(
840 plans[0]
841 .dedupe_key
842 .contains("::glob-matched-no-package-json::")
843 && plans[0].dedupe_key.ends_with("packages/scratch"),
844 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
845 plans[0].dedupe_key
846 );
847 assert!(
848 !plans[0].message.contains("directories"),
849 "single match is not aggregated"
850 );
851 }
852
853 #[test]
854 fn plan_warnings_non_glob_kinds_stay_per_instance() {
855 let root = Path::new("/project");
856 let diagnostics = vec![
857 WorkspaceDiagnostic::new(
858 root,
859 root.join("packages/a"),
860 WorkspaceDiagnosticKind::UndeclaredWorkspace,
861 ),
862 WorkspaceDiagnostic::new(
863 root,
864 root.join("packages/b"),
865 WorkspaceDiagnosticKind::MalformedPackageJson {
866 error: "trailing comma".to_owned(),
867 },
868 ),
869 ];
870
871 let plans = plan_warnings(root, &diagnostics);
872
873 assert_eq!(
874 plans.len(),
875 2,
876 "each non-glob diagnostic plans its own warning"
877 );
878 assert!(
879 plans
880 .iter()
881 .all(|p| !p.message.contains("directories with no package.json"))
882 );
883 }
884
885 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
886 WorkspaceDiagnostic::new(
887 root,
888 root.join(rel_path),
889 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
890 )
891 }
892
893 #[test]
894 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
895 let root = Path::new("/project");
896 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
897 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
898 .collect();
899
900 let plans = plan_warnings(root, &diagnostics);
901
902 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
903 assert!(
904 plans[0]
905 .dedupe_key
906 .ends_with("::tsconfig-reference-dir-missing-agg")
907 );
908 assert!(
909 plans[0]
910 .message
911 .starts_with("tsconfig.json references 30 directories that do not exist"),
912 "{}",
913 plans[0].message
914 );
915 assert!(
916 plans[0].message.contains(
917 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
918 packages/p02/tsconfig.json, and 27 more)"
919 ),
920 "three sorted examples + tail: {}",
921 plans[0].message
922 );
923 assert!(
924 plans[0]
925 .message
926 .ends_with("Update or remove the references, or restore the missing directories."),
927 "{}",
928 plans[0].message
929 );
930 }
931
932 #[test]
933 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
934 let root = Path::new("/project");
935 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
936
937 let plans = plan_warnings(root, std::slice::from_ref(&diag));
938
939 assert_eq!(plans.len(), 1);
940 assert_eq!(
941 plans[0].message, diag.message,
942 "single miss is not aggregated"
943 );
944 assert!(!plans[0].message.contains("directories that do not exist"));
945 }
946
947 #[test]
948 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
949 let root = Path::new("/project");
950 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
951 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
952 .collect();
953 diagnostics.extend(
954 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
955 );
956
957 let plans = plan_warnings(root, &diagnostics);
958
959 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
960 assert!(
961 plans
962 .iter()
963 .any(|p| p.message.contains("matched 5 directories"))
964 );
965 assert!(
966 plans
967 .iter()
968 .any(|p| p.message.contains("references 4 directories"))
969 );
970 }
971}