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