use std::path::{Path, PathBuf};
use rustc_hash::FxHashSet;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::serde_path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum WorkspaceDiagnosticKind {
UndeclaredWorkspace,
MalformedPackageJson {
error: String,
},
GlobMatchedNoPackageJson {
pattern: String,
},
MalformedTsconfig {
error: String,
},
TsconfigReferenceDirMissing,
MalformedPnpmWorkspaceYaml {
error: String,
},
SkippedLargeFile {
size_bytes: u64,
},
SkippedMinifiedFile {
size_bytes: u64,
},
SourceReadFailure {
error: String,
},
BunLockbOverrideResolutionSkipped,
BunLockOverrideResolutionSkipped,
BunResolutionsShadowedByOverrides,
}
impl WorkspaceDiagnosticKind {
#[must_use]
pub const fn id(&self) -> &'static str {
match self {
Self::UndeclaredWorkspace => "undeclared-workspace",
Self::MalformedPackageJson { .. } => "malformed-package-json",
Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
Self::MalformedTsconfig { .. } => "malformed-tsconfig",
Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
Self::SkippedLargeFile { .. } => "skipped-large-file",
Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
Self::SourceReadFailure { .. } => "source-read-failure",
Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
}
}
#[must_use]
pub const fn is_source_discovery(&self) -> bool {
matches!(
self,
Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SourceReadFailure { .. }
)
}
#[must_use]
pub const fn is_source_walk_recorded(&self) -> bool {
matches!(
self,
Self::SkippedLargeFile { .. } | Self::SkippedMinifiedFile { .. }
)
}
#[must_use]
pub const fn is_analysis_stage(&self) -> bool {
match self {
Self::MalformedPnpmWorkspaceYaml { .. }
| Self::BunLockbOverrideResolutionSkipped
| Self::BunLockOverrideResolutionSkipped
| Self::BunResolutionsShadowedByOverrides => true,
Self::UndeclaredWorkspace
| Self::MalformedPackageJson { .. }
| Self::GlobMatchedNoPackageJson { .. }
| Self::MalformedTsconfig { .. }
| Self::TsconfigReferenceDirMissing
| Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SourceReadFailure { .. } => false,
}
}
}
#[must_use]
fn format_size_mb(bytes: u64) -> String {
#[expect(
clippy::cast_precision_loss,
reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
)]
let mb = bytes as f64 / (1024.0 * 1024.0);
format!("{mb:.1} MB")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct WorkspaceDiagnostic {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
#[serde(flatten)]
pub kind: WorkspaceDiagnosticKind,
pub message: String,
}
impl WorkspaceDiagnostic {
#[must_use]
pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
let path = normalise_diagnostic_path(path);
let kind = normalise_payload_paths(root, kind);
let message = render_message(root, &path, &kind);
Self {
path,
kind,
message,
}
}
#[must_use]
pub fn into_root_relative(mut self, root: &Path) -> Self {
if let Ok(relative) = self.path.strip_prefix(root) {
self.path = relative.to_path_buf();
}
self
}
}
fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
let rebuilt: PathBuf = path.components().collect();
if rebuilt.as_os_str() == path.as_os_str() {
path
} else {
rebuilt
}
}
fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
let root_str = root.display().to_string();
let root_alt = root_str.replace('\\', "/");
let normalise = |text: String| -> String {
let stripped = text
.replace(&format!("{root_str}/"), "")
.replace(&format!("{root_alt}/"), "");
stripped
.replace(&format!("{root_str}\\"), "")
.replace(&format!("{root_alt}\\"), "")
};
match kind {
WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
WorkspaceDiagnosticKind::MalformedPackageJson {
error: normalise(error),
}
}
WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
WorkspaceDiagnosticKind::MalformedTsconfig {
error: normalise(error),
}
}
WorkspaceDiagnosticKind::SourceReadFailure { error } => {
WorkspaceDiagnosticKind::SourceReadFailure {
error: normalise(error),
}
}
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: canonical_glob_pattern(pattern),
}
}
other => other,
}
}
fn canonical_glob_pattern(pattern: String) -> String {
for prefix in ["./", ".\\"] {
if let Some(rest) = pattern.strip_prefix(prefix)
&& !rest.is_empty()
{
return rest.to_owned();
}
}
pattern
}
#[must_use]
pub fn merge_workspace_diagnostics(
primary: Vec<WorkspaceDiagnostic>,
secondary: Vec<WorkspaceDiagnostic>,
) -> Vec<WorkspaceDiagnostic> {
let mut merged = Vec::with_capacity(primary.len() + secondary.len());
let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
for diagnostic in primary.into_iter().chain(secondary) {
let key = (diagnostic.kind.clone(), diagnostic.path.clone());
if seen.insert(key) {
merged.push(diagnostic);
}
}
merged
}
#[must_use]
pub fn dedupe_workspace_diagnostics(
diagnostics: Vec<WorkspaceDiagnostic>,
) -> Vec<WorkspaceDiagnostic> {
merge_workspace_diagnostics(diagnostics, Vec::new())
}
fn display_relative(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
.replace('\\', "/")
}
fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
let display = display_relative(root, path);
match kind {
WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
"Directory '{display}' contains package.json but is not declared as a workspace. \
Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
),
WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
"Dropped workspace '{display}': package.json is not valid JSON ({error}). \
Fix the JSON syntax or remove '{display}' from the workspaces pattern."
),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
"Glob '{pattern}' matched '{display}' but no package.json is present. \
Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
),
WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
"tsconfig.json at '{display}' failed to parse ({error}); \
project references will be ignored. Fix the JSON syntax."
),
WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
"tsconfig.json references '{display}' but the directory does not exist. \
Update or remove the reference, or restore the missing directory."
),
WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
"'{display}' failed to parse ({error}); catalog and override entries \
will be ignored. Fix the YAML syntax."
),
WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
"Skipped '{display}' ({size}): exceeds the max file size limit. \
Its imports and exports are not analyzed. Raise the limit with \
--max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
to ignorePatterns.",
size = format_size_mb(*size_bytes)
),
WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
"Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
rename it with a .min.js suffix, or use --max-file-size 0 if this file \
should be analyzed.",
size = format_size_mb(*size_bytes)
),
WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
"Could not read source '{display}' ({error}). Restore the file or its read permissions, \
ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
),
WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
"Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
(bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
use instead, so unused-dependency-overrides findings are not reported. Run bun install \
--save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
bun.lockb if this repository no longer uses bun."
),
WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
"Skipped dependency-override resolution because '{display}' could not be parsed and \
no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
findings are not reported. Run bun install to regenerate the text lockfile, then \
rerun fallow."
),
WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
"'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
`overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
remove the shadowed `resolutions` entries."
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skipped_large_file_diagnostic_id_and_message() {
let root = Path::new("/project");
let diag = WorkspaceDiagnostic::new(
root,
root.join("src/vendor/app.bundle.js"),
WorkspaceDiagnosticKind::SkippedLargeFile {
size_bytes: 6 * 1024 * 1024,
},
);
assert_eq!(diag.kind.id(), "skipped-large-file");
assert!(
diag.message.contains("src/vendor/app.bundle.js"),
"message names the project-relative path: {}",
diag.message
);
assert!(
diag.message.contains("6.0 MB"),
"message reports the size: {}",
diag.message
);
assert!(
diag.message.contains("--max-file-size"),
"message names the override flag: {}",
diag.message
);
}
#[test]
fn skipped_minified_file_diagnostic_id_and_message() {
let root = Path::new("/project");
let diag = WorkspaceDiagnostic::new(
root,
root.join("src/assets/index-abc123.js"),
WorkspaceDiagnosticKind::SkippedMinifiedFile {
size_bytes: 2 * 1024 * 1024,
},
);
assert_eq!(diag.kind.id(), "skipped-minified-file");
assert!(
diag.message.contains("src/assets/index-abc123.js"),
"message names the project-relative path: {}",
diag.message
);
assert!(
diag.message.contains("2.0 MB"),
"message reports the size: {}",
diag.message
);
assert!(
diag.message.contains("--max-file-size 0"),
"message names the opt-out: {}",
diag.message
);
}
#[test]
fn source_read_failure_serializes_typed_error_payload() {
let root = Path::new("/project");
let diagnostic = WorkspaceDiagnostic::new(
root,
root.join("src/removed.ts"),
WorkspaceDiagnosticKind::SourceReadFailure {
error: "No such file or directory".to_string(),
},
);
let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
assert_eq!(json["kind"], "source-read-failure");
assert_eq!(
json["path"],
root.join("src/removed.ts")
.display()
.to_string()
.replace('\\', "/")
);
assert_eq!(json["error"], "No such file or directory");
assert!(
json["message"]
.as_str()
.is_some_and(|message| message.contains("src/removed.ts"))
);
}
#[cfg(feature = "schema")]
#[test]
fn workspace_diagnostic_schema_includes_source_read_failure() {
let schema = schemars::schema_for!(WorkspaceDiagnostic);
let json = serde_json::to_string(&schema).expect("schema serializes");
assert!(json.contains("source-read-failure"));
assert!(json.contains("error"));
}
#[test]
fn bun_lockb_override_resolution_skipped_id_and_message() {
let root = Path::new("/project");
let diag = WorkspaceDiagnostic::new(
root,
root.join("package.json"),
WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
);
assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
assert!(
diag.message.contains("'package.json'"),
"message names the project-relative manifest: {}",
diag.message
);
assert!(
diag.message.contains("no parseable text lockfile"),
"message states the cause: {}",
diag.message
);
assert!(
!diag.message.contains("only bun.lockb"),
"message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
bun.lock may sit beside it: {}",
diag.message
);
assert!(
diag.message.contains("bun install --save-text-lockfile")
&& diag.message.contains("delete the stale bun.lockb"),
"message ends with the text-lockfile next step and the stale-lockb alternative: {}",
diag.message
);
let json = serde_json::to_value(&diag).expect("diagnostic serializes");
assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
}
#[test]
fn bun_override_diagnostic_ids_and_messages_are_actionable() {
let root = Path::new("/project");
let malformed = WorkspaceDiagnostic::new(
root,
root.join("bun.lock"),
WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
);
assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
assert!(malformed.message.contains("regenerate"));
let shadowed = WorkspaceDiagnostic::new(
root,
root.join("package.json"),
WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
);
assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
assert!(shadowed.message.contains("ignores `resolutions`"));
}
#[test]
fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
let root = Path::new("/project");
let inside = WorkspaceDiagnostic::new(
root,
root.join("packages/inner"),
WorkspaceDiagnosticKind::UndeclaredWorkspace,
)
.into_root_relative(root);
assert_eq!(inside.path, Path::new("packages/inner"));
let outside = WorkspaceDiagnostic::new(
root,
PathBuf::from("/elsewhere/packages/inner"),
WorkspaceDiagnosticKind::UndeclaredWorkspace,
)
.into_root_relative(root);
assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
}
#[test]
fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
let analysis_stage = [
WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
error: "bad yaml".to_owned(),
},
WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
];
for kind in &analysis_stage {
assert!(
kind.is_analysis_stage() && !kind.is_source_discovery(),
"{} is recorded by the analyze stage only",
kind.id()
);
}
let other = [
WorkspaceDiagnosticKind::UndeclaredWorkspace,
WorkspaceDiagnosticKind::MalformedPackageJson {
error: "trailing comma".to_owned(),
},
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: "packages/*".to_owned(),
},
WorkspaceDiagnosticKind::MalformedTsconfig {
error: "unexpected token".to_owned(),
},
WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
WorkspaceDiagnosticKind::SourceReadFailure {
error: "permission denied".to_owned(),
},
];
for kind in &other {
assert!(
!kind.is_analysis_stage(),
"{} is a discovery kind, not an analyze-stage kind",
kind.id()
);
}
}
#[test]
fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
let root = Path::new("/project");
let first = WorkspaceDiagnostic::new(
root,
root.join("packages/aaa"),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: "packages/*".to_owned(),
},
);
let second = WorkspaceDiagnostic::new(
root,
root.join("packages/aaa"),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: "packages/a*".to_owned(),
},
);
let merged =
merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
let patterns: Vec<String> = merged
.iter()
.map(|diagnostic| match &diagnostic.kind {
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
other => panic!("unexpected kind {}", other.id()),
})
.collect();
assert_eq!(
patterns,
["packages/*", "packages/a*"],
"two overlapping globs report the same directory twice, with their own pattern; \
the same entry seen from two observation points still folds to one"
);
}
#[test]
fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
let root = Path::new("/project");
let dotted = WorkspaceDiagnostic::new(
root,
root.join("apps/site/.next/cache"),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: "./apps/**".to_owned(),
},
);
let bare = WorkspaceDiagnostic::new(
root,
root.join("apps/site/.next/cache"),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: "apps/**".to_owned(),
},
);
assert_eq!(
dotted.kind, bare.kind,
"the no-op ./ prefix is normalised out of the recorded pattern"
);
assert!(
dotted.message.contains("Glob 'apps/**'"),
"the message renders the normalised pattern: {}",
dotted.message
);
let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
assert_eq!(
merged.len(),
1,
"one glob declared twice is one diagnostic: {merged:?}"
);
}
#[test]
fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
let root = Path::new("/project");
let recorded = |pattern: &str| {
let diagnostic = WorkspaceDiagnostic::new(
root,
root.join("pkgs"),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: pattern.to_owned(),
},
);
let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
else {
panic!("constructed a glob-matched-no-package-json diagnostic");
};
(pattern, diagnostic.message)
};
let (root_pattern, root_message) = recorded("./");
assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
assert!(
root_message.contains("Glob './'"),
"the warning names the glob the manifest declared: {root_message}"
);
assert_eq!(recorded(".\\").0, ".\\");
assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
}
#[test]
fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
let root = Path::new("/project");
let dotted = WorkspaceDiagnostic::new(
root,
root.join("./pkgs/aaa"),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: "./pkgs/*".to_owned(),
},
);
let bare = WorkspaceDiagnostic::new(
root,
root.join("pkgs/aaa"),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: "pkgs/*".to_owned(),
},
);
let spelling = |diagnostic: &WorkspaceDiagnostic| {
diagnostic.path.display().to_string().replace('\\', "/")
};
assert_eq!(
spelling(&dotted),
"/project/pkgs/aaa",
"the stored path drops the no-op . component, which Path equality \
hides but serialization does not"
);
assert_eq!(spelling(&dotted), spelling(&bare));
assert_eq!(
spelling(&dotted.clone().into_root_relative(root)),
"pkgs/aaa"
);
let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
assert_eq!(
merged.len(),
1,
"one directory reached through two spellings of one glob: {merged:?}"
);
}
#[test]
fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
let root = Path::new("/project");
let glob = |pattern: &str, relative: &str| {
WorkspaceDiagnostic::new(
root,
root.join(relative),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: pattern.to_owned(),
},
)
};
let deduped = dedupe_workspace_diagnostics(vec![
glob("pkgs/*", "pkgs/aaa"),
glob("pkgs/*", "pkgs/bbb"),
glob("./pkgs/*", "./pkgs/aaa"),
glob("pkgs/a*", "pkgs/aaa"),
]);
let reported: Vec<(String, String)> = deduped
.iter()
.map(|diagnostic| match &diagnostic.kind {
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
pattern.clone(),
diagnostic.path.display().to_string().replace('\\', "/"),
),
other => panic!("unexpected kind {}", other.id()),
})
.collect();
assert_eq!(
reported,
vec![
("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
],
"the duplicate spelling folds away and the overlapping glob stays"
);
}
#[test]
fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
for kind in [
WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
] {
assert!(
kind.is_source_walk_recorded() && kind.is_source_discovery(),
"{} is written by the source walk",
kind.id()
);
}
let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
error: "permission denied".to_owned(),
};
assert!(
read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
"the parse stage records source-read-failure after the walk, so it must keep \
reaching sessions through the registry"
);
for kind in [
WorkspaceDiagnosticKind::UndeclaredWorkspace,
WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
] {
assert!(
!kind.is_source_walk_recorded(),
"{} is not written by the source walk",
kind.id()
);
}
}
#[test]
fn format_size_mb_one_decimal() {
assert_eq!(format_size_mb(0), "0.0 MB");
assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
}
#[test]
fn undeclared_workspace_message_has_next_step() {
let root = Path::new("/project");
let diag = WorkspaceDiagnostic::new(
root,
root.join("packages/legacy"),
WorkspaceDiagnosticKind::UndeclaredWorkspace,
);
assert_eq!(diag.kind.id(), "undeclared-workspace");
assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
assert!(
diag.message.contains("ignorePatterns"),
"next-step hint preserved: {}",
diag.message
);
}
}