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
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    /// A source discovered with a stable [`FileId`](crate::discover::FileId)
79    /// could not be read before parsing. Analysis continues with the remaining
80    /// sparse module IDs and reports the underlying filesystem or UTF-8 error.
81    SourceReadFailure {
82        /// Filesystem or UTF-8 decoding error from `read_to_string`.
83        error: String,
84    },
85}
86
87impl WorkspaceDiagnosticKind {
88    /// Stable kebab-case identifier used in dedupe keys and tracing payloads.
89    #[must_use]
90    pub const fn id(&self) -> &'static str {
91        match self {
92            Self::UndeclaredWorkspace => "undeclared-workspace",
93            Self::MalformedPackageJson { .. } => "malformed-package-json",
94            Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
95            Self::MalformedTsconfig { .. } => "malformed-tsconfig",
96            Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
97            Self::SkippedLargeFile { .. } => "skipped-large-file",
98            Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
99            Self::SourceReadFailure { .. } => "source-read-failure",
100        }
101    }
102
103    /// Whether this diagnostic is produced by SOURCE discovery (the file walk in
104    /// `discover_files`) rather than WORKSPACE discovery (config load). Source-
105    /// discovery diagnostics are APPENDED to the registry after config load, so
106    /// `stash_workspace_diagnostics` must preserve them when it replaces the
107    /// workspace-discovery set, otherwise the per-analysis config re-loads in
108    /// combined-mode (`fallow` with no subcommand re-loads config for check,
109    /// dupes, and health) wipe them before the JSON envelope is built (issue
110    /// #1086).
111    #[must_use]
112    pub const fn is_source_discovery(&self) -> bool {
113        matches!(
114            self,
115            Self::SkippedLargeFile { .. }
116                | Self::SkippedMinifiedFile { .. }
117                | Self::SourceReadFailure { .. }
118        )
119    }
120}
121
122/// Render a byte count as a megabyte figure with one decimal place for
123/// human-readable diagnostic messages (e.g. `12.3 MB`).
124#[must_use]
125fn format_size_mb(bytes: u64) -> String {
126    #[expect(
127        clippy::cast_precision_loss,
128        reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
129    )]
130    let mb = bytes as f64 / (1024.0 * 1024.0);
131    format!("{mb:.1} MB")
132}
133
134/// A diagnostic about a workspace-discovery candidate.
135///
136/// The `message` field is a human-readable rendering derived from `kind`. It
137/// always ends with a concrete next step ("fix the JSON syntax", "remove from
138/// `workspaces`", "add to `ignorePatterns`") so first-time users have a path
139/// forward.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(JsonSchema))]
142pub struct WorkspaceDiagnostic {
143    /// Path to the directory or file that triggered the diagnostic.
144    #[serde(serialize_with = "serde_path::serialize")]
145    pub path: PathBuf,
146    /// Kind discriminator with the typed payload.
147    #[serde(flatten)]
148    pub kind: WorkspaceDiagnosticKind,
149    /// Human-readable rendering derived from `kind` + `path`. Always ends
150    /// with a next-step hint.
151    pub message: String,
152}
153
154impl WorkspaceDiagnostic {
155    /// Construct a diagnostic with the message rendered from `kind` + `path`.
156    ///
157    /// `root` is used to produce project-relative paths in the message text
158    /// AND inside the variant payload (e.g. the `error` field of
159    /// `MalformedPackageJson` / `MalformedTsconfig` which embed the absolute
160    /// file path from `PackageJson::load()`'s error text). Without the
161    /// payload-side normalisation the embedded path would survive
162    /// environment-specific differences (CI vs Docker vs local) because the
163    /// post-serialisation `strip_root_prefix` only catches whole-string
164    /// matches, not paths embedded mid-sentence.
165    ///
166    /// If `path` is not under `root` (e.g. canonicalisation crossed a
167    /// symlink), the absolute path is emitted instead.
168    #[must_use]
169    pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
170        let kind = normalise_payload_paths(root, kind);
171        let message = render_message(root, &path, &kind);
172        Self {
173            path,
174            kind,
175            message,
176        }
177    }
178}
179
180/// Strip the project root from absolute paths embedded inside variant
181/// payloads (the `error` field of malformed-config and source-read failures).
182/// Mirrors the per-platform `display()` byte sequence
183/// so the substring match works on Windows too.
184fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
185    let root_str = root.display().to_string();
186    let root_alt = root_str.replace('\\', "/");
187    let normalise = |text: String| -> String {
188        let stripped = text
189            .replace(&format!("{root_str}/"), "")
190            .replace(&format!("{root_alt}/"), "");
191        stripped
192            .replace(&format!("{root_str}\\"), "")
193            .replace(&format!("{root_alt}\\"), "")
194    };
195    match kind {
196        WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
197            WorkspaceDiagnosticKind::MalformedPackageJson {
198                error: normalise(error),
199            }
200        }
201        WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
202            WorkspaceDiagnosticKind::MalformedTsconfig {
203                error: normalise(error),
204            }
205        }
206        WorkspaceDiagnosticKind::SourceReadFailure { error } => {
207            WorkspaceDiagnosticKind::SourceReadFailure {
208                error: normalise(error),
209            }
210        }
211        other => other,
212    }
213}
214
215/// Render `path` relative to `root` with forward slashes. The forward-slash
216/// normalisation is load-bearing for cross-platform output stability.
217fn display_relative(root: &Path, path: &Path) -> String {
218    path.strip_prefix(root)
219        .unwrap_or(path)
220        .display()
221        .to_string()
222        .replace('\\', "/")
223}
224
225fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
226    let display = display_relative(root, path);
227    match kind {
228        WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
229            "Directory '{display}' contains package.json but is not declared as a workspace. \
230             Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
231        ),
232        WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
233            "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
234             Fix the JSON syntax or remove '{display}' from the workspaces pattern."
235        ),
236        WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
237            "Glob '{pattern}' matched '{display}' but no package.json is present. \
238             Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
239        ),
240        WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
241            "tsconfig.json at '{display}' failed to parse ({error}); \
242             project references will be ignored. Fix the JSON syntax."
243        ),
244        WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
245            "tsconfig.json references '{display}' but the directory does not exist. \
246             Update or remove the reference, or restore the missing directory."
247        ),
248        WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
249            "Skipped '{display}' ({size}): exceeds the max file size limit. \
250             Its imports and exports are not analyzed. Raise the limit with \
251             --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
252             to ignorePatterns.",
253            size = format_size_mb(*size_bytes)
254        ),
255        WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
256            "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
257             Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
258             rename it with a .min.js suffix, or use --max-file-size 0 if this file \
259             should be analyzed.",
260            size = format_size_mb(*size_bytes)
261        ),
262        WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
263            "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
264             ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
265        ),
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn skipped_large_file_diagnostic_id_and_message() {
275        let root = Path::new("/project");
276        let diag = WorkspaceDiagnostic::new(
277            root,
278            root.join("src/vendor/app.bundle.js"),
279            WorkspaceDiagnosticKind::SkippedLargeFile {
280                size_bytes: 6 * 1024 * 1024,
281            },
282        );
283        assert_eq!(diag.kind.id(), "skipped-large-file");
284        assert!(
285            diag.message.contains("src/vendor/app.bundle.js"),
286            "message names the project-relative path: {}",
287            diag.message
288        );
289        assert!(
290            diag.message.contains("6.0 MB"),
291            "message reports the size: {}",
292            diag.message
293        );
294        assert!(
295            diag.message.contains("--max-file-size"),
296            "message names the override flag: {}",
297            diag.message
298        );
299    }
300
301    #[test]
302    fn skipped_minified_file_diagnostic_id_and_message() {
303        let root = Path::new("/project");
304        let diag = WorkspaceDiagnostic::new(
305            root,
306            root.join("src/assets/index-abc123.js"),
307            WorkspaceDiagnosticKind::SkippedMinifiedFile {
308                size_bytes: 2 * 1024 * 1024,
309            },
310        );
311        assert_eq!(diag.kind.id(), "skipped-minified-file");
312        assert!(
313            diag.message.contains("src/assets/index-abc123.js"),
314            "message names the project-relative path: {}",
315            diag.message
316        );
317        assert!(
318            diag.message.contains("2.0 MB"),
319            "message reports the size: {}",
320            diag.message
321        );
322        assert!(
323            diag.message.contains("--max-file-size 0"),
324            "message names the opt-out: {}",
325            diag.message
326        );
327    }
328
329    #[test]
330    fn source_read_failure_serializes_typed_error_payload() {
331        let root = Path::new("/project");
332        let diagnostic = WorkspaceDiagnostic::new(
333            root,
334            root.join("src/removed.ts"),
335            WorkspaceDiagnosticKind::SourceReadFailure {
336                error: "No such file or directory".to_string(),
337            },
338        );
339
340        let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
341        assert_eq!(json["kind"], "source-read-failure");
342        assert_eq!(
343            json["path"],
344            root.join("src/removed.ts")
345                .display()
346                .to_string()
347                .replace('\\', "/")
348        );
349        assert_eq!(json["error"], "No such file or directory");
350        assert!(
351            json["message"]
352                .as_str()
353                .is_some_and(|message| message.contains("src/removed.ts"))
354        );
355    }
356
357    #[cfg(feature = "schema")]
358    #[test]
359    fn workspace_diagnostic_schema_includes_source_read_failure() {
360        let schema = schemars::schema_for!(WorkspaceDiagnostic);
361        let json = serde_json::to_string(&schema).expect("schema serializes");
362        assert!(json.contains("source-read-failure"));
363        assert!(json.contains("error"));
364    }
365
366    #[test]
367    fn format_size_mb_one_decimal() {
368        assert_eq!(format_size_mb(0), "0.0 MB");
369        assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
370        assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
371    }
372
373    #[test]
374    fn undeclared_workspace_message_has_next_step() {
375        let root = Path::new("/project");
376        let diag = WorkspaceDiagnostic::new(
377            root,
378            root.join("packages/legacy"),
379            WorkspaceDiagnosticKind::UndeclaredWorkspace,
380        );
381        assert_eq!(diag.kind.id(), "undeclared-workspace");
382        assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
383        assert!(
384            diag.message.contains("ignorePatterns"),
385            "next-step hint preserved: {}",
386            diag.message
387        );
388    }
389}