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