Skip to main content

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}