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