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