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::serde_path;
22
23/// Why a workspace-discovery candidate was rejected, or why a sibling
24/// directory looked workspace-like but was not declared.
25///
26/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
27/// agents, LSP clients) get a stable, language-neutral identifier.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "schema", derive(JsonSchema))]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31pub enum WorkspaceDiagnosticKind {
32    /// A directory contains `package.json` but is not declared as a workspace
33    /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
34    /// `tsconfig.json` `references`. Surfaced by
35    /// `find_undeclared_workspaces`.
36    UndeclaredWorkspace,
37    /// A declared workspace's `package.json` failed to parse. The directory is
38    /// dropped from discovery, but analysis still proceeds (degraded).
39    MalformedPackageJson {
40        /// `serde_json` parse error text.
41        error: String,
42    },
43    /// A workspace glob pattern matched a directory that contains no
44    /// `package.json`. Honors the extended skip list and `ignorePatterns`
45    /// before emitting.
46    GlobMatchedNoPackageJson {
47        /// The glob pattern that matched the directory.
48        pattern: String,
49    },
50    /// `tsconfig.json` exists at the root but failed to parse. Project
51    /// references cannot be discovered.
52    MalformedTsconfig {
53        /// JSONC parse error text.
54        error: String,
55    },
56    /// `tsconfig.json` lists a `references[].path` that does not point to an
57    /// existing directory.
58    TsconfigReferenceDirMissing,
59    /// `pnpm-workspace.yaml` exists but failed to parse as YAML. Catalog and
60    /// dependency-override analysis proceeds with no entries (degraded), so
61    /// `catalog:`-referenced dependencies may be misclassified until the
62    /// syntax is fixed.
63    MalformedPnpmWorkspaceYaml {
64        /// `serde_yaml_ng` parse error text.
65        error: String,
66    },
67    /// A source file was skipped at discovery because it exceeds the configured
68    /// per-file size limit (`--max-file-size` / `FALLOW_MAX_FILE_SIZE`, default
69    /// 5 MB). The file is never read, parsed, or analyzed, guarding against the
70    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
71    /// causes (issue #1086). Surfaced by source discovery, not workspace
72    /// discovery, but shares this channel so the skip is visible in
73    /// `workspace_diagnostics[]` on `fallow dead-code / dupes / health` JSON.
74    SkippedLargeFile {
75        /// On-disk size of the skipped file in bytes.
76        size_bytes: u64,
77    },
78    /// A large JavaScript bundle was skipped at discovery because it appears to
79    /// be minified generated output. The file is never parsed or analyzed,
80    /// guarding against sub-limit bundles that can still create very large ASTs
81    /// and extraction payloads (issue #1086). Use `--max-file-size 0` when the
82    /// bundled file really should be analyzed.
83    SkippedMinifiedFile {
84        /// On-disk size of the skipped file in bytes.
85        size_bytes: u64,
86    },
87    /// A dot-prefixed directory was not traversed by source discovery even
88    /// though it contains at least one source file the project has not
89    /// excluded. Hidden directories are skipped by default apart from a small
90    /// convention allowlist (`.storybook`, `.vitepress`, `.well-known`,
91    /// `.changeset`, `.github`) and the directories an active framework plugin
92    /// or a `package.json` script reference contributes, so files inside are
93    /// never parsed and their imports and exports are invisible to every
94    /// analysis. No config field adds a directory to traversal: run fallow
95    /// with `--root` against the directory to analyze it on its own, or add it
96    /// to `ignorePatterns` to silence this (issue #461).
97    ///
98    /// "Not excluded" is measured the way the run measures it: a directory
99    /// whose contents are gitignored, or excluded by `ignorePatterns`, or (on
100    /// a `--production` run) excluded as test or story files, never earns this
101    /// diagnostic, because the advertised remedies would find nothing there
102    /// either. Generated tool output and non-git VCS metadata are excluded by
103    /// name.
104    ///
105    /// The advisory is best-effort and bounded: one run inspects a fixed
106    /// number of skipped directories with a fixed I/O budget, in sorted path
107    /// order, so a pathological tree yields a deterministic prefix rather than
108    /// an unbounded array or an unbounded scan. The stderr note says "at
109    /// least" when a ceiling bound the run.
110    ///
111    /// Surfaced by source discovery, not workspace discovery, but shares this
112    /// channel so the skip is visible in `workspace_diagnostics[]` on
113    /// `fallow dead-code / dupes / health` JSON.
114    ///
115    /// Unlike the two skipped-file kinds beside it, this one is CAPPED. To
116    /// bound the directory reads the check costs, a run classifies at most 64
117    /// candidate directories and spends at most 1024 directory entries across
118    /// all of them, so on a project that exceeds either ceiling the array is a
119    /// prefix of the skipped directories rather than all of them, and the
120    /// stderr note says "at least N". No measured repository comes close to
121    /// either ceiling. A consumer needing an exact total should run fallow
122    /// with `--root` against the tree rather than infer one from this array.
123    SkippedSourceDotdir,
124    /// A source discovered with a stable [`FileId`](crate::discover::FileId)
125    /// could not be read before parsing. Analysis continues with the remaining
126    /// sparse module IDs and reports the underlying filesystem or UTF-8 error.
127    SourceReadFailure {
128        /// Filesystem or UTF-8 decoding error from `read_to_string`.
129        error: String,
130    },
131    /// A source file was read but parsed with diagnostics, so the module
132    /// extracted from it may be missing imports, exports, or references after
133    /// the first error. Analysis proceeds with the partial module, which is why
134    /// this is reported: an import the parser never saw credits nothing, and its
135    /// target can surface as a confident `unused-file` or `unused-export`
136    /// finding with a `delete-file` or `remove-export` action on it.
137    ///
138    /// Recorded by the parse stage, alongside `source-read-failure`, and never
139    /// used to withhold a finding. oxc reports recoverable errors for valid
140    /// syntax newer than the parser as well as for genuinely broken files, so
141    /// gating findings on this would mute real results project-wide instead of
142    /// just the affected file.
143    SourceParseDegraded {
144        /// Number of parser diagnostics reported for the file.
145        error_count: u32,
146        /// `true` when the parser abandoned the file instead of recovering, so
147        /// the extracted module is a fragment at best.
148        panicked: bool,
149    },
150    /// Dependency-override resolution was skipped because bun's legacy binary
151    /// `bun.lockb` sits next to this `package.json`, fallow cannot read the
152    /// binary format, and no parseable text lockfile was found to use
153    /// instead: no `bun.lock` that parses, and no readable `pnpm-lock.yaml`,
154    /// `package-lock.json`, or `npm-shrinkwrap.json`. A `yarn.lock` is never
155    /// consulted (yarn ignores `overrides`), so it does not prevent the skip
156    /// either. The manifest declares overrides, so the
157    /// `unused-dependency-overrides` check would otherwise have run; without
158    /// resolution ground truth it would flag every transitive-only pin, so no
159    /// unused-override findings are reported at all (issue #2358). Surfaced
160    /// by the override analysis, not workspace discovery, but shares this
161    /// channel so the skip is visible in `workspace_diagnostics[]` JSON and
162    /// as a stderr warning.
163    BunLockbOverrideResolutionSkipped,
164    /// Dependency-override resolution was skipped because bun's text
165    /// `bun.lock` exists but could not be parsed and no readable pnpm or npm
166    /// lockfile was available as independent resolution ground truth.
167    BunLockOverrideResolutionSkipped,
168    /// A bun manifest declares both `overrides` and a non-empty `resolutions`
169    /// object. Bun applies `overrides` and ignores `resolutions`, so fallow
170    /// reports the shadowed configuration without offering removal advice.
171    BunResolutionsShadowedByOverrides,
172    /// The project has no `node_modules` directory and is not a Deno project
173    /// that legitimately runs without one. Analysis proceeds, but three things
174    /// degrade silently: package `exports` and conditional exports cannot be
175    /// read, so imports into a dependency's subpaths resolve less precisely;
176    /// framework plugins that activate on an installed package stay inactive,
177    /// so their entry points and path aliases are missing; and a dependency's
178    /// installed shape cannot be inspected, so type-only dependency
179    /// classification falls back to declaration-based heuristics.
180    ///
181    /// Recorded once per run by the source walk, anchored at the missing
182    /// `node_modules` directory so the reported path is a real location rather
183    /// than the empty string a root-anchored diagnostic would render. This used
184    /// to be a bare `tracing::warn!` duplicated in two pipelines, so it never
185    /// reached JSON output and never reached `fallow doctor`, which reported
186    /// `pass` on a tree that had never been installed.
187    NodeModulesMissing,
188    /// `boundaries` is empty while `boundary-violation` is not `off`, so the
189    /// boundary detector never ran. Its summary counters are therefore
190    /// structurally zero and say nothing about the project.
191    ///
192    /// This is the UNCONFIGURED zero, not the user-chosen one: a project that
193    /// sets `boundary-violation: off` asked for silence and can see that
194    /// choice in `fallow config`. A project that left `boundaries` empty
195    /// cannot distinguish "no violations" from "nothing was measured".
196    BoundariesNotConfigured,
197    /// `rulePacks` is empty while `policy-violation` is not `off`, so the
198    /// policy detector never ran and its summary counters are structurally
199    /// zero. The unconfigured counterpart of
200    /// [`Self::BoundariesNotConfigured`].
201    RulePacksNotConfigured,
202    /// One of fallow's built-in discovery ignore patterns (`**/dist/**`,
203    /// `**/build/**`, `**/coverage/**`, and the four minified-bundle globs)
204    /// removed at least one candidate source file from this walk. The files
205    /// are never read, so their imports and exports are invisible to every
206    /// analysis, and until issue #2638 the drop was completely silent:
207    /// pointing fallow at a directory a built-in pattern matches returned a
208    /// clean report with exit 0 and nothing said why.
209    ///
210    /// `**/node_modules/**` is carved out and never appears in `pattern`:
211    /// installed dependencies are not the first-party source this diagnostic
212    /// is about, and a project that does not gitignore them would get a
213    /// five-figure count with no useful remedy. `**/.git/**` cannot fire,
214    /// because hidden directories are not traversed.
215    ///
216    /// One entry per pattern, never per file or per directory, so the array
217    /// grows by at most the number of built-in patterns on a project of any
218    /// size. `path` anchors at the matched directory holding the most excluded
219    /// files for that pattern, ties broken by the lexicographically first
220    /// path, so two runs on one tree report the same location. On a nested
221    /// match it is the DEEPEST segment the pattern matched
222    /// (`build/tools/build`, not `build`), because that is the directory the
223    /// `--root` remedy names and re-rooting at a shallower one would leave a
224    /// matching segment behind. That directory is the
225    /// largest group and not a majority: a flat monorepo can spread ten
226    /// excluded files over ten sibling `dist/` directories and every one of
227    /// them is then "the largest". `file_count` spans all of them, and
228    /// `directory_count` says how many there were, so a reader can tell a
229    /// single tree from a scattered one without a directory list in the
230    /// payload.
231    ///
232    /// Three properties of the population are load-bearing and easy to
233    /// misread:
234    ///
235    /// - **Gitignored trees count zero.** Source discovery honors
236    ///   `.gitignore`, `.git/info/exclude`, and the global gitignore, and
237    ///   prunes those directories before this check runs. The honest reading
238    ///   is "candidate source files git did not already hide and a built-in
239    ///   pattern then dropped", which is why a repository that gitignores its
240    ///   own `dist/` never sees this diagnostic.
241    /// - **A user `ignorePatterns` entry is not a surprise.** The compiled
242    ///   ignore set is the union of `ignorePatterns` and the built-ins, so a
243    ///   file both matched was an explicit project choice and is attributed to
244    ///   no pattern here. The union also only ever adds: `ignorePatterns`
245    ///   cannot negate a built-in, so a config edit is never the remedy.
246    /// - **The remedy depends on the pattern's shape.** A directory-shaped
247    ///   built-in (`**/dist/**`) is matched against the path relative to the
248    ///   run root, so re-rooting inside the matched directory removes the
249    ///   matched segment and the files become visible: the message advertises
250    ///   `fallow --root <dir>`. A file-shaped built-in (`**/*.min.js` and the
251    ///   three other bundle globs) matches on the file name and keeps matching
252    ///   at any root, so the message says so and points at renaming instead of
253    ///   handing out a command that provably does nothing.
254    ///
255    /// Deliberately NOT one of the [`Self::source_never_analyzed`] kinds. These
256    /// exclusions are the product's designed behavior on generated output, not
257    /// a degraded run: answering `true` would attach `IncompleteFileAnalysis`
258    /// and `IncompleteImportGraph` caveats to findings on nearly every project
259    /// that keeps a non-gitignored `dist/` or `coverage/`, and make `fallow
260    /// fix` withhold `delete-file` and `remove-export` actions project-wide.
261    ExcludedByDefaultIgnore {
262        /// The built-in glob that matched, verbatim (for example
263        /// `**/build/**`).
264        pattern: String,
265        /// Candidate source files this pattern excluded in this walk, across
266        /// every directory it matched, not just the one `path` anchors at.
267        /// Exact: the walk counts each excluded candidate once.
268        file_count: u32,
269        /// Distinct directories this pattern matched at, `path` included, and
270        /// not the number of directories that held the files. A
271        /// directory-shaped pattern (`**/dist/**`) matches at the directory it
272        /// names, so an excluded subtree counts once however many nested
273        /// directories inside it held source: a `dist/` holding files in three
274        /// sub-directories reports `1`. A file-shaped pattern (`**/*.min.js`)
275        /// has no directory to collapse to and counts each matched file's own
276        /// parent. Exact either way, and anything above `1` says `path` names
277        /// one matched location out of several.
278        directory_count: u32,
279    },
280}
281
282impl WorkspaceDiagnosticKind {
283    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
284    #[must_use]
285    pub const fn id(&self) -> &'static str {
286        match self {
287            Self::UndeclaredWorkspace => "undeclared-workspace",
288            Self::MalformedPackageJson { .. } => "malformed-package-json",
289            Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
290            Self::MalformedTsconfig { .. } => "malformed-tsconfig",
291            Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
292            Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
293            Self::SkippedLargeFile { .. } => "skipped-large-file",
294            Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
295            Self::SkippedSourceDotdir => "skipped-source-dotdir",
296            Self::SourceReadFailure { .. } => "source-read-failure",
297            Self::SourceParseDegraded { .. } => "source-parse-degraded",
298            Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
299            Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
300            Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
301            Self::NodeModulesMissing => "node-modules-missing",
302            Self::BoundariesNotConfigured => "boundaries-not-configured",
303            Self::RulePacksNotConfigured => "rule-packs-not-configured",
304            Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
305        }
306    }
307
308    /// Whether this diagnostic is worth a `tracing::warn!` line on stderr, on
309    /// top of its permanent entry in `workspace_diagnostics[]`.
310    ///
311    /// A warning is for a run whose RESULTS are degraded: something the user
312    /// installed, wrote, or expected did not reach the analysis. The two
313    /// unconfigured-check kinds are not that. They fire in the product's
314    /// default state, on every project that never opted into boundaries or
315    /// rule packs, and they will keep firing forever, because the remedy they
316    /// offer is to write configuration in order to silence a warning about not
317    /// having written configuration. They stay in the structured array, where a
318    /// consumer that wants to distinguish "measured zero" from "measured
319    /// nothing" can read them, and off the stderr surface that every other
320    /// command shares.
321    #[must_use]
322    pub const fn warns_on_stderr(&self) -> bool {
323        match self {
324            Self::BoundariesNotConfigured
325            | Self::RulePacksNotConfigured
326            | Self::ExcludedByDefaultIgnore { .. } => false,
327            Self::UndeclaredWorkspace
328            | Self::MalformedPackageJson { .. }
329            | Self::GlobMatchedNoPackageJson { .. }
330            | Self::MalformedTsconfig { .. }
331            | Self::TsconfigReferenceDirMissing
332            | Self::MalformedPnpmWorkspaceYaml { .. }
333            | Self::SkippedLargeFile { .. }
334            | Self::SkippedMinifiedFile { .. }
335            | Self::SkippedSourceDotdir
336            | Self::SourceReadFailure { .. }
337            | Self::SourceParseDegraded { .. }
338            | Self::BunLockbOverrideResolutionSkipped
339            | Self::BunLockOverrideResolutionSkipped
340            | Self::BunResolutionsShadowedByOverrides
341            | Self::NodeModulesMissing => true,
342        }
343    }
344
345    /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
346    /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
347    /// discovery diagnostics are APPENDED to the registry after config load, so
348    /// `stash_workspace_diagnostics` must preserve them when it replaces the
349    /// workspace-discovery set, otherwise the per-analysis config re-loads in
350    /// combined-mode (`fallow` with no subcommand re-loads config for check,
351    /// dupes, and health) wipe them before the JSON envelope is built (issue
352    /// #1086).
353    #[must_use]
354    pub const fn is_source_discovery(&self) -> bool {
355        matches!(
356            self,
357            Self::SkippedLargeFile { .. }
358                | Self::SkippedMinifiedFile { .. }
359                | Self::SkippedSourceDotdir
360                | Self::SourceReadFailure { .. }
361                | Self::SourceParseDegraded { .. }
362                | Self::NodeModulesMissing
363                | Self::ExcludedByDefaultIgnore { .. }
364        )
365    }
366
367    /// Whether this diagnostic is written by the source file WALK
368    /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
369    /// walk replaces wholesale for its root. `source-read-failure` is the
370    /// other source-discovery kind and is NOT one of these: the parse stage
371    /// records it after the walk, so it has to keep reaching consumers through
372    /// the registry.
373    ///
374    /// A walk-recorded entry must reach an analysis from its OWN walk's return
375    /// value. Combined mode runs the dead-code and duplication walks under
376    /// `rayon::join` whenever a per-analysis `production` split stops them from
377    /// sharing a file list, so a registry read answers "whichever walk wrote
378    /// last" and varies between runs of the same command (issue #2366).
379    #[must_use]
380    pub const fn is_source_walk_recorded(&self) -> bool {
381        matches!(
382            self,
383            Self::SkippedLargeFile { .. }
384                | Self::SkippedMinifiedFile { .. }
385                | Self::SkippedSourceDotdir
386                | Self::NodeModulesMissing
387                | Self::ExcludedByDefaultIgnore { .. }
388        )
389    }
390
391    /// Whether this diagnostic reports a source file whose contents this run
392    /// never analyzed, so every import and export the file holds is invisible
393    /// to the module graph.
394    ///
395    /// This is the class `reachability_caveats[]` exists for. A file the run
396    /// never read credits nothing, so the modules it imports surface as
397    /// confident `unused-file` and `unused-export` findings carrying
398    /// `delete-file` and `remove-export` actions, and `fallow fix` would
399    /// otherwise apply the removal against source that still imports the
400    /// target.
401    ///
402    /// All four discovery-side kinds qualify, for the same reason and with the
403    /// same consequence:
404    ///
405    /// - `skipped-large-file` and `skipped-minified-file`: the file is in the
406    ///   project tree and was never opened, so its import list is unknown.
407    /// - `skipped-source-dotdir`: the directory holds at least one source file
408    ///   the project did not exclude, and none of them were traversed. The
409    ///   diagnostic is capped, so it under-reports rather than over-reports;
410    ///   its presence still proves unseen source exists.
411    /// - `source-read-failure`: the file was discovered and then could not be
412    ///   read, so nothing was extracted from it at all.
413    ///
414    /// `source-parse-degraded` is deliberately NOT one of these, though it
415    /// belongs to the same family. Neither is `excluded-by-default-ignore`,
416    /// for a different reason: that one reports designed behavior on generated
417    /// output rather than a degraded run, and its own doc comment carries the
418    /// argument.
419    ///
420    /// `source-parse-degraded`: that file WAS read, so it has a module and
421    /// a graph node and its reachability is observable, which lets the caveat
422    /// pass narrow it: a degraded module that is itself unreachable cannot
423    /// change a reachability verdict. Every kind above has no node to ask (a
424    /// read failure has one with nothing extracted into it), so no narrowing
425    /// is available and the caveat they raise is run-level.
426    ///
427    /// The match is exhaustive on purpose: a new "the run did not see this
428    /// file" kind has to be classified here, and answering `true` is the only
429    /// wiring its findings need in order to inherit both the caveat and the
430    /// `fallow fix` withholding that follows it.
431    #[must_use]
432    pub const fn source_never_analyzed(&self) -> bool {
433        match self {
434            Self::SkippedLargeFile { .. }
435            | Self::SkippedMinifiedFile { .. }
436            | Self::SkippedSourceDotdir
437            | Self::SourceReadFailure { .. } => true,
438            Self::UndeclaredWorkspace
439            | Self::MalformedPackageJson { .. }
440            | Self::GlobMatchedNoPackageJson { .. }
441            | Self::MalformedTsconfig { .. }
442            | Self::TsconfigReferenceDirMissing
443            | Self::MalformedPnpmWorkspaceYaml { .. }
444            | Self::SourceParseDegraded { .. }
445            | Self::BunLockbOverrideResolutionSkipped
446            | Self::BunLockOverrideResolutionSkipped
447            | Self::BunResolutionsShadowedByOverrides
448            | Self::NodeModulesMissing
449            | Self::BoundariesNotConfigured
450            | Self::RulePacksNotConfigured
451            | Self::ExcludedByDefaultIgnore { .. } => false,
452        }
453    }
454
455    /// Whether this diagnostic is recorded by the ANALYZE stage (the
456    /// dependency-catalog and override detectors) rather than by workspace or
457    /// source discovery. Analysis-stage diagnostics reach the registry through
458    /// `record_workspace_diagnostics` after config load, so
459    /// `stash_workspace_diagnostics` must preserve them across combined-mode's
460    /// per-analysis config re-loads, and every analyze pass clears its previous
461    /// entries before re-recording so a fixed cause drops out on the next run
462    /// (issue #2366). The match is exhaustive on purpose: a new kind must be
463    /// classified here before it compiles.
464    ///
465    /// Classify a kind `true` ONLY when a detector reachable from the dead-code
466    /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
467    /// the single clear site. A kind recorded exclusively by another stage would
468    /// be cleared by the next dead-code pass and never come back.
469    #[must_use]
470    pub const fn is_analysis_stage(&self) -> bool {
471        match self {
472            Self::MalformedPnpmWorkspaceYaml { .. }
473            | Self::BunLockbOverrideResolutionSkipped
474            | Self::BunLockOverrideResolutionSkipped
475            | Self::BunResolutionsShadowedByOverrides
476            | Self::BoundariesNotConfigured
477            | Self::RulePacksNotConfigured => true,
478            Self::UndeclaredWorkspace
479            | Self::MalformedPackageJson { .. }
480            | Self::GlobMatchedNoPackageJson { .. }
481            | Self::MalformedTsconfig { .. }
482            | Self::TsconfigReferenceDirMissing
483            | Self::SkippedLargeFile { .. }
484            | Self::SkippedMinifiedFile { .. }
485            | Self::SkippedSourceDotdir
486            | Self::SourceReadFailure { .. }
487            | Self::SourceParseDegraded { .. }
488            | Self::NodeModulesMissing
489            | Self::ExcludedByDefaultIgnore { .. } => false,
490        }
491    }
492}
493
494/// Render a byte count as a megabyte figure with one decimal place for
495/// human-readable diagnostic messages (e.g. `12.3 MB`).
496#[must_use]
497fn format_size_mb(bytes: u64) -> String {
498    #[expect(
499        clippy::cast_precision_loss,
500        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
501    )]
502    let mb = bytes as f64 / (1024.0 * 1024.0);
503    format!("{mb:.1} MB")
504}
505
506/// A diagnostic about a workspace-discovery candidate.
507///
508/// The `message` field is a human-readable rendering derived from `kind`. It
509/// always ends with a concrete next step ("fix the JSON syntax", "remove from
510/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
511/// forward.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513#[cfg_attr(feature = "schema", derive(JsonSchema))]
514pub struct WorkspaceDiagnostic {
515    /// Path to the directory or file that triggered the diagnostic.
516    #[serde(serialize_with = "serde_path::serialize")]
517    pub path: PathBuf,
518    /// Kind discriminator with the typed payload.
519    #[serde(flatten)]
520    pub kind: WorkspaceDiagnosticKind,
521    /// Human-readable rendering derived from `kind` + `path`. Always ends
522    /// with a next-step hint.
523    pub message: String,
524}
525
526impl WorkspaceDiagnostic {
527    /// Construct a diagnostic with the message rendered from `kind` + `path`.
528    ///
529    /// `root` is used to produce project-relative paths in the message text
530    /// AND inside the variant payload (e.g. the `error` field of
531    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
532    /// file path from `PackageJson::load()`'s error text). Without the
533    /// payload-side normalisation the embedded path would survive
534    /// environment-specific differences (CI vs Docker vs local) because the
535    /// post-serialisation `strip_root_prefix` only catches whole-string
536    /// matches, not paths embedded mid-sentence.
537    ///
538    /// If `path` is not under `root` (e.g. canonicalisation crossed a
539    /// symlink), the absolute path is emitted instead.
540    ///
541    /// `path` also loses any no-op `.` component, for the same reason the
542    /// payload loses a glob's `./` prefix: one directory reached through two
543    /// spellings of one glob must be one diagnostic.
544    #[must_use]
545    pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
546        let path = normalise_diagnostic_path(path);
547        let kind = normalise_payload_paths(root, kind);
548        let message = render_message(root, &path, &kind);
549        Self {
550            path,
551            kind,
552            message,
553        }
554    }
555
556    /// Return this diagnostic with `path` rewritten relative to `root`.
557    ///
558    /// `path` is stored absolute so callers can act on it. Every JSON envelope
559    /// emits it project-relative instead: the analysis envelopes get there
560    /// through the post-serialisation `strip_root_prefix` pass, which the
561    /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
562    /// `project_info` tool never run, so those emitted the absolute path while
563    /// the sibling `workspaces[].path` next to it was relative. They normalise
564    /// at the typed layer with this method instead.
565    ///
566    /// Paths outside `root` (canonicalisation crossed a symlink) are left
567    /// absolute, matching how [`Self::new`] renders the message.
568    ///
569    /// A diagnostic anchored at the root itself becomes `.`, not the empty
570    /// path: an empty string is not a location, and the analysis envelopes'
571    /// post-serialisation strip only removes a `root + separator` prefix, so a
572    /// root-anchored path that stays absolute here leaks a host path.
573    #[must_use]
574    pub fn into_root_relative(mut self, root: &Path) -> Self {
575        if let Ok(relative) = self.path.strip_prefix(root) {
576            self.path = if relative.as_os_str().is_empty() {
577                PathBuf::from(".")
578            } else {
579                relative.to_path_buf()
580            };
581        }
582        self
583    }
584}
585
586/// Rebuild `path` from its components so one directory has one spelling.
587///
588/// The dedupe key was never the problem: [`Path`] equality already ignores an
589/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
590/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
591/// expands to the first spelling and the same glob spelled `pkgs/*` in
592/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
593/// make a project-relative path differently: the analysis envelopes strip the
594/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
595/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
596/// `pkgs/aaa`). Whichever
597/// manifest happened to be read first then decided which shape every consumer
598/// saw. Collapsing at construction gives them one answer (issue #2366).
599///
600/// A path that is already component-clean rebuilds to itself. Serialization
601/// normalises separators, so the rebuild is wire-invisible on Windows.
602fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
603    let rebuilt: PathBuf = path.components().collect();
604    if rebuilt.as_os_str() == path.as_os_str() {
605        path
606    } else {
607        rebuilt
608    }
609}
610
611/// Strip the project root from absolute paths embedded inside variant
612/// payloads (the `error` field of malformed-config and source-read failures),
613/// and drop a glob pattern's no-op `./` prefix.
614///
615/// Mirrors the per-platform `display()` byte sequence so the substring match
616/// works on Windows too.
617///
618/// The pattern prefix matters because the payload is part of the dedupe key in
619/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
620/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
621/// glob twice, and without this both spellings would report every package-less
622/// directory under `apps/` a second time (issue #2366).
623fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
624    let root_str = root.display().to_string();
625    let root_alt = root_str.replace('\\', "/");
626    let normalise = |text: String| -> String {
627        let stripped = text
628            .replace(&format!("{root_str}/"), "")
629            .replace(&format!("{root_alt}/"), "");
630        stripped
631            .replace(&format!("{root_str}\\"), "")
632            .replace(&format!("{root_alt}\\"), "")
633    };
634    match kind {
635        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
636            WorkspaceDiagnosticKind::MalformedPackageJson {
637                error: normalise(error),
638            }
639        }
640        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
641            WorkspaceDiagnosticKind::MalformedTsconfig {
642                error: normalise(error),
643            }
644        }
645        WorkspaceDiagnosticKind::SourceReadFailure { error } => {
646            WorkspaceDiagnosticKind::SourceReadFailure {
647                error: normalise(error),
648            }
649        }
650        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
651            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
652                pattern: canonical_glob_pattern(pattern),
653            }
654        }
655        other => other,
656    }
657}
658
659/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
660/// pattern declared in two manifests is one payload.
661///
662/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
663/// its spelling: stripping it would report an empty `pattern` field and an
664/// empty quoted glob in the warning text, which names no glob at all.
665fn canonical_glob_pattern(pattern: String) -> String {
666    for prefix in ["./", ".\\"] {
667        if let Some(rest) = pattern.strip_prefix(prefix)
668            && !rest.is_empty()
669        {
670            return rest.to_owned();
671        }
672    }
673    pattern
674}
675
676/// Concatenate two diagnostic lists, keeping the first occurrence of each
677/// `(kind, path)` pair and the order of `primary` followed by the entries only
678/// `secondary` has.
679///
680/// The single place diagnostics from two observation points are folded
681/// together: an engine session's own capture plus the process registry, and
682/// the combined run's per-analysis lists (issue #2366). A combined run walks
683/// the project once per analysis, and per-analysis `production` modes can make
684/// those walks see different file sets, so no single observation point holds
685/// everything the run recorded; the union does, and folding it the same way
686/// everywhere is what keeps the CLI and the programmatic route answering
687/// identically.
688///
689/// The key is the WHOLE kind, payload included, not its
690/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
691/// path and still be two distinct diagnostics: overlapping workspace globs
692/// (`["packages/*", "packages/*/*"]`) each report the same package-less
693/// directory with their own `pattern`, and the standalone envelopes report
694/// both. An id-keyed fold silently dropped the second one.
695#[must_use]
696pub fn merge_workspace_diagnostics(
697    primary: Vec<WorkspaceDiagnostic>,
698    secondary: Vec<WorkspaceDiagnostic>,
699) -> Vec<WorkspaceDiagnostic> {
700    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
701    let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
702    for diagnostic in primary.into_iter().chain(secondary) {
703        let key = (diagnostic.kind.clone(), diagnostic.path.clone());
704        if seen.insert(key) {
705            merged.push(diagnostic);
706        }
707    }
708    merged
709}
710
711/// Keep the first occurrence of each `(kind, path)` pair in one list.
712///
713/// The single-list form of [`merge_workspace_diagnostics`], applied where
714/// diagnostics are produced rather than where two observation points are
715/// folded: workspace discovery reads `package.json` `workspaces`,
716/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
717/// `tsconfig.json` references additively, so a repository that declares one
718/// glob in two of them reports every package-less directory under it twice.
719/// Deduplicating at that source is what keeps the JSON envelopes, the
720/// aggregated stderr warning and the process registry telling one story
721/// (issue #2366).
722#[must_use]
723pub fn dedupe_workspace_diagnostics(
724    diagnostics: Vec<WorkspaceDiagnostic>,
725) -> Vec<WorkspaceDiagnostic> {
726    merge_workspace_diagnostics(diagnostics, Vec::new())
727}
728
729/// Render `path` relative to `root` with forward slashes. The forward-slash
730/// normalisation is load-bearing for cross-platform output stability.
731fn display_relative(root: &Path, path: &Path) -> String {
732    path.strip_prefix(root)
733        .unwrap_or(path)
734        .display()
735        .to_string()
736        .replace('\\', "/")
737}
738
739/// The first segment of a glob that contains no glob metacharacter, so it
740/// names a real directory rather than a wildcard.
741///
742/// Source discovery uses it to decide which directory a built-in ignore
743/// pattern excluded a file "at"; `render_message` uses it to decide which
744/// remedy is true for that pattern. The two have to agree, so the function
745/// lives here rather than once per crate: a pattern with such a segment
746/// (`**/dist/**`) is lifted by re-rooting inside the matched directory,
747/// because the glob is matched against the path relative to the run root. A
748/// pattern without one (`**/*.min.js`) matches on the file name and keeps
749/// matching at every root.
750#[must_use]
751pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
752    pattern.split('/').find(|segment| {
753        !segment.is_empty()
754            && !segment.contains(['*', '?', '[', ']', '{', '}'])
755            && *segment != "."
756            && *segment != ".."
757    })
758}
759
760fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
761    let display = display_relative(root, path);
762    match kind {
763        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
764            "Directory '{display}' contains package.json but is not declared as a workspace. \
765             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
766        ),
767        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
768            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
769             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
770        ),
771        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
772            "Glob '{pattern}' matched '{display}' but no package.json is present. \
773             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
774        ),
775        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
776            "tsconfig.json at '{display}' failed to parse ({error}); \
777             project references will be ignored. Fix the JSON syntax."
778        ),
779        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
780            "tsconfig.json references '{display}' but the directory does not exist. \
781             Update or remove the reference, or restore the missing directory."
782        ),
783        WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
784            "'{display}' failed to parse ({error}); catalog and override entries \
785             will be ignored. Fix the YAML syntax."
786        ),
787        WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
788            "Skipped '{display}' ({size}): exceeds the max file size limit. \
789             Its imports and exports are not analyzed. Raise the limit with \
790             --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
791             to ignorePatterns.",
792            size = format_size_mb(*size_bytes)
793        ),
794        WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
795            "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
796             Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
797             rename it with a .min.js suffix, or use --max-file-size 0 if this file \
798             should be analyzed.",
799            size = format_size_mb(*size_bytes)
800        ),
801        WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
802            "Skipped hidden directory '{display}': it contains source files but hidden \
803             directories are not traversed. Its imports and exports are not analyzed. \
804             There is no config field that adds a directory to traversal. If it holds \
805             first-party source, analyze it on its own with fallow --root {display}; if it \
806             is tool or agent scratch state, add '{display}/**' to ignorePatterns to \
807             silence this."
808        ),
809        WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
810            "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
811             ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
812        ),
813        WorkspaceDiagnosticKind::SourceParseDegraded {
814            error_count,
815            panicked,
816        } => {
817            let outcome = if *panicked {
818                "the parser stopped there"
819            } else {
820                "the parser recovered and continued"
821            };
822            format!(
823                "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
824                 references it did not reach are missing from this run, so files and symbols it \
825                 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
826                 syntax newer than fallow's parser."
827            )
828        }
829        WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
830            "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
831             sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
832             (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
833             use instead, so unused-dependency-overrides findings are not reported. Run bun install \
834             --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
835             bun.lockb if this repository no longer uses bun."
836        ),
837        WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
838            "Skipped dependency-override resolution because '{display}' could not be parsed and \
839             no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
840             findings are not reported. Run bun install to regenerate the text lockfile, then \
841             rerun fallow."
842        ),
843        WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
844            "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
845             `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
846             remove the shadowed `resolutions` entries."
847        ),
848        WorkspaceDiagnosticKind::NodeModulesMissing => format!(
849            "'{display}' does not exist. Package exports and conditional exports cannot be read, \
850             framework plugins that activate on an installed package stay inactive, and \
851             dependency classification degrades, so imports and dependencies can be \
852             misreported. Run npm install / pnpm install / yarn / bun install first."
853        ),
854        WorkspaceDiagnosticKind::BoundariesNotConfigured => {
855            "No architecture boundaries are configured, so the boundary detector did not run and \
856             its violation counts are zero because nothing was measured. Add `boundaries` to the \
857             config, or set `boundary-violation` to off to state that the check is not wanted."
858                .to_string()
859        }
860        WorkspaceDiagnosticKind::RulePacksNotConfigured => {
861            "No rule packs are configured, so the policy detector did not run and its violation \
862             counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
863             `policy-violation` to off to state that the check is not wanted."
864                .to_string()
865        }
866        WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
867            pattern,
868            file_count,
869            directory_count,
870        } => {
871            // `path` is a location, and an empty string is not one: a built-in
872            // that matched a file sitting directly at the analysis root
873            // anchors at the root itself.
874            let display = if display.is_empty() {
875                ".".to_owned()
876            } else {
877                display
878            };
879            // The payload carries no directory list, so the message names the
880            // one directory `path` anchors at. With several excluded
881            // directories that is the largest group and NOT a majority, so the
882            // sentence says which claim it is making and how many directories
883            // it is leaving unnamed.
884            let location = if *directory_count > 1 {
885                format!(
886                    "Skipped {file_count} source files across {directory_count} directories, \
887                     the largest group under '{display}'"
888                )
889            } else if *file_count == 1 {
890                format!("Skipped 1 source file under '{display}'")
891            } else {
892                format!("Skipped {file_count} source files under '{display}'")
893            };
894            let singular = *file_count == 1 && *directory_count <= 1;
895            let (subject, effect) = if singular {
896                ("it matches", "it imports, exports, or defines")
897            } else {
898                ("they match", "they import, export, or define")
899            };
900            // Only a directory-shaped built-in is lifted by re-rooting. Telling
901            // a user with a `vendor/lib.min.js` to run `fallow --root vendor`
902            // hands them a command that excludes the same file again.
903            let remedy = if glob_first_literal_segment(pattern).is_some() {
904                format!(
905                    "Move first-party source out of the matched directory, or analyze that \
906                     directory on its own with fallow --root {display}."
907                )
908            } else {
909                "This pattern matches a file name rather than a directory, so re-running under \
910                 a different --root excludes the same files again. Rename first-party source \
911                 that only looks generated, dropping the '.min' or '.bundle' infix."
912                    .to_owned()
913            };
914            format!(
915                "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
916                 {effect} is visible to this run. Built-in ignores cannot be switched off \
917                 through ignorePatterns. {remedy}"
918            )
919        }
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn skipped_large_file_diagnostic_id_and_message() {
929        let root = Path::new("/project");
930        let diag = WorkspaceDiagnostic::new(
931            root,
932            root.join("src/vendor/app.bundle.js"),
933            WorkspaceDiagnosticKind::SkippedLargeFile {
934                size_bytes: 6 * 1024 * 1024,
935            },
936        );
937        assert_eq!(diag.kind.id(), "skipped-large-file");
938        assert!(
939            diag.message.contains("src/vendor/app.bundle.js"),
940            "message names the project-relative path: {}",
941            diag.message
942        );
943        assert!(
944            diag.message.contains("6.0 MB"),
945            "message reports the size: {}",
946            diag.message
947        );
948        assert!(
949            diag.message.contains("--max-file-size"),
950            "message names the override flag: {}",
951            diag.message
952        );
953    }
954
955    #[test]
956    fn skipped_minified_file_diagnostic_id_and_message() {
957        let root = Path::new("/project");
958        let diag = WorkspaceDiagnostic::new(
959            root,
960            root.join("src/assets/index-abc123.js"),
961            WorkspaceDiagnosticKind::SkippedMinifiedFile {
962                size_bytes: 2 * 1024 * 1024,
963            },
964        );
965        assert_eq!(diag.kind.id(), "skipped-minified-file");
966        assert!(
967            diag.message.contains("src/assets/index-abc123.js"),
968            "message names the project-relative path: {}",
969            diag.message
970        );
971        assert!(
972            diag.message.contains("2.0 MB"),
973            "message reports the size: {}",
974            diag.message
975        );
976        assert!(
977            diag.message.contains("--max-file-size 0"),
978            "message names the opt-out: {}",
979            diag.message
980        );
981    }
982
983    #[test]
984    fn skipped_source_dotdir_diagnostic_id_and_message() {
985        let root = Path::new("/project");
986        let diag = WorkspaceDiagnostic::new(
987            root,
988            root.join(".claude"),
989            WorkspaceDiagnosticKind::SkippedSourceDotdir,
990        );
991        assert_eq!(diag.kind.id(), "skipped-source-dotdir");
992        assert!(
993            diag.message.contains(".claude"),
994            "message names the project-relative path: {}",
995            diag.message
996        );
997        assert!(
998            diag.message
999                .contains("Its imports and exports are not analyzed."),
1000            "message states the consequence: {}",
1001            diag.message
1002        );
1003        assert!(
1004            diag.message.contains("--root"),
1005            "message names the real remedy: {}",
1006            diag.message
1007        );
1008        assert!(
1009            diag.message.contains("ignorePatterns"),
1010            "message names the silencing route: {}",
1011            diag.message
1012        );
1013        assert!(
1014            diag.message.contains("no config field"),
1015            "the message must say plainly that no config field traverses it: {}",
1016            diag.message
1017        );
1018        assert_eq!(
1019            serde_json::to_value(&diag).expect("serializes")["kind"],
1020            "skipped-source-dotdir",
1021            "id() must byte-match the serde kebab-case tag"
1022        );
1023    }
1024
1025    #[cfg(feature = "schema")]
1026    #[test]
1027    fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
1028        let schema = schemars::schema_for!(WorkspaceDiagnostic);
1029        let json = serde_json::to_string(&schema).expect("schema serializes");
1030        assert!(json.contains("skipped-source-dotdir"));
1031    }
1032
1033    #[test]
1034    fn source_read_failure_serializes_typed_error_payload() {
1035        let root = Path::new("/project");
1036        let diagnostic = WorkspaceDiagnostic::new(
1037            root,
1038            root.join("src/removed.ts"),
1039            WorkspaceDiagnosticKind::SourceReadFailure {
1040                error: "No such file or directory".to_string(),
1041            },
1042        );
1043
1044        let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
1045        assert_eq!(json["kind"], "source-read-failure");
1046        assert_eq!(
1047            json["path"],
1048            root.join("src/removed.ts")
1049                .display()
1050                .to_string()
1051                .replace('\\', "/")
1052        );
1053        assert_eq!(json["error"], "No such file or directory");
1054        assert!(
1055            json["message"]
1056                .as_str()
1057                .is_some_and(|message| message.contains("src/removed.ts"))
1058        );
1059    }
1060
1061    #[cfg(feature = "schema")]
1062    #[test]
1063    fn workspace_diagnostic_schema_includes_source_read_failure() {
1064        let schema = schemars::schema_for!(WorkspaceDiagnostic);
1065        let json = serde_json::to_string(&schema).expect("schema serializes");
1066        assert!(json.contains("source-read-failure"));
1067        assert!(json.contains("error"));
1068    }
1069
1070    #[test]
1071    fn bun_lockb_override_resolution_skipped_id_and_message() {
1072        let root = Path::new("/project");
1073        let diag = WorkspaceDiagnostic::new(
1074            root,
1075            root.join("package.json"),
1076            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1077        );
1078        assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
1079        assert!(
1080            diag.message.contains("'package.json'"),
1081            "message names the project-relative manifest: {}",
1082            diag.message
1083        );
1084        assert!(
1085            diag.message.contains("no parseable text lockfile"),
1086            "message states the cause: {}",
1087            diag.message
1088        );
1089        assert!(
1090            !diag.message.contains("only bun.lockb"),
1091            "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
1092             bun.lock may sit beside it: {}",
1093            diag.message
1094        );
1095        assert!(
1096            diag.message.contains("bun install --save-text-lockfile")
1097                && diag.message.contains("delete the stale bun.lockb"),
1098            "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
1099            diag.message
1100        );
1101        let json = serde_json::to_value(&diag).expect("diagnostic serializes");
1102        assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
1103    }
1104
1105    #[test]
1106    fn bun_override_diagnostic_ids_and_messages_are_actionable() {
1107        let root = Path::new("/project");
1108        let malformed = WorkspaceDiagnostic::new(
1109            root,
1110            root.join("bun.lock"),
1111            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1112        );
1113        assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
1114        assert!(malformed.message.contains("regenerate"));
1115
1116        let shadowed = WorkspaceDiagnostic::new(
1117            root,
1118            root.join("package.json"),
1119            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1120        );
1121        assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
1122        assert!(shadowed.message.contains("ignores `resolutions`"));
1123    }
1124
1125    #[test]
1126    fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
1127        let root = Path::new("/project");
1128        let inside = WorkspaceDiagnostic::new(
1129            root,
1130            root.join("packages/inner"),
1131            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1132        )
1133        .into_root_relative(root);
1134        assert_eq!(inside.path, Path::new("packages/inner"));
1135
1136        let outside = WorkspaceDiagnostic::new(
1137            root,
1138            PathBuf::from("/elsewhere/packages/inner"),
1139            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1140        )
1141        .into_root_relative(root);
1142        assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
1143    }
1144
1145    #[test]
1146    fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
1147        let analysis_stage = [
1148            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1149                error: "bad yaml".to_owned(),
1150            },
1151            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1152            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1153            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1154        ];
1155        for kind in &analysis_stage {
1156            assert!(
1157                kind.is_analysis_stage() && !kind.is_source_discovery(),
1158                "{} is recorded by the analyze stage only",
1159                kind.id()
1160            );
1161        }
1162
1163        let other = [
1164            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1165            WorkspaceDiagnosticKind::MalformedPackageJson {
1166                error: "trailing comma".to_owned(),
1167            },
1168            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1169                pattern: "packages/*".to_owned(),
1170            },
1171            WorkspaceDiagnosticKind::MalformedTsconfig {
1172                error: "unexpected token".to_owned(),
1173            },
1174            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1175            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1176            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1177            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1178            WorkspaceDiagnosticKind::SourceReadFailure {
1179                error: "permission denied".to_owned(),
1180            },
1181        ];
1182        for kind in &other {
1183            assert!(
1184                !kind.is_analysis_stage(),
1185                "{} is a discovery kind, not an analyze-stage kind",
1186                kind.id()
1187            );
1188        }
1189    }
1190
1191    #[test]
1192    fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
1193        let root = Path::new("/project");
1194        let first = WorkspaceDiagnostic::new(
1195            root,
1196            root.join("packages/aaa"),
1197            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1198                pattern: "packages/*".to_owned(),
1199            },
1200        );
1201        let second = WorkspaceDiagnostic::new(
1202            root,
1203            root.join("packages/aaa"),
1204            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1205                pattern: "packages/a*".to_owned(),
1206            },
1207        );
1208
1209        let merged =
1210            merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
1211
1212        let patterns: Vec<String> = merged
1213            .iter()
1214            .map(|diagnostic| match &diagnostic.kind {
1215                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
1216                other => panic!("unexpected kind {}", other.id()),
1217            })
1218            .collect();
1219        assert_eq!(
1220            patterns,
1221            ["packages/*", "packages/a*"],
1222            "two overlapping globs report the same directory twice, with their own pattern; \
1223             the same entry seen from two observation points still folds to one"
1224        );
1225    }
1226
1227    /// Issue #2366: a repository that declares one glob in two manifests
1228    /// (`"./apps/**"` in `package.json`, `apps/**` in `pnpm-workspace.yaml`)
1229    /// must not report every package-less directory under it twice now that the
1230    /// payload is part of the dedupe key.
1231    #[test]
1232    fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
1233        let root = Path::new("/project");
1234        let dotted = WorkspaceDiagnostic::new(
1235            root,
1236            root.join("apps/site/.next/cache"),
1237            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1238                pattern: "./apps/**".to_owned(),
1239            },
1240        );
1241        let bare = WorkspaceDiagnostic::new(
1242            root,
1243            root.join("apps/site/.next/cache"),
1244            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1245                pattern: "apps/**".to_owned(),
1246            },
1247        );
1248        assert_eq!(
1249            dotted.kind, bare.kind,
1250            "the no-op ./ prefix is normalised out of the recorded pattern"
1251        );
1252        assert!(
1253            dotted.message.contains("Glob 'apps/**'"),
1254            "the message renders the normalised pattern: {}",
1255            dotted.message
1256        );
1257
1258        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1259        assert_eq!(
1260            merged.len(),
1261            1,
1262            "one glob declared twice is one diagnostic: {merged:?}"
1263        );
1264    }
1265
1266    /// A glob spelled exactly `"./"` (the project root itself) is the one
1267    /// pattern the prefix strip must leave alone: an empty `pattern` field
1268    /// names no glob, and the warning would quote nothing.
1269    #[test]
1270    fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
1271        let root = Path::new("/project");
1272        let recorded = |pattern: &str| {
1273            let diagnostic = WorkspaceDiagnostic::new(
1274                root,
1275                root.join("pkgs"),
1276                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1277                    pattern: pattern.to_owned(),
1278                },
1279            );
1280            let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
1281            else {
1282                panic!("constructed a glob-matched-no-package-json diagnostic");
1283            };
1284            (pattern, diagnostic.message)
1285        };
1286
1287        let (root_pattern, root_message) = recorded("./");
1288        assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
1289        assert!(
1290            root_message.contains("Glob './'"),
1291            "the warning names the glob the manifest declared: {root_message}"
1292        );
1293        assert_eq!(recorded(".\\").0, ".\\");
1294        assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
1295        assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
1296    }
1297
1298    /// Issue #2366, the path half of the same repository shape: expanding
1299    /// `./pkgs/*` joins the no-op `.` into every match, so the two manifests
1300    /// hand one directory to the diagnostic under two spellings. Both must
1301    /// store, render and serialise as the bare one, otherwise whichever
1302    /// manifest was read first decides whether the analysis envelopes print
1303    /// `./pkgs/aaa` while the workspace listing envelope prints `pkgs/aaa`.
1304    #[test]
1305    fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
1306        let root = Path::new("/project");
1307        let dotted = WorkspaceDiagnostic::new(
1308            root,
1309            root.join("./pkgs/aaa"),
1310            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1311                pattern: "./pkgs/*".to_owned(),
1312            },
1313        );
1314        let bare = WorkspaceDiagnostic::new(
1315            root,
1316            root.join("pkgs/aaa"),
1317            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1318                pattern: "pkgs/*".to_owned(),
1319            },
1320        );
1321
1322        let spelling = |diagnostic: &WorkspaceDiagnostic| {
1323            diagnostic.path.display().to_string().replace('\\', "/")
1324        };
1325        assert_eq!(
1326            spelling(&dotted),
1327            "/project/pkgs/aaa",
1328            "the stored path drops the no-op . component, which Path equality \
1329             hides but serialization does not"
1330        );
1331        assert_eq!(spelling(&dotted), spelling(&bare));
1332        assert_eq!(
1333            spelling(&dotted.clone().into_root_relative(root)),
1334            "pkgs/aaa"
1335        );
1336
1337        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1338        assert_eq!(
1339            merged.len(),
1340            1,
1341            "one directory reached through two spellings of one glob: {merged:?}"
1342        );
1343    }
1344
1345    /// The single-list fold applied at workspace discovery keeps one entry per
1346    /// `(kind, path)` and leaves distinct payloads alone.
1347    #[test]
1348    fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
1349        let root = Path::new("/project");
1350        let glob = |pattern: &str, relative: &str| {
1351            WorkspaceDiagnostic::new(
1352                root,
1353                root.join(relative),
1354                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1355                    pattern: pattern.to_owned(),
1356                },
1357            )
1358        };
1359
1360        let deduped = dedupe_workspace_diagnostics(vec![
1361            glob("pkgs/*", "pkgs/aaa"),
1362            glob("pkgs/*", "pkgs/bbb"),
1363            glob("./pkgs/*", "./pkgs/aaa"),
1364            glob("pkgs/a*", "pkgs/aaa"),
1365        ]);
1366
1367        let reported: Vec<(String, String)> = deduped
1368            .iter()
1369            .map(|diagnostic| match &diagnostic.kind {
1370                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
1371                    pattern.clone(),
1372                    diagnostic.path.display().to_string().replace('\\', "/"),
1373                ),
1374                other => panic!("unexpected kind {}", other.id()),
1375            })
1376            .collect();
1377
1378        assert_eq!(
1379            reported,
1380            vec![
1381                ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
1382                ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
1383                ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
1384            ],
1385            "the duplicate spelling folds away and the overlapping glob stays"
1386        );
1387    }
1388
1389    /// The class `reachability_caveats[]` is computed from. Every kind here
1390    /// means the run never read a file that is part of the project, so its
1391    /// imports credit nothing and the modules it imports can be reported
1392    /// unused with a removal action on them. Classifying a kind `true` is the
1393    /// only wiring its findings need to inherit the caveat and the `fallow fix`
1394    /// withholding that follows it.
1395    #[test]
1396    fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
1397        for kind in [
1398            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1399            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1400            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1401            WorkspaceDiagnosticKind::SourceReadFailure {
1402                error: "permission denied".to_owned(),
1403            },
1404        ] {
1405            assert!(
1406                kind.source_never_analyzed(),
1407                "{} names a source file this run never read",
1408                kind.id()
1409            );
1410        }
1411
1412        let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
1413            error_count: 3,
1414            panicked: false,
1415        };
1416        assert!(
1417            !degraded.source_never_analyzed(),
1418            "a degraded parse read the file, so it has a graph node and its reachability is \
1419             observable; the caveat pass narrows it instead of treating it as unread"
1420        );
1421
1422        for kind in [
1423            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1424            WorkspaceDiagnosticKind::MalformedPackageJson {
1425                error: "trailing comma".to_owned(),
1426            },
1427            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1428                pattern: "packages/*".to_owned(),
1429            },
1430            WorkspaceDiagnosticKind::MalformedTsconfig {
1431                error: "unexpected token".to_owned(),
1432            },
1433            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1434            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1435                error: "bad indent".to_owned(),
1436            },
1437            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1438            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1439            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1440            WorkspaceDiagnosticKind::NodeModulesMissing,
1441            WorkspaceDiagnosticKind::BoundariesNotConfigured,
1442            WorkspaceDiagnosticKind::RulePacksNotConfigured,
1443        ] {
1444            assert!(
1445                !kind.source_never_analyzed(),
1446                "{} says nothing about a source file's imports going unseen",
1447                kind.id()
1448            );
1449        }
1450    }
1451
1452    #[test]
1453    fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
1454        for kind in [
1455            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1456            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1457            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1458        ] {
1459            assert!(
1460                kind.is_source_walk_recorded() && kind.is_source_discovery(),
1461                "{} is written by the source walk",
1462                kind.id()
1463            );
1464        }
1465
1466        let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
1467            error: "permission denied".to_owned(),
1468        };
1469        assert!(
1470            read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
1471            "the parse stage records source-read-failure after the walk, so it must keep \
1472             reaching sessions through the registry"
1473        );
1474
1475        for kind in [
1476            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1477            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1478            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1479            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1480            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1481        ] {
1482            assert!(
1483                !kind.is_source_walk_recorded(),
1484                "{} is not written by the source walk",
1485                kind.id()
1486            );
1487        }
1488    }
1489
1490    /// Issue #2638, the single most load-bearing classification in the new
1491    /// kind. Answering `true` here would attach `IncompleteFileAnalysis` and
1492    /// `IncompleteImportGraph` caveats to findings on nearly every project
1493    /// that keeps a non-gitignored `dist/` or `coverage/`, and make
1494    /// `fallow fix` withhold `delete-file` and `remove-export` project-wide.
1495    /// A built-in exclusion is designed behavior on generated output, not a
1496    /// degraded run.
1497    #[test]
1498    fn a_built_in_ignore_exclusion_is_not_a_file_the_run_failed_to_analyze() {
1499        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1500            pattern: "**/build/**".to_owned(),
1501            file_count: 3,
1502            directory_count: 1,
1503        };
1504        assert!(!kind.source_never_analyzed());
1505    }
1506
1507    /// Issue #2638: these exclusions fire in the product's default state on
1508    /// most monorepos, so a default stderr line would be permanent noise that
1509    /// names no defect. The CLI prints a note under `--explain-skipped`
1510    /// instead.
1511    #[test]
1512    fn a_built_in_ignore_exclusion_does_not_warn_on_stderr_by_default() {
1513        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1514            pattern: "**/build/**".to_owned(),
1515            file_count: 3,
1516            directory_count: 1,
1517        };
1518        assert!(!kind.warns_on_stderr());
1519    }
1520
1521    /// Issue #2638 plus issue #2366: the walk writes it, so it has to be
1522    /// classified as source-discovery (or combined mode's per-analysis config
1523    /// reloads wipe it before serialization) AND as walk-recorded (or a
1524    /// concurrent walk's tally is folded into another analysis's list).
1525    #[test]
1526    fn a_built_in_ignore_exclusion_is_walk_recorded_source_discovery() {
1527        let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1528            pattern: "**/build/**".to_owned(),
1529            file_count: 3,
1530            directory_count: 1,
1531        };
1532        assert!(kind.is_source_discovery());
1533        assert!(kind.is_source_walk_recorded());
1534        assert!(!kind.is_analysis_stage());
1535        assert_eq!(kind.id(), "excluded-by-default-ignore");
1536    }
1537
1538    /// Issue #2638: the message has to name the pattern the reader cannot see,
1539    /// the directory, and the only remedy that actually analyzes the tree.
1540    /// `ignorePatterns` is not that remedy: the compiled set unions, so it
1541    /// cannot negate a built-in.
1542    #[test]
1543    fn a_built_in_ignore_exclusion_message_names_the_pattern_and_the_root_remedy() {
1544        let root = Path::new("/project");
1545        let diag = WorkspaceDiagnostic::new(
1546            root,
1547            root.join("packages/web/build"),
1548            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1549                pattern: "**/build/**".to_owned(),
1550                file_count: 4,
1551                directory_count: 1,
1552            },
1553        );
1554        assert!(diag.message.contains("**/build/**"), "{}", diag.message);
1555        assert!(
1556            diag.message.contains("packages/web/build"),
1557            "{}",
1558            diag.message
1559        );
1560        assert!(
1561            diag.message.contains("fallow --root packages/web/build"),
1562            "the remedy is copy-pasteable: {}",
1563            diag.message
1564        );
1565        assert!(
1566            diag.message
1567                .contains("cannot be switched off through ignorePatterns"),
1568            "the message must not advertise a negation that does not exist: {}",
1569            diag.message
1570        );
1571    }
1572
1573    /// One excluded file reads as one file, not as "1 source files".
1574    #[test]
1575    fn a_single_excluded_file_message_is_singular() {
1576        let root = Path::new("/project");
1577        let diag = WorkspaceDiagnostic::new(
1578            root,
1579            root.join("dist"),
1580            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1581                pattern: "**/dist/**".to_owned(),
1582                file_count: 1,
1583                directory_count: 1,
1584            },
1585        );
1586        assert!(
1587            diag.message
1588                .starts_with("Skipped 1 source file under 'dist'"),
1589            "{}",
1590            diag.message
1591        );
1592        assert!(diag.message.contains("it matches"), "{}", diag.message);
1593        assert!(
1594            diag.message
1595                .contains("nothing it imports, exports, or defines"),
1596            "the whole sentence agrees in number, not just its first clause: {}",
1597            diag.message
1598        );
1599    }
1600
1601    /// The anchor directory is the largest group, never a majority: ten
1602    /// packages each holding one excluded file make every one of them "the
1603    /// largest", and a message claiming otherwise is false on exactly the flat
1604    /// monorepo shape issue #2638 is about.
1605    #[test]
1606    fn a_scattered_exclusion_names_the_largest_group_and_counts_the_directories() {
1607        let root = Path::new("/project");
1608        let diag = WorkspaceDiagnostic::new(
1609            root,
1610            root.join("packages/a/dist"),
1611            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1612                pattern: "**/dist/**".to_owned(),
1613                file_count: 10,
1614                directory_count: 10,
1615            },
1616        );
1617        assert!(
1618            diag.message.starts_with(
1619                "Skipped 10 source files across 10 directories, the largest group under \
1620                 'packages/a/dist'"
1621            ),
1622            "{}",
1623            diag.message
1624        );
1625        assert!(
1626            !diag.message.contains("the most of them"),
1627            "a max-of-group is not a majority: {}",
1628            diag.message
1629        );
1630    }
1631
1632    /// A file-shaped built-in matches on the file name, so the `--root` remedy
1633    /// the directory-shaped patterns get would re-exclude the same file. The
1634    /// message must not print a command that provably does nothing.
1635    #[test]
1636    fn a_file_shaped_pattern_does_not_advertise_the_root_remedy() {
1637        let root = Path::new("/project");
1638        let diag = WorkspaceDiagnostic::new(
1639            root,
1640            root.join("vendor"),
1641            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1642                pattern: "**/*.min.js".to_owned(),
1643                file_count: 2,
1644                directory_count: 1,
1645            },
1646        );
1647        assert!(
1648            !diag.message.contains("fallow --root"),
1649            "the message explains why re-rooting fails, it does not prescribe it: {}",
1650            diag.message
1651        );
1652        assert!(
1653            diag.message.contains("matches a file name"),
1654            "the message says why: {}",
1655            diag.message
1656        );
1657        assert!(
1658            diag.message.contains("Rename"),
1659            "and names the remedy that does work: {}",
1660            diag.message
1661        );
1662    }
1663
1664    /// A built-in that matched a file sitting directly at the analysis root
1665    /// anchors at the root, and an empty string is not a location.
1666    #[test]
1667    fn a_root_anchored_exclusion_renders_its_location_as_dot() {
1668        let root = Path::new("/project");
1669        let diag = WorkspaceDiagnostic::new(
1670            root,
1671            root.to_path_buf(),
1672            WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1673                pattern: "**/*.min.js".to_owned(),
1674                file_count: 1,
1675                directory_count: 1,
1676            },
1677        );
1678        assert!(
1679            diag.message.starts_with("Skipped 1 source file under '.'"),
1680            "{}",
1681            diag.message
1682        );
1683    }
1684
1685    #[test]
1686    fn glob_first_literal_segment_skips_wildcards_and_dot_components() {
1687        assert_eq!(glob_first_literal_segment("**/build/**"), Some("build"));
1688        assert_eq!(glob_first_literal_segment("./dist/**"), Some("dist"));
1689        assert_eq!(glob_first_literal_segment("**/*.min.js"), None);
1690        assert_eq!(glob_first_literal_segment("**/*.bundle.js"), None);
1691        assert_eq!(glob_first_literal_segment("**/{a,b}/**"), None);
1692    }
1693
1694    #[test]
1695    fn format_size_mb_one_decimal() {
1696        assert_eq!(format_size_mb(0), "0.0 MB");
1697        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1698        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1699    }
1700
1701    #[test]
1702    fn undeclared_workspace_message_has_next_step() {
1703        let root = Path::new("/project");
1704        let diag = WorkspaceDiagnostic::new(
1705            root,
1706            root.join("packages/legacy"),
1707            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1708        );
1709        assert_eq!(diag.kind.id(), "undeclared-workspace");
1710        assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
1711        assert!(
1712            diag.message.contains("ignorePatterns"),
1713            "next-step hint preserved: {}",
1714            diag.message
1715        );
1716    }
1717}