Skip to main content

fallow_types/
workspace.rs

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