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. A diagnostic that
6//! reports a DEGRADED run also emits a deduplicated `tracing::warn!` so users
7//! running fallow with default tracing filters see the cause of "fallow doesn't
8//! see my package" or "fallow ate all my memory."
9//! [`WorkspaceDiagnosticKind::warns_on_stderr`] decides which kinds those are;
10//! the rest reach consumers through `workspace_diagnostics[]` only.
11//!
12//! Repeated `GlobMatchedNoPackageJson` diagnostics are aggregated by glob
13//! pattern at emission time so a wide glob matching hundreds of package-less
14//! directories on a large monorepo collapses to one bounded summary line per
15//! pattern instead of one line per directory (issue #637). The structured
16//! `Vec<WorkspaceDiagnostic>` returned to callers stays full; only the stderr
17//! surface is bounded.
18//!
19//! Mirrors the dedupe + capture pattern in
20//! `crates/config/src/config/parsing.rs::warn_on_unknown_rule_keys` (issue
21//! #467).
22
23use std::path::{Path, PathBuf};
24use std::sync::{Mutex, OnceLock};
25
26use rustc_hash::{FxHashMap, FxHashSet};
27
28use fallow_types::ANALYSIS_DIAGNOSTIC_LOG_TARGET;
29use fallow_types::path_util::display_relative;
30pub use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
31
32/// Workspace-discovery failures that prevent analysis from proceeding.
33///
34/// Returned only by `discover_workspaces_with_diagnostics` (in the parent
35/// module) when a root package manifest itself is malformed: without a
36/// parseable root, no workspace patterns can be collected, and analysis output
37/// would be fiction. The CLI surfaces this as exit 2.
38#[derive(Debug, Clone)]
39pub enum WorkspaceLoadError {
40    /// The project root's `package.json` exists but failed to parse.
41    MalformedRootPackageJson {
42        /// Path to the malformed manifest, shown in the diagnostic.
43        path: PathBuf,
44        /// Parser error message, embedded in the diagnostic.
45        error: String,
46    },
47    /// The project root's `deno.json` or `deno.jsonc` exists but failed to parse.
48    MalformedRootDenoConfig {
49        /// Path to the malformed manifest, shown in the diagnostic.
50        path: PathBuf,
51        /// Parser error message, embedded in the diagnostic.
52        error: String,
53    },
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    missing_dir_groups: Vec<(&'a Path, Vec<&'a WorkspaceDiagnostic>)>,
116}
117
118/// Turn a batch of workspace diagnostics into the bounded set of stderr
119/// warnings to emit, collapsing the two kinds that fan out on large monorepos
120/// (issue #637):
121/// - `GlobMatchedNoPackageJson`: aggregated by glob pattern, one summary line
122///   per pattern instead of one line per package-less directory.
123/// - `TsconfigReferenceDirMissing`: aggregated together, one summary line
124///   instead of one per missing `references[]` entry in the root tsconfig.
125/// - `WorkspacePatternDirMissing`: aggregated by declaring file, one summary
126///   line per file instead of one per pattern (`vite`, `vite/packages/*`).
127///
128/// Kinds that [`WorkspaceDiagnosticKind::warns_on_stderr`] answers `false` for
129/// plan no warning at all: they describe a check the user never configured
130/// rather than a degraded run, and belong only in the structured array.
131///
132/// Pure: no tracing, no dedupe-set mutation. A group of exactly one keeps
133/// today's per-instance message byte-for-byte (no regression for the common
134/// single-match case); every other kind plans one per-instance warning. The
135/// returned plan lists non-aggregated diagnostics first (in first-seen order),
136/// then the glob-pattern summaries, then the tsconfig summary; ordering does
137/// not affect correctness since these are independent stderr lines.
138fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
139    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
140    let WarningGroups {
141        mut plans,
142        glob_groups,
143        tsconfig_ref_misses,
144        missing_dir_groups,
145    } = group_warning_diagnostics(diagnostics, &canonical);
146
147    for (pattern, group) in glob_groups {
148        if let [only] = group.as_slice() {
149            plans.push(per_instance_warning(&canonical, only));
150            continue;
151        }
152        let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
153        plans.push(PlannedWarning {
154            dedupe_key: format!(
155                "{}::glob-matched-no-package-json-agg::{pattern}",
156                canonical.display()
157            ),
158            message: build_glob_group_message(root, pattern, &paths),
159        });
160    }
161
162    if let [only] = tsconfig_ref_misses.as_slice() {
163        plans.push(per_instance_warning(&canonical, only));
164    } else if !tsconfig_ref_misses.is_empty() {
165        let paths: Vec<&Path> = tsconfig_ref_misses
166            .iter()
167            .map(|d| d.path.as_path())
168            .collect();
169        plans.push(PlannedWarning {
170            dedupe_key: format!(
171                "{}::tsconfig-reference-dir-missing-agg",
172                canonical.display()
173            ),
174            message: build_tsconfig_refs_message(root, &paths),
175        });
176    }
177
178    for (source, group) in missing_dir_groups {
179        if let [only] = group.as_slice() {
180            plans.push(per_instance_warning(&canonical, only));
181            continue;
182        }
183        plans.push(PlannedWarning {
184            dedupe_key: format!(
185                "{}::workspace-pattern-dir-missing-agg::{}",
186                canonical.display(),
187                source.display()
188            ),
189            message: build_missing_dir_group_message(root, source, &group),
190        });
191    }
192
193    plans
194}
195
196fn group_warning_diagnostics<'a>(
197    diagnostics: &'a [WorkspaceDiagnostic],
198    canonical: &Path,
199) -> WarningGroups<'a> {
200    let mut plans: Vec<PlannedWarning> = Vec::new();
201    let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
202    let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
203    let mut missing_dir_groups: Vec<(&Path, Vec<&WorkspaceDiagnostic>)> = Vec::new();
204    for diag in diagnostics {
205        if !diag.kind.warns_on_stderr() {
206            continue;
207        }
208        match &diag.kind {
209            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
210                match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
211                    Some((_, group)) => group.push(diag),
212                    None => glob_groups.push((pattern.as_str(), vec![diag])),
213                }
214            }
215            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
216            WorkspaceDiagnosticKind::WorkspacePatternDirMissing { .. } => {
217                let source = diag.path.as_path();
218                match missing_dir_groups.iter_mut().find(|(p, _)| *p == source) {
219                    Some((_, group)) => group.push(diag),
220                    None => missing_dir_groups.push((source, vec![diag])),
221                }
222            }
223            _ => plans.push(per_instance_warning(canonical, diag)),
224        }
225    }
226    WarningGroups {
227        plans,
228        glob_groups,
229        tsconfig_ref_misses,
230        missing_dir_groups,
231    }
232}
233
234/// Plan one per-instance warning, keyed on what it PRINTS rather than on the
235/// kind id plus the path.
236///
237/// The dedupe exists so combined mode and watch-mode reruns print one line per
238/// logical diagnostic, and two entries that render different sentences are two
239/// logical diagnostics however much of the key they share. An id-and-path key
240/// swallowed the second of them: one Module Federation config whose `exposes`
241/// AND `remotes` are both unreadable produces two entries on one path under one
242/// kind, and only the first was ever printed (issue #2736). Matching
243/// `merge_workspace_diagnostics`, which keys on the whole kind for the same
244/// reason, the message stands in for the payload here: it is rendered from the
245/// kind and the path, so two entries whose sentences are byte-identical would
246/// print the same line twice and are still one key.
247fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
248    PlannedWarning {
249        dedupe_key: format!(
250            "{}::{}::{}::{}",
251            canonical.display(),
252            diag.kind.id(),
253            diag.path.display(),
254            diag.message
255        ),
256        message: diag.message.clone(),
257    }
258}
259
260/// Emit `tracing::warn!` lines for a batch of workspace diagnostics.
261///
262/// Delegates the partition/aggregation decisions to the pure [`plan_warnings`]
263/// and applies the process-wide dedupe so combined-mode (check + dupes + health
264/// through one loader) and watch-mode reruns warn at most once per logical
265/// diagnostic. The returned/stashed `Vec<WorkspaceDiagnostic>` is unaffected;
266/// only the stderr surface is bounded, so structured JSON consumers still see
267/// every diagnostic.
268pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
269    #[cfg(test)]
270    for diag in diagnostics {
271        capture_diag(diag);
272    }
273
274    for plan in plan_warnings(root, diagnostics) {
275        if should_emit(plan.dedupe_key) {
276            tracing::warn!(target: ANALYSIS_DIAGNOSTIC_LOG_TARGET, "fallow: {}", plan.message);
277        }
278    }
279}
280
281/// Render up to [`GLOB_EXAMPLE_CAP`] project-relative example paths (sorted for
282/// deterministic output) with an "and N more" tail when the count exceeds the
283/// cap. Returns the joined example string and the total path count. Shared by
284/// the aggregated-message builders.
285fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
286    let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
287    examples.sort();
288    let count = examples.len();
289    let shown = examples
290        .iter()
291        .take(GLOB_EXAMPLE_CAP)
292        .cloned()
293        .collect::<Vec<_>>()
294        .join(", ");
295    let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
296    let listed = if remaining > 0 {
297        format!("{shown}, and {remaining} more")
298    } else {
299        shown
300    };
301    (listed, count)
302}
303
304/// Build the aggregated message for a glob pattern that matched `paths`
305/// package-less directories (always called with `paths.len() >= 2`).
306fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
307    let (listed, count) = summarize_examples(root, paths);
308    format!(
309        "Glob '{pattern}' matched {count} directories with no package.json \
310         (e.g. {listed}). Add a package.json, narrow the pattern, or add \
311         them to ignorePatterns."
312    )
313}
314
315/// Build the aggregated message for `paths` `tsconfig.json` `references[]`
316/// entries that point at missing directories (always called with
317/// `paths.len() >= 2`).
318fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
319    let (listed, count) = summarize_examples(root, paths);
320    format!(
321        "tsconfig.json references {count} directories that do not exist \
322         (e.g. {listed}). Update or remove the references, or restore the \
323         missing directories."
324    )
325}
326
327/// Build the aggregated message for the `workspace-pattern-dir-missing`
328/// entries of one declaring file (always called with two or more entries).
329fn build_missing_dir_group_message(
330    root: &Path,
331    source: &Path,
332    group: &[&WorkspaceDiagnostic],
333) -> String {
334    let display = display_relative(root, source);
335    let source = if display.is_empty() || display == "." {
336        "The fallow config `workspaces.patterns`".to_owned()
337    } else {
338        format!("'{display}'")
339    };
340    let patterns: Vec<String> = group
341        .iter()
342        .filter_map(|diag| match &diag.kind {
343            WorkspaceDiagnosticKind::WorkspacePatternDirMissing { pattern } => {
344                Some(format!("'{pattern}'"))
345            }
346            _ => None,
347        })
348        .collect();
349    format!(
350        "{source} declares {count} workspace patterns whose directories do not exist ({list}). \
351         Fallow analyzes no package from them. Unused catalog entries are not reported in \
352         this run, because packages in those directories can use them. Create the \
353         directories before the run, or remove the patterns.",
354        count = patterns.len(),
355        list = patterns.join(", ")
356    )
357}
358
359thread_local! {
360    /// Per-thread capture of workspace diagnostics, for tests that assert
361    /// emission without inspecting tracing output. Parallel test execution
362    /// stays race-free because the buffer is thread-local; production code
363    /// keeps the cell empty so emission goes only to tracing.
364    ///
365    /// Mirrors `parsing::UNKNOWN_RULE_CAPTURE` (issue #467).
366    #[cfg(test)]
367    static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
368        const { std::cell::RefCell::new(None) };
369}
370
371/// Push `diag` into the thread-local capture buffer when one is installed.
372/// No-op when no test has called [`capture_workspace_warnings`] on the current
373/// thread, so production code never allocates. Called once per diagnostic by
374/// [`emit_diagnostics`] before the dedupe gate, so every diagnostic is observed
375/// regardless of whether it was emitted per-instance or aggregated.
376#[cfg(test)]
377fn capture_diag(diag: &WorkspaceDiagnostic) {
378    WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
379        if let Some(buf) = cell.borrow_mut().as_mut() {
380            buf.push(diag.clone());
381        }
382    });
383}
384
385/// Install a thread-local capture buffer and run `body`. Returns the body's
386/// result alongside every diagnostic passed through [`emit_diagnostics`] on the
387/// current thread, in order.
388///
389/// Test-only. Diagnostics captured here also bypass the process-wide dedupe
390/// (so two captures on the same root + kind + path inside one test both
391/// observe the emission).
392#[cfg(test)]
393#[must_use]
394pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
395    WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
396        *cell.borrow_mut() = Some(Vec::new());
397    });
398    let result = body();
399    let findings =
400        WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
401    (result, findings)
402}
403
404/// Process-wide registry of workspace-discovery diagnostics, keyed by
405/// canonical root. Populated by callers that run
406/// [`super::discover_workspaces_with_diagnostics`] and (after config load
407/// completes) by the analysis pipeline's `find_undeclared_workspaces_*`
408/// pass. Consumers (`fallow list --workspaces`, the JSON envelope on
409/// `fallow dead-code / dupes / health`) read via [`workspace_diagnostics_for`].
410///
411/// Canonicalisation matches the dedupe-key canonicalisation in
412/// [`plan_warnings`]: two callers on the same physical root coalesce, and
413/// nested-monorepo callers on different roots stay independent.
414static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
415    OnceLock::new();
416
417/// Replace the workspace-discovery diagnostics for `root` with `diagnostics`,
418/// PRESERVING any source-discovery diagnostics (see
419/// [`WorkspaceDiagnosticKind::is_source_discovery`]) and analysis-stage
420/// diagnostics (see [`WorkspaceDiagnosticKind::is_analysis_stage`]) already
421/// appended for the root.
422///
423/// Called at config-load time after [`super::discover_workspaces_with_diagnostics`]
424/// completes; the analyze pipeline then APPENDS undeclared-workspace and
425/// source-discovery (`skipped-large-file`, `skipped-source-dotdir`, and the
426/// other kinds [`WorkspaceDiagnosticKind::is_source_discovery`] covers)
427/// diagnostics via
428/// [`append_workspace_diagnostics`]. The workspace-discovery set is authoritative
429/// and replaced wholesale (so a fixed `package.json` clears its stale diagnostic
430/// across watch-mode reruns), but source-discovery diagnostics are appended
431/// AFTER this stash, so combined-mode's per-analysis config re-loads would
432/// otherwise wipe a `skipped-large-file` entry that the first analysis's
433/// discovery already recorded (issue #1086). Analysis-stage diagnostics
434/// (`malformed-pnpm-workspace-yaml`, `bun-lockb-override-resolution-skipped`)
435/// are recorded by the analyze pass through [`record_workspace_diagnostics`],
436/// also after this stash, and are preserved for the same reason; each analyze
437/// pass refreshes them through [`clear_analysis_stage_diagnostics`] (issue
438/// #2366). Plugin-stage diagnostics
439/// ([`WorkspaceDiagnosticKind::is_plugin_stage`]) are preserved on the same
440/// grounds: framework plugins run after config load, and
441/// [`record_plugin_config_diagnostics`] refreshes their set in one operation
442/// (issue #2736).
443///
444/// The stored set is deduplicated on the whole `(kind, path)` the way every
445/// fold is: a repository that declares one glob in both `package.json` and
446/// `pnpm-workspace.yaml` produces the same diagnostic twice at config load, and
447/// the standalone envelopes read this registry verbatim (issue #2366).
448pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
449    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
450    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
451    if let Ok(mut map) = registry.lock() {
452        let preserved = map.get(&canonical).map_or_else(Vec::new, |existing| {
453            existing
454                .iter()
455                .filter(|d| {
456                    d.kind.is_source_discovery()
457                        || d.kind.is_analysis_stage()
458                        || d.kind.is_health_stage()
459                        || d.kind.is_plugin_stage()
460                })
461                .cloned()
462                .collect()
463        });
464        map.insert(
465            canonical,
466            fallow_types::workspace::merge_workspace_diagnostics(diagnostics, preserved),
467        );
468    }
469}
470
471/// Append `additions` to the workspace-discovery diagnostics for `root`,
472/// skipping any entry whose `(kind id, canonical path, message)` is already
473/// present.
474///
475/// The message is part of the key because two entries of one kind on one path
476/// can state different facts: one source file can hold a dynamic
477/// `registerRemotes` call and a dynamic `loadRemote` call, and an id-and-path
478/// key kept only the first (issue #2795, the same failure as issue #2736).
479///
480/// Used by the analyze pipeline's undeclared-workspace pass to fold its
481/// findings into the registry without re-emitting diagnostics that the
482/// config-load pass already surfaced (e.g. a directory whose `package.json`
483/// is malformed should NOT also produce a separate "undeclared" diagnostic
484/// alongside the malformed-package-json one).
485pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
486    if additions.is_empty() {
487        return;
488    }
489    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
490    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
491    if let Ok(mut map) = registry.lock() {
492        let existing = map.entry(canonical).or_default();
493        let mut seen: FxHashSet<(String, String, String)> = existing
494            .iter()
495            .map(|d| {
496                (
497                    d.kind.id().to_owned(),
498                    dunce::canonicalize(&d.path)
499                        .unwrap_or_else(|_| d.path.clone())
500                        .display()
501                        .to_string(),
502                    d.message.clone(),
503                )
504            })
505            .collect();
506        for addition in additions {
507            let key = (
508                addition.kind.id().to_owned(),
509                dunce::canonicalize(&addition.path)
510                    .unwrap_or_else(|_| addition.path.clone())
511                    .display()
512                    .to_string(),
513                addition.message.clone(),
514            );
515            if seen.insert(key) {
516                existing.push(addition);
517            }
518        }
519    }
520}
521
522/// Append `diagnostics` to the registry for `root` AND emit their deduplicated
523/// stderr warnings, for analysis-stage callers outside this crate (e.g. the
524/// pnpm catalog/override gathers in `fallow-core`) that surface a diagnostic
525/// after config load completed. [`append_workspace_diagnostics`] alone would
526/// reach `workspace_diagnostics[]` JSON but never warn a human on stderr.
527pub fn record_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
528    if diagnostics.is_empty() {
529        return;
530    }
531    emit_diagnostics(root, &diagnostics);
532    append_workspace_diagnostics(root, diagnostics);
533}
534
535/// Replace the plugin-stage diagnostics for `root` with `diagnostics` in ONE
536/// registry operation, emit their deduplicated stderr warnings, and hand the
537/// same list back to the caller.
538///
539/// Called once per analysis, at the end of the plugin run, which is the single
540/// point where the root and workspace plugin results have converged. The
541/// replacement is what keeps the set CURRENT across reruns, the way
542/// [`replace_source_discovery_diagnostics`] does for a walk: a config the user
543/// fixed drops out on the next run with no separate clear, and a long-lived
544/// engine session or a watch-mode rerun does not accumulate stale entries.
545///
546/// Deliberately NOT [`append_workspace_diagnostics`], whose dedupe key is the
547/// kind id plus the canonical path. One Module Federation config file can hold
548/// two unreadable keys, which is two entries under one kind on one path, and
549/// that key drops the second. This one dedupes the incoming list on the whole
550/// `(kind, path)` the way every other fold does (issue #2736).
551#[must_use]
552pub fn record_plugin_config_diagnostics(
553    root: &Path,
554    diagnostics: Vec<WorkspaceDiagnostic>,
555) -> Vec<WorkspaceDiagnostic> {
556    let diagnostics = fallow_types::workspace::dedupe_workspace_diagnostics(diagnostics);
557    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
558    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
559    if let Ok(mut map) = registry.lock() {
560        let existing = map.entry(canonical).or_default();
561        existing.retain(|diagnostic| !diagnostic.kind.is_plugin_stage());
562        existing.extend(diagnostics.iter().cloned());
563    }
564    emit_diagnostics(root, &diagnostics);
565    diagnostics
566}
567
568/// Replace source-read-failure diagnostics for `root` with the failures from
569/// the current parse while preserving every workspace and discovery diagnostic
570/// produced by other stages.
571///
572/// Returns the structured diagnostics so session-owned outputs can carry the
573/// exact same values as the process registry used by direct core and CLI paths.
574#[must_use]
575pub fn record_source_read_failures(
576    root: &Path,
577    failures: &[fallow_types::extract::SourceReadFailure],
578) -> Vec<WorkspaceDiagnostic> {
579    let diagnostics: Vec<WorkspaceDiagnostic> = failures
580        .iter()
581        .map(|failure| {
582            WorkspaceDiagnostic::new(
583                root,
584                failure.path.clone(),
585                WorkspaceDiagnosticKind::SourceReadFailure {
586                    error: failure.error.clone(),
587                },
588            )
589        })
590        .collect();
591    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
592    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
593    if let Ok(mut map) = registry.lock() {
594        let existing = map.entry(canonical).or_default();
595        existing.retain(|diagnostic| {
596            !matches!(
597                diagnostic.kind,
598                WorkspaceDiagnosticKind::SourceReadFailure { .. }
599            )
600        });
601        existing.extend(diagnostics.iter().cloned());
602    }
603    emit_diagnostics(root, &diagnostics);
604    diagnostics
605}
606
607/// Whether `root` should have a `node_modules` directory and does not.
608///
609/// The single predicate behind [`WorkspaceDiagnosticKind::NodeModulesMissing`].
610/// A Deno project with no `package.json` legitimately runs without one, so it
611/// is not reported.
612#[must_use]
613pub fn node_modules_missing(root: &Path) -> bool {
614    !root.join("node_modules").is_dir() && !super::is_deno_without_node_modules(root)
615}
616
617/// The install state of `root`, by the same rule as [`node_modules_missing`].
618/// A Deno project without `package.json` counts as present.
619#[must_use]
620pub fn node_modules_state(root: &Path) -> fallow_types::workspace::NodeModulesState {
621    if node_modules_missing(root) {
622        fallow_types::workspace::NodeModulesState::Missing
623    } else {
624        fallow_types::workspace::NodeModulesState::Present
625    }
626}
627
628/// Build the missing-`node_modules` diagnostic for `root`, or `None` once the
629/// project has been installed.
630///
631/// Replaces the previous per-pipeline `tracing::warn!`, which existed twice
632/// byte-identically and reached neither JSON output nor `fallow doctor`. The
633/// source walk folds this into its own diagnostic set, so it reaches an
634/// analysis by value like every other walk-recorded kind instead of through a
635/// second registry writer.
636#[must_use]
637pub fn missing_node_modules_diagnostic(root: &Path) -> Option<WorkspaceDiagnostic> {
638    node_modules_missing(root).then(|| {
639        WorkspaceDiagnostic::new(
640            root,
641            root.join("node_modules"),
642            WorkspaceDiagnosticKind::NodeModulesMissing,
643        )
644    })
645}
646
647/// Replace source-parse-degraded diagnostics for `root` with the degradations
648/// from the current parse while preserving every workspace and discovery
649/// diagnostic produced by other stages.
650///
651/// Mirrors [`record_source_read_failures`]: the parse stage owns this kind, so
652/// a fixed file drops out of the set on the next run instead of persisting.
653///
654/// Returns the structured diagnostics so session-owned outputs can carry the
655/// exact same values as the process registry used by direct core and CLI paths.
656#[must_use]
657pub fn record_source_parse_degradations(
658    config: &crate::ResolvedConfig,
659    degradations: &[fallow_types::extract::SourceParseDegradation],
660) -> Vec<WorkspaceDiagnostic> {
661    let root = config.root.as_path();
662    let diagnostics: Vec<WorkspaceDiagnostic> = degradations
663        .iter()
664        .filter(|degradation| {
665            !config
666                .ignore_findings
667                .is_ignored_path(root, &degradation.path)
668        })
669        .map(|degradation| {
670            let kind = degradation.nesting_limit.map_or(
671                WorkspaceDiagnosticKind::SourceParseDegraded {
672                    error_count: degradation.error_count,
673                    panicked: degradation.panicked,
674                },
675                |limit| WorkspaceDiagnosticKind::SourceNestingTooDeep { limit },
676            );
677            WorkspaceDiagnostic::new(root, degradation.path.clone(), kind)
678        })
679        .collect();
680    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
681    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
682    if let Ok(mut map) = registry.lock() {
683        let existing = map.entry(canonical).or_default();
684        existing.retain(|diagnostic| {
685            !matches!(
686                diagnostic.kind,
687                WorkspaceDiagnosticKind::SourceParseDegraded { .. }
688                    | WorkspaceDiagnosticKind::SourceNestingTooDeep { .. }
689            )
690        });
691        existing.extend(diagnostics.iter().cloned());
692    }
693    emit_diagnostics(root, &diagnostics);
694    diagnostics
695}
696
697/// Replace every source-discovery diagnostic for `root` with `diagnostics` in
698/// ONE registry operation, and hand the same list back to the caller.
699///
700/// Called at the END of each source walk (`discover_files`) so a stale
701/// `skipped-large-file` entry from a previous analysis pass (a watch-mode
702/// rerun after the user raised `--max-file-size` or added the file to
703/// `ignorePatterns`) is dropped while the current walk's skips are written.
704/// Pairs with the preserve in [`stash_workspace_diagnostics`]: this call keeps
705/// the set CURRENT across reruns, the preserve keeps it ALIVE across
706/// combined-mode's per-analysis config re-loads (issue #1086).
707///
708/// The clear-then-append pair this replaces was two separate lock
709/// acquisitions, so a second source walk running concurrently on the same root
710/// (combined mode runs the dead-code and duplication walks under `rayon::join`
711/// whenever a per-analysis `production` split stops them from sharing a file
712/// list) could interleave its clear between this walk's clear and its appends,
713/// or between the appends and the walk's own read-back. Holding the lock across
714/// the whole replacement makes the registry state a clean last-writer-wins, and
715/// returning the list lets each analysis carry exactly what ITS walk skipped
716/// without reading the shared registry back at all (issue #2366).
717///
718/// The retain also drops the parse stage's `source-read-failure` entries,
719/// because [`WorkspaceDiagnosticKind::is_source_discovery`] covers that kind
720/// too, so a concurrent walk on the same root can clear a read failure another
721/// analysis's parse recorded. That window closes on its own:
722/// [`record_source_read_failures`] replaces the read-failure set from each
723/// analysis's own parse, and a fold's closing registry leg reads after both
724/// walks have finished. Narrowing this retain to
725/// [`WorkspaceDiagnosticKind::is_source_walk_recorded`] would leave the
726/// read-failure set to its own recorder entirely.
727#[must_use]
728pub fn replace_source_discovery_diagnostics(
729    root: &Path,
730    diagnostics: Vec<WorkspaceDiagnostic>,
731) -> Vec<WorkspaceDiagnostic> {
732    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
733    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
734    if let Ok(mut map) = registry.lock() {
735        let existing = map.entry(canonical).or_default();
736        existing.retain(|d| !d.kind.is_source_discovery());
737        existing.extend(diagnostics.iter().cloned());
738    }
739    diagnostics
740}
741
742/// Remove all analysis-stage diagnostics (see
743/// [`WorkspaceDiagnosticKind::is_analysis_stage`]) for `root` from the
744/// registry, keeping every workspace-discovery and source-discovery entry.
745///
746/// Called at the START of each dead-code analyze pass so a stale
747/// `malformed-pnpm-workspace-yaml` or `bun-lockb-override-resolution-skipped`
748/// entry from a previous pass (a watch-mode rerun or a long-lived engine
749/// session after the YAML was fixed or a text `bun.lock` was written) is
750/// dropped before the detectors re-record only what still applies. Mirrors
751/// [`replace_source_discovery_diagnostics`] and pairs with the preserve in
752/// [`stash_workspace_diagnostics`] (issue #2366).
753pub fn clear_analysis_stage_diagnostics(root: &Path) {
754    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
755    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
756        return;
757    };
758    if let Ok(mut map) = registry.lock()
759        && let Some(existing) = map.get_mut(&canonical)
760    {
761        existing.retain(|d| !d.kind.is_analysis_stage());
762    }
763}
764
765/// Remove all health-stage diagnostics (see
766/// [`WorkspaceDiagnosticKind::is_health_stage`]) for `root` from the registry,
767/// keeping every other entry.
768///
769/// Called at the START of each health run so a stale `shallow-clone` or
770/// `ownership-unavailable` entry from a previous run over the same root (a
771/// watch-mode rerun, a long-lived engine session, the two passes of `fallow
772/// audit`) is dropped before the pipeline re-records only what still applies.
773/// Mirrors [`clear_analysis_stage_diagnostics`] and pairs with the preserve in
774/// [`stash_workspace_diagnostics`] (issue #2689).
775pub fn clear_health_stage_diagnostics(root: &Path) {
776    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
777    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
778        return;
779    };
780    if let Ok(mut map) = registry.lock()
781        && let Some(existing) = map.get_mut(&canonical)
782    {
783        existing.retain(|d| !d.kind.is_health_stage());
784    }
785}
786
787/// Read only the health-stage diagnostics (see
788/// [`WorkspaceDiagnosticKind::is_health_stage`]) the registry holds for `root`.
789///
790/// The health envelope captures `workspace_diagnostics` before the analysis
791/// runs, so the pipeline's own diagnostics reach it through this read at
792/// finalize time rather than by threading a mutable list through scoring,
793/// churn, ownership, trend and coverage resolution (issue #2689).
794#[must_use]
795pub fn health_stage_workspace_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
796    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
797    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
798        return Vec::new();
799    };
800    registry
801        .lock()
802        .ok()
803        .map(|map| {
804            map.get(&canonical).map_or_else(Vec::new, |existing| {
805                existing
806                    .iter()
807                    .filter(|d| d.kind.is_health_stage())
808                    .cloned()
809                    .collect()
810            })
811        })
812        .unwrap_or_default()
813}
814
815/// Read the workspace-discovery diagnostics produced by the most recent
816/// `stash_workspace_diagnostics` + any subsequent
817/// `append_workspace_diagnostics` calls for `root`. Returns an empty vector
818/// when nothing has been stashed for this root yet (e.g. programmatic
819/// callers bypassing the standard loader).
820#[must_use]
821pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
822    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
823    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
824        return Vec::new();
825    };
826    registry
827        .lock()
828        .ok()
829        .and_then(|map| map.get(&canonical).cloned())
830        .unwrap_or_default()
831}
832
833/// Read the registry leg of a diagnostics FOLD: everything
834/// [`workspace_diagnostics_for`] holds for `root` EXCEPT the entries a source
835/// walk records (see
836/// [`WorkspaceDiagnosticKind::is_source_walk_recorded`]).
837///
838/// A fold combines an analysis's own captured list with the registry. The
839/// analysis already carries its own walk's skips by value, and each walk
840/// replaces the registry's source-discovery set for the root, so an unfiltered
841/// registry read imports ANOTHER walk's file set: under a per-analysis
842/// `production` split the dead-code and duplication walks see different files,
843/// and the read answers whichever walk wrote last. That made the audit family
844/// report a skip its dead-code analysis never saw, disagreeing with the MCP
845/// `audit` tool, and made the order of the combined root's union depend on
846/// which parallel walk won the race (issue #2366).
847///
848/// `source-read-failure` is deliberately still read: the parse stage records
849/// it after the walk, so the registry is the only place it exists. Every
850/// non-walk kind (workspace discovery, analysis stage) is likewise still read,
851/// which is what lets `--skip check` and `--only health` report what their
852/// analyses recorded after the section captured its list.
853///
854/// The result is ordered by `(path, kind id, message)` rather than by arrival.
855/// Analysis-stage detectors record into the registry from a rayon pool, so
856/// arrival order is a scheduling artefact: `boundaries-not-configured` and
857/// `rule-packs-not-configured` swapped places between a one-worker and an
858/// eight-worker run of the same command, on a `required` wire array. Ordering
859/// the registry leg fixes that at the single point every consumer reads it.
860/// The caller's own list keeps its meaningful discovery order; only this leg
861/// is sorted, and `merge_workspace_diagnostics` puts it after that list.
862#[must_use]
863pub fn registry_diagnostics_to_fold(root: &Path) -> Vec<WorkspaceDiagnostic> {
864    let mut diagnostics: Vec<WorkspaceDiagnostic> = workspace_diagnostics_for(root)
865        .into_iter()
866        .filter(|diagnostic| !diagnostic.kind.is_source_walk_recorded())
867        .collect();
868    diagnostics.sort_by(|left, right| {
869        left.path
870            .cmp(&right.path)
871            .then_with(|| left.kind.id().cmp(right.kind.id()))
872            .then_with(|| left.message.cmp(&right.message))
873    });
874    diagnostics
875}
876
877/// Directories that are conventionally NOT workspace packages even when a
878/// glob like `packages/*` matches them. Mirrors pnpm/npm/yarn behavior of
879/// silently filtering these out. Shared by workspace discovery and glob
880/// diagnostics so both exclude hidden directories, build artifacts and tooling
881/// caches.
882#[must_use]
883pub(super) fn is_skip_listed_dir(name: &str) -> bool {
884    name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
885}
886
887/// Test if a project-root-relative directory path is excluded by user
888/// `ignorePatterns`. The directory itself and its `package.json` are both
889/// checked because users variably write `packages/legacy/**` or
890/// `packages/legacy/package.json` in their ignore globs.
891#[must_use]
892pub(super) fn is_ignored_workspace_dir(
893    relative_dir: &Path,
894    ignore_patterns: &crate::IgnorePatternSet,
895) -> bool {
896    if ignore_patterns.is_empty() {
897        return false;
898    }
899    let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
900    ignore_patterns.excludes(relative_str.as_str())
901        || ignore_patterns.excludes(format!("{relative_str}/package.json").as_str())
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907    use fallow_types::discover::FileId;
908    use fallow_types::extract::SourceReadFailure;
909
910    fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
911        WorkspaceDiagnostic::new(
912            root,
913            root.join(rel_path),
914            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
915                pattern: pattern.to_owned(),
916            },
917        )
918    }
919
920    #[test]
921    fn stash_preserves_appended_skipped_large_file_across_restash() {
922        // Unique synthetic root so the process-global registry does not collide
923        // with sibling tests.
924        let root = Path::new("/fallow-test-1086-stash-preserve");
925        let undeclared = || {
926            WorkspaceDiagnostic::new(
927                root,
928                root.join("pkg"),
929                WorkspaceDiagnosticKind::UndeclaredWorkspace,
930            )
931        };
932        // First analysis loads config and stashes the workspace-discovery set.
933        stash_workspace_diagnostics(root, vec![undeclared()]);
934        // Its source discovery appends a skipped-large-file diagnostic.
935        append_workspace_diagnostics(
936            root,
937            vec![WorkspaceDiagnostic::new(
938                root,
939                root.join("vendor/big.js"),
940                WorkspaceDiagnosticKind::SkippedLargeFile {
941                    size_bytes: 9_999_999,
942                },
943            )],
944        );
945        // A sibling analysis (combined-mode dupes/health) re-loads config and
946        // re-stashes the same workspace-discovery set.
947        stash_workspace_diagnostics(root, vec![undeclared()]);
948
949        let after = workspace_diagnostics_for(root);
950        assert_eq!(
951            after
952                .iter()
953                .filter(|d| d.kind.is_source_discovery())
954                .count(),
955            1,
956            "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
957        );
958        assert_eq!(
959            after
960                .iter()
961                .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
962                .count(),
963            1,
964            "the workspace-discovery diagnostic is replaced, not duplicated"
965        );
966    }
967
968    fn analysis_stage_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
969        vec![
970            WorkspaceDiagnostic::new(
971                root,
972                root.join("pnpm-workspace.yaml"),
973                WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
974                    error: "could not find expected ':'".to_owned(),
975                },
976            ),
977            WorkspaceDiagnostic::new(
978                root,
979                root.join("package.json"),
980                WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
981            ),
982        ]
983    }
984
985    fn count_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> usize {
986        diagnostics.iter().filter(|d| d.kind.id() == id).count()
987    }
988
989    #[test]
990    fn stash_preserves_recorded_analysis_stage_diagnostics_across_restash() {
991        let root = Path::new("/fallow-test-2366-stash-preserve");
992        let undeclared = || {
993            WorkspaceDiagnostic::new(
994                root,
995                root.join("pkg"),
996                WorkspaceDiagnosticKind::UndeclaredWorkspace,
997            )
998        };
999        // The check analysis loads config, then its analyze pass records both
1000        // analysis-stage kinds.
1001        stash_workspace_diagnostics(root, vec![undeclared()]);
1002        record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1003        // Combined-mode dupes/health re-load config and re-stash the same
1004        // workspace-discovery set before the JSON envelope is built.
1005        stash_workspace_diagnostics(root, vec![undeclared()]);
1006
1007        let after = workspace_diagnostics_for(root);
1008        assert_eq!(
1009            count_kind(&after, "malformed-pnpm-workspace-yaml"),
1010            1,
1011            "malformed-pnpm-workspace-yaml survives the combined-mode re-stash exactly once (#2366): {after:?}"
1012        );
1013        assert_eq!(
1014            count_kind(&after, "bun-lockb-override-resolution-skipped"),
1015            1,
1016            "bun-lockb-override-resolution-skipped survives the combined-mode re-stash exactly once (#2366): {after:?}"
1017        );
1018        assert_eq!(
1019            count_kind(&after, "undeclared-workspace"),
1020            1,
1021            "the workspace-discovery diagnostic is replaced, not duplicated"
1022        );
1023    }
1024
1025    #[test]
1026    fn source_read_failures_replace_only_their_previous_parse_set() {
1027        let root = Path::new("/fallow-test-source-read-replace");
1028        stash_workspace_diagnostics(
1029            root,
1030            vec![WorkspaceDiagnostic::new(
1031                root,
1032                root.join("pkg"),
1033                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1034            )],
1035        );
1036        append_workspace_diagnostics(
1037            root,
1038            vec![WorkspaceDiagnostic::new(
1039                root,
1040                root.join("vendor/big.js"),
1041                WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
1042            )],
1043        );
1044        let first = SourceReadFailure {
1045            file_id: FileId(1),
1046            path: root.join("src/first.ts"),
1047            error: "removed".to_string(),
1048        };
1049        let _ = record_source_read_failures(root, &[first]);
1050        let second = SourceReadFailure {
1051            file_id: FileId(2),
1052            path: root.join("src/second.ts"),
1053            error: "permission denied".to_string(),
1054        };
1055
1056        let _ = record_source_read_failures(root, std::slice::from_ref(&second));
1057
1058        let diagnostics = workspace_diagnostics_for(root);
1059        let source_failures: Vec<_> = diagnostics
1060            .iter()
1061            .filter(|diagnostic| {
1062                matches!(
1063                    diagnostic.kind,
1064                    WorkspaceDiagnosticKind::SourceReadFailure { .. }
1065                )
1066            })
1067            .collect();
1068        assert_eq!(source_failures.len(), 1);
1069        assert_eq!(source_failures[0].path, second.path);
1070        assert!(diagnostics.iter().any(|diagnostic| matches!(
1071            diagnostic.kind,
1072            WorkspaceDiagnosticKind::UndeclaredWorkspace
1073        )));
1074        assert!(diagnostics.iter().any(|diagnostic| matches!(
1075            diagnostic.kind,
1076            WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1077        )));
1078
1079        let _ = record_source_read_failures(root, &[]);
1080        assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
1081            !matches!(
1082                diagnostic.kind,
1083                WorkspaceDiagnosticKind::SourceReadFailure { .. }
1084            )
1085        }));
1086    }
1087
1088    #[test]
1089    fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
1090        let root = Path::new("/fallow-test-1086-clear-stale");
1091        stash_workspace_diagnostics(
1092            root,
1093            vec![WorkspaceDiagnostic::new(
1094                root,
1095                root.join("pkg"),
1096                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1097            )],
1098        );
1099        append_workspace_diagnostics(
1100            root,
1101            vec![WorkspaceDiagnostic::new(
1102                root,
1103                root.join("vendor/big.js"),
1104                WorkspaceDiagnosticKind::SkippedLargeFile {
1105                    size_bytes: 9_999_999,
1106                },
1107            )],
1108        );
1109        // A later walk (the file is no longer skipped) clears the stale entry.
1110        let replaced = replace_source_discovery_diagnostics(root, Vec::new());
1111        assert!(
1112            replaced.is_empty(),
1113            "the walk's own list is what it wrote, not what it removed"
1114        );
1115
1116        let after = workspace_diagnostics_for(root);
1117        assert!(
1118            !after.iter().any(|d| d.kind.is_source_discovery()),
1119            "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
1120        );
1121        assert!(
1122            after
1123                .iter()
1124                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
1125            "the workspace-discovery diagnostic survives the source-discovery clear"
1126        );
1127    }
1128
1129    #[test]
1130    fn clear_analysis_stage_drops_stale_entries_keeps_other_kinds() {
1131        let root = Path::new("/fallow-test-2366-clear-stale");
1132        stash_workspace_diagnostics(
1133            root,
1134            vec![WorkspaceDiagnostic::new(
1135                root,
1136                root.join("pkg"),
1137                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1138            )],
1139        );
1140        append_workspace_diagnostics(
1141            root,
1142            vec![WorkspaceDiagnostic::new(
1143                root,
1144                root.join("vendor/big.js"),
1145                WorkspaceDiagnosticKind::SkippedLargeFile {
1146                    size_bytes: 9_999_999,
1147                },
1148            )],
1149        );
1150        record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1151        // The next analyze pass (the yaml is fixed, a text bun.lock exists)
1152        // clears the stale entries before re-recording nothing.
1153        clear_analysis_stage_diagnostics(root);
1154
1155        let after = workspace_diagnostics_for(root);
1156        assert!(
1157            !after.iter().any(|d| d.kind.is_analysis_stage()),
1158            "stale analysis-stage entries are dropped on the next analyze pass (#2366): {after:?}"
1159        );
1160        assert_eq!(
1161            count_kind(&after, "undeclared-workspace"),
1162            1,
1163            "the workspace-discovery diagnostic survives the analysis-stage clear"
1164        );
1165        assert_eq!(
1166            count_kind(&after, "skipped-large-file"),
1167            1,
1168            "the source-discovery diagnostic survives the analysis-stage clear"
1169        );
1170    }
1171
1172    /// The health pipeline appends after config load, so combined mode's
1173    /// per-analysis config re-load must preserve its entries. It must also not
1174    /// preserve them past the next health run, or a fixed CODEOWNERS is
1175    /// reported forever (issue #2689).
1176    #[test]
1177    fn health_stage_entries_survive_a_config_reload_and_not_the_next_health_run() {
1178        let root = Path::new("/fallow-test-2689-health-stage");
1179        stash_workspace_diagnostics(
1180            root,
1181            vec![WorkspaceDiagnostic::new(
1182                root,
1183                root.join("pkg"),
1184                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1185            )],
1186        );
1187        append_workspace_diagnostics(
1188            root,
1189            vec![
1190                WorkspaceDiagnostic::new(
1191                    root,
1192                    root.to_path_buf(),
1193                    WorkspaceDiagnosticKind::HotspotsSkipped {
1194                        cause: "not-a-repository".to_owned(),
1195                    },
1196                ),
1197                WorkspaceDiagnostic::new(
1198                    root,
1199                    root.join("coverage/coverage-final.json"),
1200                    WorkspaceDiagnosticKind::CoverageAutoDetected,
1201                ),
1202            ],
1203        );
1204
1205        // Combined mode re-loads config for the next analysis.
1206        stash_workspace_diagnostics(
1207            root,
1208            vec![WorkspaceDiagnostic::new(
1209                root,
1210                root.join("pkg"),
1211                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1212            )],
1213        );
1214        let after_reload = workspace_diagnostics_for(root);
1215        assert_eq!(count_kind(&after_reload, "hotspots-skipped"), 1);
1216        assert_eq!(count_kind(&after_reload, "coverage-auto-detected"), 1);
1217
1218        // The dead-code analyze pass inside the health run must not take them.
1219        clear_analysis_stage_diagnostics(root);
1220        assert_eq!(
1221            health_stage_workspace_diagnostics(root).len(),
1222            2,
1223            "the analysis-stage clear must leave health-stage entries alone"
1224        );
1225
1226        clear_health_stage_diagnostics(root);
1227        let after = workspace_diagnostics_for(root);
1228        assert!(
1229            !after.iter().any(|d| d.kind.is_health_stage()),
1230            "the next health run starts from nothing: {after:?}"
1231        );
1232        assert_eq!(
1233            count_kind(&after, "undeclared-workspace"),
1234            1,
1235            "the workspace-discovery diagnostic survives the health-stage clear"
1236        );
1237    }
1238
1239    fn plugin_diagnostic(root: &Path, key: &str, reason: &str) -> WorkspaceDiagnostic {
1240        WorkspaceDiagnostic::new(
1241            root,
1242            root.join("module-federation.config.ts"),
1243            WorkspaceDiagnosticKind::PluginConfigUnreadable {
1244                plugin: "module-federation".to_owned(),
1245                key: key.to_owned(),
1246                reason: reason.to_owned(),
1247            },
1248        )
1249    }
1250
1251    /// Plugins run after config load, so combined mode's per-analysis config
1252    /// re-load must preserve their entries, and the next plugin run must
1253    /// replace rather than accumulate them so a fixed config drops out
1254    /// (issue #2736).
1255    #[test]
1256    fn plugin_stage_entries_survive_a_config_reload_and_are_replaced_by_the_next_run() {
1257        let root = Path::new("/fallow-test-2736-plugin-stage");
1258        let undeclared = || {
1259            WorkspaceDiagnostic::new(
1260                root,
1261                root.join("pkg"),
1262                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1263            )
1264        };
1265        stash_workspace_diagnostics(root, vec![undeclared()]);
1266        let recorded = record_plugin_config_diagnostics(
1267            root,
1268            vec![plugin_diagnostic(root, "exposes", "not-object-literal")],
1269        );
1270        assert_eq!(recorded.len(), 1, "the caller gets its own copy back");
1271
1272        // Combined mode re-loads config for the next analysis.
1273        stash_workspace_diagnostics(root, vec![undeclared()]);
1274        let after_reload = workspace_diagnostics_for(root);
1275        assert_eq!(
1276            count_kind(&after_reload, "plugin-config-unreadable"),
1277            1,
1278            "the plugin entry survives the combined-mode re-stash exactly once: {after_reload:?}"
1279        );
1280        assert_eq!(count_kind(&after_reload, "undeclared-workspace"), 1);
1281
1282        // The dead-code analyze pass clears its own stage on entry, and plugins
1283        // run inside that pass's prelude.
1284        clear_analysis_stage_diagnostics(root);
1285        assert_eq!(
1286            count_kind(&workspace_diagnostics_for(root), "plugin-config-unreadable"),
1287            1,
1288            "the analysis-stage clear must leave plugin-stage entries alone"
1289        );
1290
1291        // A rerun after the config was fixed reports nothing, and the stale
1292        // entry goes with it.
1293        let _ = record_plugin_config_diagnostics(root, Vec::new());
1294        let after = workspace_diagnostics_for(root);
1295        assert!(
1296            !after.iter().any(|d| d.kind.is_plugin_stage()),
1297            "each plugin run replaces the previous set: {after:?}"
1298        );
1299        assert_eq!(
1300            count_kind(&after, "undeclared-workspace"),
1301            1,
1302            "the workspace-discovery diagnostic survives the plugin replace"
1303        );
1304    }
1305
1306    /// One config file can hold two unreadable keys. They share a kind id and a
1307    /// path, so both the registry write and the stderr dedupe have to key on
1308    /// the payload, or the second one is invisible (issue #2736).
1309    #[test]
1310    fn two_unreadable_keys_in_one_config_are_recorded_and_printed_twice() {
1311        let root = Path::new("/fallow-test-2736-two-keys");
1312        let (_, captured) = capture_workspace_warnings(|| {
1313            record_plugin_config_diagnostics(
1314                root,
1315                vec![
1316                    plugin_diagnostic(root, "exposes", "not-object-literal"),
1317                    plugin_diagnostic(root, "remotes", "spread"),
1318                ],
1319            )
1320        });
1321        assert_eq!(
1322            captured.len(),
1323            2,
1324            "both keys reach the emitter: {captured:?}"
1325        );
1326        let stored = workspace_diagnostics_for(root);
1327        assert_eq!(
1328            count_kind(&stored, "plugin-config-unreadable"),
1329            2,
1330            "both keys are recorded: {stored:?}"
1331        );
1332
1333        let plans = plan_warnings(
1334            root,
1335            &[
1336                plugin_diagnostic(root, "exposes", "not-object-literal"),
1337                plugin_diagnostic(root, "remotes", "spread"),
1338            ],
1339        );
1340        assert_eq!(plans.len(), 2, "two distinct lines are planned: {plans:?}");
1341        assert_ne!(
1342            plans[0].dedupe_key, plans[1].dedupe_key,
1343            "the process-wide dedupe must not swallow the second key: {plans:?}"
1344        );
1345    }
1346
1347    /// The quiet kind reaches the registry and never the stderr plan, so a
1348    /// project whose `nuxt.config` fallow does not model is reported once in
1349    /// the envelope and never warned about again.
1350    #[test]
1351    fn the_not_modeled_kind_is_recorded_without_a_stderr_line() {
1352        let root = Path::new("/fallow-test-2736-not-modeled");
1353        let diagnostic = WorkspaceDiagnostic::new(
1354            root,
1355            root.join("nuxt.config.ts"),
1356            WorkspaceDiagnosticKind::PluginEffectNotModeled {
1357                plugin: "nuxt".to_owned(),
1358                key: "components".to_owned(),
1359                reason: "key-effect-not-modeled".to_owned(),
1360            },
1361        );
1362        let _ = record_plugin_config_diagnostics(root, vec![diagnostic.clone()]);
1363        assert_eq!(
1364            count_kind(
1365                &workspace_diagnostics_for(root),
1366                "plugin-effect-not-modeled"
1367            ),
1368            1
1369        );
1370        assert!(
1371            plan_warnings(root, &[diagnostic]).is_empty(),
1372            "a kind that does not degrade the run plans no stderr line"
1373        );
1374    }
1375
1376    #[test]
1377    fn build_glob_group_message_caps_examples_and_summarises_tail() {
1378        let root = Path::new("/project");
1379        let paths = [
1380            root.join("playground/cli"),
1381            root.join("playground/lib-types"),
1382            root.join("playground/minify"),
1383            root.join("playground/ssr"),
1384            root.join("playground/worker"),
1385        ];
1386        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1387        let message = build_glob_group_message(root, "playground/**", &refs);
1388
1389        assert!(
1390            message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
1391            "count and pattern lead the message: {message}"
1392        );
1393        assert!(
1394            message.contains(
1395                "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
1396            ),
1397            "three sorted examples + tail count: {message}"
1398        );
1399        assert!(
1400            message.ends_with(
1401                "Add a package.json, narrow the pattern, or add them to ignorePatterns."
1402            ),
1403            "next-step hint preserved: {message}"
1404        );
1405        assert!(
1406            !message.contains("playground/ssr"),
1407            "tail example not named: {message}"
1408        );
1409    }
1410
1411    #[test]
1412    fn build_glob_group_message_no_tail_when_at_or_below_cap() {
1413        let root = Path::new("/project");
1414        let paths = [root.join("packages/a"), root.join("packages/b")];
1415        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1416        let message = build_glob_group_message(root, "packages/*", &refs);
1417
1418        assert!(message.contains("matched 2 directories"), "{message}");
1419        assert!(
1420            message.contains("(e.g. packages/a, packages/b)"),
1421            "both examples named, no `and N more`: {message}"
1422        );
1423        assert!(!message.contains("more)"), "no tail clause: {message}");
1424    }
1425
1426    #[test]
1427    fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
1428        let root = Path::new("/project");
1429        let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
1430            .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
1431            .collect();
1432
1433        let plans = plan_warnings(root, &diagnostics);
1434
1435        assert_eq!(
1436            plans.len(),
1437            1,
1438            "50 same-pattern diagnostics collapse to one plan"
1439        );
1440        assert!(
1441            plans[0]
1442                .dedupe_key
1443                .ends_with("::glob-matched-no-package-json-agg::playground/**")
1444        );
1445        assert!(plans[0].message.contains("matched 50 directories"));
1446    }
1447
1448    #[test]
1449    fn plan_warnings_keeps_distinct_patterns_separate() {
1450        let root = Path::new("/project");
1451        let diagnostics = vec![
1452            glob_diag(root, "apps/*", "apps/a"),
1453            glob_diag(root, "apps/*", "apps/b"),
1454            glob_diag(root, "packages/*", "packages/x"),
1455            glob_diag(root, "packages/*", "packages/y"),
1456        ];
1457
1458        let plans = plan_warnings(root, &diagnostics);
1459
1460        assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
1461        let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
1462        assert!(
1463            messages
1464                .iter()
1465                .any(|m| m.contains("Glob 'apps/*' matched 2")),
1466            "{messages:?}"
1467        );
1468        assert!(
1469            messages
1470                .iter()
1471                .any(|m| m.contains("Glob 'packages/*' matched 2")),
1472            "{messages:?}"
1473        );
1474    }
1475
1476    #[test]
1477    fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
1478        let root = Path::new("/project");
1479        let diag = glob_diag(root, "packages/*", "packages/scratch");
1480
1481        let plans = plan_warnings(root, std::slice::from_ref(&diag));
1482
1483        assert_eq!(plans.len(), 1);
1484        assert_eq!(plans[0].message, diag.message);
1485        // The key embeds `diag.path` through a raw `Display`, so the expected
1486        // path segment carries the platform separator: the stored path is
1487        // rebuilt from its components and renders with backslashes on Windows.
1488        // It ends with the rendered message, which is what stands in for the
1489        // payload so two entries sharing a kind and a path stay two lines.
1490        let expected_path = Path::new("packages").join("scratch");
1491        assert!(
1492            plans[0]
1493                .dedupe_key
1494                .contains("::glob-matched-no-package-json::")
1495                && plans[0]
1496                    .dedupe_key
1497                    .contains(&expected_path.display().to_string())
1498                && plans[0].dedupe_key.ends_with(&diag.message),
1499            "per-instance key is `root::kind::path::message`, not the `-agg::pattern` form: {}",
1500            plans[0].dedupe_key
1501        );
1502        assert!(
1503            !plans[0].message.contains("directories"),
1504            "single match is not aggregated"
1505        );
1506    }
1507
1508    #[test]
1509    fn plan_warnings_non_glob_kinds_stay_per_instance() {
1510        let root = Path::new("/project");
1511        let diagnostics = vec![
1512            WorkspaceDiagnostic::new(
1513                root,
1514                root.join("packages/a"),
1515                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1516            ),
1517            WorkspaceDiagnostic::new(
1518                root,
1519                root.join("packages/b"),
1520                WorkspaceDiagnosticKind::MalformedPackageJson {
1521                    error: "trailing comma".to_owned(),
1522                },
1523            ),
1524        ];
1525
1526        let plans = plan_warnings(root, &diagnostics);
1527
1528        assert_eq!(
1529            plans.len(),
1530            2,
1531            "each non-glob diagnostic plans its own warning"
1532        );
1533        assert!(
1534            plans
1535                .iter()
1536                .all(|p| !p.message.contains("directories with no package.json"))
1537        );
1538    }
1539
1540    /// The unconfigured-check kinds fire in the product's DEFAULT state, on
1541    /// every project that never opted into boundaries or rule packs, so a
1542    /// stderr warning for them is permanent noise whose only remedy is to write
1543    /// config to silence a warning about not having written config. They stay
1544    /// in `workspace_diagnostics[]` for a consumer that wants them.
1545    #[test]
1546    fn plan_warnings_drops_the_unconfigured_check_kinds() {
1547        let root = Path::new("/project");
1548        let diagnostics = vec![
1549            WorkspaceDiagnostic::new(
1550                root,
1551                root.to_path_buf(),
1552                WorkspaceDiagnosticKind::BoundariesNotConfigured,
1553            ),
1554            WorkspaceDiagnostic::new(
1555                root,
1556                root.to_path_buf(),
1557                WorkspaceDiagnosticKind::RulePacksNotConfigured,
1558            ),
1559        ];
1560
1561        assert!(
1562            plan_warnings(root, &diagnostics).is_empty(),
1563            "an unconfigured check is not a degraded run and warns nobody"
1564        );
1565    }
1566
1567    /// A missing dependency tree really does change what the analysis can see,
1568    /// so it keeps its stderr line while the unconfigured-check kinds lose
1569    /// theirs, even when both arrive in the same batch.
1570    #[test]
1571    fn plan_warnings_keeps_the_degradation_kinds_alongside_dropped_ones() {
1572        let root = Path::new("/project");
1573        let diagnostics = vec![
1574            WorkspaceDiagnostic::new(
1575                root,
1576                root.to_path_buf(),
1577                WorkspaceDiagnosticKind::BoundariesNotConfigured,
1578            ),
1579            WorkspaceDiagnostic::new(
1580                root,
1581                root.join("node_modules"),
1582                WorkspaceDiagnosticKind::NodeModulesMissing,
1583            ),
1584            WorkspaceDiagnostic::new(
1585                root,
1586                root.to_path_buf(),
1587                WorkspaceDiagnosticKind::RulePacksNotConfigured,
1588            ),
1589        ];
1590
1591        let messages: Vec<String> = plan_warnings(root, &diagnostics)
1592            .into_iter()
1593            .map(|plan| plan.message)
1594            .collect();
1595
1596        assert_eq!(
1597            messages.len(),
1598            1,
1599            "only the degradation warns: {messages:?}"
1600        );
1601        assert!(
1602            messages[0].contains("node_modules"),
1603            "the surviving line is the missing dependency tree: {messages:?}"
1604        );
1605    }
1606
1607    fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
1608        WorkspaceDiagnostic::new(
1609            root,
1610            root.join(rel_path),
1611            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1612        )
1613    }
1614
1615    #[test]
1616    fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
1617        let root = Path::new("/project");
1618        let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
1619            .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
1620            .collect();
1621
1622        let plans = plan_warnings(root, &diagnostics);
1623
1624        assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
1625        assert!(
1626            plans[0]
1627                .dedupe_key
1628                .ends_with("::tsconfig-reference-dir-missing-agg")
1629        );
1630        assert!(
1631            plans[0]
1632                .message
1633                .starts_with("tsconfig.json references 30 directories that do not exist"),
1634            "{}",
1635            plans[0].message
1636        );
1637        assert!(
1638            plans[0].message.contains(
1639                "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1640                 packages/p02/tsconfig.json, and 27 more)"
1641            ),
1642            "three sorted examples + tail: {}",
1643            plans[0].message
1644        );
1645        assert!(
1646            plans[0]
1647                .message
1648                .ends_with("Update or remove the references, or restore the missing directories."),
1649            "{}",
1650            plans[0].message
1651        );
1652    }
1653
1654    #[test]
1655    fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1656        let root = Path::new("/project");
1657        let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1658
1659        let plans = plan_warnings(root, std::slice::from_ref(&diag));
1660
1661        assert_eq!(plans.len(), 1);
1662        assert_eq!(
1663            plans[0].message, diag.message,
1664            "single miss is not aggregated"
1665        );
1666        assert!(!plans[0].message.contains("directories that do not exist"));
1667    }
1668
1669    #[test]
1670    fn plan_warnings_aggregates_missing_pattern_dirs_per_declaring_file() {
1671        let root = Path::new("/project");
1672        let missing = |pattern: &str| {
1673            WorkspaceDiagnostic::new(
1674                root,
1675                root.join("pnpm-workspace.yaml"),
1676                WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
1677                    pattern: pattern.to_owned(),
1678                },
1679            )
1680        };
1681        let diagnostics = vec![
1682            missing("vite"),
1683            missing("vite/packages/*"),
1684            missing("rolldown"),
1685        ];
1686
1687        let plans = plan_warnings(root, &diagnostics);
1688
1689        let [plan] = plans.as_slice() else {
1690            panic!("expected one aggregated warning: {plans:?}");
1691        };
1692        assert!(
1693            plan.message.starts_with(
1694                "'pnpm-workspace.yaml' declares 3 workspace patterns whose directories do not \
1695                 exist ('vite', 'vite/packages/*', 'rolldown')."
1696            ),
1697            "{}",
1698            plan.message
1699        );
1700    }
1701
1702    #[test]
1703    fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1704        let root = Path::new("/project");
1705        let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1706            .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1707            .collect();
1708        diagnostics.extend(
1709            (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1710        );
1711
1712        let plans = plan_warnings(root, &diagnostics);
1713
1714        assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1715        assert!(
1716            plans
1717                .iter()
1718                .any(|p| p.message.contains("matched 5 directories"))
1719        );
1720        assert!(
1721            plans
1722                .iter()
1723                .any(|p| p.message.contains("references 4 directories"))
1724        );
1725    }
1726
1727    /// Issue #2366: the aggregated warning groups the diagnostics it is
1728    /// handed and counts the group, so a list that still holds the duplicate
1729    /// entries of one glob declared in two manifests reports a directory
1730    /// count that does not exist and names one directory twice among its
1731    /// examples. Deduplicating at discovery is what makes the summary true.
1732    #[test]
1733    fn two_manifest_glob_warning_counts_each_directory_once() {
1734        let dir = tempfile::tempdir().expect("create temp dir");
1735        crate::workspace::write_two_manifest_glob_project(dir.path());
1736
1737        let (_, diagnostics) = crate::workspace::discover_workspaces_with_diagnostics(
1738            dir.path(),
1739            &crate::IgnorePatternSet::empty(),
1740        )
1741        .expect("root package.json is valid");
1742
1743        let messages: Vec<String> = plan_warnings(dir.path(), &diagnostics)
1744            .into_iter()
1745            .map(|plan| plan.message)
1746            .collect();
1747
1748        assert_eq!(
1749            messages,
1750            vec![
1751                "Glob 'pkgs/*' matched 2 directories with no package.json \
1752                 (e.g. pkgs/aaa, pkgs/bbb). Add a package.json, narrow the \
1753                 pattern, or add them to ignorePatterns."
1754                    .to_owned()
1755            ],
1756            "the summary names the true directory count and each example once"
1757        );
1758    }
1759}