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