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>) {
372 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
373 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
374 if let Ok(mut map) = registry.lock() {
375 let preserved = map.get(&canonical).map_or_else(Vec::new, |existing| {
376 existing
377 .iter()
378 .filter(|d| d.kind.is_source_discovery() || d.kind.is_analysis_stage())
379 .cloned()
380 .collect()
381 });
382 map.insert(
383 canonical,
384 fallow_types::workspace::merge_workspace_diagnostics(diagnostics, preserved),
385 );
386 }
387}
388
389pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
398 if additions.is_empty() {
399 return;
400 }
401 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
402 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
403 if let Ok(mut map) = registry.lock() {
404 let existing = map.entry(canonical).or_default();
405 let mut seen: FxHashSet<(String, String)> = existing
406 .iter()
407 .map(|d| {
408 (
409 d.kind.id().to_owned(),
410 dunce::canonicalize(&d.path)
411 .unwrap_or_else(|_| d.path.clone())
412 .display()
413 .to_string(),
414 )
415 })
416 .collect();
417 for addition in additions {
418 let key = (
419 addition.kind.id().to_owned(),
420 dunce::canonicalize(&addition.path)
421 .unwrap_or_else(|_| addition.path.clone())
422 .display()
423 .to_string(),
424 );
425 if seen.insert(key) {
426 existing.push(addition);
427 }
428 }
429 }
430}
431
432pub fn record_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
438 if diagnostics.is_empty() {
439 return;
440 }
441 emit_diagnostics(root, &diagnostics);
442 append_workspace_diagnostics(root, diagnostics);
443}
444
445#[must_use]
452pub fn record_source_read_failures(
453 root: &Path,
454 failures: &[fallow_types::extract::SourceReadFailure],
455) -> Vec<WorkspaceDiagnostic> {
456 let diagnostics: Vec<WorkspaceDiagnostic> = failures
457 .iter()
458 .map(|failure| {
459 WorkspaceDiagnostic::new(
460 root,
461 failure.path.clone(),
462 WorkspaceDiagnosticKind::SourceReadFailure {
463 error: failure.error.clone(),
464 },
465 )
466 })
467 .collect();
468 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
469 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
470 if let Ok(mut map) = registry.lock() {
471 let existing = map.entry(canonical).or_default();
472 existing.retain(|diagnostic| {
473 !matches!(
474 diagnostic.kind,
475 WorkspaceDiagnosticKind::SourceReadFailure { .. }
476 )
477 });
478 existing.extend(diagnostics.iter().cloned());
479 }
480 emit_diagnostics(root, &diagnostics);
481 diagnostics
482}
483
484#[must_use]
515pub fn replace_source_discovery_diagnostics(
516 root: &Path,
517 diagnostics: Vec<WorkspaceDiagnostic>,
518) -> Vec<WorkspaceDiagnostic> {
519 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
520 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
521 if let Ok(mut map) = registry.lock() {
522 let existing = map.entry(canonical).or_default();
523 existing.retain(|d| !d.kind.is_source_discovery());
524 existing.extend(diagnostics.iter().cloned());
525 }
526 diagnostics
527}
528
529pub fn clear_analysis_stage_diagnostics(root: &Path) {
541 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
542 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
543 return;
544 };
545 if let Ok(mut map) = registry.lock()
546 && let Some(existing) = map.get_mut(&canonical)
547 {
548 existing.retain(|d| !d.kind.is_analysis_stage());
549 }
550}
551
552#[must_use]
558pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
559 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
560 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
561 return Vec::new();
562 };
563 registry
564 .lock()
565 .ok()
566 .and_then(|map| map.get(&canonical).cloned())
567 .unwrap_or_default()
568}
569
570#[must_use]
591pub fn registry_diagnostics_to_fold(root: &Path) -> Vec<WorkspaceDiagnostic> {
592 workspace_diagnostics_for(root)
593 .into_iter()
594 .filter(|diagnostic| !diagnostic.kind.is_source_walk_recorded())
595 .collect()
596}
597
598#[must_use]
604pub(super) fn is_skip_listed_dir(name: &str) -> bool {
605 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
606}
607
608#[must_use]
613pub(super) fn is_ignored_workspace_dir(
614 relative_dir: &Path,
615 ignore_patterns: &globset::GlobSet,
616) -> bool {
617 if ignore_patterns.is_empty() {
618 return false;
619 }
620 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
621 ignore_patterns.is_match(relative_str.as_str())
622 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use fallow_types::discover::FileId;
629 use fallow_types::extract::SourceReadFailure;
630
631 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
632 WorkspaceDiagnostic::new(
633 root,
634 root.join(rel_path),
635 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
636 pattern: pattern.to_owned(),
637 },
638 )
639 }
640
641 #[test]
642 fn skipped_large_file_diagnostic_id_and_message() {
643 let root = Path::new("/project");
644 let diag = WorkspaceDiagnostic::new(
645 root,
646 root.join("src/vendor/app.bundle.js"),
647 WorkspaceDiagnosticKind::SkippedLargeFile {
648 size_bytes: 6 * 1024 * 1024,
649 },
650 );
651 assert_eq!(diag.kind.id(), "skipped-large-file");
652 assert!(
653 diag.message.contains("src/vendor/app.bundle.js"),
654 "message names the project-relative path: {}",
655 diag.message
656 );
657 assert!(
658 diag.message.contains("6.0 MB"),
659 "message reports the size: {}",
660 diag.message
661 );
662 assert!(
663 diag.message.contains("--max-file-size"),
664 "message names the override flag: {}",
665 diag.message
666 );
667 }
668
669 #[test]
670 fn skipped_minified_file_diagnostic_id_and_message() {
671 let root = Path::new("/project");
672 let diag = WorkspaceDiagnostic::new(
673 root,
674 root.join("src/assets/index-abc123.js"),
675 WorkspaceDiagnosticKind::SkippedMinifiedFile {
676 size_bytes: 2 * 1024 * 1024,
677 },
678 );
679 assert_eq!(diag.kind.id(), "skipped-minified-file");
680 assert!(
681 diag.message.contains("src/assets/index-abc123.js"),
682 "message names the project-relative path: {}",
683 diag.message
684 );
685 assert!(
686 diag.message.contains("2.0 MB"),
687 "message reports the size: {}",
688 diag.message
689 );
690 assert!(
691 diag.message.contains("--max-file-size 0"),
692 "message names the opt-out: {}",
693 diag.message
694 );
695 }
696
697 #[test]
698 fn stash_preserves_appended_skipped_large_file_across_restash() {
699 let root = Path::new("/fallow-test-1086-stash-preserve");
702 let undeclared = || {
703 WorkspaceDiagnostic::new(
704 root,
705 root.join("pkg"),
706 WorkspaceDiagnosticKind::UndeclaredWorkspace,
707 )
708 };
709 stash_workspace_diagnostics(root, vec![undeclared()]);
711 append_workspace_diagnostics(
713 root,
714 vec![WorkspaceDiagnostic::new(
715 root,
716 root.join("vendor/big.js"),
717 WorkspaceDiagnosticKind::SkippedLargeFile {
718 size_bytes: 9_999_999,
719 },
720 )],
721 );
722 stash_workspace_diagnostics(root, vec![undeclared()]);
725
726 let after = workspace_diagnostics_for(root);
727 assert_eq!(
728 after
729 .iter()
730 .filter(|d| d.kind.is_source_discovery())
731 .count(),
732 1,
733 "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
734 );
735 assert_eq!(
736 after
737 .iter()
738 .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
739 .count(),
740 1,
741 "the workspace-discovery diagnostic is replaced, not duplicated"
742 );
743 }
744
745 fn analysis_stage_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
746 vec![
747 WorkspaceDiagnostic::new(
748 root,
749 root.join("pnpm-workspace.yaml"),
750 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
751 error: "could not find expected ':'".to_owned(),
752 },
753 ),
754 WorkspaceDiagnostic::new(
755 root,
756 root.join("package.json"),
757 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
758 ),
759 ]
760 }
761
762 fn count_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> usize {
763 diagnostics.iter().filter(|d| d.kind.id() == id).count()
764 }
765
766 #[test]
767 fn stash_preserves_recorded_analysis_stage_diagnostics_across_restash() {
768 let root = Path::new("/fallow-test-2366-stash-preserve");
769 let undeclared = || {
770 WorkspaceDiagnostic::new(
771 root,
772 root.join("pkg"),
773 WorkspaceDiagnosticKind::UndeclaredWorkspace,
774 )
775 };
776 stash_workspace_diagnostics(root, vec![undeclared()]);
779 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
780 stash_workspace_diagnostics(root, vec![undeclared()]);
783
784 let after = workspace_diagnostics_for(root);
785 assert_eq!(
786 count_kind(&after, "malformed-pnpm-workspace-yaml"),
787 1,
788 "malformed-pnpm-workspace-yaml survives the combined-mode re-stash exactly once (#2366): {after:?}"
789 );
790 assert_eq!(
791 count_kind(&after, "bun-lockb-override-resolution-skipped"),
792 1,
793 "bun-lockb-override-resolution-skipped survives the combined-mode re-stash exactly once (#2366): {after:?}"
794 );
795 assert_eq!(
796 count_kind(&after, "undeclared-workspace"),
797 1,
798 "the workspace-discovery diagnostic is replaced, not duplicated"
799 );
800 }
801
802 #[test]
803 fn source_read_failures_replace_only_their_previous_parse_set() {
804 let root = Path::new("/fallow-test-source-read-replace");
805 stash_workspace_diagnostics(
806 root,
807 vec![WorkspaceDiagnostic::new(
808 root,
809 root.join("pkg"),
810 WorkspaceDiagnosticKind::UndeclaredWorkspace,
811 )],
812 );
813 append_workspace_diagnostics(
814 root,
815 vec![WorkspaceDiagnostic::new(
816 root,
817 root.join("vendor/big.js"),
818 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
819 )],
820 );
821 let first = SourceReadFailure {
822 file_id: FileId(1),
823 path: root.join("src/first.ts"),
824 error: "removed".to_string(),
825 };
826 let _ = record_source_read_failures(root, &[first]);
827 let second = SourceReadFailure {
828 file_id: FileId(2),
829 path: root.join("src/second.ts"),
830 error: "permission denied".to_string(),
831 };
832
833 let _ = record_source_read_failures(root, std::slice::from_ref(&second));
834
835 let diagnostics = workspace_diagnostics_for(root);
836 let source_failures: Vec<_> = diagnostics
837 .iter()
838 .filter(|diagnostic| {
839 matches!(
840 diagnostic.kind,
841 WorkspaceDiagnosticKind::SourceReadFailure { .. }
842 )
843 })
844 .collect();
845 assert_eq!(source_failures.len(), 1);
846 assert_eq!(source_failures[0].path, second.path);
847 assert!(diagnostics.iter().any(|diagnostic| matches!(
848 diagnostic.kind,
849 WorkspaceDiagnosticKind::UndeclaredWorkspace
850 )));
851 assert!(diagnostics.iter().any(|diagnostic| matches!(
852 diagnostic.kind,
853 WorkspaceDiagnosticKind::SkippedLargeFile { .. }
854 )));
855
856 let _ = record_source_read_failures(root, &[]);
857 assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
858 !matches!(
859 diagnostic.kind,
860 WorkspaceDiagnosticKind::SourceReadFailure { .. }
861 )
862 }));
863 }
864
865 #[test]
866 fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
867 let root = Path::new("/fallow-test-1086-clear-stale");
868 stash_workspace_diagnostics(
869 root,
870 vec![WorkspaceDiagnostic::new(
871 root,
872 root.join("pkg"),
873 WorkspaceDiagnosticKind::UndeclaredWorkspace,
874 )],
875 );
876 append_workspace_diagnostics(
877 root,
878 vec![WorkspaceDiagnostic::new(
879 root,
880 root.join("vendor/big.js"),
881 WorkspaceDiagnosticKind::SkippedLargeFile {
882 size_bytes: 9_999_999,
883 },
884 )],
885 );
886 let replaced = replace_source_discovery_diagnostics(root, Vec::new());
888 assert!(
889 replaced.is_empty(),
890 "the walk's own list is what it wrote, not what it removed"
891 );
892
893 let after = workspace_diagnostics_for(root);
894 assert!(
895 !after.iter().any(|d| d.kind.is_source_discovery()),
896 "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
897 );
898 assert!(
899 after
900 .iter()
901 .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
902 "the workspace-discovery diagnostic survives the source-discovery clear"
903 );
904 }
905
906 #[test]
907 fn clear_analysis_stage_drops_stale_entries_keeps_other_kinds() {
908 let root = Path::new("/fallow-test-2366-clear-stale");
909 stash_workspace_diagnostics(
910 root,
911 vec![WorkspaceDiagnostic::new(
912 root,
913 root.join("pkg"),
914 WorkspaceDiagnosticKind::UndeclaredWorkspace,
915 )],
916 );
917 append_workspace_diagnostics(
918 root,
919 vec![WorkspaceDiagnostic::new(
920 root,
921 root.join("vendor/big.js"),
922 WorkspaceDiagnosticKind::SkippedLargeFile {
923 size_bytes: 9_999_999,
924 },
925 )],
926 );
927 record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
928 clear_analysis_stage_diagnostics(root);
931
932 let after = workspace_diagnostics_for(root);
933 assert!(
934 !after.iter().any(|d| d.kind.is_analysis_stage()),
935 "stale analysis-stage entries are dropped on the next analyze pass (#2366): {after:?}"
936 );
937 assert_eq!(
938 count_kind(&after, "undeclared-workspace"),
939 1,
940 "the workspace-discovery diagnostic survives the analysis-stage clear"
941 );
942 assert_eq!(
943 count_kind(&after, "skipped-large-file"),
944 1,
945 "the source-discovery diagnostic survives the analysis-stage clear"
946 );
947 }
948
949 #[test]
950 fn build_glob_group_message_caps_examples_and_summarises_tail() {
951 let root = Path::new("/project");
952 let paths = [
953 root.join("playground/cli"),
954 root.join("playground/lib-types"),
955 root.join("playground/minify"),
956 root.join("playground/ssr"),
957 root.join("playground/worker"),
958 ];
959 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
960 let message = build_glob_group_message(root, "playground/**", &refs);
961
962 assert!(
963 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
964 "count and pattern lead the message: {message}"
965 );
966 assert!(
967 message.contains(
968 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
969 ),
970 "three sorted examples + tail count: {message}"
971 );
972 assert!(
973 message.ends_with(
974 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
975 ),
976 "next-step hint preserved: {message}"
977 );
978 assert!(
979 !message.contains("playground/ssr"),
980 "tail example not named: {message}"
981 );
982 }
983
984 #[test]
985 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
986 let root = Path::new("/project");
987 let paths = [root.join("packages/a"), root.join("packages/b")];
988 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
989 let message = build_glob_group_message(root, "packages/*", &refs);
990
991 assert!(message.contains("matched 2 directories"), "{message}");
992 assert!(
993 message.contains("(e.g. packages/a, packages/b)"),
994 "both examples named, no `and N more`: {message}"
995 );
996 assert!(!message.contains("more)"), "no tail clause: {message}");
997 }
998
999 #[test]
1000 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
1001 let root = Path::new("/project");
1002 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
1003 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
1004 .collect();
1005
1006 let plans = plan_warnings(root, &diagnostics);
1007
1008 assert_eq!(
1009 plans.len(),
1010 1,
1011 "50 same-pattern diagnostics collapse to one plan"
1012 );
1013 assert!(
1014 plans[0]
1015 .dedupe_key
1016 .ends_with("::glob-matched-no-package-json-agg::playground/**")
1017 );
1018 assert!(plans[0].message.contains("matched 50 directories"));
1019 }
1020
1021 #[test]
1022 fn plan_warnings_keeps_distinct_patterns_separate() {
1023 let root = Path::new("/project");
1024 let diagnostics = vec![
1025 glob_diag(root, "apps/*", "apps/a"),
1026 glob_diag(root, "apps/*", "apps/b"),
1027 glob_diag(root, "packages/*", "packages/x"),
1028 glob_diag(root, "packages/*", "packages/y"),
1029 ];
1030
1031 let plans = plan_warnings(root, &diagnostics);
1032
1033 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
1034 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
1035 assert!(
1036 messages
1037 .iter()
1038 .any(|m| m.contains("Glob 'apps/*' matched 2")),
1039 "{messages:?}"
1040 );
1041 assert!(
1042 messages
1043 .iter()
1044 .any(|m| m.contains("Glob 'packages/*' matched 2")),
1045 "{messages:?}"
1046 );
1047 }
1048
1049 #[test]
1050 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
1051 let root = Path::new("/project");
1052 let diag = glob_diag(root, "packages/*", "packages/scratch");
1053
1054 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1055
1056 assert_eq!(plans.len(), 1);
1057 assert_eq!(plans[0].message, diag.message);
1058 let expected_tail = Path::new("packages").join("scratch");
1062 assert!(
1063 plans[0]
1064 .dedupe_key
1065 .contains("::glob-matched-no-package-json::")
1066 && plans[0]
1067 .dedupe_key
1068 .ends_with(&expected_tail.display().to_string()),
1069 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
1070 plans[0].dedupe_key
1071 );
1072 assert!(
1073 !plans[0].message.contains("directories"),
1074 "single match is not aggregated"
1075 );
1076 }
1077
1078 #[test]
1079 fn plan_warnings_non_glob_kinds_stay_per_instance() {
1080 let root = Path::new("/project");
1081 let diagnostics = vec![
1082 WorkspaceDiagnostic::new(
1083 root,
1084 root.join("packages/a"),
1085 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1086 ),
1087 WorkspaceDiagnostic::new(
1088 root,
1089 root.join("packages/b"),
1090 WorkspaceDiagnosticKind::MalformedPackageJson {
1091 error: "trailing comma".to_owned(),
1092 },
1093 ),
1094 ];
1095
1096 let plans = plan_warnings(root, &diagnostics);
1097
1098 assert_eq!(
1099 plans.len(),
1100 2,
1101 "each non-glob diagnostic plans its own warning"
1102 );
1103 assert!(
1104 plans
1105 .iter()
1106 .all(|p| !p.message.contains("directories with no package.json"))
1107 );
1108 }
1109
1110 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
1111 WorkspaceDiagnostic::new(
1112 root,
1113 root.join(rel_path),
1114 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1115 )
1116 }
1117
1118 #[test]
1119 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
1120 let root = Path::new("/project");
1121 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
1122 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
1123 .collect();
1124
1125 let plans = plan_warnings(root, &diagnostics);
1126
1127 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
1128 assert!(
1129 plans[0]
1130 .dedupe_key
1131 .ends_with("::tsconfig-reference-dir-missing-agg")
1132 );
1133 assert!(
1134 plans[0]
1135 .message
1136 .starts_with("tsconfig.json references 30 directories that do not exist"),
1137 "{}",
1138 plans[0].message
1139 );
1140 assert!(
1141 plans[0].message.contains(
1142 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1143 packages/p02/tsconfig.json, and 27 more)"
1144 ),
1145 "three sorted examples + tail: {}",
1146 plans[0].message
1147 );
1148 assert!(
1149 plans[0]
1150 .message
1151 .ends_with("Update or remove the references, or restore the missing directories."),
1152 "{}",
1153 plans[0].message
1154 );
1155 }
1156
1157 #[test]
1158 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1159 let root = Path::new("/project");
1160 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1161
1162 let plans = plan_warnings(root, std::slice::from_ref(&diag));
1163
1164 assert_eq!(plans.len(), 1);
1165 assert_eq!(
1166 plans[0].message, diag.message,
1167 "single miss is not aggregated"
1168 );
1169 assert!(!plans[0].message.contains("directories that do not exist"));
1170 }
1171
1172 #[test]
1173 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1174 let root = Path::new("/project");
1175 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1176 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1177 .collect();
1178 diagnostics.extend(
1179 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1180 );
1181
1182 let plans = plan_warnings(root, &diagnostics);
1183
1184 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1185 assert!(
1186 plans
1187 .iter()
1188 .any(|p| p.message.contains("matched 5 directories"))
1189 );
1190 assert!(
1191 plans
1192 .iter()
1193 .any(|p| p.message.contains("references 4 directories"))
1194 );
1195 }
1196
1197 #[test]
1203 fn two_manifest_glob_warning_counts_each_directory_once() {
1204 let dir = tempfile::tempdir().expect("create temp dir");
1205 crate::workspace::write_two_manifest_glob_project(dir.path());
1206
1207 let (_, diagnostics) = crate::workspace::discover_workspaces_with_diagnostics(
1208 dir.path(),
1209 &globset::GlobSet::empty(),
1210 )
1211 .expect("root package.json is valid");
1212
1213 let messages: Vec<String> = plan_warnings(dir.path(), &diagnostics)
1214 .into_iter()
1215 .map(|plan| plan.message)
1216 .collect();
1217
1218 assert_eq!(
1219 messages,
1220 vec![
1221 "Glob 'pkgs/*' matched 2 directories with no package.json \
1222 (e.g. pkgs/aaa, pkgs/bbb). Add a package.json, narrow the \
1223 pattern, or add them to ignorePatterns."
1224 .to_owned()
1225 ],
1226 "the summary names the true directory count and each example once"
1227 );
1228 }
1229}