Skip to main content

fallow_config/workspace/
diagnostics.rs

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