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