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
103struct WarningGroups<'a> {
104    plans: Vec<PlannedWarning>,
105    glob_groups: Vec<(&'a str, Vec<&'a WorkspaceDiagnostic>)>,
106    tsconfig_ref_misses: Vec<&'a WorkspaceDiagnostic>,
107}
108
109/// Turn a batch of workspace diagnostics into the bounded set of stderr
110/// warnings to emit, collapsing the two kinds that fan out on large monorepos
111/// (issue #637):
112/// - `GlobMatchedNoPackageJson`: aggregated by glob pattern, one summary line
113///   per pattern instead of one line per package-less directory.
114/// - `TsconfigReferenceDirMissing`: aggregated together, one summary line
115///   instead of one per missing `references[]` entry in the root tsconfig.
116///
117/// Pure: no tracing, no dedupe-set mutation. A group of exactly one keeps
118/// today's per-instance message byte-for-byte (no regression for the common
119/// single-match case); every other kind plans one per-instance warning. The
120/// returned plan lists non-aggregated diagnostics first (in first-seen order),
121/// then the glob-pattern summaries, then the tsconfig summary; ordering does
122/// not affect correctness since these are independent stderr lines.
123fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
124    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
125    let WarningGroups {
126        mut plans,
127        glob_groups,
128        tsconfig_ref_misses,
129    } = group_warning_diagnostics(diagnostics, &canonical);
130
131    for (pattern, group) in glob_groups {
132        if let [only] = group.as_slice() {
133            plans.push(per_instance_warning(&canonical, only));
134            continue;
135        }
136        let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
137        plans.push(PlannedWarning {
138            dedupe_key: format!(
139                "{}::glob-matched-no-package-json-agg::{pattern}",
140                canonical.display()
141            ),
142            message: build_glob_group_message(root, pattern, &paths),
143        });
144    }
145
146    if let [only] = tsconfig_ref_misses.as_slice() {
147        plans.push(per_instance_warning(&canonical, only));
148    } else if !tsconfig_ref_misses.is_empty() {
149        let paths: Vec<&Path> = tsconfig_ref_misses
150            .iter()
151            .map(|d| d.path.as_path())
152            .collect();
153        plans.push(PlannedWarning {
154            dedupe_key: format!(
155                "{}::tsconfig-reference-dir-missing-agg",
156                canonical.display()
157            ),
158            message: build_tsconfig_refs_message(root, &paths),
159        });
160    }
161
162    plans
163}
164
165fn group_warning_diagnostics<'a>(
166    diagnostics: &'a [WorkspaceDiagnostic],
167    canonical: &Path,
168) -> WarningGroups<'a> {
169    let mut plans: Vec<PlannedWarning> = Vec::new();
170    let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
171    let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
172    for diag in diagnostics {
173        match &diag.kind {
174            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
175                match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
176                    Some((_, group)) => group.push(diag),
177                    None => glob_groups.push((pattern.as_str(), vec![diag])),
178                }
179            }
180            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
181            _ => plans.push(per_instance_warning(canonical, diag)),
182        }
183    }
184    WarningGroups {
185        plans,
186        glob_groups,
187        tsconfig_ref_misses,
188    }
189}
190
191fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
192    PlannedWarning {
193        dedupe_key: format!(
194            "{}::{}::{}",
195            canonical.display(),
196            diag.kind.id(),
197            diag.path.display()
198        ),
199        message: diag.message.clone(),
200    }
201}
202
203/// Emit `tracing::warn!` lines for a batch of workspace diagnostics.
204///
205/// Delegates the partition/aggregation decisions to the pure [`plan_warnings`]
206/// and applies the process-wide dedupe so combined-mode (check + dupes + health
207/// through one loader) and watch-mode reruns warn at most once per logical
208/// diagnostic. The returned/stashed `Vec<WorkspaceDiagnostic>` is unaffected;
209/// only the stderr surface is bounded, so structured JSON consumers still see
210/// every diagnostic.
211pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
212    #[cfg(test)]
213    for diag in diagnostics {
214        capture_diag(diag);
215    }
216
217    for plan in plan_warnings(root, diagnostics) {
218        if should_emit(plan.dedupe_key) {
219            tracing::warn!("fallow: {}", plan.message);
220        }
221    }
222}
223
224/// Render up to [`GLOB_EXAMPLE_CAP`] project-relative example paths (sorted for
225/// deterministic output) with an "and N more" tail when the count exceeds the
226/// cap. Returns the joined example string and the total path count. Shared by
227/// the aggregated-message builders.
228fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
229    let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
230    examples.sort();
231    let count = examples.len();
232    let shown = examples
233        .iter()
234        .take(GLOB_EXAMPLE_CAP)
235        .cloned()
236        .collect::<Vec<_>>()
237        .join(", ");
238    let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
239    let listed = if remaining > 0 {
240        format!("{shown}, and {remaining} more")
241    } else {
242        shown
243    };
244    (listed, count)
245}
246
247/// Build the aggregated message for a glob pattern that matched `paths`
248/// package-less directories (always called with `paths.len() >= 2`).
249fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
250    let (listed, count) = summarize_examples(root, paths);
251    format!(
252        "Glob '{pattern}' matched {count} directories with no package.json \
253         (e.g. {listed}). Add a package.json, narrow the pattern, or add \
254         them to ignorePatterns."
255    )
256}
257
258/// Build the aggregated message for `paths` `tsconfig.json` `references[]`
259/// entries that point at missing directories (always called with
260/// `paths.len() >= 2`).
261fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
262    let (listed, count) = summarize_examples(root, paths);
263    format!(
264        "tsconfig.json references {count} directories that do not exist \
265         (e.g. {listed}). Update or remove the references, or restore the \
266         missing directories."
267    )
268}
269
270thread_local! {
271    /// Per-thread capture of workspace diagnostics, for tests that assert
272    /// emission without inspecting tracing output. Parallel test execution
273    /// stays race-free because the buffer is thread-local; production code
274    /// keeps the cell empty so emission goes only to tracing.
275    ///
276    /// Mirrors `parsing::UNKNOWN_RULE_CAPTURE` (issue #467).
277    #[cfg(test)]
278    static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
279        const { std::cell::RefCell::new(None) };
280}
281
282/// Push `diag` into the thread-local capture buffer when one is installed.
283/// No-op when no test has called [`capture_workspace_warnings`] on the current
284/// thread, so production code never allocates. Called once per diagnostic by
285/// [`emit_diagnostics`] before the dedupe gate, so every diagnostic is observed
286/// regardless of whether it was emitted per-instance or aggregated.
287#[cfg(test)]
288fn capture_diag(diag: &WorkspaceDiagnostic) {
289    WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
290        if let Some(buf) = cell.borrow_mut().as_mut() {
291            buf.push(diag.clone());
292        }
293    });
294}
295
296/// Install a thread-local capture buffer and run `body`. Returns the body's
297/// result alongside every diagnostic passed through [`emit_diagnostics`] on the
298/// current thread, in order.
299///
300/// Test-only. Diagnostics captured here also bypass the process-wide dedupe
301/// (so two captures on the same root + kind + path inside one test both
302/// observe the emission).
303#[cfg(test)]
304#[must_use]
305pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
306    WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
307        *cell.borrow_mut() = Some(Vec::new());
308    });
309    let result = body();
310    let findings =
311        WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
312    (result, findings)
313}
314
315/// Process-wide registry of workspace-discovery diagnostics, keyed by
316/// canonical root. Populated by callers that run
317/// [`super::discover_workspaces_with_diagnostics`] and (after config load
318/// completes) by the analysis pipeline's `find_undeclared_workspaces_*`
319/// pass. Consumers (`fallow list --workspaces`, the JSON envelope on
320/// `fallow dead-code / dupes / health`) read via [`workspace_diagnostics_for`].
321///
322/// Canonicalisation matches the dedupe-key canonicalisation in
323/// [`plan_warnings`]: two callers on the same physical root coalesce, and
324/// nested-monorepo callers on different roots stay independent.
325static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
326    OnceLock::new();
327
328/// Replace the workspace-discovery diagnostics for `root` with `diagnostics`,
329/// PRESERVING any source-discovery diagnostics (see
330/// [`WorkspaceDiagnosticKind::is_source_discovery`]) already appended for the
331/// root.
332///
333/// Called at config-load time after [`super::discover_workspaces_with_diagnostics`]
334/// completes; the analyze pipeline then APPENDS undeclared-workspace and
335/// source-discovery (`skipped-large-file`) diagnostics via
336/// [`append_workspace_diagnostics`]. The workspace-discovery set is authoritative
337/// and replaced wholesale (so a fixed `package.json` clears its stale diagnostic
338/// across watch-mode reruns), but source-discovery diagnostics are appended
339/// AFTER this stash, so combined-mode's per-analysis config re-loads would
340/// otherwise wipe a `skipped-large-file` entry that the first analysis's
341/// discovery already recorded (issue #1086).
342pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
343    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
344    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
345    if let Ok(mut map) = registry.lock() {
346        let mut combined = diagnostics;
347        if let Some(existing) = map.get(&canonical) {
348            combined.extend(
349                existing
350                    .iter()
351                    .filter(|d| d.kind.is_source_discovery())
352                    .cloned(),
353            );
354        }
355        map.insert(canonical, combined);
356    }
357}
358
359/// Append `additions` to the workspace-discovery diagnostics for `root`,
360/// skipping any entry whose `(kind id, canonical path)` is already present.
361///
362/// Used by the analyze pipeline's undeclared-workspace pass to fold its
363/// findings into the registry without re-emitting diagnostics that the
364/// config-load pass already surfaced (e.g. a directory whose `package.json`
365/// is malformed should NOT also produce a separate "undeclared" diagnostic
366/// alongside the malformed-package-json one).
367pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
368    if additions.is_empty() {
369        return;
370    }
371    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
372    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
373    if let Ok(mut map) = registry.lock() {
374        let existing = map.entry(canonical).or_default();
375        let mut seen: FxHashSet<(String, String)> = existing
376            .iter()
377            .map(|d| {
378                (
379                    d.kind.id().to_owned(),
380                    dunce::canonicalize(&d.path)
381                        .unwrap_or_else(|_| d.path.clone())
382                        .display()
383                        .to_string(),
384                )
385            })
386            .collect();
387        for addition in additions {
388            let key = (
389                addition.kind.id().to_owned(),
390                dunce::canonicalize(&addition.path)
391                    .unwrap_or_else(|_| addition.path.clone())
392                    .display()
393                    .to_string(),
394            );
395            if seen.insert(key) {
396                existing.push(addition);
397            }
398        }
399    }
400}
401
402/// Replace source-read-failure diagnostics for `root` with the failures from
403/// the current parse while preserving every workspace and discovery diagnostic
404/// produced by other stages.
405///
406/// Returns the structured diagnostics so session-owned outputs can carry the
407/// exact same values as the process registry used by direct core and CLI paths.
408#[must_use]
409pub fn record_source_read_failures(
410    root: &Path,
411    failures: &[fallow_types::extract::SourceReadFailure],
412) -> Vec<WorkspaceDiagnostic> {
413    let diagnostics: Vec<WorkspaceDiagnostic> = failures
414        .iter()
415        .map(|failure| {
416            WorkspaceDiagnostic::new(
417                root,
418                failure.path.clone(),
419                WorkspaceDiagnosticKind::SourceReadFailure {
420                    error: failure.error.clone(),
421                },
422            )
423        })
424        .collect();
425    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
426    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
427    if let Ok(mut map) = registry.lock() {
428        let existing = map.entry(canonical).or_default();
429        existing.retain(|diagnostic| {
430            !matches!(
431                diagnostic.kind,
432                WorkspaceDiagnosticKind::SourceReadFailure { .. }
433            )
434        });
435        existing.extend(diagnostics.iter().cloned());
436    }
437    emit_diagnostics(root, &diagnostics);
438    diagnostics
439}
440
441/// Remove all source-discovery diagnostics (see
442/// [`WorkspaceDiagnosticKind::is_source_discovery`]) for `root` from the
443/// registry, keeping the workspace-discovery set intact.
444///
445/// Called at the START of each source walk (`discover_files`) so a stale
446/// `skipped-large-file` entry from a previous analysis pass (e.g. a watch-mode
447/// rerun after the user raised `--max-file-size` or added the file to
448/// `ignorePatterns`) is dropped before the current walk re-appends only the
449/// files it actually skips. Pairs with the preserve in
450/// [`stash_workspace_diagnostics`]: clear keeps the set CURRENT across reruns,
451/// preserve keeps it ALIVE across combined-mode's per-analysis config re-loads
452/// (issue #1086).
453pub fn clear_source_discovery_diagnostics(root: &Path) {
454    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
455    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
456        return;
457    };
458    if let Ok(mut map) = registry.lock()
459        && let Some(existing) = map.get_mut(&canonical)
460    {
461        existing.retain(|d| !d.kind.is_source_discovery());
462    }
463}
464
465/// Read the workspace-discovery diagnostics produced by the most recent
466/// `stash_workspace_diagnostics` + any subsequent
467/// `append_workspace_diagnostics` calls for `root`. Returns an empty vector
468/// when nothing has been stashed for this root yet (e.g. programmatic
469/// callers bypassing the standard loader).
470#[must_use]
471pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
472    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
473    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
474        return Vec::new();
475    };
476    registry
477        .lock()
478        .ok()
479        .and_then(|map| map.get(&canonical).cloned())
480        .unwrap_or_default()
481}
482
483/// Directories that are conventionally NOT workspace packages even when a
484/// glob like `packages/*` matches them. Mirrors pnpm/npm/yarn behavior of
485/// silently filtering these out, and extends fallow's existing
486/// `should_skip_workspace_scan_dir` list with build artifacts and tooling
487/// caches.
488#[must_use]
489pub(super) fn is_skip_listed_dir(name: &str) -> bool {
490    name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
491}
492
493/// Test if a project-root-relative directory path is excluded by user
494/// `ignorePatterns`. The directory itself and its `package.json` are both
495/// checked because users variably write `packages/legacy/**` or
496/// `packages/legacy/package.json` in their ignore globs.
497#[must_use]
498pub(super) fn is_ignored_workspace_dir(
499    relative_dir: &Path,
500    ignore_patterns: &globset::GlobSet,
501) -> bool {
502    if ignore_patterns.is_empty() {
503        return false;
504    }
505    let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
506    ignore_patterns.is_match(relative_str.as_str())
507        || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use fallow_types::discover::FileId;
514    use fallow_types::extract::SourceReadFailure;
515
516    fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
517        WorkspaceDiagnostic::new(
518            root,
519            root.join(rel_path),
520            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
521                pattern: pattern.to_owned(),
522            },
523        )
524    }
525
526    #[test]
527    fn skipped_large_file_diagnostic_id_and_message() {
528        let root = Path::new("/project");
529        let diag = WorkspaceDiagnostic::new(
530            root,
531            root.join("src/vendor/app.bundle.js"),
532            WorkspaceDiagnosticKind::SkippedLargeFile {
533                size_bytes: 6 * 1024 * 1024,
534            },
535        );
536        assert_eq!(diag.kind.id(), "skipped-large-file");
537        assert!(
538            diag.message.contains("src/vendor/app.bundle.js"),
539            "message names the project-relative path: {}",
540            diag.message
541        );
542        assert!(
543            diag.message.contains("6.0 MB"),
544            "message reports the size: {}",
545            diag.message
546        );
547        assert!(
548            diag.message.contains("--max-file-size"),
549            "message names the override flag: {}",
550            diag.message
551        );
552    }
553
554    #[test]
555    fn skipped_minified_file_diagnostic_id_and_message() {
556        let root = Path::new("/project");
557        let diag = WorkspaceDiagnostic::new(
558            root,
559            root.join("src/assets/index-abc123.js"),
560            WorkspaceDiagnosticKind::SkippedMinifiedFile {
561                size_bytes: 2 * 1024 * 1024,
562            },
563        );
564        assert_eq!(diag.kind.id(), "skipped-minified-file");
565        assert!(
566            diag.message.contains("src/assets/index-abc123.js"),
567            "message names the project-relative path: {}",
568            diag.message
569        );
570        assert!(
571            diag.message.contains("2.0 MB"),
572            "message reports the size: {}",
573            diag.message
574        );
575        assert!(
576            diag.message.contains("--max-file-size 0"),
577            "message names the opt-out: {}",
578            diag.message
579        );
580    }
581
582    #[test]
583    fn stash_preserves_appended_skipped_large_file_across_restash() {
584        // Unique synthetic root so the process-global registry does not collide
585        // with sibling tests.
586        let root = Path::new("/fallow-test-1086-stash-preserve");
587        let undeclared = || {
588            WorkspaceDiagnostic::new(
589                root,
590                root.join("pkg"),
591                WorkspaceDiagnosticKind::UndeclaredWorkspace,
592            )
593        };
594        // First analysis loads config and stashes the workspace-discovery set.
595        stash_workspace_diagnostics(root, vec![undeclared()]);
596        // Its source discovery appends a skipped-large-file diagnostic.
597        append_workspace_diagnostics(
598            root,
599            vec![WorkspaceDiagnostic::new(
600                root,
601                root.join("vendor/big.js"),
602                WorkspaceDiagnosticKind::SkippedLargeFile {
603                    size_bytes: 9_999_999,
604                },
605            )],
606        );
607        // A sibling analysis (combined-mode dupes/health) re-loads config and
608        // re-stashes the same workspace-discovery set.
609        stash_workspace_diagnostics(root, vec![undeclared()]);
610
611        let after = workspace_diagnostics_for(root);
612        assert_eq!(
613            after
614                .iter()
615                .filter(|d| d.kind.is_source_discovery())
616                .count(),
617            1,
618            "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
619        );
620        assert_eq!(
621            after
622                .iter()
623                .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
624                .count(),
625            1,
626            "the workspace-discovery diagnostic is replaced, not duplicated"
627        );
628    }
629
630    #[test]
631    fn source_read_failures_replace_only_their_previous_parse_set() {
632        let root = Path::new("/fallow-test-source-read-replace");
633        stash_workspace_diagnostics(
634            root,
635            vec![WorkspaceDiagnostic::new(
636                root,
637                root.join("pkg"),
638                WorkspaceDiagnosticKind::UndeclaredWorkspace,
639            )],
640        );
641        append_workspace_diagnostics(
642            root,
643            vec![WorkspaceDiagnostic::new(
644                root,
645                root.join("vendor/big.js"),
646                WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
647            )],
648        );
649        let first = SourceReadFailure {
650            file_id: FileId(1),
651            path: root.join("src/first.ts"),
652            error: "removed".to_string(),
653        };
654        let _ = record_source_read_failures(root, &[first]);
655        let second = SourceReadFailure {
656            file_id: FileId(2),
657            path: root.join("src/second.ts"),
658            error: "permission denied".to_string(),
659        };
660
661        let _ = record_source_read_failures(root, std::slice::from_ref(&second));
662
663        let diagnostics = workspace_diagnostics_for(root);
664        let source_failures: Vec<_> = diagnostics
665            .iter()
666            .filter(|diagnostic| {
667                matches!(
668                    diagnostic.kind,
669                    WorkspaceDiagnosticKind::SourceReadFailure { .. }
670                )
671            })
672            .collect();
673        assert_eq!(source_failures.len(), 1);
674        assert_eq!(source_failures[0].path, second.path);
675        assert!(diagnostics.iter().any(|diagnostic| matches!(
676            diagnostic.kind,
677            WorkspaceDiagnosticKind::UndeclaredWorkspace
678        )));
679        assert!(diagnostics.iter().any(|diagnostic| matches!(
680            diagnostic.kind,
681            WorkspaceDiagnosticKind::SkippedLargeFile { .. }
682        )));
683
684        let _ = record_source_read_failures(root, &[]);
685        assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
686            !matches!(
687                diagnostic.kind,
688                WorkspaceDiagnosticKind::SourceReadFailure { .. }
689            )
690        }));
691    }
692
693    #[test]
694    fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
695        let root = Path::new("/fallow-test-1086-clear-stale");
696        stash_workspace_diagnostics(
697            root,
698            vec![WorkspaceDiagnostic::new(
699                root,
700                root.join("pkg"),
701                WorkspaceDiagnosticKind::UndeclaredWorkspace,
702            )],
703        );
704        append_workspace_diagnostics(
705            root,
706            vec![WorkspaceDiagnostic::new(
707                root,
708                root.join("vendor/big.js"),
709                WorkspaceDiagnosticKind::SkippedLargeFile {
710                    size_bytes: 9_999_999,
711                },
712            )],
713        );
714        // A later walk (the file is no longer skipped) clears the stale entry.
715        clear_source_discovery_diagnostics(root);
716
717        let after = workspace_diagnostics_for(root);
718        assert!(
719            !after.iter().any(|d| d.kind.is_source_discovery()),
720            "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
721        );
722        assert!(
723            after
724                .iter()
725                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
726            "the workspace-discovery diagnostic survives the source-discovery clear"
727        );
728    }
729
730    #[test]
731    fn build_glob_group_message_caps_examples_and_summarises_tail() {
732        let root = Path::new("/project");
733        let paths = [
734            root.join("playground/cli"),
735            root.join("playground/lib-types"),
736            root.join("playground/minify"),
737            root.join("playground/ssr"),
738            root.join("playground/worker"),
739        ];
740        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
741        let message = build_glob_group_message(root, "playground/**", &refs);
742
743        assert!(
744            message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
745            "count and pattern lead the message: {message}"
746        );
747        assert!(
748            message.contains(
749                "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
750            ),
751            "three sorted examples + tail count: {message}"
752        );
753        assert!(
754            message.ends_with(
755                "Add a package.json, narrow the pattern, or add them to ignorePatterns."
756            ),
757            "next-step hint preserved: {message}"
758        );
759        assert!(
760            !message.contains("playground/ssr"),
761            "tail example not named: {message}"
762        );
763    }
764
765    #[test]
766    fn build_glob_group_message_no_tail_when_at_or_below_cap() {
767        let root = Path::new("/project");
768        let paths = [root.join("packages/a"), root.join("packages/b")];
769        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
770        let message = build_glob_group_message(root, "packages/*", &refs);
771
772        assert!(message.contains("matched 2 directories"), "{message}");
773        assert!(
774            message.contains("(e.g. packages/a, packages/b)"),
775            "both examples named, no `and N more`: {message}"
776        );
777        assert!(!message.contains("more)"), "no tail clause: {message}");
778    }
779
780    #[test]
781    fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
782        let root = Path::new("/project");
783        let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
784            .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
785            .collect();
786
787        let plans = plan_warnings(root, &diagnostics);
788
789        assert_eq!(
790            plans.len(),
791            1,
792            "50 same-pattern diagnostics collapse to one plan"
793        );
794        assert!(
795            plans[0]
796                .dedupe_key
797                .ends_with("::glob-matched-no-package-json-agg::playground/**")
798        );
799        assert!(plans[0].message.contains("matched 50 directories"));
800    }
801
802    #[test]
803    fn plan_warnings_keeps_distinct_patterns_separate() {
804        let root = Path::new("/project");
805        let diagnostics = vec![
806            glob_diag(root, "apps/*", "apps/a"),
807            glob_diag(root, "apps/*", "apps/b"),
808            glob_diag(root, "packages/*", "packages/x"),
809            glob_diag(root, "packages/*", "packages/y"),
810        ];
811
812        let plans = plan_warnings(root, &diagnostics);
813
814        assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
815        let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
816        assert!(
817            messages
818                .iter()
819                .any(|m| m.contains("Glob 'apps/*' matched 2")),
820            "{messages:?}"
821        );
822        assert!(
823            messages
824                .iter()
825                .any(|m| m.contains("Glob 'packages/*' matched 2")),
826            "{messages:?}"
827        );
828    }
829
830    #[test]
831    fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
832        let root = Path::new("/project");
833        let diag = glob_diag(root, "packages/*", "packages/scratch");
834
835        let plans = plan_warnings(root, std::slice::from_ref(&diag));
836
837        assert_eq!(plans.len(), 1);
838        assert_eq!(plans[0].message, diag.message);
839        assert!(
840            plans[0]
841                .dedupe_key
842                .contains("::glob-matched-no-package-json::")
843                && plans[0].dedupe_key.ends_with("packages/scratch"),
844            "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
845            plans[0].dedupe_key
846        );
847        assert!(
848            !plans[0].message.contains("directories"),
849            "single match is not aggregated"
850        );
851    }
852
853    #[test]
854    fn plan_warnings_non_glob_kinds_stay_per_instance() {
855        let root = Path::new("/project");
856        let diagnostics = vec![
857            WorkspaceDiagnostic::new(
858                root,
859                root.join("packages/a"),
860                WorkspaceDiagnosticKind::UndeclaredWorkspace,
861            ),
862            WorkspaceDiagnostic::new(
863                root,
864                root.join("packages/b"),
865                WorkspaceDiagnosticKind::MalformedPackageJson {
866                    error: "trailing comma".to_owned(),
867                },
868            ),
869        ];
870
871        let plans = plan_warnings(root, &diagnostics);
872
873        assert_eq!(
874            plans.len(),
875            2,
876            "each non-glob diagnostic plans its own warning"
877        );
878        assert!(
879            plans
880                .iter()
881                .all(|p| !p.message.contains("directories with no package.json"))
882        );
883    }
884
885    fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
886        WorkspaceDiagnostic::new(
887            root,
888            root.join(rel_path),
889            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
890        )
891    }
892
893    #[test]
894    fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
895        let root = Path::new("/project");
896        let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
897            .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
898            .collect();
899
900        let plans = plan_warnings(root, &diagnostics);
901
902        assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
903        assert!(
904            plans[0]
905                .dedupe_key
906                .ends_with("::tsconfig-reference-dir-missing-agg")
907        );
908        assert!(
909            plans[0]
910                .message
911                .starts_with("tsconfig.json references 30 directories that do not exist"),
912            "{}",
913            plans[0].message
914        );
915        assert!(
916            plans[0].message.contains(
917                "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
918                 packages/p02/tsconfig.json, and 27 more)"
919            ),
920            "three sorted examples + tail: {}",
921            plans[0].message
922        );
923        assert!(
924            plans[0]
925                .message
926                .ends_with("Update or remove the references, or restore the missing directories."),
927            "{}",
928            plans[0].message
929        );
930    }
931
932    #[test]
933    fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
934        let root = Path::new("/project");
935        let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
936
937        let plans = plan_warnings(root, std::slice::from_ref(&diag));
938
939        assert_eq!(plans.len(), 1);
940        assert_eq!(
941            plans[0].message, diag.message,
942            "single miss is not aggregated"
943        );
944        assert!(!plans[0].message.contains("directories that do not exist"));
945    }
946
947    #[test]
948    fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
949        let root = Path::new("/project");
950        let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
951            .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
952            .collect();
953        diagnostics.extend(
954            (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
955        );
956
957        let plans = plan_warnings(root, &diagnostics);
958
959        assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
960        assert!(
961            plans
962                .iter()
963                .any(|p| p.message.contains("matched 5 directories"))
964        );
965        assert!(
966            plans
967                .iter()
968                .any(|p| p.message.contains("references 4 directories"))
969        );
970    }
971}