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//! Repeated `GlobMatchedNoPackageJson` diagnostics are aggregated by glob
10//! pattern at emission time so a wide glob matching hundreds of package-less
11//! directories on a large monorepo collapses to one bounded summary line per
12//! pattern instead of one line per directory (issue #637). The structured
13//! `Vec<WorkspaceDiagnostic>` returned to callers stays full; only the stderr
14//! surface is bounded.
15//!
16//! Mirrors the dedupe + capture pattern in
17//! `crates/config/src/config/parsing.rs::warn_on_unknown_rule_keys` (issue
18//! #467).
19
20use std::path::{Path, PathBuf};
21use std::sync::{Mutex, OnceLock};
22
23use rustc_hash::{FxHashMap, FxHashSet};
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27/// Why a workspace-discovery candidate was rejected, or why a sibling
28/// directory looked workspace-like but was not declared.
29///
30/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
31/// agents, LSP clients) get a stable, language-neutral identifier.
32#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
33#[serde(tag = "kind", rename_all = "kebab-case")]
34pub enum WorkspaceDiagnosticKind {
35    /// A directory contains `package.json` but is not declared as a workspace
36    /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
37    /// `tsconfig.json` `references`. Surfaced by
38    /// `find_undeclared_workspaces`.
39    UndeclaredWorkspace,
40    /// A declared workspace's `package.json` failed to parse. The directory is
41    /// dropped from discovery, but analysis still proceeds (degraded).
42    MalformedPackageJson {
43        /// `serde_json` parse error text.
44        error: String,
45    },
46    /// A workspace glob pattern matched a directory that contains no
47    /// `package.json`. Honors the extended skip list and `ignorePatterns`
48    /// before emitting.
49    GlobMatchedNoPackageJson {
50        /// The glob pattern that matched the directory.
51        pattern: String,
52    },
53    /// `tsconfig.json` exists at the root but failed to parse. Project
54    /// references cannot be discovered.
55    MalformedTsconfig {
56        /// JSONC parse error text.
57        error: String,
58    },
59    /// `tsconfig.json` lists a `references[].path` that does not point to an
60    /// existing directory.
61    TsconfigReferenceDirMissing,
62}
63
64impl WorkspaceDiagnosticKind {
65    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
66    #[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/// A diagnostic about a workspace-discovery candidate.
79///
80/// The `message` field is a human-readable rendering derived from `kind`. It
81/// always ends with a concrete next step ("fix the JSON syntax", "remove from
82/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
83/// forward.
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct WorkspaceDiagnostic {
86    /// Path to the directory or file that triggered the diagnostic.
87    pub path: PathBuf,
88    /// Kind discriminator with the typed payload.
89    #[serde(flatten)]
90    pub kind: WorkspaceDiagnosticKind,
91    /// Human-readable rendering derived from `kind` + `path`. Always ends
92    /// with a next-step hint.
93    pub message: String,
94}
95
96impl WorkspaceDiagnostic {
97    /// Construct a diagnostic with the message rendered from `kind` + `path`.
98    ///
99    /// `root` is used to produce project-relative paths in the message text
100    /// AND inside the variant payload (e.g. the `error` field of
101    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
102    /// file path from `PackageJson::load()`'s error text). Without the
103    /// payload-side normalisation the embedded path would survive
104    /// environment-specific differences (CI vs Docker vs local) because the
105    /// post-serialisation `strip_root_prefix` only catches whole-string
106    /// matches, not paths embedded mid-sentence.
107    ///
108    /// If `path` is not under `root` (e.g. canonicalisation crossed a
109    /// symlink), the absolute path is emitted instead.
110    #[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
122/// Strip the project root from absolute paths embedded inside variant
123/// payloads (today: the `error` field of `MalformedPackageJson` and
124/// `MalformedTsconfig`). Mirrors the per-platform `display()` byte sequence
125/// so the substring match works on Windows too.
126fn 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        // Also strip a stray Windows-style trailing-separator form just in case
134        // the diagnostic was constructed with a path whose `display()` keeps
135        // backslashes.
136        stripped
137            .replace(&format!("{root_str}\\"), "")
138            .replace(&format!("{root_alt}\\"), "")
139    };
140    match kind {
141        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
142            WorkspaceDiagnosticKind::MalformedPackageJson {
143                error: normalise(error),
144            }
145        }
146        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
147            WorkspaceDiagnosticKind::MalformedTsconfig {
148                error: normalise(error),
149            }
150        }
151        other => other,
152    }
153}
154
155/// Render `path` relative to `root` with forward slashes. Shared by
156/// [`render_message`] and [`build_glob_group_message`] so the per-instance and
157/// aggregated message surfaces format paths identically (the forward-slash
158/// normalisation is load-bearing for cross-platform output stability).
159fn display_relative(root: &Path, path: &Path) -> String {
160    path.strip_prefix(root)
161        .unwrap_or(path)
162        .display()
163        .to_string()
164        .replace('\\', "/")
165}
166
167fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
168    let display = display_relative(root, path);
169    match kind {
170        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
171            "Directory '{display}' contains package.json but is not declared as a workspace. \
172             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
173        ),
174        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
175            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
176             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
177        ),
178        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
179            "Glob '{pattern}' matched '{display}' but no package.json is present. \
180             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
181        ),
182        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
183            "tsconfig.json at '{display}' failed to parse ({error}); \
184             project references will be ignored. Fix the JSON syntax."
185        ),
186        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
187            "tsconfig.json references '{display}' but the directory does not exist. \
188             Update or remove the reference, or restore the missing directory."
189        ),
190    }
191}
192
193/// Workspace-discovery failures that prevent analysis from proceeding.
194///
195/// Returned only by `discover_workspaces_with_diagnostics` (in the parent
196/// module) when the root `package.json` itself is malformed: without a
197/// parseable root, no workspace patterns can be collected, and analysis
198/// output would be fiction. The CLI surfaces this as exit 2.
199#[derive(Debug, Clone)]
200pub enum WorkspaceLoadError {
201    /// The project root's `package.json` exists but failed to parse.
202    MalformedRootPackageJson { path: PathBuf, error: String },
203}
204
205impl std::fmt::Display for WorkspaceLoadError {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            Self::MalformedRootPackageJson { path, error } => write!(
209                f,
210                "root package.json at '{}' is not valid JSON ({error}). \
211                 Fix the syntax before re-running fallow.",
212                path.display()
213            ),
214        }
215    }
216}
217
218impl std::error::Error for WorkspaceLoadError {}
219
220/// Maximum number of example directories named in an aggregated
221/// `GlobMatchedNoPackageJson` warning before the tail is summarised as
222/// "and N more". Keeps a fanned-out glob to one bounded stderr line.
223const GLOB_EXAMPLE_CAP: usize = 3;
224
225/// Process-wide set of already-emitted diagnostic dedupe keys. Per-instance
226/// keys (`root::kind::path`) and aggregated per-pattern keys
227/// (`root::glob-matched-no-package-json-agg::pattern`) share one set so
228/// combined-mode (check + dupes + health through one loader) and watch-mode
229/// reruns warn at most once per logical diagnostic. The two key namespaces are
230/// disjoint, so there is no cross-talk.
231fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
232    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
233    WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
234}
235
236/// Insert `key` and return `true` when it was newly inserted (caller should
237/// emit). On a poisoned mutex returns `true` so over-warning beats swallowing
238/// a typo. Mirrors `parsing::warn_on_unknown_rule_keys` and
239/// `plugins::registry::should_warn`.
240fn should_emit(key: String) -> bool {
241    warned_keys().lock().map_or(true, |mut set| set.insert(key))
242}
243
244/// A single planned stderr warning: its process-dedupe key and the rendered
245/// message. The pure output of [`plan_warnings`] so the partition/aggregation
246/// logic is unit-testable without a tracing subscriber or the process-wide
247/// dedupe set.
248#[derive(Debug, PartialEq, Eq)]
249struct PlannedWarning {
250    dedupe_key: String,
251    message: String,
252}
253
254/// Turn a batch of workspace diagnostics into the bounded set of stderr
255/// warnings to emit, collapsing the two kinds that fan out on large monorepos
256/// (issue #637):
257/// - `GlobMatchedNoPackageJson`: aggregated by glob pattern, one summary line
258///   per pattern instead of one line per package-less directory.
259/// - `TsconfigReferenceDirMissing`: aggregated together, one summary line
260///   instead of one per missing `references[]` entry in the root tsconfig.
261///
262/// Pure: no tracing, no dedupe-set mutation. A group of exactly one keeps
263/// today's per-instance message byte-for-byte (no regression for the common
264/// single-match case); every other kind plans one per-instance warning. The
265/// returned plan lists non-aggregated diagnostics first (in first-seen order),
266/// then the glob-pattern summaries, then the tsconfig summary; ordering does
267/// not affect correctness since these are independent stderr lines.
268fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
269    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
270    let per_instance = |diag: &WorkspaceDiagnostic| PlannedWarning {
271        dedupe_key: format!(
272            "{}::{}::{}",
273            canonical.display(),
274            diag.kind.id(),
275            diag.path.display()
276        ),
277        message: diag.message.clone(),
278    };
279
280    let mut plans: Vec<PlannedWarning> = Vec::new();
281    let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
282    // `tsconfig.json` references[] entries pointing at missing directories are
283    // aggregated together: a single root tsconfig commonly lists every sibling
284    // package, and on a large monorepo the referenced source tree may not be
285    // checked out, producing dozens of distinct-but-repetitive lines.
286    let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
287    for diag in diagnostics {
288        match &diag.kind {
289            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
290                match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
291                    Some((_, group)) => group.push(diag),
292                    None => glob_groups.push((pattern.as_str(), vec![diag])),
293                }
294            }
295            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
296            _ => plans.push(per_instance(diag)),
297        }
298    }
299
300    for (pattern, group) in glob_groups {
301        if let [only] = group.as_slice() {
302            plans.push(per_instance(only));
303            continue;
304        }
305        let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
306        plans.push(PlannedWarning {
307            dedupe_key: format!(
308                "{}::glob-matched-no-package-json-agg::{pattern}",
309                canonical.display()
310            ),
311            message: build_glob_group_message(root, pattern, &paths),
312        });
313    }
314
315    // A single missing reference keeps today's per-instance message; two or
316    // more collapse to one summary line naming a few of the missing paths.
317    if let [only] = tsconfig_ref_misses.as_slice() {
318        plans.push(per_instance(only));
319    } else if !tsconfig_ref_misses.is_empty() {
320        let paths: Vec<&Path> = tsconfig_ref_misses
321            .iter()
322            .map(|d| d.path.as_path())
323            .collect();
324        plans.push(PlannedWarning {
325            dedupe_key: format!(
326                "{}::tsconfig-reference-dir-missing-agg",
327                canonical.display()
328            ),
329            message: build_tsconfig_refs_message(root, &paths),
330        });
331    }
332
333    plans
334}
335
336/// Emit `tracing::warn!` lines for a batch of workspace diagnostics.
337///
338/// Delegates the partition/aggregation decisions to the pure [`plan_warnings`]
339/// and applies the process-wide dedupe so combined-mode (check + dupes + health
340/// through one loader) and watch-mode reruns warn at most once per logical
341/// diagnostic. The returned/stashed `Vec<WorkspaceDiagnostic>` is unaffected;
342/// only the stderr surface is bounded, so structured JSON consumers still see
343/// every diagnostic.
344pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
345    // Capture every diagnostic before the dedupe gate so tests observe both
346    // calls on the same (root, kind, path) and see the constituents behind an
347    // aggregated glob warning.
348    #[cfg(test)]
349    for diag in diagnostics {
350        capture_diag(diag);
351    }
352
353    for plan in plan_warnings(root, diagnostics) {
354        // On a poisoned mutex, `should_emit` returns true and we emit anyway:
355        // over-warning beats swallowing a typo.
356        if should_emit(plan.dedupe_key) {
357            tracing::warn!("fallow: {}", plan.message);
358        }
359    }
360}
361
362/// Render up to [`GLOB_EXAMPLE_CAP`] project-relative example paths (sorted for
363/// deterministic output) with an "and N more" tail when the count exceeds the
364/// cap. Returns the joined example string and the total path count. Shared by
365/// the aggregated-message builders.
366fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
367    let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
368    examples.sort();
369    let count = examples.len();
370    let shown = examples
371        .iter()
372        .take(GLOB_EXAMPLE_CAP)
373        .cloned()
374        .collect::<Vec<_>>()
375        .join(", ");
376    let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
377    let listed = if remaining > 0 {
378        format!("{shown}, and {remaining} more")
379    } else {
380        shown
381    };
382    (listed, count)
383}
384
385/// Build the aggregated message for a glob pattern that matched `paths`
386/// package-less directories (always called with `paths.len() >= 2`).
387fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
388    let (listed, count) = summarize_examples(root, paths);
389    format!(
390        "Glob '{pattern}' matched {count} directories with no package.json \
391         (e.g. {listed}). Add a package.json, narrow the pattern, or add \
392         them to ignorePatterns."
393    )
394}
395
396/// Build the aggregated message for `paths` `tsconfig.json` `references[]`
397/// entries that point at missing directories (always called with
398/// `paths.len() >= 2`).
399fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
400    let (listed, count) = summarize_examples(root, paths);
401    format!(
402        "tsconfig.json references {count} directories that do not exist \
403         (e.g. {listed}). Update or remove the references, or restore the \
404         missing directories."
405    )
406}
407
408thread_local! {
409    /// Per-thread capture of workspace diagnostics, for tests that assert
410    /// emission without inspecting tracing output. Parallel test execution
411    /// stays race-free because the buffer is thread-local; production code
412    /// keeps the cell empty so emission goes only to tracing.
413    ///
414    /// Mirrors `parsing::UNKNOWN_RULE_CAPTURE` (issue #467).
415    #[cfg(test)]
416    static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
417        const { std::cell::RefCell::new(None) };
418}
419
420/// Push `diag` into the thread-local capture buffer when one is installed.
421/// No-op when no test has called [`capture_workspace_warnings`] on the current
422/// thread, so production code never allocates. Called once per diagnostic by
423/// [`emit_diagnostics`] before the dedupe gate, so every diagnostic is observed
424/// regardless of whether it was emitted per-instance or aggregated.
425#[cfg(test)]
426fn capture_diag(diag: &WorkspaceDiagnostic) {
427    WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
428        if let Some(buf) = cell.borrow_mut().as_mut() {
429            buf.push(diag.clone());
430        }
431    });
432}
433
434/// Install a thread-local capture buffer and run `body`. Returns the body's
435/// result alongside every diagnostic passed through [`emit_diagnostics`] on the
436/// current thread, in order.
437///
438/// Test-only. Diagnostics captured here also bypass the process-wide dedupe
439/// (so two captures on the same root + kind + path inside one test both
440/// observe the emission).
441#[cfg(test)]
442#[must_use]
443pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
444    WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
445        *cell.borrow_mut() = Some(Vec::new());
446    });
447    let result = body();
448    let findings =
449        WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
450    (result, findings)
451}
452
453/// Process-wide registry of workspace-discovery diagnostics, keyed by
454/// canonical root. Populated by callers that run
455/// [`super::discover_workspaces_with_diagnostics`] and (after config load
456/// completes) by the analysis pipeline's `find_undeclared_workspaces_*`
457/// pass. Consumers (`fallow list --workspaces`, the JSON envelope on
458/// `fallow check / dupes / health`) read via [`workspace_diagnostics_for`].
459///
460/// Canonicalisation matches the dedupe-key canonicalisation in
461/// [`plan_warnings`]: two callers on the same physical root coalesce, and
462/// nested-monorepo callers on different roots stay independent.
463static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
464    OnceLock::new();
465
466/// Replace the workspace-discovery diagnostics for `root` with `diagnostics`.
467///
468/// Called at config-load time after [`super::discover_workspaces_with_diagnostics`]
469/// completes; the analyze pipeline then APPENDS undeclared-workspace
470/// diagnostics via [`append_workspace_diagnostics`].
471pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
472    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
473    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
474    if let Ok(mut map) = registry.lock() {
475        map.insert(canonical, diagnostics);
476    }
477}
478
479/// Append `additions` to the workspace-discovery diagnostics for `root`,
480/// skipping any entry whose `(kind id, canonical path)` is already present.
481///
482/// Used by the analyze pipeline's undeclared-workspace pass to fold its
483/// findings into the registry without re-emitting diagnostics that the
484/// config-load pass already surfaced (e.g. a directory whose `package.json`
485/// is malformed should NOT also produce a separate "undeclared" diagnostic
486/// alongside the malformed-package-json one).
487pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
488    if additions.is_empty() {
489        return;
490    }
491    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
492    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
493    if let Ok(mut map) = registry.lock() {
494        let existing = map.entry(canonical).or_default();
495        let mut seen: FxHashSet<(String, String)> = existing
496            .iter()
497            .map(|d| {
498                (
499                    d.kind.id().to_owned(),
500                    dunce::canonicalize(&d.path)
501                        .unwrap_or_else(|_| d.path.clone())
502                        .display()
503                        .to_string(),
504                )
505            })
506            .collect();
507        for addition in additions {
508            let key = (
509                addition.kind.id().to_owned(),
510                dunce::canonicalize(&addition.path)
511                    .unwrap_or_else(|_| addition.path.clone())
512                    .display()
513                    .to_string(),
514            );
515            if seen.insert(key) {
516                existing.push(addition);
517            }
518        }
519    }
520}
521
522/// Read the workspace-discovery diagnostics produced by the most recent
523/// `stash_workspace_diagnostics` + any subsequent
524/// `append_workspace_diagnostics` calls for `root`. Returns an empty vector
525/// when nothing has been stashed for this root yet (e.g. programmatic
526/// callers bypassing the standard loader).
527#[must_use]
528pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
529    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
530    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
531        return Vec::new();
532    };
533    registry
534        .lock()
535        .ok()
536        .and_then(|map| map.get(&canonical).cloned())
537        .unwrap_or_default()
538}
539
540/// Directories that are conventionally NOT workspace packages even when a
541/// glob like `packages/*` matches them. Mirrors pnpm/npm/yarn behavior of
542/// silently filtering these out, and extends fallow's existing
543/// `should_skip_workspace_scan_dir` list with build artifacts and tooling
544/// caches.
545#[must_use]
546pub(super) fn is_skip_listed_dir(name: &str) -> bool {
547    // Dot-prefixed names (`.next`, `.turbo`, `.nuxt`, `.svelte-kit`, `.cache`)
548    // are caught by the `starts_with('.')` arm; do not duplicate them in the
549    // explicit list. The explicit list is reserved for non-dot conventional
550    // build / output / tooling directories that pnpm/npm/yarn also filter.
551    name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
552}
553
554/// Test if a project-root-relative directory path is excluded by user
555/// `ignorePatterns`. The directory itself and its `package.json` are both
556/// checked because users variably write `packages/legacy/**` or
557/// `packages/legacy/package.json` in their ignore globs.
558#[must_use]
559pub(super) fn is_ignored_workspace_dir(
560    relative_dir: &Path,
561    ignore_patterns: &globset::GlobSet,
562) -> bool {
563    if ignore_patterns.is_empty() {
564        return false;
565    }
566    let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
567    ignore_patterns.is_match(relative_str.as_str())
568        || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
576        WorkspaceDiagnostic::new(
577            root,
578            root.join(rel_path),
579            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
580                pattern: pattern.to_owned(),
581            },
582        )
583    }
584
585    #[test]
586    fn build_glob_group_message_caps_examples_and_summarises_tail() {
587        let root = Path::new("/project");
588        let paths = [
589            root.join("playground/cli"),
590            root.join("playground/lib-types"),
591            root.join("playground/minify"),
592            root.join("playground/ssr"),
593            root.join("playground/worker"),
594        ];
595        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
596        let message = build_glob_group_message(root, "playground/**", &refs);
597
598        assert!(
599            message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
600            "count and pattern lead the message: {message}"
601        );
602        // First three sorted examples are named; the rest summarised.
603        assert!(
604            message.contains(
605                "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
606            ),
607            "three sorted examples + tail count: {message}"
608        );
609        assert!(
610            message.ends_with(
611                "Add a package.json, narrow the pattern, or add them to ignorePatterns."
612            ),
613            "next-step hint preserved: {message}"
614        );
615        // Never names the truncated examples inline.
616        assert!(
617            !message.contains("playground/ssr"),
618            "tail example not named: {message}"
619        );
620    }
621
622    #[test]
623    fn build_glob_group_message_no_tail_when_at_or_below_cap() {
624        let root = Path::new("/project");
625        let paths = [root.join("packages/a"), root.join("packages/b")];
626        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
627        let message = build_glob_group_message(root, "packages/*", &refs);
628
629        assert!(message.contains("matched 2 directories"), "{message}");
630        assert!(
631            message.contains("(e.g. packages/a, packages/b)"),
632            "both examples named, no `and N more`: {message}"
633        );
634        assert!(!message.contains("more)"), "no tail clause: {message}");
635    }
636
637    #[test]
638    fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
639        let root = Path::new("/project");
640        let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
641            .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
642            .collect();
643
644        let plans = plan_warnings(root, &diagnostics);
645
646        assert_eq!(
647            plans.len(),
648            1,
649            "50 same-pattern diagnostics collapse to one plan"
650        );
651        assert!(
652            plans[0]
653                .dedupe_key
654                .ends_with("::glob-matched-no-package-json-agg::playground/**")
655        );
656        assert!(plans[0].message.contains("matched 50 directories"));
657    }
658
659    #[test]
660    fn plan_warnings_keeps_distinct_patterns_separate() {
661        let root = Path::new("/project");
662        let diagnostics = vec![
663            glob_diag(root, "apps/*", "apps/a"),
664            glob_diag(root, "apps/*", "apps/b"),
665            glob_diag(root, "packages/*", "packages/x"),
666            glob_diag(root, "packages/*", "packages/y"),
667        ];
668
669        let plans = plan_warnings(root, &diagnostics);
670
671        assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
672        let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
673        assert!(
674            messages
675                .iter()
676                .any(|m| m.contains("Glob 'apps/*' matched 2")),
677            "{messages:?}"
678        );
679        assert!(
680            messages
681                .iter()
682                .any(|m| m.contains("Glob 'packages/*' matched 2")),
683            "{messages:?}"
684        );
685    }
686
687    #[test]
688    fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
689        let root = Path::new("/project");
690        let diag = glob_diag(root, "packages/*", "packages/scratch");
691
692        let plans = plan_warnings(root, std::slice::from_ref(&diag));
693
694        assert_eq!(plans.len(), 1);
695        // Byte-identical to the per-instance message and key (no aggregation).
696        assert_eq!(plans[0].message, diag.message);
697        assert!(
698            plans[0]
699                .dedupe_key
700                .contains("::glob-matched-no-package-json::")
701                && plans[0].dedupe_key.ends_with("packages/scratch"),
702            "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
703            plans[0].dedupe_key
704        );
705        assert!(
706            !plans[0].message.contains("directories"),
707            "single match is not aggregated"
708        );
709    }
710
711    #[test]
712    fn plan_warnings_non_glob_kinds_stay_per_instance() {
713        let root = Path::new("/project");
714        let diagnostics = vec![
715            WorkspaceDiagnostic::new(
716                root,
717                root.join("packages/a"),
718                WorkspaceDiagnosticKind::UndeclaredWorkspace,
719            ),
720            WorkspaceDiagnostic::new(
721                root,
722                root.join("packages/b"),
723                WorkspaceDiagnosticKind::MalformedPackageJson {
724                    error: "trailing comma".to_owned(),
725                },
726            ),
727        ];
728
729        let plans = plan_warnings(root, &diagnostics);
730
731        assert_eq!(
732            plans.len(),
733            2,
734            "each non-glob diagnostic plans its own warning"
735        );
736        assert!(
737            plans
738                .iter()
739                .all(|p| !p.message.contains("directories with no package.json"))
740        );
741    }
742
743    fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
744        WorkspaceDiagnostic::new(
745            root,
746            root.join(rel_path),
747            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
748        )
749    }
750
751    #[test]
752    fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
753        let root = Path::new("/project");
754        let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
755            .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
756            .collect();
757
758        let plans = plan_warnings(root, &diagnostics);
759
760        assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
761        assert!(
762            plans[0]
763                .dedupe_key
764                .ends_with("::tsconfig-reference-dir-missing-agg")
765        );
766        assert!(
767            plans[0]
768                .message
769                .starts_with("tsconfig.json references 30 directories that do not exist"),
770            "{}",
771            plans[0].message
772        );
773        assert!(
774            plans[0].message.contains(
775                "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
776                 packages/p02/tsconfig.json, and 27 more)"
777            ),
778            "three sorted examples + tail: {}",
779            plans[0].message
780        );
781        assert!(
782            plans[0]
783                .message
784                .ends_with("Update or remove the references, or restore the missing directories."),
785            "{}",
786            plans[0].message
787        );
788    }
789
790    #[test]
791    fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
792        let root = Path::new("/project");
793        let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
794
795        let plans = plan_warnings(root, std::slice::from_ref(&diag));
796
797        assert_eq!(plans.len(), 1);
798        assert_eq!(
799            plans[0].message, diag.message,
800            "single miss is not aggregated"
801        );
802        assert!(!plans[0].message.contains("directories that do not exist"));
803    }
804
805    #[test]
806    fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
807        let root = Path::new("/project");
808        let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
809            .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
810            .collect();
811        diagnostics.extend(
812            (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
813        );
814
815        let plans = plan_warnings(root, &diagnostics);
816
817        assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
818        assert!(
819            plans
820                .iter()
821                .any(|p| p.message.contains("matched 5 directories"))
822        );
823        assert!(
824            plans
825                .iter()
826                .any(|p| p.message.contains("references 4 directories"))
827        );
828    }
829}