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        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
152/// Render `path` relative to `root` with forward slashes. Shared by
153/// [`render_message`] and [`build_glob_group_message`] so the per-instance and
154/// aggregated message surfaces format paths identically (the forward-slash
155/// normalisation is load-bearing for cross-platform output stability).
156fn 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/// Workspace-discovery failures that prevent analysis from proceeding.
191///
192/// Returned only by `discover_workspaces_with_diagnostics` (in the parent
193/// module) when the root `package.json` itself is malformed: without a
194/// parseable root, no workspace patterns can be collected, and analysis
195/// output would be fiction. The CLI surfaces this as exit 2.
196#[derive(Debug, Clone)]
197pub enum WorkspaceLoadError {
198    /// The project root's `package.json` exists but failed to parse.
199    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
217/// Maximum number of example directories named in an aggregated
218/// `GlobMatchedNoPackageJson` warning before the tail is summarised as
219/// "and N more". Keeps a fanned-out glob to one bounded stderr line.
220const GLOB_EXAMPLE_CAP: usize = 3;
221
222/// Process-wide set of already-emitted diagnostic dedupe keys. Per-instance
223/// keys (`root::kind::path`) and aggregated per-pattern keys
224/// (`root::glob-matched-no-package-json-agg::pattern`) share one set so
225/// combined-mode (check + dupes + health through one loader) and watch-mode
226/// reruns warn at most once per logical diagnostic. The two key namespaces are
227/// disjoint, so there is no cross-talk.
228fn 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
233/// Insert `key` and return `true` when it was newly inserted (caller should
234/// emit). On a poisoned mutex returns `true` so over-warning beats swallowing
235/// a typo. Mirrors `parsing::warn_on_unknown_rule_keys` and
236/// `plugins::registry::should_warn`.
237fn should_emit(key: String) -> bool {
238    warned_keys().lock().map_or(true, |mut set| set.insert(key))
239}
240
241/// A single planned stderr warning: its process-dedupe key and the rendered
242/// message. The pure output of [`plan_warnings`] so the partition/aggregation
243/// logic is unit-testable without a tracing subscriber or the process-wide
244/// dedupe set.
245#[derive(Debug, PartialEq, Eq)]
246struct PlannedWarning {
247    dedupe_key: String,
248    message: String,
249}
250
251/// Turn a batch of workspace diagnostics into the bounded set of stderr
252/// warnings to emit, collapsing the two kinds that fan out on large monorepos
253/// (issue #637):
254/// - `GlobMatchedNoPackageJson`: aggregated by glob pattern, one summary line
255///   per pattern instead of one line per package-less directory.
256/// - `TsconfigReferenceDirMissing`: aggregated together, one summary line
257///   instead of one per missing `references[]` entry in the root tsconfig.
258///
259/// Pure: no tracing, no dedupe-set mutation. A group of exactly one keeps
260/// today's per-instance message byte-for-byte (no regression for the common
261/// single-match case); every other kind plans one per-instance warning. The
262/// returned plan lists non-aggregated diagnostics first (in first-seen order),
263/// then the glob-pattern summaries, then the tsconfig summary; ordering does
264/// not affect correctness since these are independent stderr lines.
265fn 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
327/// Emit `tracing::warn!` lines for a batch of workspace diagnostics.
328///
329/// Delegates the partition/aggregation decisions to the pure [`plan_warnings`]
330/// and applies the process-wide dedupe so combined-mode (check + dupes + health
331/// through one loader) and watch-mode reruns warn at most once per logical
332/// diagnostic. The returned/stashed `Vec<WorkspaceDiagnostic>` is unaffected;
333/// only the stderr surface is bounded, so structured JSON consumers still see
334/// every diagnostic.
335pub(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
348/// Render up to [`GLOB_EXAMPLE_CAP`] project-relative example paths (sorted for
349/// deterministic output) with an "and N more" tail when the count exceeds the
350/// cap. Returns the joined example string and the total path count. Shared by
351/// the aggregated-message builders.
352fn 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
371/// Build the aggregated message for a glob pattern that matched `paths`
372/// package-less directories (always called with `paths.len() >= 2`).
373fn 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
382/// Build the aggregated message for `paths` `tsconfig.json` `references[]`
383/// entries that point at missing directories (always called with
384/// `paths.len() >= 2`).
385fn 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    /// Per-thread capture of workspace diagnostics, for tests that assert
396    /// emission without inspecting tracing output. Parallel test execution
397    /// stays race-free because the buffer is thread-local; production code
398    /// keeps the cell empty so emission goes only to tracing.
399    ///
400    /// Mirrors `parsing::UNKNOWN_RULE_CAPTURE` (issue #467).
401    #[cfg(test)]
402    static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
403        const { std::cell::RefCell::new(None) };
404}
405
406/// Push `diag` into the thread-local capture buffer when one is installed.
407/// No-op when no test has called [`capture_workspace_warnings`] on the current
408/// thread, so production code never allocates. Called once per diagnostic by
409/// [`emit_diagnostics`] before the dedupe gate, so every diagnostic is observed
410/// regardless of whether it was emitted per-instance or aggregated.
411#[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/// Install a thread-local capture buffer and run `body`. Returns the body's
421/// result alongside every diagnostic passed through [`emit_diagnostics`] on the
422/// current thread, in order.
423///
424/// Test-only. Diagnostics captured here also bypass the process-wide dedupe
425/// (so two captures on the same root + kind + path inside one test both
426/// observe the emission).
427#[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
439/// Process-wide registry of workspace-discovery diagnostics, keyed by
440/// canonical root. Populated by callers that run
441/// [`super::discover_workspaces_with_diagnostics`] and (after config load
442/// completes) by the analysis pipeline's `find_undeclared_workspaces_*`
443/// pass. Consumers (`fallow list --workspaces`, the JSON envelope on
444/// `fallow check / dupes / health`) read via [`workspace_diagnostics_for`].
445///
446/// Canonicalisation matches the dedupe-key canonicalisation in
447/// [`plan_warnings`]: two callers on the same physical root coalesce, and
448/// nested-monorepo callers on different roots stay independent.
449static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
450    OnceLock::new();
451
452/// Replace the workspace-discovery diagnostics for `root` with `diagnostics`.
453///
454/// Called at config-load time after [`super::discover_workspaces_with_diagnostics`]
455/// completes; the analyze pipeline then APPENDS undeclared-workspace
456/// diagnostics via [`append_workspace_diagnostics`].
457pub 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
465/// Append `additions` to the workspace-discovery diagnostics for `root`,
466/// skipping any entry whose `(kind id, canonical path)` is already present.
467///
468/// Used by the analyze pipeline's undeclared-workspace pass to fold its
469/// findings into the registry without re-emitting diagnostics that the
470/// config-load pass already surfaced (e.g. a directory whose `package.json`
471/// is malformed should NOT also produce a separate "undeclared" diagnostic
472/// alongside the malformed-package-json one).
473pub 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/// Read the workspace-discovery diagnostics produced by the most recent
509/// `stash_workspace_diagnostics` + any subsequent
510/// `append_workspace_diagnostics` calls for `root`. Returns an empty vector
511/// when nothing has been stashed for this root yet (e.g. programmatic
512/// callers bypassing the standard loader).
513#[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/// Directories that are conventionally NOT workspace packages even when a
527/// glob like `packages/*` matches them. Mirrors pnpm/npm/yarn behavior of
528/// silently filtering these out, and extends fallow's existing
529/// `should_skip_workspace_scan_dir` list with build artifacts and tooling
530/// caches.
531#[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/// Test if a project-root-relative directory path is excluded by user
537/// `ignorePatterns`. The directory itself and its `package.json` are both
538/// checked because users variably write `packages/legacy/**` or
539/// `packages/legacy/package.json` in their ignore globs.
540#[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}