Skip to main content

fallow_config/workspace/
diagnostics.rs

1//! Workspace and source-discovery diagnostics.
2//!
3//! Surfaces malformed `package.json`, unreachable glob matches, missing
4//! tsconfig references, undeclared workspaces, and source files skipped during
5//! source discovery as typed [`WorkspaceDiagnostic`] values. Each diagnostic
6//! also emits a deduplicated `tracing::warn!` so users running fallow with
7//! default tracing filters see the cause of "fallow doesn't see my package" or
8//! "fallow ate all my memory."
9//!
10//! Repeated `GlobMatchedNoPackageJson` diagnostics are aggregated by glob
11//! pattern at emission time so a wide glob matching hundreds of package-less
12//! directories on a large monorepo collapses to one bounded summary line per
13//! pattern instead of one line per directory (issue #637). The structured
14//! `Vec<WorkspaceDiagnostic>` returned to callers stays full; only the stderr
15//! surface is bounded.
16//!
17//! Mirrors the dedupe + capture pattern in
18//! `crates/config/src/config/parsing.rs::warn_on_unknown_rule_keys` (issue
19//! #467).
20
21use std::path::{Path, PathBuf};
22use std::sync::{Mutex, OnceLock};
23
24use rustc_hash::{FxHashMap, FxHashSet};
25
26pub use fallow_types::workspace::{WorkspaceDiagnostic, WorkspaceDiagnosticKind};
27
28/// Render `path` relative to `root` with forward slashes. Mirrors the private
29/// helper of the same name in `fallow_types::workspace`, kept here for the
30/// aggregated stderr-message builders ([`build_glob_group_message`] and
31/// [`build_tsconfig_refs_message`]) so the per-instance and aggregated message
32/// surfaces format paths identically (the forward-slash normalisation is
33/// load-bearing for cross-platform output stability).
34fn display_relative(root: &Path, path: &Path) -> String {
35    path.strip_prefix(root)
36        .unwrap_or(path)
37        .display()
38        .to_string()
39        .replace('\\', "/")
40}
41
42/// Workspace-discovery failures that prevent analysis from proceeding.
43///
44/// Returned only by `discover_workspaces_with_diagnostics` (in the parent
45/// module) when a root package manifest itself is malformed: without a
46/// parseable root, no workspace patterns can be collected, and analysis output
47/// would be fiction. The CLI surfaces this as exit 2.
48#[derive(Debug, Clone)]
49pub enum WorkspaceLoadError {
50    /// The project root's `package.json` exists but failed to parse.
51    MalformedRootPackageJson {
52        /// Path to the malformed manifest, shown in the diagnostic.
53        path: PathBuf,
54        /// Parser error message, embedded in the diagnostic.
55        error: String,
56    },
57    /// The project root's `deno.json` or `deno.jsonc` exists but failed to parse.
58    MalformedRootDenoConfig {
59        /// Path to the malformed manifest, shown in the diagnostic.
60        path: PathBuf,
61        /// Parser error message, embedded in the diagnostic.
62        error: String,
63    },
64}
65
66impl std::fmt::Display for WorkspaceLoadError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::MalformedRootPackageJson { path, error } => write!(
70                f,
71                "root package.json at '{}' is not valid JSON ({error}). \
72                 Fix the syntax before re-running fallow.",
73                path.display()
74            ),
75            Self::MalformedRootDenoConfig { path, error } => write!(
76                f,
77                "root Deno config at '{}' is not valid JSONC ({error}). \
78                 Fix the syntax before re-running fallow.",
79                path.display()
80            ),
81        }
82    }
83}
84
85impl std::error::Error for WorkspaceLoadError {}
86
87/// Maximum number of example directories named in an aggregated
88/// `GlobMatchedNoPackageJson` warning before the tail is summarised as
89/// "and N more". Keeps a fanned-out glob to one bounded stderr line.
90const GLOB_EXAMPLE_CAP: usize = 3;
91
92/// Process-wide set of already-emitted diagnostic dedupe keys. Per-instance
93/// keys (`root::kind::path`) and aggregated per-pattern keys
94/// (`root::glob-matched-no-package-json-agg::pattern`) share one set so
95/// combined-mode (check + dupes + health through one loader) and watch-mode
96/// reruns warn at most once per logical diagnostic. The two key namespaces are
97/// disjoint, so there is no cross-talk.
98fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
99    static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
100    WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
101}
102
103/// Insert `key` and return `true` when it was newly inserted (caller should
104/// emit). On a poisoned mutex returns `true` so over-warning beats swallowing
105/// a typo. Mirrors `parsing::warn_on_unknown_rule_keys` and
106/// `plugins::registry::should_warn`.
107fn should_emit(key: String) -> bool {
108    warned_keys().lock().map_or(true, |mut set| set.insert(key))
109}
110
111/// A single planned stderr warning: its process-dedupe key and the rendered
112/// message. The pure output of [`plan_warnings`] so the partition/aggregation
113/// logic is unit-testable without a tracing subscriber or the process-wide
114/// dedupe set.
115#[derive(Debug, PartialEq, Eq)]
116struct PlannedWarning {
117    dedupe_key: String,
118    message: String,
119}
120
121struct WarningGroups<'a> {
122    plans: Vec<PlannedWarning>,
123    glob_groups: Vec<(&'a str, Vec<&'a WorkspaceDiagnostic>)>,
124    tsconfig_ref_misses: Vec<&'a WorkspaceDiagnostic>,
125}
126
127/// Turn a batch of workspace diagnostics into the bounded set of stderr
128/// warnings to emit, collapsing the two kinds that fan out on large monorepos
129/// (issue #637):
130/// - `GlobMatchedNoPackageJson`: aggregated by glob pattern, one summary line
131///   per pattern instead of one line per package-less directory.
132/// - `TsconfigReferenceDirMissing`: aggregated together, one summary line
133///   instead of one per missing `references[]` entry in the root tsconfig.
134///
135/// Pure: no tracing, no dedupe-set mutation. A group of exactly one keeps
136/// today's per-instance message byte-for-byte (no regression for the common
137/// single-match case); every other kind plans one per-instance warning. The
138/// returned plan lists non-aggregated diagnostics first (in first-seen order),
139/// then the glob-pattern summaries, then the tsconfig summary; ordering does
140/// not affect correctness since these are independent stderr lines.
141fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
142    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
143    let WarningGroups {
144        mut plans,
145        glob_groups,
146        tsconfig_ref_misses,
147    } = group_warning_diagnostics(diagnostics, &canonical);
148
149    for (pattern, group) in glob_groups {
150        if let [only] = group.as_slice() {
151            plans.push(per_instance_warning(&canonical, only));
152            continue;
153        }
154        let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
155        plans.push(PlannedWarning {
156            dedupe_key: format!(
157                "{}::glob-matched-no-package-json-agg::{pattern}",
158                canonical.display()
159            ),
160            message: build_glob_group_message(root, pattern, &paths),
161        });
162    }
163
164    if let [only] = tsconfig_ref_misses.as_slice() {
165        plans.push(per_instance_warning(&canonical, only));
166    } else if !tsconfig_ref_misses.is_empty() {
167        let paths: Vec<&Path> = tsconfig_ref_misses
168            .iter()
169            .map(|d| d.path.as_path())
170            .collect();
171        plans.push(PlannedWarning {
172            dedupe_key: format!(
173                "{}::tsconfig-reference-dir-missing-agg",
174                canonical.display()
175            ),
176            message: build_tsconfig_refs_message(root, &paths),
177        });
178    }
179
180    plans
181}
182
183fn group_warning_diagnostics<'a>(
184    diagnostics: &'a [WorkspaceDiagnostic],
185    canonical: &Path,
186) -> WarningGroups<'a> {
187    let mut plans: Vec<PlannedWarning> = Vec::new();
188    let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
189    let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
190    for diag in diagnostics {
191        match &diag.kind {
192            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
193                match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
194                    Some((_, group)) => group.push(diag),
195                    None => glob_groups.push((pattern.as_str(), vec![diag])),
196                }
197            }
198            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
199            _ => plans.push(per_instance_warning(canonical, diag)),
200        }
201    }
202    WarningGroups {
203        plans,
204        glob_groups,
205        tsconfig_ref_misses,
206    }
207}
208
209fn per_instance_warning(canonical: &Path, diag: &WorkspaceDiagnostic) -> PlannedWarning {
210    PlannedWarning {
211        dedupe_key: format!(
212            "{}::{}::{}",
213            canonical.display(),
214            diag.kind.id(),
215            diag.path.display()
216        ),
217        message: diag.message.clone(),
218    }
219}
220
221/// Emit `tracing::warn!` lines for a batch of workspace diagnostics.
222///
223/// Delegates the partition/aggregation decisions to the pure [`plan_warnings`]
224/// and applies the process-wide dedupe so combined-mode (check + dupes + health
225/// through one loader) and watch-mode reruns warn at most once per logical
226/// diagnostic. The returned/stashed `Vec<WorkspaceDiagnostic>` is unaffected;
227/// only the stderr surface is bounded, so structured JSON consumers still see
228/// every diagnostic.
229pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
230    #[cfg(test)]
231    for diag in diagnostics {
232        capture_diag(diag);
233    }
234
235    for plan in plan_warnings(root, diagnostics) {
236        if should_emit(plan.dedupe_key) {
237            tracing::warn!("fallow: {}", plan.message);
238        }
239    }
240}
241
242/// Render up to [`GLOB_EXAMPLE_CAP`] project-relative example paths (sorted for
243/// deterministic output) with an "and N more" tail when the count exceeds the
244/// cap. Returns the joined example string and the total path count. Shared by
245/// the aggregated-message builders.
246fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
247    let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
248    examples.sort();
249    let count = examples.len();
250    let shown = examples
251        .iter()
252        .take(GLOB_EXAMPLE_CAP)
253        .cloned()
254        .collect::<Vec<_>>()
255        .join(", ");
256    let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
257    let listed = if remaining > 0 {
258        format!("{shown}, and {remaining} more")
259    } else {
260        shown
261    };
262    (listed, count)
263}
264
265/// Build the aggregated message for a glob pattern that matched `paths`
266/// package-less directories (always called with `paths.len() >= 2`).
267fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
268    let (listed, count) = summarize_examples(root, paths);
269    format!(
270        "Glob '{pattern}' matched {count} directories with no package.json \
271         (e.g. {listed}). Add a package.json, narrow the pattern, or add \
272         them to ignorePatterns."
273    )
274}
275
276/// Build the aggregated message for `paths` `tsconfig.json` `references[]`
277/// entries that point at missing directories (always called with
278/// `paths.len() >= 2`).
279fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
280    let (listed, count) = summarize_examples(root, paths);
281    format!(
282        "tsconfig.json references {count} directories that do not exist \
283         (e.g. {listed}). Update or remove the references, or restore the \
284         missing directories."
285    )
286}
287
288thread_local! {
289    /// Per-thread capture of workspace diagnostics, for tests that assert
290    /// emission without inspecting tracing output. Parallel test execution
291    /// stays race-free because the buffer is thread-local; production code
292    /// keeps the cell empty so emission goes only to tracing.
293    ///
294    /// Mirrors `parsing::UNKNOWN_RULE_CAPTURE` (issue #467).
295    #[cfg(test)]
296    static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
297        const { std::cell::RefCell::new(None) };
298}
299
300/// Push `diag` into the thread-local capture buffer when one is installed.
301/// No-op when no test has called [`capture_workspace_warnings`] on the current
302/// thread, so production code never allocates. Called once per diagnostic by
303/// [`emit_diagnostics`] before the dedupe gate, so every diagnostic is observed
304/// regardless of whether it was emitted per-instance or aggregated.
305#[cfg(test)]
306fn capture_diag(diag: &WorkspaceDiagnostic) {
307    WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
308        if let Some(buf) = cell.borrow_mut().as_mut() {
309            buf.push(diag.clone());
310        }
311    });
312}
313
314/// Install a thread-local capture buffer and run `body`. Returns the body's
315/// result alongside every diagnostic passed through [`emit_diagnostics`] on the
316/// current thread, in order.
317///
318/// Test-only. Diagnostics captured here also bypass the process-wide dedupe
319/// (so two captures on the same root + kind + path inside one test both
320/// observe the emission).
321#[cfg(test)]
322#[must_use]
323pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
324    WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
325        *cell.borrow_mut() = Some(Vec::new());
326    });
327    let result = body();
328    let findings =
329        WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
330    (result, findings)
331}
332
333/// Process-wide registry of workspace-discovery diagnostics, keyed by
334/// canonical root. Populated by callers that run
335/// [`super::discover_workspaces_with_diagnostics`] and (after config load
336/// completes) by the analysis pipeline's `find_undeclared_workspaces_*`
337/// pass. Consumers (`fallow list --workspaces`, the JSON envelope on
338/// `fallow dead-code / dupes / health`) read via [`workspace_diagnostics_for`].
339///
340/// Canonicalisation matches the dedupe-key canonicalisation in
341/// [`plan_warnings`]: two callers on the same physical root coalesce, and
342/// nested-monorepo callers on different roots stay independent.
343static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
344    OnceLock::new();
345
346/// Replace the workspace-discovery diagnostics for `root` with `diagnostics`,
347/// PRESERVING any source-discovery diagnostics (see
348/// [`WorkspaceDiagnosticKind::is_source_discovery`]) and analysis-stage
349/// diagnostics (see [`WorkspaceDiagnosticKind::is_analysis_stage`]) already
350/// appended for the root.
351///
352/// Called at config-load time after [`super::discover_workspaces_with_diagnostics`]
353/// completes; the analyze pipeline then APPENDS undeclared-workspace and
354/// source-discovery (`skipped-large-file`, `skipped-source-dotdir`, and the
355/// other kinds [`WorkspaceDiagnosticKind::is_source_discovery`] covers)
356/// diagnostics via
357/// [`append_workspace_diagnostics`]. The workspace-discovery set is authoritative
358/// and replaced wholesale (so a fixed `package.json` clears its stale diagnostic
359/// across watch-mode reruns), but source-discovery diagnostics are appended
360/// AFTER this stash, so combined-mode's per-analysis config re-loads would
361/// otherwise wipe a `skipped-large-file` entry that the first analysis's
362/// discovery already recorded (issue #1086). Analysis-stage diagnostics
363/// (`malformed-pnpm-workspace-yaml`, `bun-lockb-override-resolution-skipped`)
364/// are recorded by the analyze pass through [`record_workspace_diagnostics`],
365/// also after this stash, and are preserved for the same reason; each analyze
366/// pass refreshes them through [`clear_analysis_stage_diagnostics`] (issue
367/// #2366).
368///
369/// The stored set is deduplicated on the whole `(kind, path)` the way every
370/// fold is: a repository that declares one glob in both `package.json` and
371/// `pnpm-workspace.yaml` produces the same diagnostic twice at config load, and
372/// the standalone envelopes read this registry verbatim (issue #2366).
373pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
374    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
375    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
376    if let Ok(mut map) = registry.lock() {
377        let preserved = map.get(&canonical).map_or_else(Vec::new, |existing| {
378            existing
379                .iter()
380                .filter(|d| d.kind.is_source_discovery() || d.kind.is_analysis_stage())
381                .cloned()
382                .collect()
383        });
384        map.insert(
385            canonical,
386            fallow_types::workspace::merge_workspace_diagnostics(diagnostics, preserved),
387        );
388    }
389}
390
391/// Append `additions` to the workspace-discovery diagnostics for `root`,
392/// skipping any entry whose `(kind id, canonical path)` is already present.
393///
394/// Used by the analyze pipeline's undeclared-workspace pass to fold its
395/// findings into the registry without re-emitting diagnostics that the
396/// config-load pass already surfaced (e.g. a directory whose `package.json`
397/// is malformed should NOT also produce a separate "undeclared" diagnostic
398/// alongside the malformed-package-json one).
399pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
400    if additions.is_empty() {
401        return;
402    }
403    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
404    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
405    if let Ok(mut map) = registry.lock() {
406        let existing = map.entry(canonical).or_default();
407        let mut seen: FxHashSet<(String, String)> = existing
408            .iter()
409            .map(|d| {
410                (
411                    d.kind.id().to_owned(),
412                    dunce::canonicalize(&d.path)
413                        .unwrap_or_else(|_| d.path.clone())
414                        .display()
415                        .to_string(),
416                )
417            })
418            .collect();
419        for addition in additions {
420            let key = (
421                addition.kind.id().to_owned(),
422                dunce::canonicalize(&addition.path)
423                    .unwrap_or_else(|_| addition.path.clone())
424                    .display()
425                    .to_string(),
426            );
427            if seen.insert(key) {
428                existing.push(addition);
429            }
430        }
431    }
432}
433
434/// Append `diagnostics` to the registry for `root` AND emit their deduplicated
435/// stderr warnings, for analysis-stage callers outside this crate (e.g. the
436/// pnpm catalog/override gathers in `fallow-core`) that surface a diagnostic
437/// after config load completed. [`append_workspace_diagnostics`] alone would
438/// reach `workspace_diagnostics[]` JSON but never warn a human on stderr.
439pub fn record_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
440    if diagnostics.is_empty() {
441        return;
442    }
443    emit_diagnostics(root, &diagnostics);
444    append_workspace_diagnostics(root, diagnostics);
445}
446
447/// Replace source-read-failure diagnostics for `root` with the failures from
448/// the current parse while preserving every workspace and discovery diagnostic
449/// produced by other stages.
450///
451/// Returns the structured diagnostics so session-owned outputs can carry the
452/// exact same values as the process registry used by direct core and CLI paths.
453#[must_use]
454pub fn record_source_read_failures(
455    root: &Path,
456    failures: &[fallow_types::extract::SourceReadFailure],
457) -> Vec<WorkspaceDiagnostic> {
458    let diagnostics: Vec<WorkspaceDiagnostic> = failures
459        .iter()
460        .map(|failure| {
461            WorkspaceDiagnostic::new(
462                root,
463                failure.path.clone(),
464                WorkspaceDiagnosticKind::SourceReadFailure {
465                    error: failure.error.clone(),
466                },
467            )
468        })
469        .collect();
470    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
471    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
472    if let Ok(mut map) = registry.lock() {
473        let existing = map.entry(canonical).or_default();
474        existing.retain(|diagnostic| {
475            !matches!(
476                diagnostic.kind,
477                WorkspaceDiagnosticKind::SourceReadFailure { .. }
478            )
479        });
480        existing.extend(diagnostics.iter().cloned());
481    }
482    emit_diagnostics(root, &diagnostics);
483    diagnostics
484}
485
486/// Replace every source-discovery diagnostic for `root` with `diagnostics` in
487/// ONE registry operation, and hand the same list back to the caller.
488///
489/// Called at the END of each source walk (`discover_files`) so a stale
490/// `skipped-large-file` entry from a previous analysis pass (a watch-mode
491/// rerun after the user raised `--max-file-size` or added the file to
492/// `ignorePatterns`) is dropped while the current walk's skips are written.
493/// Pairs with the preserve in [`stash_workspace_diagnostics`]: this call keeps
494/// the set CURRENT across reruns, the preserve keeps it ALIVE across
495/// combined-mode's per-analysis config re-loads (issue #1086).
496///
497/// The clear-then-append pair this replaces was two separate lock
498/// acquisitions, so a second source walk running concurrently on the same root
499/// (combined mode runs the dead-code and duplication walks under `rayon::join`
500/// whenever a per-analysis `production` split stops them from sharing a file
501/// list) could interleave its clear between this walk's clear and its appends,
502/// or between the appends and the walk's own read-back. Holding the lock across
503/// the whole replacement makes the registry state a clean last-writer-wins, and
504/// returning the list lets each analysis carry exactly what ITS walk skipped
505/// without reading the shared registry back at all (issue #2366).
506///
507/// The retain also drops the parse stage's `source-read-failure` entries,
508/// because [`WorkspaceDiagnosticKind::is_source_discovery`] covers that kind
509/// too, so a concurrent walk on the same root can clear a read failure another
510/// analysis's parse recorded. That window closes on its own:
511/// [`record_source_read_failures`] replaces the read-failure set from each
512/// analysis's own parse, and a fold's closing registry leg reads after both
513/// walks have finished. Narrowing this retain to
514/// [`WorkspaceDiagnosticKind::is_source_walk_recorded`] would leave the
515/// read-failure set to its own recorder entirely.
516#[must_use]
517pub fn replace_source_discovery_diagnostics(
518    root: &Path,
519    diagnostics: Vec<WorkspaceDiagnostic>,
520) -> Vec<WorkspaceDiagnostic> {
521    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
522    let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
523    if let Ok(mut map) = registry.lock() {
524        let existing = map.entry(canonical).or_default();
525        existing.retain(|d| !d.kind.is_source_discovery());
526        existing.extend(diagnostics.iter().cloned());
527    }
528    diagnostics
529}
530
531/// Remove all analysis-stage diagnostics (see
532/// [`WorkspaceDiagnosticKind::is_analysis_stage`]) for `root` from the
533/// registry, keeping every workspace-discovery and source-discovery entry.
534///
535/// Called at the START of each dead-code analyze pass so a stale
536/// `malformed-pnpm-workspace-yaml` or `bun-lockb-override-resolution-skipped`
537/// entry from a previous pass (a watch-mode rerun or a long-lived engine
538/// session after the YAML was fixed or a text `bun.lock` was written) is
539/// dropped before the detectors re-record only what still applies. Mirrors
540/// [`replace_source_discovery_diagnostics`] and pairs with the preserve in
541/// [`stash_workspace_diagnostics`] (issue #2366).
542pub fn clear_analysis_stage_diagnostics(root: &Path) {
543    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
544    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
545        return;
546    };
547    if let Ok(mut map) = registry.lock()
548        && let Some(existing) = map.get_mut(&canonical)
549    {
550        existing.retain(|d| !d.kind.is_analysis_stage());
551    }
552}
553
554/// Read the workspace-discovery diagnostics produced by the most recent
555/// `stash_workspace_diagnostics` + any subsequent
556/// `append_workspace_diagnostics` calls for `root`. Returns an empty vector
557/// when nothing has been stashed for this root yet (e.g. programmatic
558/// callers bypassing the standard loader).
559#[must_use]
560pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
561    let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
562    let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
563        return Vec::new();
564    };
565    registry
566        .lock()
567        .ok()
568        .and_then(|map| map.get(&canonical).cloned())
569        .unwrap_or_default()
570}
571
572/// Read the registry leg of a diagnostics FOLD: everything
573/// [`workspace_diagnostics_for`] holds for `root` EXCEPT the entries a source
574/// walk records (see
575/// [`WorkspaceDiagnosticKind::is_source_walk_recorded`]).
576///
577/// A fold combines an analysis's own captured list with the registry. The
578/// analysis already carries its own walk's skips by value, and each walk
579/// replaces the registry's source-discovery set for the root, so an unfiltered
580/// registry read imports ANOTHER walk's file set: under a per-analysis
581/// `production` split the dead-code and duplication walks see different files,
582/// and the read answers whichever walk wrote last. That made the audit family
583/// report a skip its dead-code analysis never saw, disagreeing with the MCP
584/// `audit` tool, and made the order of the combined root's union depend on
585/// which parallel walk won the race (issue #2366).
586///
587/// `source-read-failure` is deliberately still read: the parse stage records
588/// it after the walk, so the registry is the only place it exists. Every
589/// non-walk kind (workspace discovery, analysis stage) is likewise still read,
590/// which is what lets `--skip check` and `--only health` report what their
591/// analyses recorded after the section captured its list.
592#[must_use]
593pub fn registry_diagnostics_to_fold(root: &Path) -> Vec<WorkspaceDiagnostic> {
594    workspace_diagnostics_for(root)
595        .into_iter()
596        .filter(|diagnostic| !diagnostic.kind.is_source_walk_recorded())
597        .collect()
598}
599
600/// Directories that are conventionally NOT workspace packages even when a
601/// glob like `packages/*` matches them. Mirrors pnpm/npm/yarn behavior of
602/// silently filtering these out, and extends fallow's existing
603/// `should_skip_workspace_scan_dir` list with build artifacts and tooling
604/// caches.
605#[must_use]
606pub(super) fn is_skip_listed_dir(name: &str) -> bool {
607    name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
608}
609
610/// Test if a project-root-relative directory path is excluded by user
611/// `ignorePatterns`. The directory itself and its `package.json` are both
612/// checked because users variably write `packages/legacy/**` or
613/// `packages/legacy/package.json` in their ignore globs.
614#[must_use]
615pub(super) fn is_ignored_workspace_dir(
616    relative_dir: &Path,
617    ignore_patterns: &globset::GlobSet,
618) -> bool {
619    if ignore_patterns.is_empty() {
620        return false;
621    }
622    let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
623    ignore_patterns.is_match(relative_str.as_str())
624        || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use fallow_types::discover::FileId;
631    use fallow_types::extract::SourceReadFailure;
632
633    fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
634        WorkspaceDiagnostic::new(
635            root,
636            root.join(rel_path),
637            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
638                pattern: pattern.to_owned(),
639            },
640        )
641    }
642
643    #[test]
644    fn skipped_large_file_diagnostic_id_and_message() {
645        let root = Path::new("/project");
646        let diag = WorkspaceDiagnostic::new(
647            root,
648            root.join("src/vendor/app.bundle.js"),
649            WorkspaceDiagnosticKind::SkippedLargeFile {
650                size_bytes: 6 * 1024 * 1024,
651            },
652        );
653        assert_eq!(diag.kind.id(), "skipped-large-file");
654        assert!(
655            diag.message.contains("src/vendor/app.bundle.js"),
656            "message names the project-relative path: {}",
657            diag.message
658        );
659        assert!(
660            diag.message.contains("6.0 MB"),
661            "message reports the size: {}",
662            diag.message
663        );
664        assert!(
665            diag.message.contains("--max-file-size"),
666            "message names the override flag: {}",
667            diag.message
668        );
669    }
670
671    #[test]
672    fn skipped_minified_file_diagnostic_id_and_message() {
673        let root = Path::new("/project");
674        let diag = WorkspaceDiagnostic::new(
675            root,
676            root.join("src/assets/index-abc123.js"),
677            WorkspaceDiagnosticKind::SkippedMinifiedFile {
678                size_bytes: 2 * 1024 * 1024,
679            },
680        );
681        assert_eq!(diag.kind.id(), "skipped-minified-file");
682        assert!(
683            diag.message.contains("src/assets/index-abc123.js"),
684            "message names the project-relative path: {}",
685            diag.message
686        );
687        assert!(
688            diag.message.contains("2.0 MB"),
689            "message reports the size: {}",
690            diag.message
691        );
692        assert!(
693            diag.message.contains("--max-file-size 0"),
694            "message names the opt-out: {}",
695            diag.message
696        );
697    }
698
699    #[test]
700    fn skipped_source_dotdir_diagnostic_id_and_message() {
701        let root = Path::new("/project");
702        let diag = WorkspaceDiagnostic::new(
703            root,
704            root.join(".claude"),
705            WorkspaceDiagnosticKind::SkippedSourceDotdir,
706        );
707        assert_eq!(diag.kind.id(), "skipped-source-dotdir");
708        assert!(
709            diag.message.contains(".claude"),
710            "message names the project-relative path: {}",
711            diag.message
712        );
713        assert!(
714            diag.message
715                .contains("Its imports and exports are not analyzed."),
716            "message states the consequence: {}",
717            diag.message
718        );
719        assert!(
720            diag.message.contains("--root"),
721            "message names the real remedy: {}",
722            diag.message
723        );
724        assert!(
725            diag.message.contains("no config field"),
726            "the message must say plainly that no config field traverses it: {}",
727            diag.message
728        );
729    }
730
731    #[test]
732    fn stash_preserves_appended_skipped_large_file_across_restash() {
733        // Unique synthetic root so the process-global registry does not collide
734        // with sibling tests.
735        let root = Path::new("/fallow-test-1086-stash-preserve");
736        let undeclared = || {
737            WorkspaceDiagnostic::new(
738                root,
739                root.join("pkg"),
740                WorkspaceDiagnosticKind::UndeclaredWorkspace,
741            )
742        };
743        // First analysis loads config and stashes the workspace-discovery set.
744        stash_workspace_diagnostics(root, vec![undeclared()]);
745        // Its source discovery appends a skipped-large-file diagnostic.
746        append_workspace_diagnostics(
747            root,
748            vec![WorkspaceDiagnostic::new(
749                root,
750                root.join("vendor/big.js"),
751                WorkspaceDiagnosticKind::SkippedLargeFile {
752                    size_bytes: 9_999_999,
753                },
754            )],
755        );
756        // A sibling analysis (combined-mode dupes/health) re-loads config and
757        // re-stashes the same workspace-discovery set.
758        stash_workspace_diagnostics(root, vec![undeclared()]);
759
760        let after = workspace_diagnostics_for(root);
761        assert_eq!(
762            after
763                .iter()
764                .filter(|d| d.kind.is_source_discovery())
765                .count(),
766            1,
767            "skipped-large-file survives the combined-mode re-stash exactly once (#1086): {after:?}"
768        );
769        assert_eq!(
770            after
771                .iter()
772                .filter(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace))
773                .count(),
774            1,
775            "the workspace-discovery diagnostic is replaced, not duplicated"
776        );
777    }
778
779    fn analysis_stage_diagnostics(root: &Path) -> Vec<WorkspaceDiagnostic> {
780        vec![
781            WorkspaceDiagnostic::new(
782                root,
783                root.join("pnpm-workspace.yaml"),
784                WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
785                    error: "could not find expected ':'".to_owned(),
786                },
787            ),
788            WorkspaceDiagnostic::new(
789                root,
790                root.join("package.json"),
791                WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
792            ),
793        ]
794    }
795
796    fn count_kind(diagnostics: &[WorkspaceDiagnostic], id: &str) -> usize {
797        diagnostics.iter().filter(|d| d.kind.id() == id).count()
798    }
799
800    #[test]
801    fn stash_preserves_recorded_analysis_stage_diagnostics_across_restash() {
802        let root = Path::new("/fallow-test-2366-stash-preserve");
803        let undeclared = || {
804            WorkspaceDiagnostic::new(
805                root,
806                root.join("pkg"),
807                WorkspaceDiagnosticKind::UndeclaredWorkspace,
808            )
809        };
810        // The check analysis loads config, then its analyze pass records both
811        // analysis-stage kinds.
812        stash_workspace_diagnostics(root, vec![undeclared()]);
813        record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
814        // Combined-mode dupes/health re-load config and re-stash the same
815        // workspace-discovery set before the JSON envelope is built.
816        stash_workspace_diagnostics(root, vec![undeclared()]);
817
818        let after = workspace_diagnostics_for(root);
819        assert_eq!(
820            count_kind(&after, "malformed-pnpm-workspace-yaml"),
821            1,
822            "malformed-pnpm-workspace-yaml survives the combined-mode re-stash exactly once (#2366): {after:?}"
823        );
824        assert_eq!(
825            count_kind(&after, "bun-lockb-override-resolution-skipped"),
826            1,
827            "bun-lockb-override-resolution-skipped survives the combined-mode re-stash exactly once (#2366): {after:?}"
828        );
829        assert_eq!(
830            count_kind(&after, "undeclared-workspace"),
831            1,
832            "the workspace-discovery diagnostic is replaced, not duplicated"
833        );
834    }
835
836    #[test]
837    fn source_read_failures_replace_only_their_previous_parse_set() {
838        let root = Path::new("/fallow-test-source-read-replace");
839        stash_workspace_diagnostics(
840            root,
841            vec![WorkspaceDiagnostic::new(
842                root,
843                root.join("pkg"),
844                WorkspaceDiagnosticKind::UndeclaredWorkspace,
845            )],
846        );
847        append_workspace_diagnostics(
848            root,
849            vec![WorkspaceDiagnostic::new(
850                root,
851                root.join("vendor/big.js"),
852                WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 99 },
853            )],
854        );
855        let first = SourceReadFailure {
856            file_id: FileId(1),
857            path: root.join("src/first.ts"),
858            error: "removed".to_string(),
859        };
860        let _ = record_source_read_failures(root, &[first]);
861        let second = SourceReadFailure {
862            file_id: FileId(2),
863            path: root.join("src/second.ts"),
864            error: "permission denied".to_string(),
865        };
866
867        let _ = record_source_read_failures(root, std::slice::from_ref(&second));
868
869        let diagnostics = workspace_diagnostics_for(root);
870        let source_failures: Vec<_> = diagnostics
871            .iter()
872            .filter(|diagnostic| {
873                matches!(
874                    diagnostic.kind,
875                    WorkspaceDiagnosticKind::SourceReadFailure { .. }
876                )
877            })
878            .collect();
879        assert_eq!(source_failures.len(), 1);
880        assert_eq!(source_failures[0].path, second.path);
881        assert!(diagnostics.iter().any(|diagnostic| matches!(
882            diagnostic.kind,
883            WorkspaceDiagnosticKind::UndeclaredWorkspace
884        )));
885        assert!(diagnostics.iter().any(|diagnostic| matches!(
886            diagnostic.kind,
887            WorkspaceDiagnosticKind::SkippedLargeFile { .. }
888        )));
889
890        let _ = record_source_read_failures(root, &[]);
891        assert!(workspace_diagnostics_for(root).iter().all(|diagnostic| {
892            !matches!(
893                diagnostic.kind,
894                WorkspaceDiagnosticKind::SourceReadFailure { .. }
895            )
896        }));
897    }
898
899    #[test]
900    fn clear_source_discovery_drops_stale_skip_keeps_workspace_diag() {
901        let root = Path::new("/fallow-test-1086-clear-stale");
902        stash_workspace_diagnostics(
903            root,
904            vec![WorkspaceDiagnostic::new(
905                root,
906                root.join("pkg"),
907                WorkspaceDiagnosticKind::UndeclaredWorkspace,
908            )],
909        );
910        append_workspace_diagnostics(
911            root,
912            vec![WorkspaceDiagnostic::new(
913                root,
914                root.join("vendor/big.js"),
915                WorkspaceDiagnosticKind::SkippedLargeFile {
916                    size_bytes: 9_999_999,
917                },
918            )],
919        );
920        // A later walk (the file is no longer skipped) clears the stale entry.
921        let replaced = replace_source_discovery_diagnostics(root, Vec::new());
922        assert!(
923            replaced.is_empty(),
924            "the walk's own list is what it wrote, not what it removed"
925        );
926
927        let after = workspace_diagnostics_for(root);
928        assert!(
929            !after.iter().any(|d| d.kind.is_source_discovery()),
930            "stale skipped-large-file is dropped on the next walk (#1086 watch-mode): {after:?}"
931        );
932        assert!(
933            after
934                .iter()
935                .any(|d| matches!(d.kind, WorkspaceDiagnosticKind::UndeclaredWorkspace)),
936            "the workspace-discovery diagnostic survives the source-discovery clear"
937        );
938    }
939
940    #[test]
941    fn clear_analysis_stage_drops_stale_entries_keeps_other_kinds() {
942        let root = Path::new("/fallow-test-2366-clear-stale");
943        stash_workspace_diagnostics(
944            root,
945            vec![WorkspaceDiagnostic::new(
946                root,
947                root.join("pkg"),
948                WorkspaceDiagnosticKind::UndeclaredWorkspace,
949            )],
950        );
951        append_workspace_diagnostics(
952            root,
953            vec![WorkspaceDiagnostic::new(
954                root,
955                root.join("vendor/big.js"),
956                WorkspaceDiagnosticKind::SkippedLargeFile {
957                    size_bytes: 9_999_999,
958                },
959            )],
960        );
961        record_workspace_diagnostics(root, analysis_stage_diagnostics(root));
962        // The next analyze pass (the yaml is fixed, a text bun.lock exists)
963        // clears the stale entries before re-recording nothing.
964        clear_analysis_stage_diagnostics(root);
965
966        let after = workspace_diagnostics_for(root);
967        assert!(
968            !after.iter().any(|d| d.kind.is_analysis_stage()),
969            "stale analysis-stage entries are dropped on the next analyze pass (#2366): {after:?}"
970        );
971        assert_eq!(
972            count_kind(&after, "undeclared-workspace"),
973            1,
974            "the workspace-discovery diagnostic survives the analysis-stage clear"
975        );
976        assert_eq!(
977            count_kind(&after, "skipped-large-file"),
978            1,
979            "the source-discovery diagnostic survives the analysis-stage clear"
980        );
981    }
982
983    #[test]
984    fn build_glob_group_message_caps_examples_and_summarises_tail() {
985        let root = Path::new("/project");
986        let paths = [
987            root.join("playground/cli"),
988            root.join("playground/lib-types"),
989            root.join("playground/minify"),
990            root.join("playground/ssr"),
991            root.join("playground/worker"),
992        ];
993        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
994        let message = build_glob_group_message(root, "playground/**", &refs);
995
996        assert!(
997            message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
998            "count and pattern lead the message: {message}"
999        );
1000        assert!(
1001            message.contains(
1002                "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
1003            ),
1004            "three sorted examples + tail count: {message}"
1005        );
1006        assert!(
1007            message.ends_with(
1008                "Add a package.json, narrow the pattern, or add them to ignorePatterns."
1009            ),
1010            "next-step hint preserved: {message}"
1011        );
1012        assert!(
1013            !message.contains("playground/ssr"),
1014            "tail example not named: {message}"
1015        );
1016    }
1017
1018    #[test]
1019    fn build_glob_group_message_no_tail_when_at_or_below_cap() {
1020        let root = Path::new("/project");
1021        let paths = [root.join("packages/a"), root.join("packages/b")];
1022        let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
1023        let message = build_glob_group_message(root, "packages/*", &refs);
1024
1025        assert!(message.contains("matched 2 directories"), "{message}");
1026        assert!(
1027            message.contains("(e.g. packages/a, packages/b)"),
1028            "both examples named, no `and N more`: {message}"
1029        );
1030        assert!(!message.contains("more)"), "no tail clause: {message}");
1031    }
1032
1033    #[test]
1034    fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
1035        let root = Path::new("/project");
1036        let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
1037            .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
1038            .collect();
1039
1040        let plans = plan_warnings(root, &diagnostics);
1041
1042        assert_eq!(
1043            plans.len(),
1044            1,
1045            "50 same-pattern diagnostics collapse to one plan"
1046        );
1047        assert!(
1048            plans[0]
1049                .dedupe_key
1050                .ends_with("::glob-matched-no-package-json-agg::playground/**")
1051        );
1052        assert!(plans[0].message.contains("matched 50 directories"));
1053    }
1054
1055    #[test]
1056    fn plan_warnings_keeps_distinct_patterns_separate() {
1057        let root = Path::new("/project");
1058        let diagnostics = vec![
1059            glob_diag(root, "apps/*", "apps/a"),
1060            glob_diag(root, "apps/*", "apps/b"),
1061            glob_diag(root, "packages/*", "packages/x"),
1062            glob_diag(root, "packages/*", "packages/y"),
1063        ];
1064
1065        let plans = plan_warnings(root, &diagnostics);
1066
1067        assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
1068        let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
1069        assert!(
1070            messages
1071                .iter()
1072                .any(|m| m.contains("Glob 'apps/*' matched 2")),
1073            "{messages:?}"
1074        );
1075        assert!(
1076            messages
1077                .iter()
1078                .any(|m| m.contains("Glob 'packages/*' matched 2")),
1079            "{messages:?}"
1080        );
1081    }
1082
1083    #[test]
1084    fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
1085        let root = Path::new("/project");
1086        let diag = glob_diag(root, "packages/*", "packages/scratch");
1087
1088        let plans = plan_warnings(root, std::slice::from_ref(&diag));
1089
1090        assert_eq!(plans.len(), 1);
1091        assert_eq!(plans[0].message, diag.message);
1092        // The key embeds `diag.path` through a raw `Display`, so the expected
1093        // tail carries the platform separator: the stored path is rebuilt from
1094        // its components and renders with backslashes on Windows.
1095        let expected_tail = Path::new("packages").join("scratch");
1096        assert!(
1097            plans[0]
1098                .dedupe_key
1099                .contains("::glob-matched-no-package-json::")
1100                && plans[0]
1101                    .dedupe_key
1102                    .ends_with(&expected_tail.display().to_string()),
1103            "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
1104            plans[0].dedupe_key
1105        );
1106        assert!(
1107            !plans[0].message.contains("directories"),
1108            "single match is not aggregated"
1109        );
1110    }
1111
1112    #[test]
1113    fn plan_warnings_non_glob_kinds_stay_per_instance() {
1114        let root = Path::new("/project");
1115        let diagnostics = vec![
1116            WorkspaceDiagnostic::new(
1117                root,
1118                root.join("packages/a"),
1119                WorkspaceDiagnosticKind::UndeclaredWorkspace,
1120            ),
1121            WorkspaceDiagnostic::new(
1122                root,
1123                root.join("packages/b"),
1124                WorkspaceDiagnosticKind::MalformedPackageJson {
1125                    error: "trailing comma".to_owned(),
1126                },
1127            ),
1128        ];
1129
1130        let plans = plan_warnings(root, &diagnostics);
1131
1132        assert_eq!(
1133            plans.len(),
1134            2,
1135            "each non-glob diagnostic plans its own warning"
1136        );
1137        assert!(
1138            plans
1139                .iter()
1140                .all(|p| !p.message.contains("directories with no package.json"))
1141        );
1142    }
1143
1144    fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
1145        WorkspaceDiagnostic::new(
1146            root,
1147            root.join(rel_path),
1148            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1149        )
1150    }
1151
1152    #[test]
1153    fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
1154        let root = Path::new("/project");
1155        let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
1156            .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
1157            .collect();
1158
1159        let plans = plan_warnings(root, &diagnostics);
1160
1161        assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
1162        assert!(
1163            plans[0]
1164                .dedupe_key
1165                .ends_with("::tsconfig-reference-dir-missing-agg")
1166        );
1167        assert!(
1168            plans[0]
1169                .message
1170                .starts_with("tsconfig.json references 30 directories that do not exist"),
1171            "{}",
1172            plans[0].message
1173        );
1174        assert!(
1175            plans[0].message.contains(
1176                "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
1177                 packages/p02/tsconfig.json, and 27 more)"
1178            ),
1179            "three sorted examples + tail: {}",
1180            plans[0].message
1181        );
1182        assert!(
1183            plans[0]
1184                .message
1185                .ends_with("Update or remove the references, or restore the missing directories."),
1186            "{}",
1187            plans[0].message
1188        );
1189    }
1190
1191    #[test]
1192    fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
1193        let root = Path::new("/project");
1194        let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
1195
1196        let plans = plan_warnings(root, std::slice::from_ref(&diag));
1197
1198        assert_eq!(plans.len(), 1);
1199        assert_eq!(
1200            plans[0].message, diag.message,
1201            "single miss is not aggregated"
1202        );
1203        assert!(!plans[0].message.contains("directories that do not exist"));
1204    }
1205
1206    #[test]
1207    fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
1208        let root = Path::new("/project");
1209        let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
1210            .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
1211            .collect();
1212        diagnostics.extend(
1213            (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
1214        );
1215
1216        let plans = plan_warnings(root, &diagnostics);
1217
1218        assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
1219        assert!(
1220            plans
1221                .iter()
1222                .any(|p| p.message.contains("matched 5 directories"))
1223        );
1224        assert!(
1225            plans
1226                .iter()
1227                .any(|p| p.message.contains("references 4 directories"))
1228        );
1229    }
1230
1231    /// Issue #2366: the aggregated warning groups the diagnostics it is
1232    /// handed and counts the group, so a list that still holds the duplicate
1233    /// entries of one glob declared in two manifests reports a directory
1234    /// count that does not exist and names one directory twice among its
1235    /// examples. Deduplicating at discovery is what makes the summary true.
1236    #[test]
1237    fn two_manifest_glob_warning_counts_each_directory_once() {
1238        let dir = tempfile::tempdir().expect("create temp dir");
1239        crate::workspace::write_two_manifest_glob_project(dir.path());
1240
1241        let (_, diagnostics) = crate::workspace::discover_workspaces_with_diagnostics(
1242            dir.path(),
1243            &globset::GlobSet::empty(),
1244        )
1245        .expect("root package.json is valid");
1246
1247        let messages: Vec<String> = plan_warnings(dir.path(), &diagnostics)
1248            .into_iter()
1249            .map(|plan| plan.message)
1250            .collect();
1251
1252        assert_eq!(
1253            messages,
1254            vec![
1255                "Glob 'pkgs/*' matched 2 directories with no package.json \
1256                 (e.g. pkgs/aaa, pkgs/bbb). Add a package.json, narrow the \
1257                 pattern, or add them to ignorePatterns."
1258                    .to_owned()
1259            ],
1260            "the summary names the true directory count and each example once"
1261        );
1262    }
1263}