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}
203
204impl WorkspaceDiagnosticKind {
205    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
206    #[must_use]
207    pub const fn id(&self) -> &'static str {
208        match self {
209            Self::UndeclaredWorkspace => "undeclared-workspace",
210            Self::MalformedPackageJson { .. } => "malformed-package-json",
211            Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
212            Self::MalformedTsconfig { .. } => "malformed-tsconfig",
213            Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
214            Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
215            Self::SkippedLargeFile { .. } => "skipped-large-file",
216            Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
217            Self::SkippedSourceDotdir => "skipped-source-dotdir",
218            Self::SourceReadFailure { .. } => "source-read-failure",
219            Self::SourceParseDegraded { .. } => "source-parse-degraded",
220            Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
221            Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
222            Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
223            Self::NodeModulesMissing => "node-modules-missing",
224            Self::BoundariesNotConfigured => "boundaries-not-configured",
225            Self::RulePacksNotConfigured => "rule-packs-not-configured",
226        }
227    }
228
229    /// Whether this diagnostic is worth a `tracing::warn!` line on stderr, on
230    /// top of its permanent entry in `workspace_diagnostics[]`.
231    ///
232    /// A warning is for a run whose RESULTS are degraded: something the user
233    /// installed, wrote, or expected did not reach the analysis. The two
234    /// unconfigured-check kinds are not that. They fire in the product's
235    /// default state, on every project that never opted into boundaries or
236    /// rule packs, and they will keep firing forever, because the remedy they
237    /// offer is to write configuration in order to silence a warning about not
238    /// having written configuration. They stay in the structured array, where a
239    /// consumer that wants to distinguish "measured zero" from "measured
240    /// nothing" can read them, and off the stderr surface that every other
241    /// command shares.
242    #[must_use]
243    pub const fn warns_on_stderr(&self) -> bool {
244        match self {
245            Self::BoundariesNotConfigured | Self::RulePacksNotConfigured => false,
246            Self::UndeclaredWorkspace
247            | Self::MalformedPackageJson { .. }
248            | Self::GlobMatchedNoPackageJson { .. }
249            | Self::MalformedTsconfig { .. }
250            | Self::TsconfigReferenceDirMissing
251            | Self::MalformedPnpmWorkspaceYaml { .. }
252            | Self::SkippedLargeFile { .. }
253            | Self::SkippedMinifiedFile { .. }
254            | Self::SkippedSourceDotdir
255            | Self::SourceReadFailure { .. }
256            | Self::SourceParseDegraded { .. }
257            | Self::BunLockbOverrideResolutionSkipped
258            | Self::BunLockOverrideResolutionSkipped
259            | Self::BunResolutionsShadowedByOverrides
260            | Self::NodeModulesMissing => true,
261        }
262    }
263
264    /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
265    /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
266    /// discovery diagnostics are APPENDED to the registry after config load, so
267    /// `stash_workspace_diagnostics` must preserve them when it replaces the
268    /// workspace-discovery set, otherwise the per-analysis config re-loads in
269    /// combined-mode (`fallow` with no subcommand re-loads config for check,
270    /// dupes, and health) wipe them before the JSON envelope is built (issue
271    /// #1086).
272    #[must_use]
273    pub const fn is_source_discovery(&self) -> bool {
274        matches!(
275            self,
276            Self::SkippedLargeFile { .. }
277                | Self::SkippedMinifiedFile { .. }
278                | Self::SkippedSourceDotdir
279                | Self::SourceReadFailure { .. }
280                | Self::SourceParseDegraded { .. }
281                | Self::NodeModulesMissing
282        )
283    }
284
285    /// Whether this diagnostic is written by the source file WALK
286    /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
287    /// walk replaces wholesale for its root. `source-read-failure` is the
288    /// other source-discovery kind and is NOT one of these: the parse stage
289    /// records it after the walk, so it has to keep reaching consumers through
290    /// the registry.
291    ///
292    /// A walk-recorded entry must reach an analysis from its OWN walk's return
293    /// value. Combined mode runs the dead-code and duplication walks under
294    /// `rayon::join` whenever a per-analysis `production` split stops them from
295    /// sharing a file list, so a registry read answers "whichever walk wrote
296    /// last" and varies between runs of the same command (issue #2366).
297    #[must_use]
298    pub const fn is_source_walk_recorded(&self) -> bool {
299        matches!(
300            self,
301            Self::SkippedLargeFile { .. }
302                | Self::SkippedMinifiedFile { .. }
303                | Self::SkippedSourceDotdir
304                | Self::NodeModulesMissing
305        )
306    }
307
308    /// Whether this diagnostic reports a source file whose contents this run
309    /// never analyzed, so every import and export the file holds is invisible
310    /// to the module graph.
311    ///
312    /// This is the class `reachability_caveats[]` exists for. A file the run
313    /// never read credits nothing, so the modules it imports surface as
314    /// confident `unused-file` and `unused-export` findings carrying
315    /// `delete-file` and `remove-export` actions, and `fallow fix` would
316    /// otherwise apply the removal against source that still imports the
317    /// target.
318    ///
319    /// All four discovery-side kinds qualify, for the same reason and with the
320    /// same consequence:
321    ///
322    /// - `skipped-large-file` and `skipped-minified-file`: the file is in the
323    ///   project tree and was never opened, so its import list is unknown.
324    /// - `skipped-source-dotdir`: the directory holds at least one source file
325    ///   the project did not exclude, and none of them were traversed. The
326    ///   diagnostic is capped, so it under-reports rather than over-reports;
327    ///   its presence still proves unseen source exists.
328    /// - `source-read-failure`: the file was discovered and then could not be
329    ///   read, so nothing was extracted from it at all.
330    ///
331    /// `source-parse-degraded` is deliberately NOT one of these, though it
332    /// belongs to the same family. That file WAS read, so it has a module and
333    /// a graph node and its reachability is observable, which lets the caveat
334    /// pass narrow it: a degraded module that is itself unreachable cannot
335    /// change a reachability verdict. Every kind above has no node to ask (a
336    /// read failure has one with nothing extracted into it), so no narrowing
337    /// is available and the caveat they raise is run-level.
338    ///
339    /// The match is exhaustive on purpose: a new "the run did not see this
340    /// file" kind has to be classified here, and answering `true` is the only
341    /// wiring its findings need in order to inherit both the caveat and the
342    /// `fallow fix` withholding that follows it.
343    #[must_use]
344    pub const fn source_never_analyzed(&self) -> bool {
345        match self {
346            Self::SkippedLargeFile { .. }
347            | Self::SkippedMinifiedFile { .. }
348            | Self::SkippedSourceDotdir
349            | Self::SourceReadFailure { .. } => true,
350            Self::UndeclaredWorkspace
351            | Self::MalformedPackageJson { .. }
352            | Self::GlobMatchedNoPackageJson { .. }
353            | Self::MalformedTsconfig { .. }
354            | Self::TsconfigReferenceDirMissing
355            | Self::MalformedPnpmWorkspaceYaml { .. }
356            | Self::SourceParseDegraded { .. }
357            | Self::BunLockbOverrideResolutionSkipped
358            | Self::BunLockOverrideResolutionSkipped
359            | Self::BunResolutionsShadowedByOverrides
360            | Self::NodeModulesMissing
361            | Self::BoundariesNotConfigured
362            | Self::RulePacksNotConfigured => false,
363        }
364    }
365
366    /// Whether this diagnostic is recorded by the ANALYZE stage (the
367    /// dependency-catalog and override detectors) rather than by workspace or
368    /// source discovery. Analysis-stage diagnostics reach the registry through
369    /// `record_workspace_diagnostics` after config load, so
370    /// `stash_workspace_diagnostics` must preserve them across combined-mode's
371    /// per-analysis config re-loads, and every analyze pass clears its previous
372    /// entries before re-recording so a fixed cause drops out on the next run
373    /// (issue #2366). The match is exhaustive on purpose: a new kind must be
374    /// classified here before it compiles.
375    ///
376    /// Classify a kind `true` ONLY when a detector reachable from the dead-code
377    /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
378    /// the single clear site. A kind recorded exclusively by another stage would
379    /// be cleared by the next dead-code pass and never come back.
380    #[must_use]
381    pub const fn is_analysis_stage(&self) -> bool {
382        match self {
383            Self::MalformedPnpmWorkspaceYaml { .. }
384            | Self::BunLockbOverrideResolutionSkipped
385            | Self::BunLockOverrideResolutionSkipped
386            | Self::BunResolutionsShadowedByOverrides
387            | Self::BoundariesNotConfigured
388            | Self::RulePacksNotConfigured => true,
389            Self::UndeclaredWorkspace
390            | Self::MalformedPackageJson { .. }
391            | Self::GlobMatchedNoPackageJson { .. }
392            | Self::MalformedTsconfig { .. }
393            | Self::TsconfigReferenceDirMissing
394            | Self::SkippedLargeFile { .. }
395            | Self::SkippedMinifiedFile { .. }
396            | Self::SkippedSourceDotdir
397            | Self::SourceReadFailure { .. }
398            | Self::SourceParseDegraded { .. }
399            | Self::NodeModulesMissing => false,
400        }
401    }
402}
403
404/// Render a byte count as a megabyte figure with one decimal place for
405/// human-readable diagnostic messages (e.g. `12.3 MB`).
406#[must_use]
407fn format_size_mb(bytes: u64) -> String {
408    #[expect(
409        clippy::cast_precision_loss,
410        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
411    )]
412    let mb = bytes as f64 / (1024.0 * 1024.0);
413    format!("{mb:.1} MB")
414}
415
416/// A diagnostic about a workspace-discovery candidate.
417///
418/// The `message` field is a human-readable rendering derived from `kind`. It
419/// always ends with a concrete next step ("fix the JSON syntax", "remove from
420/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
421/// forward.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[cfg_attr(feature = "schema", derive(JsonSchema))]
424pub struct WorkspaceDiagnostic {
425    /// Path to the directory or file that triggered the diagnostic.
426    #[serde(serialize_with = "serde_path::serialize")]
427    pub path: PathBuf,
428    /// Kind discriminator with the typed payload.
429    #[serde(flatten)]
430    pub kind: WorkspaceDiagnosticKind,
431    /// Human-readable rendering derived from `kind` + `path`. Always ends
432    /// with a next-step hint.
433    pub message: String,
434}
435
436impl WorkspaceDiagnostic {
437    /// Construct a diagnostic with the message rendered from `kind` + `path`.
438    ///
439    /// `root` is used to produce project-relative paths in the message text
440    /// AND inside the variant payload (e.g. the `error` field of
441    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
442    /// file path from `PackageJson::load()`'s error text). Without the
443    /// payload-side normalisation the embedded path would survive
444    /// environment-specific differences (CI vs Docker vs local) because the
445    /// post-serialisation `strip_root_prefix` only catches whole-string
446    /// matches, not paths embedded mid-sentence.
447    ///
448    /// If `path` is not under `root` (e.g. canonicalisation crossed a
449    /// symlink), the absolute path is emitted instead.
450    ///
451    /// `path` also loses any no-op `.` component, for the same reason the
452    /// payload loses a glob's `./` prefix: one directory reached through two
453    /// spellings of one glob must be one diagnostic.
454    #[must_use]
455    pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
456        let path = normalise_diagnostic_path(path);
457        let kind = normalise_payload_paths(root, kind);
458        let message = render_message(root, &path, &kind);
459        Self {
460            path,
461            kind,
462            message,
463        }
464    }
465
466    /// Return this diagnostic with `path` rewritten relative to `root`.
467    ///
468    /// `path` is stored absolute so callers can act on it. Every JSON envelope
469    /// emits it project-relative instead: the analysis envelopes get there
470    /// through the post-serialisation `strip_root_prefix` pass, which the
471    /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
472    /// `project_info` tool never run, so those emitted the absolute path while
473    /// the sibling `workspaces[].path` next to it was relative. They normalise
474    /// at the typed layer with this method instead.
475    ///
476    /// Paths outside `root` (canonicalisation crossed a symlink) are left
477    /// absolute, matching how [`Self::new`] renders the message.
478    ///
479    /// A diagnostic anchored at the root itself becomes `.`, not the empty
480    /// path: an empty string is not a location, and the analysis envelopes'
481    /// post-serialisation strip only removes a `root + separator` prefix, so a
482    /// root-anchored path that stays absolute here leaks a host path.
483    #[must_use]
484    pub fn into_root_relative(mut self, root: &Path) -> Self {
485        if let Ok(relative) = self.path.strip_prefix(root) {
486            self.path = if relative.as_os_str().is_empty() {
487                PathBuf::from(".")
488            } else {
489                relative.to_path_buf()
490            };
491        }
492        self
493    }
494}
495
496/// Rebuild `path` from its components so one directory has one spelling.
497///
498/// The dedupe key was never the problem: [`Path`] equality already ignores an
499/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
500/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
501/// expands to the first spelling and the same glob spelled `pkgs/*` in
502/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
503/// make a project-relative path differently: the analysis envelopes strip the
504/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
505/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
506/// `pkgs/aaa`). Whichever
507/// manifest happened to be read first then decided which shape every consumer
508/// saw. Collapsing at construction gives them one answer (issue #2366).
509///
510/// A path that is already component-clean rebuilds to itself. Serialization
511/// normalises separators, so the rebuild is wire-invisible on Windows.
512fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
513    let rebuilt: PathBuf = path.components().collect();
514    if rebuilt.as_os_str() == path.as_os_str() {
515        path
516    } else {
517        rebuilt
518    }
519}
520
521/// Strip the project root from absolute paths embedded inside variant
522/// payloads (the `error` field of malformed-config and source-read failures),
523/// and drop a glob pattern's no-op `./` prefix.
524///
525/// Mirrors the per-platform `display()` byte sequence so the substring match
526/// works on Windows too.
527///
528/// The pattern prefix matters because the payload is part of the dedupe key in
529/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
530/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
531/// glob twice, and without this both spellings would report every package-less
532/// directory under `apps/` a second time (issue #2366).
533fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
534    let root_str = root.display().to_string();
535    let root_alt = root_str.replace('\\', "/");
536    let normalise = |text: String| -> String {
537        let stripped = text
538            .replace(&format!("{root_str}/"), "")
539            .replace(&format!("{root_alt}/"), "");
540        stripped
541            .replace(&format!("{root_str}\\"), "")
542            .replace(&format!("{root_alt}\\"), "")
543    };
544    match kind {
545        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
546            WorkspaceDiagnosticKind::MalformedPackageJson {
547                error: normalise(error),
548            }
549        }
550        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
551            WorkspaceDiagnosticKind::MalformedTsconfig {
552                error: normalise(error),
553            }
554        }
555        WorkspaceDiagnosticKind::SourceReadFailure { error } => {
556            WorkspaceDiagnosticKind::SourceReadFailure {
557                error: normalise(error),
558            }
559        }
560        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
561            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
562                pattern: canonical_glob_pattern(pattern),
563            }
564        }
565        other => other,
566    }
567}
568
569/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
570/// pattern declared in two manifests is one payload.
571///
572/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
573/// its spelling: stripping it would report an empty `pattern` field and an
574/// empty quoted glob in the warning text, which names no glob at all.
575fn canonical_glob_pattern(pattern: String) -> String {
576    for prefix in ["./", ".\\"] {
577        if let Some(rest) = pattern.strip_prefix(prefix)
578            && !rest.is_empty()
579        {
580            return rest.to_owned();
581        }
582    }
583    pattern
584}
585
586/// Concatenate two diagnostic lists, keeping the first occurrence of each
587/// `(kind, path)` pair and the order of `primary` followed by the entries only
588/// `secondary` has.
589///
590/// The single place diagnostics from two observation points are folded
591/// together: an engine session's own capture plus the process registry, and
592/// the combined run's per-analysis lists (issue #2366). A combined run walks
593/// the project once per analysis, and per-analysis `production` modes can make
594/// those walks see different file sets, so no single observation point holds
595/// everything the run recorded; the union does, and folding it the same way
596/// everywhere is what keeps the CLI and the programmatic route answering
597/// identically.
598///
599/// The key is the WHOLE kind, payload included, not its
600/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
601/// path and still be two distinct diagnostics: overlapping workspace globs
602/// (`["packages/*", "packages/*/*"]`) each report the same package-less
603/// directory with their own `pattern`, and the standalone envelopes report
604/// both. An id-keyed fold silently dropped the second one.
605#[must_use]
606pub fn merge_workspace_diagnostics(
607    primary: Vec<WorkspaceDiagnostic>,
608    secondary: Vec<WorkspaceDiagnostic>,
609) -> Vec<WorkspaceDiagnostic> {
610    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
611    let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
612    for diagnostic in primary.into_iter().chain(secondary) {
613        let key = (diagnostic.kind.clone(), diagnostic.path.clone());
614        if seen.insert(key) {
615            merged.push(diagnostic);
616        }
617    }
618    merged
619}
620
621/// Keep the first occurrence of each `(kind, path)` pair in one list.
622///
623/// The single-list form of [`merge_workspace_diagnostics`], applied where
624/// diagnostics are produced rather than where two observation points are
625/// folded: workspace discovery reads `package.json` `workspaces`,
626/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
627/// `tsconfig.json` references additively, so a repository that declares one
628/// glob in two of them reports every package-less directory under it twice.
629/// Deduplicating at that source is what keeps the JSON envelopes, the
630/// aggregated stderr warning and the process registry telling one story
631/// (issue #2366).
632#[must_use]
633pub fn dedupe_workspace_diagnostics(
634    diagnostics: Vec<WorkspaceDiagnostic>,
635) -> Vec<WorkspaceDiagnostic> {
636    merge_workspace_diagnostics(diagnostics, Vec::new())
637}
638
639/// Render `path` relative to `root` with forward slashes. The forward-slash
640/// normalisation is load-bearing for cross-platform output stability.
641fn display_relative(root: &Path, path: &Path) -> String {
642    path.strip_prefix(root)
643        .unwrap_or(path)
644        .display()
645        .to_string()
646        .replace('\\', "/")
647}
648
649fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
650    let display = display_relative(root, path);
651    match kind {
652        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
653            "Directory '{display}' contains package.json but is not declared as a workspace. \
654             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
655        ),
656        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
657            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
658             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
659        ),
660        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
661            "Glob '{pattern}' matched '{display}' but no package.json is present. \
662             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
663        ),
664        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
665            "tsconfig.json at '{display}' failed to parse ({error}); \
666             project references will be ignored. Fix the JSON syntax."
667        ),
668        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
669            "tsconfig.json references '{display}' but the directory does not exist. \
670             Update or remove the reference, or restore the missing directory."
671        ),
672        WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
673            "'{display}' failed to parse ({error}); catalog and override entries \
674             will be ignored. Fix the YAML syntax."
675        ),
676        WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
677            "Skipped '{display}' ({size}): exceeds the max file size limit. \
678             Its imports and exports are not analyzed. Raise the limit with \
679             --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
680             to ignorePatterns.",
681            size = format_size_mb(*size_bytes)
682        ),
683        WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
684            "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
685             Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
686             rename it with a .min.js suffix, or use --max-file-size 0 if this file \
687             should be analyzed.",
688            size = format_size_mb(*size_bytes)
689        ),
690        WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
691            "Skipped hidden directory '{display}': it contains source files but hidden \
692             directories are not traversed. Its imports and exports are not analyzed. \
693             There is no config field that adds a directory to traversal. If it holds \
694             first-party source, analyze it on its own with fallow --root {display}; if it \
695             is tool or agent scratch state, add '{display}/**' to ignorePatterns to \
696             silence this."
697        ),
698        WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
699            "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
700             ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
701        ),
702        WorkspaceDiagnosticKind::SourceParseDegraded {
703            error_count,
704            panicked,
705        } => {
706            let outcome = if *panicked {
707                "the parser stopped there"
708            } else {
709                "the parser recovered and continued"
710            };
711            format!(
712                "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
713                 references it did not reach are missing from this run, so files and symbols it \
714                 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
715                 syntax newer than fallow's parser."
716            )
717        }
718        WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
719            "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
720             sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
721             (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
722             use instead, so unused-dependency-overrides findings are not reported. Run bun install \
723             --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
724             bun.lockb if this repository no longer uses bun."
725        ),
726        WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
727            "Skipped dependency-override resolution because '{display}' could not be parsed and \
728             no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
729             findings are not reported. Run bun install to regenerate the text lockfile, then \
730             rerun fallow."
731        ),
732        WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
733            "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
734             `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
735             remove the shadowed `resolutions` entries."
736        ),
737        WorkspaceDiagnosticKind::NodeModulesMissing => format!(
738            "'{display}' does not exist. Package exports and conditional exports cannot be read, \
739             framework plugins that activate on an installed package stay inactive, and \
740             dependency classification degrades, so imports and dependencies can be \
741             misreported. Run npm install / pnpm install / yarn / bun install first."
742        ),
743        WorkspaceDiagnosticKind::BoundariesNotConfigured => {
744            "No architecture boundaries are configured, so the boundary detector did not run and \
745             its violation counts are zero because nothing was measured. Add `boundaries` to the \
746             config, or set `boundary-violation` to off to state that the check is not wanted."
747                .to_string()
748        }
749        WorkspaceDiagnosticKind::RulePacksNotConfigured => {
750            "No rule packs are configured, so the policy detector did not run and its violation \
751             counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
752             `policy-violation` to off to state that the check is not wanted."
753                .to_string()
754        }
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn skipped_large_file_diagnostic_id_and_message() {
764        let root = Path::new("/project");
765        let diag = WorkspaceDiagnostic::new(
766            root,
767            root.join("src/vendor/app.bundle.js"),
768            WorkspaceDiagnosticKind::SkippedLargeFile {
769                size_bytes: 6 * 1024 * 1024,
770            },
771        );
772        assert_eq!(diag.kind.id(), "skipped-large-file");
773        assert!(
774            diag.message.contains("src/vendor/app.bundle.js"),
775            "message names the project-relative path: {}",
776            diag.message
777        );
778        assert!(
779            diag.message.contains("6.0 MB"),
780            "message reports the size: {}",
781            diag.message
782        );
783        assert!(
784            diag.message.contains("--max-file-size"),
785            "message names the override flag: {}",
786            diag.message
787        );
788    }
789
790    #[test]
791    fn skipped_minified_file_diagnostic_id_and_message() {
792        let root = Path::new("/project");
793        let diag = WorkspaceDiagnostic::new(
794            root,
795            root.join("src/assets/index-abc123.js"),
796            WorkspaceDiagnosticKind::SkippedMinifiedFile {
797                size_bytes: 2 * 1024 * 1024,
798            },
799        );
800        assert_eq!(diag.kind.id(), "skipped-minified-file");
801        assert!(
802            diag.message.contains("src/assets/index-abc123.js"),
803            "message names the project-relative path: {}",
804            diag.message
805        );
806        assert!(
807            diag.message.contains("2.0 MB"),
808            "message reports the size: {}",
809            diag.message
810        );
811        assert!(
812            diag.message.contains("--max-file-size 0"),
813            "message names the opt-out: {}",
814            diag.message
815        );
816    }
817
818    #[test]
819    fn skipped_source_dotdir_diagnostic_id_and_message() {
820        let root = Path::new("/project");
821        let diag = WorkspaceDiagnostic::new(
822            root,
823            root.join(".claude"),
824            WorkspaceDiagnosticKind::SkippedSourceDotdir,
825        );
826        assert_eq!(diag.kind.id(), "skipped-source-dotdir");
827        assert!(
828            diag.message.contains(".claude"),
829            "message names the project-relative path: {}",
830            diag.message
831        );
832        assert!(
833            diag.message
834                .contains("Its imports and exports are not analyzed."),
835            "message states the consequence: {}",
836            diag.message
837        );
838        assert!(
839            diag.message.contains("--root"),
840            "message names the real remedy: {}",
841            diag.message
842        );
843        assert!(
844            diag.message.contains("ignorePatterns"),
845            "message names the silencing route: {}",
846            diag.message
847        );
848        assert!(
849            diag.message.contains("no config field"),
850            "the message must say plainly that no config field traverses it: {}",
851            diag.message
852        );
853        assert_eq!(
854            serde_json::to_value(&diag).expect("serializes")["kind"],
855            "skipped-source-dotdir",
856            "id() must byte-match the serde kebab-case tag"
857        );
858    }
859
860    #[cfg(feature = "schema")]
861    #[test]
862    fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
863        let schema = schemars::schema_for!(WorkspaceDiagnostic);
864        let json = serde_json::to_string(&schema).expect("schema serializes");
865        assert!(json.contains("skipped-source-dotdir"));
866    }
867
868    #[test]
869    fn source_read_failure_serializes_typed_error_payload() {
870        let root = Path::new("/project");
871        let diagnostic = WorkspaceDiagnostic::new(
872            root,
873            root.join("src/removed.ts"),
874            WorkspaceDiagnosticKind::SourceReadFailure {
875                error: "No such file or directory".to_string(),
876            },
877        );
878
879        let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
880        assert_eq!(json["kind"], "source-read-failure");
881        assert_eq!(
882            json["path"],
883            root.join("src/removed.ts")
884                .display()
885                .to_string()
886                .replace('\\', "/")
887        );
888        assert_eq!(json["error"], "No such file or directory");
889        assert!(
890            json["message"]
891                .as_str()
892                .is_some_and(|message| message.contains("src/removed.ts"))
893        );
894    }
895
896    #[cfg(feature = "schema")]
897    #[test]
898    fn workspace_diagnostic_schema_includes_source_read_failure() {
899        let schema = schemars::schema_for!(WorkspaceDiagnostic);
900        let json = serde_json::to_string(&schema).expect("schema serializes");
901        assert!(json.contains("source-read-failure"));
902        assert!(json.contains("error"));
903    }
904
905    #[test]
906    fn bun_lockb_override_resolution_skipped_id_and_message() {
907        let root = Path::new("/project");
908        let diag = WorkspaceDiagnostic::new(
909            root,
910            root.join("package.json"),
911            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
912        );
913        assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
914        assert!(
915            diag.message.contains("'package.json'"),
916            "message names the project-relative manifest: {}",
917            diag.message
918        );
919        assert!(
920            diag.message.contains("no parseable text lockfile"),
921            "message states the cause: {}",
922            diag.message
923        );
924        assert!(
925            !diag.message.contains("only bun.lockb"),
926            "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
927             bun.lock may sit beside it: {}",
928            diag.message
929        );
930        assert!(
931            diag.message.contains("bun install --save-text-lockfile")
932                && diag.message.contains("delete the stale bun.lockb"),
933            "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
934            diag.message
935        );
936        let json = serde_json::to_value(&diag).expect("diagnostic serializes");
937        assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
938    }
939
940    #[test]
941    fn bun_override_diagnostic_ids_and_messages_are_actionable() {
942        let root = Path::new("/project");
943        let malformed = WorkspaceDiagnostic::new(
944            root,
945            root.join("bun.lock"),
946            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
947        );
948        assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
949        assert!(malformed.message.contains("regenerate"));
950
951        let shadowed = WorkspaceDiagnostic::new(
952            root,
953            root.join("package.json"),
954            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
955        );
956        assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
957        assert!(shadowed.message.contains("ignores `resolutions`"));
958    }
959
960    #[test]
961    fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
962        let root = Path::new("/project");
963        let inside = WorkspaceDiagnostic::new(
964            root,
965            root.join("packages/inner"),
966            WorkspaceDiagnosticKind::UndeclaredWorkspace,
967        )
968        .into_root_relative(root);
969        assert_eq!(inside.path, Path::new("packages/inner"));
970
971        let outside = WorkspaceDiagnostic::new(
972            root,
973            PathBuf::from("/elsewhere/packages/inner"),
974            WorkspaceDiagnosticKind::UndeclaredWorkspace,
975        )
976        .into_root_relative(root);
977        assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
978    }
979
980    #[test]
981    fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
982        let analysis_stage = [
983            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
984                error: "bad yaml".to_owned(),
985            },
986            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
987            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
988            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
989        ];
990        for kind in &analysis_stage {
991            assert!(
992                kind.is_analysis_stage() && !kind.is_source_discovery(),
993                "{} is recorded by the analyze stage only",
994                kind.id()
995            );
996        }
997
998        let other = [
999            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1000            WorkspaceDiagnosticKind::MalformedPackageJson {
1001                error: "trailing comma".to_owned(),
1002            },
1003            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1004                pattern: "packages/*".to_owned(),
1005            },
1006            WorkspaceDiagnosticKind::MalformedTsconfig {
1007                error: "unexpected token".to_owned(),
1008            },
1009            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1010            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1011            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1012            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1013            WorkspaceDiagnosticKind::SourceReadFailure {
1014                error: "permission denied".to_owned(),
1015            },
1016        ];
1017        for kind in &other {
1018            assert!(
1019                !kind.is_analysis_stage(),
1020                "{} is a discovery kind, not an analyze-stage kind",
1021                kind.id()
1022            );
1023        }
1024    }
1025
1026    #[test]
1027    fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
1028        let root = Path::new("/project");
1029        let first = WorkspaceDiagnostic::new(
1030            root,
1031            root.join("packages/aaa"),
1032            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1033                pattern: "packages/*".to_owned(),
1034            },
1035        );
1036        let second = WorkspaceDiagnostic::new(
1037            root,
1038            root.join("packages/aaa"),
1039            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1040                pattern: "packages/a*".to_owned(),
1041            },
1042        );
1043
1044        let merged =
1045            merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
1046
1047        let patterns: Vec<String> = merged
1048            .iter()
1049            .map(|diagnostic| match &diagnostic.kind {
1050                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
1051                other => panic!("unexpected kind {}", other.id()),
1052            })
1053            .collect();
1054        assert_eq!(
1055            patterns,
1056            ["packages/*", "packages/a*"],
1057            "two overlapping globs report the same directory twice, with their own pattern; \
1058             the same entry seen from two observation points still folds to one"
1059        );
1060    }
1061
1062    /// Issue #2366: a repository that declares one glob in two manifests
1063    /// (`"./apps/**"` in `package.json`, `apps/**` in `pnpm-workspace.yaml`)
1064    /// must not report every package-less directory under it twice now that the
1065    /// payload is part of the dedupe key.
1066    #[test]
1067    fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
1068        let root = Path::new("/project");
1069        let dotted = WorkspaceDiagnostic::new(
1070            root,
1071            root.join("apps/site/.next/cache"),
1072            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1073                pattern: "./apps/**".to_owned(),
1074            },
1075        );
1076        let bare = WorkspaceDiagnostic::new(
1077            root,
1078            root.join("apps/site/.next/cache"),
1079            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1080                pattern: "apps/**".to_owned(),
1081            },
1082        );
1083        assert_eq!(
1084            dotted.kind, bare.kind,
1085            "the no-op ./ prefix is normalised out of the recorded pattern"
1086        );
1087        assert!(
1088            dotted.message.contains("Glob 'apps/**'"),
1089            "the message renders the normalised pattern: {}",
1090            dotted.message
1091        );
1092
1093        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1094        assert_eq!(
1095            merged.len(),
1096            1,
1097            "one glob declared twice is one diagnostic: {merged:?}"
1098        );
1099    }
1100
1101    /// A glob spelled exactly `"./"` (the project root itself) is the one
1102    /// pattern the prefix strip must leave alone: an empty `pattern` field
1103    /// names no glob, and the warning would quote nothing.
1104    #[test]
1105    fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
1106        let root = Path::new("/project");
1107        let recorded = |pattern: &str| {
1108            let diagnostic = WorkspaceDiagnostic::new(
1109                root,
1110                root.join("pkgs"),
1111                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1112                    pattern: pattern.to_owned(),
1113                },
1114            );
1115            let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
1116            else {
1117                panic!("constructed a glob-matched-no-package-json diagnostic");
1118            };
1119            (pattern, diagnostic.message)
1120        };
1121
1122        let (root_pattern, root_message) = recorded("./");
1123        assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
1124        assert!(
1125            root_message.contains("Glob './'"),
1126            "the warning names the glob the manifest declared: {root_message}"
1127        );
1128        assert_eq!(recorded(".\\").0, ".\\");
1129        assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
1130        assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
1131    }
1132
1133    /// Issue #2366, the path half of the same repository shape: expanding
1134    /// `./pkgs/*` joins the no-op `.` into every match, so the two manifests
1135    /// hand one directory to the diagnostic under two spellings. Both must
1136    /// store, render and serialise as the bare one, otherwise whichever
1137    /// manifest was read first decides whether the analysis envelopes print
1138    /// `./pkgs/aaa` while the workspace listing envelope prints `pkgs/aaa`.
1139    #[test]
1140    fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
1141        let root = Path::new("/project");
1142        let dotted = WorkspaceDiagnostic::new(
1143            root,
1144            root.join("./pkgs/aaa"),
1145            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1146                pattern: "./pkgs/*".to_owned(),
1147            },
1148        );
1149        let bare = WorkspaceDiagnostic::new(
1150            root,
1151            root.join("pkgs/aaa"),
1152            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1153                pattern: "pkgs/*".to_owned(),
1154            },
1155        );
1156
1157        let spelling = |diagnostic: &WorkspaceDiagnostic| {
1158            diagnostic.path.display().to_string().replace('\\', "/")
1159        };
1160        assert_eq!(
1161            spelling(&dotted),
1162            "/project/pkgs/aaa",
1163            "the stored path drops the no-op . component, which Path equality \
1164             hides but serialization does not"
1165        );
1166        assert_eq!(spelling(&dotted), spelling(&bare));
1167        assert_eq!(
1168            spelling(&dotted.clone().into_root_relative(root)),
1169            "pkgs/aaa"
1170        );
1171
1172        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1173        assert_eq!(
1174            merged.len(),
1175            1,
1176            "one directory reached through two spellings of one glob: {merged:?}"
1177        );
1178    }
1179
1180    /// The single-list fold applied at workspace discovery keeps one entry per
1181    /// `(kind, path)` and leaves distinct payloads alone.
1182    #[test]
1183    fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
1184        let root = Path::new("/project");
1185        let glob = |pattern: &str, relative: &str| {
1186            WorkspaceDiagnostic::new(
1187                root,
1188                root.join(relative),
1189                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1190                    pattern: pattern.to_owned(),
1191                },
1192            )
1193        };
1194
1195        let deduped = dedupe_workspace_diagnostics(vec![
1196            glob("pkgs/*", "pkgs/aaa"),
1197            glob("pkgs/*", "pkgs/bbb"),
1198            glob("./pkgs/*", "./pkgs/aaa"),
1199            glob("pkgs/a*", "pkgs/aaa"),
1200        ]);
1201
1202        let reported: Vec<(String, String)> = deduped
1203            .iter()
1204            .map(|diagnostic| match &diagnostic.kind {
1205                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
1206                    pattern.clone(),
1207                    diagnostic.path.display().to_string().replace('\\', "/"),
1208                ),
1209                other => panic!("unexpected kind {}", other.id()),
1210            })
1211            .collect();
1212
1213        assert_eq!(
1214            reported,
1215            vec![
1216                ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
1217                ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
1218                ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
1219            ],
1220            "the duplicate spelling folds away and the overlapping glob stays"
1221        );
1222    }
1223
1224    /// The class `reachability_caveats[]` is computed from. Every kind here
1225    /// means the run never read a file that is part of the project, so its
1226    /// imports credit nothing and the modules it imports can be reported
1227    /// unused with a removal action on them. Classifying a kind `true` is the
1228    /// only wiring its findings need to inherit the caveat and the `fallow fix`
1229    /// withholding that follows it.
1230    #[test]
1231    fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
1232        for kind in [
1233            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1234            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1235            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1236            WorkspaceDiagnosticKind::SourceReadFailure {
1237                error: "permission denied".to_owned(),
1238            },
1239        ] {
1240            assert!(
1241                kind.source_never_analyzed(),
1242                "{} names a source file this run never read",
1243                kind.id()
1244            );
1245        }
1246
1247        let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
1248            error_count: 3,
1249            panicked: false,
1250        };
1251        assert!(
1252            !degraded.source_never_analyzed(),
1253            "a degraded parse read the file, so it has a graph node and its reachability is \
1254             observable; the caveat pass narrows it instead of treating it as unread"
1255        );
1256
1257        for kind in [
1258            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1259            WorkspaceDiagnosticKind::MalformedPackageJson {
1260                error: "trailing comma".to_owned(),
1261            },
1262            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1263                pattern: "packages/*".to_owned(),
1264            },
1265            WorkspaceDiagnosticKind::MalformedTsconfig {
1266                error: "unexpected token".to_owned(),
1267            },
1268            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1269            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1270                error: "bad indent".to_owned(),
1271            },
1272            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1273            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1274            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1275            WorkspaceDiagnosticKind::NodeModulesMissing,
1276            WorkspaceDiagnosticKind::BoundariesNotConfigured,
1277            WorkspaceDiagnosticKind::RulePacksNotConfigured,
1278        ] {
1279            assert!(
1280                !kind.source_never_analyzed(),
1281                "{} says nothing about a source file's imports going unseen",
1282                kind.id()
1283            );
1284        }
1285    }
1286
1287    #[test]
1288    fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
1289        for kind in [
1290            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1291            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1292            WorkspaceDiagnosticKind::SkippedSourceDotdir,
1293        ] {
1294            assert!(
1295                kind.is_source_walk_recorded() && kind.is_source_discovery(),
1296                "{} is written by the source walk",
1297                kind.id()
1298            );
1299        }
1300
1301        let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
1302            error: "permission denied".to_owned(),
1303        };
1304        assert!(
1305            read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
1306            "the parse stage records source-read-failure after the walk, so it must keep \
1307             reaching sessions through the registry"
1308        );
1309
1310        for kind in [
1311            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1312            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1313            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1314            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1315            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1316        ] {
1317            assert!(
1318                !kind.is_source_walk_recorded(),
1319                "{} is not written by the source walk",
1320                kind.id()
1321            );
1322        }
1323    }
1324
1325    #[test]
1326    fn format_size_mb_one_decimal() {
1327        assert_eq!(format_size_mb(0), "0.0 MB");
1328        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1329        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1330    }
1331
1332    #[test]
1333    fn undeclared_workspace_message_has_next_step() {
1334        let root = Path::new("/project");
1335        let diag = WorkspaceDiagnostic::new(
1336            root,
1337            root.join("packages/legacy"),
1338            WorkspaceDiagnosticKind::UndeclaredWorkspace,
1339        );
1340        assert_eq!(diag.kind.id(), "undeclared-workspace");
1341        assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
1342        assert!(
1343            diag.message.contains("ignorePatterns"),
1344            "next-step hint preserved: {}",
1345            diag.message
1346        );
1347    }
1348}