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    /// Per-file health scoring failed, so the score list is empty and the
304    /// scored-file count is `0` because nothing was measured rather than
305    /// because the project has no files worth scoring. Every score-derived
306    /// number (the average maintainability index, the refactoring targets, the
307    /// hotspot complexity half) is then structurally zero (issue #2689).
308    FileScoresUnavailable {
309        /// Scoring error text.
310        error: String,
311    },
312    /// Churn-based hotspot analysis was skipped, so the hotspots, churn and
313    /// ownership sections report nothing at all. The remaining health sections
314    /// are unaffected.
315    HotspotsSkipped {
316        /// Which input stopped it, as a kebab-case token: `not-a-repository`,
317        /// `invalid-since` or `churn-file-unreadable`. The set is open.
318        ///
319        /// The cause decides the remedy, which is why it is on the wire: a run
320        /// outside a repository is fixed by running fallow inside one, a
321        /// malformed `--since` by respelling the flag, and a churn file that
322        /// changed under the run by rerunning it. A consumer reading only the
323        /// kind would offer the first remedy for all three.
324        cause: String,
325    },
326    /// The repository is a shallow clone, so churn is measured over the fetched
327    /// history only and every hotspot figure is incomplete.
328    ShallowClone {
329        /// `true` when the run also asked for ownership attribution, which a
330        /// shallow clone skews further by inflating single-author dominance.
331        ownership_requested: bool,
332    },
333    /// No commit timestamp was available, so churn recency and ownership
334    /// staleness were measured against the wall clock and drift between two
335    /// runs over the same commit.
336    UnpinnedClock,
337    /// Ownership attribution was requested but its inputs did not load, so
338    /// hotspot entries carry degraded or absent owner signals.
339    OwnershipUnavailable {
340        /// Which input failed, as a kebab-case token: `invalid-bot-pattern` or
341        /// `codeowners-parse-failed`. The set is open.
342        cause: String,
343        /// Underlying error text.
344        error: String,
345    },
346    /// A saved health snapshot could not be read or parsed, so the trend is
347    /// computed over fewer snapshots than the project has on disk and a
348    /// direction can flip on the missing point alone.
349    TrendSnapshotUnreadable {
350        /// Filesystem or JSON error text.
351        error: String,
352    },
353    /// A framework plugin read a build config and could not read one of its
354    /// keys in full, so part of what the key declares never reached the
355    /// analysis. `path` names the config file.
356    ///
357    /// The reader is syntactic, so a key whose value is computed at build time
358    /// is invisible to it: a Module Federation `exposes: makeExposes()` or a
359    /// `remotes` map spread from an environment module declares entries this
360    /// run does not know about. The consequence is a finding, not a missing
361    /// number: an unread `exposes` target is not registered as an entry point
362    /// and its file can surface as `unused-file`, and an unread `remotes` alias
363    /// is not treated as provided by a remote container and its import can
364    /// surface as an unlisted dependency.
365    ///
366    /// Recorded by the plugin stage, which runs before analysis and is not
367    /// cached, so the entry is present on a warm cache too. It used to be a
368    /// bare `tracing::warn!` from inside the plugin, so it reached no envelope
369    /// and no CI consumer (issue #2736).
370    PluginConfigUnreadable {
371        /// The plugin that read the config, as it labels itself:
372        /// `module-federation` for a standalone `module-federation.config.*`,
373        /// or the bundler plugin (`webpack`, `rspack`, `rsbuild`, `vite`) that
374        /// read the same options inline from its own config.
375        plugin: String,
376        /// The config key that was present and not fully readable (`exposes`,
377        /// `remotes`). The set is open.
378        key: String,
379        /// Why it could not be read, as a kebab-case token:
380        /// `not-object-literal`, `array-form`, `spread` or
381        /// `unreadable-entries`. The set is open.
382        ///
383        /// The reason decides the remedy, which is why it is on the wire: a
384        /// value that is not an object literal is fixed by writing one, while
385        /// unreadable entries are fixed by naming those entries in the config
386        /// option the message points at.
387        reason: String,
388    },
389    /// A framework plugin read a config key it understands, does not model
390    /// that key's effect, and therefore stood a modeled default down. `path`
391    /// names the config file.
392    ///
393    /// The Nuxt auto-import gate is the case this exists for. With
394    /// `autoImports` enabled fallow drops the Nuxt convention entry patterns
395    /// so a genuinely unreferenced convention file is reported, and a
396    /// `components:` or `imports:` block whose effect it cannot model keeps
397    /// them, which silently costs the user the findings they opted in for.
398    ///
399    /// Deliberately NOT one of the [`Self::warns_on_stderr`] kinds. Nothing
400    /// was lost that the run could have measured: the patterns stayed, so
401    /// findings are suppressed rather than invented, and a project in this
402    /// state would otherwise warn on every run forever with "write different
403    /// config" as the only remedy, which is the reason
404    /// `boundaries-not-configured` is off stderr as well.
405    PluginEffectNotModeled {
406        /// The plugin that read the config, as it labels itself (`nuxt`).
407        plugin: String,
408        /// The config key whose effect is not modeled (`components`,
409        /// `imports`). The set is open.
410        key: String,
411        /// Why the effect is not modeled, as a kebab-case token:
412        /// `key-effect-not-modeled` when the key's own value is the reason,
413        /// `config-property-unreadable` when a top-level property of the same
414        /// config file could not be read statically, so no surface in it can
415        /// be classified at all. The set is open.
416        reason: String,
417    },
418    /// Test coverage was auto-detected on disk rather than passed with
419    /// `--coverage`, and `path` names the file that fed the CRAP scores.
420    ///
421    /// Deliberately NOT one of the [`Self::warns_on_stderr`] kinds: nothing
422    /// degraded, the run measured exactly what it found. It is provenance, and
423    /// it is on the wire because a score computed against a file the user did
424    /// not name is not reproducible and nothing else says which file it was.
425    CoverageAutoDetected,
426}
427
428impl WorkspaceDiagnosticKind {
429    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
430    #[must_use]
431    pub const fn id(&self) -> &'static str {
432        match self {
433            Self::UndeclaredWorkspace => "undeclared-workspace",
434            Self::MalformedPackageJson { .. } => "malformed-package-json",
435            Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
436            Self::MalformedTsconfig { .. } => "malformed-tsconfig",
437            Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
438            Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
439            Self::SkippedLargeFile { .. } => "skipped-large-file",
440            Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
441            Self::SkippedSourceDotdir => "skipped-source-dotdir",
442            Self::SourceReadFailure { .. } => "source-read-failure",
443            Self::SourceParseDegraded { .. } => "source-parse-degraded",
444            Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
445            Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
446            Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
447            Self::NodeModulesMissing => "node-modules-missing",
448            Self::BoundariesNotConfigured => "boundaries-not-configured",
449            Self::RulePacksNotConfigured => "rule-packs-not-configured",
450            Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
451            Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
452            Self::FileScoresUnavailable { .. } => "file-scores-unavailable",
453            Self::HotspotsSkipped { .. } => "hotspots-skipped",
454            Self::ShallowClone { .. } => "shallow-clone",
455            Self::UnpinnedClock => "unpinned-clock",
456            Self::OwnershipUnavailable { .. } => "ownership-unavailable",
457            Self::TrendSnapshotUnreadable { .. } => "trend-snapshot-unreadable",
458            Self::PluginConfigUnreadable { .. } => "plugin-config-unreadable",
459            Self::PluginEffectNotModeled { .. } => "plugin-effect-not-modeled",
460            Self::CoverageAutoDetected => "coverage-auto-detected",
461        }
462    }
463
464    /// Whether this diagnostic is worth a `tracing::warn!` line on stderr, on
465    /// top of its permanent entry in `workspace_diagnostics[]`.
466    ///
467    /// A warning is for a run whose RESULTS are degraded: something the user
468    /// installed, wrote, or expected did not reach the analysis. The two
469    /// unconfigured-check kinds are not that. They fire in the product's
470    /// default state, on every project that never opted into boundaries or
471    /// rule packs, and they will keep firing forever, because the remedy they
472    /// offer is to write configuration in order to silence a warning about not
473    /// having written configuration. They stay in the structured array, where a
474    /// consumer that wants to distinguish "measured zero" from "measured
475    /// nothing" can read them, and off the stderr surface that every other
476    /// command shares.
477    ///
478    /// `coverage-auto-detected` answers false for a third reason: it reports
479    /// the provenance of an input that DID load, so a consumer sentence about a
480    /// degraded run would state something untrue about it. Its own note is
481    /// printed by the health pipeline.
482    ///
483    /// `plugin-effect-not-modeled` answers false for the first reason: the
484    /// config was readable and nothing the run could have measured was lost,
485    /// so it would warn forever on a project whose `nuxt.config` fallow does
486    /// not model. Its sibling `plugin-config-unreadable` answers true, because
487    /// there a declaration the user wrote did not reach the analysis and
488    /// findings can be wrong in either direction.
489    #[must_use]
490    pub const fn warns_on_stderr(&self) -> bool {
491        match self {
492            Self::BoundariesNotConfigured
493            | Self::RulePacksNotConfigured
494            | Self::ExcludedByDefaultIgnore { .. }
495            | Self::PluginEffectNotModeled { .. }
496            | Self::CoverageAutoDetected => false,
497            Self::UndeclaredWorkspace
498            | Self::MalformedPackageJson { .. }
499            | Self::GlobMatchedNoPackageJson { .. }
500            | Self::MalformedTsconfig { .. }
501            | Self::TsconfigReferenceDirMissing
502            | Self::MalformedPnpmWorkspaceYaml { .. }
503            | Self::SkippedLargeFile { .. }
504            | Self::SkippedMinifiedFile { .. }
505            | Self::SkippedSourceDotdir
506            | Self::SourceReadFailure { .. }
507            | Self::SourceParseDegraded { .. }
508            | Self::BunLockbOverrideResolutionSkipped
509            | Self::BunLockOverrideResolutionSkipped
510            | Self::BunResolutionsShadowedByOverrides
511            | Self::NodeModulesMissing
512            | Self::NoSourceFilesAnalyzed { .. }
513            | Self::FileScoresUnavailable { .. }
514            | Self::HotspotsSkipped { .. }
515            | Self::ShallowClone { .. }
516            | Self::UnpinnedClock
517            | Self::OwnershipUnavailable { .. }
518            | Self::TrendSnapshotUnreadable { .. }
519            | Self::PluginConfigUnreadable { .. } => true,
520        }
521    }
522
523    /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
524    /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
525    /// discovery diagnostics are APPENDED to the registry after config load, so
526    /// `stash_workspace_diagnostics` must preserve them when it replaces the
527    /// workspace-discovery set, otherwise the per-analysis config re-loads in
528    /// combined-mode (`fallow` with no subcommand re-loads config for check,
529    /// dupes, and health) wipe them before the JSON envelope is built (issue
530    /// #1086).
531    #[must_use]
532    pub const fn is_source_discovery(&self) -> bool {
533        matches!(
534            self,
535            Self::SkippedLargeFile { .. }
536                | Self::SkippedMinifiedFile { .. }
537                | Self::SkippedSourceDotdir
538                | Self::SourceReadFailure { .. }
539                | Self::SourceParseDegraded { .. }
540                | Self::NodeModulesMissing
541                | Self::ExcludedByDefaultIgnore { .. }
542                | Self::NoSourceFilesAnalyzed { .. }
543        )
544    }
545
546    /// Whether this diagnostic is written by the source file WALK
547    /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
548    /// walk replaces wholesale for its root. `source-read-failure` is the
549    /// other source-discovery kind and is NOT one of these: the parse stage
550    /// records it after the walk, so it has to keep reaching consumers through
551    /// the registry.
552    ///
553    /// A walk-recorded entry must reach an analysis from its OWN walk's return
554    /// value. Combined mode runs the dead-code and duplication walks under
555    /// `rayon::join` whenever a per-analysis `production` split stops them from
556    /// sharing a file list, so a registry read answers "whichever walk wrote
557    /// last" and varies between runs of the same command (issue #2366).
558    #[must_use]
559    pub const fn is_source_walk_recorded(&self) -> bool {
560        matches!(
561            self,
562            Self::SkippedLargeFile { .. }
563                | Self::SkippedMinifiedFile { .. }
564                | Self::SkippedSourceDotdir
565                | Self::NodeModulesMissing
566                | Self::ExcludedByDefaultIgnore { .. }
567                | Self::NoSourceFilesAnalyzed { .. }
568        )
569    }
570
571    /// Whether this diagnostic reports a source file whose contents this run
572    /// never analyzed, so every import and export the file holds is invisible
573    /// to the module graph.
574    ///
575    /// This is the class `reachability_caveats[]` exists for. A file the run
576    /// never read credits nothing, so the modules it imports surface as
577    /// confident `unused-file` and `unused-export` findings carrying
578    /// `delete-file` and `remove-export` actions, and `fallow fix` would
579    /// otherwise apply the removal against source that still imports the
580    /// target.
581    ///
582    /// All four discovery-side kinds qualify, for the same reason and with the
583    /// same consequence:
584    ///
585    /// - `skipped-large-file` and `skipped-minified-file`: the file is in the
586    ///   project tree and was never opened, so its import list is unknown.
587    /// - `skipped-source-dotdir`: the directory holds at least one source file
588    ///   the project did not exclude, and none of them were traversed. The
589    ///   diagnostic is capped, so it under-reports rather than over-reports;
590    ///   its presence still proves unseen source exists.
591    /// - `source-read-failure`: the file was discovered and then could not be
592    ///   read, so nothing was extracted from it at all.
593    ///
594    /// `source-parse-degraded` is deliberately NOT one of these, though it
595    /// belongs to the same family. Neither is `excluded-by-default-ignore`,
596    /// for a different reason: that one reports designed behavior on generated
597    /// output rather than a degraded run, and its own doc comment carries the
598    /// argument.
599    ///
600    /// `source-parse-degraded`: that file WAS read, so it has a module and
601    /// a graph node and its reachability is observable, which lets the caveat
602    /// pass narrow it: a degraded module that is itself unreachable cannot
603    /// change a reachability verdict. Every kind above has no node to ask (a
604    /// read failure has one with nothing extracted into it), so no narrowing
605    /// is available and the caveat they raise is run-level.
606    ///
607    /// The match is exhaustive on purpose: a new "the run did not see this
608    /// file" kind has to be classified here, and answering `true` is the only
609    /// wiring its findings need in order to inherit both the caveat and the
610    /// `fallow fix` withholding that follows it.
611    #[must_use]
612    pub const fn source_never_analyzed(&self) -> bool {
613        match self {
614            Self::SkippedLargeFile { .. }
615            | Self::SkippedMinifiedFile { .. }
616            | Self::SkippedSourceDotdir
617            | Self::SourceReadFailure { .. } => true,
618            Self::UndeclaredWorkspace
619            | Self::MalformedPackageJson { .. }
620            | Self::GlobMatchedNoPackageJson { .. }
621            | Self::MalformedTsconfig { .. }
622            | Self::TsconfigReferenceDirMissing
623            | Self::MalformedPnpmWorkspaceYaml { .. }
624            | Self::SourceParseDegraded { .. }
625            | Self::BunLockbOverrideResolutionSkipped
626            | Self::BunLockOverrideResolutionSkipped
627            | Self::BunResolutionsShadowedByOverrides
628            | Self::NodeModulesMissing
629            | Self::BoundariesNotConfigured
630            | Self::RulePacksNotConfigured
631            | Self::ExcludedByDefaultIgnore { .. }
632            | Self::NoSourceFilesAnalyzed { .. }
633            | Self::FileScoresUnavailable { .. }
634            | Self::HotspotsSkipped { .. }
635            | Self::ShallowClone { .. }
636            | Self::UnpinnedClock
637            | Self::OwnershipUnavailable { .. }
638            | Self::TrendSnapshotUnreadable { .. }
639            | Self::PluginConfigUnreadable { .. }
640            | Self::PluginEffectNotModeled { .. }
641            | Self::CoverageAutoDetected => false,
642        }
643    }
644
645    /// Whether this diagnostic is recorded by the ANALYZE stage (the
646    /// dependency-catalog and override detectors) rather than by workspace or
647    /// source discovery. Analysis-stage diagnostics reach the registry through
648    /// `record_workspace_diagnostics` after config load, so
649    /// `stash_workspace_diagnostics` must preserve them across combined-mode's
650    /// per-analysis config re-loads, and every analyze pass clears its previous
651    /// entries before re-recording so a fixed cause drops out on the next run
652    /// (issue #2366). The match is exhaustive on purpose: a new kind must be
653    /// classified here before it compiles.
654    ///
655    /// Classify a kind `true` ONLY when a detector reachable from the dead-code
656    /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
657    /// the single clear site. A kind recorded exclusively by another stage would
658    /// be cleared by the next dead-code pass and never come back.
659    #[must_use]
660    pub const fn is_analysis_stage(&self) -> bool {
661        match self {
662            Self::MalformedPnpmWorkspaceYaml { .. }
663            | Self::BunLockbOverrideResolutionSkipped
664            | Self::BunLockOverrideResolutionSkipped
665            | Self::BunResolutionsShadowedByOverrides
666            | Self::BoundariesNotConfigured
667            | Self::RulePacksNotConfigured => true,
668            Self::UndeclaredWorkspace
669            | Self::MalformedPackageJson { .. }
670            | Self::GlobMatchedNoPackageJson { .. }
671            | Self::MalformedTsconfig { .. }
672            | Self::TsconfigReferenceDirMissing
673            | Self::SkippedLargeFile { .. }
674            | Self::SkippedMinifiedFile { .. }
675            | Self::SkippedSourceDotdir
676            | Self::SourceReadFailure { .. }
677            | Self::SourceParseDegraded { .. }
678            | Self::NodeModulesMissing
679            | Self::ExcludedByDefaultIgnore { .. }
680            | Self::NoSourceFilesAnalyzed { .. }
681            | Self::FileScoresUnavailable { .. }
682            | Self::HotspotsSkipped { .. }
683            | Self::ShallowClone { .. }
684            | Self::UnpinnedClock
685            | Self::OwnershipUnavailable { .. }
686            | Self::TrendSnapshotUnreadable { .. }
687            | Self::PluginConfigUnreadable { .. }
688            | Self::PluginEffectNotModeled { .. }
689            | Self::CoverageAutoDetected => false,
690        }
691    }
692
693    /// Whether this diagnostic is recorded by the HEALTH pipeline (scoring,
694    /// churn, ownership, trend, coverage input resolution) rather than by
695    /// workspace discovery, source discovery or the analyze stage.
696    ///
697    /// Health-stage diagnostics are appended to the registry after config
698    /// load, so `stash_workspace_diagnostics` must preserve them across
699    /// combined mode's per-analysis config re-loads, and the health run clears
700    /// its previous entries before re-recording so a fixed CODEOWNERS drops out
701    /// on the next run (issue #2689).
702    ///
703    /// They are deliberately NOT [`Self::is_analysis_stage`], although they
704    /// share both of those properties. That predicate additionally means "the
705    /// dead-code analyze pass re-records this", and the pass clears every kind
706    /// answering it on entry. Health computes file scores by running that same
707    /// pass, so a health-stage kind classified there would be wiped mid-run by
708    /// the analysis it is reporting on.
709    #[must_use]
710    pub const fn is_health_stage(&self) -> bool {
711        match self {
712            Self::FileScoresUnavailable { .. }
713            | Self::HotspotsSkipped { .. }
714            | Self::ShallowClone { .. }
715            | Self::UnpinnedClock
716            | Self::OwnershipUnavailable { .. }
717            | Self::TrendSnapshotUnreadable { .. }
718            | Self::CoverageAutoDetected => true,
719            Self::UndeclaredWorkspace
720            | Self::MalformedPackageJson { .. }
721            | Self::GlobMatchedNoPackageJson { .. }
722            | Self::MalformedTsconfig { .. }
723            | Self::TsconfigReferenceDirMissing
724            | Self::MalformedPnpmWorkspaceYaml { .. }
725            | Self::SkippedLargeFile { .. }
726            | Self::SkippedMinifiedFile { .. }
727            | Self::SkippedSourceDotdir
728            | Self::SourceReadFailure { .. }
729            | Self::SourceParseDegraded { .. }
730            | Self::BunLockbOverrideResolutionSkipped
731            | Self::BunLockOverrideResolutionSkipped
732            | Self::BunResolutionsShadowedByOverrides
733            | Self::NodeModulesMissing
734            | Self::BoundariesNotConfigured
735            | Self::RulePacksNotConfigured
736            | Self::ExcludedByDefaultIgnore { .. }
737            | Self::PluginConfigUnreadable { .. }
738            | Self::PluginEffectNotModeled { .. }
739            | Self::NoSourceFilesAnalyzed { .. } => false,
740        }
741    }
742
743    /// Whether this diagnostic is recorded by the PLUGIN stage (framework
744    /// plugins reading their own build configs) rather than by workspace
745    /// discovery, source discovery, the analyze stage or the health pipeline.
746    ///
747    /// Plugin-stage diagnostics are recorded after config load, so
748    /// `stash_workspace_diagnostics` must preserve them across combined mode's
749    /// per-analysis config re-loads, and each plugin run replaces the previous
750    /// run's set so a fixed config drops out on the next run (issue #2736).
751    ///
752    /// They are deliberately NOT [`Self::is_analysis_stage`], although they
753    /// share both of those properties. That predicate additionally means "the
754    /// dead-code analyze pass re-records this", and the pass clears every kind
755    /// answering it on entry. Plugins run in the prelude of that same pass, so
756    /// a plugin-stage kind classified there would be wiped inside the run that
757    /// produced it.
758    ///
759    /// The match is exhaustive on purpose: a new kind must be classified here
760    /// before it compiles.
761    #[must_use]
762    pub const fn is_plugin_stage(&self) -> bool {
763        match self {
764            Self::PluginConfigUnreadable { .. } | Self::PluginEffectNotModeled { .. } => true,
765            Self::UndeclaredWorkspace
766            | Self::MalformedPackageJson { .. }
767            | Self::GlobMatchedNoPackageJson { .. }
768            | Self::MalformedTsconfig { .. }
769            | Self::TsconfigReferenceDirMissing
770            | Self::MalformedPnpmWorkspaceYaml { .. }
771            | Self::SkippedLargeFile { .. }
772            | Self::SkippedMinifiedFile { .. }
773            | Self::SkippedSourceDotdir
774            | Self::SourceReadFailure { .. }
775            | Self::SourceParseDegraded { .. }
776            | Self::BunLockbOverrideResolutionSkipped
777            | Self::BunLockOverrideResolutionSkipped
778            | Self::BunResolutionsShadowedByOverrides
779            | Self::NodeModulesMissing
780            | Self::BoundariesNotConfigured
781            | Self::RulePacksNotConfigured
782            | Self::ExcludedByDefaultIgnore { .. }
783            | Self::NoSourceFilesAnalyzed { .. }
784            | Self::FileScoresUnavailable { .. }
785            | Self::HotspotsSkipped { .. }
786            | Self::ShallowClone { .. }
787            | Self::UnpinnedClock
788            | Self::OwnershipUnavailable { .. }
789            | Self::TrendSnapshotUnreadable { .. }
790            | Self::CoverageAutoDetected => false,
791        }
792    }
793}
794
795/// Render a byte count as a megabyte figure with one decimal place for
796/// human-readable diagnostic messages (e.g. `12.3 MB`).
797#[must_use]
798fn format_size_mb(bytes: u64) -> String {
799    #[expect(
800        clippy::cast_precision_loss,
801        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
802    )]
803    let mb = bytes as f64 / (1024.0 * 1024.0);
804    format!("{mb:.1} MB")
805}
806
807/// A diagnostic about a workspace-discovery candidate.
808///
809/// The `message` field is a human-readable rendering derived from `kind`. It
810/// always ends with a concrete next step ("fix the JSON syntax", "remove from
811/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
812/// forward.
813#[derive(Debug, Clone, Serialize, Deserialize)]
814#[cfg_attr(feature = "schema", derive(JsonSchema))]
815pub struct WorkspaceDiagnostic {
816    /// Path to the directory or file that triggered the diagnostic.
817    #[serde(serialize_with = "serde_path::serialize")]
818    pub path: PathBuf,
819    /// Kind discriminator with the typed payload.
820    #[serde(flatten)]
821    pub kind: WorkspaceDiagnosticKind,
822    /// Human-readable rendering derived from `kind` + `path`. Always ends
823    /// with a next-step hint.
824    pub message: String,
825    /// True when this diagnostic reports a run whose RESULTS are degraded:
826    /// something the user installed, wrote, or expected did not reach the
827    /// analysis. Projected from [`WorkspaceDiagnosticKind::warns_on_stderr`],
828    /// which is the same classification that decides whether the CLI prints a
829    /// stderr line, so a CI log built from this field and a local non-quiet run
830    /// say the same thing.
831    ///
832    /// Omitted when false, which is what keeps every clean run byte-identical.
833    /// The two unconfigured-check kinds answer false on purpose: they fire in
834    /// the product's default state on every project that never opted into
835    /// boundaries or rule packs, so warning on them would warn forever. So does
836    /// `excluded-by-default-ignore`, which is designed behavior on generated
837    /// output; the alarm for that case is `no-source-files-analyzed`.
838    ///
839    /// Read this instead of hardcoding a kind allowlist: a degrading kind added
840    /// in a later release then reaches an unchanged consumer.
841    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
842    pub degrades_analysis: bool,
843}
844
845impl WorkspaceDiagnostic {
846    /// Construct a diagnostic with the message rendered from `kind` + `path`.
847    ///
848    /// `root` is used to produce project-relative paths in the message text
849    /// AND inside the variant payload (e.g. the `error` field of
850    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
851    /// file path from `PackageJson::load()`'s error text). Without the
852    /// payload-side normalisation the embedded path would survive
853    /// environment-specific differences (CI vs Docker vs local) because the
854    /// post-serialisation `strip_root_prefix` only catches whole-string
855    /// matches, not paths embedded mid-sentence.
856    ///
857    /// If `path` is not under `root` (e.g. canonicalisation crossed a
858    /// symlink), the absolute path is emitted instead.
859    ///
860    /// `path` also loses any no-op `.` component, for the same reason the
861    /// payload loses a glob's `./` prefix: one directory reached through two
862    /// spellings of one glob must be one diagnostic.
863    #[must_use]
864    pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
865        let path = normalise_diagnostic_path(path);
866        let kind = normalise_payload_paths(root, kind);
867        let message = render_message(root, &path, &kind);
868        let degrades_analysis = kind.warns_on_stderr();
869        Self {
870            path,
871            kind,
872            message,
873            degrades_analysis,
874        }
875    }
876
877    /// Return this diagnostic with `path` rewritten relative to `root`.
878    ///
879    /// `path` is stored absolute so callers can act on it. Every JSON envelope
880    /// emits it project-relative instead: the analysis envelopes get there
881    /// through the post-serialisation `strip_root_prefix` pass, which the
882    /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
883    /// `project_info` tool never run, so those emitted the absolute path while
884    /// the sibling `workspaces[].path` next to it was relative. They normalise
885    /// at the typed layer with this method instead.
886    ///
887    /// Paths outside `root` (canonicalisation crossed a symlink) are left
888    /// absolute, matching how [`Self::new`] renders the message.
889    ///
890    /// A diagnostic anchored at the root itself becomes `.`, not the empty
891    /// path: an empty string is not a location, and the analysis envelopes'
892    /// post-serialisation strip only removes a `root + separator` prefix, so a
893    /// root-anchored path that stays absolute here leaks a host path.
894    #[must_use]
895    pub fn into_root_relative(mut self, root: &Path) -> Self {
896        if let Ok(relative) = self.path.strip_prefix(root) {
897            self.path = if relative.as_os_str().is_empty() {
898                PathBuf::from(".")
899            } else {
900                relative.to_path_buf()
901            };
902        }
903        self
904    }
905}
906
907/// Rebuild `path` from its components so one directory has one spelling.
908///
909/// The dedupe key was never the problem: [`Path`] equality already ignores an
910/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
911/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
912/// expands to the first spelling and the same glob spelled `pkgs/*` in
913/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
914/// make a project-relative path differently: the analysis envelopes strip the
915/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
916/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
917/// `pkgs/aaa`). Whichever
918/// manifest happened to be read first then decided which shape every consumer
919/// saw. Collapsing at construction gives them one answer (issue #2366).
920///
921/// A path that is already component-clean rebuilds to itself. Serialization
922/// normalises separators, so the rebuild is wire-invisible on Windows.
923fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
924    let rebuilt: PathBuf = path.components().collect();
925    if rebuilt.as_os_str() == path.as_os_str() {
926        path
927    } else {
928        rebuilt
929    }
930}
931
932/// Strip the project root from absolute paths embedded inside variant
933/// payloads (the `error` field of malformed-config and source-read failures),
934/// and drop a glob pattern's no-op `./` prefix.
935///
936/// Mirrors the per-platform `display()` byte sequence so the substring match
937/// works on Windows too.
938///
939/// The pattern prefix matters because the payload is part of the dedupe key in
940/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
941/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
942/// glob twice, and without this both spellings would report every package-less
943/// directory under `apps/` a second time (issue #2366).
944fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
945    let root_str = root.display().to_string();
946    let root_alt = root_str.replace('\\', "/");
947    let normalise = |text: String| -> String {
948        let stripped = text
949            .replace(&format!("{root_str}/"), "")
950            .replace(&format!("{root_alt}/"), "");
951        stripped
952            .replace(&format!("{root_str}\\"), "")
953            .replace(&format!("{root_alt}\\"), "")
954    };
955    match kind {
956        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
957            WorkspaceDiagnosticKind::MalformedPackageJson {
958                error: normalise(error),
959            }
960        }
961        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
962            WorkspaceDiagnosticKind::MalformedTsconfig {
963                error: normalise(error),
964            }
965        }
966        WorkspaceDiagnosticKind::SourceReadFailure { error } => {
967            WorkspaceDiagnosticKind::SourceReadFailure {
968                error: normalise(error),
969            }
970        }
971        WorkspaceDiagnosticKind::FileScoresUnavailable { error } => {
972            WorkspaceDiagnosticKind::FileScoresUnavailable {
973                error: normalise(error),
974            }
975        }
976        WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
977            WorkspaceDiagnosticKind::OwnershipUnavailable {
978                cause,
979                error: normalise(error),
980            }
981        }
982        WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => {
983            WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
984                error: normalise(error),
985            }
986        }
987        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
988            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
989                pattern: canonical_glob_pattern(pattern),
990            }
991        }
992        other => other,
993    }
994}
995
996/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
997/// pattern declared in two manifests is one payload.
998///
999/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
1000/// its spelling: stripping it would report an empty `pattern` field and an
1001/// empty quoted glob in the warning text, which names no glob at all.
1002fn canonical_glob_pattern(pattern: String) -> String {
1003    for prefix in ["./", ".\\"] {
1004        if let Some(rest) = pattern.strip_prefix(prefix)
1005            && !rest.is_empty()
1006        {
1007            return rest.to_owned();
1008        }
1009    }
1010    pattern
1011}
1012
1013/// Concatenate two diagnostic lists, keeping the first occurrence of each
1014/// `(kind, path)` pair and the order of `primary` followed by the entries only
1015/// `secondary` has.
1016///
1017/// The single place diagnostics from two observation points are folded
1018/// together: an engine session's own capture plus the process registry, and
1019/// the combined run's per-analysis lists (issue #2366). A combined run walks
1020/// the project once per analysis, and per-analysis `production` modes can make
1021/// those walks see different file sets, so no single observation point holds
1022/// everything the run recorded; the union does, and folding it the same way
1023/// everywhere is what keeps the CLI and the programmatic route answering
1024/// identically.
1025///
1026/// The key is the WHOLE kind, payload included, not its
1027/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
1028/// path and still be two distinct diagnostics: overlapping workspace globs
1029/// (`["packages/*", "packages/*/*"]`) each report the same package-less
1030/// directory with their own `pattern`, and the standalone envelopes report
1031/// both. An id-keyed fold silently dropped the second one.
1032#[must_use]
1033pub fn merge_workspace_diagnostics(
1034    primary: Vec<WorkspaceDiagnostic>,
1035    secondary: Vec<WorkspaceDiagnostic>,
1036) -> Vec<WorkspaceDiagnostic> {
1037    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
1038    let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
1039    for diagnostic in primary.into_iter().chain(secondary) {
1040        let key = (diagnostic.kind.clone(), diagnostic.path.clone());
1041        if seen.insert(key) {
1042            merged.push(diagnostic);
1043        }
1044    }
1045    merged
1046}
1047
1048/// Keep the first occurrence of each `(kind, path)` pair in one list.
1049///
1050/// The single-list form of [`merge_workspace_diagnostics`], applied where
1051/// diagnostics are produced rather than where two observation points are
1052/// folded: workspace discovery reads `package.json` `workspaces`,
1053/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
1054/// `tsconfig.json` references additively, so a repository that declares one
1055/// glob in two of them reports every package-less directory under it twice.
1056/// Deduplicating at that source is what keeps the JSON envelopes, the
1057/// aggregated stderr warning and the process registry telling one story
1058/// (issue #2366).
1059#[must_use]
1060pub fn dedupe_workspace_diagnostics(
1061    diagnostics: Vec<WorkspaceDiagnostic>,
1062) -> Vec<WorkspaceDiagnostic> {
1063    merge_workspace_diagnostics(diagnostics, Vec::new())
1064}
1065
1066/// Render `path` relative to `root` with forward slashes. The forward-slash
1067/// normalisation is load-bearing for cross-platform output stability.
1068fn display_relative(root: &Path, path: &Path) -> String {
1069    path.strip_prefix(root)
1070        .unwrap_or(path)
1071        .display()
1072        .to_string()
1073        .replace('\\', "/")
1074}
1075
1076/// The first segment of a glob that contains no glob metacharacter, so it
1077/// names a real directory rather than a wildcard.
1078///
1079/// Source discovery uses it to decide which directory a built-in ignore
1080/// pattern excluded a file "at"; `render_message` uses it to decide which
1081/// remedy is true for that pattern. The two have to agree, so the function
1082/// lives here rather than once per crate: a pattern with such a segment
1083/// (`**/dist/**`) is lifted by re-rooting inside the matched directory,
1084/// because the glob is matched against the path relative to the run root. A
1085/// pattern without one (`**/*.min.js`) matches on the file name and keeps
1086/// matching at every root.
1087#[must_use]
1088pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
1089    pattern.split('/').find(|segment| {
1090        !segment.is_empty()
1091            && !segment.contains(['*', '?', '[', ']', '{', '}'])
1092            && *segment != "."
1093            && *segment != ".."
1094    })
1095}
1096
1097/// The clause naming why a plugin could not read a config key in full, for one
1098/// `plugin-config-unreadable` reason token.
1099///
1100/// The token set is open, so an unrecognised token renders the general claim
1101/// rather than nothing: a diagnostic from a plugin added later still reads as a
1102/// sentence.
1103fn unreadable_situation(reason: &str) -> &'static str {
1104    match reason {
1105        "array-form" => "uses the array form, which is not read yet",
1106        "spread" => "spreads a value that is not statically readable",
1107        "unreadable-entries" => "has entries that hold no statically readable value",
1108        "not-object-literal" => "is not a static object literal",
1109        _ => "could not be read statically",
1110    }
1111}
1112
1113/// What an unread config key costs, and the configuration option that covers
1114/// the gap.
1115///
1116/// Keyed on the config KEY rather than on the plugin name, because one key is
1117/// read by several plugins: Module Federation `exposes` and `remotes` reach a
1118/// build from a standalone config file and inline from the webpack, rspack,
1119/// rsbuild and vite configs, and both the consequence and the remedy are the
1120/// same in all five. A key this build does not know falls back to the general
1121/// claim rather than borrowing another key's remedy, so a plugin added later
1122/// still renders a sentence that is true.
1123fn unreadable_key_consequence(key: &str) -> (&'static str, &'static str) {
1124    match key {
1125        "exposes" => (
1126            "the targets are not registered as entry points",
1127            "Name the exposed files in `dynamicallyLoaded`.",
1128        ),
1129        "remotes" => (
1130            "the aliases are not treated as provided by a remote container",
1131            "Name the aliases in `ignoreDependencies`, or declare them as the keys of an object \
1132             literal, whose values may be computed.",
1133        ),
1134        _ => (
1135            "what it declares is not fully registered",
1136            "Declare the value as a static object literal.",
1137        ),
1138    }
1139}
1140
1141fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
1142    let display = display_relative(root, path);
1143    match kind {
1144        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
1145            "Directory '{display}' contains package.json but is not declared as a workspace. \
1146             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
1147        ),
1148        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
1149            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
1150             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
1151        ),
1152        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
1153            "Glob '{pattern}' matched '{display}' but no package.json is present. \
1154             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
1155        ),
1156        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
1157            "tsconfig.json at '{display}' failed to parse ({error}); \
1158             project references will be ignored. Fix the JSON syntax."
1159        ),
1160        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
1161            "tsconfig.json references '{display}' but the directory does not exist. \
1162             Update or remove the reference, or restore the missing directory."
1163        ),
1164        WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
1165            "'{display}' failed to parse ({error}); catalog and override entries \
1166             will be ignored. Fix the YAML syntax."
1167        ),
1168        WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
1169            "Skipped '{display}' ({size}): exceeds the max file size limit. \
1170             Its imports and exports are not analyzed. Raise the limit with \
1171             --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
1172             to ignorePatterns.",
1173            size = format_size_mb(*size_bytes)
1174        ),
1175        WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
1176            "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
1177             Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
1178             rename it with a .min.js suffix, or use --max-file-size 0 if this file \
1179             should be analyzed.",
1180            size = format_size_mb(*size_bytes)
1181        ),
1182        WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
1183            "Skipped hidden directory '{display}': it contains source files but hidden \
1184             directories are not traversed. Its imports and exports are not analyzed. \
1185             There is no config field that adds a directory to traversal. If it holds \
1186             first-party source, analyze it on its own with fallow --root {display}; if it \
1187             is tool or agent scratch state, add '{display}/**' to ignorePatterns to \
1188             silence this."
1189        ),
1190        WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
1191            "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
1192             ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
1193        ),
1194        WorkspaceDiagnosticKind::SourceParseDegraded {
1195            error_count,
1196            panicked,
1197        } => {
1198            let outcome = if *panicked {
1199                "the parser stopped there"
1200            } else {
1201                "the parser recovered and continued"
1202            };
1203            format!(
1204                "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
1205                 references it did not reach are missing from this run, so files and symbols it \
1206                 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
1207                 syntax newer than fallow's parser."
1208            )
1209        }
1210        WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
1211            "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
1212             sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
1213             (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
1214             use instead, so unused-dependency-overrides findings are not reported. Run bun install \
1215             --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
1216             bun.lockb if this repository no longer uses bun."
1217        ),
1218        WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
1219            "Skipped dependency-override resolution because '{display}' could not be parsed and \
1220             no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
1221             findings are not reported. Run bun install to regenerate the text lockfile, then \
1222             rerun fallow."
1223        ),
1224        WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
1225            "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
1226             `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
1227             remove the shadowed `resolutions` entries."
1228        ),
1229        WorkspaceDiagnosticKind::NodeModulesMissing => format!(
1230            "'{display}' does not exist. Package exports and conditional exports cannot be read, \
1231             framework plugins that activate on an installed package stay inactive, and \
1232             dependency classification degrades, so imports and dependencies can be \
1233             misreported. Run npm install / pnpm install / yarn / bun install first."
1234        ),
1235        WorkspaceDiagnosticKind::BoundariesNotConfigured => {
1236            "No architecture boundaries are configured, so the boundary detector did not run and \
1237             its violation counts are zero because nothing was measured. Add `boundaries` to the \
1238             config, or set `boundary-violation` to off to state that the check is not wanted."
1239                .to_string()
1240        }
1241        WorkspaceDiagnosticKind::RulePacksNotConfigured => {
1242            "No rule packs are configured, so the policy detector did not run and its violation \
1243             counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
1244             `policy-violation` to off to state that the check is not wanted."
1245                .to_string()
1246        }
1247        WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
1248            excluded_file_count,
1249        } => {
1250            if *excluded_file_count == 0 {
1251                "No source files were analyzed, so every finding count this run reports is zero \
1252                 because nothing was measured. Check the analysis root, ignorePatterns, and any \
1253                 path or workspace filter this run applied."
1254                    .to_owned()
1255            } else {
1256                format!(
1257                    "No source files were analyzed. Fallow's built-in ignore patterns excluded \
1258                     {excluded_file_count} candidate files, so every finding count this run \
1259                     reports is zero because nothing was measured; run with --explain-skipped \
1260                     for the breakdown."
1261                )
1262            }
1263        }
1264        WorkspaceDiagnosticKind::FileScoresUnavailable { error } => format!(
1265            "Could not compute per-file health scores ({error}), so the score list is empty and \
1266             the scored-file count is 0 because nothing was measured rather than because the \
1267             project has nothing to score. Rerun with --no-cache, or scope the run to a \
1268             subdirectory to find the input that fails."
1269        ),
1270        WorkspaceDiagnosticKind::HotspotsSkipped { cause } => match cause.as_str() {
1271            "invalid-since" => "Hotspot analysis was skipped because --since could not be read \
1272                 as a time window, so the hotspots, churn and ownership sections report nothing \
1273                 rather than zero. Spell it as a duration such as 6m or 90d, or drop it to use \
1274                 the default window."
1275                .to_owned(),
1276            "churn-file-unreadable" => format!(
1277                "Hotspot analysis was skipped because the churn file '{display}' could no longer \
1278                 be read after it was validated, so the hotspots, churn and ownership sections \
1279                 report nothing rather than zero. Make sure nothing rewrites the file while \
1280                 fallow runs, and rerun."
1281            ),
1282            // The original single cause, whose wording predates the token and
1283            // is kept byte-identical: a consumer matching on this sentence is
1284            // reading the same run it always was.
1285            _ => "Hotspot analysis was skipped because no git repository was found at the \
1286                  project root, so the hotspots, churn and ownership sections report nothing \
1287                  rather than zero. Run fallow inside the repository, or pass --churn-file with \
1288                  exported change history."
1289                .to_owned(),
1290        },
1291        WorkspaceDiagnosticKind::ShallowClone {
1292            ownership_requested,
1293        } => {
1294            let ownership = if *ownership_requested {
1295                " Ownership signals are skewed too, because a shallow clone inflates \
1296                 single-author dominance."
1297            } else {
1298                ""
1299            };
1300            format!(
1301                "This is a shallow clone, so churn covers only the fetched history and every \
1302                 hotspot figure is incomplete.{ownership} Run git fetch --unshallow for the full \
1303                 history."
1304            )
1305        }
1306        WorkspaceDiagnosticKind::UnpinnedClock => {
1307            "No commit timestamp was available, so churn recency and ownership staleness were \
1308             measured against the wall clock and drift between runs over the same commit. Set \
1309             FALLOW_CLOCK_EPOCH to pin the run clock."
1310                .to_owned()
1311        }
1312        WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1313            if cause == "codeowners-parse-failed" {
1314                format!(
1315                    "Ownership signals are degraded: CODEOWNERS could not be parsed ({error}), \
1316                     so hotspot entries carry no declared owner. Fix the CODEOWNERS syntax, or \
1317                     drop --ownership for this run."
1318                )
1319            } else {
1320                format!(
1321                    "Ownership signals are degraded: health.ownership.botPatterns contains an \
1322                     invalid glob ({error}), so no author is classified as a bot and bot commits \
1323                     count towards ownership. Fix the pattern, or remove it from the config."
1324                )
1325            }
1326        }
1327        WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => format!(
1328            "Skipped health snapshot '{display}' ({error}), so the trend is computed over fewer \
1329             snapshots than this project has on disk. Delete the unreadable file, or rewrite it \
1330             with fallow health --save-snapshot."
1331        ),
1332        WorkspaceDiagnosticKind::CoverageAutoDetected => format!(
1333            "Coverage was auto-detected at '{display}' rather than passed with --coverage, so the \
1334             CRAP scores depend on whichever coverage file is on disk at run time. Pass --coverage \
1335             '{display}' explicitly for reproducible scores."
1336        ),
1337        WorkspaceDiagnosticKind::PluginConfigUnreadable {
1338            plugin,
1339            key,
1340            reason,
1341        } => {
1342            let (consequence, advice) = unreadable_key_consequence(key);
1343            format!(
1344                "Plugin '{plugin}': `{key}` in '{display}' {situation}, so {consequence}. {advice}",
1345                situation = unreadable_situation(reason)
1346            )
1347        }
1348        WorkspaceDiagnosticKind::PluginEffectNotModeled {
1349            plugin,
1350            key,
1351            reason,
1352        } => {
1353            // Two causes, one effect, one remedy. Each cause gets its own
1354            // sentence: the cause and the effect on the findings are separate
1355            // facts, and one sentence with two `so` clauses states neither fact
1356            // clearly.
1357            let effect = "`autoImports` kept the convention entry patterns for that surface, and \
1358                          fallow reports no unused file there. Write the setting as static \
1359                          literals, or remove the key to use the framework defaults.";
1360            if reason == "config-property-unreadable" {
1361                format!(
1362                    "Plugin '{plugin}': fallow cannot read a top-level property in '{display}', so \
1363                     it cannot classify the `{key}` surface. {effect}"
1364                )
1365            } else {
1366                format!(
1367                    "Plugin '{plugin}': fallow does not model the effect of `{key}` in \
1368                     '{display}'. {effect}"
1369                )
1370            }
1371        }
1372        WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1373            pattern,
1374            file_count,
1375            directory_count,
1376        } => {
1377            // `path` is a location, and an empty string is not one: a built-in
1378            // that matched a file sitting directly at the analysis root
1379            // anchors at the root itself.
1380            let display = if display.is_empty() {
1381                ".".to_owned()
1382            } else {
1383                display
1384            };
1385            // The payload carries no directory list, so the message names the
1386            // one directory `path` anchors at. With several excluded
1387            // directories that is the largest group and NOT a majority, so the
1388            // sentence says which claim it is making and how many directories
1389            // it is leaving unnamed.
1390            let location = if *directory_count > 1 {
1391                format!(
1392                    "Skipped {file_count} source files across {directory_count} directories, \
1393                     the largest group under '{display}'"
1394                )
1395            } else if *file_count == 1 {
1396                format!("Skipped 1 source file under '{display}'")
1397            } else {
1398                format!("Skipped {file_count} source files under '{display}'")
1399            };
1400            let singular = *file_count == 1 && *directory_count <= 1;
1401            let (subject, effect) = if singular {
1402                ("it matches", "it imports, exports, or defines")
1403            } else {
1404                ("they match", "they import, export, or define")
1405            };
1406            // Only a directory-shaped built-in is lifted by re-rooting. Telling
1407            // a user with a `vendor/lib.min.js` to run `fallow --root vendor`
1408            // hands them a command that excludes the same file again.
1409            let remedy = if glob_first_literal_segment(pattern).is_some() {
1410                format!(
1411                    "Move first-party source out of the matched directory, or analyze that \
1412                     directory on its own with fallow --root {display}."
1413                )
1414            } else {
1415                "This pattern matches a file name rather than a directory, so re-running under \
1416                 a different --root excludes the same files again. Rename first-party source \
1417                 that only looks generated, dropping the '.min' or '.bundle' infix."
1418                    .to_owned()
1419            };
1420            format!(
1421                "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
1422                 {effect} is visible to this run. Built-in ignores cannot be switched off \
1423                 through ignorePatterns. {remedy}"
1424            )
1425        }
1426    }
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431    use super::*;
1432
1433    #[test]
1434    fn skipped_large_file_diagnostic_id_and_message() {
1435        let root = Path::new("/project");
1436        let diag = WorkspaceDiagnostic::new(
1437            root,
1438            root.join("src/vendor/app.bundle.js"),
1439            WorkspaceDiagnosticKind::SkippedLargeFile {
1440                size_bytes: 6 * 1024 * 1024,
1441            },
1442        );
1443        assert_eq!(diag.kind.id(), "skipped-large-file");
1444        assert!(
1445            diag.message.contains("src/vendor/app.bundle.js"),
1446            "message names the project-relative path: {}",
1447            diag.message
1448        );
1449        assert!(
1450            diag.message.contains("6.0 MB"),
1451            "message reports the size: {}",
1452            diag.message
1453        );
1454        assert!(
1455            diag.message.contains("--max-file-size"),
1456            "message names the override flag: {}",
1457            diag.message
1458        );
1459    }
1460
1461    #[test]
1462    fn skipped_minified_file_diagnostic_id_and_message() {
1463        let root = Path::new("/project");
1464        let diag = WorkspaceDiagnostic::new(
1465            root,
1466            root.join("src/assets/index-abc123.js"),
1467            WorkspaceDiagnosticKind::SkippedMinifiedFile {
1468                size_bytes: 2 * 1024 * 1024,
1469            },
1470        );
1471        assert_eq!(diag.kind.id(), "skipped-minified-file");
1472        assert!(
1473            diag.message.contains("src/assets/index-abc123.js"),
1474            "message names the project-relative path: {}",
1475            diag.message
1476        );
1477        assert!(
1478            diag.message.contains("2.0 MB"),
1479            "message reports the size: {}",
1480            diag.message
1481        );
1482        assert!(
1483            diag.message.contains("--max-file-size 0"),
1484            "message names the opt-out: {}",
1485            diag.message
1486        );
1487    }
1488
1489    #[test]
1490    fn skipped_source_dotdir_diagnostic_id_and_message() {
1491        let root = Path::new("/project");
1492        let diag = WorkspaceDiagnostic::new(
1493            root,
1494            root.join(".claude"),
1495            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1496        );
1497        assert_eq!(diag.kind.id(), "skipped-source-dotdir");
1498        assert!(
1499            diag.message.contains(".claude"),
1500            "message names the project-relative path: {}",
1501            diag.message
1502        );
1503        assert!(
1504            diag.message
1505                .contains("Its imports and exports are not analyzed."),
1506            "message states the consequence: {}",
1507            diag.message
1508        );
1509        assert!(
1510            diag.message.contains("--root"),
1511            "message names the real remedy: {}",
1512            diag.message
1513        );
1514        assert!(
1515            diag.message.contains("ignorePatterns"),
1516            "message names the silencing route: {}",
1517            diag.message
1518        );
1519        assert!(
1520            diag.message.contains("no config field"),
1521            "the message must say plainly that no config field traverses it: {}",
1522            diag.message
1523        );
1524        assert_eq!(
1525            serde_json::to_value(&diag).expect("serializes")["kind"],
1526            "skipped-source-dotdir",
1527            "id() must byte-match the serde kebab-case tag"
1528        );
1529    }
1530
1531    #[cfg(feature = "schema")]
1532    #[test]
1533    fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
1534        let schema = schemars::schema_for!(WorkspaceDiagnostic);
1535        let json = serde_json::to_string(&schema).expect("schema serializes");
1536        assert!(json.contains("skipped-source-dotdir"));
1537    }
1538
1539    #[test]
1540    fn source_read_failure_serializes_typed_error_payload() {
1541        let root = Path::new("/project");
1542        let diagnostic = WorkspaceDiagnostic::new(
1543            root,
1544            root.join("src/removed.ts"),
1545            WorkspaceDiagnosticKind::SourceReadFailure {
1546                error: "No such file or directory".to_string(),
1547            },
1548        );
1549
1550        let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
1551        assert_eq!(json["kind"], "source-read-failure");
1552        assert_eq!(
1553            json["path"],
1554            root.join("src/removed.ts")
1555                .display()
1556                .to_string()
1557                .replace('\\', "/")
1558        );
1559        assert_eq!(json["error"], "No such file or directory");
1560        assert!(
1561            json["message"]
1562                .as_str()
1563                .is_some_and(|message| message.contains("src/removed.ts"))
1564        );
1565    }
1566
1567    #[cfg(feature = "schema")]
1568    #[test]
1569    fn workspace_diagnostic_schema_includes_source_read_failure() {
1570        let schema = schemars::schema_for!(WorkspaceDiagnostic);
1571        let json = serde_json::to_string(&schema).expect("schema serializes");
1572        assert!(json.contains("source-read-failure"));
1573        assert!(json.contains("error"));
1574    }
1575
1576    #[test]
1577    fn bun_lockb_override_resolution_skipped_id_and_message() {
1578        let root = Path::new("/project");
1579        let diag = WorkspaceDiagnostic::new(
1580            root,
1581            root.join("package.json"),
1582            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1583        );
1584        assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
1585        assert!(
1586            diag.message.contains("'package.json'"),
1587            "message names the project-relative manifest: {}",
1588            diag.message
1589        );
1590        assert!(
1591            diag.message.contains("no parseable text lockfile"),
1592            "message states the cause: {}",
1593            diag.message
1594        );
1595        assert!(
1596            !diag.message.contains("only bun.lockb"),
1597            "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
1598             bun.lock may sit beside it: {}",
1599            diag.message
1600        );
1601        assert!(
1602            diag.message.contains("bun install --save-text-lockfile")
1603                && diag.message.contains("delete the stale bun.lockb"),
1604            "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
1605            diag.message
1606        );
1607        let json = serde_json::to_value(&diag).expect("diagnostic serializes");
1608        assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
1609    }
1610
1611    #[test]
1612    fn bun_override_diagnostic_ids_and_messages_are_actionable() {
1613        let root = Path::new("/project");
1614        let malformed = WorkspaceDiagnostic::new(
1615            root,
1616            root.join("bun.lock"),
1617            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1618        );
1619        assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
1620        assert!(malformed.message.contains("regenerate"));
1621
1622        let shadowed = WorkspaceDiagnostic::new(
1623            root,
1624            root.join("package.json"),
1625            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1626        );
1627        assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
1628        assert!(shadowed.message.contains("ignores `resolutions`"));
1629    }
1630
1631    #[test]
1632    fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
1633        let root = Path::new("/project");
1634        let inside = WorkspaceDiagnostic::new(
1635            root,
1636            root.join("packages/inner"),
1637            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1638        )
1639        .into_root_relative(root);
1640        assert_eq!(inside.path, Path::new("packages/inner"));
1641
1642        let outside = WorkspaceDiagnostic::new(
1643            root,
1644            PathBuf::from("/elsewhere/packages/inner"),
1645            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1646        )
1647        .into_root_relative(root);
1648        assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
1649    }
1650
1651    #[test]
1652    fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
1653        let analysis_stage = [
1654            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1655                error: "bad yaml".to_owned(),
1656            },
1657            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1658            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1659            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1660        ];
1661        for kind in &analysis_stage {
1662            assert!(
1663                kind.is_analysis_stage() && !kind.is_source_discovery(),
1664                "{} is recorded by the analyze stage only",
1665                kind.id()
1666            );
1667        }
1668
1669        let other = [
1670            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1671            WorkspaceDiagnosticKind::MalformedPackageJson {
1672                error: "trailing comma".to_owned(),
1673            },
1674            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1675                pattern: "packages/*".to_owned(),
1676            },
1677            WorkspaceDiagnosticKind::MalformedTsconfig {
1678                error: "unexpected token".to_owned(),
1679            },
1680            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1681            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1682            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1683            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1684            WorkspaceDiagnosticKind::SourceReadFailure {
1685                error: "permission denied".to_owned(),
1686            },
1687        ];
1688        for kind in &other {
1689            assert!(
1690                !kind.is_analysis_stage(),
1691                "{} is a discovery kind, not an analyze-stage kind",
1692                kind.id()
1693            );
1694        }
1695    }
1696
1697    #[test]
1698    fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
1699        let root = Path::new("/project");
1700        let first = WorkspaceDiagnostic::new(
1701            root,
1702            root.join("packages/aaa"),
1703            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1704                pattern: "packages/*".to_owned(),
1705            },
1706        );
1707        let second = WorkspaceDiagnostic::new(
1708            root,
1709            root.join("packages/aaa"),
1710            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1711                pattern: "packages/a*".to_owned(),
1712            },
1713        );
1714
1715        let merged =
1716            merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
1717
1718        let patterns: Vec<String> = merged
1719            .iter()
1720            .map(|diagnostic| match &diagnostic.kind {
1721                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
1722                other => panic!("unexpected kind {}", other.id()),
1723            })
1724            .collect();
1725        assert_eq!(
1726            patterns,
1727            ["packages/*", "packages/a*"],
1728            "two overlapping globs report the same directory twice, with their own pattern; \
1729             the same entry seen from two observation points still folds to one"
1730        );
1731    }
1732
1733    /// Issue #2366: a repository that declares one glob in two manifests
1734    /// (`"./apps/**"` in `package.json`, `apps/**` in `pnpm-workspace.yaml`)
1735    /// must not report every package-less directory under it twice now that the
1736    /// payload is part of the dedupe key.
1737    #[test]
1738    fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
1739        let root = Path::new("/project");
1740        let dotted = WorkspaceDiagnostic::new(
1741            root,
1742            root.join("apps/site/.next/cache"),
1743            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1744                pattern: "./apps/**".to_owned(),
1745            },
1746        );
1747        let bare = WorkspaceDiagnostic::new(
1748            root,
1749            root.join("apps/site/.next/cache"),
1750            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1751                pattern: "apps/**".to_owned(),
1752            },
1753        );
1754        assert_eq!(
1755            dotted.kind, bare.kind,
1756            "the no-op ./ prefix is normalised out of the recorded pattern"
1757        );
1758        assert!(
1759            dotted.message.contains("Glob 'apps/**'"),
1760            "the message renders the normalised pattern: {}",
1761            dotted.message
1762        );
1763
1764        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1765        assert_eq!(
1766            merged.len(),
1767            1,
1768            "one glob declared twice is one diagnostic: {merged:?}"
1769        );
1770    }
1771
1772    /// A glob spelled exactly `"./"` (the project root itself) is the one
1773    /// pattern the prefix strip must leave alone: an empty `pattern` field
1774    /// names no glob, and the warning would quote nothing.
1775    #[test]
1776    fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
1777        let root = Path::new("/project");
1778        let recorded = |pattern: &str| {
1779            let diagnostic = WorkspaceDiagnostic::new(
1780                root,
1781                root.join("pkgs"),
1782                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1783                    pattern: pattern.to_owned(),
1784                },
1785            );
1786            let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
1787            else {
1788                panic!("constructed a glob-matched-no-package-json diagnostic");
1789            };
1790            (pattern, diagnostic.message)
1791        };
1792
1793        let (root_pattern, root_message) = recorded("./");
1794        assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
1795        assert!(
1796            root_message.contains("Glob './'"),
1797            "the warning names the glob the manifest declared: {root_message}"
1798        );
1799        assert_eq!(recorded(".\\").0, ".\\");
1800        assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
1801        assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
1802    }
1803
1804    /// Issue #2366, the path half of the same repository shape: expanding
1805    /// `./pkgs/*` joins the no-op `.` into every match, so the two manifests
1806    /// hand one directory to the diagnostic under two spellings. Both must
1807    /// store, render and serialise as the bare one, otherwise whichever
1808    /// manifest was read first decides whether the analysis envelopes print
1809    /// `./pkgs/aaa` while the workspace listing envelope prints `pkgs/aaa`.
1810    #[test]
1811    fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
1812        let root = Path::new("/project");
1813        let dotted = WorkspaceDiagnostic::new(
1814            root,
1815            root.join("./pkgs/aaa"),
1816            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1817                pattern: "./pkgs/*".to_owned(),
1818            },
1819        );
1820        let bare = WorkspaceDiagnostic::new(
1821            root,
1822            root.join("pkgs/aaa"),
1823            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1824                pattern: "pkgs/*".to_owned(),
1825            },
1826        );
1827
1828        let spelling = |diagnostic: &WorkspaceDiagnostic| {
1829            diagnostic.path.display().to_string().replace('\\', "/")
1830        };
1831        assert_eq!(
1832            spelling(&dotted),
1833            "/project/pkgs/aaa",
1834            "the stored path drops the no-op . component, which Path equality \
1835             hides but serialization does not"
1836        );
1837        assert_eq!(spelling(&dotted), spelling(&bare));
1838        assert_eq!(
1839            spelling(&dotted.clone().into_root_relative(root)),
1840            "pkgs/aaa"
1841        );
1842
1843        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1844        assert_eq!(
1845            merged.len(),
1846            1,
1847            "one directory reached through two spellings of one glob: {merged:?}"
1848        );
1849    }
1850
1851    /// The single-list fold applied at workspace discovery keeps one entry per
1852    /// `(kind, path)` and leaves distinct payloads alone.
1853    #[test]
1854    fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
1855        let root = Path::new("/project");
1856        let glob = |pattern: &str, relative: &str| {
1857            WorkspaceDiagnostic::new(
1858                root,
1859                root.join(relative),
1860                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1861                    pattern: pattern.to_owned(),
1862                },
1863            )
1864        };
1865
1866        let deduped = dedupe_workspace_diagnostics(vec![
1867            glob("pkgs/*", "pkgs/aaa"),
1868            glob("pkgs/*", "pkgs/bbb"),
1869            glob("./pkgs/*", "./pkgs/aaa"),
1870            glob("pkgs/a*", "pkgs/aaa"),
1871        ]);
1872
1873        let reported: Vec<(String, String)> = deduped
1874            .iter()
1875            .map(|diagnostic| match &diagnostic.kind {
1876                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
1877                    pattern.clone(),
1878                    diagnostic.path.display().to_string().replace('\\', "/"),
1879                ),
1880                other => panic!("unexpected kind {}", other.id()),
1881            })
1882            .collect();
1883
1884        assert_eq!(
1885            reported,
1886            vec![
1887                ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
1888                ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
1889                ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
1890            ],
1891            "the duplicate spelling folds away and the overlapping glob stays"
1892        );
1893    }
1894
1895    /// The class `reachability_caveats[]` is computed from. Every kind here
1896    /// means the run never read a file that is part of the project, so its
1897    /// imports credit nothing and the modules it imports can be reported
1898    /// unused with a removal action on them. Classifying a kind `true` is the
1899    /// only wiring its findings need to inherit the caveat and the `fallow fix`
1900    /// withholding that follows it.
1901    #[test]
1902    fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
1903        for kind in [
1904            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1905            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1906            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1907            WorkspaceDiagnosticKind::SourceReadFailure {
1908                error: "permission denied".to_owned(),
1909            },
1910        ] {
1911            assert!(
1912                kind.source_never_analyzed(),
1913                "{} names a source file this run never read",
1914                kind.id()
1915            );
1916        }
1917
1918        let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
1919            error_count: 3,
1920            panicked: false,
1921        };
1922        assert!(
1923            !degraded.source_never_analyzed(),
1924            "a degraded parse read the file, so it has a graph node and its reachability is \
1925             observable; the caveat pass narrows it instead of treating it as unread"
1926        );
1927
1928        for kind in [
1929            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1930            WorkspaceDiagnosticKind::MalformedPackageJson {
1931                error: "trailing comma".to_owned(),
1932            },
1933            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1934                pattern: "packages/*".to_owned(),
1935            },
1936            WorkspaceDiagnosticKind::MalformedTsconfig {
1937                error: "unexpected token".to_owned(),
1938            },
1939            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1940            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1941                error: "bad indent".to_owned(),
1942            },
1943            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1944            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1945            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1946            WorkspaceDiagnosticKind::NodeModulesMissing,
1947            WorkspaceDiagnosticKind::BoundariesNotConfigured,
1948            WorkspaceDiagnosticKind::RulePacksNotConfigured,
1949        ] {
1950            assert!(
1951                !kind.source_never_analyzed(),
1952                "{} says nothing about a source file's imports going unseen",
1953                kind.id()
1954            );
1955        }
1956    }
1957
1958    #[test]
1959    fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
1960        for kind in [
1961            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1962            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1963            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1964        ] {
1965            assert!(
1966                kind.is_source_walk_recorded() && kind.is_source_discovery(),
1967                "{} is written by the source walk",
1968                kind.id()
1969            );
1970        }
1971
1972        let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
1973            error: "permission denied".to_owned(),
1974        };
1975        assert!(
1976            read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
1977            "the parse stage records source-read-failure after the walk, so it must keep \
1978             reaching sessions through the registry"
1979        );
1980
1981        for kind in [
1982            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1983            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1984            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1985            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1986            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1987        ] {
1988            assert!(
1989                !kind.is_source_walk_recorded(),
1990                "{} is not written by the source walk",
1991                kind.id()
1992            );
1993        }
1994    }
1995
1996    /// Issue #2638, the single most load-bearing classification in the new
1997    /// kind. Answering `true` here would attach `IncompleteFileAnalysis` and
1998    /// `IncompleteImportGraph` caveats to findings on nearly every project
1999    /// that keeps a non-gitignored `dist/` or `coverage/`, and make
2000    /// `fallow fix` withhold `delete-file` and `remove-export` project-wide.
2001    /// A built-in exclusion is designed behavior on generated output, not a
2002    /// degraded run.
2003    #[test]
2004    fn a_built_in_ignore_exclusion_is_not_a_file_the_run_failed_to_analyze() {
2005        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2006            pattern: "**/build/**".to_owned(),
2007            file_count: 3,
2008            directory_count: 1,
2009        };
2010        assert!(!kind.source_never_analyzed());
2011    }
2012
2013    /// Issue #2638: these exclusions fire in the product's default state on
2014    /// most monorepos, so a default stderr line would be permanent noise that
2015    /// names no defect. The CLI prints a note under `--explain-skipped`
2016    /// instead.
2017    #[test]
2018    fn a_built_in_ignore_exclusion_does_not_warn_on_stderr_by_default() {
2019        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2020            pattern: "**/build/**".to_owned(),
2021            file_count: 3,
2022            directory_count: 1,
2023        };
2024        assert!(!kind.warns_on_stderr());
2025    }
2026
2027    /// Issue #2638 plus issue #2366: the walk writes it, so it has to be
2028    /// classified as source-discovery (or combined mode's per-analysis config
2029    /// reloads wipe it before serialization) AND as walk-recorded (or a
2030    /// concurrent walk's tally is folded into another analysis's list).
2031    #[test]
2032    fn a_built_in_ignore_exclusion_is_walk_recorded_source_discovery() {
2033        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2034            pattern: "**/build/**".to_owned(),
2035            file_count: 3,
2036            directory_count: 1,
2037        };
2038        assert!(kind.is_source_discovery());
2039        assert!(kind.is_source_walk_recorded());
2040        assert!(!kind.is_analysis_stage());
2041        assert_eq!(kind.id(), "excluded-by-default-ignore");
2042    }
2043
2044    /// Issue #2638: the message has to name the pattern the reader cannot see,
2045    /// the directory, and the only remedy that actually analyzes the tree.
2046    /// `ignorePatterns` is not that remedy: the compiled set unions, so it
2047    /// cannot negate a built-in.
2048    #[test]
2049    fn a_built_in_ignore_exclusion_message_names_the_pattern_and_the_root_remedy() {
2050        let root = Path::new("/project");
2051        let diag = WorkspaceDiagnostic::new(
2052            root,
2053            root.join("packages/web/build"),
2054            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2055                pattern: "**/build/**".to_owned(),
2056                file_count: 4,
2057                directory_count: 1,
2058            },
2059        );
2060        assert!(diag.message.contains("**/build/**"), "{}", diag.message);
2061        assert!(
2062            diag.message.contains("packages/web/build"),
2063            "{}",
2064            diag.message
2065        );
2066        assert!(
2067            diag.message.contains("fallow --root packages/web/build"),
2068            "the remedy is copy-pasteable: {}",
2069            diag.message
2070        );
2071        assert!(
2072            diag.message
2073                .contains("cannot be switched off through ignorePatterns"),
2074            "the message must not advertise a negation that does not exist: {}",
2075            diag.message
2076        );
2077    }
2078
2079    /// One excluded file reads as one file, not as "1 source files".
2080    #[test]
2081    fn a_single_excluded_file_message_is_singular() {
2082        let root = Path::new("/project");
2083        let diag = WorkspaceDiagnostic::new(
2084            root,
2085            root.join("dist"),
2086            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2087                pattern: "**/dist/**".to_owned(),
2088                file_count: 1,
2089                directory_count: 1,
2090            },
2091        );
2092        assert!(
2093            diag.message
2094                .starts_with("Skipped 1 source file under 'dist'"),
2095            "{}",
2096            diag.message
2097        );
2098        assert!(diag.message.contains("it matches"), "{}", diag.message);
2099        assert!(
2100            diag.message
2101                .contains("nothing it imports, exports, or defines"),
2102            "the whole sentence agrees in number, not just its first clause: {}",
2103            diag.message
2104        );
2105    }
2106
2107    /// The anchor directory is the largest group, never a majority: ten
2108    /// packages each holding one excluded file make every one of them "the
2109    /// largest", and a message claiming otherwise is false on exactly the flat
2110    /// monorepo shape issue #2638 is about.
2111    #[test]
2112    fn a_scattered_exclusion_names_the_largest_group_and_counts_the_directories() {
2113        let root = Path::new("/project");
2114        let diag = WorkspaceDiagnostic::new(
2115            root,
2116            root.join("packages/a/dist"),
2117            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2118                pattern: "**/dist/**".to_owned(),
2119                file_count: 10,
2120                directory_count: 10,
2121            },
2122        );
2123        assert!(
2124            diag.message.starts_with(
2125                "Skipped 10 source files across 10 directories, the largest group under \
2126                 'packages/a/dist'"
2127            ),
2128            "{}",
2129            diag.message
2130        );
2131        assert!(
2132            !diag.message.contains("the most of them"),
2133            "a max-of-group is not a majority: {}",
2134            diag.message
2135        );
2136    }
2137
2138    /// A file-shaped built-in matches on the file name, so the `--root` remedy
2139    /// the directory-shaped patterns get would re-exclude the same file. The
2140    /// message must not print a command that provably does nothing.
2141    #[test]
2142    fn a_file_shaped_pattern_does_not_advertise_the_root_remedy() {
2143        let root = Path::new("/project");
2144        let diag = WorkspaceDiagnostic::new(
2145            root,
2146            root.join("vendor"),
2147            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2148                pattern: "**/*.min.js".to_owned(),
2149                file_count: 2,
2150                directory_count: 1,
2151            },
2152        );
2153        assert!(
2154            !diag.message.contains("fallow --root"),
2155            "the message explains why re-rooting fails, it does not prescribe it: {}",
2156            diag.message
2157        );
2158        assert!(
2159            diag.message.contains("matches a file name"),
2160            "the message says why: {}",
2161            diag.message
2162        );
2163        assert!(
2164            diag.message.contains("Rename"),
2165            "and names the remedy that does work: {}",
2166            diag.message
2167        );
2168    }
2169
2170    /// A built-in that matched a file sitting directly at the analysis root
2171    /// anchors at the root, and an empty string is not a location.
2172    #[test]
2173    fn a_root_anchored_exclusion_renders_its_location_as_dot() {
2174        let root = Path::new("/project");
2175        let diag = WorkspaceDiagnostic::new(
2176            root,
2177            root.to_path_buf(),
2178            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2179                pattern: "**/*.min.js".to_owned(),
2180                file_count: 1,
2181                directory_count: 1,
2182            },
2183        );
2184        assert!(
2185            diag.message.starts_with("Skipped 1 source file under '.'"),
2186            "{}",
2187            diag.message
2188        );
2189    }
2190
2191    #[test]
2192    fn glob_first_literal_segment_skips_wildcards_and_dot_components() {
2193        assert_eq!(glob_first_literal_segment("**/build/**"), Some("build"));
2194        assert_eq!(glob_first_literal_segment("./dist/**"), Some("dist"));
2195        assert_eq!(glob_first_literal_segment("**/*.min.js"), None);
2196        assert_eq!(glob_first_literal_segment("**/*.bundle.js"), None);
2197        assert_eq!(glob_first_literal_segment("**/{a,b}/**"), None);
2198    }
2199
2200    #[test]
2201    fn format_size_mb_one_decimal() {
2202        assert_eq!(format_size_mb(0), "0.0 MB");
2203        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
2204        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
2205    }
2206
2207    #[test]
2208    fn undeclared_workspace_message_has_next_step() {
2209        let root = Path::new("/project");
2210        let diag = WorkspaceDiagnostic::new(
2211            root,
2212            root.join("packages/legacy"),
2213            WorkspaceDiagnosticKind::UndeclaredWorkspace,
2214        );
2215        assert_eq!(diag.kind.id(), "undeclared-workspace");
2216        assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
2217        assert!(
2218            diag.message.contains("ignorePatterns"),
2219            "next-step hint preserved: {}",
2220            diag.message
2221        );
2222    }
2223    /// The seven health-pipeline kinds (issue #2689). Each is classified in
2224    /// four places, and getting one wrong is silent: an entry that answers
2225    /// `is_analysis_stage` is wiped by the dead-code pass the health run itself
2226    /// invokes, and one that answers `is_source_discovery` is preserved by the
2227    /// wrong mechanism.
2228    #[test]
2229    fn health_stage_kinds_are_classified_as_health_stage_and_nothing_else() {
2230        for kind in [
2231            WorkspaceDiagnosticKind::FileScoresUnavailable {
2232                error: "boom".to_owned(),
2233            },
2234            WorkspaceDiagnosticKind::HotspotsSkipped {
2235                cause: "not-a-repository".to_owned(),
2236            },
2237            WorkspaceDiagnosticKind::ShallowClone {
2238                ownership_requested: true,
2239            },
2240            WorkspaceDiagnosticKind::UnpinnedClock,
2241            WorkspaceDiagnosticKind::OwnershipUnavailable {
2242                cause: "codeowners-parse-failed".to_owned(),
2243                error: "boom".to_owned(),
2244            },
2245            WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
2246                error: "boom".to_owned(),
2247            },
2248            WorkspaceDiagnosticKind::CoverageAutoDetected,
2249        ] {
2250            let id = kind.id();
2251            assert!(kind.is_health_stage(), "{id} must be health-stage");
2252            assert!(!kind.is_analysis_stage(), "{id} must not be analysis-stage");
2253            assert!(
2254                !kind.is_source_discovery(),
2255                "{id} must not be source-discovery"
2256            );
2257            assert!(
2258                !kind.is_source_walk_recorded(),
2259                "{id} must not be walk-recorded"
2260            );
2261            assert!(
2262                !kind.source_never_analyzed(),
2263                "{id} reports an input, not an unread source file"
2264            );
2265        }
2266    }
2267
2268    /// Six of the seven report a result the run could not measure as asked;
2269    /// the coverage provenance entry does not, and a consumer sentence about a
2270    /// degraded run must not fire for it.
2271    #[test]
2272    fn only_the_coverage_provenance_kind_does_not_degrade_the_analysis() {
2273        assert!(
2274            WorkspaceDiagnosticKind::HotspotsSkipped {
2275                cause: "invalid-since".to_owned(),
2276            }
2277            .warns_on_stderr(),
2278            "a skipped hotspot section is a degraded result"
2279        );
2280        assert!(
2281            WorkspaceDiagnosticKind::UnpinnedClock.warns_on_stderr(),
2282            "a drifting measurement is a degraded result"
2283        );
2284        assert!(
2285            !WorkspaceDiagnosticKind::CoverageAutoDetected.warns_on_stderr(),
2286            "auto-detected coverage loaded fine and degraded nothing"
2287        );
2288    }
2289
2290    /// Each skip cause carries its own remedy, and the original cause's wording
2291    /// is frozen: it shipped before the token existed, so a reader who matched
2292    /// on that sentence must still match on it.
2293    #[test]
2294    fn every_hotspot_skip_cause_renders_its_own_remedy() {
2295        let root = Path::new("/project");
2296        let skipped = |cause: &str, path: PathBuf| {
2297            WorkspaceDiagnostic::new(
2298                root,
2299                path,
2300                WorkspaceDiagnosticKind::HotspotsSkipped {
2301                    cause: cause.to_owned(),
2302                },
2303            )
2304        };
2305
2306        let no_repo = skipped("not-a-repository", root.to_path_buf());
2307        assert_eq!(
2308            no_repo.message,
2309            "Hotspot analysis was skipped because no git repository was found at the project \
2310             root, so the hotspots, churn and ownership sections report nothing rather than \
2311             zero. Run fallow inside the repository, or pass --churn-file with exported change \
2312             history."
2313        );
2314
2315        let bad_since = skipped("invalid-since", root.to_path_buf());
2316        assert!(
2317            bad_since.message.contains("--since")
2318                && bad_since.message.contains("6m or 90d")
2319                && !bad_since.message.contains("no git repository"),
2320            "a malformed window is respelled, not moved into a repository: {}",
2321            bad_since.message
2322        );
2323
2324        let churn = skipped("churn-file-unreadable", root.join("build/churn.json"));
2325        assert!(
2326            churn.message.contains("'build/churn.json'") && churn.message.contains("rerun"),
2327            "the remedy names the file that changed under the run: {}",
2328            churn.message
2329        );
2330
2331        for diagnostic in [&no_repo, &bad_since, &churn] {
2332            assert!(
2333                diagnostic.degrades_analysis,
2334                "every skip leaves the hotspot sections unmeasured: {}",
2335                diagnostic.message
2336            );
2337        }
2338    }
2339
2340    /// The message is the only prose a consumer renders, so each one must name
2341    /// the consequence and a next step rather than restate the kind.
2342    #[test]
2343    fn health_stage_messages_name_a_next_step() {
2344        let root = Path::new("/project");
2345        let shallow = WorkspaceDiagnostic::new(
2346            root,
2347            root.to_path_buf(),
2348            WorkspaceDiagnosticKind::ShallowClone {
2349                ownership_requested: true,
2350            },
2351        );
2352        assert!(
2353            shallow.message.contains("git fetch --unshallow"),
2354            "{}",
2355            shallow.message
2356        );
2357        assert!(
2358            shallow.message.contains("Ownership signals are skewed too"),
2359            "a run that asked for ownership is told what else it costs: {}",
2360            shallow.message
2361        );
2362        let without_ownership = WorkspaceDiagnostic::new(
2363            root,
2364            root.to_path_buf(),
2365            WorkspaceDiagnosticKind::ShallowClone {
2366                ownership_requested: false,
2367            },
2368        );
2369        assert!(
2370            !without_ownership.message.contains("Ownership"),
2371            "a run that did not ask for ownership is not told about it: {}",
2372            without_ownership.message
2373        );
2374
2375        let coverage = WorkspaceDiagnostic::new(
2376            root,
2377            root.join("coverage/coverage-final.json"),
2378            WorkspaceDiagnosticKind::CoverageAutoDetected,
2379        );
2380        assert_eq!(coverage.kind.id(), "coverage-auto-detected");
2381        assert!(
2382            coverage
2383                .message
2384                .contains("--coverage 'coverage/coverage-final.json'"),
2385            "the remedy names the file that fed the score: {}",
2386            coverage.message
2387        );
2388        assert!(
2389            !coverage.degrades_analysis,
2390            "provenance is not a degraded run"
2391        );
2392
2393        let ownership = WorkspaceDiagnostic::new(
2394            root,
2395            root.to_path_buf(),
2396            WorkspaceDiagnosticKind::OwnershipUnavailable {
2397                cause: "invalid-bot-pattern".to_owned(),
2398                error: "unclosed".to_owned(),
2399            },
2400        );
2401        assert!(
2402            ownership.message.contains("botPatterns"),
2403            "the two causes render different remedies: {}",
2404            ownership.message
2405        );
2406    }
2407
2408    fn plugin_unreadable(key: &str, reason: &str) -> WorkspaceDiagnostic {
2409        WorkspaceDiagnostic::new(
2410            Path::new("/project"),
2411            PathBuf::from("/project/module-federation.config.ts"),
2412            WorkspaceDiagnosticKind::PluginConfigUnreadable {
2413                plugin: "module-federation".to_owned(),
2414                key: key.to_owned(),
2415                reason: reason.to_owned(),
2416            },
2417        )
2418    }
2419
2420    fn plugin_not_modeled(key: &str, reason: &str) -> WorkspaceDiagnostic {
2421        WorkspaceDiagnostic::new(
2422            Path::new("/project"),
2423            PathBuf::from("/project/nuxt.config.ts"),
2424            WorkspaceDiagnosticKind::PluginEffectNotModeled {
2425                plugin: "nuxt".to_owned(),
2426                key: key.to_owned(),
2427                reason: reason.to_owned(),
2428            },
2429        )
2430    }
2431
2432    /// The plugin stage is its own stage: classified there and nowhere else, so
2433    /// the stash preserve keeps it and no other stage's clear wipes it.
2434    #[test]
2435    fn plugin_stage_kinds_are_classified_as_plugin_stage_and_nothing_else() {
2436        for kind in [
2437            WorkspaceDiagnosticKind::PluginConfigUnreadable {
2438                plugin: "module-federation".to_owned(),
2439                key: "exposes".to_owned(),
2440                reason: "not-object-literal".to_owned(),
2441            },
2442            WorkspaceDiagnosticKind::PluginEffectNotModeled {
2443                plugin: "nuxt".to_owned(),
2444                key: "components".to_owned(),
2445                reason: "key-effect-not-modeled".to_owned(),
2446            },
2447        ] {
2448            let id = kind.id();
2449            assert!(kind.is_plugin_stage(), "{id} must be plugin-stage");
2450            assert!(!kind.is_analysis_stage(), "{id} must not be analysis-stage");
2451            assert!(!kind.is_health_stage(), "{id} must not be health-stage");
2452            assert!(
2453                !kind.is_source_discovery(),
2454                "{id} must not be source-discovery"
2455            );
2456            assert!(
2457                !kind.is_source_walk_recorded(),
2458                "{id} must not be walk-recorded"
2459            );
2460            assert!(
2461                !kind.source_never_analyzed(),
2462                "{id} reports a config file, not an unread source file"
2463            );
2464        }
2465        assert!(
2466            !WorkspaceDiagnosticKind::UnpinnedClock.is_plugin_stage(),
2467            "another stage's kind must not answer the plugin predicate"
2468        );
2469    }
2470
2471    /// An unread declaration costs findings in both directions, so it degrades
2472    /// the analysis; an effect fallow does not model suppresses findings the
2473    /// user opted into and must not warn on every run forever.
2474    #[test]
2475    fn only_the_unreadable_plugin_config_degrades_the_analysis() {
2476        let unreadable = plugin_unreadable("exposes", "not-object-literal");
2477        assert!(
2478            unreadable.degrades_analysis,
2479            "an unread declaration did not reach the analysis: {}",
2480            unreadable.message
2481        );
2482        let not_modeled = plugin_not_modeled("components", "key-effect-not-modeled");
2483        assert!(
2484            !not_modeled.degrades_analysis,
2485            "the config was readable and the patterns stayed: {}",
2486            not_modeled.message
2487        );
2488    }
2489
2490    /// The reason decides the remedy, so each token renders its own situation,
2491    /// and a token from a later release still renders a sentence.
2492    #[test]
2493    fn every_unreadable_reason_renders_its_own_situation() {
2494        let cases = [
2495            ("not-object-literal", "is not a static object literal"),
2496            ("array-form", "uses the array form"),
2497            ("spread", "spreads a value that is not statically readable"),
2498            (
2499                "unreadable-entries",
2500                "has entries that hold no statically readable value",
2501            ),
2502        ];
2503        for (reason, expected) in cases {
2504            let diagnostic = plugin_unreadable("exposes", reason);
2505            assert!(
2506                diagnostic.message.contains(expected),
2507                "`{reason}` must render its own situation: {}",
2508                diagnostic.message
2509            );
2510        }
2511        let unknown = plugin_unreadable("exposes", "reason-from-a-later-release");
2512        assert!(
2513            unknown.message.contains("could not be read statically"),
2514            "an unrecognised token still renders a sentence: {}",
2515            unknown.message
2516        );
2517    }
2518
2519    /// The payload carries no prose, so the remedy comes from the key: both
2520    /// Module Federation keys name the option that covers the gap, and the
2521    /// message names the config file the user must edit.
2522    #[test]
2523    fn unreadable_plugin_messages_name_the_config_file_and_the_option() {
2524        let exposes = plugin_unreadable("exposes", "not-object-literal");
2525        assert!(
2526            exposes
2527                .message
2528                .contains("`exposes` in 'module-federation.config.ts'")
2529                && exposes.message.contains("dynamicallyLoaded")
2530                && exposes.message.starts_with("Plugin 'module-federation':"),
2531            "{}",
2532            exposes.message
2533        );
2534        let remotes = plugin_unreadable("remotes", "spread");
2535        assert!(
2536            remotes.message.contains("ignoreDependencies"),
2537            "the two keys have different remedies: {}",
2538            remotes.message
2539        );
2540        let unknown_key = plugin_unreadable("shared", "not-object-literal");
2541        assert!(
2542            !unknown_key.message.contains("dynamicallyLoaded")
2543                && !unknown_key.message.contains("ignoreDependencies")
2544                && unknown_key
2545                    .message
2546                    .contains("Declare the value as a static object literal."),
2547            "a key with no documented consequence falls back to the general claim: {}",
2548            unknown_key.message
2549        );
2550        assert!(
2551            !exposes.message.contains('\n'),
2552            "the sentence travels into a CI annotation and stays on one line: {}",
2553            exposes.message
2554        );
2555    }
2556
2557    /// One config file can hold two unreadable keys, and the payload is what
2558    /// tells them apart: the fold keys on the whole kind, so both survive.
2559    #[test]
2560    fn two_unreadable_keys_in_one_file_are_two_diagnostics() {
2561        let merged = dedupe_workspace_diagnostics(vec![
2562            plugin_unreadable("exposes", "not-object-literal"),
2563            plugin_unreadable("remotes", "spread"),
2564        ]);
2565        assert_eq!(merged.len(), 2, "{merged:?}");
2566    }
2567
2568    /// A surface whose own key fallow cannot model and a config file whose
2569    /// top-level property it cannot read need different remedies. The two tokens
2570    /// render different causes, and each cause states one fact per sentence.
2571    #[test]
2572    fn the_not_modeled_reasons_render_different_causes() {
2573        let key = plugin_not_modeled("components", "key-effect-not-modeled");
2574        assert!(
2575            key.message
2576                .contains("fallow does not model the effect of `components` in 'nuxt.config.ts'.")
2577                && key
2578                    .message
2579                    .contains("`autoImports` kept the convention entry patterns"),
2580            "{}",
2581            key.message
2582        );
2583        let property = plugin_not_modeled("imports", "config-property-unreadable");
2584        assert!(
2585            property.message.contains(
2586                "fallow cannot read a top-level property in 'nuxt.config.ts', so it cannot \
2587                 classify the `imports` surface. `autoImports` kept the convention entry patterns \
2588                 for that surface, and fallow reports no unused file there."
2589            ),
2590            "{}",
2591            property.message
2592        );
2593        assert_eq!(
2594            property.message.matches(", so ").count(),
2595            1,
2596            "one cause per sentence: {}",
2597            property.message
2598        );
2599    }
2600}