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 source discovered with a stable [`FileId`](crate::discover::FileId)
88    /// could not be read before parsing. Analysis continues with the remaining
89    /// sparse module IDs and reports the underlying filesystem or UTF-8 error.
90    SourceReadFailure {
91        /// Filesystem or UTF-8 decoding error from `read_to_string`.
92        error: String,
93    },
94    /// Dependency-override resolution was skipped because bun's legacy binary
95    /// `bun.lockb` sits next to this `package.json`, fallow cannot read the
96    /// binary format, and no parseable text lockfile was found to use
97    /// instead: no `bun.lock` that parses, and no readable `pnpm-lock.yaml`,
98    /// `package-lock.json`, or `npm-shrinkwrap.json`. A `yarn.lock` is never
99    /// consulted (yarn ignores `overrides`), so it does not prevent the skip
100    /// either. The manifest declares overrides, so the
101    /// `unused-dependency-overrides` check would otherwise have run; without
102    /// resolution ground truth it would flag every transitive-only pin, so no
103    /// unused-override findings are reported at all (issue #2358). Surfaced
104    /// by the override analysis, not workspace discovery, but shares this
105    /// channel so the skip is visible in `workspace_diagnostics[]` JSON and
106    /// as a stderr warning.
107    BunLockbOverrideResolutionSkipped,
108    /// Dependency-override resolution was skipped because bun's text
109    /// `bun.lock` exists but could not be parsed and no readable pnpm or npm
110    /// lockfile was available as independent resolution ground truth.
111    BunLockOverrideResolutionSkipped,
112    /// A bun manifest declares both `overrides` and a non-empty `resolutions`
113    /// object. Bun applies `overrides` and ignores `resolutions`, so fallow
114    /// reports the shadowed configuration without offering removal advice.
115    BunResolutionsShadowedByOverrides,
116}
117
118impl WorkspaceDiagnosticKind {
119    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
120    #[must_use]
121    pub const fn id(&self) -> &'static str {
122        match self {
123            Self::UndeclaredWorkspace => "undeclared-workspace",
124            Self::MalformedPackageJson { .. } => "malformed-package-json",
125            Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
126            Self::MalformedTsconfig { .. } => "malformed-tsconfig",
127            Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
128            Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
129            Self::SkippedLargeFile { .. } => "skipped-large-file",
130            Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
131            Self::SourceReadFailure { .. } => "source-read-failure",
132            Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
133            Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
134            Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
135        }
136    }
137
138    /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
139    /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
140    /// discovery diagnostics are APPENDED to the registry after config load, so
141    /// `stash_workspace_diagnostics` must preserve them when it replaces the
142    /// workspace-discovery set, otherwise the per-analysis config re-loads in
143    /// combined-mode (`fallow` with no subcommand re-loads config for check,
144    /// dupes, and health) wipe them before the JSON envelope is built (issue
145    /// #1086).
146    #[must_use]
147    pub const fn is_source_discovery(&self) -> bool {
148        matches!(
149            self,
150            Self::SkippedLargeFile { .. }
151                | Self::SkippedMinifiedFile { .. }
152                | Self::SourceReadFailure { .. }
153        )
154    }
155
156    /// Whether this diagnostic is written by the source file WALK
157    /// (`discover_files`), the subset of [`Self::is_source_discovery`] that a
158    /// walk replaces wholesale for its root. `source-read-failure` is the
159    /// other source-discovery kind and is NOT one of these: the parse stage
160    /// records it after the walk, so it has to keep reaching consumers through
161    /// the registry.
162    ///
163    /// A walk-recorded entry must reach an analysis from its OWN walk's return
164    /// value. Combined mode runs the dead-code and duplication walks under
165    /// `rayon::join` whenever a per-analysis `production` split stops them from
166    /// sharing a file list, so a registry read answers "whichever walk wrote
167    /// last" and varies between runs of the same command (issue #2366).
168    #[must_use]
169    pub const fn is_source_walk_recorded(&self) -> bool {
170        matches!(
171            self,
172            Self::SkippedLargeFile { .. } | Self::SkippedMinifiedFile { .. }
173        )
174    }
175
176    /// Whether this diagnostic is recorded by the ANALYZE stage (the
177    /// dependency-catalog and override detectors) rather than by workspace or
178    /// source discovery. Analysis-stage diagnostics reach the registry through
179    /// `record_workspace_diagnostics` after config load, so
180    /// `stash_workspace_diagnostics` must preserve them across combined-mode's
181    /// per-analysis config re-loads, and every analyze pass clears its previous
182    /// entries before re-recording so a fixed cause drops out on the next run
183    /// (issue #2366). The match is exhaustive on purpose: a new kind must be
184    /// classified here before it compiles.
185    ///
186    /// Classify a kind `true` ONLY when a detector reachable from the dead-code
187    /// analyze pass (`find_dead_code_full`) re-records it, because that pass is
188    /// the single clear site. A kind recorded exclusively by another stage would
189    /// be cleared by the next dead-code pass and never come back.
190    #[must_use]
191    pub const fn is_analysis_stage(&self) -> bool {
192        match self {
193            Self::MalformedPnpmWorkspaceYaml { .. }
194            | Self::BunLockbOverrideResolutionSkipped
195            | Self::BunLockOverrideResolutionSkipped
196            | Self::BunResolutionsShadowedByOverrides => true,
197            Self::UndeclaredWorkspace
198            | Self::MalformedPackageJson { .. }
199            | Self::GlobMatchedNoPackageJson { .. }
200            | Self::MalformedTsconfig { .. }
201            | Self::TsconfigReferenceDirMissing
202            | Self::SkippedLargeFile { .. }
203            | Self::SkippedMinifiedFile { .. }
204            | Self::SourceReadFailure { .. } => false,
205        }
206    }
207}
208
209/// Render a byte count as a megabyte figure with one decimal place for
210/// human-readable diagnostic messages (e.g. `12.3 MB`).
211#[must_use]
212fn format_size_mb(bytes: u64) -> String {
213    #[expect(
214        clippy::cast_precision_loss,
215        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
216    )]
217    let mb = bytes as f64 / (1024.0 * 1024.0);
218    format!("{mb:.1} MB")
219}
220
221/// A diagnostic about a workspace-discovery candidate.
222///
223/// The `message` field is a human-readable rendering derived from `kind`. It
224/// always ends with a concrete next step ("fix the JSON syntax", "remove from
225/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
226/// forward.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[cfg_attr(feature = "schema", derive(JsonSchema))]
229pub struct WorkspaceDiagnostic {
230    /// Path to the directory or file that triggered the diagnostic.
231    #[serde(serialize_with = "serde_path::serialize")]
232    pub path: PathBuf,
233    /// Kind discriminator with the typed payload.
234    #[serde(flatten)]
235    pub kind: WorkspaceDiagnosticKind,
236    /// Human-readable rendering derived from `kind` + `path`. Always ends
237    /// with a next-step hint.
238    pub message: String,
239}
240
241impl WorkspaceDiagnostic {
242    /// Construct a diagnostic with the message rendered from `kind` + `path`.
243    ///
244    /// `root` is used to produce project-relative paths in the message text
245    /// AND inside the variant payload (e.g. the `error` field of
246    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
247    /// file path from `PackageJson::load()`'s error text). Without the
248    /// payload-side normalisation the embedded path would survive
249    /// environment-specific differences (CI vs Docker vs local) because the
250    /// post-serialisation `strip_root_prefix` only catches whole-string
251    /// matches, not paths embedded mid-sentence.
252    ///
253    /// If `path` is not under `root` (e.g. canonicalisation crossed a
254    /// symlink), the absolute path is emitted instead.
255    ///
256    /// `path` also loses any no-op `.` component, for the same reason the
257    /// payload loses a glob's `./` prefix: one directory reached through two
258    /// spellings of one glob must be one diagnostic.
259    #[must_use]
260    pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
261        let path = normalise_diagnostic_path(path);
262        let kind = normalise_payload_paths(root, kind);
263        let message = render_message(root, &path, &kind);
264        Self {
265            path,
266            kind,
267            message,
268        }
269    }
270
271    /// Return this diagnostic with `path` rewritten relative to `root`.
272    ///
273    /// `path` is stored absolute so callers can act on it. Every JSON envelope
274    /// emits it project-relative instead: the analysis envelopes get there
275    /// through the post-serialisation `strip_root_prefix` pass, which the
276    /// `fallow workspaces` / `fallow list --workspaces` envelope and the MCP
277    /// `project_info` tool never run, so those emitted the absolute path while
278    /// the sibling `workspaces[].path` next to it was relative. They normalise
279    /// at the typed layer with this method instead.
280    ///
281    /// Paths outside `root` (canonicalisation crossed a symlink) are left
282    /// absolute, matching how [`Self::new`] renders the message.
283    #[must_use]
284    pub fn into_root_relative(mut self, root: &Path) -> Self {
285        if let Ok(relative) = self.path.strip_prefix(root) {
286            self.path = relative.to_path_buf();
287        }
288        self
289    }
290}
291
292/// Rebuild `path` from its components so one directory has one spelling.
293///
294/// The dedupe key was never the problem: [`Path`] equality already ignores an
295/// interior `.`, so `<root>/./pkgs/aaa` and `<root>/pkgs/aaa` are one key. The
296/// stored bytes were. A workspace glob spelled `./pkgs/*` in `package.json`
297/// expands to the first spelling and the same glob spelled `pkgs/*` in
298/// `pnpm-workspace.yaml` expands to the second, and the two envelope families
299/// make a project-relative path differently: the analysis envelopes strip the
300/// root as a string (leaving `./pkgs/aaa`) while the workspace listing
301/// envelope uses [`WorkspaceDiagnostic::into_root_relative`] (leaving
302/// `pkgs/aaa`). Whichever
303/// manifest happened to be read first then decided which shape every consumer
304/// saw. Collapsing at construction gives them one answer (issue #2366).
305///
306/// A path that is already component-clean rebuilds to itself. Serialization
307/// normalises separators, so the rebuild is wire-invisible on Windows.
308fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
309    let rebuilt: PathBuf = path.components().collect();
310    if rebuilt.as_os_str() == path.as_os_str() {
311        path
312    } else {
313        rebuilt
314    }
315}
316
317/// Strip the project root from absolute paths embedded inside variant
318/// payloads (the `error` field of malformed-config and source-read failures),
319/// and drop a glob pattern's no-op `./` prefix.
320///
321/// Mirrors the per-platform `display()` byte sequence so the substring match
322/// works on Windows too.
323///
324/// The pattern prefix matters because the payload is part of the dedupe key in
325/// [`merge_workspace_diagnostics`]. A repository whose `package.json` declares
326/// `"./apps/**"` and whose `pnpm-workspace.yaml` declares `apps/**` names one
327/// glob twice, and without this both spellings would report every package-less
328/// directory under `apps/` a second time (issue #2366).
329fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
330    let root_str = root.display().to_string();
331    let root_alt = root_str.replace('\\', "/");
332    let normalise = |text: String| -> String {
333        let stripped = text
334            .replace(&format!("{root_str}/"), "")
335            .replace(&format!("{root_alt}/"), "");
336        stripped
337            .replace(&format!("{root_str}\\"), "")
338            .replace(&format!("{root_alt}\\"), "")
339    };
340    match kind {
341        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
342            WorkspaceDiagnosticKind::MalformedPackageJson {
343                error: normalise(error),
344            }
345        }
346        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
347            WorkspaceDiagnosticKind::MalformedTsconfig {
348                error: normalise(error),
349            }
350        }
351        WorkspaceDiagnosticKind::SourceReadFailure { error } => {
352            WorkspaceDiagnosticKind::SourceReadFailure {
353                error: normalise(error),
354            }
355        }
356        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
357            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
358                pattern: canonical_glob_pattern(pattern),
359            }
360        }
361        other => other,
362    }
363}
364
365/// Drop the leading `./` (or `.\`) a workspace glob may carry, so the same
366/// pattern declared in two manifests is one payload.
367///
368/// A pattern that is nothing BUT the prefix (`"./"`, the root itself) keeps
369/// its spelling: stripping it would report an empty `pattern` field and an
370/// empty quoted glob in the warning text, which names no glob at all.
371fn canonical_glob_pattern(pattern: String) -> String {
372    for prefix in ["./", ".\\"] {
373        if let Some(rest) = pattern.strip_prefix(prefix)
374            && !rest.is_empty()
375        {
376            return rest.to_owned();
377        }
378    }
379    pattern
380}
381
382/// Concatenate two diagnostic lists, keeping the first occurrence of each
383/// `(kind, path)` pair and the order of `primary` followed by the entries only
384/// `secondary` has.
385///
386/// The single place diagnostics from two observation points are folded
387/// together: an engine session's own capture plus the process registry, and
388/// the combined run's per-analysis lists (issue #2366). A combined run walks
389/// the project once per analysis, and per-analysis `production` modes can make
390/// those walks see different file sets, so no single observation point holds
391/// everything the run recorded; the union does, and folding it the same way
392/// everywhere is what keeps the CLI and the programmatic route answering
393/// identically.
394///
395/// The key is the WHOLE kind, payload included, not its
396/// [`id`](WorkspaceDiagnosticKind::id). Two entries can share a kind id and a
397/// path and still be two distinct diagnostics: overlapping workspace globs
398/// (`["packages/*", "packages/*/*"]`) each report the same package-less
399/// directory with their own `pattern`, and the standalone envelopes report
400/// both. An id-keyed fold silently dropped the second one.
401#[must_use]
402pub fn merge_workspace_diagnostics(
403    primary: Vec<WorkspaceDiagnostic>,
404    secondary: Vec<WorkspaceDiagnostic>,
405) -> Vec<WorkspaceDiagnostic> {
406    let mut merged = Vec::with_capacity(primary.len() + secondary.len());
407    let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
408    for diagnostic in primary.into_iter().chain(secondary) {
409        let key = (diagnostic.kind.clone(), diagnostic.path.clone());
410        if seen.insert(key) {
411            merged.push(diagnostic);
412        }
413    }
414    merged
415}
416
417/// Keep the first occurrence of each `(kind, path)` pair in one list.
418///
419/// The single-list form of [`merge_workspace_diagnostics`], applied where
420/// diagnostics are produced rather than where two observation points are
421/// folded: workspace discovery reads `package.json` `workspaces`,
422/// `pnpm-workspace.yaml` `packages`, `deno.json` `workspace` and the root
423/// `tsconfig.json` references additively, so a repository that declares one
424/// glob in two of them reports every package-less directory under it twice.
425/// Deduplicating at that source is what keeps the JSON envelopes, the
426/// aggregated stderr warning and the process registry telling one story
427/// (issue #2366).
428#[must_use]
429pub fn dedupe_workspace_diagnostics(
430    diagnostics: Vec<WorkspaceDiagnostic>,
431) -> Vec<WorkspaceDiagnostic> {
432    merge_workspace_diagnostics(diagnostics, Vec::new())
433}
434
435/// Render `path` relative to `root` with forward slashes. The forward-slash
436/// normalisation is load-bearing for cross-platform output stability.
437fn display_relative(root: &Path, path: &Path) -> String {
438    path.strip_prefix(root)
439        .unwrap_or(path)
440        .display()
441        .to_string()
442        .replace('\\', "/")
443}
444
445fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
446    let display = display_relative(root, path);
447    match kind {
448        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
449            "Directory '{display}' contains package.json but is not declared as a workspace. \
450             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
451        ),
452        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
453            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
454             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
455        ),
456        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
457            "Glob '{pattern}' matched '{display}' but no package.json is present. \
458             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
459        ),
460        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
461            "tsconfig.json at '{display}' failed to parse ({error}); \
462             project references will be ignored. Fix the JSON syntax."
463        ),
464        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
465            "tsconfig.json references '{display}' but the directory does not exist. \
466             Update or remove the reference, or restore the missing directory."
467        ),
468        WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
469            "'{display}' failed to parse ({error}); catalog and override entries \
470             will be ignored. Fix the YAML syntax."
471        ),
472        WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
473            "Skipped '{display}' ({size}): exceeds the max file size limit. \
474             Its imports and exports are not analyzed. Raise the limit with \
475             --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
476             to ignorePatterns.",
477            size = format_size_mb(*size_bytes)
478        ),
479        WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
480            "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
481             Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
482             rename it with a .min.js suffix, or use --max-file-size 0 if this file \
483             should be analyzed.",
484            size = format_size_mb(*size_bytes)
485        ),
486        WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
487            "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
488             ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
489        ),
490        WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
491            "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
492             sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
493             (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
494             use instead, so unused-dependency-overrides findings are not reported. Run bun install \
495             --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
496             bun.lockb if this repository no longer uses bun."
497        ),
498        WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
499            "Skipped dependency-override resolution because '{display}' could not be parsed and \
500             no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
501             findings are not reported. Run bun install to regenerate the text lockfile, then \
502             rerun fallow."
503        ),
504        WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
505            "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
506             `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
507             remove the shadowed `resolutions` entries."
508        ),
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn skipped_large_file_diagnostic_id_and_message() {
518        let root = Path::new("/project");
519        let diag = WorkspaceDiagnostic::new(
520            root,
521            root.join("src/vendor/app.bundle.js"),
522            WorkspaceDiagnosticKind::SkippedLargeFile {
523                size_bytes: 6 * 1024 * 1024,
524            },
525        );
526        assert_eq!(diag.kind.id(), "skipped-large-file");
527        assert!(
528            diag.message.contains("src/vendor/app.bundle.js"),
529            "message names the project-relative path: {}",
530            diag.message
531        );
532        assert!(
533            diag.message.contains("6.0 MB"),
534            "message reports the size: {}",
535            diag.message
536        );
537        assert!(
538            diag.message.contains("--max-file-size"),
539            "message names the override flag: {}",
540            diag.message
541        );
542    }
543
544    #[test]
545    fn skipped_minified_file_diagnostic_id_and_message() {
546        let root = Path::new("/project");
547        let diag = WorkspaceDiagnostic::new(
548            root,
549            root.join("src/assets/index-abc123.js"),
550            WorkspaceDiagnosticKind::SkippedMinifiedFile {
551                size_bytes: 2 * 1024 * 1024,
552            },
553        );
554        assert_eq!(diag.kind.id(), "skipped-minified-file");
555        assert!(
556            diag.message.contains("src/assets/index-abc123.js"),
557            "message names the project-relative path: {}",
558            diag.message
559        );
560        assert!(
561            diag.message.contains("2.0 MB"),
562            "message reports the size: {}",
563            diag.message
564        );
565        assert!(
566            diag.message.contains("--max-file-size 0"),
567            "message names the opt-out: {}",
568            diag.message
569        );
570    }
571
572    #[test]
573    fn source_read_failure_serializes_typed_error_payload() {
574        let root = Path::new("/project");
575        let diagnostic = WorkspaceDiagnostic::new(
576            root,
577            root.join("src/removed.ts"),
578            WorkspaceDiagnosticKind::SourceReadFailure {
579                error: "No such file or directory".to_string(),
580            },
581        );
582
583        let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
584        assert_eq!(json["kind"], "source-read-failure");
585        assert_eq!(
586            json["path"],
587            root.join("src/removed.ts")
588                .display()
589                .to_string()
590                .replace('\\', "/")
591        );
592        assert_eq!(json["error"], "No such file or directory");
593        assert!(
594            json["message"]
595                .as_str()
596                .is_some_and(|message| message.contains("src/removed.ts"))
597        );
598    }
599
600    #[cfg(feature = "schema")]
601    #[test]
602    fn workspace_diagnostic_schema_includes_source_read_failure() {
603        let schema = schemars::schema_for!(WorkspaceDiagnostic);
604        let json = serde_json::to_string(&schema).expect("schema serializes");
605        assert!(json.contains("source-read-failure"));
606        assert!(json.contains("error"));
607    }
608
609    #[test]
610    fn bun_lockb_override_resolution_skipped_id_and_message() {
611        let root = Path::new("/project");
612        let diag = WorkspaceDiagnostic::new(
613            root,
614            root.join("package.json"),
615            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
616        );
617        assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
618        assert!(
619            diag.message.contains("'package.json'"),
620            "message names the project-relative manifest: {}",
621            diag.message
622        );
623        assert!(
624            diag.message.contains("no parseable text lockfile"),
625            "message states the cause: {}",
626            diag.message
627        );
628        assert!(
629            !diag.message.contains("only bun.lockb"),
630            "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
631             bun.lock may sit beside it: {}",
632            diag.message
633        );
634        assert!(
635            diag.message.contains("bun install --save-text-lockfile")
636                && diag.message.contains("delete the stale bun.lockb"),
637            "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
638            diag.message
639        );
640        let json = serde_json::to_value(&diag).expect("diagnostic serializes");
641        assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
642    }
643
644    #[test]
645    fn bun_override_diagnostic_ids_and_messages_are_actionable() {
646        let root = Path::new("/project");
647        let malformed = WorkspaceDiagnostic::new(
648            root,
649            root.join("bun.lock"),
650            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
651        );
652        assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
653        assert!(malformed.message.contains("regenerate"));
654
655        let shadowed = WorkspaceDiagnostic::new(
656            root,
657            root.join("package.json"),
658            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
659        );
660        assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
661        assert!(shadowed.message.contains("ignores `resolutions`"));
662    }
663
664    #[test]
665    fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
666        let root = Path::new("/project");
667        let inside = WorkspaceDiagnostic::new(
668            root,
669            root.join("packages/inner"),
670            WorkspaceDiagnosticKind::UndeclaredWorkspace,
671        )
672        .into_root_relative(root);
673        assert_eq!(inside.path, Path::new("packages/inner"));
674
675        let outside = WorkspaceDiagnostic::new(
676            root,
677            PathBuf::from("/elsewhere/packages/inner"),
678            WorkspaceDiagnosticKind::UndeclaredWorkspace,
679        )
680        .into_root_relative(root);
681        assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
682    }
683
684    #[test]
685    fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
686        let analysis_stage = [
687            WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
688                error: "bad yaml".to_owned(),
689            },
690            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
691            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
692            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
693        ];
694        for kind in &analysis_stage {
695            assert!(
696                kind.is_analysis_stage() && !kind.is_source_discovery(),
697                "{} is recorded by the analyze stage only",
698                kind.id()
699            );
700        }
701
702        let other = [
703            WorkspaceDiagnosticKind::UndeclaredWorkspace,
704            WorkspaceDiagnosticKind::MalformedPackageJson {
705                error: "trailing comma".to_owned(),
706            },
707            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
708                pattern: "packages/*".to_owned(),
709            },
710            WorkspaceDiagnosticKind::MalformedTsconfig {
711                error: "unexpected token".to_owned(),
712            },
713            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
714            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
715            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
716            WorkspaceDiagnosticKind::SourceReadFailure {
717                error: "permission denied".to_owned(),
718            },
719        ];
720        for kind in &other {
721            assert!(
722                !kind.is_analysis_stage(),
723                "{} is a discovery kind, not an analyze-stage kind",
724                kind.id()
725            );
726        }
727    }
728
729    #[test]
730    fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
731        let root = Path::new("/project");
732        let first = WorkspaceDiagnostic::new(
733            root,
734            root.join("packages/aaa"),
735            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
736                pattern: "packages/*".to_owned(),
737            },
738        );
739        let second = WorkspaceDiagnostic::new(
740            root,
741            root.join("packages/aaa"),
742            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
743                pattern: "packages/a*".to_owned(),
744            },
745        );
746
747        let merged =
748            merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
749
750        let patterns: Vec<String> = merged
751            .iter()
752            .map(|diagnostic| match &diagnostic.kind {
753                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
754                other => panic!("unexpected kind {}", other.id()),
755            })
756            .collect();
757        assert_eq!(
758            patterns,
759            ["packages/*", "packages/a*"],
760            "two overlapping globs report the same directory twice, with their own pattern; \
761             the same entry seen from two observation points still folds to one"
762        );
763    }
764
765    /// Issue #2366: a repository that declares one glob in two manifests
766    /// (`"./apps/**"` in `package.json`, `apps/**` in `pnpm-workspace.yaml`)
767    /// must not report every package-less directory under it twice now that the
768    /// payload is part of the dedupe key.
769    #[test]
770    fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
771        let root = Path::new("/project");
772        let dotted = WorkspaceDiagnostic::new(
773            root,
774            root.join("apps/site/.next/cache"),
775            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
776                pattern: "./apps/**".to_owned(),
777            },
778        );
779        let bare = WorkspaceDiagnostic::new(
780            root,
781            root.join("apps/site/.next/cache"),
782            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
783                pattern: "apps/**".to_owned(),
784            },
785        );
786        assert_eq!(
787            dotted.kind, bare.kind,
788            "the no-op ./ prefix is normalised out of the recorded pattern"
789        );
790        assert!(
791            dotted.message.contains("Glob 'apps/**'"),
792            "the message renders the normalised pattern: {}",
793            dotted.message
794        );
795
796        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
797        assert_eq!(
798            merged.len(),
799            1,
800            "one glob declared twice is one diagnostic: {merged:?}"
801        );
802    }
803
804    /// A glob spelled exactly `"./"` (the project root itself) is the one
805    /// pattern the prefix strip must leave alone: an empty `pattern` field
806    /// names no glob, and the warning would quote nothing.
807    #[test]
808    fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
809        let root = Path::new("/project");
810        let recorded = |pattern: &str| {
811            let diagnostic = WorkspaceDiagnostic::new(
812                root,
813                root.join("pkgs"),
814                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
815                    pattern: pattern.to_owned(),
816                },
817            );
818            let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
819            else {
820                panic!("constructed a glob-matched-no-package-json diagnostic");
821            };
822            (pattern, diagnostic.message)
823        };
824
825        let (root_pattern, root_message) = recorded("./");
826        assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
827        assert!(
828            root_message.contains("Glob './'"),
829            "the warning names the glob the manifest declared: {root_message}"
830        );
831        assert_eq!(recorded(".\\").0, ".\\");
832        assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
833        assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
834    }
835
836    /// Issue #2366, the path half of the same repository shape: expanding
837    /// `./pkgs/*` joins the no-op `.` into every match, so the two manifests
838    /// hand one directory to the diagnostic under two spellings. Both must
839    /// store, render and serialise as the bare one, otherwise whichever
840    /// manifest was read first decides whether the analysis envelopes print
841    /// `./pkgs/aaa` while the workspace listing envelope prints `pkgs/aaa`.
842    #[test]
843    fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
844        let root = Path::new("/project");
845        let dotted = WorkspaceDiagnostic::new(
846            root,
847            root.join("./pkgs/aaa"),
848            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
849                pattern: "./pkgs/*".to_owned(),
850            },
851        );
852        let bare = WorkspaceDiagnostic::new(
853            root,
854            root.join("pkgs/aaa"),
855            WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
856                pattern: "pkgs/*".to_owned(),
857            },
858        );
859
860        let spelling = |diagnostic: &WorkspaceDiagnostic| {
861            diagnostic.path.display().to_string().replace('\\', "/")
862        };
863        assert_eq!(
864            spelling(&dotted),
865            "/project/pkgs/aaa",
866            "the stored path drops the no-op . component, which Path equality \
867             hides but serialization does not"
868        );
869        assert_eq!(spelling(&dotted), spelling(&bare));
870        assert_eq!(
871            spelling(&dotted.clone().into_root_relative(root)),
872            "pkgs/aaa"
873        );
874
875        let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
876        assert_eq!(
877            merged.len(),
878            1,
879            "one directory reached through two spellings of one glob: {merged:?}"
880        );
881    }
882
883    /// The single-list fold applied at workspace discovery keeps one entry per
884    /// `(kind, path)` and leaves distinct payloads alone.
885    #[test]
886    fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
887        let root = Path::new("/project");
888        let glob = |pattern: &str, relative: &str| {
889            WorkspaceDiagnostic::new(
890                root,
891                root.join(relative),
892                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
893                    pattern: pattern.to_owned(),
894                },
895            )
896        };
897
898        let deduped = dedupe_workspace_diagnostics(vec![
899            glob("pkgs/*", "pkgs/aaa"),
900            glob("pkgs/*", "pkgs/bbb"),
901            glob("./pkgs/*", "./pkgs/aaa"),
902            glob("pkgs/a*", "pkgs/aaa"),
903        ]);
904
905        let reported: Vec<(String, String)> = deduped
906            .iter()
907            .map(|diagnostic| match &diagnostic.kind {
908                WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
909                    pattern.clone(),
910                    diagnostic.path.display().to_string().replace('\\', "/"),
911                ),
912                other => panic!("unexpected kind {}", other.id()),
913            })
914            .collect();
915
916        assert_eq!(
917            reported,
918            vec![
919                ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
920                ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
921                ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
922            ],
923            "the duplicate spelling folds away and the overlapping glob stays"
924        );
925    }
926
927    #[test]
928    fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
929        for kind in [
930            WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
931            WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
932        ] {
933            assert!(
934                kind.is_source_walk_recorded() && kind.is_source_discovery(),
935                "{} is written by the source walk",
936                kind.id()
937            );
938        }
939
940        let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
941            error: "permission denied".to_owned(),
942        };
943        assert!(
944            read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
945            "the parse stage records source-read-failure after the walk, so it must keep \
946             reaching sessions through the registry"
947        );
948
949        for kind in [
950            WorkspaceDiagnosticKind::UndeclaredWorkspace,
951            WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
952            WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
953            WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
954            WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
955        ] {
956            assert!(
957                !kind.is_source_walk_recorded(),
958                "{} is not written by the source walk",
959                kind.id()
960            );
961        }
962    }
963
964    #[test]
965    fn format_size_mb_one_decimal() {
966        assert_eq!(format_size_mb(0), "0.0 MB");
967        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
968        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
969    }
970
971    #[test]
972    fn undeclared_workspace_message_has_next_step() {
973        let root = Path::new("/project");
974        let diag = WorkspaceDiagnostic::new(
975            root,
976            root.join("packages/legacy"),
977            WorkspaceDiagnosticKind::UndeclaredWorkspace,
978        );
979        assert_eq!(diag.kind.id(), "undeclared-workspace");
980        assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
981        assert!(
982            diag.message.contains("ignorePatterns"),
983            "next-step hint preserved: {}",
984            diag.message
985        );
986    }
987}