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#[path = "workspace_config_entry.rs"]
25mod config_entry;
26
27/// Why the declared pnpm version ignores the `overrides` section of
28/// `pnpm-workspace.yaml`.
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
30#[cfg_attr(feature = "schema", derive(JsonSchema))]
31#[serde(rename_all = "kebab-case")]
32pub enum PnpmWorkspaceOverridesIgnoredCause {
33    /// pnpm 10 or earlier, and the root `package.json` declares non-empty
34    /// `pnpm.overrides` or `resolutions`. pnpm uses these entries in place
35    /// of the `pnpm-workspace.yaml` overrides.
36    PackageJsonOverrides,
37    /// pnpm before 10.5.1, which does not read the `overrides` section of
38    /// `pnpm-workspace.yaml`.
39    PnpmVersion,
40}
41
42/// Whether the project had a `node_modules` directory at a point in time.
43///
44/// A dead-code baseline records this when it is saved, so a later run in a
45/// different install state can say that its results can differ.
46#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
47#[cfg_attr(feature = "schema", derive(JsonSchema))]
48#[serde(rename_all = "kebab-case")]
49pub enum NodeModulesState {
50    /// `node_modules` existed, or the project legitimately runs without it.
51    Present,
52    /// `node_modules` did not exist.
53    Missing,
54}
55
56impl NodeModulesState {
57    /// The wire token, as it appears in JSON.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::Present => "present",
62            Self::Missing => "missing",
63        }
64    }
65}
66
67/// Why a workspace-discovery candidate was rejected, or why a sibling
68/// directory looked workspace-like but was not declared.
69///
70/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
71/// agents, LSP clients) get a stable, language-neutral identifier.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
73#[cfg_attr(feature = "schema", derive(JsonSchema))]
74#[serde(tag = "kind", rename_all = "kebab-case")]
75pub enum WorkspaceDiagnosticKind {
76    /// A directory contains `package.json` but is not declared as a workspace
77    /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
78    /// `tsconfig.json` `references`. Surfaced by
79    /// `find_undeclared_workspaces`.
80    UndeclaredWorkspace,
81    /// A declared workspace's `package.json` failed to parse. The directory is
82    /// dropped from discovery, but analysis still proceeds (degraded).
83    MalformedPackageJson {
84        /// `serde_json` parse error text.
85        error: String,
86    },
87    /// A workspace glob pattern matched a directory that contains no
88    /// `package.json`. Honors the extended skip list and `ignorePatterns`
89    /// before emitting.
90    GlobMatchedNoPackageJson {
91        /// The glob pattern that matched the directory.
92        pattern: String,
93    },
94    /// A workspace entry is an absolute path or has `..` segments that lead
95    /// above the project root, so it can name directories outside the root. Fallow skips the entry
96    /// before it reads the disk and analyzes no package from it. `path` is
97    /// the file that declares the entry (`package.json`,
98    /// `pnpm-workspace.yaml`, `deno.json` or `deno.jsonc`), or the project
99    /// root for an entry of the fallow config `workspaces.patterns`. The
100    /// diagnostic never names a directory outside the root.
101    ///
102    /// npm, pnpm and yarn accept such an entry, so fallow does not exit with
103    /// an error on it. The project root is the security boundary of a run.
104    WorkspaceGlobOutsideRoot {
105        /// The workspace entry, as written in the file.
106        pattern: String,
107    },
108    /// A positive workspace pattern names a directory that does not exist.
109    /// The check reads the literal part of the pattern before the first
110    /// segment with a glob character (`vite` for `vite/packages/*`). A pattern
111    /// without a literal part (`**/pkg`) never gets this diagnostic, and a
112    /// literal directory that exists but holds no package does not get it
113    /// either. `path` is the file that declares the pattern, or the project
114    /// root for an entry of the fallow config `workspaces.patterns`.
115    ///
116    /// A repository that clones other repositories into the workspace in CI
117    /// declares such patterns. The packages there can consume catalog
118    /// entries, so the run does not report `unused-catalog-entries` while a
119    /// pattern of this kind exists.
120    WorkspacePatternDirMissing {
121        /// The workspace pattern, as written in the file.
122        pattern: String,
123    },
124    /// A directory has its own `package.json` and `pnpm-workspace.yaml`, and
125    /// no workspace pattern of the root manifests declares it. pnpm installs
126    /// it as an independent project. Fallow analyzes it as a workspace, so its
127    /// `package.json` owns its files and dependencies, and its `catalog:`
128    /// references are not checked against the root catalog.
129    ///
130    /// Information only: nothing is lost from the analysis, so
131    /// `degrades_analysis` is false.
132    NestedProjectRoot,
133    /// The root `tsconfig.json`, or a config in its `extends` chain, exists
134    /// but failed to parse. `path` names the file that failed. Fallow ignores
135    /// the project references, path aliases and compiler options of that file.
136    MalformedTsconfig {
137        /// JSONC parse error text.
138        error: String,
139    },
140    /// `tsconfig.json` lists a `references[].path` that does not point to an
141    /// existing directory.
142    TsconfigReferenceDirMissing,
143    /// `pnpm-workspace.yaml` exists but failed to parse as YAML. The catalog
144    /// checks are skipped and dependency-override analysis proceeds without
145    /// the file's entries (degraded) until the syntax is fixed.
146    MalformedPnpmWorkspaceYaml {
147        /// YAML parse error text.
148        error: String,
149    },
150    /// A source file was skipped at discovery because it exceeds the configured
151    /// per-file size limit (`--max-file-size` / `FALLOW_MAX_FILE_SIZE`, default
152    /// 5 MB). The file is never read, parsed, or analyzed, guarding against the
153    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
154    /// causes (issue #1086). Surfaced by source discovery, not workspace
155    /// discovery, but shares this channel so the skip is visible in
156    /// `workspace_diagnostics[]` on `fallow dead-code / dupes / health` JSON.
157    SkippedLargeFile {
158        /// On-disk size of the skipped file in bytes.
159        size_bytes: u64,
160    },
161    /// A large JavaScript bundle was skipped at discovery because it appears to
162    /// be minified generated output. The file is never parsed or analyzed,
163    /// guarding against sub-limit bundles that can still create very large ASTs
164    /// and extraction payloads (issue #1086). Use `--max-file-size 0` when the
165    /// bundled file really should be analyzed.
166    SkippedMinifiedFile {
167        /// On-disk size of the skipped file in bytes.
168        size_bytes: u64,
169    },
170    /// A dot-prefixed directory was not traversed by source discovery even
171    /// though it contains at least one source file the project has not
172    /// excluded. Hidden directories are skipped by default apart from a small
173    /// convention allowlist (`.storybook`, `.vitepress`, `.well-known`,
174    /// `.changeset`, `.github`) and the directories an active framework plugin
175    /// or a `package.json` script reference contributes, so files inside are
176    /// never parsed and their imports and exports are invisible to every
177    /// analysis. A file, export or dependency that only the directory uses can
178    /// be reported as unused. A `!<dir>/**` entry in `ignorePatterns` adds the
179    /// directory to traversal (issue #2452). Or add the file to `entry`, the
180    /// export to `ignoreExports` or the dependency to `ignoreDependencies` to
181    /// stop that false positive, or add the directory to `ignorePatterns` to
182    /// silence this (issue #461). Running
183    /// fallow with `--root` against the directory analyzes it on its own and
184    /// does not fix the main run (issue #2797).
185    ///
186    /// "Not excluded" is measured the way the run measures it: a directory
187    /// whose contents are gitignored, or excluded by `ignorePatterns`, or (on
188    /// a `--production` run) excluded as test or story files, never earns this
189    /// diagnostic, because the advertised remedies would find nothing there
190    /// either. Generated tool output and non-git VCS metadata are excluded by
191    /// name.
192    ///
193    /// The advisory is best-effort and bounded: one run inspects a fixed
194    /// number of skipped directories with a fixed I/O budget, in sorted path
195    /// order, so a pathological tree yields a deterministic prefix rather than
196    /// an unbounded array or an unbounded scan. The stderr note says "at
197    /// least" when a ceiling bound the run.
198    ///
199    /// Surfaced by source discovery, not workspace discovery, but shares this
200    /// channel so the skip is visible in `workspace_diagnostics[]` on
201    /// `fallow dead-code / dupes / health` JSON.
202    ///
203    /// Unlike the two skipped-file kinds beside it, this one is CAPPED. To
204    /// bound the directory reads the check costs, a run classifies at most 64
205    /// candidate directories and spends at most 1024 directory entries across
206    /// all of them, so on a project that exceeds either ceiling the array is a
207    /// prefix of the skipped directories rather than all of them, and the
208    /// stderr note says "at least N". No measured repository comes close to
209    /// either ceiling. A consumer needing an exact total should run fallow
210    /// with `--root` against the tree rather than infer one from this array.
211    SkippedSourceDotdir,
212    /// A source discovered with a stable [`FileId`](crate::discover::FileId)
213    /// could not be read before parsing. Analysis continues with the remaining
214    /// sparse module IDs and reports the underlying filesystem or UTF-8 error.
215    SourceReadFailure {
216        /// Filesystem or UTF-8 decoding error from `read_to_string`.
217        error: String,
218    },
219    /// A source file was read but parsed with diagnostics, so the module
220    /// extracted from it may be missing imports, exports, or references after
221    /// the first error. Analysis proceeds with the partial module, which is why
222    /// this is reported: an import the parser never saw credits nothing, and its
223    /// target can surface as a confident `unused-file` or `unused-export`
224    /// finding with a `delete-file` or `remove-export` action on it.
225    ///
226    /// Recorded by the parse stage, alongside `source-read-failure`, and never
227    /// used to withhold a finding. oxc reports recoverable errors for valid
228    /// syntax newer than the parser as well as for genuinely broken files, so
229    /// gating findings on this would mute real results project-wide instead of
230    /// just the affected file.
231    ///
232    /// A file that `ignoreFindings` covers is not reported. The user marked it
233    /// as broken on purpose, so its partial parse also adds no caveat to other
234    /// findings and does not count for the parse-error gate.
235    SourceParseDegraded {
236        /// Number of parser diagnostics reported for the file.
237        error_count: u32,
238        /// `true` when the parser abandoned the file instead of recovering, so
239        /// the extracted module is a fragment at best.
240        panicked: bool,
241    },
242    /// A source file nests brackets deeper than the parser cap, or needs more
243    /// stack than fallow reserves, so it was not parsed at all. The file can be
244    /// valid code, so this is not reported as a syntax error, but it degrades
245    /// the run in the same way: its imports credit nothing. It counts for the
246    /// parse-error gate and adds the same caveats as `source-parse-degraded`.
247    SourceNestingTooDeep {
248        /// The bracket nesting cap of the pre-parse scan.
249        limit: u32,
250    },
251    /// Dependency-override resolution was skipped because bun's legacy binary
252    /// `bun.lockb` sits next to this `package.json`, fallow cannot read the
253    /// binary format, and no parseable text lockfile was found to use
254    /// instead: no `bun.lock` that parses, and no readable `pnpm-lock.yaml`,
255    /// `package-lock.json`, or `npm-shrinkwrap.json`. A `yarn.lock` is never
256    /// consulted (yarn ignores `overrides`), so it does not prevent the skip
257    /// either. The manifest declares overrides, so the
258    /// `unused-dependency-overrides` check would otherwise have run; without
259    /// resolution ground truth it would flag every transitive-only pin, so no
260    /// unused-override findings are reported at all (issue #2358). Surfaced
261    /// by the override analysis, not workspace discovery, but shares this
262    /// channel so the skip is visible in `workspace_diagnostics[]` JSON and
263    /// as a stderr warning.
264    BunLockbOverrideResolutionSkipped,
265    /// Dependency-override resolution was skipped because bun's text
266    /// `bun.lock` exists but could not be parsed and no readable pnpm or npm
267    /// lockfile was available as independent resolution ground truth.
268    BunLockOverrideResolutionSkipped,
269    /// Dependency-override resolution was skipped because `pnpm-lock.yaml`
270    /// exists but could not be parsed, for example because it still holds
271    /// unresolved merge-conflict markers, and no other parseable lockfile was
272    /// available as independent resolution ground truth.
273    PnpmLockOverrideResolutionSkipped,
274    /// Dependency-override resolution was skipped because `package-lock.json`
275    /// or `npm-shrinkwrap.json` exists but could not be parsed, for example
276    /// because it still holds unresolved merge-conflict markers, and no other
277    /// parseable lockfile was available as independent resolution ground
278    /// truth.
279    NpmLockOverrideResolutionSkipped,
280    /// A bun manifest declares both `overrides` and a non-empty `resolutions`
281    /// object. Bun applies `overrides` and ignores `resolutions`, so fallow
282    /// reports the shadowed configuration without offering removal advice.
283    BunResolutionsShadowedByOverrides,
284    /// The declared pnpm version ignores the `overrides` section of
285    /// `pnpm-workspace.yaml`. pnpm does not print a warning. Fallow reports
286    /// no findings for the ignored entries. `cause` tells why pnpm ignores
287    /// the section.
288    PnpmWorkspaceOverridesIgnored {
289        /// Why pnpm ignores the section. The cause decides the remedy.
290        cause: PnpmWorkspaceOverridesIgnoredCause,
291    },
292    /// The project has no `node_modules` directory and is not a Deno project
293    /// that legitimately runs without one. Analysis proceeds, but three things
294    /// degrade silently: package `exports` and conditional exports cannot be
295    /// read, so imports into a dependency's subpaths resolve less precisely;
296    /// framework plugins that activate on an installed package stay inactive,
297    /// so their entry points and path aliases are missing; and a dependency's
298    /// installed shape cannot be inspected, so type-only dependency
299    /// classification falls back to declaration-based heuristics.
300    ///
301    /// Recorded once per run by the source walk, anchored at the missing
302    /// `node_modules` directory so the reported path is a real location rather
303    /// than the empty string a root-anchored diagnostic would render. This used
304    /// to be a bare `tracing::warn!` duplicated in two pipelines, so it never
305    /// reached JSON output and never reached `fallow doctor`, which reported
306    /// `pass` on a tree that had never been installed.
307    NodeModulesMissing,
308    /// `boundaries` is empty while `boundary-violation` is not `off`, so the
309    /// boundary detector never ran. Its summary counters are therefore
310    /// structurally zero and say nothing about the project.
311    ///
312    /// This is the UNCONFIGURED zero, not the user-chosen one: a project that
313    /// sets `boundary-violation: off` asked for silence and can see that
314    /// choice in `fallow config`. A project that left `boundaries` empty
315    /// cannot distinguish "no violations" from "nothing was measured".
316    BoundariesNotConfigured,
317    /// `rulePacks` is empty while `policy-violation` is not `off`, so the
318    /// policy detector never ran and its summary counters are structurally
319    /// zero. The unconfigured counterpart of
320    /// [`Self::BoundariesNotConfigured`].
321    RulePacksNotConfigured,
322    /// One of fallow's built-in discovery ignore patterns (`**/dist/**`,
323    /// `**/build/**`, `**/coverage/**`, and the four minified-bundle globs)
324    /// removed at least one candidate source file from this walk. The files
325    /// are never read, so their imports and exports are invisible to every
326    /// analysis, and until issue #2638 the drop was completely silent:
327    /// pointing fallow at a directory a built-in pattern matches returned a
328    /// clean report with exit 0 and nothing said why.
329    ///
330    /// `**/node_modules/**` is carved out and never appears in `pattern`:
331    /// installed dependencies are not the first-party source this diagnostic
332    /// is about, and a project that does not gitignore them would get a
333    /// five-figure count with no useful remedy. `**/.git/**` cannot fire,
334    /// because hidden directories are not traversed.
335    ///
336    /// One entry per pattern, never per file or per directory, so the array
337    /// grows by at most the number of built-in patterns on a project of any
338    /// size. `path` anchors at the matched directory holding the most excluded
339    /// files for that pattern, ties broken by the lexicographically first
340    /// path, so two runs on one tree report the same location. On a nested
341    /// match it is the DEEPEST segment the pattern matched
342    /// (`build/tools/build`, not `build`), because that is the directory the
343    /// `--root` remedy names and re-rooting at a shallower one would leave a
344    /// matching segment behind. That directory is the
345    /// largest group and not a majority: a flat monorepo can spread ten
346    /// excluded files over ten sibling `dist/` directories and every one of
347    /// them is then "the largest". `file_count` spans all of them, and
348    /// `directory_count` says how many there were, so a reader can tell a
349    /// single tree from a scattered one without a directory list in the
350    /// payload.
351    ///
352    /// Three properties of the population are load-bearing and easy to
353    /// misread:
354    ///
355    /// - **Gitignored trees count zero.** Source discovery honors
356    ///   `.gitignore`, `.git/info/exclude`, and the global gitignore, and
357    ///   prunes those directories before this check runs. The honest reading
358    ///   is "candidate source files git did not already hide and a built-in
359    ///   pattern then dropped", which is why a repository that gitignores its
360    ///   own `dist/` never sees this diagnostic.
361    /// - **A user `ignorePatterns` entry is not a surprise.** The compiled
362    ///   ignore set is the union of `ignorePatterns` and the built-ins, so a
363    ///   file both matched was an explicit project choice and is attributed to
364    ///   no pattern here. A `!` entry in `ignorePatterns` lifts a built-in for
365    ///   the paths it matches (issue #2940), and a lifted file is discovered,
366    ///   so it is never counted here.
367    /// - **The remedy depends on the pattern's shape.** A directory-shaped
368    ///   built-in (`**/dist/**`) is matched against the path relative to the
369    ///   run root, so re-rooting inside the matched directory removes the
370    ///   matched segment and the files become visible: the message advertises
371    ///   `fallow --root <dir>`. A file-shaped built-in (`**/*.min.js` and the
372    ///   three other bundle globs) matches on the file name and keeps matching
373    ///   at any root, so the message says so and points at renaming instead of
374    ///   handing out a command that provably does nothing.
375    ///
376    /// Deliberately NOT one of the [`Self::source_never_analyzed`] kinds. These
377    /// exclusions are the product's designed behavior on generated output, not
378    /// a degraded run: answering `true` would attach `IncompleteFileAnalysis`
379    /// and `IncompleteImportGraph` caveats to findings on nearly every project
380    /// that keeps a non-gitignored `dist/` or `coverage/`, and make `fallow
381    /// fix` withhold `delete-file` and `remove-export` actions project-wide.
382    ExcludedByDefaultIgnore {
383        /// The built-in glob that matched, verbatim (for example
384        /// `**/build/**`).
385        pattern: String,
386        /// Candidate source files this pattern excluded in this walk, across
387        /// every directory it matched, not just the one `path` anchors at.
388        /// Exact: the walk counts each excluded candidate once.
389        file_count: u32,
390        /// Distinct directories this pattern matched at, `path` included, and
391        /// not the number of directories that held the files. A
392        /// directory-shaped pattern (`**/dist/**`) matches at the directory it
393        /// names, so an excluded subtree counts once however many nested
394        /// directories inside it held source: a `dist/` holding files in three
395        /// sub-directories reports `1`. A file-shaped pattern (`**/*.min.js`)
396        /// has no directory to collapse to and counts each matched file's own
397        /// parent. Exact either way, and anything above `1` says `path` names
398        /// one matched location out of several.
399        directory_count: u32,
400    },
401    /// The walk finished with no source file to analyze at all, so every
402    /// finding count this run reports is zero because nothing was measured
403    /// rather than because the project is clean (issue #2686).
404    ///
405    /// Distinct from [`Self::ExcludedByDefaultIgnore`], which reports one
406    /// pattern's exclusions and is designed behavior on generated output. The
407    /// alarm is not the exclusion, it is having nothing left afterwards, and
408    /// that condition also fires with no exclusion at all: a docs-only
409    /// repository, a workspace member with no TypeScript, or a path filter that
410    /// matched nothing. `excluded_file_count` names the built-in-ignore
411    /// contribution so the common cause is still attributable, and is `0` when
412    /// no built-in pattern took part.
413    ///
414    /// This is the kind a CI consumer reads to tell "measured zero" from
415    /// "measured nothing": the human report has said so since 3.26.0, but only
416    /// in human format and only under the built-in-ignore cause, so `--quiet
417    /// --format json` saw a clean green either way.
418    NoSourceFilesAnalyzed {
419        /// Candidate source files the built-in ignore patterns removed from
420        /// this walk, summed across every pattern. `0` when the walk found no
421        /// candidate to exclude in the first place.
422        excluded_file_count: u32,
423    },
424    /// Per-file health scoring failed, so the score list is empty and the
425    /// scored-file count is `0` because nothing was measured rather than
426    /// because the project has no files worth scoring. Every score-derived
427    /// number (the average maintainability index, the refactoring targets, the
428    /// hotspot complexity half) is then structurally zero (issue #2689).
429    FileScoresUnavailable {
430        /// Scoring error text.
431        error: String,
432    },
433    /// Churn-based hotspot analysis was skipped, so the hotspots, churn and
434    /// ownership sections report nothing at all. The remaining health sections
435    /// are unaffected.
436    HotspotsSkipped {
437        /// Which input stopped it, as a kebab-case token: `not-a-repository`,
438        /// `no-commits`, `invalid-since` or `churn-file-unreadable`. The set is
439        /// open.
440        ///
441        /// The cause decides the remedy, which is why it is on the wire: a run
442        /// outside a repository is fixed by running fallow inside one, a
443        /// branch without a commit by committing, a malformed `--since` by
444        /// respelling the flag, and a churn file that changed under the run by
445        /// rerunning it. A consumer reading only the kind would offer the first
446        /// remedy for all four.
447        cause: String,
448    },
449    /// Change coupling found no pairs because the history cannot show them.
450    /// The `change_coupling` section is present with an empty `pairs` list,
451    /// and this entry tells why the list is empty.
452    ChangeCouplingSkipped {
453        /// Kebab-case token: `shallow-clone` (the fetched history is
454        /// incomplete) or `too-few-commits` (fewer analyzed commits than the
455        /// minimum revisions of one file). The set is open.
456        cause: String,
457    },
458    /// The repository is a shallow clone, so churn is measured over the fetched
459    /// history only and every hotspot figure is incomplete.
460    ShallowClone {
461        /// `true` when the run also asked for ownership attribution, which a
462        /// shallow clone skews further by inflating single-author dominance.
463        ownership_requested: bool,
464    },
465    /// No commit timestamp was available, so churn recency and ownership
466    /// staleness were measured against the wall clock and drift between two
467    /// runs over the same commit.
468    UnpinnedClock,
469    /// Ownership attribution was requested but its inputs did not load, so
470    /// hotspot entries carry degraded or absent owner signals.
471    OwnershipUnavailable {
472        /// Which input failed, as a kebab-case token: `invalid-bot-pattern` or
473        /// `codeowners-parse-failed`. The set is open.
474        cause: String,
475        /// Underlying error text.
476        error: String,
477    },
478    /// A saved health snapshot could not be read or parsed, so the trend is
479    /// computed over fewer snapshots than the project has on disk and a
480    /// direction can flip on the missing point alone.
481    TrendSnapshotUnreadable {
482        /// Filesystem or JSON error text.
483        error: String,
484    },
485    /// A grouped health run asked for a trend, but the baseline snapshot holds
486    /// no group data that matches this run, so the groups carry no trend. The
487    /// project trend is not affected. `path` names the baseline snapshot.
488    TrendGroupBaselineUnavailable {
489        /// Why the groups have no baseline, as a kebab-case token:
490        /// `snapshot-has-no-groups` or `grouped-by-mismatch`. The set is open.
491        cause: String,
492    },
493    /// A framework plugin read a build config and could not read one of its
494    /// keys in full, so part of what the key declares never reached the
495    /// analysis. `path` names the config file.
496    ///
497    /// The reader is syntactic, so a key whose value is computed at build time
498    /// is invisible to it: a Module Federation `exposes: makeExposes()` or a
499    /// `remotes` map spread from an environment module declares entries this
500    /// run does not know about. The consequence is a finding, not a missing
501    /// number: an unread `exposes` target is not registered as an entry point
502    /// and its file can surface as `unused-file`, and an unread `remotes` alias
503    /// is not treated as provided by a remote container and its import can
504    /// surface as an unlisted dependency.
505    ///
506    /// Recorded by the plugin stage, which runs before analysis and is not
507    /// cached, so the entry is present on a warm cache too. It used to be a
508    /// bare `tracing::warn!` from inside the plugin, so it reached no envelope
509    /// and no CI consumer (issue #2736).
510    ///
511    /// A source file that calls the Module Federation runtime API gets the same
512    /// entry: `path` names the source file, `key` names the runtime function
513    /// (`registerRemotes`, `loadRemote`, `init`, `createInstance`) and
514    /// `reason` is `dynamic-argument` when the call receives a value that is
515    /// not a static literal. The analysis records it from the facts of the
516    /// parse, which a warm cache restores (issue #2795). A `.vue` or `.svelte`
517    /// file gets it for a call in its `<script>` blocks (issue #2876).
518    PluginConfigUnreadable {
519        /// The plugin that read the config, as it labels itself:
520        /// `module-federation` for a standalone `module-federation.config.*`,
521        /// or the bundler plugin (`webpack`, `rspack`, `rsbuild`, `vite`) that
522        /// read the same options inline from its own config.
523        plugin: String,
524        /// The config key that was present and not fully readable (`exposes`,
525        /// `remotes`), or the Module Federation runtime function whose
526        /// argument was not readable (`registerRemotes`, `loadRemote`, `init`,
527        /// `createInstance`). The set is open.
528        key: String,
529        /// Why it could not be read, as a kebab-case token:
530        /// `not-object-literal`, `array-form`, `spread`,
531        /// `unreadable-entries`, `unrecognized-call`,
532        /// `import-target-unreadable` or `dynamic-argument`. The set is open.
533        ///
534        /// The reason decides the remedy, which is why it is on the wire: a
535        /// value that is not an object literal is fixed by writing one, while
536        /// unreadable entries are fixed by naming those entries in the config
537        /// option the message points at.
538        reason: String,
539    },
540    /// A framework plugin read a config key it understands, does not model
541    /// that key's effect, and therefore stood a modeled default down. `path`
542    /// names the config file.
543    ///
544    /// A file can also be the `path`: a Nuxt file that reads `#components` or
545    /// `#imports` in a way fallow cannot narrow to names, such as a spread of
546    /// a namespace import, has `key` set to that module and `reason` set to
547    /// `key-effect-not-modeled`. Every name of the module then counts as used.
548    ///
549    /// The Nuxt auto-import gate is the case this exists for. With
550    /// `autoImports` enabled fallow drops the Nuxt convention entry patterns
551    /// so a genuinely unreferenced convention file is reported, and a
552    /// `components:` or `imports:` block whose effect it cannot model keeps
553    /// them, which silently costs the user the findings they opted in for.
554    ///
555    /// Deliberately NOT one of the [`Self::warns_on_stderr`] kinds. Nothing
556    /// was lost that the run could have measured: the patterns stayed, so
557    /// findings are suppressed rather than invented, and a project in this
558    /// state would otherwise warn on every run forever with "write different
559    /// config" as the only remedy, which is the reason
560    /// `boundaries-not-configured` is off stderr as well.
561    PluginEffectNotModeled {
562        /// The plugin that read the config, as it labels itself (`nuxt`).
563        plugin: String,
564        /// The config key whose effect is not modeled (`components`,
565        /// `imports`), or the virtual module a file reads (`#components`,
566        /// `#imports`). The set is open.
567        key: String,
568        /// Why the effect is not modeled, as a kebab-case token:
569        /// `key-effect-not-modeled` when the key's own value is the reason,
570        /// `config-property-unreadable` when a top-level property of the same
571        /// config file could not be read statically, so no surface in it can
572        /// be classified at all. The set is open.
573        reason: String,
574    },
575    /// Test coverage was auto-detected on disk rather than passed with
576    /// `--coverage`, and `path` names the file that fed the CRAP scores.
577    ///
578    /// Deliberately NOT one of the [`Self::warns_on_stderr`] kinds: nothing
579    /// degraded, the run measured exactly what it found. It is provenance, and
580    /// it is on the wire because a score computed against a file the user did
581    /// not name is not reproducible and nothing else says which file it was.
582    CoverageAutoDetected,
583    /// `fallow flags --retirement` asked for flag age, but the repository is
584    /// a shallow clone. Blame and pickaxe see the fetched history only, so
585    /// every age would be too young. The report gives no age.
586    FlagAgeShallowClone,
587    /// `fallow flags --retirement` asked for flag age, but git history is
588    /// not available. The report gives no age.
589    FlagAgeUnavailable {
590        /// Why no history is available, as a kebab-case token:
591        /// `not-a-repository` or `no-commits`. The set is open.
592        cause: String,
593    },
594    /// A glob in `ignoreDependencies` matched no dependency that a
595    /// `package.json` of the project declares, so the entry had no effect in
596    /// this run. The usual cause is a typo in the scope or the name.
597    ///
598    /// The dead-code run records it only when the unused-dependency check ran
599    /// and the run reports dependency findings. A run filtered to other issue
600    /// types (`--unused-files`) or scoped to files (`--file`) does not record
601    /// it. An exact-name entry never produces it. `path` is the project root.
602    ///
603    /// Not a degraded run: the analysis measured what it was asked to, so
604    /// `degrades_analysis` is false and no finding carries a caveat.
605    IgnoreDependenciesGlobUnmatched {
606        /// The `ignoreDependencies` entry, as written in the config.
607        pattern: String,
608    },
609    /// A pattern in `ignoreFindings` matched no finding of the dead-code run,
610    /// so the entry had no effect in this run. The usual cause is a typo in
611    /// the path.
612    ///
613    /// The pattern is compared with every finding the analysis produced,
614    /// before issue-type filters and scope filters, so a filtered run does not
615    /// make a pattern look unused. `path` is the project root.
616    ///
617    /// Not a degraded run, for the same reason as
618    /// [`Self::IgnoreDependenciesGlobUnmatched`].
619    IgnoreFindingsPatternUnmatched {
620        /// The `ignoreFindings` entry, as written in the config.
621        pattern: String,
622    },
623    /// An `entry` pattern matched only files that another source (a
624    /// `package.json` field or script, a plugin, `dynamicallyLoaded`) makes
625    /// runtime entry points (issue #3824). Not for a pattern that matches no
626    /// file, nor a `--production` run. `path` is the root. Not degraded.
627    EntryPatternRedundant {
628        /// The `entry` pattern, as written in the config.
629        pattern: String,
630    },
631    /// An `ignoreDependencies` entry hid no dependency finding (issue #3824).
632    /// A glob that matches no declared dependency is
633    /// [`Self::IgnoreDependenciesGlobUnmatched`] instead. Same conditions as
634    /// that kind, and not a `--production` run. `path` is the root.
635    IgnoreDependenciesEntryRedundant {
636        /// The `ignoreDependencies` entry, as written in the config.
637        pattern: String,
638    },
639    /// An `ignorePatterns` glob excluded no file that the run would otherwise
640    /// analyze (issue #3824). Never a `!` exception, a glob that names a
641    /// hidden directory, or a `--production` run. A glob for build output has
642    /// an effect only after a build. `path` is the root. Not degraded.
643    IgnorePatternsEntryRedundant {
644        /// The `ignorePatterns` glob, as written in the config.
645        pattern: String,
646    },
647    /// The dead-code baseline was saved in a different install state than
648    /// this run: one of the two runs had `node_modules` and the other did not.
649    /// Findings that need installed packages (dependency, unlisted,
650    /// unresolved-import and script-binary findings) can differ, so the run
651    /// can report baseline findings as new. `path` is the baseline file.
652    ///
653    /// Only a baseline that records its install state can report this. A
654    /// legacy baseline has no record, so the state is unknown.
655    BaselineInstallStateMismatch {
656        /// The install state when the baseline was saved.
657        baseline_node_modules: NodeModulesState,
658        /// The install state of this run.
659        run_node_modules: NodeModulesState,
660    },
661}
662
663impl WorkspaceDiagnosticKind {
664    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
665    #[must_use]
666    pub const fn id(&self) -> &'static str {
667        match self {
668            Self::UndeclaredWorkspace => "undeclared-workspace",
669            Self::MalformedPackageJson { .. } => "malformed-package-json",
670            Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
671            Self::WorkspaceGlobOutsideRoot { .. } => "workspace-glob-outside-root",
672            Self::WorkspacePatternDirMissing { .. } => "workspace-pattern-dir-missing",
673            Self::NestedProjectRoot => "nested-project-root",
674            Self::MalformedTsconfig { .. } => "malformed-tsconfig",
675            Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
676            Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
677            Self::SkippedLargeFile { .. } => "skipped-large-file",
678            Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
679            Self::SkippedSourceDotdir => "skipped-source-dotdir",
680            Self::SourceReadFailure { .. } => "source-read-failure",
681            Self::SourceParseDegraded { .. } => "source-parse-degraded",
682            Self::SourceNestingTooDeep { .. } => "source-nesting-too-deep",
683            Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
684            Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
685            Self::PnpmLockOverrideResolutionSkipped => "pnpm-lock-override-resolution-skipped",
686            Self::NpmLockOverrideResolutionSkipped => "npm-lock-override-resolution-skipped",
687            Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
688            Self::PnpmWorkspaceOverridesIgnored { .. } => "pnpm-workspace-overrides-ignored",
689            Self::NodeModulesMissing => "node-modules-missing",
690            Self::BoundariesNotConfigured => "boundaries-not-configured",
691            Self::RulePacksNotConfigured => "rule-packs-not-configured",
692            Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
693            Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
694            Self::FileScoresUnavailable { .. } => "file-scores-unavailable",
695            Self::HotspotsSkipped { .. } => "hotspots-skipped",
696            Self::ChangeCouplingSkipped { .. } => "change-coupling-skipped",
697            Self::ShallowClone { .. } => "shallow-clone",
698            Self::UnpinnedClock => "unpinned-clock",
699            Self::OwnershipUnavailable { .. } => "ownership-unavailable",
700            Self::TrendSnapshotUnreadable { .. } => "trend-snapshot-unreadable",
701            Self::TrendGroupBaselineUnavailable { .. } => "trend-group-baseline-unavailable",
702            Self::PluginConfigUnreadable { .. } => "plugin-config-unreadable",
703            Self::PluginEffectNotModeled { .. } => "plugin-effect-not-modeled",
704            Self::CoverageAutoDetected => "coverage-auto-detected",
705            Self::FlagAgeShallowClone => "flag-age-shallow-clone",
706            Self::FlagAgeUnavailable { .. } => "flag-age-unavailable",
707            Self::IgnoreDependenciesGlobUnmatched { .. } => "ignore-dependencies-glob-unmatched",
708            Self::IgnoreFindingsPatternUnmatched { .. } => "ignore-findings-pattern-unmatched",
709            Self::EntryPatternRedundant { .. } => "entry-pattern-redundant",
710            Self::IgnoreDependenciesEntryRedundant { .. } => "ignore-dependencies-entry-redundant",
711            Self::IgnorePatternsEntryRedundant { .. } => "ignore-patterns-entry-redundant",
712            Self::BaselineInstallStateMismatch { .. } => "baseline-install-state-mismatch",
713        }
714    }
715
716    /// Whether this diagnostic is worth a `tracing::warn!` line on stderr, on
717    /// top of its permanent entry in `workspace_diagnostics[]`.
718    ///
719    /// A warning is for a run whose RESULTS are degraded: something the user
720    /// installed, wrote, or expected did not reach the analysis. The two
721    /// unconfigured-check kinds are not that. They fire in the product's
722    /// default state, on every project that never opted into boundaries or
723    /// rule packs, and they will keep firing forever, because the remedy they
724    /// offer is to write configuration in order to silence a warning about not
725    /// having written configuration. They stay in the structured array, where a
726    /// consumer that wants to distinguish "measured zero" from "measured
727    /// nothing" can read them, and off the stderr surface that every other
728    /// command shares.
729    ///
730    /// `coverage-auto-detected` answers false for a third reason: it reports
731    /// the provenance of an input that DID load, so a consumer sentence about a
732    /// degraded run would state something untrue about it. Its own note is
733    /// printed by the health pipeline.
734    ///
735    /// `plugin-effect-not-modeled` answers false for the first reason: the
736    /// config was readable and nothing the run could have measured was lost,
737    /// so it would warn forever on a project whose `nuxt.config` fallow does
738    /// not model. Its sibling `plugin-config-unreadable` answers true, because
739    /// there a declaration the user wrote did not reach the analysis and
740    /// findings can be wrong in either direction.
741    #[must_use]
742    pub const fn warns_on_stderr(&self) -> bool {
743        match self {
744            Self::BoundariesNotConfigured
745            | Self::RulePacksNotConfigured
746            | Self::ExcludedByDefaultIgnore { .. }
747            | Self::PluginEffectNotModeled { .. }
748            | Self::CoverageAutoDetected
749            | Self::TrendGroupBaselineUnavailable { .. }
750            | Self::ChangeCouplingSkipped { .. }
751            | Self::IgnoreDependenciesGlobUnmatched { .. }
752            | Self::IgnoreFindingsPatternUnmatched { .. }
753            | Self::EntryPatternRedundant { .. }
754            | Self::IgnoreDependenciesEntryRedundant { .. }
755            | Self::IgnorePatternsEntryRedundant { .. }
756            | Self::NestedProjectRoot => false,
757            Self::UndeclaredWorkspace
758            | Self::MalformedPackageJson { .. }
759            | Self::GlobMatchedNoPackageJson { .. }
760            | Self::WorkspaceGlobOutsideRoot { .. }
761            | Self::WorkspacePatternDirMissing { .. }
762            | Self::MalformedTsconfig { .. }
763            | Self::TsconfigReferenceDirMissing
764            | Self::MalformedPnpmWorkspaceYaml { .. }
765            | Self::SkippedLargeFile { .. }
766            | Self::SkippedMinifiedFile { .. }
767            | Self::SkippedSourceDotdir
768            | Self::SourceReadFailure { .. }
769            | Self::SourceParseDegraded { .. }
770            | Self::SourceNestingTooDeep { .. }
771            | Self::BunLockbOverrideResolutionSkipped
772            | Self::BunLockOverrideResolutionSkipped
773            | Self::PnpmLockOverrideResolutionSkipped
774            | Self::NpmLockOverrideResolutionSkipped
775            | Self::BunResolutionsShadowedByOverrides
776            | Self::PnpmWorkspaceOverridesIgnored { .. }
777            | Self::NodeModulesMissing
778            | Self::NoSourceFilesAnalyzed { .. }
779            | Self::FileScoresUnavailable { .. }
780            | Self::HotspotsSkipped { .. }
781            | Self::ShallowClone { .. }
782            | Self::UnpinnedClock
783            | Self::OwnershipUnavailable { .. }
784            | Self::TrendSnapshotUnreadable { .. }
785            | Self::PluginConfigUnreadable { .. }
786            | Self::FlagAgeShallowClone
787            | Self::FlagAgeUnavailable { .. }
788            | Self::BaselineInstallStateMismatch { .. } => true,
789        }
790    }
791
792    /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
793    /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
794    /// discovery diagnostics are APPENDED to the registry after config load, so
795    /// `stash_workspace_diagnostics` must preserve them when it replaces the
796    /// workspace-discovery set, otherwise the per-analysis config re-loads in
797    /// combined-mode (`fallow` with no subcommand re-loads config for check,
798    /// dupes, and health) wipe them before the JSON envelope is built (issue
799    /// #1086).
800    #[must_use]
801    pub const fn is_source_discovery(&self) -> bool {
802        matches!(
803            self,
804            Self::SkippedLargeFile { .. }
805                | Self::SkippedMinifiedFile { .. }
806                | Self::SkippedSourceDotdir
807                | Self::SourceReadFailure { .. }
808                | Self::SourceParseDegraded { .. }
809                | Self::SourceNestingTooDeep { .. }
810                | Self::NodeModulesMissing
811                | Self::ExcludedByDefaultIgnore { .. }
812                | Self::NoSourceFilesAnalyzed { .. }
813        )
814    }
815
816    /// Whether this diagnostic is written by the source file WALK
817    /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
818    /// walk replaces wholesale for its root. `source-read-failure` is the
819    /// other source-discovery kind and is NOT one of these: the parse stage
820    /// records it after the walk, so it has to keep reaching consumers through
821    /// the registry.
822    ///
823    /// A walk-recorded entry must reach an analysis from its OWN walk's return
824    /// value. Combined mode runs the dead-code and duplication walks under
825    /// `rayon::join` whenever a per-analysis `production` split stops them from
826    /// sharing a file list, so a registry read answers "whichever walk wrote
827    /// last" and varies between runs of the same command (issue #2366).
828    #[must_use]
829    pub const fn is_source_walk_recorded(&self) -> bool {
830        matches!(
831            self,
832            Self::SkippedLargeFile { .. }
833                | Self::SkippedMinifiedFile { .. }
834                | Self::SkippedSourceDotdir
835                | Self::NodeModulesMissing
836                | Self::ExcludedByDefaultIgnore { .. }
837                | Self::NoSourceFilesAnalyzed { .. }
838        )
839    }
840
841    /// Whether this diagnostic reports a source file whose contents this run
842    /// never analyzed, so every import and export the file holds is invisible
843    /// to the module graph.
844    ///
845    /// This is the class `reachability_caveats[]` exists for. A file the run
846    /// never read credits nothing, so the modules it imports surface as
847    /// confident `unused-file` and `unused-export` findings carrying
848    /// `delete-file` and `remove-export` actions, and `fallow fix` would
849    /// otherwise apply the removal against source that still imports the
850    /// target.
851    ///
852    /// All four discovery-side kinds qualify, for the same reason and with the
853    /// same consequence:
854    ///
855    /// - `skipped-large-file` and `skipped-minified-file`: the file is in the
856    ///   project tree and was never opened, so its import list is unknown.
857    /// - `skipped-source-dotdir`: the directory holds at least one source file
858    ///   the project did not exclude, and none of them were traversed. The
859    ///   diagnostic is capped, so it under-reports rather than over-reports;
860    ///   its presence still proves unseen source exists.
861    /// - `source-read-failure`: the file was discovered and then could not be
862    ///   read, so nothing was extracted from it at all.
863    ///
864    /// `source-parse-degraded` is deliberately NOT one of these, though it
865    /// belongs to the same family. Neither is `excluded-by-default-ignore`,
866    /// for a different reason: that one reports designed behavior on generated
867    /// output rather than a degraded run, and its own doc comment carries the
868    /// argument.
869    ///
870    /// `source-parse-degraded`: that file WAS read, so it has a module and
871    /// a graph node and its reachability is observable, which lets the caveat
872    /// pass narrow it: a degraded module that is itself unreachable cannot
873    /// change a reachability verdict. Every kind above has no node to ask (a
874    /// read failure has one with nothing extracted into it), so no narrowing
875    /// is available and the caveat they raise is run-level.
876    ///
877    /// The match is exhaustive on purpose: a new "the run did not see this
878    /// file" kind has to be classified here, and answering `true` is the only
879    /// wiring its findings need in order to inherit both the caveat and the
880    /// `fallow fix` withholding that follows it.
881    #[must_use]
882    pub const fn source_never_analyzed(&self) -> bool {
883        match self {
884            Self::SkippedLargeFile { .. }
885            | Self::SkippedMinifiedFile { .. }
886            | Self::SkippedSourceDotdir
887            | Self::SourceReadFailure { .. } => true,
888            Self::UndeclaredWorkspace
889            | Self::MalformedPackageJson { .. }
890            | Self::GlobMatchedNoPackageJson { .. }
891            | Self::WorkspaceGlobOutsideRoot { .. }
892            | Self::WorkspacePatternDirMissing { .. }
893            | Self::NestedProjectRoot
894            | Self::MalformedTsconfig { .. }
895            | Self::TsconfigReferenceDirMissing
896            | Self::MalformedPnpmWorkspaceYaml { .. }
897            | Self::SourceParseDegraded { .. }
898            | Self::SourceNestingTooDeep { .. }
899            | Self::BunLockbOverrideResolutionSkipped
900            | Self::BunLockOverrideResolutionSkipped
901            | Self::PnpmLockOverrideResolutionSkipped
902            | Self::NpmLockOverrideResolutionSkipped
903            | Self::BunResolutionsShadowedByOverrides
904            | Self::PnpmWorkspaceOverridesIgnored { .. }
905            | Self::NodeModulesMissing
906            | Self::BoundariesNotConfigured
907            | Self::RulePacksNotConfigured
908            | Self::ExcludedByDefaultIgnore { .. }
909            | Self::NoSourceFilesAnalyzed { .. }
910            | Self::FileScoresUnavailable { .. }
911            | Self::HotspotsSkipped { .. }
912            | Self::ChangeCouplingSkipped { .. }
913            | Self::ShallowClone { .. }
914            | Self::UnpinnedClock
915            | Self::OwnershipUnavailable { .. }
916            | Self::TrendSnapshotUnreadable { .. }
917            | Self::TrendGroupBaselineUnavailable { .. }
918            | Self::PluginConfigUnreadable { .. }
919            | Self::PluginEffectNotModeled { .. }
920            | Self::CoverageAutoDetected
921            | Self::FlagAgeShallowClone
922            | Self::FlagAgeUnavailable { .. }
923            | Self::IgnoreDependenciesGlobUnmatched { .. }
924            | Self::IgnoreFindingsPatternUnmatched { .. }
925            | Self::EntryPatternRedundant { .. }
926            | Self::IgnoreDependenciesEntryRedundant { .. }
927            | Self::IgnorePatternsEntryRedundant { .. }
928            | Self::BaselineInstallStateMismatch { .. } => false,
929        }
930    }
931
932    /// Whether this diagnostic is recorded by the ANALYZE stage (the
933    /// dependency-catalog and override detectors) rather than by workspace or
934    /// source discovery. Analysis-stage diagnostics reach the registry through
935    /// `record_workspace_diagnostics` after config load, so
936    /// `stash_workspace_diagnostics` must preserve them across combined-mode's
937    /// per-analysis config re-loads, and every analyze pass clears its previous
938    /// entries before re-recording so a fixed cause drops out on the next run
939    /// (issue #2366). The match is exhaustive on purpose: a new kind must be
940    /// classified here before it compiles.
941    ///
942    /// Classify a kind `true` ONLY when a detector reachable from the dead-code
943    /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
944    /// the single clear site. A kind recorded exclusively by another stage would
945    /// be cleared by the next dead-code pass and never come back.
946    #[must_use]
947    pub const fn is_analysis_stage(&self) -> bool {
948        match self {
949            Self::MalformedPnpmWorkspaceYaml { .. }
950            | Self::BunLockbOverrideResolutionSkipped
951            | Self::BunLockOverrideResolutionSkipped
952            | Self::PnpmLockOverrideResolutionSkipped
953            | Self::NpmLockOverrideResolutionSkipped
954            | Self::BunResolutionsShadowedByOverrides
955            | Self::PnpmWorkspaceOverridesIgnored { .. }
956            | Self::BoundariesNotConfigured
957            | Self::RulePacksNotConfigured => true,
958            Self::UndeclaredWorkspace
959            | Self::MalformedPackageJson { .. }
960            | Self::GlobMatchedNoPackageJson { .. }
961            | Self::WorkspaceGlobOutsideRoot { .. }
962            | Self::WorkspacePatternDirMissing { .. }
963            | Self::NestedProjectRoot
964            | Self::MalformedTsconfig { .. }
965            | Self::TsconfigReferenceDirMissing
966            | Self::SkippedLargeFile { .. }
967            | Self::SkippedMinifiedFile { .. }
968            | Self::SkippedSourceDotdir
969            | Self::SourceReadFailure { .. }
970            | Self::SourceParseDegraded { .. }
971            | Self::SourceNestingTooDeep { .. }
972            | Self::NodeModulesMissing
973            | Self::ExcludedByDefaultIgnore { .. }
974            | Self::NoSourceFilesAnalyzed { .. }
975            | Self::FileScoresUnavailable { .. }
976            | Self::HotspotsSkipped { .. }
977            | Self::ChangeCouplingSkipped { .. }
978            | Self::ShallowClone { .. }
979            | Self::UnpinnedClock
980            | Self::OwnershipUnavailable { .. }
981            | Self::TrendSnapshotUnreadable { .. }
982            | Self::TrendGroupBaselineUnavailable { .. }
983            | Self::PluginConfigUnreadable { .. }
984            | Self::PluginEffectNotModeled { .. }
985            | Self::CoverageAutoDetected
986            | Self::FlagAgeShallowClone
987            | Self::FlagAgeUnavailable { .. }
988            | Self::IgnoreDependenciesGlobUnmatched { .. }
989            | Self::IgnoreFindingsPatternUnmatched { .. }
990            | Self::EntryPatternRedundant { .. }
991            | Self::IgnoreDependenciesEntryRedundant { .. }
992            | Self::IgnorePatternsEntryRedundant { .. }
993            | Self::BaselineInstallStateMismatch { .. } => false,
994        }
995    }
996
997    /// Whether this diagnostic is recorded by the HEALTH pipeline (scoring,
998    /// churn, ownership, trend, coverage input resolution) rather than by
999    /// workspace discovery, source discovery or the analyze stage.
1000    ///
1001    /// Health-stage diagnostics are appended to the registry after config
1002    /// load, so `stash_workspace_diagnostics` must preserve them across
1003    /// combined mode's per-analysis config re-loads, and the health run clears
1004    /// its previous entries before re-recording so a fixed CODEOWNERS drops out
1005    /// on the next run (issue #2689).
1006    ///
1007    /// They are deliberately NOT [`Self::is_analysis_stage`], although they
1008    /// share both of those properties. That predicate additionally means "the
1009    /// dead-code analyze pass re-records this", and the pass clears every kind
1010    /// answering it on entry. Health computes file scores by running that same
1011    /// pass, so a health-stage kind classified there would be wiped mid-run by
1012    /// the analysis it is reporting on.
1013    #[must_use]
1014    pub const fn is_health_stage(&self) -> bool {
1015        match self {
1016            Self::FileScoresUnavailable { .. }
1017            | Self::HotspotsSkipped { .. }
1018            | Self::ChangeCouplingSkipped { .. }
1019            | Self::ShallowClone { .. }
1020            | Self::UnpinnedClock
1021            | Self::OwnershipUnavailable { .. }
1022            | Self::TrendSnapshotUnreadable { .. }
1023            | Self::TrendGroupBaselineUnavailable { .. }
1024            | Self::CoverageAutoDetected => true,
1025            Self::UndeclaredWorkspace
1026            | Self::MalformedPackageJson { .. }
1027            | Self::GlobMatchedNoPackageJson { .. }
1028            | Self::WorkspaceGlobOutsideRoot { .. }
1029            | Self::WorkspacePatternDirMissing { .. }
1030            | Self::NestedProjectRoot
1031            | Self::MalformedTsconfig { .. }
1032            | Self::TsconfigReferenceDirMissing
1033            | Self::MalformedPnpmWorkspaceYaml { .. }
1034            | Self::SkippedLargeFile { .. }
1035            | Self::SkippedMinifiedFile { .. }
1036            | Self::SkippedSourceDotdir
1037            | Self::SourceReadFailure { .. }
1038            | Self::SourceParseDegraded { .. }
1039            | Self::SourceNestingTooDeep { .. }
1040            | Self::BunLockbOverrideResolutionSkipped
1041            | Self::BunLockOverrideResolutionSkipped
1042            | Self::PnpmLockOverrideResolutionSkipped
1043            | Self::NpmLockOverrideResolutionSkipped
1044            | Self::BunResolutionsShadowedByOverrides
1045            | Self::PnpmWorkspaceOverridesIgnored { .. }
1046            | Self::NodeModulesMissing
1047            | Self::BoundariesNotConfigured
1048            | Self::RulePacksNotConfigured
1049            | Self::ExcludedByDefaultIgnore { .. }
1050            | Self::PluginConfigUnreadable { .. }
1051            | Self::PluginEffectNotModeled { .. }
1052            | Self::NoSourceFilesAnalyzed { .. }
1053            | Self::FlagAgeShallowClone
1054            | Self::FlagAgeUnavailable { .. }
1055            | Self::IgnoreDependenciesGlobUnmatched { .. }
1056            | Self::IgnoreFindingsPatternUnmatched { .. }
1057            | Self::EntryPatternRedundant { .. }
1058            | Self::IgnoreDependenciesEntryRedundant { .. }
1059            | Self::IgnorePatternsEntryRedundant { .. }
1060            | Self::BaselineInstallStateMismatch { .. } => false,
1061        }
1062    }
1063
1064    /// Whether this diagnostic is recorded by the PLUGIN stage (framework
1065    /// plugins reading their own build configs) rather than by workspace
1066    /// discovery, source discovery, the analyze stage or the health pipeline.
1067    ///
1068    /// Plugin-stage diagnostics are recorded after config load, so
1069    /// `stash_workspace_diagnostics` must preserve them across combined mode's
1070    /// per-analysis config re-loads, and each plugin run replaces the previous
1071    /// run's set so a fixed config drops out on the next run (issue #2736).
1072    ///
1073    /// They are deliberately NOT [`Self::is_analysis_stage`], although they
1074    /// share both of those properties. That predicate additionally means "the
1075    /// dead-code analyze pass re-records this", and the pass clears every kind
1076    /// answering it on entry. Plugins run in the prelude of that same pass, so
1077    /// a plugin-stage kind classified there would be wiped inside the run that
1078    /// produced it.
1079    ///
1080    /// The match is exhaustive on purpose: a new kind must be classified here
1081    /// before it compiles.
1082    #[must_use]
1083    pub const fn is_plugin_stage(&self) -> bool {
1084        match self {
1085            Self::PluginConfigUnreadable { .. } | Self::PluginEffectNotModeled { .. } => true,
1086            Self::UndeclaredWorkspace
1087            | Self::MalformedPackageJson { .. }
1088            | Self::GlobMatchedNoPackageJson { .. }
1089            | Self::WorkspaceGlobOutsideRoot { .. }
1090            | Self::WorkspacePatternDirMissing { .. }
1091            | Self::NestedProjectRoot
1092            | Self::MalformedTsconfig { .. }
1093            | Self::TsconfigReferenceDirMissing
1094            | Self::MalformedPnpmWorkspaceYaml { .. }
1095            | Self::SkippedLargeFile { .. }
1096            | Self::SkippedMinifiedFile { .. }
1097            | Self::SkippedSourceDotdir
1098            | Self::SourceReadFailure { .. }
1099            | Self::SourceParseDegraded { .. }
1100            | Self::SourceNestingTooDeep { .. }
1101            | Self::BunLockbOverrideResolutionSkipped
1102            | Self::BunLockOverrideResolutionSkipped
1103            | Self::PnpmLockOverrideResolutionSkipped
1104            | Self::NpmLockOverrideResolutionSkipped
1105            | Self::BunResolutionsShadowedByOverrides
1106            | Self::PnpmWorkspaceOverridesIgnored { .. }
1107            | Self::NodeModulesMissing
1108            | Self::BoundariesNotConfigured
1109            | Self::RulePacksNotConfigured
1110            | Self::ExcludedByDefaultIgnore { .. }
1111            | Self::NoSourceFilesAnalyzed { .. }
1112            | Self::FileScoresUnavailable { .. }
1113            | Self::HotspotsSkipped { .. }
1114            | Self::ChangeCouplingSkipped { .. }
1115            | Self::ShallowClone { .. }
1116            | Self::UnpinnedClock
1117            | Self::OwnershipUnavailable { .. }
1118            | Self::TrendSnapshotUnreadable { .. }
1119            | Self::TrendGroupBaselineUnavailable { .. }
1120            | Self::CoverageAutoDetected
1121            | Self::FlagAgeShallowClone
1122            | Self::FlagAgeUnavailable { .. }
1123            | Self::IgnoreDependenciesGlobUnmatched { .. }
1124            | Self::IgnoreFindingsPatternUnmatched { .. }
1125            | Self::EntryPatternRedundant { .. }
1126            | Self::IgnoreDependenciesEntryRedundant { .. }
1127            | Self::IgnorePatternsEntryRedundant { .. }
1128            | Self::BaselineInstallStateMismatch { .. } => false,
1129        }
1130    }
1131}
1132
1133/// Render a byte count as a megabyte figure with one decimal place for
1134/// human-readable diagnostic messages (e.g. `12.3 MB`).
1135#[must_use]
1136fn format_size_mb(bytes: u64) -> String {
1137    #[expect(
1138        clippy::cast_precision_loss,
1139        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
1140    )]
1141    let mb = bytes as f64 / (1024.0 * 1024.0);
1142    format!("{mb:.1} MB")
1143}
1144
1145/// A diagnostic about a workspace-discovery candidate.
1146///
1147/// The `message` field is a human-readable rendering derived from `kind`. It
1148/// always ends with a concrete next step ("fix the JSON syntax", "remove from
1149/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
1150/// forward.
1151#[derive(Debug, Clone, Serialize, Deserialize)]
1152#[cfg_attr(feature = "schema", derive(JsonSchema))]
1153pub struct WorkspaceDiagnostic {
1154    /// Path to the directory or file that triggered the diagnostic.
1155    #[serde(serialize_with = "serde_path::serialize")]
1156    pub path: PathBuf,
1157    /// Kind discriminator with the typed payload.
1158    #[serde(flatten)]
1159    pub kind: WorkspaceDiagnosticKind,
1160    /// Human-readable rendering derived from `kind` + `path`. Always ends
1161    /// with a next-step hint.
1162    pub message: String,
1163    /// True when this diagnostic reports a run whose RESULTS are degraded:
1164    /// something the user installed, wrote, or expected did not reach the
1165    /// analysis. Projected from [`WorkspaceDiagnosticKind::warns_on_stderr`],
1166    /// which is the same classification that decides whether the CLI prints a
1167    /// stderr line, so a CI log built from this field and a local non-quiet run
1168    /// say the same thing.
1169    ///
1170    /// Omitted when false, which is what keeps every clean run byte-identical.
1171    /// The two unconfigured-check kinds answer false on purpose: they fire in
1172    /// the product's default state on every project that never opted into
1173    /// boundaries or rule packs, so warning on them would warn forever. So does
1174    /// `excluded-by-default-ignore`, which is designed behavior on generated
1175    /// output; the alarm for that case is `no-source-files-analyzed`.
1176    ///
1177    /// Read this instead of hardcoding a kind allowlist: a degrading kind added
1178    /// in a later release then reaches an unchanged consumer.
1179    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1180    pub degrades_analysis: bool,
1181}
1182
1183impl WorkspaceDiagnostic {
1184    /// Construct a diagnostic with the message rendered from `kind` + `path`.
1185    ///
1186    /// `root` is used to produce project-relative paths in the message text
1187    /// AND inside the variant payload (e.g. the `error` field of
1188    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
1189    /// file path from `PackageJson::load()`'s error text). Without the
1190    /// payload-side normalisation the embedded path would survive
1191    /// environment-specific differences (CI vs Docker vs local) because the
1192    /// post-serialisation `strip_root_prefix` only catches whole-string
1193    /// matches, not paths embedded mid-sentence.
1194    ///
1195    /// If `path` is not under `root` (e.g. canonicalisation crossed a
1196    /// symlink), the absolute path is emitted instead.
1197    ///
1198    /// `path` also loses any no-op `.` component, for the same reason the
1199    /// payload loses a glob's `./` prefix: one directory reached through two
1200    /// spellings of one glob must be one diagnostic.
1201    #[must_use]
1202    pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
1203        let path = normalise_diagnostic_path(path);
1204        let kind = normalise_payload_paths(root, kind);
1205        let message = render_message(root, &path, &kind);
1206        let degrades_analysis = kind.warns_on_stderr();
1207        Self {
1208            path,
1209            kind,
1210            message,
1211            degrades_analysis,
1212        }
1213    }
1214
1215    /// Return this diagnostic with `path` rewritten relative to `root`.
1216    ///
1217    /// `path` is stored absolute so callers can act on it. Every JSON envelope
1218    /// emits it project-relative instead: the analysis envelopes get there
1219    /// through the post-serialisation `strip_root_prefix` pass, which the
1220    /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
1221    /// `project_info` tool never run, so those emitted the absolute path while
1222    /// the sibling `workspaces[].path` next to it was relative. They normalise
1223    /// at the typed layer with this method instead.
1224    ///
1225    /// Paths outside `root` (canonicalisation crossed a symlink) are left
1226    /// absolute, matching how [`Self::new`] renders the message.
1227    ///
1228    /// A diagnostic anchored at the root itself becomes `.`, not the empty
1229    /// path: an empty string is not a location, and the analysis envelopes'
1230    /// post-serialisation strip only removes a `root + separator` prefix, so a
1231    /// root-anchored path that stays absolute here leaks a host path.
1232    #[must_use]
1233    pub fn into_root_relative(mut self, root: &Path) -> Self {
1234        if let Ok(relative) = self.path.strip_prefix(root) {
1235            self.path = if relative.as_os_str().is_empty() {
1236                PathBuf::from(".")
1237            } else {
1238                relative.to_path_buf()
1239            };
1240        }
1241        self
1242    }
1243}
1244
1245/// Rebuild `path` from its components so one directory has one spelling.
1246///
1247/// The dedupe key was never the problem: [`Path`] equality already ignores an
1248/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
1249/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
1250/// expands to the first spelling and the same glob spelled `pkgs/*` in
1251/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
1252/// make a project-relative path differently: the analysis envelopes strip the
1253/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
1254/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
1255/// `pkgs/aaa`). Whichever
1256/// manifest happened to be read first then decided which shape every consumer
1257/// saw. Collapsing at construction gives them one answer (issue #2366).
1258///
1259/// A path that is already component-clean rebuilds to itself. Serialization
1260/// normalises separators, so the rebuild is wire-invisible on Windows.
1261fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
1262    let rebuilt: PathBuf = path.components().collect();
1263    if rebuilt.as_os_str() == path.as_os_str() {
1264        path
1265    } else {
1266        rebuilt
1267    }
1268}
1269
1270/// Strip the project root from absolute paths embedded inside variant
1271/// payloads (the `error` field of malformed-config and source-read failures),
1272/// and drop a glob pattern's no-op `./` prefix.
1273///
1274/// Mirrors the per-platform `display()` byte sequence so the substring match
1275/// works on Windows too.
1276///
1277/// The pattern prefix matters because the payload is part of the dedupe key in
1278/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
1279/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
1280/// glob twice, and without this both spellings would report every package-less
1281/// directory under `apps/` a second time (issue #2366).
1282fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
1283    let root_str = root.display().to_string();
1284    let root_alt = root_str.replace('\\', "/");
1285    let normalise = |text: String| -> String {
1286        let stripped = text
1287            .replace(&format!("{root_str}/"), "")
1288            .replace(&format!("{root_alt}/"), "");
1289        stripped
1290            .replace(&format!("{root_str}\\"), "")
1291            .replace(&format!("{root_alt}\\"), "")
1292    };
1293    match kind {
1294        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
1295            WorkspaceDiagnosticKind::MalformedPackageJson {
1296                error: normalise(error),
1297            }
1298        }
1299        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
1300            WorkspaceDiagnosticKind::MalformedTsconfig {
1301                error: normalise(error),
1302            }
1303        }
1304        WorkspaceDiagnosticKind::SourceReadFailure { error } => {
1305            WorkspaceDiagnosticKind::SourceReadFailure {
1306                error: normalise(error),
1307            }
1308        }
1309        WorkspaceDiagnosticKind::FileScoresUnavailable { error } => {
1310            WorkspaceDiagnosticKind::FileScoresUnavailable {
1311                error: normalise(error),
1312            }
1313        }
1314        WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1315            WorkspaceDiagnosticKind::OwnershipUnavailable {
1316                cause,
1317                error: normalise(error),
1318            }
1319        }
1320        WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => {
1321            WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
1322                error: normalise(error),
1323            }
1324        }
1325        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1326            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1327                pattern: canonical_glob_pattern(pattern),
1328            }
1329        }
1330        WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot { pattern } => {
1331            WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
1332                pattern: canonical_glob_pattern(pattern),
1333            }
1334        }
1335        WorkspaceDiagnosticKind::WorkspacePatternDirMissing { pattern } => {
1336            WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
1337                pattern: canonical_glob_pattern(pattern),
1338            }
1339        }
1340        other => other,
1341    }
1342}
1343
1344/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
1345/// pattern declared in two manifests is one payload.
1346///
1347/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
1348/// its spelling: stripping it would report an empty `pattern` field and an
1349/// empty quoted glob in the warning text, which names no glob at all.
1350fn canonical_glob_pattern(pattern: String) -> String {
1351    for prefix in ["./", ".\\"] {
1352        if let Some(rest) = pattern.strip_prefix(prefix)
1353            && !rest.is_empty()
1354        {
1355            return rest.to_owned();
1356        }
1357    }
1358    pattern
1359}
1360
1361/// Concatenate two diagnostic lists, keeping the first occurrence of each
1362/// `(kind, path)` pair and the order of `primary` followed by the entries only
1363/// `secondary` has.
1364///
1365/// The single place diagnostics from two observation points are folded
1366/// together: an engine session's own capture plus the process registry, and
1367/// the combined run's per-analysis lists (issue #2366). A combined run walks
1368/// the project once per analysis, and per-analysis `production` modes can make
1369/// those walks see different file sets, so no single observation point holds
1370/// everything the run recorded; the union does, and folding it the same way
1371/// everywhere is what keeps the CLI and the programmatic route answering
1372/// identically.
1373///
1374/// The key is the WHOLE kind, payload included, not its
1375/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
1376/// path and still be two distinct diagnostics: overlapping workspace globs
1377/// (`["packages/*", "packages/*/*"]`) each report the same package-less
1378/// directory with their own `pattern`, and the standalone envelopes report
1379/// both. An id-keyed fold silently dropped the second one.
1380#[must_use]
1381pub fn merge_workspace_diagnostics(
1382    primary: Vec<WorkspaceDiagnostic>,
1383    secondary: Vec<WorkspaceDiagnostic>,
1384) -> Vec<WorkspaceDiagnostic> {
1385    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
1386    let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
1387    for diagnostic in primary.into_iter().chain(secondary) {
1388        let key = (diagnostic.kind.clone(), diagnostic.path.clone());
1389        if seen.insert(key) {
1390            merged.push(diagnostic);
1391        }
1392    }
1393    merged
1394}
1395
1396/// Keep the first occurrence of each `(kind, path)` pair in one list.
1397///
1398/// The single-list form of [`merge_workspace_diagnostics`], applied where
1399/// diagnostics are produced rather than where two observation points are
1400/// folded: workspace discovery reads `package.json` `workspaces`,
1401/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
1402/// `tsconfig.json` references additively, so a repository that declares one
1403/// glob in two of them reports every package-less directory under it twice.
1404/// Deduplicating at that source is what keeps the JSON envelopes, the
1405/// aggregated stderr warning and the process registry telling one story
1406/// (issue #2366).
1407#[must_use]
1408pub fn dedupe_workspace_diagnostics(
1409    diagnostics: Vec<WorkspaceDiagnostic>,
1410) -> Vec<WorkspaceDiagnostic> {
1411    merge_workspace_diagnostics(diagnostics, Vec::new())
1412}
1413
1414/// The first segment of a glob that contains no glob metacharacter, so it
1415/// names a real directory rather than a wildcard.
1416///
1417/// Source discovery uses it to decide which directory a built-in ignore
1418/// pattern excluded a file "at"; `render_message` uses it to decide which
1419/// remedy is true for that pattern. The two have to agree, so the function
1420/// lives here rather than once per crate: a pattern with such a segment
1421/// (`**/dist/**`) is lifted by re-rooting inside the matched directory,
1422/// because the glob is matched against the path relative to the run root. A
1423/// pattern without one (`**/*.min.js`) matches on the file name and keeps
1424/// matching at every root.
1425#[must_use]
1426pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
1427    pattern.split('/').find(|segment| {
1428        !segment.is_empty()
1429            && !segment.contains(['*', '?', '[', ']', '{', '}'])
1430            && *segment != "."
1431            && *segment != ".."
1432    })
1433}
1434
1435/// The directory part of a workspace pattern: the segments before the first
1436/// segment with a glob character, joined with `/`.
1437///
1438/// `vite/packages/*` gives `vite/packages`, `vite` gives `vite`, and a
1439/// pattern that starts with a glob segment (`**/pkg`, `{a,b}/*`) gives
1440/// `None`. A leading `./` and empty or `.` segments are dropped. Workspace
1441/// discovery uses it to find a pattern whose directory does not exist, and
1442/// the diagnostic message uses it to name that directory, so both read the
1443/// same part of the pattern.
1444#[must_use]
1445pub fn workspace_pattern_literal_dir(pattern: &str) -> Option<String> {
1446    let literal: Vec<&str> = pattern
1447        .split(['/', '\\'])
1448        .filter(|segment| !segment.is_empty() && *segment != ".")
1449        .take_while(|segment| !segment.contains(['*', '?', '[', ']', '{', '}']))
1450        .collect();
1451    if literal.is_empty() {
1452        None
1453    } else {
1454        Some(literal.join("/"))
1455    }
1456}
1457
1458/// The clause naming why a plugin could not read a config key in full, for one
1459/// `plugin-config-unreadable` reason token.
1460///
1461/// The token set is open, so an unrecognised token renders the general claim
1462/// rather than nothing: a diagnostic from a plugin added later still reads as a
1463/// sentence.
1464fn unreadable_situation(reason: &str) -> &'static str {
1465    match reason {
1466        "array-form" => "uses the array form, which is not read yet",
1467        "spread" => "spreads a value that is not statically readable",
1468        "unreadable-entries" => "has entries that hold no statically readable value",
1469        "not-object-literal" => "is not a static object literal",
1470        "unrecognized-call" => "is passed through a call that is not a known config wrapper",
1471        "import-target-unreadable" => "comes from an imported file that is not statically readable",
1472        "dynamic-argument" => "receives an argument that is not a static literal",
1473        _ => "could not be read statically",
1474    }
1475}
1476
1477/// What an unread config key costs, and the configuration option that covers
1478/// the gap.
1479///
1480/// Keyed on the config KEY rather than on the plugin name, because one key is
1481/// read by several plugins: Module Federation `exposes` and `remotes` reach a
1482/// build from a standalone config file and inline from the webpack, rspack,
1483/// rsbuild and vite configs, and both the consequence and the remedy are the
1484/// same in all five. A key this build does not know falls back to the general
1485/// claim rather than borrowing another key's remedy, so a plugin added later
1486/// still renders a sentence that is true.
1487///
1488/// Two reasons change the remedy. An unrecognized call was read as a lower
1489/// bound, so only what the call adds is missing. An unreadable import target
1490/// holds config that is shared across files, so the remedy names the option
1491/// and does not ask for an object literal.
1492fn unreadable_key_consequence(key: &str, reason: &str) -> (&'static str, &'static str) {
1493    match (key, reason) {
1494        ("exposes", "unrecognized-call") => (
1495            "only the targets in the object literal it receives are registered as entry points",
1496            "Name any other exposed files in `dynamicallyLoaded`.",
1497        ),
1498        ("remotes", "unrecognized-call") => (
1499            "only the aliases in the object literal it receives are treated as provided by a \
1500             remote container",
1501            "Name any other aliases in `ignoreDependencies`.",
1502        ),
1503        ("exposes", "import-target-unreadable") => (
1504            "the targets that file declares are not registered as entry points",
1505            "Name the exposed files in `dynamicallyLoaded`.",
1506        ),
1507        ("remotes", "import-target-unreadable") => (
1508            "the aliases that file declares are not treated as provided by a remote container",
1509            "Name the aliases in `ignoreDependencies`.",
1510        ),
1511        ("registerRemotes", _) => (
1512            "the remotes it registers are not treated as provided by a remote container",
1513            "Name the remote aliases in `ignoreDependencies`, or pass the remote names as \
1514             string literals.",
1515        ),
1516        ("init" | "createInstance", _) => (
1517            "the remotes its options declare are not treated as provided by a remote container",
1518            "Name the remote aliases in `ignoreDependencies`, or pass `remotes` as an array of \
1519             objects with literal names.",
1520        ),
1521        ("loadRemote", _) => (
1522            "the remote it loads is not treated as provided by a remote container",
1523            "Name the remote alias in `ignoreDependencies`, or pass the request as a string \
1524             literal.",
1525        ),
1526        ("exposes", _) => (
1527            "the targets are not registered as entry points",
1528            "Name the exposed files in `dynamicallyLoaded`.",
1529        ),
1530        ("remotes", _) => (
1531            "the aliases are not treated as provided by a remote container",
1532            "Name the aliases in `ignoreDependencies`, or declare them as the keys of an object \
1533             literal, whose values may be computed.",
1534        ),
1535        _ => (
1536            "what it declares is not fully registered",
1537            "Declare the value as a static object literal.",
1538        ),
1539    }
1540}
1541
1542fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
1543    let display = display_relative(root, path);
1544    match kind {
1545        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
1546            "Directory '{display}' contains package.json but is not declared as a workspace. \
1547             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
1548        ),
1549        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
1550            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
1551             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
1552        ),
1553        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
1554            "Glob '{pattern}' matched '{display}' but no package.json is present. \
1555             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
1556        ),
1557        WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot { pattern } => {
1558            let source = if display.is_empty() {
1559                "the fallow config `workspaces.patterns`".to_owned()
1560            } else {
1561                format!("'{display}'")
1562            };
1563            format!(
1564                "Skipped workspace entry '{pattern}' in {source}: it is an absolute path \
1565                 or its '..' segments lead above the project root, so it can match directories \
1566                 outside the root. \
1567                 Fallow analyzes no package from it. Move the package inside the project root, \
1568                 or run fallow with --root set to a directory that holds both."
1569            )
1570        }
1571        WorkspaceDiagnosticKind::WorkspacePatternDirMissing { pattern } => {
1572            let source = if display.is_empty() {
1573                "the fallow config `workspaces.patterns`".to_owned()
1574            } else {
1575                format!("'{display}'")
1576            };
1577            let dir = workspace_pattern_literal_dir(pattern).unwrap_or_else(|| pattern.clone());
1578            format!(
1579                "Workspace pattern '{pattern}' in {source} names directory '{dir}', which does \
1580                 not exist. Fallow analyzes no package from it. Unused catalog entries are not \
1581                 reported in this run, because packages in that directory can use them. \
1582                 Create the directory before the run, or remove the pattern."
1583            )
1584        }
1585        WorkspaceDiagnosticKind::NestedProjectRoot => format!(
1586            "Directory '{display}' has its own pnpm-workspace.yaml, so it is an independent \
1587             project. Fallow analyzes it as a workspace: its package.json owns its files and \
1588             dependencies, and its catalog references are not checked against the root \
1589             catalog. Add '{display}' to ignorePatterns to exclude it."
1590        ),
1591        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
1592            "tsconfig at '{display}' failed to parse ({error}); \
1593             its project references, path aliases and compiler options are ignored. \
1594             Fix the JSON syntax."
1595        ),
1596        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
1597            "tsconfig.json references '{display}' but the directory does not exist. \
1598             Update or remove the reference, or restore the missing directory."
1599        ),
1600        WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
1601            "'{display}' is not valid YAML ({error}). Catalog checks are skipped \
1602             and its override entries are ignored until it parses. pnpm install \
1603             can accept this file, but `pnpm add` and YAML formatters reject it. \
1604             Indent each continuation line of a quoted value or a `[...]` / \
1605             `{{...}}` list deeper than its key."
1606        ),
1607        WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
1608            "Skipped '{display}' ({size}): exceeds the max file size limit. \
1609             Its imports and exports are not analyzed. Raise the limit with \
1610             --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
1611             to ignorePatterns.",
1612            size = format_size_mb(*size_bytes)
1613        ),
1614        WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
1615            "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
1616             Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
1617             rename it with a .min.js suffix, or use --max-file-size 0 if this file \
1618             should be analyzed.",
1619            size = format_size_mb(*size_bytes)
1620        ),
1621        WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
1622            "Skipped hidden directory '{display}': it contains source files but hidden \
1623             directories are not traversed. Its imports and exports are not analyzed. \
1624             A file, export or dependency that only this directory uses can be reported as \
1625             unused. To analyze it, add '!{display}/**' to ignorePatterns. To stop \
1626             that false positive without that, add the file to entry, the export to \
1627             ignoreExports or the dependency to ignoreDependencies. To silence this message, \
1628             add '{display}/**' to ignorePatterns. fallow --root {display} analyzes only \
1629             that directory on its own and does not fix this run."
1630        ),
1631        WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
1632            "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
1633             ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
1634        ),
1635        WorkspaceDiagnosticKind::SourceParseDegraded {
1636            error_count,
1637            panicked,
1638        } => {
1639            let outcome = if *panicked {
1640                "the parser stopped there"
1641            } else {
1642                "the parser recovered and continued"
1643            };
1644            format!(
1645                "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
1646                 references it did not reach are missing from this run, so files and symbols it \
1647                 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
1648                 syntax newer than fallow's parser."
1649            )
1650        }
1651        WorkspaceDiagnosticKind::SourceNestingTooDeep { limit } => format!(
1652            "Did not parse '{display}': it nests brackets deeper than {limit} levels, or it \
1653             needs more stack than fallow reserves, so a parse could overflow the stack. Its \
1654             imports and exports are missing from this run, so files and symbols it uses can be \
1655             reported as unused, and while the file is reachable `fallow fix` holds back its \
1656             actions for the whole project. Add '{display}' to ignoreFindings if it is generated \
1657             code, or reduce its nesting."
1658        ),
1659        WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
1660            "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
1661             sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
1662             (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
1663             use instead, so unused-dependency-overrides findings are not reported. Run bun install \
1664             --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
1665             bun.lockb if this repository no longer uses bun."
1666        ),
1667        WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
1668            "Skipped dependency-override resolution because '{display}' could not be parsed and \
1669             no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
1670             findings are not reported. Run bun install to regenerate the text lockfile, then \
1671             rerun fallow."
1672        ),
1673        WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped => format!(
1674            "Skipped dependency-override resolution because '{display}' could not be parsed and \
1675             no other parseable lockfile was available, so unused-dependency-overrides findings \
1676             are not reported. Resolve any merge-conflict markers in the lockfile, or run pnpm \
1677             install to regenerate it, then rerun fallow."
1678        ),
1679        WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped => format!(
1680            "Skipped dependency-override resolution because '{display}' could not be parsed and \
1681             no other parseable lockfile was available, so unused-dependency-overrides findings \
1682             are not reported. Resolve any merge-conflict markers in the lockfile, or run npm \
1683             install to regenerate it, then rerun fallow."
1684        ),
1685        WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
1686            "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
1687             `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
1688             remove the shadowed `resolutions` entries."
1689        ),
1690        WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1691            cause: PnpmWorkspaceOverridesIgnoredCause::PackageJsonOverrides,
1692        } => format!(
1693            "pnpm 10 and earlier ignore the `overrides` in '{display}' because the root package.json \
1694             declares `pnpm.overrides` or `resolutions`, so fallow does not check them. Move the \
1695             entries into one source."
1696        ),
1697        WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1698            cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
1699        } => format!(
1700            "pnpm versions before 10.5.1 do not read the `overrides` in '{display}', so fallow \
1701             does not check them. Update `packageManager` to pnpm 10.5.1 or later, or move the \
1702             entries into `pnpm.overrides` in the root package.json."
1703        ),
1704        WorkspaceDiagnosticKind::NodeModulesMissing => format!(
1705            "'{display}' does not exist. Package exports and conditional exports cannot be read, \
1706             framework plugins that activate on an installed package stay inactive, and \
1707             dependency classification degrades, so imports and dependencies can be \
1708             misreported. Run npm install / pnpm install / yarn / bun install first."
1709        ),
1710        WorkspaceDiagnosticKind::BoundariesNotConfigured => {
1711            "No architecture boundaries are configured, so the boundary detector did not run and \
1712             its violation counts are zero because nothing was measured. Add `boundaries` to the \
1713             config, or set `boundary-violation` to off to state that the check is not wanted."
1714                .to_string()
1715        }
1716        WorkspaceDiagnosticKind::RulePacksNotConfigured => {
1717            "No rule packs are configured, so the policy detector did not run and its violation \
1718             counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
1719             `policy-violation` to off to state that the check is not wanted."
1720                .to_string()
1721        }
1722        WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
1723            excluded_file_count,
1724        } => {
1725            if *excluded_file_count == 0 {
1726                "No source files were analyzed, so every finding count this run reports is zero \
1727                 because nothing was measured. Check the analysis root, ignorePatterns, and any \
1728                 path or workspace filter this run applied."
1729                    .to_owned()
1730            } else {
1731                format!(
1732                    "No source files were analyzed. Fallow's built-in ignore patterns excluded \
1733                     {excluded_file_count} candidate files, so every finding count this run \
1734                     reports is zero because nothing was measured; run with --explain-skipped \
1735                     for the breakdown."
1736                )
1737            }
1738        }
1739        WorkspaceDiagnosticKind::FileScoresUnavailable { error } => format!(
1740            "Could not compute per-file health scores ({error}), so the score list is empty and \
1741             the scored-file count is 0 because nothing was measured rather than because the \
1742             project has nothing to score. Rerun with --no-cache, or scope the run to a \
1743             subdirectory to find the input that fails."
1744        ),
1745        WorkspaceDiagnosticKind::HotspotsSkipped { cause } => match cause.as_str() {
1746            "invalid-since" => "Hotspot analysis was skipped because --since could not be read \
1747                 as a time window, so the hotspots, churn and ownership sections report nothing \
1748                 rather than zero. Spell it as a duration such as 6m or 90d, or drop it to use \
1749                 the default window."
1750                .to_owned(),
1751            "no-commits" => "Hotspot analysis was skipped because the current branch has no \
1752                 commits yet, so the hotspots, churn and ownership sections report nothing \
1753                 rather than zero. Commit the project to give churn a history, or pass \
1754                 --churn-file with exported change history."
1755                .to_owned(),
1756            "churn-file-unreadable" => format!(
1757                "Hotspot analysis was skipped because the churn file '{display}' could no longer \
1758                 be read after it was validated, so the hotspots, churn and ownership sections \
1759                 report nothing rather than zero. Make sure nothing rewrites the file while \
1760                 fallow runs, and rerun."
1761            ),
1762            // The original single cause, whose wording predates the token and
1763            // is kept byte-identical: a consumer matching on this sentence is
1764            // reading the same run it always was.
1765            _ => "Hotspot analysis was skipped because no git repository was found at the \
1766                  project root, so the hotspots, churn and ownership sections report nothing \
1767                  rather than zero. Run fallow inside the repository, or pass --churn-file with \
1768                  exported change history."
1769                .to_owned(),
1770        },
1771        WorkspaceDiagnosticKind::ChangeCouplingSkipped { cause } => match cause.as_str() {
1772            "shallow-clone" => "Change coupling was not measured because the repository is a \
1773                 shallow clone, so no change coupling pairs are reported. Fetch the full \
1774                 history with `git fetch --unshallow` to measure it."
1775                .to_owned(),
1776            _ => "Change coupling found no pairs because the history window has fewer \
1777                  analyzed commits than the minimum revisions of one file. Widen the window \
1778                  with --since, or lower health.changeCoupling.minRevisions."
1779                .to_owned(),
1780        },
1781        WorkspaceDiagnosticKind::ShallowClone {
1782            ownership_requested,
1783        } => {
1784            let ownership = if *ownership_requested {
1785                " Ownership signals are skewed too, because a shallow clone inflates \
1786                 single-author dominance."
1787            } else {
1788                ""
1789            };
1790            format!(
1791                "This is a shallow clone, so churn covers only the fetched history and every \
1792                 hotspot figure is incomplete.{ownership} Run git fetch --unshallow for the full \
1793                 history."
1794            )
1795        }
1796        WorkspaceDiagnosticKind::UnpinnedClock => {
1797            "No commit timestamp was available, so churn recency and ownership staleness were \
1798             measured against the wall clock and drift between runs over the same commit. Set \
1799             FALLOW_CLOCK_EPOCH to pin the run clock."
1800                .to_owned()
1801        }
1802        WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1803            if cause == "codeowners-parse-failed" {
1804                format!(
1805                    "Ownership signals are degraded: CODEOWNERS could not be parsed ({error}), \
1806                     so hotspot entries carry no declared owner. Fix the CODEOWNERS syntax, or \
1807                     drop --ownership for this run."
1808                )
1809            } else {
1810                format!(
1811                    "Ownership signals are degraded: health.ownership.botPatterns contains an \
1812                     invalid glob ({error}), so no author is classified as a bot and bot commits \
1813                     count towards ownership. Fix the pattern, or remove it from the config."
1814                )
1815            }
1816        }
1817        WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => format!(
1818            "Skipped health snapshot '{display}' ({error}), so the trend is computed over fewer \
1819             snapshots than this project has on disk. Delete the unreadable file, or rewrite it \
1820             with fallow health --save-snapshot."
1821        ),
1822        WorkspaceDiagnosticKind::TrendGroupBaselineUnavailable { cause } => {
1823            if cause == "grouped-by-mismatch" {
1824                format!(
1825                    "Health snapshot '{display}' was saved with a different --group-by mode, so \
1826                     the groups have no trend. The project trend is not affected. Save a new \
1827                     snapshot with the same --group-by mode."
1828                )
1829            } else {
1830                format!(
1831                    "Health snapshot '{display}' holds no group data, so the groups have no \
1832                     trend. The project trend is not affected. Save a new snapshot with \
1833                     --group-by and --save-snapshot."
1834                )
1835            }
1836        }
1837        WorkspaceDiagnosticKind::CoverageAutoDetected => format!(
1838            "Coverage was auto-detected at '{display}' rather than passed with --coverage, so the \
1839             CRAP scores depend on whichever coverage file is on disk at run time. Pass --coverage \
1840             '{display}' explicitly for reproducible scores."
1841        ),
1842        WorkspaceDiagnosticKind::FlagAgeShallowClone => {
1843            "This is a shallow clone, so the flag retirement report gives no flag age. Run git \
1844             fetch --unshallow for the full history, or pass --flag-age off."
1845                .to_owned()
1846        }
1847        WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => format!(
1848            "ignoreDependencies glob '{pattern}' matched no declared dependency in this run, so \
1849             it has no effect. A glob matches package names, such as @scope/*. Fix the glob, or \
1850             remove it from the config."
1851        ),
1852        WorkspaceDiagnosticKind::BaselineInstallStateMismatch {
1853            baseline_node_modules,
1854            ..
1855        } => {
1856            let (saved, now, remedy) = match baseline_node_modules {
1857                NodeModulesState::Present => (
1858                    "present",
1859                    "has no node_modules",
1860                    "Install dependencies before you compare",
1861                ),
1862                NodeModulesState::Missing => (
1863                    "missing",
1864                    "has node_modules",
1865                    "Compare without installed dependencies",
1866                ),
1867            };
1868            format!(
1869                "The baseline '{display}' was saved with node_modules {saved}, and this run \
1870                 {now}. Findings that need installed packages (dependency, unlisted, \
1871                 unresolved-import and script-binary findings) can differ, so the run can report \
1872                 baseline findings as new. {remedy}, or save the baseline again in the same \
1873                 install state."
1874            )
1875        }
1876        WorkspaceDiagnosticKind::EntryPatternRedundant { .. }
1877        | WorkspaceDiagnosticKind::IgnoreDependenciesEntryRedundant { .. }
1878        | WorkspaceDiagnosticKind::IgnorePatternsEntryRedundant { .. } => {
1879            config_entry::redundant_entry_message(kind)
1880        }
1881        WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => format!(
1882            "ignoreFindings pattern '{pattern}' matched no finding in this run, so it has no \
1883             effect. A pattern is a glob relative to the project root. Fix the pattern, or \
1884             remove it from the config."
1885        ),
1886        WorkspaceDiagnosticKind::FlagAgeUnavailable { cause } => {
1887            if cause == "no-commits" {
1888                "The flag retirement report gives no flag age, because the current branch has no \
1889                 commit. Commit the code, or pass --flag-age off."
1890                    .to_owned()
1891            } else {
1892                "The flag retirement report gives no flag age, because no git repository was \
1893                 found at the project root. Run fallow inside the repository, or pass --flag-age \
1894                 off."
1895                    .to_owned()
1896            }
1897        }
1898        WorkspaceDiagnosticKind::PluginConfigUnreadable {
1899            plugin,
1900            key,
1901            reason,
1902        } => {
1903            let (consequence, advice) = unreadable_key_consequence(key, reason);
1904            format!(
1905                "Plugin '{plugin}': `{key}` in '{display}' {situation}, so {consequence}. {advice}",
1906                situation = unreadable_situation(reason)
1907            )
1908        }
1909        WorkspaceDiagnosticKind::PluginEffectNotModeled {
1910            plugin,
1911            key,
1912            reason,
1913        } => {
1914            // Two causes, one effect, one remedy. Each cause gets its own
1915            // sentence: the cause and the effect on the findings are separate
1916            // facts, and one sentence with two `so` clauses states neither fact
1917            // clearly.
1918            let effect = "`autoImports` kept the convention entry patterns for that surface, and \
1919                          fallow reports no unused file there. Write the setting as static \
1920                          literals, or remove the key to use the framework defaults.";
1921            if key.starts_with('#') {
1922                format!(
1923                    "Plugin '{plugin}': fallow cannot read which names '{display}' takes from \
1924                     `{key}`, so every name of `{key}` counts as used, and fallow reports no \
1925                     unused file for these names. Read each name with a member access such as \
1926                     `C.Card`, or import it by name."
1927                )
1928            } else if reason == "config-property-unreadable" {
1929                format!(
1930                    "Plugin '{plugin}': fallow cannot read a top-level property in '{display}', so \
1931                     it cannot classify the `{key}` surface. {effect}"
1932                )
1933            } else {
1934                format!(
1935                    "Plugin '{plugin}': fallow does not model the effect of `{key}` in \
1936                     '{display}'. {effect}"
1937                )
1938            }
1939        }
1940        WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1941            pattern,
1942            file_count,
1943            directory_count,
1944        } => {
1945            // `path` is a location, and an empty string is not one: a built-in
1946            // that matched a file sitting directly at the analysis root
1947            // anchors at the root itself.
1948            let display = if display.is_empty() {
1949                ".".to_owned()
1950            } else {
1951                display
1952            };
1953            // The payload carries no directory list, so the message names the
1954            // one directory `path` anchors at. With several excluded
1955            // directories that is the largest group and NOT a majority, so the
1956            // sentence says which claim it is making and how many directories
1957            // it is leaving unnamed.
1958            let location = if *directory_count > 1 {
1959                format!(
1960                    "Skipped {file_count} source files across {directory_count} directories, \
1961                     the largest group under '{display}'"
1962                )
1963            } else if *file_count == 1 {
1964                format!("Skipped 1 source file under '{display}'")
1965            } else {
1966                format!("Skipped {file_count} source files under '{display}'")
1967            };
1968            let singular = *file_count == 1 && *directory_count <= 1;
1969            let (subject, effect) = if singular {
1970                ("it matches", "it imports, exports, or defines")
1971            } else {
1972                ("they match", "they import, export, or define")
1973            };
1974            // Only a directory-shaped built-in is lifted by re-rooting. Telling
1975            // a user with a `vendor/lib.min.js` to run `fallow --root vendor`
1976            // hands them a command that excludes the same file again.
1977            let remedy = if glob_first_literal_segment(pattern).is_some() {
1978                format!(
1979                    "To analyze first-party source there, add '!{display}/**' to \
1980                     ignorePatterns, or analyze that directory on its own with \
1981                     fallow --root {display}."
1982                )
1983            } else {
1984                "This pattern matches a file name rather than a directory, so re-running under \
1985                 a different --root excludes the same files again. Rename first-party source \
1986                 that only looks generated, dropping the '.min' or '.bundle' infix, or add a \
1987                 '!' entry for the file to ignorePatterns."
1988                    .to_owned()
1989            };
1990            format!(
1991                "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
1992                 {effect} is visible to this run. {remedy}"
1993            )
1994        }
1995    }
1996}
1997
1998#[cfg(test)]
1999#[path = "workspace_tests.rs"]
2000mod tests;