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