Skip to main content

fallow_types/
workspace.rs

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