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 {
52 path: PathBuf,
54 error: String,
56 },
57 MalformedRootDenoConfig {
59 path: PathBuf,
61 error: String,
63 },
64}
65
66impl std::fmt::Display for WorkspaceLoadError {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 Self::MalformedRootPackageJson { path, error } => write!(
70 f,
71 "root package.json at '{}' is not valid JSON ({error}). \
72 Fix the syntax before re-running fallow.",
73 path.display()
74 ),
75 Self::MalformedRootDenoConfig { path, error } => write!(
76 f,
77 "root Deno config at '{}' is not valid JSONC ({error}). \
78 Fix the syntax before re-running fallow.",
79 path.display()
80 ),
81 }
82 }
83}
84
85impl std::error::Error for WorkspaceLoadError {}
86
87const GLOB_EXAMPLE_CAP: usize = 3;
91
92fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
99 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
100 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
101}
102
103fn should_emit(key: String) -> bool {
108 warned_keys().lock().map_or(true, |mut set| set.insert(key))
109}
110
111#[derive(Debug, PartialEq, Eq)]
116struct PlannedWarning {
117 dedupe_key: String,
118 message: String,
119}
120
121struct WarningGroups<'a> {
122 plans: Vec<PlannedWarning>,
123 glob_groups: Vec<(&'a str, Vec<&'a WorkspaceDiagnostic>)>,
124 tsconfig_ref_misses: Vec<&'a WorkspaceDiagnostic>,
125}
126
127fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
142 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
143 let WarningGroups {
144 mut plans,
145 glob_groups,
146 tsconfig_ref_misses,
147 } = group_warning_diagnostics(diagnostics, &canonical);
148
149 for (pattern, group) in glob_groups {
150 if let [only] = group.as_slice() {
151 plans.push(per_instance_warning(&canonical, only));
152 continue;
153 }
154 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
155 plans.push(PlannedWarning {
156 dedupe_key: format!(
157 "{}::glob-matched-no-package-json-agg::{pattern}",
158 canonical.display()
159 ),
160 message: build_glob_group_message(root, pattern, &paths),
161 });
162 }
163
164 if let [only] = tsconfig_ref_misses.as_slice() {
165 plans.push(per_instance_warning(&canonical, only));
166 } else if !tsconfig_ref_misses.is_empty() {
167 let paths: Vec<&Path> = tsconfig_ref_misses
168 .iter()
169 .map(|d| d.path.as_path())
170 .collect();
171 plans.push(PlannedWarning {
172 dedupe_key: format!(
173 "{}::tsconfig-reference-dir-missing-agg",
174 canonical.display()
175 ),
176 message: build_tsconfig_refs_message(root, &paths),
177 });
178 }
179
180 plans
181}
182
183fn group_warning_diagnostics<'a>(
184 diagnostics: &'a [WorkspaceDiagnostic],
185 canonical: &Path,
186) -> WarningGroups<'a> {
187 let mut plans: Vec<PlannedWarning> = Vec::new();
188 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
189 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
190 for diag in diagnostics {
191 match &diag.kind {
192 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
193 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
194 Some((_, group)) => group.push(diag),
195 None => glob_groups.push((pattern.as_str(), vec![diag])),
196 }
197 }
198 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
199 _ => plans.push(per_instance_warning(canonical, diag)),
200 }
201 }
202 WarningGroups {
203 plans,
204 glob_groups,
205 tsconfig_ref_misses,
206 }
207}
208
209fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
210 PlannedWarning {
211 dedupe_key: format!(
212 "{}::{}::{}",
213 canonical.display(),
214 diag.kind.id(),
215 diag.path.display()
216 ),
217 message: diag.message.clone(),
218 }
219}
220
221pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
230 #[cfg(test)]
231 for diag in diagnostics {
232 capture_diag(diag);
233 }
234
235 for plan in plan_warnings(root, diagnostics) {
236 if should_emit(plan.dedupe_key) {
237 tracing::warn!("fallow: {}", plan.message);
238 }
239 }
240}
241
242fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
247 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
248 examples.sort();
249 let count = examples.len();
250 let shown = examples
251 .iter()
252 .take(GLOB_EXAMPLE_CAP)
253 .cloned()
254 .collect::<Vec<_>>()
255 .join(", ");
256 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
257 let listed = if remaining > 0 {
258 format!("{shown}, and {remaining} more")
259 } else {
260 shown
261 };
262 (listed, count)
263}
264
265fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
268 let (listed, count) = summarize_examples(root, paths);
269 format!(
270 "Glob '{pattern}' matched {count} directories with no package.json \
271 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
272 them to ignorePatterns."
273 )
274}
275
276fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
280 let (listed, count) = summarize_examples(root, paths);
281 format!(
282 "tsconfig.json references {count} directories that do not exist \
283 (e.g. {listed}). Update or remove the references, or restore the \
284 missing directories."
285 )
286}
287
288thread_local! {
289 #[cfg(test)]
296 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
297 const { std::cell::RefCell::new(None) };
298}
299
300#[cfg(test)]
306fn capture_diag(diag: &WorkspaceDiagnostic) {
307 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
308 if let Some(buf) = cell.borrow_mut().as_mut() {
309 buf.push(diag.clone());
310 }
311 });
312}
313
314#[cfg(test)]
322#[must_use]
323pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
324 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
325 *cell.borrow_mut() = Some(Vec::new());
326 });
327 let result = body();
328 let findings =
329 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
330 (result, findings)
331}
332
333static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
344 OnceLock::new();
345
346pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
374 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
375 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
376 if let Ok(mut map) = registry.lock() {
377 let preserved = map.get(&canonical).map_or_else(Vec::new, |existing| {
378 existing
379 .iter()
380 .filter(|d| d.kind.is_source_discovery() || d.kind.is_analysis_stage())
381 .cloned()
382 .collect()
383 });
384 map.insert(
385 canonical,
386 fallow_types::workspace::merge_workspace_diagnostics(diagnostics, preserved),
387 );
388 }
389}
390
391pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
400 if additions.is_empty() {
401 return;
402 }
403 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
404 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
405 if let Ok(mut map) = registry.lock() {
406 let existing = map.entry(canonical).or_default();
407 let mut seen: FxHashSet<(String, String)> = existing
408 .iter()
409 .map(|d| {
410 (
411 d.kind.id().to_owned(),
412 dunce::canonicalize(&d.path)
413 .unwrap_or_else(|_| d.path.clone())
414 .display()
415 .to_string(),
416 )
417 })
418 .collect();
419 for addition in additions {
420 let key = (
421 addition.kind.id().to_owned(),
422 dunce::canonicalize(&addition.path)
423 .unwrap_or_else(|_| addition.path.clone())
424 .display()
425 .to_string(),
426 );
427 if seen.insert(key) {
428 existing.push(addition);
429 }
430 }
431 }
432}
433
434pub fn record_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
440 if diagnostics.is_empty() {
441 return;
442 }
443 emit_diagnostics(root, &diagnostics);
444 append_workspace_diagnostics(root, diagnostics);
445}
446
447#[must_use]
454pub fn record_source_read_failures(
455 root: &Path,
456 failures: &[fallow_types::extract::SourceReadFailure],
457) -> Vec<WorkspaceDiagnostic> {
458 let diagnostics: Vec<WorkspaceDiagnostic> = failures
459 .iter()
460 .map(|failure| {
461 WorkspaceDiagnostic::new(
462 root,
463 failure.path.clone(),
464 WorkspaceDiagnosticKind::SourceReadFailure {
465 error: failure.error.clone(),
466 },
467 )
468 })
469 .collect();
470 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
471 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
472 if let Ok(mut map) = registry.lock() {
473 let existing = map.entry(canonical).or_default();
474 existing.retain(|diagnostic| {
475 !matches!(
476 diagnostic.kind,
477 WorkspaceDiagnosticKind::SourceReadFailure { .. }
478 )
479 });
480 existing.extend(diagnostics.iter().cloned());
481 }
482 emit_diagnostics(root, &diagnostics);
483 diagnostics
484}
485
486#[must_use]
517pub fn replace_source_discovery_diagnostics(
518 root: &Path,
519 diagnostics: Vec<WorkspaceDiagnostic>,
520) -> Vec<WorkspaceDiagnostic> {
521 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
522 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
523 if let Ok(mut map) = registry.lock() {
524 let existing = map.entry(canonical).or_default();
525 existing.retain(|d| !d.kind.is_source_discovery());
526 existing.extend(diagnostics.iter().cloned());
527 }
528 diagnostics
529}
530
531pub fn clear_analysis_stage_diagnostics(root: &Path) {
543 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
544 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
545 return;
546 };
547 if let Ok(mut map) = registry.lock()
548 && let Some(existing) = map.get_mut(&canonical)
549 {
550 existing.retain(|d| !d.kind.is_analysis_stage());
551 }
552}
553
554#[must_use]
560pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
561 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
562 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
563 return Vec::new();
564 };
565 registry
566 .lock()
567 .ok()
568 .and_then(|map| map.get(&canonical).cloned())
569 .unwrap_or_default()
570}
571
572#[must_use]
593pub fn registry_diagnostics_to_fold(root: &Path) -> Vec<WorkspaceDiagnostic> {
594 workspace_diagnostics_for(root)
595 .into_iter()
596 .filter(|diagnostic| !diagnostic.kind.is_source_walk_recorded())
597 .collect()
598}
599
600#[must_use]
606pub(super) fn is_skip_listed_dir(name: &str) -> bool {
607 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
608}
609
610#[must_use]
615pub(super) fn is_ignored_workspace_dir(
616 relative_dir: &Path,
617 ignore_patterns: &globset::GlobSet,
618) -> bool {
619 if ignore_patterns.is_empty() {
620 return false;
621 }
622 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
623 ignore_patterns.is_match(relative_str.as_str())
624 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use fallow_types::discover::FileId;
631 use fallow_types::extract::SourceReadFailure;
632
633 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
634 WorkspaceDiagnostic::new(
635 root,
636 root.join(rel_path),
637 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
638 pattern: pattern.to_owned(),
639 },
640 )
641 }
642
643 #[test]
644 fn skipped_large_file_diagnostic_id_and_message() {
645 let root = Path::new("/project");
646 let diag = WorkspaceDiagnostic::new(
647 root,
648 root.join("src/vendor/app.bundle.js"),
649 WorkspaceDiagnosticKind::SkippedLargeFile {
650 size_bytes: 6 * 1024 * 1024,
651 },
652 );
653 assert_eq!(diag.kind.id(), "skipped-large-file");
654 assert!(
655 diag.message.contains("src/vendor/app.bundle.js"),
656 "message names the project-relative path: {}",
657 diag.message
658 );
659 assert!(
660 diag.message.contains("6.0 MB"),
661 "message reports the size: {}",
662 diag.message
663 );
664 assert!(
665 diag.message.contains("--max-file-size"),
666 "message names the override flag: {}",
667 diag.message
668 );
669 }
670
671 #[test]
672 fn skipped_minified_file_diagnostic_id_and_message() {
673 let root = Path::new("/project");
674 let diag = WorkspaceDiagnostic::new(
675 root,
676 root.join("src/assets/index-abc123.js"),
677 WorkspaceDiagnosticKind::SkippedMinifiedFile {
678 size_bytes: 2 * 1024 * 1024,
679 },
680 );
681 assert_eq!(diag.kind.id(), "skipped-minified-file");
682 assert!(
683 diag.message.contains("src/assets/index-abc123.js"),
684 "message names the project-relative path: {}",
685 diag.message
686 );
687 assert!(
688 diag.message.contains("2.0 MB"),
689 "message reports the size: {}",
690 diag.message
691 );
692 assert!(
693 diag.message.contains("--max-file-size 0"),
694 "message names the opt-out: {}",
695 diag.message
696 );
697 }
698
699 #[test]
700 fn skipped_source_dotdir_diagnostic_id_and_message() {
701 let root = Path::new("/project");
702 let diag = WorkspaceDiagnostic::new(
703 root,
704 root.join(".claude"),
705 WorkspaceDiagnosticKind::SkippedSourceDotdir,
706 );
707 assert_eq!(diag.kind.id(), "skipped-source-dotdir");
708 assert!(
709 diag.message.contains(".claude"),
710 "message names the project-relative path: {}",
711 diag.message
712 );
713 assert!(
714 diag.message
715 .contains("Its imports and exports are not analyzed."),
716 "message states the consequence: {}",
717 diag.message
718 );
719 assert!(
720 diag.message.contains("--root"),
721 "message names the real remedy: {}",
722 diag.message
723 );
724 assert!(
725 diag.message.contains("no config field"),
726 "the message must say plainly that no config field traverses it: {}",
727 diag.message
728 );
729 }
730
731 #[test]
732 fn stash_preserves_appended_skipped_large_file_across_restash() {
733 let root = Path::new("/fallow-test-1086-stash-preserve");
736 let undeclared = || {
737 WorkspaceDiagnostic::new(
738 root,
739 root.join("pkg"),
740 WorkspaceDiagnosticKind::UndeclaredWorkspace,
741 )
742 };
743 stash_workspace_diagnostics(root, vec![undeclared()]);
745 append_workspace_diagnostics(
747 root,
748 vec![WorkspaceDiagnostic::new(
749 root,
750 root.join("vendor/big.js"),
751 WorkspaceDiagnosticKind::SkippedLargeFile {
752 size_bytes: 9_999_999,
753 },
754 )],
755 );
756 stash_workspace_diagnostics(root, vec![undeclared()]);
759
760 let after = workspace_diagnostics_for(root);
761 assert_eq!(
762 after
763 .iter()
764 .filter(|d| d.kind.is_source_discovery())
765 .count(),
766 1,
767 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
768 );
769 assert_eq!(
770 after
771 .iter()
772 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
773 .count(),
774 1,
775 "the workspace-discovery diagnostic is replaced, not duplicated"
776 );
777 }
778
779 fn analysis_stage_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
780 vec![
781 WorkspaceDiagnostic::new(
782 root,
783 root.join("pnpm-workspace.yaml"),
784 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
785 error: "could not find expected ':'".to_owned(),
786 },
787 ),
788 WorkspaceDiagnostic::new(
789 root,
790 root.join("package.json"),
791 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
792 ),
793 ]
794 }
795
796 fn count_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> usize {
797 diagnostics.iter().filter(|d| d.kind.id() == id).count()
798 }
799
800 #[test]
801 fn stash_preserves_recorded_analysis_stage_diagnostics_across_restash() {
802 let root = Path::new("/fallow-test-2366-stash-preserve");
803 let undeclared = || {
804 WorkspaceDiagnostic::new(
805 root,
806 root.join("pkg"),
807 WorkspaceDiagnosticKind::UndeclaredWorkspace,
808 )
809 };
810 stash_workspace_diagnostics(root, vec![undeclared()]);
813 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
814 stash_workspace_diagnostics(root, vec![undeclared()]);
817
818 let after = workspace_diagnostics_for(root);
819 assert_eq!(
820 count_kind(&after, "malformed-pnpm-workspace-yaml"),
821 1,
822 "malformed-pnpm-workspace-yaml survives the combined-mode re-stash exactly once (#2366): {after:?}"
823 );
824 assert_eq!(
825 count_kind(&after, "bun-lockb-override-resolution-skipped"),
826 1,
827 "bun-lockb-override-resolution-skipped survives the combined-mode re-stash exactly once (#2366): {after:?}"
828 );
829 assert_eq!(
830 count_kind(&after, "undeclared-workspace"),
831 1,
832 "the workspace-discovery diagnostic is replaced, not duplicated"
833 );
834 }
835
836 #[test]
837 fn source_read_failures_replace_only_their_previous_parse_set() {
838 let root = Path::new("/fallow-test-source-read-replace");
839 stash_workspace_diagnostics(
840 root,
841 vec![WorkspaceDiagnostic::new(
842 root,
843 root.join("pkg"),
844 WorkspaceDiagnosticKind::UndeclaredWorkspace,
845 )],
846 );
847 append_workspace_diagnostics(
848 root,
849 vec![WorkspaceDiagnostic::new(
850 root,
851 root.join("vendor/big.js"),
852 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
853 )],
854 );
855 let first = SourceReadFailure {
856 file_id: FileId(1),
857 path: root.join("src/first.ts"),
858 error: "removed".to_string(),
859 };
860 let _ = record_source_read_failures(root, &[first]);
861 let second = SourceReadFailure {
862 file_id: FileId(2),
863 path: root.join("src/second.ts"),
864 error: "permission denied".to_string(),
865 };
866
867 let _ = record_source_read_failures(root, std::slice::from_ref(&second));
868
869 let diagnostics = workspace_diagnostics_for(root);
870 let source_failures: Vec<_> = diagnostics
871 .iter()
872 .filter(|diagnostic| {
873 matches!(
874 diagnostic.kind,
875 WorkspaceDiagnosticKind::SourceReadFailure { .. }
876 )
877 })
878 .collect();
879 assert_eq!(source_failures.len(), 1);
880 assert_eq!(source_failures[0].path, second.path);
881 assert!(diagnostics.iter().any(|diagnostic| matches!(
882 diagnostic.kind,
883 WorkspaceDiagnosticKind::UndeclaredWorkspace
884 )));
885 assert!(diagnostics.iter().any(|diagnostic| matches!(
886 diagnostic.kind,
887 WorkspaceDiagnosticKind::SkippedLargeFile { .. }
888 )));
889
890 let _ = record_source_read_failures(root, &[]);
891 assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
892 !matches!(
893 diagnostic.kind,
894 WorkspaceDiagnosticKind::SourceReadFailure { .. }
895 )
896 }));
897 }
898
899 #[test]
900 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
901 let root = Path::new("/fallow-test-1086-clear-stale");
902 stash_workspace_diagnostics(
903 root,
904 vec![WorkspaceDiagnostic::new(
905 root,
906 root.join("pkg"),
907 WorkspaceDiagnosticKind::UndeclaredWorkspace,
908 )],
909 );
910 append_workspace_diagnostics(
911 root,
912 vec![WorkspaceDiagnostic::new(
913 root,
914 root.join("vendor/big.js"),
915 WorkspaceDiagnosticKind::SkippedLargeFile {
916 size_bytes: 9_999_999,
917 },
918 )],
919 );
920 let replaced = replace_source_discovery_diagnostics(root, Vec::new());
922 assert!(
923 replaced.is_empty(),
924 "the walk's own list is what it wrote, not what it removed"
925 );
926
927 let after = workspace_diagnostics_for(root);
928 assert!(
929 !after.iter().any(|d| d.kind.is_source_discovery()),
930 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
931 );
932 assert!(
933 after
934 .iter()
935 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
936 "the workspace-discovery diagnostic survives the source-discovery clear"
937 );
938 }
939
940 #[test]
941 fn clear_analysis_stage_drops_stale_entries_keeps_other_kinds() {
942 let root = Path::new("/fallow-test-2366-clear-stale");
943 stash_workspace_diagnostics(
944 root,
945 vec![WorkspaceDiagnostic::new(
946 root,
947 root.join("pkg"),
948 WorkspaceDiagnosticKind::UndeclaredWorkspace,
949 )],
950 );
951 append_workspace_diagnostics(
952 root,
953 vec![WorkspaceDiagnostic::new(
954 root,
955 root.join("vendor/big.js"),
956 WorkspaceDiagnosticKind::SkippedLargeFile {
957 size_bytes: 9_999_999,
958 },
959 )],
960 );
961 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
962 clear_analysis_stage_diagnostics(root);
965
966 let after = workspace_diagnostics_for(root);
967 assert!(
968 !after.iter().any(|d| d.kind.is_analysis_stage()),
969 "stale analysis-stage entries are dropped on the next analyze pass (#2366): {after:?}"
970 );
971 assert_eq!(
972 count_kind(&after, "undeclared-workspace"),
973 1,
974 "the workspace-discovery diagnostic survives the analysis-stage clear"
975 );
976 assert_eq!(
977 count_kind(&after, "skipped-large-file"),
978 1,
979 "the source-discovery diagnostic survives the analysis-stage clear"
980 );
981 }
982
983 #[test]
984 fn build_glob_group_message_caps_examples_and_summarises_tail() {
985 let root = Path::new("/project");
986 let paths = [
987 root.join("playground/cli"),
988 root.join("playground/lib-types"),
989 root.join("playground/minify"),
990 root.join("playground/ssr"),
991 root.join("playground/worker"),
992 ];
993 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
994 let message = build_glob_group_message(root, "playground/**", &refs);
995
996 assert!(
997 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
998 "count and pattern lead the message: {message}"
999 );
1000 assert!(
1001 message.contains(
1002 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
1003 ),
1004 "three sorted examples + tail count: {message}"
1005 );
1006 assert!(
1007 message.ends_with(
1008 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
1009 ),
1010 "next-step hint preserved: {message}"
1011 );
1012 assert!(
1013 !message.contains("playground/ssr"),
1014 "tail example not named: {message}"
1015 );
1016 }
1017
1018 #[test]
1019 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
1020 let root = Path::new("/project");
1021 let paths = [root.join("packages/a"), root.join("packages/b")];
1022 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1023 let message = build_glob_group_message(root, "packages/*", &refs);
1024
1025 assert!(message.contains("matched 2 directories"), "{message}");
1026 assert!(
1027 message.contains("(e.g. packages/a, packages/b)"),
1028 "both examples named, no `and N more`: {message}"
1029 );
1030 assert!(!message.contains("more)"), "no tail clause: {message}");
1031 }
1032
1033 #[test]
1034 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
1035 let root = Path::new("/project");
1036 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
1037 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
1038 .collect();
1039
1040 let plans = plan_warnings(root, &diagnostics);
1041
1042 assert_eq!(
1043 plans.len(),
1044 1,
1045 "50 same-pattern diagnostics collapse to one plan"
1046 );
1047 assert!(
1048 plans[0]
1049 .dedupe_key
1050 .ends_with("::glob-matched-no-package-json-agg::playground/**")
1051 );
1052 assert!(plans[0].message.contains("matched 50 directories"));
1053 }
1054
1055 #[test]
1056 fn plan_warnings_keeps_distinct_patterns_separate() {
1057 let root = Path::new("/project");
1058 let diagnostics = vec![
1059 glob_diag(root, "apps/*", "apps/a"),
1060 glob_diag(root, "apps/*", "apps/b"),
1061 glob_diag(root, "packages/*", "packages/x"),
1062 glob_diag(root, "packages/*", "packages/y"),
1063 ];
1064
1065 let plans = plan_warnings(root, &diagnostics);
1066
1067 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
1068 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
1069 assert!(
1070 messages
1071 .iter()
1072 .any(|m| m.contains("Glob 'apps/*' matched 2")),
1073 "{messages:?}"
1074 );
1075 assert!(
1076 messages
1077 .iter()
1078 .any(|m| m.contains("Glob 'packages/*' matched 2")),
1079 "{messages:?}"
1080 );
1081 }
1082
1083 #[test]
1084 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
1085 let root = Path::new("/project");
1086 let diag = glob_diag(root, "packages/*", "packages/scratch");
1087
1088 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1089
1090 assert_eq!(plans.len(), 1);
1091 assert_eq!(plans[0].message, diag.message);
1092 let expected_tail = Path::new("packages").join("scratch");
1096 assert!(
1097 plans[0]
1098 .dedupe_key
1099 .contains("::glob-matched-no-package-json::")
1100 && plans[0]
1101 .dedupe_key
1102 .ends_with(&expected_tail.display().to_string()),
1103 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
1104 plans[0].dedupe_key
1105 );
1106 assert!(
1107 !plans[0].message.contains("directories"),
1108 "single match is not aggregated"
1109 );
1110 }
1111
1112 #[test]
1113 fn plan_warnings_non_glob_kinds_stay_per_instance() {
1114 let root = Path::new("/project");
1115 let diagnostics = vec![
1116 WorkspaceDiagnostic::new(
1117 root,
1118 root.join("packages/a"),
1119 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1120 ),
1121 WorkspaceDiagnostic::new(
1122 root,
1123 root.join("packages/b"),
1124 WorkspaceDiagnosticKind::MalformedPackageJson {
1125 error: "trailing comma".to_owned(),
1126 },
1127 ),
1128 ];
1129
1130 let plans = plan_warnings(root, &diagnostics);
1131
1132 assert_eq!(
1133 plans.len(),
1134 2,
1135 "each non-glob diagnostic plans its own warning"
1136 );
1137 assert!(
1138 plans
1139 .iter()
1140 .all(|p| !p.message.contains("directories with no package.json"))
1141 );
1142 }
1143
1144 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
1145 WorkspaceDiagnostic::new(
1146 root,
1147 root.join(rel_path),
1148 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1149 )
1150 }
1151
1152 #[test]
1153 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
1154 let root = Path::new("/project");
1155 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
1156 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
1157 .collect();
1158
1159 let plans = plan_warnings(root, &diagnostics);
1160
1161 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
1162 assert!(
1163 plans[0]
1164 .dedupe_key
1165 .ends_with("::tsconfig-reference-dir-missing-agg")
1166 );
1167 assert!(
1168 plans[0]
1169 .message
1170 .starts_with("tsconfig.json references 30 directories that do not exist"),
1171 "{}",
1172 plans[0].message
1173 );
1174 assert!(
1175 plans[0].message.contains(
1176 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1177 packages/p02/tsconfig.json, and 27 more)"
1178 ),
1179 "three sorted examples + tail: {}",
1180 plans[0].message
1181 );
1182 assert!(
1183 plans[0]
1184 .message
1185 .ends_with("Update or remove the references, or restore the missing directories."),
1186 "{}",
1187 plans[0].message
1188 );
1189 }
1190
1191 #[test]
1192 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1193 let root = Path::new("/project");
1194 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1195
1196 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1197
1198 assert_eq!(plans.len(), 1);
1199 assert_eq!(
1200 plans[0].message, diag.message,
1201 "single miss is not aggregated"
1202 );
1203 assert!(!plans[0].message.contains("directories that do not exist"));
1204 }
1205
1206 #[test]
1207 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1208 let root = Path::new("/project");
1209 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1210 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1211 .collect();
1212 diagnostics.extend(
1213 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1214 );
1215
1216 let plans = plan_warnings(root, &diagnostics);
1217
1218 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1219 assert!(
1220 plans
1221 .iter()
1222 .any(|p| p.message.contains("matched 5 directories"))
1223 );
1224 assert!(
1225 plans
1226 .iter()
1227 .any(|p| p.message.contains("references 4 directories"))
1228 );
1229 }
1230
1231 #[test]
1237 fn two_manifest_glob_warning_counts_each_directory_once() {
1238 let dir = tempfile::tempdir().expect("create temp dir");
1239 crate::workspace::write_two_manifest_glob_project(dir.path());
1240
1241 let (_, diagnostics) = crate::workspace::discover_workspaces_with_diagnostics(
1242 dir.path(),
1243 &globset::GlobSet::empty(),
1244 )
1245 .expect("root package.json is valid");
1246
1247 let messages: Vec<String> = plan_warnings(dir.path(), &diagnostics)
1248 .into_iter()
1249 .map(|plan| plan.message)
1250 .collect();
1251
1252 assert_eq!(
1253 messages,
1254 vec![
1255 "Glob 'pkgs/*' matched 2 directories with no package.json \
1256 (e.g. pkgs/aaa, pkgs/bbb). Add a package.json, narrow the \
1257 pattern, or add them to ignorePatterns."
1258 .to_owned()
1259 ],
1260 "the summary names the true directory count and each example once"
1261 );
1262 }
1263}