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 7-variant `kind` oneOf) in
11//! `docs/output-schema.json` without coupling output contracts to config
12//! loading.
13
14use std::path::{Path, PathBuf};
15
16#[cfg(feature = "schema")]
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::serde_path;
21
22/// Why a workspace-discovery candidate was rejected, or why a sibling
23/// directory looked workspace-like but was not declared.
24///
25/// Wire-format names are kebab-case so JSON consumers (CI integrations, MCP
26/// agents, LSP clients) get a stable, language-neutral identifier.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
28#[cfg_attr(feature = "schema", derive(JsonSchema))]
29#[serde(tag = "kind", rename_all = "kebab-case")]
30pub enum WorkspaceDiagnosticKind {
31    /// A directory contains `package.json` but is not declared as a workspace
32    /// in `package.json` `workspaces`, `pnpm-workspace.yaml`, or
33    /// `tsconfig.json` `references`. Surfaced by
34    /// `find_undeclared_workspaces`.
35    UndeclaredWorkspace,
36    /// A declared workspace's `package.json` failed to parse. The directory is
37    /// dropped from discovery, but analysis still proceeds (degraded).
38    MalformedPackageJson {
39        /// `serde_json` parse error text.
40        error: String,
41    },
42    /// A workspace glob pattern matched a directory that contains no
43    /// `package.json`. Honors the extended skip list and `ignorePatterns`
44    /// before emitting.
45    GlobMatchedNoPackageJson {
46        /// The glob pattern that matched the directory.
47        pattern: String,
48    },
49    /// `tsconfig.json` exists at the root but failed to parse. Project
50    /// references cannot be discovered.
51    MalformedTsconfig {
52        /// JSONC parse error text.
53        error: String,
54    },
55    /// `tsconfig.json` lists a `references[].path` that does not point to an
56    /// existing directory.
57    TsconfigReferenceDirMissing,
58    /// A source file was skipped at discovery because it exceeds the configured
59    /// per-file size limit (`--max-file-size` / `FALLOW_MAX_FILE_SIZE`, default
60    /// 5 MB). The file is never read, parsed, or analyzed, guarding against the
61    /// out-of-memory blowup a single multi-MB generated/vendored/bundled file
62    /// causes (issue #1086). Surfaced by source discovery, not workspace
63    /// discovery, but shares this channel so the skip is visible in
64    /// `workspace_diagnostics[]` on `fallow dead-code / dupes / health` JSON.
65    SkippedLargeFile {
66        /// On-disk size of the skipped file in bytes.
67        size_bytes: u64,
68    },
69    /// A large JavaScript bundle was skipped at discovery because it appears to
70    /// be minified generated output. The file is never parsed or analyzed,
71    /// guarding against sub-limit bundles that can still create very large ASTs
72    /// and extraction payloads (issue #1086). Use `--max-file-size 0` when the
73    /// bundled file really should be analyzed.
74    SkippedMinifiedFile {
75        /// On-disk size of the skipped file in bytes.
76        size_bytes: u64,
77    },
78}
79
80impl WorkspaceDiagnosticKind {
81    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
82    #[must_use]
83    pub const fn id(&self) -> &'static str {
84        match self {
85            Self::UndeclaredWorkspace => "undeclared-workspace",
86            Self::MalformedPackageJson { .. } => "malformed-package-json",
87            Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
88            Self::MalformedTsconfig { .. } => "malformed-tsconfig",
89            Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
90            Self::SkippedLargeFile { .. } => "skipped-large-file",
91            Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
92        }
93    }
94
95    /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
96    /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
97    /// discovery diagnostics are APPENDED to the registry after config load, so
98    /// `stash_workspace_diagnostics` must preserve them when it replaces the
99    /// workspace-discovery set, otherwise the per-analysis config re-loads in
100    /// combined-mode (`fallow` with no subcommand re-loads config for check,
101    /// dupes, and health) wipe them before the JSON envelope is built (issue
102    /// #1086).
103    #[must_use]
104    pub const fn is_source_discovery(&self) -> bool {
105        matches!(
106            self,
107            Self::SkippedLargeFile { .. } | Self::SkippedMinifiedFile { .. }
108        )
109    }
110}
111
112/// Render a byte count as a megabyte figure with one decimal place for
113/// human-readable diagnostic messages (e.g. `12.3 MB`).
114#[must_use]
115fn format_size_mb(bytes: u64) -> String {
116    #[expect(
117        clippy::cast_precision_loss,
118        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
119    )]
120    let mb = bytes as f64 / (1024.0 * 1024.0);
121    format!("{mb:.1} MB")
122}
123
124/// A diagnostic about a workspace-discovery candidate.
125///
126/// The `message` field is a human-readable rendering derived from `kind`. It
127/// always ends with a concrete next step ("fix the JSON syntax", "remove from
128/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
129/// forward.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "schema", derive(JsonSchema))]
132pub struct WorkspaceDiagnostic {
133    /// Path to the directory or file that triggered the diagnostic.
134    #[serde(serialize_with = "serde_path::serialize")]
135    pub path: PathBuf,
136    /// Kind discriminator with the typed payload.
137    #[serde(flatten)]
138    pub kind: WorkspaceDiagnosticKind,
139    /// Human-readable rendering derived from `kind` + `path`. Always ends
140    /// with a next-step hint.
141    pub message: String,
142}
143
144impl WorkspaceDiagnostic {
145    /// Construct a diagnostic with the message rendered from `kind` + `path`.
146    ///
147    /// `root` is used to produce project-relative paths in the message text
148    /// AND inside the variant payload (e.g. the `error` field of
149    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
150    /// file path from `PackageJson::load()`'s error text). Without the
151    /// payload-side normalisation the embedded path would survive
152    /// environment-specific differences (CI vs Docker vs local) because the
153    /// post-serialisation `strip_root_prefix` only catches whole-string
154    /// matches, not paths embedded mid-sentence.
155    ///
156    /// If `path` is not under `root` (e.g. canonicalisation crossed a
157    /// symlink), the absolute path is emitted instead.
158    #[must_use]
159    pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
160        let kind = normalise_payload_paths(root, kind);
161        let message = render_message(root, &path, &kind);
162        Self {
163            path,
164            kind,
165            message,
166        }
167    }
168}
169
170/// Strip the project root from absolute paths embedded inside variant
171/// payloads (today: the `error` field of `MalformedPackageJson` and
172/// `MalformedTsconfig`). Mirrors the per-platform `display()` byte sequence
173/// so the substring match works on Windows too.
174fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
175    let root_str = root.display().to_string();
176    let root_alt = root_str.replace('\\', "/");
177    let normalise = |text: String| -> String {
178        let stripped = text
179            .replace(&format!("{root_str}/"), "")
180            .replace(&format!("{root_alt}/"), "");
181        stripped
182            .replace(&format!("{root_str}\\"), "")
183            .replace(&format!("{root_alt}\\"), "")
184    };
185    match kind {
186        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
187            WorkspaceDiagnosticKind::MalformedPackageJson {
188                error: normalise(error),
189            }
190        }
191        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
192            WorkspaceDiagnosticKind::MalformedTsconfig {
193                error: normalise(error),
194            }
195        }
196        other => other,
197    }
198}
199
200/// Render `path` relative to `root` with forward slashes. The forward-slash
201/// normalisation is load-bearing for cross-platform output stability.
202fn display_relative(root: &Path, path: &Path) -> String {
203    path.strip_prefix(root)
204        .unwrap_or(path)
205        .display()
206        .to_string()
207        .replace('\\', "/")
208}
209
210fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
211    let display = display_relative(root, path);
212    match kind {
213        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
214            "Directory '{display}' contains package.json but is not declared as a workspace. \
215             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
216        ),
217        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
218            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
219             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
220        ),
221        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
222            "Glob '{pattern}' matched '{display}' but no package.json is present. \
223             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
224        ),
225        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
226            "tsconfig.json at '{display}' failed to parse ({error}); \
227             project references will be ignored. Fix the JSON syntax."
228        ),
229        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
230            "tsconfig.json references '{display}' but the directory does not exist. \
231             Update or remove the reference, or restore the missing directory."
232        ),
233        WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
234            "Skipped '{display}' ({size}): exceeds the max file size limit. \
235             Its imports and exports are not analyzed. Raise the limit with \
236             --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
237             to ignorePatterns.",
238            size = format_size_mb(*size_bytes)
239        ),
240        WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
241            "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
242             Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
243             rename it with a .min.js suffix, or use --max-file-size 0 if this file \
244             should be analyzed.",
245            size = format_size_mb(*size_bytes)
246        ),
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn skipped_large_file_diagnostic_id_and_message() {
256        let root = Path::new("/project");
257        let diag = WorkspaceDiagnostic::new(
258            root,
259            root.join("src/vendor/app.bundle.js"),
260            WorkspaceDiagnosticKind::SkippedLargeFile {
261                size_bytes: 6 * 1024 * 1024,
262            },
263        );
264        assert_eq!(diag.kind.id(), "skipped-large-file");
265        assert!(
266            diag.message.contains("src/vendor/app.bundle.js"),
267            "message names the project-relative path: {}",
268            diag.message
269        );
270        assert!(
271            diag.message.contains("6.0 MB"),
272            "message reports the size: {}",
273            diag.message
274        );
275        assert!(
276            diag.message.contains("--max-file-size"),
277            "message names the override flag: {}",
278            diag.message
279        );
280    }
281
282    #[test]
283    fn skipped_minified_file_diagnostic_id_and_message() {
284        let root = Path::new("/project");
285        let diag = WorkspaceDiagnostic::new(
286            root,
287            root.join("src/assets/index-abc123.js"),
288            WorkspaceDiagnosticKind::SkippedMinifiedFile {
289                size_bytes: 2 * 1024 * 1024,
290            },
291        );
292        assert_eq!(diag.kind.id(), "skipped-minified-file");
293        assert!(
294            diag.message.contains("src/assets/index-abc123.js"),
295            "message names the project-relative path: {}",
296            diag.message
297        );
298        assert!(
299            diag.message.contains("2.0 MB"),
300            "message reports the size: {}",
301            diag.message
302        );
303        assert!(
304            diag.message.contains("--max-file-size 0"),
305            "message names the opt-out: {}",
306            diag.message
307        );
308    }
309
310    #[test]
311    fn format_size_mb_one_decimal() {
312        assert_eq!(format_size_mb(0), "0.0 MB");
313        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
314        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
315    }
316
317    #[test]
318    fn undeclared_workspace_message_has_next_step() {
319        let root = Path::new("/project");
320        let diag = WorkspaceDiagnostic::new(
321            root,
322            root.join("packages/legacy"),
323            WorkspaceDiagnosticKind::UndeclaredWorkspace,
324        );
325        assert_eq!(diag.kind.id(), "undeclared-workspace");
326        assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
327        assert!(
328            diag.message.contains("ignorePatterns"),
329            "next-step hint preserved: {}",
330            diag.message
331        );
332    }
333}