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