Skip to main content

fallow_types/
workspace.rs

1//! Workspace and source-discovery diagnostic data types.
2//!
3//! The serializable `WorkspaceDiagnostic` / `WorkspaceDiagnosticKind` pair
4//! lives here, upstream of both `fallow-config` (which owns the registry and
5//! emission logic and re-exports these types for back-compat) and
6//! `fallow-output` (which embeds `Vec<WorkspaceDiagnostic>` in its JSON
7//! envelopes). Keeping the data types in `fallow-types` lets the output layer
8//! reference the real, schema-bearing type instead of an opaque
9//! `serde_json::Value` newtype, so `workspace_diagnostics[]` keeps its typed
10//! `kind`/`path`/`message` shape (and the typed `kind` oneOf) in
11//! `docs/output-schema.json` without coupling output contracts to config
12//! loading.
13
14use std::path::{Path, PathBuf};
15
16use rustc_hash::FxHashSet;
17#[cfg(feature = "schema")]
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::serde_path;
22
23/// Why a workspace-discovery candidate was rejected, or why a sibling
24/// directory looked workspace-like but was not declared.
25///
26/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
27/// agents, LSP clients) get a stable, language-neutral identifier.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "schema", derive(JsonSchema))]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31pub enum WorkspaceDiagnosticKind {
32    /// A directory contains `package.json` but is not declared as a workspace
33    /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
34    /// `tsconfig.json` `references`. Surfaced by
35    /// `find_undeclared_workspaces`.
36    UndeclaredWorkspace,
37    /// A declared workspace's `package.json` failed to parse. The directory is
38    /// dropped from discovery, but analysis still proceeds (degraded).
39    MalformedPackageJson {
40        /// `serde_json` parse error text.
41        error: String,
42    },
43    /// A workspace glob pattern matched a directory that contains no
44    /// `package.json`. Honors the extended skip list and `ignorePatterns`
45    /// before emitting.
46    GlobMatchedNoPackageJson {
47        /// The glob pattern that matched the directory.
48        pattern: String,
49    },
50    /// `tsconfig.json` exists at the root but failed to parse. Project
51    /// references cannot be discovered.
52    MalformedTsconfig {
53        /// JSONC parse error text.
54        error: String,
55    },
56    /// `tsconfig.json` lists a `references[].path` that does not point to an
57    /// existing directory.
58    TsconfigReferenceDirMissing,
59    /// `pnpm-workspace.yaml` exists but failed to parse as YAML. Catalog and
60    /// dependency-override analysis proceeds with no entries (degraded), so
61    /// `catalog:`-referenced dependencies may be misclassified until the
62    /// syntax is fixed.
63    MalformedPnpmWorkspaceYaml {
64        /// `serde_yaml_ng` parse error text.
65        error: String,
66    },
67    /// A source file was skipped at discovery because it exceeds the configured
68    /// per-file size limit (`--max-file-size` / `FALLOW_MAX_FILE_SIZE`, default
69    /// 5 MB). The file is never read, parsed, or analyzed, guarding against the
70    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
71    /// causes (issue #1086). Surfaced by source discovery, not workspace
72    /// discovery, but shares this channel so the skip is visible in
73    /// `workspace_diagnostics[]` on `fallow dead-code / dupes / health` JSON.
74    SkippedLargeFile {
75        /// On-disk size of the skipped file in bytes.
76        size_bytes: u64,
77    },
78    /// A large JavaScript bundle was skipped at discovery because it appears to
79    /// be minified generated output. The file is never parsed or analyzed,
80    /// guarding against sub-limit bundles that can still create very large ASTs
81    /// and extraction payloads (issue #1086). Use `--max-file-size 0` when the
82    /// bundled file really should be analyzed.
83    SkippedMinifiedFile {
84        /// On-disk size of the skipped file in bytes.
85        size_bytes: u64,
86    },
87    /// A dot-prefixed directory was not traversed by source discovery even
88    /// though it contains at least one source file the project has not
89    /// excluded. Hidden directories are skipped by default apart from a small
90    /// convention allowlist (`.storybook`, `.vitepress`, `.well-known`,
91    /// `.changeset`, `.github`) and the directories an active framework plugin
92    /// or a `package.json` script reference contributes, so files inside are
93    /// never parsed and their imports and exports are invisible to every
94    /// analysis. No config field adds a directory to traversal: run fallow
95    /// with `--root` against the directory to analyze it on its own, or add it
96    /// to `ignorePatterns` to silence this (issue #461).
97    ///
98    /// "Not excluded" is measured the way the run measures it: a directory
99    /// whose contents are gitignored, or excluded by `ignorePatterns`, or (on
100    /// a `--production` run) excluded as test or story files, never earns this
101    /// diagnostic, because the advertised remedies would find nothing there
102    /// either. Generated tool output and non-git VCS metadata are excluded by
103    /// name.
104    ///
105    /// The advisory is best-effort and bounded: one run inspects a fixed
106    /// number of skipped directories with a fixed I/O budget, in sorted path
107    /// order, so a pathological tree yields a deterministic prefix rather than
108    /// an unbounded array or an unbounded scan. The stderr note says "at
109    /// least" when a ceiling bound the run.
110    ///
111    /// Surfaced by source discovery, not workspace discovery, but shares this
112    /// channel so the skip is visible in `workspace_diagnostics[]` on
113    /// `fallow dead-code / dupes / health` JSON.
114    ///
115    /// Unlike the two skipped-file kinds beside it, this one is CAPPED. To
116    /// bound the directory reads the check costs, a run classifies at most 64
117    /// candidate directories and spends at most 1024 directory entries across
118    /// all of them, so on a project that exceeds either ceiling the array is a
119    /// prefix of the skipped directories rather than all of them, and the
120    /// stderr note says "at least N". No measured repository comes close to
121    /// either ceiling. A consumer needing an exact total should run fallow
122    /// with `--root` against the tree rather than infer one from this array.
123    SkippedSourceDotdir,
124    /// A source discovered with a stable [`FileId`](crate::discover::FileId)
125    /// could not be read before parsing. Analysis continues with the remaining
126    /// sparse module IDs and reports the underlying filesystem or UTF-8 error.
127    SourceReadFailure {
128        /// Filesystem or UTF-8 decoding error from `read_to_string`.
129        error: String,
130    },
131    /// A source file was read but parsed with diagnostics, so the module
132    /// extracted from it may be missing imports, exports, or references after
133    /// the first error. Analysis proceeds with the partial module, which is why
134    /// this is reported: an import the parser never saw credits nothing, and its
135    /// target can surface as a confident `unused-file` or `unused-export`
136    /// finding with a `delete-file` or `remove-export` action on it.
137    ///
138    /// Recorded by the parse stage, alongside `source-read-failure`, and never
139    /// used to withhold a finding. oxc reports recoverable errors for valid
140    /// syntax newer than the parser as well as for genuinely broken files, so
141    /// gating findings on this would mute real results project-wide instead of
142    /// just the affected file.
143    SourceParseDegraded {
144        /// Number of parser diagnostics reported for the file.
145        error_count: u32,
146        /// `true` when the parser abandoned the file instead of recovering, so
147        /// the extracted module is a fragment at best.
148        panicked: bool,
149    },
150    /// Dependency-override resolution was skipped because bun's legacy binary
151    /// `bun.lockb` sits next to this `package.json`, fallow cannot read the
152    /// binary format, and no parseable text lockfile was found to use
153    /// instead: no `bun.lock` that parses, and no readable `pnpm-lock.yaml`,
154    /// `package-lock.json`, or `npm-shrinkwrap.json`. A `yarn.lock` is never
155    /// consulted (yarn ignores `overrides`), so it does not prevent the skip
156    /// either. The manifest declares overrides, so the
157    /// `unused-dependency-overrides` check would otherwise have run; without
158    /// resolution ground truth it would flag every transitive-only pin, so no
159    /// unused-override findings are reported at all (issue #2358). Surfaced
160    /// by the override analysis, not workspace discovery, but shares this
161    /// channel so the skip is visible in `workspace_diagnostics[]` JSON and
162    /// as a stderr warning.
163    BunLockbOverrideResolutionSkipped,
164    /// Dependency-override resolution was skipped because bun's text
165    /// `bun.lock` exists but could not be parsed and no readable pnpm or npm
166    /// lockfile was available as independent resolution ground truth.
167    BunLockOverrideResolutionSkipped,
168    /// A bun manifest declares both `overrides` and a non-empty `resolutions`
169    /// object. Bun applies `overrides` and ignores `resolutions`, so fallow
170    /// reports the shadowed configuration without offering removal advice.
171    BunResolutionsShadowedByOverrides,
172    /// The project has no `node_modules` directory and is not a Deno project
173    /// that legitimately runs without one. Analysis proceeds, but three things
174    /// degrade silently: package `exports` and conditional exports cannot be
175    /// read, so imports into a dependency's subpaths resolve less precisely;
176    /// framework plugins that activate on an installed package stay inactive,
177    /// so their entry points and path aliases are missing; and a dependency's
178    /// installed shape cannot be inspected, so type-only dependency
179    /// classification falls back to declaration-based heuristics.
180    ///
181    /// Recorded once per run by the source walk, anchored at the missing
182    /// `node_modules` directory so the reported path is a real location rather
183    /// than the empty string a root-anchored diagnostic would render. This used
184    /// to be a bare `tracing::warn!` duplicated in two pipelines, so it never
185    /// reached JSON output and never reached `fallow doctor`, which reported
186    /// `pass` on a tree that had never been installed.
187    NodeModulesMissing,
188    /// `boundaries` is empty while `boundary-violation` is not `off`, so the
189    /// boundary detector never ran. Its summary counters are therefore
190    /// structurally zero and say nothing about the project.
191    ///
192    /// This is the UNCONFIGURED zero, not the user-chosen one: a project that
193    /// sets `boundary-violation: off` asked for silence and can see that
194    /// choice in `fallow config`. A project that left `boundaries` empty
195    /// cannot distinguish "no violations" from "nothing was measured".
196    BoundariesNotConfigured,
197    /// `rulePacks` is empty while `policy-violation` is not `off`, so the
198    /// policy detector never ran and its summary counters are structurally
199    /// zero. The unconfigured counterpart of
200    /// [`Self::BoundariesNotConfigured`].
201    RulePacksNotConfigured,
202    /// One of fallow's built-in discovery ignore patterns (`**/dist/**`,
203    /// `**/build/**`, `**/coverage/**`, and the four minified-bundle globs)
204    /// removed at least one candidate source file from this walk. The files
205    /// are never read, so their imports and exports are invisible to every
206    /// analysis, and until issue #2638 the drop was completely silent:
207    /// pointing fallow at a directory a built-in pattern matches returned a
208    /// clean report with exit 0 and nothing said why.
209    ///
210    /// `**/node_modules/**` is carved out and never appears in `pattern`:
211    /// installed dependencies are not the first-party source this diagnostic
212    /// is about, and a project that does not gitignore them would get a
213    /// five-figure count with no useful remedy. `**/.git/**` cannot fire,
214    /// because hidden directories are not traversed.
215    ///
216    /// One entry per pattern, never per file or per directory, so the array
217    /// grows by at most the number of built-in patterns on a project of any
218    /// size. `path` anchors at the matched directory holding the most excluded
219    /// files for that pattern, ties broken by the lexicographically first
220    /// path, so two runs on one tree report the same location. On a nested
221    /// match it is the DEEPEST segment the pattern matched
222    /// (`build/tools/build`, not `build`), because that is the directory the
223    /// `--root` remedy names and re-rooting at a shallower one would leave a
224    /// matching segment behind. That directory is the
225    /// largest group and not a majority: a flat monorepo can spread ten
226    /// excluded files over ten sibling `dist/` directories and every one of
227    /// them is then "the largest". `file_count` spans all of them, and
228    /// `directory_count` says how many there were, so a reader can tell a
229    /// single tree from a scattered one without a directory list in the
230    /// payload.
231    ///
232    /// Three properties of the population are load-bearing and easy to
233    /// misread:
234    ///
235    /// - **Gitignored trees count zero.** Source discovery honors
236    ///   `.gitignore`, `.git/info/exclude`, and the global gitignore, and
237    ///   prunes those directories before this check runs. The honest reading
238    ///   is "candidate source files git did not already hide and a built-in
239    ///   pattern then dropped", which is why a repository that gitignores its
240    ///   own `dist/` never sees this diagnostic.
241    /// - **A user `ignorePatterns` entry is not a surprise.** The compiled
242    ///   ignore set is the union of `ignorePatterns` and the built-ins, so a
243    ///   file both matched was an explicit project choice and is attributed to
244    ///   no pattern here. The union also only ever adds: `ignorePatterns`
245    ///   cannot negate a built-in, so a config edit is never the remedy.
246    /// - **The remedy depends on the pattern's shape.** A directory-shaped
247    ///   built-in (`**/dist/**`) is matched against the path relative to the
248    ///   run root, so re-rooting inside the matched directory removes the
249    ///   matched segment and the files become visible: the message advertises
250    ///   `fallow --root <dir>`. A file-shaped built-in (`**/*.min.js` and the
251    ///   three other bundle globs) matches on the file name and keeps matching
252    ///   at any root, so the message says so and points at renaming instead of
253    ///   handing out a command that provably does nothing.
254    ///
255    /// Deliberately NOT one of the [`Self::source_never_analyzed`] kinds. These
256    /// exclusions are the product's designed behavior on generated output, not
257    /// a degraded run: answering `true` would attach `IncompleteFileAnalysis`
258    /// and `IncompleteImportGraph` caveats to findings on nearly every project
259    /// that keeps a non-gitignored `dist/` or `coverage/`, and make `fallow
260    /// fix` withhold `delete-file` and `remove-export` actions project-wide.
261    ExcludedByDefaultIgnore {
262        /// The built-in glob that matched, verbatim (for example
263        /// `**/build/**`).
264        pattern: String,
265        /// Candidate source files this pattern excluded in this walk, across
266        /// every directory it matched, not just the one `path` anchors at.
267        /// Exact: the walk counts each excluded candidate once.
268        file_count: u32,
269        /// Distinct directories this pattern matched at, `path` included, and
270        /// not the number of directories that held the files. A
271        /// directory-shaped pattern (`**/dist/**`) matches at the directory it
272        /// names, so an excluded subtree counts once however many nested
273        /// directories inside it held source: a `dist/` holding files in three
274        /// sub-directories reports `1`. A file-shaped pattern (`**/*.min.js`)
275        /// has no directory to collapse to and counts each matched file's own
276        /// parent. Exact either way, and anything above `1` says `path` names
277        /// one matched location out of several.
278        directory_count: u32,
279    },
280    /// The walk finished with no source file to analyze at all, so every
281    /// finding count this run reports is zero because nothing was measured
282    /// rather than because the project is clean (issue #2686).
283    ///
284    /// Distinct from [`Self::ExcludedByDefaultIgnore`], which reports one
285    /// pattern's exclusions and is designed behavior on generated output. The
286    /// alarm is not the exclusion, it is having nothing left afterwards, and
287    /// that condition also fires with no exclusion at all: a docs-only
288    /// repository, a workspace member with no TypeScript, or a path filter that
289    /// matched nothing. `excluded_file_count` names the built-in-ignore
290    /// contribution so the common cause is still attributable, and is `0` when
291    /// no built-in pattern took part.
292    ///
293    /// This is the kind a CI consumer reads to tell "measured zero" from
294    /// "measured nothing": the human report has said so since 3.26.0, but only
295    /// in human format and only under the built-in-ignore cause, so `--quiet
296    /// --format json` saw a clean green either way.
297    NoSourceFilesAnalyzed {
298        /// Candidate source files the built-in ignore patterns removed from
299        /// this walk, summed across every pattern. `0` when the walk found no
300        /// candidate to exclude in the first place.
301        excluded_file_count: u32,
302    },
303}
304
305impl WorkspaceDiagnosticKind {
306    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
307    #[must_use]
308    pub const fn id(&self) -> &'static str {
309        match self {
310            Self::UndeclaredWorkspace => "undeclared-workspace",
311            Self::MalformedPackageJson { .. } => "malformed-package-json",
312            Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
313            Self::MalformedTsconfig { .. } => "malformed-tsconfig",
314            Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
315            Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
316            Self::SkippedLargeFile { .. } => "skipped-large-file",
317            Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
318            Self::SkippedSourceDotdir => "skipped-source-dotdir",
319            Self::SourceReadFailure { .. } => "source-read-failure",
320            Self::SourceParseDegraded { .. } => "source-parse-degraded",
321            Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
322            Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
323            Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
324            Self::NodeModulesMissing => "node-modules-missing",
325            Self::BoundariesNotConfigured => "boundaries-not-configured",
326            Self::RulePacksNotConfigured => "rule-packs-not-configured",
327            Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
328            Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
329        }
330    }
331
332    /// Whether this diagnostic is worth a `tracing::warn!` line on stderr, on
333    /// top of its permanent entry in `workspace_diagnostics[]`.
334    ///
335    /// A warning is for a run whose RESULTS are degraded: something the user
336    /// installed, wrote, or expected did not reach the analysis. The two
337    /// unconfigured-check kinds are not that. They fire in the product's
338    /// default state, on every project that never opted into boundaries or
339    /// rule packs, and they will keep firing forever, because the remedy they
340    /// offer is to write configuration in order to silence a warning about not
341    /// having written configuration. They stay in the structured array, where a
342    /// consumer that wants to distinguish "measured zero" from "measured
343    /// nothing" can read them, and off the stderr surface that every other
344    /// command shares.
345    #[must_use]
346    pub const fn warns_on_stderr(&self) -> bool {
347        match self {
348            Self::BoundariesNotConfigured
349            | Self::RulePacksNotConfigured
350            | Self::ExcludedByDefaultIgnore { .. } => false,
351            Self::UndeclaredWorkspace
352            | Self::MalformedPackageJson { .. }
353            | Self::GlobMatchedNoPackageJson { .. }
354            | Self::MalformedTsconfig { .. }
355            | Self::TsconfigReferenceDirMissing
356            | Self::MalformedPnpmWorkspaceYaml { .. }
357            | Self::SkippedLargeFile { .. }
358            | Self::SkippedMinifiedFile { .. }
359            | Self::SkippedSourceDotdir
360            | Self::SourceReadFailure { .. }
361            | Self::SourceParseDegraded { .. }
362            | Self::BunLockbOverrideResolutionSkipped
363            | Self::BunLockOverrideResolutionSkipped
364            | Self::BunResolutionsShadowedByOverrides
365            | Self::NodeModulesMissing
366            | Self::NoSourceFilesAnalyzed { .. } => true,
367        }
368    }
369
370    /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
371    /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
372    /// discovery diagnostics are APPENDED to the registry after config load, so
373    /// `stash_workspace_diagnostics` must preserve them when it replaces the
374    /// workspace-discovery set, otherwise the per-analysis config re-loads in
375    /// combined-mode (`fallow` with no subcommand re-loads config for check,
376    /// dupes, and health) wipe them before the JSON envelope is built (issue
377    /// #1086).
378    #[must_use]
379    pub const fn is_source_discovery(&self) -> bool {
380        matches!(
381            self,
382            Self::SkippedLargeFile { .. }
383                | Self::SkippedMinifiedFile { .. }
384                | Self::SkippedSourceDotdir
385                | Self::SourceReadFailure { .. }
386                | Self::SourceParseDegraded { .. }
387                | Self::NodeModulesMissing
388                | Self::ExcludedByDefaultIgnore { .. }
389                | Self::NoSourceFilesAnalyzed { .. }
390        )
391    }
392
393    /// Whether this diagnostic is written by the source file WALK
394    /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
395    /// walk replaces wholesale for its root. `source-read-failure` is the
396    /// other source-discovery kind and is NOT one of these: the parse stage
397    /// records it after the walk, so it has to keep reaching consumers through
398    /// the registry.
399    ///
400    /// A walk-recorded entry must reach an analysis from its OWN walk's return
401    /// value. Combined mode runs the dead-code and duplication walks under
402    /// `rayon::join` whenever a per-analysis `production` split stops them from
403    /// sharing a file list, so a registry read answers "whichever walk wrote
404    /// last" and varies between runs of the same command (issue #2366).
405    #[must_use]
406    pub const fn is_source_walk_recorded(&self) -> bool {
407        matches!(
408            self,
409            Self::SkippedLargeFile { .. }
410                | Self::SkippedMinifiedFile { .. }
411                | Self::SkippedSourceDotdir
412                | Self::NodeModulesMissing
413                | Self::ExcludedByDefaultIgnore { .. }
414                | Self::NoSourceFilesAnalyzed { .. }
415        )
416    }
417
418    /// Whether this diagnostic reports a source file whose contents this run
419    /// never analyzed, so every import and export the file holds is invisible
420    /// to the module graph.
421    ///
422    /// This is the class `reachability_caveats[]` exists for. A file the run
423    /// never read credits nothing, so the modules it imports surface as
424    /// confident `unused-file` and `unused-export` findings carrying
425    /// `delete-file` and `remove-export` actions, and `fallow fix` would
426    /// otherwise apply the removal against source that still imports the
427    /// target.
428    ///
429    /// All four discovery-side kinds qualify, for the same reason and with the
430    /// same consequence:
431    ///
432    /// - `skipped-large-file` and `skipped-minified-file`: the file is in the
433    ///   project tree and was never opened, so its import list is unknown.
434    /// - `skipped-source-dotdir`: the directory holds at least one source file
435    ///   the project did not exclude, and none of them were traversed. The
436    ///   diagnostic is capped, so it under-reports rather than over-reports;
437    ///   its presence still proves unseen source exists.
438    /// - `source-read-failure`: the file was discovered and then could not be
439    ///   read, so nothing was extracted from it at all.
440    ///
441    /// `source-parse-degraded` is deliberately NOT one of these, though it
442    /// belongs to the same family. Neither is `excluded-by-default-ignore`,
443    /// for a different reason: that one reports designed behavior on generated
444    /// output rather than a degraded run, and its own doc comment carries the
445    /// argument.
446    ///
447    /// `source-parse-degraded`: that file WAS read, so it has a module and
448    /// a graph node and its reachability is observable, which lets the caveat
449    /// pass narrow it: a degraded module that is itself unreachable cannot
450    /// change a reachability verdict. Every kind above has no node to ask (a
451    /// read failure has one with nothing extracted into it), so no narrowing
452    /// is available and the caveat they raise is run-level.
453    ///
454    /// The match is exhaustive on purpose: a new "the run did not see this
455    /// file" kind has to be classified here, and answering `true` is the only
456    /// wiring its findings need in order to inherit both the caveat and the
457    /// `fallow fix` withholding that follows it.
458    #[must_use]
459    pub const fn source_never_analyzed(&self) -> bool {
460        match self {
461            Self::SkippedLargeFile { .. }
462            | Self::SkippedMinifiedFile { .. }
463            | Self::SkippedSourceDotdir
464            | Self::SourceReadFailure { .. } => true,
465            Self::UndeclaredWorkspace
466            | Self::MalformedPackageJson { .. }
467            | Self::GlobMatchedNoPackageJson { .. }
468            | Self::MalformedTsconfig { .. }
469            | Self::TsconfigReferenceDirMissing
470            | Self::MalformedPnpmWorkspaceYaml { .. }
471            | Self::SourceParseDegraded { .. }
472            | Self::BunLockbOverrideResolutionSkipped
473            | Self::BunLockOverrideResolutionSkipped
474            | Self::BunResolutionsShadowedByOverrides
475            | Self::NodeModulesMissing
476            | Self::BoundariesNotConfigured
477            | Self::RulePacksNotConfigured
478            | Self::ExcludedByDefaultIgnore { .. }
479            | Self::NoSourceFilesAnalyzed { .. } => false,
480        }
481    }
482
483    /// Whether this diagnostic is recorded by the ANALYZE stage (the
484    /// dependency-catalog and override detectors) rather than by workspace or
485    /// source discovery. Analysis-stage diagnostics reach the registry through
486    /// `record_workspace_diagnostics` after config load, so
487    /// `stash_workspace_diagnostics` must preserve them across combined-mode's
488    /// per-analysis config re-loads, and every analyze pass clears its previous
489    /// entries before re-recording so a fixed cause drops out on the next run
490    /// (issue #2366). The match is exhaustive on purpose: a new kind must be
491    /// classified here before it compiles.
492    ///
493    /// Classify a kind `true` ONLY when a detector reachable from the dead-code
494    /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
495    /// the single clear site. A kind recorded exclusively by another stage would
496    /// be cleared by the next dead-code pass and never come back.
497    #[must_use]
498    pub const fn is_analysis_stage(&self) -> bool {
499        match self {
500            Self::MalformedPnpmWorkspaceYaml { .. }
501            | Self::BunLockbOverrideResolutionSkipped
502            | Self::BunLockOverrideResolutionSkipped
503            | Self::BunResolutionsShadowedByOverrides
504            | Self::BoundariesNotConfigured
505            | Self::RulePacksNotConfigured => true,
506            Self::UndeclaredWorkspace
507            | Self::MalformedPackageJson { .. }
508            | Self::GlobMatchedNoPackageJson { .. }
509            | Self::MalformedTsconfig { .. }
510            | Self::TsconfigReferenceDirMissing
511            | Self::SkippedLargeFile { .. }
512            | Self::SkippedMinifiedFile { .. }
513            | Self::SkippedSourceDotdir
514            | Self::SourceReadFailure { .. }
515            | Self::SourceParseDegraded { .. }
516            | Self::NodeModulesMissing
517            | Self::ExcludedByDefaultIgnore { .. }
518            | Self::NoSourceFilesAnalyzed { .. } => false,
519        }
520    }
521}
522
523/// Render a byte count as a megabyte figure with one decimal place for
524/// human-readable diagnostic messages (e.g. `12.3 MB`).
525#[must_use]
526fn format_size_mb(bytes: u64) -> String {
527    #[expect(
528        clippy::cast_precision_loss,
529        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
530    )]
531    let mb = bytes as f64 / (1024.0 * 1024.0);
532    format!("{mb:.1} MB")
533}
534
535/// A diagnostic about a workspace-discovery candidate.
536///
537/// The `message` field is a human-readable rendering derived from `kind`. It
538/// always ends with a concrete next step ("fix the JSON syntax", "remove from
539/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
540/// forward.
541#[derive(Debug, Clone, Serialize, Deserialize)]
542#[cfg_attr(feature = "schema", derive(JsonSchema))]
543pub struct WorkspaceDiagnostic {
544    /// Path to the directory or file that triggered the diagnostic.
545    #[serde(serialize_with = "serde_path::serialize")]
546    pub path: PathBuf,
547    /// Kind discriminator with the typed payload.
548    #[serde(flatten)]
549    pub kind: WorkspaceDiagnosticKind,
550    /// Human-readable rendering derived from `kind` + `path`. Always ends
551    /// with a next-step hint.
552    pub message: String,
553    /// True when this diagnostic reports a run whose RESULTS are degraded:
554    /// something the user installed, wrote, or expected did not reach the
555    /// analysis. Projected from [`WorkspaceDiagnosticKind::warns_on_stderr`],
556    /// which is the same classification that decides whether the CLI prints a
557    /// stderr line, so a CI log built from this field and a local non-quiet run
558    /// say the same thing.
559    ///
560    /// Omitted when false, which is what keeps every clean run byte-identical.
561    /// The two unconfigured-check kinds answer false on purpose: they fire in
562    /// the product's default state on every project that never opted into
563    /// boundaries or rule packs, so warning on them would warn forever. So does
564    /// `excluded-by-default-ignore`, which is designed behavior on generated
565    /// output; the alarm for that case is `no-source-files-analyzed`.
566    ///
567    /// Read this instead of hardcoding a kind allowlist: a degrading kind added
568    /// in a later release then reaches an unchanged consumer.
569    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
570    pub degrades_analysis: bool,
571}
572
573impl WorkspaceDiagnostic {
574    /// Construct a diagnostic with the message rendered from `kind` + `path`.
575    ///
576    /// `root` is used to produce project-relative paths in the message text
577    /// AND inside the variant payload (e.g. the `error` field of
578    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
579    /// file path from `PackageJson::load()`'s error text). Without the
580    /// payload-side normalisation the embedded path would survive
581    /// environment-specific differences (CI vs Docker vs local) because the
582    /// post-serialisation `strip_root_prefix` only catches whole-string
583    /// matches, not paths embedded mid-sentence.
584    ///
585    /// If `path` is not under `root` (e.g. canonicalisation crossed a
586    /// symlink), the absolute path is emitted instead.
587    ///
588    /// `path` also loses any no-op `.` component, for the same reason the
589    /// payload loses a glob's `./` prefix: one directory reached through two
590    /// spellings of one glob must be one diagnostic.
591    #[must_use]
592    pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
593        let path = normalise_diagnostic_path(path);
594        let kind = normalise_payload_paths(root, kind);
595        let message = render_message(root, &path, &kind);
596        let degrades_analysis = kind.warns_on_stderr();
597        Self {
598            path,
599            kind,
600            message,
601            degrades_analysis,
602        }
603    }
604
605    /// Return this diagnostic with `path` rewritten relative to `root`.
606    ///
607    /// `path` is stored absolute so callers can act on it. Every JSON envelope
608    /// emits it project-relative instead: the analysis envelopes get there
609    /// through the post-serialisation `strip_root_prefix` pass, which the
610    /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
611    /// `project_info` tool never run, so those emitted the absolute path while
612    /// the sibling `workspaces[].path` next to it was relative. They normalise
613    /// at the typed layer with this method instead.
614    ///
615    /// Paths outside `root` (canonicalisation crossed a symlink) are left
616    /// absolute, matching how [`Self::new`] renders the message.
617    ///
618    /// A diagnostic anchored at the root itself becomes `.`, not the empty
619    /// path: an empty string is not a location, and the analysis envelopes'
620    /// post-serialisation strip only removes a `root + separator` prefix, so a
621    /// root-anchored path that stays absolute here leaks a host path.
622    #[must_use]
623    pub fn into_root_relative(mut self, root: &Path) -> Self {
624        if let Ok(relative) = self.path.strip_prefix(root) {
625            self.path = if relative.as_os_str().is_empty() {
626                PathBuf::from(".")
627            } else {
628                relative.to_path_buf()
629            };
630        }
631        self
632    }
633}
634
635/// Rebuild `path` from its components so one directory has one spelling.
636///
637/// The dedupe key was never the problem: [`Path`] equality already ignores an
638/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
639/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
640/// expands to the first spelling and the same glob spelled `pkgs/*` in
641/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
642/// make a project-relative path differently: the analysis envelopes strip the
643/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
644/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
645/// `pkgs/aaa`). Whichever
646/// manifest happened to be read first then decided which shape every consumer
647/// saw. Collapsing at construction gives them one answer (issue #2366).
648///
649/// A path that is already component-clean rebuilds to itself. Serialization
650/// normalises separators, so the rebuild is wire-invisible on Windows.
651fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
652    let rebuilt: PathBuf = path.components().collect();
653    if rebuilt.as_os_str() == path.as_os_str() {
654        path
655    } else {
656        rebuilt
657    }
658}
659
660/// Strip the project root from absolute paths embedded inside variant
661/// payloads (the `error` field of malformed-config and source-read failures),
662/// and drop a glob pattern's no-op `./` prefix.
663///
664/// Mirrors the per-platform `display()` byte sequence so the substring match
665/// works on Windows too.
666///
667/// The pattern prefix matters because the payload is part of the dedupe key in
668/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
669/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
670/// glob twice, and without this both spellings would report every package-less
671/// directory under `apps/` a second time (issue #2366).
672fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
673    let root_str = root.display().to_string();
674    let root_alt = root_str.replace('\\', "/");
675    let normalise = |text: String| -> String {
676        let stripped = text
677            .replace(&format!("{root_str}/"), "")
678            .replace(&format!("{root_alt}/"), "");
679        stripped
680            .replace(&format!("{root_str}\\"), "")
681            .replace(&format!("{root_alt}\\"), "")
682    };
683    match kind {
684        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
685            WorkspaceDiagnosticKind::MalformedPackageJson {
686                error: normalise(error),
687            }
688        }
689        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
690            WorkspaceDiagnosticKind::MalformedTsconfig {
691                error: normalise(error),
692            }
693        }
694        WorkspaceDiagnosticKind::SourceReadFailure { error } => {
695            WorkspaceDiagnosticKind::SourceReadFailure {
696                error: normalise(error),
697            }
698        }
699        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
700            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
701                pattern: canonical_glob_pattern(pattern),
702            }
703        }
704        other => other,
705    }
706}
707
708/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
709/// pattern declared in two manifests is one payload.
710///
711/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
712/// its spelling: stripping it would report an empty `pattern` field and an
713/// empty quoted glob in the warning text, which names no glob at all.
714fn canonical_glob_pattern(pattern: String) -> String {
715    for prefix in ["./", ".\\"] {
716        if let Some(rest) = pattern.strip_prefix(prefix)
717            && !rest.is_empty()
718        {
719            return rest.to_owned();
720        }
721    }
722    pattern
723}
724
725/// Concatenate two diagnostic lists, keeping the first occurrence of each
726/// `(kind, path)` pair and the order of `primary` followed by the entries only
727/// `secondary` has.
728///
729/// The single place diagnostics from two observation points are folded
730/// together: an engine session's own capture plus the process registry, and
731/// the combined run's per-analysis lists (issue #2366). A combined run walks
732/// the project once per analysis, and per-analysis `production` modes can make
733/// those walks see different file sets, so no single observation point holds
734/// everything the run recorded; the union does, and folding it the same way
735/// everywhere is what keeps the CLI and the programmatic route answering
736/// identically.
737///
738/// The key is the WHOLE kind, payload included, not its
739/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
740/// path and still be two distinct diagnostics: overlapping workspace globs
741/// (`["packages/*", "packages/*/*"]`) each report the same package-less
742/// directory with their own `pattern`, and the standalone envelopes report
743/// both. An id-keyed fold silently dropped the second one.
744#[must_use]
745pub fn merge_workspace_diagnostics(
746    primary: Vec<WorkspaceDiagnostic>,
747    secondary: Vec<WorkspaceDiagnostic>,
748) -> Vec<WorkspaceDiagnostic> {
749    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
750    let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
751    for diagnostic in primary.into_iter().chain(secondary) {
752        let key = (diagnostic.kind.clone(), diagnostic.path.clone());
753        if seen.insert(key) {
754            merged.push(diagnostic);
755        }
756    }
757    merged
758}
759
760/// Keep the first occurrence of each `(kind, path)` pair in one list.
761///
762/// The single-list form of [`merge_workspace_diagnostics`], applied where
763/// diagnostics are produced rather than where two observation points are
764/// folded: workspace discovery reads `package.json` `workspaces`,
765/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
766/// `tsconfig.json` references additively, so a repository that declares one
767/// glob in two of them reports every package-less directory under it twice.
768/// Deduplicating at that source is what keeps the JSON envelopes, the
769/// aggregated stderr warning and the process registry telling one story
770/// (issue #2366).
771#[must_use]
772pub fn dedupe_workspace_diagnostics(
773    diagnostics: Vec<WorkspaceDiagnostic>,
774) -> Vec<WorkspaceDiagnostic> {
775    merge_workspace_diagnostics(diagnostics, Vec::new())
776}
777
778/// Render `path` relative to `root` with forward slashes. The forward-slash
779/// normalisation is load-bearing for cross-platform output stability.
780fn display_relative(root: &Path, path: &Path) -> String {
781    path.strip_prefix(root)
782        .unwrap_or(path)
783        .display()
784        .to_string()
785        .replace('\\', "/")
786}
787
788/// The first segment of a glob that contains no glob metacharacter, so it
789/// names a real directory rather than a wildcard.
790///
791/// Source discovery uses it to decide which directory a built-in ignore
792/// pattern excluded a file "at"; `render_message` uses it to decide which
793/// remedy is true for that pattern. The two have to agree, so the function
794/// lives here rather than once per crate: a pattern with such a segment
795/// (`**/dist/**`) is lifted by re-rooting inside the matched directory,
796/// because the glob is matched against the path relative to the run root. A
797/// pattern without one (`**/*.min.js`) matches on the file name and keeps
798/// matching at every root.
799#[must_use]
800pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
801    pattern.split('/').find(|segment| {
802        !segment.is_empty()
803            && !segment.contains(['*', '?', '[', ']', '{', '}'])
804            && *segment != "."
805            && *segment != ".."
806    })
807}
808
809fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
810    let display = display_relative(root, path);
811    match kind {
812        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
813            "Directory '{display}' contains package.json but is not declared as a workspace. \
814             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
815        ),
816        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
817            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
818             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
819        ),
820        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
821            "Glob '{pattern}' matched '{display}' but no package.json is present. \
822             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
823        ),
824        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
825            "tsconfig.json at '{display}' failed to parse ({error}); \
826             project references will be ignored. Fix the JSON syntax."
827        ),
828        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
829            "tsconfig.json references '{display}' but the directory does not exist. \
830             Update or remove the reference, or restore the missing directory."
831        ),
832        WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
833            "'{display}' failed to parse ({error}); catalog and override entries \
834             will be ignored. Fix the YAML syntax."
835        ),
836        WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
837            "Skipped '{display}' ({size}): exceeds the max file size limit. \
838             Its imports and exports are not analyzed. Raise the limit with \
839             --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
840             to ignorePatterns.",
841            size = format_size_mb(*size_bytes)
842        ),
843        WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
844            "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
845             Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
846             rename it with a .min.js suffix, or use --max-file-size 0 if this file \
847             should be analyzed.",
848            size = format_size_mb(*size_bytes)
849        ),
850        WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
851            "Skipped hidden directory '{display}': it contains source files but hidden \
852             directories are not traversed. Its imports and exports are not analyzed. \
853             There is no config field that adds a directory to traversal. If it holds \
854             first-party source, analyze it on its own with fallow --root {display}; if it \
855             is tool or agent scratch state, add '{display}/**' to ignorePatterns to \
856             silence this."
857        ),
858        WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
859            "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
860             ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
861        ),
862        WorkspaceDiagnosticKind::SourceParseDegraded {
863            error_count,
864            panicked,
865        } => {
866            let outcome = if *panicked {
867                "the parser stopped there"
868            } else {
869                "the parser recovered and continued"
870            };
871            format!(
872                "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
873                 references it did not reach are missing from this run, so files and symbols it \
874                 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
875                 syntax newer than fallow's parser."
876            )
877        }
878        WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
879            "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
880             sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
881             (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
882             use instead, so unused-dependency-overrides findings are not reported. Run bun install \
883             --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
884             bun.lockb if this repository no longer uses bun."
885        ),
886        WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
887            "Skipped dependency-override resolution because '{display}' could not be parsed and \
888             no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
889             findings are not reported. Run bun install to regenerate the text lockfile, then \
890             rerun fallow."
891        ),
892        WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
893            "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
894             `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
895             remove the shadowed `resolutions` entries."
896        ),
897        WorkspaceDiagnosticKind::NodeModulesMissing => format!(
898            "'{display}' does not exist. Package exports and conditional exports cannot be read, \
899             framework plugins that activate on an installed package stay inactive, and \
900             dependency classification degrades, so imports and dependencies can be \
901             misreported. Run npm install / pnpm install / yarn / bun install first."
902        ),
903        WorkspaceDiagnosticKind::BoundariesNotConfigured => {
904            "No architecture boundaries are configured, so the boundary detector did not run and \
905             its violation counts are zero because nothing was measured. Add `boundaries` to the \
906             config, or set `boundary-violation` to off to state that the check is not wanted."
907                .to_string()
908        }
909        WorkspaceDiagnosticKind::RulePacksNotConfigured => {
910            "No rule packs are configured, so the policy detector did not run and its violation \
911             counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
912             `policy-violation` to off to state that the check is not wanted."
913                .to_string()
914        }
915        WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
916            excluded_file_count,
917        } => {
918            if *excluded_file_count == 0 {
919                "No source files were analyzed, so every finding count this run reports is zero \
920                 because nothing was measured. Check the analysis root, ignorePatterns, and any \
921                 path or workspace filter this run applied."
922                    .to_owned()
923            } else {
924                format!(
925                    "No source files were analyzed. Fallow's built-in ignore patterns excluded \
926                     {excluded_file_count} candidate files, so every finding count this run \
927                     reports is zero because nothing was measured; run with --explain-skipped \
928                     for the breakdown."
929                )
930            }
931        }
932        WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
933            pattern,
934            file_count,
935            directory_count,
936        } => {
937            // `path` is a location, and an empty string is not one: a built-in
938            // that matched a file sitting directly at the analysis root
939            // anchors at the root itself.
940            let display = if display.is_empty() {
941                ".".to_owned()
942            } else {
943                display
944            };
945            // The payload carries no directory list, so the message names the
946            // one directory `path` anchors at. With several excluded
947            // directories that is the largest group and NOT a majority, so the
948            // sentence says which claim it is making and how many directories
949            // it is leaving unnamed.
950            let location = if *directory_count > 1 {
951                format!(
952                    "Skipped {file_count} source files across {directory_count} directories, \
953                     the largest group under '{display}'"
954                )
955            } else if *file_count == 1 {
956                format!("Skipped 1 source file under '{display}'")
957            } else {
958                format!("Skipped {file_count} source files under '{display}'")
959            };
960            let singular = *file_count == 1 && *directory_count <= 1;
961            let (subject, effect) = if singular {
962                ("it matches", "it imports, exports, or defines")
963            } else {
964                ("they match", "they import, export, or define")
965            };
966            // Only a directory-shaped built-in is lifted by re-rooting. Telling
967            // a user with a `vendor/lib.min.js` to run `fallow --root vendor`
968            // hands them a command that excludes the same file again.
969            let remedy = if glob_first_literal_segment(pattern).is_some() {
970                format!(
971                    "Move first-party source out of the matched directory, or analyze that \
972                     directory on its own with fallow --root {display}."
973                )
974            } else {
975                "This pattern matches a file name rather than a directory, so re-running under \
976                 a different --root excludes the same files again. Rename first-party source \
977                 that only looks generated, dropping the '.min' or '.bundle' infix."
978                    .to_owned()
979            };
980            format!(
981                "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
982                 {effect} is visible to this run. Built-in ignores cannot be switched off \
983                 through ignorePatterns. {remedy}"
984            )
985        }
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    #[test]
994    fn skipped_large_file_diagnostic_id_and_message() {
995        let root = Path::new("/project");
996        let diag = WorkspaceDiagnostic::new(
997            root,
998            root.join("src/vendor/app.bundle.js"),
999            WorkspaceDiagnosticKind::SkippedLargeFile {
1000                size_bytes: 6 * 1024 * 1024,
1001            },
1002        );
1003        assert_eq!(diag.kind.id(), "skipped-large-file");
1004        assert!(
1005            diag.message.contains("src/vendor/app.bundle.js"),
1006            "message names the project-relative path: {}",
1007            diag.message
1008        );
1009        assert!(
1010            diag.message.contains("6.0 MB"),
1011            "message reports the size: {}",
1012            diag.message
1013        );
1014        assert!(
1015            diag.message.contains("--max-file-size"),
1016            "message names the override flag: {}",
1017            diag.message
1018        );
1019    }
1020
1021    #[test]
1022    fn skipped_minified_file_diagnostic_id_and_message() {
1023        let root = Path::new("/project");
1024        let diag = WorkspaceDiagnostic::new(
1025            root,
1026            root.join("src/assets/index-abc123.js"),
1027            WorkspaceDiagnosticKind::SkippedMinifiedFile {
1028                size_bytes: 2 * 1024 * 1024,
1029            },
1030        );
1031        assert_eq!(diag.kind.id(), "skipped-minified-file");
1032        assert!(
1033            diag.message.contains("src/assets/index-abc123.js"),
1034            "message names the project-relative path: {}",
1035            diag.message
1036        );
1037        assert!(
1038            diag.message.contains("2.0 MB"),
1039            "message reports the size: {}",
1040            diag.message
1041        );
1042        assert!(
1043            diag.message.contains("--max-file-size 0"),
1044            "message names the opt-out: {}",
1045            diag.message
1046        );
1047    }
1048
1049    #[test]
1050    fn skipped_source_dotdir_diagnostic_id_and_message() {
1051        let root = Path::new("/project");
1052        let diag = WorkspaceDiagnostic::new(
1053            root,
1054            root.join(".claude"),
1055            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1056        );
1057        assert_eq!(diag.kind.id(), "skipped-source-dotdir");
1058        assert!(
1059            diag.message.contains(".claude"),
1060            "message names the project-relative path: {}",
1061            diag.message
1062        );
1063        assert!(
1064            diag.message
1065                .contains("Its imports and exports are not analyzed."),
1066            "message states the consequence: {}",
1067            diag.message
1068        );
1069        assert!(
1070            diag.message.contains("--root"),
1071            "message names the real remedy: {}",
1072            diag.message
1073        );
1074        assert!(
1075            diag.message.contains("ignorePatterns"),
1076            "message names the silencing route: {}",
1077            diag.message
1078        );
1079        assert!(
1080            diag.message.contains("no config field"),
1081            "the message must say plainly that no config field traverses it: {}",
1082            diag.message
1083        );
1084        assert_eq!(
1085            serde_json::to_value(&diag).expect("serializes")["kind"],
1086            "skipped-source-dotdir",
1087            "id() must byte-match the serde kebab-case tag"
1088        );
1089    }
1090
1091    #[cfg(feature = "schema")]
1092    #[test]
1093    fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
1094        let schema = schemars::schema_for!(WorkspaceDiagnostic);
1095        let json = serde_json::to_string(&schema).expect("schema serializes");
1096        assert!(json.contains("skipped-source-dotdir"));
1097    }
1098
1099    #[test]
1100    fn source_read_failure_serializes_typed_error_payload() {
1101        let root = Path::new("/project");
1102        let diagnostic = WorkspaceDiagnostic::new(
1103            root,
1104            root.join("src/removed.ts"),
1105            WorkspaceDiagnosticKind::SourceReadFailure {
1106                error: "No such file or directory".to_string(),
1107            },
1108        );
1109
1110        let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
1111        assert_eq!(json["kind"], "source-read-failure");
1112        assert_eq!(
1113            json["path"],
1114            root.join("src/removed.ts")
1115                .display()
1116                .to_string()
1117                .replace('\\', "/")
1118        );
1119        assert_eq!(json["error"], "No such file or directory");
1120        assert!(
1121            json["message"]
1122                .as_str()
1123                .is_some_and(|message| message.contains("src/removed.ts"))
1124        );
1125    }
1126
1127    #[cfg(feature = "schema")]
1128    #[test]
1129    fn workspace_diagnostic_schema_includes_source_read_failure() {
1130        let schema = schemars::schema_for!(WorkspaceDiagnostic);
1131        let json = serde_json::to_string(&schema).expect("schema serializes");
1132        assert!(json.contains("source-read-failure"));
1133        assert!(json.contains("error"));
1134    }
1135
1136    #[test]
1137    fn bun_lockb_override_resolution_skipped_id_and_message() {
1138        let root = Path::new("/project");
1139        let diag = WorkspaceDiagnostic::new(
1140            root,
1141            root.join("package.json"),
1142            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1143        );
1144        assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
1145        assert!(
1146            diag.message.contains("'package.json'"),
1147            "message names the project-relative manifest: {}",
1148            diag.message
1149        );
1150        assert!(
1151            diag.message.contains("no parseable text lockfile"),
1152            "message states the cause: {}",
1153            diag.message
1154        );
1155        assert!(
1156            !diag.message.contains("only bun.lockb"),
1157            "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
1158             bun.lock may sit beside it: {}",
1159            diag.message
1160        );
1161        assert!(
1162            diag.message.contains("bun install --save-text-lockfile")
1163                && diag.message.contains("delete the stale bun.lockb"),
1164            "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
1165            diag.message
1166        );
1167        let json = serde_json::to_value(&diag).expect("diagnostic serializes");
1168        assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
1169    }
1170
1171    #[test]
1172    fn bun_override_diagnostic_ids_and_messages_are_actionable() {
1173        let root = Path::new("/project");
1174        let malformed = WorkspaceDiagnostic::new(
1175            root,
1176            root.join("bun.lock"),
1177            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1178        );
1179        assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
1180        assert!(malformed.message.contains("regenerate"));
1181
1182        let shadowed = WorkspaceDiagnostic::new(
1183            root,
1184            root.join("package.json"),
1185            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1186        );
1187        assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
1188        assert!(shadowed.message.contains("ignores `resolutions`"));
1189    }
1190
1191    #[test]
1192    fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
1193        let root = Path::new("/project");
1194        let inside = WorkspaceDiagnostic::new(
1195            root,
1196            root.join("packages/inner"),
1197            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1198        )
1199        .into_root_relative(root);
1200        assert_eq!(inside.path, Path::new("packages/inner"));
1201
1202        let outside = WorkspaceDiagnostic::new(
1203            root,
1204            PathBuf::from("/elsewhere/packages/inner"),
1205            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1206        )
1207        .into_root_relative(root);
1208        assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
1209    }
1210
1211    #[test]
1212    fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
1213        let analysis_stage = [
1214            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1215                error: "bad yaml".to_owned(),
1216            },
1217            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1218            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1219            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1220        ];
1221        for kind in &analysis_stage {
1222            assert!(
1223                kind.is_analysis_stage() && !kind.is_source_discovery(),
1224                "{} is recorded by the analyze stage only",
1225                kind.id()
1226            );
1227        }
1228
1229        let other = [
1230            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1231            WorkspaceDiagnosticKind::MalformedPackageJson {
1232                error: "trailing comma".to_owned(),
1233            },
1234            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1235                pattern: "packages/*".to_owned(),
1236            },
1237            WorkspaceDiagnosticKind::MalformedTsconfig {
1238                error: "unexpected token".to_owned(),
1239            },
1240            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1241            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1242            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1243            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1244            WorkspaceDiagnosticKind::SourceReadFailure {
1245                error: "permission denied".to_owned(),
1246            },
1247        ];
1248        for kind in &other {
1249            assert!(
1250                !kind.is_analysis_stage(),
1251                "{} is a discovery kind, not an analyze-stage kind",
1252                kind.id()
1253            );
1254        }
1255    }
1256
1257    #[test]
1258    fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
1259        let root = Path::new("/project");
1260        let first = WorkspaceDiagnostic::new(
1261            root,
1262            root.join("packages/aaa"),
1263            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1264                pattern: "packages/*".to_owned(),
1265            },
1266        );
1267        let second = WorkspaceDiagnostic::new(
1268            root,
1269            root.join("packages/aaa"),
1270            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1271                pattern: "packages/a*".to_owned(),
1272            },
1273        );
1274
1275        let merged =
1276            merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
1277
1278        let patterns: Vec<String> = merged
1279            .iter()
1280            .map(|diagnostic| match &diagnostic.kind {
1281                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
1282                other => panic!("unexpected kind {}", other.id()),
1283            })
1284            .collect();
1285        assert_eq!(
1286            patterns,
1287            ["packages/*", "packages/a*"],
1288            "two overlapping globs report the same directory twice, with their own pattern; \
1289             the same entry seen from two observation points still folds to one"
1290        );
1291    }
1292
1293    /// Issue #2366: a repository that declares one glob in two manifests
1294    /// (`"./apps/**"` in `package.json`, `apps/**` in `pnpm-workspace.yaml`)
1295    /// must not report every package-less directory under it twice now that the
1296    /// payload is part of the dedupe key.
1297    #[test]
1298    fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
1299        let root = Path::new("/project");
1300        let dotted = WorkspaceDiagnostic::new(
1301            root,
1302            root.join("apps/site/.next/cache"),
1303            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1304                pattern: "./apps/**".to_owned(),
1305            },
1306        );
1307        let bare = WorkspaceDiagnostic::new(
1308            root,
1309            root.join("apps/site/.next/cache"),
1310            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1311                pattern: "apps/**".to_owned(),
1312            },
1313        );
1314        assert_eq!(
1315            dotted.kind, bare.kind,
1316            "the no-op ./ prefix is normalised out of the recorded pattern"
1317        );
1318        assert!(
1319            dotted.message.contains("Glob 'apps/**'"),
1320            "the message renders the normalised pattern: {}",
1321            dotted.message
1322        );
1323
1324        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1325        assert_eq!(
1326            merged.len(),
1327            1,
1328            "one glob declared twice is one diagnostic: {merged:?}"
1329        );
1330    }
1331
1332    /// A glob spelled exactly `"./"` (the project root itself) is the one
1333    /// pattern the prefix strip must leave alone: an empty `pattern` field
1334    /// names no glob, and the warning would quote nothing.
1335    #[test]
1336    fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
1337        let root = Path::new("/project");
1338        let recorded = |pattern: &str| {
1339            let diagnostic = WorkspaceDiagnostic::new(
1340                root,
1341                root.join("pkgs"),
1342                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1343                    pattern: pattern.to_owned(),
1344                },
1345            );
1346            let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
1347            else {
1348                panic!("constructed a glob-matched-no-package-json diagnostic");
1349            };
1350            (pattern, diagnostic.message)
1351        };
1352
1353        let (root_pattern, root_message) = recorded("./");
1354        assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
1355        assert!(
1356            root_message.contains("Glob './'"),
1357            "the warning names the glob the manifest declared: {root_message}"
1358        );
1359        assert_eq!(recorded(".\\").0, ".\\");
1360        assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
1361        assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
1362    }
1363
1364    /// Issue #2366, the path half of the same repository shape: expanding
1365    /// `./pkgs/*` joins the no-op `.` into every match, so the two manifests
1366    /// hand one directory to the diagnostic under two spellings. Both must
1367    /// store, render and serialise as the bare one, otherwise whichever
1368    /// manifest was read first decides whether the analysis envelopes print
1369    /// `./pkgs/aaa` while the workspace listing envelope prints `pkgs/aaa`.
1370    #[test]
1371    fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
1372        let root = Path::new("/project");
1373        let dotted = WorkspaceDiagnostic::new(
1374            root,
1375            root.join("./pkgs/aaa"),
1376            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1377                pattern: "./pkgs/*".to_owned(),
1378            },
1379        );
1380        let bare = WorkspaceDiagnostic::new(
1381            root,
1382            root.join("pkgs/aaa"),
1383            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1384                pattern: "pkgs/*".to_owned(),
1385            },
1386        );
1387
1388        let spelling = |diagnostic: &WorkspaceDiagnostic| {
1389            diagnostic.path.display().to_string().replace('\\', "/")
1390        };
1391        assert_eq!(
1392            spelling(&dotted),
1393            "/project/pkgs/aaa",
1394            "the stored path drops the no-op . component, which Path equality \
1395             hides but serialization does not"
1396        );
1397        assert_eq!(spelling(&dotted), spelling(&bare));
1398        assert_eq!(
1399            spelling(&dotted.clone().into_root_relative(root)),
1400            "pkgs/aaa"
1401        );
1402
1403        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1404        assert_eq!(
1405            merged.len(),
1406            1,
1407            "one directory reached through two spellings of one glob: {merged:?}"
1408        );
1409    }
1410
1411    /// The single-list fold applied at workspace discovery keeps one entry per
1412    /// `(kind, path)` and leaves distinct payloads alone.
1413    #[test]
1414    fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
1415        let root = Path::new("/project");
1416        let glob = |pattern: &str, relative: &str| {
1417            WorkspaceDiagnostic::new(
1418                root,
1419                root.join(relative),
1420                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1421                    pattern: pattern.to_owned(),
1422                },
1423            )
1424        };
1425
1426        let deduped = dedupe_workspace_diagnostics(vec![
1427            glob("pkgs/*", "pkgs/aaa"),
1428            glob("pkgs/*", "pkgs/bbb"),
1429            glob("./pkgs/*", "./pkgs/aaa"),
1430            glob("pkgs/a*", "pkgs/aaa"),
1431        ]);
1432
1433        let reported: Vec<(String, String)> = deduped
1434            .iter()
1435            .map(|diagnostic| match &diagnostic.kind {
1436                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
1437                    pattern.clone(),
1438                    diagnostic.path.display().to_string().replace('\\', "/"),
1439                ),
1440                other => panic!("unexpected kind {}", other.id()),
1441            })
1442            .collect();
1443
1444        assert_eq!(
1445            reported,
1446            vec![
1447                ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
1448                ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
1449                ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
1450            ],
1451            "the duplicate spelling folds away and the overlapping glob stays"
1452        );
1453    }
1454
1455    /// The class `reachability_caveats[]` is computed from. Every kind here
1456    /// means the run never read a file that is part of the project, so its
1457    /// imports credit nothing and the modules it imports can be reported
1458    /// unused with a removal action on them. Classifying a kind `true` is the
1459    /// only wiring its findings need to inherit the caveat and the `fallow fix`
1460    /// withholding that follows it.
1461    #[test]
1462    fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
1463        for kind in [
1464            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1465            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1466            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1467            WorkspaceDiagnosticKind::SourceReadFailure {
1468                error: "permission denied".to_owned(),
1469            },
1470        ] {
1471            assert!(
1472                kind.source_never_analyzed(),
1473                "{} names a source file this run never read",
1474                kind.id()
1475            );
1476        }
1477
1478        let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
1479            error_count: 3,
1480            panicked: false,
1481        };
1482        assert!(
1483            !degraded.source_never_analyzed(),
1484            "a degraded parse read the file, so it has a graph node and its reachability is \
1485             observable; the caveat pass narrows it instead of treating it as unread"
1486        );
1487
1488        for kind in [
1489            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1490            WorkspaceDiagnosticKind::MalformedPackageJson {
1491                error: "trailing comma".to_owned(),
1492            },
1493            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1494                pattern: "packages/*".to_owned(),
1495            },
1496            WorkspaceDiagnosticKind::MalformedTsconfig {
1497                error: "unexpected token".to_owned(),
1498            },
1499            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1500            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1501                error: "bad indent".to_owned(),
1502            },
1503            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1504            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1505            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1506            WorkspaceDiagnosticKind::NodeModulesMissing,
1507            WorkspaceDiagnosticKind::BoundariesNotConfigured,
1508            WorkspaceDiagnosticKind::RulePacksNotConfigured,
1509        ] {
1510            assert!(
1511                !kind.source_never_analyzed(),
1512                "{} says nothing about a source file's imports going unseen",
1513                kind.id()
1514            );
1515        }
1516    }
1517
1518    #[test]
1519    fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
1520        for kind in [
1521            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1522            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1523            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1524        ] {
1525            assert!(
1526                kind.is_source_walk_recorded() && kind.is_source_discovery(),
1527                "{} is written by the source walk",
1528                kind.id()
1529            );
1530        }
1531
1532        let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
1533            error: "permission denied".to_owned(),
1534        };
1535        assert!(
1536            read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
1537            "the parse stage records source-read-failure after the walk, so it must keep \
1538             reaching sessions through the registry"
1539        );
1540
1541        for kind in [
1542            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1543            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1544            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1545            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1546            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1547        ] {
1548            assert!(
1549                !kind.is_source_walk_recorded(),
1550                "{} is not written by the source walk",
1551                kind.id()
1552            );
1553        }
1554    }
1555
1556    /// Issue #2638, the single most load-bearing classification in the new
1557    /// kind. Answering `true` here would attach `IncompleteFileAnalysis` and
1558    /// `IncompleteImportGraph` caveats to findings on nearly every project
1559    /// that keeps a non-gitignored `dist/` or `coverage/`, and make
1560    /// `fallow fix` withhold `delete-file` and `remove-export` project-wide.
1561    /// A built-in exclusion is designed behavior on generated output, not a
1562    /// degraded run.
1563    #[test]
1564    fn a_built_in_ignore_exclusion_is_not_a_file_the_run_failed_to_analyze() {
1565        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1566            pattern: "**/build/**".to_owned(),
1567            file_count: 3,
1568            directory_count: 1,
1569        };
1570        assert!(!kind.source_never_analyzed());
1571    }
1572
1573    /// Issue #2638: these exclusions fire in the product's default state on
1574    /// most monorepos, so a default stderr line would be permanent noise that
1575    /// names no defect. The CLI prints a note under `--explain-skipped`
1576    /// instead.
1577    #[test]
1578    fn a_built_in_ignore_exclusion_does_not_warn_on_stderr_by_default() {
1579        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1580            pattern: "**/build/**".to_owned(),
1581            file_count: 3,
1582            directory_count: 1,
1583        };
1584        assert!(!kind.warns_on_stderr());
1585    }
1586
1587    /// Issue #2638 plus issue #2366: the walk writes it, so it has to be
1588    /// classified as source-discovery (or combined mode's per-analysis config
1589    /// reloads wipe it before serialization) AND as walk-recorded (or a
1590    /// concurrent walk's tally is folded into another analysis's list).
1591    #[test]
1592    fn a_built_in_ignore_exclusion_is_walk_recorded_source_discovery() {
1593        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1594            pattern: "**/build/**".to_owned(),
1595            file_count: 3,
1596            directory_count: 1,
1597        };
1598        assert!(kind.is_source_discovery());
1599        assert!(kind.is_source_walk_recorded());
1600        assert!(!kind.is_analysis_stage());
1601        assert_eq!(kind.id(), "excluded-by-default-ignore");
1602    }
1603
1604    /// Issue #2638: the message has to name the pattern the reader cannot see,
1605    /// the directory, and the only remedy that actually analyzes the tree.
1606    /// `ignorePatterns` is not that remedy: the compiled set unions, so it
1607    /// cannot negate a built-in.
1608    #[test]
1609    fn a_built_in_ignore_exclusion_message_names_the_pattern_and_the_root_remedy() {
1610        let root = Path::new("/project");
1611        let diag = WorkspaceDiagnostic::new(
1612            root,
1613            root.join("packages/web/build"),
1614            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1615                pattern: "**/build/**".to_owned(),
1616                file_count: 4,
1617                directory_count: 1,
1618            },
1619        );
1620        assert!(diag.message.contains("**/build/**"), "{}", diag.message);
1621        assert!(
1622            diag.message.contains("packages/web/build"),
1623            "{}",
1624            diag.message
1625        );
1626        assert!(
1627            diag.message.contains("fallow --root packages/web/build"),
1628            "the remedy is copy-pasteable: {}",
1629            diag.message
1630        );
1631        assert!(
1632            diag.message
1633                .contains("cannot be switched off through ignorePatterns"),
1634            "the message must not advertise a negation that does not exist: {}",
1635            diag.message
1636        );
1637    }
1638
1639    /// One excluded file reads as one file, not as "1 source files".
1640    #[test]
1641    fn a_single_excluded_file_message_is_singular() {
1642        let root = Path::new("/project");
1643        let diag = WorkspaceDiagnostic::new(
1644            root,
1645            root.join("dist"),
1646            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1647                pattern: "**/dist/**".to_owned(),
1648                file_count: 1,
1649                directory_count: 1,
1650            },
1651        );
1652        assert!(
1653            diag.message
1654                .starts_with("Skipped 1 source file under 'dist'"),
1655            "{}",
1656            diag.message
1657        );
1658        assert!(diag.message.contains("it matches"), "{}", diag.message);
1659        assert!(
1660            diag.message
1661                .contains("nothing it imports, exports, or defines"),
1662            "the whole sentence agrees in number, not just its first clause: {}",
1663            diag.message
1664        );
1665    }
1666
1667    /// The anchor directory is the largest group, never a majority: ten
1668    /// packages each holding one excluded file make every one of them "the
1669    /// largest", and a message claiming otherwise is false on exactly the flat
1670    /// monorepo shape issue #2638 is about.
1671    #[test]
1672    fn a_scattered_exclusion_names_the_largest_group_and_counts_the_directories() {
1673        let root = Path::new("/project");
1674        let diag = WorkspaceDiagnostic::new(
1675            root,
1676            root.join("packages/a/dist"),
1677            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1678                pattern: "**/dist/**".to_owned(),
1679                file_count: 10,
1680                directory_count: 10,
1681            },
1682        );
1683        assert!(
1684            diag.message.starts_with(
1685                "Skipped 10 source files across 10 directories, the largest group under \
1686                 'packages/a/dist'"
1687            ),
1688            "{}",
1689            diag.message
1690        );
1691        assert!(
1692            !diag.message.contains("the most of them"),
1693            "a max-of-group is not a majority: {}",
1694            diag.message
1695        );
1696    }
1697
1698    /// A file-shaped built-in matches on the file name, so the `--root` remedy
1699    /// the directory-shaped patterns get would re-exclude the same file. The
1700    /// message must not print a command that provably does nothing.
1701    #[test]
1702    fn a_file_shaped_pattern_does_not_advertise_the_root_remedy() {
1703        let root = Path::new("/project");
1704        let diag = WorkspaceDiagnostic::new(
1705            root,
1706            root.join("vendor"),
1707            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1708                pattern: "**/*.min.js".to_owned(),
1709                file_count: 2,
1710                directory_count: 1,
1711            },
1712        );
1713        assert!(
1714            !diag.message.contains("fallow --root"),
1715            "the message explains why re-rooting fails, it does not prescribe it: {}",
1716            diag.message
1717        );
1718        assert!(
1719            diag.message.contains("matches a file name"),
1720            "the message says why: {}",
1721            diag.message
1722        );
1723        assert!(
1724            diag.message.contains("Rename"),
1725            "and names the remedy that does work: {}",
1726            diag.message
1727        );
1728    }
1729
1730    /// A built-in that matched a file sitting directly at the analysis root
1731    /// anchors at the root, and an empty string is not a location.
1732    #[test]
1733    fn a_root_anchored_exclusion_renders_its_location_as_dot() {
1734        let root = Path::new("/project");
1735        let diag = WorkspaceDiagnostic::new(
1736            root,
1737            root.to_path_buf(),
1738            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1739                pattern: "**/*.min.js".to_owned(),
1740                file_count: 1,
1741                directory_count: 1,
1742            },
1743        );
1744        assert!(
1745            diag.message.starts_with("Skipped 1 source file under '.'"),
1746            "{}",
1747            diag.message
1748        );
1749    }
1750
1751    #[test]
1752    fn glob_first_literal_segment_skips_wildcards_and_dot_components() {
1753        assert_eq!(glob_first_literal_segment("**/build/**"), Some("build"));
1754        assert_eq!(glob_first_literal_segment("./dist/**"), Some("dist"));
1755        assert_eq!(glob_first_literal_segment("**/*.min.js"), None);
1756        assert_eq!(glob_first_literal_segment("**/*.bundle.js"), None);
1757        assert_eq!(glob_first_literal_segment("**/{a,b}/**"), None);
1758    }
1759
1760    #[test]
1761    fn format_size_mb_one_decimal() {
1762        assert_eq!(format_size_mb(0), "0.0 MB");
1763        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1764        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1765    }
1766
1767    #[test]
1768    fn undeclared_workspace_message_has_next_step() {
1769        let root = Path::new("/project");
1770        let diag = WorkspaceDiagnostic::new(
1771            root,
1772            root.join("packages/legacy"),
1773            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1774        );
1775        assert_eq!(diag.kind.id(), "undeclared-workspace");
1776        assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
1777        assert!(
1778            diag.message.contains("ignorePatterns"),
1779            "next-step hint preserved: {}",
1780            diag.message
1781        );
1782    }
1783}