1use std::path::{Path, PathBuf};
21use std::sync::{Mutex, OnceLock};
22
23use rustc_hash::{FxHashMap, FxHashSet};
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
33#[serde(tag = "kind", rename_all = "kebab-case")]
34pub enum WorkspaceDiagnosticKind {
35 UndeclaredWorkspace,
40 MalformedPackageJson {
43 error: String,
45 },
46 GlobMatchedNoPackageJson {
50 pattern: String,
52 },
53 MalformedTsconfig {
56 error: String,
58 },
59 TsconfigReferenceDirMissing,
62}
63
64impl WorkspaceDiagnosticKind {
65 #[must_use]
67 pub const fn id(&self) -> &'static str {
68 match self {
69 Self::UndeclaredWorkspace => "undeclared-workspace",
70 Self::MalformedPackageJson { .. } => "malformed-package-json",
71 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
72 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
73 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct WorkspaceDiagnostic {
86 pub path: PathBuf,
88 #[serde(flatten)]
90 pub kind: WorkspaceDiagnosticKind,
91 pub message: String,
94}
95
96impl WorkspaceDiagnostic {
97 #[must_use]
111 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
112 let kind = normalise_payload_paths(root, kind);
113 let message = render_message(root, &path, &kind);
114 Self {
115 path,
116 kind,
117 message,
118 }
119 }
120}
121
122fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
127 let root_str = root.display().to_string();
128 let root_alt = root_str.replace('\\', "/");
129 let normalise = |text: String| -> String {
130 let stripped = text
131 .replace(&format!("{root_str}/"), "")
132 .replace(&format!("{root_alt}/"), "");
133 stripped
134 .replace(&format!("{root_str}\\"), "")
135 .replace(&format!("{root_alt}\\"), "")
136 };
137 match kind {
138 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
139 WorkspaceDiagnosticKind::MalformedPackageJson {
140 error: normalise(error),
141 }
142 }
143 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
144 WorkspaceDiagnosticKind::MalformedTsconfig {
145 error: normalise(error),
146 }
147 }
148 other => other,
149 }
150}
151
152fn display_relative(root: &Path, path: &Path) -> String {
157 path.strip_prefix(root)
158 .unwrap_or(path)
159 .display()
160 .to_string()
161 .replace('\\', "/")
162}
163
164fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
165 let display = display_relative(root, path);
166 match kind {
167 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
168 "Directory '{display}' contains package.json but is not declared as a workspace. \
169 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
170 ),
171 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
172 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
173 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
174 ),
175 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
176 "Glob '{pattern}' matched '{display}' but no package.json is present. \
177 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
178 ),
179 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
180 "tsconfig.json at '{display}' failed to parse ({error}); \
181 project references will be ignored. Fix the JSON syntax."
182 ),
183 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
184 "tsconfig.json references '{display}' but the directory does not exist. \
185 Update or remove the reference, or restore the missing directory."
186 ),
187 }
188}
189
190#[derive(Debug, Clone)]
197pub enum WorkspaceLoadError {
198 MalformedRootPackageJson { path: PathBuf, error: String },
200}
201
202impl std::fmt::Display for WorkspaceLoadError {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 match self {
205 Self::MalformedRootPackageJson { path, error } => write!(
206 f,
207 "root package.json at '{}' is not valid JSON ({error}). \
208 Fix the syntax before re-running fallow.",
209 path.display()
210 ),
211 }
212 }
213}
214
215impl std::error::Error for WorkspaceLoadError {}
216
217const GLOB_EXAMPLE_CAP: usize = 3;
221
222fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
229 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
230 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
231}
232
233fn should_emit(key: String) -> bool {
238 warned_keys().lock().map_or(true, |mut set| set.insert(key))
239}
240
241#[derive(Debug, PartialEq, Eq)]
246struct PlannedWarning {
247 dedupe_key: String,
248 message: String,
249}
250
251fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
266 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
267 let per_instance = |diag: &WorkspaceDiagnostic| PlannedWarning {
268 dedupe_key: format!(
269 "{}::{}::{}",
270 canonical.display(),
271 diag.kind.id(),
272 diag.path.display()
273 ),
274 message: diag.message.clone(),
275 };
276
277 let mut plans: Vec<PlannedWarning> = Vec::new();
278 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
279 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
280 for diag in diagnostics {
281 match &diag.kind {
282 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
283 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
284 Some((_, group)) => group.push(diag),
285 None => glob_groups.push((pattern.as_str(), vec![diag])),
286 }
287 }
288 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
289 _ => plans.push(per_instance(diag)),
290 }
291 }
292
293 for (pattern, group) in glob_groups {
294 if let [only] = group.as_slice() {
295 plans.push(per_instance(only));
296 continue;
297 }
298 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
299 plans.push(PlannedWarning {
300 dedupe_key: format!(
301 "{}::glob-matched-no-package-json-agg::{pattern}",
302 canonical.display()
303 ),
304 message: build_glob_group_message(root, pattern, &paths),
305 });
306 }
307
308 if let [only] = tsconfig_ref_misses.as_slice() {
309 plans.push(per_instance(only));
310 } else if !tsconfig_ref_misses.is_empty() {
311 let paths: Vec<&Path> = tsconfig_ref_misses
312 .iter()
313 .map(|d| d.path.as_path())
314 .collect();
315 plans.push(PlannedWarning {
316 dedupe_key: format!(
317 "{}::tsconfig-reference-dir-missing-agg",
318 canonical.display()
319 ),
320 message: build_tsconfig_refs_message(root, &paths),
321 });
322 }
323
324 plans
325}
326
327pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
336 #[cfg(test)]
337 for diag in diagnostics {
338 capture_diag(diag);
339 }
340
341 for plan in plan_warnings(root, diagnostics) {
342 if should_emit(plan.dedupe_key) {
343 tracing::warn!("fallow: {}", plan.message);
344 }
345 }
346}
347
348fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
353 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
354 examples.sort();
355 let count = examples.len();
356 let shown = examples
357 .iter()
358 .take(GLOB_EXAMPLE_CAP)
359 .cloned()
360 .collect::<Vec<_>>()
361 .join(", ");
362 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
363 let listed = if remaining > 0 {
364 format!("{shown}, and {remaining} more")
365 } else {
366 shown
367 };
368 (listed, count)
369}
370
371fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
374 let (listed, count) = summarize_examples(root, paths);
375 format!(
376 "Glob '{pattern}' matched {count} directories with no package.json \
377 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
378 them to ignorePatterns."
379 )
380}
381
382fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
386 let (listed, count) = summarize_examples(root, paths);
387 format!(
388 "tsconfig.json references {count} directories that do not exist \
389 (e.g. {listed}). Update or remove the references, or restore the \
390 missing directories."
391 )
392}
393
394thread_local! {
395 #[cfg(test)]
402 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
403 const { std::cell::RefCell::new(None) };
404}
405
406#[cfg(test)]
412fn capture_diag(diag: &WorkspaceDiagnostic) {
413 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
414 if let Some(buf) = cell.borrow_mut().as_mut() {
415 buf.push(diag.clone());
416 }
417 });
418}
419
420#[cfg(test)]
428#[must_use]
429pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
430 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
431 *cell.borrow_mut() = Some(Vec::new());
432 });
433 let result = body();
434 let findings =
435 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
436 (result, findings)
437}
438
439static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
450 OnceLock::new();
451
452pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
458 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
459 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
460 if let Ok(mut map) = registry.lock() {
461 map.insert(canonical, diagnostics);
462 }
463}
464
465pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
474 if additions.is_empty() {
475 return;
476 }
477 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
478 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
479 if let Ok(mut map) = registry.lock() {
480 let existing = map.entry(canonical).or_default();
481 let mut seen: FxHashSet<(String, String)> = existing
482 .iter()
483 .map(|d| {
484 (
485 d.kind.id().to_owned(),
486 dunce::canonicalize(&d.path)
487 .unwrap_or_else(|_| d.path.clone())
488 .display()
489 .to_string(),
490 )
491 })
492 .collect();
493 for addition in additions {
494 let key = (
495 addition.kind.id().to_owned(),
496 dunce::canonicalize(&addition.path)
497 .unwrap_or_else(|_| addition.path.clone())
498 .display()
499 .to_string(),
500 );
501 if seen.insert(key) {
502 existing.push(addition);
503 }
504 }
505 }
506}
507
508#[must_use]
514pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
515 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
516 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
517 return Vec::new();
518 };
519 registry
520 .lock()
521 .ok()
522 .and_then(|map| map.get(&canonical).cloned())
523 .unwrap_or_default()
524}
525
526#[must_use]
532pub(super) fn is_skip_listed_dir(name: &str) -> bool {
533 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
534}
535
536#[must_use]
541pub(super) fn is_ignored_workspace_dir(
542 relative_dir: &Path,
543 ignore_patterns: &globset::GlobSet,
544) -> bool {
545 if ignore_patterns.is_empty() {
546 return false;
547 }
548 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
549 ignore_patterns.is_match(relative_str.as_str())
550 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
558 WorkspaceDiagnostic::new(
559 root,
560 root.join(rel_path),
561 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
562 pattern: pattern.to_owned(),
563 },
564 )
565 }
566
567 #[test]
568 fn build_glob_group_message_caps_examples_and_summarises_tail() {
569 let root = Path::new("/project");
570 let paths = [
571 root.join("playground/cli"),
572 root.join("playground/lib-types"),
573 root.join("playground/minify"),
574 root.join("playground/ssr"),
575 root.join("playground/worker"),
576 ];
577 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
578 let message = build_glob_group_message(root, "playground/**", &refs);
579
580 assert!(
581 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
582 "count and pattern lead the message: {message}"
583 );
584 assert!(
585 message.contains(
586 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
587 ),
588 "three sorted examples + tail count: {message}"
589 );
590 assert!(
591 message.ends_with(
592 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
593 ),
594 "next-step hint preserved: {message}"
595 );
596 assert!(
597 !message.contains("playground/ssr"),
598 "tail example not named: {message}"
599 );
600 }
601
602 #[test]
603 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
604 let root = Path::new("/project");
605 let paths = [root.join("packages/a"), root.join("packages/b")];
606 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
607 let message = build_glob_group_message(root, "packages/*", &refs);
608
609 assert!(message.contains("matched 2 directories"), "{message}");
610 assert!(
611 message.contains("(e.g. packages/a, packages/b)"),
612 "both examples named, no `and N more`: {message}"
613 );
614 assert!(!message.contains("more)"), "no tail clause: {message}");
615 }
616
617 #[test]
618 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
619 let root = Path::new("/project");
620 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
621 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
622 .collect();
623
624 let plans = plan_warnings(root, &diagnostics);
625
626 assert_eq!(
627 plans.len(),
628 1,
629 "50 same-pattern diagnostics collapse to one plan"
630 );
631 assert!(
632 plans[0]
633 .dedupe_key
634 .ends_with("::glob-matched-no-package-json-agg::playground/**")
635 );
636 assert!(plans[0].message.contains("matched 50 directories"));
637 }
638
639 #[test]
640 fn plan_warnings_keeps_distinct_patterns_separate() {
641 let root = Path::new("/project");
642 let diagnostics = vec![
643 glob_diag(root, "apps/*", "apps/a"),
644 glob_diag(root, "apps/*", "apps/b"),
645 glob_diag(root, "packages/*", "packages/x"),
646 glob_diag(root, "packages/*", "packages/y"),
647 ];
648
649 let plans = plan_warnings(root, &diagnostics);
650
651 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
652 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
653 assert!(
654 messages
655 .iter()
656 .any(|m| m.contains("Glob 'apps/*' matched 2")),
657 "{messages:?}"
658 );
659 assert!(
660 messages
661 .iter()
662 .any(|m| m.contains("Glob 'packages/*' matched 2")),
663 "{messages:?}"
664 );
665 }
666
667 #[test]
668 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
669 let root = Path::new("/project");
670 let diag = glob_diag(root, "packages/*", "packages/scratch");
671
672 let plans = plan_warnings(root, std::slice::from_ref(&diag));
673
674 assert_eq!(plans.len(), 1);
675 assert_eq!(plans[0].message, diag.message);
676 assert!(
677 plans[0]
678 .dedupe_key
679 .contains("::glob-matched-no-package-json::")
680 && plans[0].dedupe_key.ends_with("packages/scratch"),
681 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
682 plans[0].dedupe_key
683 );
684 assert!(
685 !plans[0].message.contains("directories"),
686 "single match is not aggregated"
687 );
688 }
689
690 #[test]
691 fn plan_warnings_non_glob_kinds_stay_per_instance() {
692 let root = Path::new("/project");
693 let diagnostics = vec![
694 WorkspaceDiagnostic::new(
695 root,
696 root.join("packages/a"),
697 WorkspaceDiagnosticKind::UndeclaredWorkspace,
698 ),
699 WorkspaceDiagnostic::new(
700 root,
701 root.join("packages/b"),
702 WorkspaceDiagnosticKind::MalformedPackageJson {
703 error: "trailing comma".to_owned(),
704 },
705 ),
706 ];
707
708 let plans = plan_warnings(root, &diagnostics);
709
710 assert_eq!(
711 plans.len(),
712 2,
713 "each non-glob diagnostic plans its own warning"
714 );
715 assert!(
716 plans
717 .iter()
718 .all(|p| !p.message.contains("directories with no package.json"))
719 );
720 }
721
722 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
723 WorkspaceDiagnostic::new(
724 root,
725 root.join(rel_path),
726 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
727 )
728 }
729
730 #[test]
731 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
732 let root = Path::new("/project");
733 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
734 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
735 .collect();
736
737 let plans = plan_warnings(root, &diagnostics);
738
739 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
740 assert!(
741 plans[0]
742 .dedupe_key
743 .ends_with("::tsconfig-reference-dir-missing-agg")
744 );
745 assert!(
746 plans[0]
747 .message
748 .starts_with("tsconfig.json references 30 directories that do not exist"),
749 "{}",
750 plans[0].message
751 );
752 assert!(
753 plans[0].message.contains(
754 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
755 packages/p02/tsconfig.json, and 27 more)"
756 ),
757 "three sorted examples + tail: {}",
758 plans[0].message
759 );
760 assert!(
761 plans[0]
762 .message
763 .ends_with("Update or remove the references, or restore the missing directories."),
764 "{}",
765 plans[0].message
766 );
767 }
768
769 #[test]
770 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
771 let root = Path::new("/project");
772 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
773
774 let plans = plan_warnings(root, std::slice::from_ref(&diag));
775
776 assert_eq!(plans.len(), 1);
777 assert_eq!(
778 plans[0].message, diag.message,
779 "single miss is not aggregated"
780 );
781 assert!(!plans[0].message.contains("directories that do not exist"));
782 }
783
784 #[test]
785 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
786 let root = Path::new("/project");
787 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
788 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
789 .collect();
790 diagnostics.extend(
791 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
792 );
793
794 let plans = plan_warnings(root, &diagnostics);
795
796 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
797 assert!(
798 plans
799 .iter()
800 .any(|p| p.message.contains("matched 5 directories"))
801 );
802 assert!(
803 plans
804 .iter()
805 .any(|p| p.message.contains("references 4 directories"))
806 );
807 }
808}