fallow_config/workspace/diagnostics.rs
1//! Workspace discovery diagnostics.
2//!
3//! Surfaces malformed `package.json`, unreachable glob matches, missing
4//! tsconfig references, and undeclared workspaces as typed
5//! [`WorkspaceDiagnostic`] values. Each diagnostic also emits a deduplicated
6//! `tracing::warn!` so users running fallow with default tracing filters see
7//! the cause of "fallow doesn't see my package."
8//!
9//! Mirrors the dedupe + capture pattern in
10//! `crates/config/src/config/parsing.rs::warn_on_unknown_rule_keys` (issue
11//! #467).
12
13use std::path::{Path, PathBuf};
14use std::sync::{Mutex, OnceLock};
15
16use rustc_hash::{FxHashMap, FxHashSet};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20/// Why a workspace-discovery candidate was rejected, or why a sibling
21/// directory looked workspace-like but was not declared.
22///
23/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
24/// agents, LSP clients) get a stable, language-neutral identifier.
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
26#[serde(tag = "kind", rename_all = "kebab-case")]
27pub enum WorkspaceDiagnosticKind {
28 /// A directory contains `package.json` but is not declared as a workspace
29 /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
30 /// `tsconfig.json` `references`. Surfaced by
31 /// `find_undeclared_workspaces`.
32 UndeclaredWorkspace,
33 /// A declared workspace's `package.json` failed to parse. The directory is
34 /// dropped from discovery, but analysis still proceeds (degraded).
35 MalformedPackageJson {
36 /// `serde_json` parse error text.
37 error: String,
38 },
39 /// A workspace glob pattern matched a directory that contains no
40 /// `package.json`. Honors the extended skip list and `ignorePatterns`
41 /// before emitting.
42 GlobMatchedNoPackageJson {
43 /// The glob pattern that matched the directory.
44 pattern: String,
45 },
46 /// `tsconfig.json` exists at the root but failed to parse. Project
47 /// references cannot be discovered.
48 MalformedTsconfig {
49 /// JSONC parse error text.
50 error: String,
51 },
52 /// `tsconfig.json` lists a `references[].path` that does not point to an
53 /// existing directory.
54 TsconfigReferenceDirMissing,
55}
56
57impl WorkspaceDiagnosticKind {
58 /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
59 #[must_use]
60 pub const fn id(&self) -> &'static str {
61 match self {
62 Self::UndeclaredWorkspace => "undeclared-workspace",
63 Self::MalformedPackageJson { .. } => "malformed-package-json",
64 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
65 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
66 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
67 }
68 }
69}
70
71/// A diagnostic about a workspace-discovery candidate.
72///
73/// The `message` field is a human-readable rendering derived from `kind`. It
74/// always ends with a concrete next step ("fix the JSON syntax", "remove from
75/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
76/// forward.
77#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
78pub struct WorkspaceDiagnostic {
79 /// Path to the directory or file that triggered the diagnostic.
80 pub path: PathBuf,
81 /// Kind discriminator with the typed payload.
82 #[serde(flatten)]
83 pub kind: WorkspaceDiagnosticKind,
84 /// Human-readable rendering derived from `kind` + `path`. Always ends
85 /// with a next-step hint.
86 pub message: String,
87}
88
89impl WorkspaceDiagnostic {
90 /// Construct a diagnostic with the message rendered from `kind` + `path`.
91 ///
92 /// `root` is used to produce project-relative paths in the message text
93 /// AND inside the variant payload (e.g. the `error` field of
94 /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
95 /// file path from `PackageJson::load()`'s error text). Without the
96 /// payload-side normalisation the embedded path would survive
97 /// environment-specific differences (CI vs Docker vs local) because the
98 /// post-serialisation `strip_root_prefix` only catches whole-string
99 /// matches, not paths embedded mid-sentence.
100 ///
101 /// If `path` is not under `root` (e.g. canonicalisation crossed a
102 /// symlink), the absolute path is emitted instead.
103 #[must_use]
104 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
105 let kind = normalise_payload_paths(root, kind);
106 let message = render_message(root, &path, &kind);
107 Self {
108 path,
109 kind,
110 message,
111 }
112 }
113}
114
115/// Strip the project root from absolute paths embedded inside variant
116/// payloads (today: the `error` field of `MalformedPackageJson` and
117/// `MalformedTsconfig`). Mirrors the per-platform `display()` byte sequence
118/// so the substring match works on Windows too.
119fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
120 let root_str = root.display().to_string();
121 let root_alt = root_str.replace('\\', "/");
122 let normalise = |text: String| -> String {
123 let stripped = text
124 .replace(&format!("{root_str}/"), "")
125 .replace(&format!("{root_alt}/"), "");
126 // Also strip a stray Windows-style trailing-separator form just in case
127 // the diagnostic was constructed with a path whose `display()` keeps
128 // backslashes.
129 stripped
130 .replace(&format!("{root_str}\\"), "")
131 .replace(&format!("{root_alt}\\"), "")
132 };
133 match kind {
134 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
135 WorkspaceDiagnosticKind::MalformedPackageJson {
136 error: normalise(error),
137 }
138 }
139 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
140 WorkspaceDiagnosticKind::MalformedTsconfig {
141 error: normalise(error),
142 }
143 }
144 other => other,
145 }
146}
147
148fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
149 let display = path
150 .strip_prefix(root)
151 .unwrap_or(path)
152 .display()
153 .to_string()
154 .replace('\\', "/");
155 match kind {
156 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
157 "Directory '{display}' contains package.json but is not declared as a workspace. \
158 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
159 ),
160 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
161 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
162 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
163 ),
164 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
165 "Glob '{pattern}' matched '{display}' but no package.json is present. \
166 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
167 ),
168 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
169 "tsconfig.json at '{display}' failed to parse ({error}); \
170 project references will be ignored. Fix the JSON syntax."
171 ),
172 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
173 "tsconfig.json references '{display}' but the directory does not exist. \
174 Update or remove the reference, or restore the missing directory."
175 ),
176 }
177}
178
179/// Workspace-discovery failures that prevent analysis from proceeding.
180///
181/// Returned only by `discover_workspaces_with_diagnostics` (in the parent
182/// module) when the root `package.json` itself is malformed: without a
183/// parseable root, no workspace patterns can be collected, and analysis
184/// output would be fiction. The CLI surfaces this as exit 2.
185#[derive(Debug, Clone)]
186pub enum WorkspaceLoadError {
187 /// The project root's `package.json` exists but failed to parse.
188 MalformedRootPackageJson { path: PathBuf, error: String },
189}
190
191impl std::fmt::Display for WorkspaceLoadError {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 match self {
194 Self::MalformedRootPackageJson { path, error } => write!(
195 f,
196 "root package.json at '{}' is not valid JSON ({error}). \
197 Fix the syntax before re-running fallow.",
198 path.display()
199 ),
200 }
201 }
202}
203
204impl std::error::Error for WorkspaceLoadError {}
205
206/// Emit a `tracing::warn!` for a workspace diagnostic, dedupe-keyed on the
207/// canonical workspace root, the diagnostic's kind identifier, and the
208/// offending path.
209///
210/// `root` is canonicalised before hashing so watch-mode reruns and parallel
211/// agents on the same root coalesce. Two distinct roots produce independent
212/// keys, which is what nested-monorepo callers want.
213pub(super) fn emit_warn(root: &Path, diag: &WorkspaceDiagnostic) {
214 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
215 let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
216
217 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
218 let dedupe_key = format!(
219 "{}::{}::{}",
220 canonical.display(),
221 diag.kind.id(),
222 diag.path.display()
223 );
224
225 // Push into the test-only capture FIRST, before the dedupe gate. The
226 // capture buffer is meant for assertion-friendly tests; two calls on the
227 // same (root, kind, path) inside one test should both observe the
228 // emission. The process-wide dedupe still suppresses repeated
229 // `tracing::warn!` calls, which is the surface that matters for real
230 // users (watch-mode reruns, combined-mode running check + dupes + health
231 // through the same loader).
232 #[cfg(test)]
233 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
234 if let Some(buf) = cell.borrow_mut().as_mut() {
235 buf.push(diag.clone());
236 }
237 });
238
239 // On a poisoned mutex, fall through and emit anyway: over-warning beats
240 // swallowing a typo. Matches the parsing.rs::warn_on_unknown_rule_keys
241 // pattern.
242 if let Ok(mut set) = warned.lock()
243 && !set.insert(dedupe_key)
244 {
245 return;
246 }
247
248 tracing::warn!("fallow: {}", diag.message);
249}
250
251thread_local! {
252 /// Per-thread capture of workspace diagnostics, for tests that assert
253 /// emission without inspecting tracing output. Parallel test execution
254 /// stays race-free because the buffer is thread-local; production code
255 /// keeps the cell empty so emission goes only to tracing.
256 ///
257 /// Mirrors `parsing::UNKNOWN_RULE_CAPTURE` (issue #467).
258 #[cfg(test)]
259 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
260 const { std::cell::RefCell::new(None) };
261}
262
263/// Install a thread-local capture buffer and run `body`. Returns the body's
264/// result alongside every diagnostic emitted by [`emit_warn`] on the current
265/// thread, in order.
266///
267/// Test-only. Diagnostics captured here also bypass the process-wide dedupe
268/// (so two captures on the same root + kind + path inside one test both
269/// observe the emission).
270#[cfg(test)]
271#[must_use]
272pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
273 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
274 *cell.borrow_mut() = Some(Vec::new());
275 });
276 let result = body();
277 let findings =
278 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
279 (result, findings)
280}
281
282/// Process-wide registry of workspace-discovery diagnostics, keyed by
283/// canonical root. Populated by callers that run
284/// [`super::discover_workspaces_with_diagnostics`] and (after config load
285/// completes) by the analysis pipeline's `find_undeclared_workspaces_*`
286/// pass. Consumers (`fallow list --workspaces`, the JSON envelope on
287/// `fallow check / dupes / health`) read via [`workspace_diagnostics_for`].
288///
289/// Canonicalisation matches the dedupe-key canonicalisation in
290/// [`emit_warn`]: two callers on the same physical root coalesce, and
291/// nested-monorepo callers on different roots stay independent.
292static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
293 OnceLock::new();
294
295/// Replace the workspace-discovery diagnostics for `root` with `diagnostics`.
296///
297/// Called at config-load time after [`super::discover_workspaces_with_diagnostics`]
298/// completes; the analyze pipeline then APPENDS undeclared-workspace
299/// diagnostics via [`append_workspace_diagnostics`].
300pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
301 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
302 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
303 if let Ok(mut map) = registry.lock() {
304 map.insert(canonical, diagnostics);
305 }
306}
307
308/// Append `additions` to the workspace-discovery diagnostics for `root`,
309/// skipping any entry whose `(kind id, canonical path)` is already present.
310///
311/// Used by the analyze pipeline's undeclared-workspace pass to fold its
312/// findings into the registry without re-emitting diagnostics that the
313/// config-load pass already surfaced (e.g. a directory whose `package.json`
314/// is malformed should NOT also produce a separate "undeclared" diagnostic
315/// alongside the malformed-package-json one).
316pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
317 if additions.is_empty() {
318 return;
319 }
320 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
321 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
322 if let Ok(mut map) = registry.lock() {
323 let existing = map.entry(canonical).or_default();
324 let mut seen: FxHashSet<(String, String)> = existing
325 .iter()
326 .map(|d| {
327 (
328 d.kind.id().to_owned(),
329 dunce::canonicalize(&d.path)
330 .unwrap_or_else(|_| d.path.clone())
331 .display()
332 .to_string(),
333 )
334 })
335 .collect();
336 for addition in additions {
337 let key = (
338 addition.kind.id().to_owned(),
339 dunce::canonicalize(&addition.path)
340 .unwrap_or_else(|_| addition.path.clone())
341 .display()
342 .to_string(),
343 );
344 if seen.insert(key) {
345 existing.push(addition);
346 }
347 }
348 }
349}
350
351/// Read the workspace-discovery diagnostics produced by the most recent
352/// `stash_workspace_diagnostics` + any subsequent
353/// `append_workspace_diagnostics` calls for `root`. Returns an empty vector
354/// when nothing has been stashed for this root yet (e.g. programmatic
355/// callers bypassing the standard loader).
356#[must_use]
357pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
358 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
359 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
360 return Vec::new();
361 };
362 registry
363 .lock()
364 .ok()
365 .and_then(|map| map.get(&canonical).cloned())
366 .unwrap_or_default()
367}
368
369/// Directories that are conventionally NOT workspace packages even when a
370/// glob like `packages/*` matches them. Mirrors pnpm/npm/yarn behavior of
371/// silently filtering these out, and extends fallow's existing
372/// `should_skip_workspace_scan_dir` list with build artifacts and tooling
373/// caches.
374#[must_use]
375pub(super) fn is_skip_listed_dir(name: &str) -> bool {
376 // Dot-prefixed names (`.next`, `.turbo`, `.nuxt`, `.svelte-kit`, `.cache`)
377 // are caught by the `starts_with('.')` arm; do not duplicate them in the
378 // explicit list. The explicit list is reserved for non-dot conventional
379 // build / output / tooling directories that pnpm/npm/yarn also filter.
380 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
381}
382
383/// Test if a project-root-relative directory path is excluded by user
384/// `ignorePatterns`. The directory itself and its `package.json` are both
385/// checked because users variably write `packages/legacy/**` or
386/// `packages/legacy/package.json` in their ignore globs.
387#[must_use]
388pub(super) fn is_ignored_workspace_dir(
389 relative_dir: &Path,
390 ignore_patterns: &globset::GlobSet,
391) -> bool {
392 if ignore_patterns.is_empty() {
393 return false;
394 }
395 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
396 ignore_patterns.is_match(relative_str.as_str())
397 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
398}