use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use rustc_hash::{FxHashMap, FxHashSet};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum WorkspaceDiagnosticKind {
UndeclaredWorkspace,
MalformedPackageJson {
error: String,
},
GlobMatchedNoPackageJson {
pattern: String,
},
MalformedTsconfig {
error: String,
},
TsconfigReferenceDirMissing,
}
impl WorkspaceDiagnosticKind {
#[must_use]
pub const fn id(&self) -> &'static str {
match self {
Self::UndeclaredWorkspace => "undeclared-workspace",
Self::MalformedPackageJson { .. } => "malformed-package-json",
Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
Self::MalformedTsconfig { .. } => "malformed-tsconfig",
Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WorkspaceDiagnostic {
pub path: PathBuf,
#[serde(flatten)]
pub kind: WorkspaceDiagnosticKind,
pub message: String,
}
impl WorkspaceDiagnostic {
#[must_use]
pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
let kind = normalise_payload_paths(root, kind);
let message = render_message(root, &path, &kind);
Self {
path,
kind,
message,
}
}
}
fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
let root_str = root.display().to_string();
let root_alt = root_str.replace('\\', "/");
let normalise = |text: String| -> String {
let stripped = text
.replace(&format!("{root_str}/"), "")
.replace(&format!("{root_alt}/"), "");
stripped
.replace(&format!("{root_str}\\"), "")
.replace(&format!("{root_alt}\\"), "")
};
match kind {
WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
WorkspaceDiagnosticKind::MalformedPackageJson {
error: normalise(error),
}
}
WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
WorkspaceDiagnosticKind::MalformedTsconfig {
error: normalise(error),
}
}
other => other,
}
}
fn display_relative(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
.replace('\\', "/")
}
fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
let display = display_relative(root, path);
match kind {
WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
"Directory '{display}' contains package.json but is not declared as a workspace. \
Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
),
WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
"Dropped workspace '{display}': package.json is not valid JSON ({error}). \
Fix the JSON syntax or remove '{display}' from the workspaces pattern."
),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
"Glob '{pattern}' matched '{display}' but no package.json is present. \
Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
),
WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
"tsconfig.json at '{display}' failed to parse ({error}); \
project references will be ignored. Fix the JSON syntax."
),
WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
"tsconfig.json references '{display}' but the directory does not exist. \
Update or remove the reference, or restore the missing directory."
),
}
}
#[derive(Debug, Clone)]
pub enum WorkspaceLoadError {
MalformedRootPackageJson { path: PathBuf, error: String },
}
impl std::fmt::Display for WorkspaceLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MalformedRootPackageJson { path, error } => write!(
f,
"root package.json at '{}' is not valid JSON ({error}). \
Fix the syntax before re-running fallow.",
path.display()
),
}
}
}
impl std::error::Error for WorkspaceLoadError {}
const GLOB_EXAMPLE_CAP: usize = 3;
fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
}
fn should_emit(key: String) -> bool {
warned_keys().lock().map_or(true, |mut set| set.insert(key))
}
#[derive(Debug, PartialEq, Eq)]
struct PlannedWarning {
dedupe_key: String,
message: String,
}
fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let per_instance = |diag: &WorkspaceDiagnostic| PlannedWarning {
dedupe_key: format!(
"{}::{}::{}",
canonical.display(),
diag.kind.id(),
diag.path.display()
),
message: diag.message.clone(),
};
let mut plans: Vec<PlannedWarning> = Vec::new();
let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
for diag in diagnostics {
match &diag.kind {
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
Some((_, group)) => group.push(diag),
None => glob_groups.push((pattern.as_str(), vec![diag])),
}
}
WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
_ => plans.push(per_instance(diag)),
}
}
for (pattern, group) in glob_groups {
if let [only] = group.as_slice() {
plans.push(per_instance(only));
continue;
}
let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
plans.push(PlannedWarning {
dedupe_key: format!(
"{}::glob-matched-no-package-json-agg::{pattern}",
canonical.display()
),
message: build_glob_group_message(root, pattern, &paths),
});
}
if let [only] = tsconfig_ref_misses.as_slice() {
plans.push(per_instance(only));
} else if !tsconfig_ref_misses.is_empty() {
let paths: Vec<&Path> = tsconfig_ref_misses
.iter()
.map(|d| d.path.as_path())
.collect();
plans.push(PlannedWarning {
dedupe_key: format!(
"{}::tsconfig-reference-dir-missing-agg",
canonical.display()
),
message: build_tsconfig_refs_message(root, &paths),
});
}
plans
}
pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
#[cfg(test)]
for diag in diagnostics {
capture_diag(diag);
}
for plan in plan_warnings(root, diagnostics) {
if should_emit(plan.dedupe_key) {
tracing::warn!("fallow: {}", plan.message);
}
}
}
fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
examples.sort();
let count = examples.len();
let shown = examples
.iter()
.take(GLOB_EXAMPLE_CAP)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
let listed = if remaining > 0 {
format!("{shown}, and {remaining} more")
} else {
shown
};
(listed, count)
}
fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
let (listed, count) = summarize_examples(root, paths);
format!(
"Glob '{pattern}' matched {count} directories with no package.json \
(e.g. {listed}). Add a package.json, narrow the pattern, or add \
them to ignorePatterns."
)
}
fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
let (listed, count) = summarize_examples(root, paths);
format!(
"tsconfig.json references {count} directories that do not exist \
(e.g. {listed}). Update or remove the references, or restore the \
missing directories."
)
}
thread_local! {
#[cfg(test)]
static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
fn capture_diag(diag: &WorkspaceDiagnostic) {
WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
if let Some(buf) = cell.borrow_mut().as_mut() {
buf.push(diag.clone());
}
});
}
#[cfg(test)]
#[must_use]
pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
*cell.borrow_mut() = Some(Vec::new());
});
let result = body();
let findings =
WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
(result, findings)
}
static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
OnceLock::new();
pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
if let Ok(mut map) = registry.lock() {
map.insert(canonical, diagnostics);
}
}
pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
if additions.is_empty() {
return;
}
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
if let Ok(mut map) = registry.lock() {
let existing = map.entry(canonical).or_default();
let mut seen: FxHashSet<(String, String)> = existing
.iter()
.map(|d| {
(
d.kind.id().to_owned(),
dunce::canonicalize(&d.path)
.unwrap_or_else(|_| d.path.clone())
.display()
.to_string(),
)
})
.collect();
for addition in additions {
let key = (
addition.kind.id().to_owned(),
dunce::canonicalize(&addition.path)
.unwrap_or_else(|_| addition.path.clone())
.display()
.to_string(),
);
if seen.insert(key) {
existing.push(addition);
}
}
}
}
#[must_use]
pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
return Vec::new();
};
registry
.lock()
.ok()
.and_then(|map| map.get(&canonical).cloned())
.unwrap_or_default()
}
#[must_use]
pub(super) fn is_skip_listed_dir(name: &str) -> bool {
name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
}
#[must_use]
pub(super) fn is_ignored_workspace_dir(
relative_dir: &Path,
ignore_patterns: &globset::GlobSet,
) -> bool {
if ignore_patterns.is_empty() {
return false;
}
let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
ignore_patterns.is_match(relative_str.as_str())
|| ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
}
#[cfg(test)]
mod tests {
use super::*;
fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
WorkspaceDiagnostic::new(
root,
root.join(rel_path),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: pattern.to_owned(),
},
)
}
#[test]
fn build_glob_group_message_caps_examples_and_summarises_tail() {
let root = Path::new("/project");
let paths = [
root.join("playground/cli"),
root.join("playground/lib-types"),
root.join("playground/minify"),
root.join("playground/ssr"),
root.join("playground/worker"),
];
let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
let message = build_glob_group_message(root, "playground/**", &refs);
assert!(
message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
"count and pattern lead the message: {message}"
);
assert!(
message.contains(
"(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
),
"three sorted examples + tail count: {message}"
);
assert!(
message.ends_with(
"Add a package.json, narrow the pattern, or add them to ignorePatterns."
),
"next-step hint preserved: {message}"
);
assert!(
!message.contains("playground/ssr"),
"tail example not named: {message}"
);
}
#[test]
fn build_glob_group_message_no_tail_when_at_or_below_cap() {
let root = Path::new("/project");
let paths = [root.join("packages/a"), root.join("packages/b")];
let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
let message = build_glob_group_message(root, "packages/*", &refs);
assert!(message.contains("matched 2 directories"), "{message}");
assert!(
message.contains("(e.g. packages/a, packages/b)"),
"both examples named, no `and N more`: {message}"
);
assert!(!message.contains("more)"), "no tail clause: {message}");
}
#[test]
fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
let root = Path::new("/project");
let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
.map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
.collect();
let plans = plan_warnings(root, &diagnostics);
assert_eq!(
plans.len(),
1,
"50 same-pattern diagnostics collapse to one plan"
);
assert!(
plans[0]
.dedupe_key
.ends_with("::glob-matched-no-package-json-agg::playground/**")
);
assert!(plans[0].message.contains("matched 50 directories"));
}
#[test]
fn plan_warnings_keeps_distinct_patterns_separate() {
let root = Path::new("/project");
let diagnostics = vec![
glob_diag(root, "apps/*", "apps/a"),
glob_diag(root, "apps/*", "apps/b"),
glob_diag(root, "packages/*", "packages/x"),
glob_diag(root, "packages/*", "packages/y"),
];
let plans = plan_warnings(root, &diagnostics);
assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
assert!(
messages
.iter()
.any(|m| m.contains("Glob 'apps/*' matched 2")),
"{messages:?}"
);
assert!(
messages
.iter()
.any(|m| m.contains("Glob 'packages/*' matched 2")),
"{messages:?}"
);
}
#[test]
fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
let root = Path::new("/project");
let diag = glob_diag(root, "packages/*", "packages/scratch");
let plans = plan_warnings(root, std::slice::from_ref(&diag));
assert_eq!(plans.len(), 1);
assert_eq!(plans[0].message, diag.message);
assert!(
plans[0]
.dedupe_key
.contains("::glob-matched-no-package-json::")
&& plans[0].dedupe_key.ends_with("packages/scratch"),
"per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
plans[0].dedupe_key
);
assert!(
!plans[0].message.contains("directories"),
"single match is not aggregated"
);
}
#[test]
fn plan_warnings_non_glob_kinds_stay_per_instance() {
let root = Path::new("/project");
let diagnostics = vec![
WorkspaceDiagnostic::new(
root,
root.join("packages/a"),
WorkspaceDiagnosticKind::UndeclaredWorkspace,
),
WorkspaceDiagnostic::new(
root,
root.join("packages/b"),
WorkspaceDiagnosticKind::MalformedPackageJson {
error: "trailing comma".to_owned(),
},
),
];
let plans = plan_warnings(root, &diagnostics);
assert_eq!(
plans.len(),
2,
"each non-glob diagnostic plans its own warning"
);
assert!(
plans
.iter()
.all(|p| !p.message.contains("directories with no package.json"))
);
}
fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
WorkspaceDiagnostic::new(
root,
root.join(rel_path),
WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
)
}
#[test]
fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
let root = Path::new("/project");
let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
.map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
.collect();
let plans = plan_warnings(root, &diagnostics);
assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
assert!(
plans[0]
.dedupe_key
.ends_with("::tsconfig-reference-dir-missing-agg")
);
assert!(
plans[0]
.message
.starts_with("tsconfig.json references 30 directories that do not exist"),
"{}",
plans[0].message
);
assert!(
plans[0].message.contains(
"(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
packages/p02/tsconfig.json, and 27 more)"
),
"three sorted examples + tail: {}",
plans[0].message
);
assert!(
plans[0]
.message
.ends_with("Update or remove the references, or restore the missing directories."),
"{}",
plans[0].message
);
}
#[test]
fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
let root = Path::new("/project");
let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
let plans = plan_warnings(root, std::slice::from_ref(&diag));
assert_eq!(plans.len(), 1);
assert_eq!(
plans[0].message, diag.message,
"single miss is not aggregated"
);
assert!(!plans[0].message.contains("directories that do not exist"));
}
#[test]
fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
let root = Path::new("/project");
let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
.map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
.collect();
diagnostics.extend(
(0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
);
let plans = plan_warnings(root, &diagnostics);
assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
assert!(
plans
.iter()
.any(|p| p.message.contains("matched 5 directories"))
);
assert!(
plans
.iter()
.any(|p| p.message.contains("references 4 directories"))
);
}
}