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            WorkspaceDiagnostic::new(
671                root,
672                degradation.path.clone(),
673                WorkspaceDiagnosticKind::SourceParseDegraded {
674                    error_count: degradation.error_count,
675                    panicked: degradation.panicked,
676                },
677            )
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            )
689        });
690        existing.extend(diagnostics.iter().cloned());
691    }
692    emit_diagnostics(root, &diagnostics);
693    diagnostics
694}
695
696/// Replace every source-discovery diagnostic for `root` with `diagnostics` in
697/// ONE registry operation, and hand the same list back to the caller.
698///
699/// Called at the END of each source walk (`discover_files`) so a stale
700/// `skipped-large-file` entry from a previous analysis pass (a watch-mode
701/// rerun after the user raised `--max-file-size` or added the file to
702/// `ignorePatterns`) is dropped while the current walk's skips are written.
703/// Pairs with the preserve in [`stash_workspace_diagnostics`]: this call keeps
704/// the set CURRENT across reruns, the preserve keeps it ALIVE across
705/// combined-mode's per-analysis config re-loads (issue #1086).
706///
707/// The clear-then-append pair this replaces was two separate lock
708/// acquisitions, so a second source walk running concurrently on the same root
709/// (combined mode runs the dead-code and duplication walks under `rayon::join`
710/// whenever a per-analysis `production` split stops them from sharing a file
711/// list) could interleave its clear between this walk's clear and its appends,
712/// or between the appends and the walk's own read-back. Holding the lock across
713/// the whole replacement makes the registry state a clean last-writer-wins, and
714/// returning the list lets each analysis carry exactly what ITS walk skipped
715/// without reading the shared registry back at all (issue #2366).
716///
717/// The retain also drops the parse stage's `source-read-failure` entries,
718/// because [`WorkspaceDiagnosticKind::is_source_discovery`] covers that kind
719/// too, so a concurrent walk on the same root can clear a read failure another
720/// analysis's parse recorded. That window closes on its own:
721/// [`record_source_read_failures`] replaces the read-failure set from each
722/// analysis's own parse, and a fold's closing registry leg reads after both
723/// walks have finished. Narrowing this retain to
724/// [`WorkspaceDiagnosticKind::is_source_walk_recorded`] would leave the
725/// read-failure set to its own recorder entirely.
726#[must_use]
727pub fn replace_source_discovery_diagnostics(
728    root: &Path,
729    diagnostics: Vec<WorkspaceDiagnostic>,
730) -> Vec<WorkspaceDiagnostic> {
731    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
732    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
733    if let Ok(mut map) = registry.lock() {
734        let existing = map.entry(canonical).or_default();
735        existing.retain(|d| !d.kind.is_source_discovery());
736        existing.extend(diagnostics.iter().cloned());
737    }
738    diagnostics
739}
740
741/// Remove all analysis-stage diagnostics (see
742/// [`WorkspaceDiagnosticKind::is_analysis_stage`]) for `root` from the
743/// registry, keeping every workspace-discovery and source-discovery entry.
744///
745/// Called at the START of each dead-code analyze pass so a stale
746/// `malformed-pnpm-workspace-yaml` or `bun-lockb-override-resolution-skipped`
747/// entry from a previous pass (a watch-mode rerun or a long-lived engine
748/// session after the YAML was fixed or a text `bun.lock` was written) is
749/// dropped before the detectors re-record only what still applies. Mirrors
750/// [`replace_source_discovery_diagnostics`] and pairs with the preserve in
751/// [`stash_workspace_diagnostics`] (issue #2366).
752pub fn clear_analysis_stage_diagnostics(root: &Path) {
753    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
754    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
755        return;
756    };
757    if let Ok(mut map) = registry.lock()
758        && let Some(existing) = map.get_mut(&canonical)
759    {
760        existing.retain(|d| !d.kind.is_analysis_stage());
761    }
762}
763
764/// Remove all health-stage diagnostics (see
765/// [`WorkspaceDiagnosticKind::is_health_stage`]) for `root` from the registry,
766/// keeping every other entry.
767///
768/// Called at the START of each health run so a stale `shallow-clone` or
769/// `ownership-unavailable` entry from a previous run over the same root (a
770/// watch-mode rerun, a long-lived engine session, the two passes of `fallow
771/// audit`) is dropped before the pipeline re-records only what still applies.
772/// Mirrors [`clear_analysis_stage_diagnostics`] and pairs with the preserve in
773/// [`stash_workspace_diagnostics`] (issue #2689).
774pub fn clear_health_stage_diagnostics(root: &Path) {
775    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
776    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
777        return;
778    };
779    if let Ok(mut map) = registry.lock()
780        && let Some(existing) = map.get_mut(&canonical)
781    {
782        existing.retain(|d| !d.kind.is_health_stage());
783    }
784}
785
786/// Read only the health-stage diagnostics (see
787/// [`WorkspaceDiagnosticKind::is_health_stage`]) the registry holds for `root`.
788///
789/// The health envelope captures `workspace_diagnostics` before the analysis
790/// runs, so the pipeline's own diagnostics reach it through this read at
791/// finalize time rather than by threading a mutable list through scoring,
792/// churn, ownership, trend and coverage resolution (issue #2689).
793#[must_use]
794pub fn health_stage_workspace_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
795    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
796    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
797        return Vec::new();
798    };
799    registry
800        .lock()
801        .ok()
802        .map(|map| {
803            map.get(&canonical).map_or_else(Vec::new, |existing| {
804                existing
805                    .iter()
806                    .filter(|d| d.kind.is_health_stage())
807                    .cloned()
808                    .collect()
809            })
810        })
811        .unwrap_or_default()
812}
813
814/// Read the workspace-discovery diagnostics produced by the most recent
815/// `stash_workspace_diagnostics` + any subsequent
816/// `append_workspace_diagnostics` calls for `root`. Returns an empty vector
817/// when nothing has been stashed for this root yet (e.g. programmatic
818/// callers bypassing the standard loader).
819#[must_use]
820pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
821    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
822    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
823        return Vec::new();
824    };
825    registry
826        .lock()
827        .ok()
828        .and_then(|map| map.get(&canonical).cloned())
829        .unwrap_or_default()
830}
831
832/// Read the registry leg of a diagnostics FOLD: everything
833/// [`workspace_diagnostics_for`] holds for `root` EXCEPT the entries a source
834/// walk records (see
835/// [`WorkspaceDiagnosticKind::is_source_walk_recorded`]).
836///
837/// A fold combines an analysis's own captured list with the registry. The
838/// analysis already carries its own walk's skips by value, and each walk
839/// replaces the registry's source-discovery set for the root, so an unfiltered
840/// registry read imports ANOTHER walk's file set: under a per-analysis
841/// `production` split the dead-code and duplication walks see different files,
842/// and the read answers whichever walk wrote last. That made the audit family
843/// report a skip its dead-code analysis never saw, disagreeing with the MCP
844/// `audit` tool, and made the order of the combined root's union depend on
845/// which parallel walk won the race (issue #2366).
846///
847/// `source-read-failure` is deliberately still read: the parse stage records
848/// it after the walk, so the registry is the only place it exists. Every
849/// non-walk kind (workspace discovery, analysis stage) is likewise still read,
850/// which is what lets `--skip check` and `--only health` report what their
851/// analyses recorded after the section captured its list.
852///
853/// The result is ordered by `(path, kind id, message)` rather than by arrival.
854/// Analysis-stage detectors record into the registry from a rayon pool, so
855/// arrival order is a scheduling artefact: `boundaries-not-configured` and
856/// `rule-packs-not-configured` swapped places between a one-worker and an
857/// eight-worker run of the same command, on a `required` wire array. Ordering
858/// the registry leg fixes that at the single point every consumer reads it.
859/// The caller's own list keeps its meaningful discovery order; only this leg
860/// is sorted, and `merge_workspace_diagnostics` puts it after that list.
861#[must_use]
862pub fn registry_diagnostics_to_fold(root: &Path) -> Vec<WorkspaceDiagnostic> {
863    let mut diagnostics: Vec<WorkspaceDiagnostic> = workspace_diagnostics_for(root)
864        .into_iter()
865        .filter(|diagnostic| !diagnostic.kind.is_source_walk_recorded())
866        .collect();
867    diagnostics.sort_by(|left, right| {
868        left.path
869            .cmp(&right.path)
870            .then_with(|| left.kind.id().cmp(right.kind.id()))
871            .then_with(|| left.message.cmp(&right.message))
872    });
873    diagnostics
874}
875
876/// Directories that are conventionally NOT workspace packages even when a
877/// glob like `packages/*` matches them. Mirrors pnpm/npm/yarn behavior of
878/// silently filtering these out. Shared by workspace discovery and glob
879/// diagnostics so both exclude hidden directories, build artifacts and tooling
880/// caches.
881#[must_use]
882pub(super) fn is_skip_listed_dir(name: &str) -> bool {
883    name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
884}
885
886/// Test if a project-root-relative directory path is excluded by user
887/// `ignorePatterns`. The directory itself and its `package.json` are both
888/// checked because users variably write `packages/legacy/**` or
889/// `packages/legacy/package.json` in their ignore globs.
890#[must_use]
891pub(super) fn is_ignored_workspace_dir(
892    relative_dir: &Path,
893    ignore_patterns: &crate::IgnorePatternSet,
894) -> bool {
895    if ignore_patterns.is_empty() {
896        return false;
897    }
898    let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
899    ignore_patterns.is_match(relative_str.as_str())
900        || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use fallow_types::discover::FileId;
907    use fallow_types::extract::SourceReadFailure;
908
909    fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
910        WorkspaceDiagnostic::new(
911            root,
912            root.join(rel_path),
913            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
914                pattern: pattern.to_owned(),
915            },
916        )
917    }
918
919    #[test]
920    fn stash_preserves_appended_skipped_large_file_across_restash() {
921        // Unique synthetic root so the process-global registry does not collide
922        // with sibling tests.
923        let root = Path::new("/fallow-test-1086-stash-preserve");
924        let undeclared = || {
925            WorkspaceDiagnostic::new(
926                root,
927                root.join("pkg"),
928                WorkspaceDiagnosticKind::UndeclaredWorkspace,
929            )
930        };
931        // First analysis loads config and stashes the workspace-discovery set.
932        stash_workspace_diagnostics(root, vec![undeclared()]);
933        // Its source discovery appends a skipped-large-file diagnostic.
934        append_workspace_diagnostics(
935            root,
936            vec![WorkspaceDiagnostic::new(
937                root,
938                root.join("vendor/big.js"),
939                WorkspaceDiagnosticKind::SkippedLargeFile {
940                    size_bytes: 9_999_999,
941                },
942            )],
943        );
944        // A sibling analysis (combined-mode dupes/health) re-loads config and
945        // re-stashes the same workspace-discovery set.
946        stash_workspace_diagnostics(root, vec![undeclared()]);
947
948        let after = workspace_diagnostics_for(root);
949        assert_eq!(
950            after
951                .iter()
952                .filter(|d| d.kind.is_source_discovery())
953                .count(),
954            1,
955            "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
956        );
957        assert_eq!(
958            after
959                .iter()
960                .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
961                .count(),
962            1,
963            "the workspace-discovery diagnostic is replaced, not duplicated"
964        );
965    }
966
967    fn analysis_stage_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
968        vec![
969            WorkspaceDiagnostic::new(
970                root,
971                root.join("pnpm-workspace.yaml"),
972                WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
973                    error: "could not find expected ':'".to_owned(),
974                },
975            ),
976            WorkspaceDiagnostic::new(
977                root,
978                root.join("package.json"),
979                WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
980            ),
981        ]
982    }
983
984    fn count_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> usize {
985        diagnostics.iter().filter(|d| d.kind.id() == id).count()
986    }
987
988    #[test]
989    fn stash_preserves_recorded_analysis_stage_diagnostics_across_restash() {
990        let root = Path::new("/fallow-test-2366-stash-preserve");
991        let undeclared = || {
992            WorkspaceDiagnostic::new(
993                root,
994                root.join("pkg"),
995                WorkspaceDiagnosticKind::UndeclaredWorkspace,
996            )
997        };
998        // The check analysis loads config, then its analyze pass records both
999        // analysis-stage kinds.
1000        stash_workspace_diagnostics(root, vec![undeclared()]);
1001        record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1002        // Combined-mode dupes/health re-load config and re-stash the same
1003        // workspace-discovery set before the JSON envelope is built.
1004        stash_workspace_diagnostics(root, vec![undeclared()]);
1005
1006        let after = workspace_diagnostics_for(root);
1007        assert_eq!(
1008            count_kind(&after, "malformed-pnpm-workspace-yaml"),
1009            1,
1010            "malformed-pnpm-workspace-yaml survives the combined-mode re-stash exactly once (#2366): {after:?}"
1011        );
1012        assert_eq!(
1013            count_kind(&after, "bun-lockb-override-resolution-skipped"),
1014            1,
1015            "bun-lockb-override-resolution-skipped survives the combined-mode re-stash exactly once (#2366): {after:?}"
1016        );
1017        assert_eq!(
1018            count_kind(&after, "undeclared-workspace"),
1019            1,
1020            "the workspace-discovery diagnostic is replaced, not duplicated"
1021        );
1022    }
1023
1024    #[test]
1025    fn source_read_failures_replace_only_their_previous_parse_set() {
1026        let root = Path::new("/fallow-test-source-read-replace");
1027        stash_workspace_diagnostics(
1028            root,
1029            vec![WorkspaceDiagnostic::new(
1030                root,
1031                root.join("pkg"),
1032                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1033            )],
1034        );
1035        append_workspace_diagnostics(
1036            root,
1037            vec![WorkspaceDiagnostic::new(
1038                root,
1039                root.join("vendor/big.js"),
1040                WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
1041            )],
1042        );
1043        let first = SourceReadFailure {
1044            file_id: FileId(1),
1045            path: root.join("src/first.ts"),
1046            error: "removed".to_string(),
1047        };
1048        let _ = record_source_read_failures(root, &[first]);
1049        let second = SourceReadFailure {
1050            file_id: FileId(2),
1051            path: root.join("src/second.ts"),
1052            error: "permission denied".to_string(),
1053        };
1054
1055        let _ = record_source_read_failures(root, std::slice::from_ref(&second));
1056
1057        let diagnostics = workspace_diagnostics_for(root);
1058        let source_failures: Vec<_> = diagnostics
1059            .iter()
1060            .filter(|diagnostic| {
1061                matches!(
1062                    diagnostic.kind,
1063                    WorkspaceDiagnosticKind::SourceReadFailure { .. }
1064                )
1065            })
1066            .collect();
1067        assert_eq!(source_failures.len(), 1);
1068        assert_eq!(source_failures[0].path, second.path);
1069        assert!(diagnostics.iter().any(|diagnostic| matches!(
1070            diagnostic.kind,
1071            WorkspaceDiagnosticKind::UndeclaredWorkspace
1072        )));
1073        assert!(diagnostics.iter().any(|diagnostic| matches!(
1074            diagnostic.kind,
1075            WorkspaceDiagnosticKind::SkippedLargeFile { .. }
1076        )));
1077
1078        let _ = record_source_read_failures(root, &[]);
1079        assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
1080            !matches!(
1081                diagnostic.kind,
1082                WorkspaceDiagnosticKind::SourceReadFailure { .. }
1083            )
1084        }));
1085    }
1086
1087    #[test]
1088    fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
1089        let root = Path::new("/fallow-test-1086-clear-stale");
1090        stash_workspace_diagnostics(
1091            root,
1092            vec![WorkspaceDiagnostic::new(
1093                root,
1094                root.join("pkg"),
1095                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1096            )],
1097        );
1098        append_workspace_diagnostics(
1099            root,
1100            vec![WorkspaceDiagnostic::new(
1101                root,
1102                root.join("vendor/big.js"),
1103                WorkspaceDiagnosticKind::SkippedLargeFile {
1104                    size_bytes: 9_999_999,
1105                },
1106            )],
1107        );
1108        // A later walk (the file is no longer skipped) clears the stale entry.
1109        let replaced = replace_source_discovery_diagnostics(root, Vec::new());
1110        assert!(
1111            replaced.is_empty(),
1112            "the walk's own list is what it wrote, not what it removed"
1113        );
1114
1115        let after = workspace_diagnostics_for(root);
1116        assert!(
1117            !after.iter().any(|d| d.kind.is_source_discovery()),
1118            "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
1119        );
1120        assert!(
1121            after
1122                .iter()
1123                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
1124            "the workspace-discovery diagnostic survives the source-discovery clear"
1125        );
1126    }
1127
1128    #[test]
1129    fn clear_analysis_stage_drops_stale_entries_keeps_other_kinds() {
1130        let root = Path::new("/fallow-test-2366-clear-stale");
1131        stash_workspace_diagnostics(
1132            root,
1133            vec![WorkspaceDiagnostic::new(
1134                root,
1135                root.join("pkg"),
1136                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1137            )],
1138        );
1139        append_workspace_diagnostics(
1140            root,
1141            vec![WorkspaceDiagnostic::new(
1142                root,
1143                root.join("vendor/big.js"),
1144                WorkspaceDiagnosticKind::SkippedLargeFile {
1145                    size_bytes: 9_999_999,
1146                },
1147            )],
1148        );
1149        record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
1150        // The next analyze pass (the yaml is fixed, a text bun.lock exists)
1151        // clears the stale entries before re-recording nothing.
1152        clear_analysis_stage_diagnostics(root);
1153
1154        let after = workspace_diagnostics_for(root);
1155        assert!(
1156            !after.iter().any(|d| d.kind.is_analysis_stage()),
1157            "stale analysis-stage entries are dropped on the next analyze pass (#2366): {after:?}"
1158        );
1159        assert_eq!(
1160            count_kind(&after, "undeclared-workspace"),
1161            1,
1162            "the workspace-discovery diagnostic survives the analysis-stage clear"
1163        );
1164        assert_eq!(
1165            count_kind(&after, "skipped-large-file"),
1166            1,
1167            "the source-discovery diagnostic survives the analysis-stage clear"
1168        );
1169    }
1170
1171    /// The health pipeline appends after config load, so combined mode's
1172    /// per-analysis config re-load must preserve its entries. It must also not
1173    /// preserve them past the next health run, or a fixed CODEOWNERS is
1174    /// reported forever (issue #2689).
1175    #[test]
1176    fn health_stage_entries_survive_a_config_reload_and_not_the_next_health_run() {
1177        let root = Path::new("/fallow-test-2689-health-stage");
1178        stash_workspace_diagnostics(
1179            root,
1180            vec![WorkspaceDiagnostic::new(
1181                root,
1182                root.join("pkg"),
1183                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1184            )],
1185        );
1186        append_workspace_diagnostics(
1187            root,
1188            vec![
1189                WorkspaceDiagnostic::new(
1190                    root,
1191                    root.to_path_buf(),
1192                    WorkspaceDiagnosticKind::HotspotsSkipped {
1193                        cause: "not-a-repository".to_owned(),
1194                    },
1195                ),
1196                WorkspaceDiagnostic::new(
1197                    root,
1198                    root.join("coverage/coverage-final.json"),
1199                    WorkspaceDiagnosticKind::CoverageAutoDetected,
1200                ),
1201            ],
1202        );
1203
1204        // Combined mode re-loads config for the next analysis.
1205        stash_workspace_diagnostics(
1206            root,
1207            vec![WorkspaceDiagnostic::new(
1208                root,
1209                root.join("pkg"),
1210                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1211            )],
1212        );
1213        let after_reload = workspace_diagnostics_for(root);
1214        assert_eq!(count_kind(&after_reload, "hotspots-skipped"), 1);
1215        assert_eq!(count_kind(&after_reload, "coverage-auto-detected"), 1);
1216
1217        // The dead-code analyze pass inside the health run must not take them.
1218        clear_analysis_stage_diagnostics(root);
1219        assert_eq!(
1220            health_stage_workspace_diagnostics(root).len(),
1221            2,
1222            "the analysis-stage clear must leave health-stage entries alone"
1223        );
1224
1225        clear_health_stage_diagnostics(root);
1226        let after = workspace_diagnostics_for(root);
1227        assert!(
1228            !after.iter().any(|d| d.kind.is_health_stage()),
1229            "the next health run starts from nothing: {after:?}"
1230        );
1231        assert_eq!(
1232            count_kind(&after, "undeclared-workspace"),
1233            1,
1234            "the workspace-discovery diagnostic survives the health-stage clear"
1235        );
1236    }
1237
1238    fn plugin_diagnostic(root: &Path, key: &str, reason: &str) -> WorkspaceDiagnostic {
1239        WorkspaceDiagnostic::new(
1240            root,
1241            root.join("module-federation.config.ts"),
1242            WorkspaceDiagnosticKind::PluginConfigUnreadable {
1243                plugin: "module-federation".to_owned(),
1244                key: key.to_owned(),
1245                reason: reason.to_owned(),
1246            },
1247        )
1248    }
1249
1250    /// Plugins run after config load, so combined mode's per-analysis config
1251    /// re-load must preserve their entries, and the next plugin run must
1252    /// replace rather than accumulate them so a fixed config drops out
1253    /// (issue #2736).
1254    #[test]
1255    fn plugin_stage_entries_survive_a_config_reload_and_are_replaced_by_the_next_run() {
1256        let root = Path::new("/fallow-test-2736-plugin-stage");
1257        let undeclared = || {
1258            WorkspaceDiagnostic::new(
1259                root,
1260                root.join("pkg"),
1261                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1262            )
1263        };
1264        stash_workspace_diagnostics(root, vec![undeclared()]);
1265        let recorded = record_plugin_config_diagnostics(
1266            root,
1267            vec![plugin_diagnostic(root, "exposes", "not-object-literal")],
1268        );
1269        assert_eq!(recorded.len(), 1, "the caller gets its own copy back");
1270
1271        // Combined mode re-loads config for the next analysis.
1272        stash_workspace_diagnostics(root, vec![undeclared()]);
1273        let after_reload = workspace_diagnostics_for(root);
1274        assert_eq!(
1275            count_kind(&after_reload, "plugin-config-unreadable"),
1276            1,
1277            "the plugin entry survives the combined-mode re-stash exactly once: {after_reload:?}"
1278        );
1279        assert_eq!(count_kind(&after_reload, "undeclared-workspace"), 1);
1280
1281        // The dead-code analyze pass clears its own stage on entry, and plugins
1282        // run inside that pass's prelude.
1283        clear_analysis_stage_diagnostics(root);
1284        assert_eq!(
1285            count_kind(&workspace_diagnostics_for(root), "plugin-config-unreadable"),
1286            1,
1287            "the analysis-stage clear must leave plugin-stage entries alone"
1288        );
1289
1290        // A rerun after the config was fixed reports nothing, and the stale
1291        // entry goes with it.
1292        let _ = record_plugin_config_diagnostics(root, Vec::new());
1293        let after = workspace_diagnostics_for(root);
1294        assert!(
1295            !after.iter().any(|d| d.kind.is_plugin_stage()),
1296            "each plugin run replaces the previous set: {after:?}"
1297        );
1298        assert_eq!(
1299            count_kind(&after, "undeclared-workspace"),
1300            1,
1301            "the workspace-discovery diagnostic survives the plugin replace"
1302        );
1303    }
1304
1305    /// One config file can hold two unreadable keys. They share a kind id and a
1306    /// path, so both the registry write and the stderr dedupe have to key on
1307    /// the payload, or the second one is invisible (issue #2736).
1308    #[test]
1309    fn two_unreadable_keys_in_one_config_are_recorded_and_printed_twice() {
1310        let root = Path::new("/fallow-test-2736-two-keys");
1311        let (_, captured) = capture_workspace_warnings(|| {
1312            record_plugin_config_diagnostics(
1313                root,
1314                vec![
1315                    plugin_diagnostic(root, "exposes", "not-object-literal"),
1316                    plugin_diagnostic(root, "remotes", "spread"),
1317                ],
1318            )
1319        });
1320        assert_eq!(
1321            captured.len(),
1322            2,
1323            "both keys reach the emitter: {captured:?}"
1324        );
1325        let stored = workspace_diagnostics_for(root);
1326        assert_eq!(
1327            count_kind(&stored, "plugin-config-unreadable"),
1328            2,
1329            "both keys are recorded: {stored:?}"
1330        );
1331
1332        let plans = plan_warnings(
1333            root,
1334            &[
1335                plugin_diagnostic(root, "exposes", "not-object-literal"),
1336                plugin_diagnostic(root, "remotes", "spread"),
1337            ],
1338        );
1339        assert_eq!(plans.len(), 2, "two distinct lines are planned: {plans:?}");
1340        assert_ne!(
1341            plans[0].dedupe_key, plans[1].dedupe_key,
1342            "the process-wide dedupe must not swallow the second key: {plans:?}"
1343        );
1344    }
1345
1346    /// The quiet kind reaches the registry and never the stderr plan, so a
1347    /// project whose `nuxt.config` fallow does not model is reported once in
1348    /// the envelope and never warned about again.
1349    #[test]
1350    fn the_not_modeled_kind_is_recorded_without_a_stderr_line() {
1351        let root = Path::new("/fallow-test-2736-not-modeled");
1352        let diagnostic = WorkspaceDiagnostic::new(
1353            root,
1354            root.join("nuxt.config.ts"),
1355            WorkspaceDiagnosticKind::PluginEffectNotModeled {
1356                plugin: "nuxt".to_owned(),
1357                key: "components".to_owned(),
1358                reason: "key-effect-not-modeled".to_owned(),
1359            },
1360        );
1361        let _ = record_plugin_config_diagnostics(root, vec![diagnostic.clone()]);
1362        assert_eq!(
1363            count_kind(
1364                &workspace_diagnostics_for(root),
1365                "plugin-effect-not-modeled"
1366            ),
1367            1
1368        );
1369        assert!(
1370            plan_warnings(root, &[diagnostic]).is_empty(),
1371            "a kind that does not degrade the run plans no stderr line"
1372        );
1373    }
1374
1375    #[test]
1376    fn build_glob_group_message_caps_examples_and_summarises_tail() {
1377        let root = Path::new("/project");
1378        let paths = [
1379            root.join("playground/cli"),
1380            root.join("playground/lib-types"),
1381            root.join("playground/minify"),
1382            root.join("playground/ssr"),
1383            root.join("playground/worker"),
1384        ];
1385        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1386        let message = build_glob_group_message(root, "playground/**", &refs);
1387
1388        assert!(
1389            message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
1390            "count and pattern lead the message: {message}"
1391        );
1392        assert!(
1393            message.contains(
1394                "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
1395            ),
1396            "three sorted examples + tail count: {message}"
1397        );
1398        assert!(
1399            message.ends_with(
1400                "Add a package.json, narrow the pattern, or add them to ignorePatterns."
1401            ),
1402            "next-step hint preserved: {message}"
1403        );
1404        assert!(
1405            !message.contains("playground/ssr"),
1406            "tail example not named: {message}"
1407        );
1408    }
1409
1410    #[test]
1411    fn build_glob_group_message_no_tail_when_at_or_below_cap() {
1412        let root = Path::new("/project");
1413        let paths = [root.join("packages/a"), root.join("packages/b")];
1414        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1415        let message = build_glob_group_message(root, "packages/*", &refs);
1416
1417        assert!(message.contains("matched 2 directories"), "{message}");
1418        assert!(
1419            message.contains("(e.g. packages/a, packages/b)"),
1420            "both examples named, no `and N more`: {message}"
1421        );
1422        assert!(!message.contains("more)"), "no tail clause: {message}");
1423    }
1424
1425    #[test]
1426    fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
1427        let root = Path::new("/project");
1428        let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
1429            .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
1430            .collect();
1431
1432        let plans = plan_warnings(root, &diagnostics);
1433
1434        assert_eq!(
1435            plans.len(),
1436            1,
1437            "50 same-pattern diagnostics collapse to one plan"
1438        );
1439        assert!(
1440            plans[0]
1441                .dedupe_key
1442                .ends_with("::glob-matched-no-package-json-agg::playground/**")
1443        );
1444        assert!(plans[0].message.contains("matched 50 directories"));
1445    }
1446
1447    #[test]
1448    fn plan_warnings_keeps_distinct_patterns_separate() {
1449        let root = Path::new("/project");
1450        let diagnostics = vec![
1451            glob_diag(root, "apps/*", "apps/a"),
1452            glob_diag(root, "apps/*", "apps/b"),
1453            glob_diag(root, "packages/*", "packages/x"),
1454            glob_diag(root, "packages/*", "packages/y"),
1455        ];
1456
1457        let plans = plan_warnings(root, &diagnostics);
1458
1459        assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
1460        let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
1461        assert!(
1462            messages
1463                .iter()
1464                .any(|m| m.contains("Glob 'apps/*' matched 2")),
1465            "{messages:?}"
1466        );
1467        assert!(
1468            messages
1469                .iter()
1470                .any(|m| m.contains("Glob 'packages/*' matched 2")),
1471            "{messages:?}"
1472        );
1473    }
1474
1475    #[test]
1476    fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
1477        let root = Path::new("/project");
1478        let diag = glob_diag(root, "packages/*", "packages/scratch");
1479
1480        let plans = plan_warnings(root, std::slice::from_ref(&diag));
1481
1482        assert_eq!(plans.len(), 1);
1483        assert_eq!(plans[0].message, diag.message);
1484        // The key embeds `diag.path` through a raw `Display`, so the expected
1485        // path segment carries the platform separator: the stored path is
1486        // rebuilt from its components and renders with backslashes on Windows.
1487        // It ends with the rendered message, which is what stands in for the
1488        // payload so two entries sharing a kind and a path stay two lines.
1489        let expected_path = Path::new("packages").join("scratch");
1490        assert!(
1491            plans[0]
1492                .dedupe_key
1493                .contains("::glob-matched-no-package-json::")
1494                && plans[0]
1495                    .dedupe_key
1496                    .contains(&expected_path.display().to_string())
1497                && plans[0].dedupe_key.ends_with(&diag.message),
1498            "per-instance key is `root::kind::path::message`, not the `-agg::pattern` form: {}",
1499            plans[0].dedupe_key
1500        );
1501        assert!(
1502            !plans[0].message.contains("directories"),
1503            "single match is not aggregated"
1504        );
1505    }
1506
1507    #[test]
1508    fn plan_warnings_non_glob_kinds_stay_per_instance() {
1509        let root = Path::new("/project");
1510        let diagnostics = vec![
1511            WorkspaceDiagnostic::new(
1512                root,
1513                root.join("packages/a"),
1514                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1515            ),
1516            WorkspaceDiagnostic::new(
1517                root,
1518                root.join("packages/b"),
1519                WorkspaceDiagnosticKind::MalformedPackageJson {
1520                    error: "trailing comma".to_owned(),
1521                },
1522            ),
1523        ];
1524
1525        let plans = plan_warnings(root, &diagnostics);
1526
1527        assert_eq!(
1528            plans.len(),
1529            2,
1530            "each non-glob diagnostic plans its own warning"
1531        );
1532        assert!(
1533            plans
1534                .iter()
1535                .all(|p| !p.message.contains("directories with no package.json"))
1536        );
1537    }
1538
1539    /// The unconfigured-check kinds fire in the product's DEFAULT state, on
1540    /// every project that never opted into boundaries or rule packs, so a
1541    /// stderr warning for them is permanent noise whose only remedy is to write
1542    /// config to silence a warning about not having written config. They stay
1543    /// in `workspace_diagnostics[]` for a consumer that wants them.
1544    #[test]
1545    fn plan_warnings_drops_the_unconfigured_check_kinds() {
1546        let root = Path::new("/project");
1547        let diagnostics = vec![
1548            WorkspaceDiagnostic::new(
1549                root,
1550                root.to_path_buf(),
1551                WorkspaceDiagnosticKind::BoundariesNotConfigured,
1552            ),
1553            WorkspaceDiagnostic::new(
1554                root,
1555                root.to_path_buf(),
1556                WorkspaceDiagnosticKind::RulePacksNotConfigured,
1557            ),
1558        ];
1559
1560        assert!(
1561            plan_warnings(root, &diagnostics).is_empty(),
1562            "an unconfigured check is not a degraded run and warns nobody"
1563        );
1564    }
1565
1566    /// A missing dependency tree really does change what the analysis can see,
1567    /// so it keeps its stderr line while the unconfigured-check kinds lose
1568    /// theirs, even when both arrive in the same batch.
1569    #[test]
1570    fn plan_warnings_keeps_the_degradation_kinds_alongside_dropped_ones() {
1571        let root = Path::new("/project");
1572        let diagnostics = vec![
1573            WorkspaceDiagnostic::new(
1574                root,
1575                root.to_path_buf(),
1576                WorkspaceDiagnosticKind::BoundariesNotConfigured,
1577            ),
1578            WorkspaceDiagnostic::new(
1579                root,
1580                root.join("node_modules"),
1581                WorkspaceDiagnosticKind::NodeModulesMissing,
1582            ),
1583            WorkspaceDiagnostic::new(
1584                root,
1585                root.to_path_buf(),
1586                WorkspaceDiagnosticKind::RulePacksNotConfigured,
1587            ),
1588        ];
1589
1590        let messages: Vec<String> = plan_warnings(root, &diagnostics)
1591            .into_iter()
1592            .map(|plan| plan.message)
1593            .collect();
1594
1595        assert_eq!(
1596            messages.len(),
1597            1,
1598            "only the degradation warns: {messages:?}"
1599        );
1600        assert!(
1601            messages[0].contains("node_modules"),
1602            "the surviving line is the missing dependency tree: {messages:?}"
1603        );
1604    }
1605
1606    fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
1607        WorkspaceDiagnostic::new(
1608            root,
1609            root.join(rel_path),
1610            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1611        )
1612    }
1613
1614    #[test]
1615    fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
1616        let root = Path::new("/project");
1617        let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
1618            .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
1619            .collect();
1620
1621        let plans = plan_warnings(root, &diagnostics);
1622
1623        assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
1624        assert!(
1625            plans[0]
1626                .dedupe_key
1627                .ends_with("::tsconfig-reference-dir-missing-agg")
1628        );
1629        assert!(
1630            plans[0]
1631                .message
1632                .starts_with("tsconfig.json references 30 directories that do not exist"),
1633            "{}",
1634            plans[0].message
1635        );
1636        assert!(
1637            plans[0].message.contains(
1638                "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1639                 packages/p02/tsconfig.json, and 27 more)"
1640            ),
1641            "three sorted examples + tail: {}",
1642            plans[0].message
1643        );
1644        assert!(
1645            plans[0]
1646                .message
1647                .ends_with("Update or remove the references, or restore the missing directories."),
1648            "{}",
1649            plans[0].message
1650        );
1651    }
1652
1653    #[test]
1654    fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1655        let root = Path::new("/project");
1656        let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1657
1658        let plans = plan_warnings(root, std::slice::from_ref(&diag));
1659
1660        assert_eq!(plans.len(), 1);
1661        assert_eq!(
1662            plans[0].message, diag.message,
1663            "single miss is not aggregated"
1664        );
1665        assert!(!plans[0].message.contains("directories that do not exist"));
1666    }
1667
1668    #[test]
1669    fn plan_warnings_aggregates_missing_pattern_dirs_per_declaring_file() {
1670        let root = Path::new("/project");
1671        let missing = |pattern: &str| {
1672            WorkspaceDiagnostic::new(
1673                root,
1674                root.join("pnpm-workspace.yaml"),
1675                WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
1676                    pattern: pattern.to_owned(),
1677                },
1678            )
1679        };
1680        let diagnostics = vec![
1681            missing("vite"),
1682            missing("vite/packages/*"),
1683            missing("rolldown"),
1684        ];
1685
1686        let plans = plan_warnings(root, &diagnostics);
1687
1688        let [plan] = plans.as_slice() else {
1689            panic!("expected one aggregated warning: {plans:?}");
1690        };
1691        assert!(
1692            plan.message.starts_with(
1693                "'pnpm-workspace.yaml' declares 3 workspace patterns whose directories do not \
1694                 exist ('vite', 'vite/packages/*', 'rolldown')."
1695            ),
1696            "{}",
1697            plan.message
1698        );
1699    }
1700
1701    #[test]
1702    fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1703        let root = Path::new("/project");
1704        let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1705            .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1706            .collect();
1707        diagnostics.extend(
1708            (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1709        );
1710
1711        let plans = plan_warnings(root, &diagnostics);
1712
1713        assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1714        assert!(
1715            plans
1716                .iter()
1717                .any(|p| p.message.contains("matched 5 directories"))
1718        );
1719        assert!(
1720            plans
1721                .iter()
1722                .any(|p| p.message.contains("references 4 directories"))
1723        );
1724    }
1725
1726    /// Issue #2366: the aggregated warning groups the diagnostics it is
1727    /// handed and counts the group, so a list that still holds the duplicate
1728    /// entries of one glob declared in two manifests reports a directory
1729    /// count that does not exist and names one directory twice among its
1730    /// examples. Deduplicating at discovery is what makes the summary true.
1731    #[test]
1732    fn two_manifest_glob_warning_counts_each_directory_once() {
1733        let dir = tempfile::tempdir().expect("create temp dir");
1734        crate::workspace::write_two_manifest_glob_project(dir.path());
1735
1736        let (_, diagnostics) = crate::workspace::discover_workspaces_with_diagnostics(
1737            dir.path(),
1738            &crate::IgnorePatternSet::empty(),
1739        )
1740        .expect("root package.json is valid");
1741
1742        let messages: Vec<String> = plan_warnings(dir.path(), &diagnostics)
1743            .into_iter()
1744            .map(|plan| plan.message)
1745            .collect();
1746
1747        assert_eq!(
1748            messages,
1749            vec![
1750                "Glob 'pkgs/*' matched 2 directories with no package.json \
1751                 (e.g. pkgs/aaa, pkgs/bbb). Add a package.json, narrow the \
1752                 pattern, or add them to ignorePatterns."
1753                    .to_owned()
1754            ],
1755            "the summary names the true directory count and each example once"
1756        );
1757    }
1758}