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