use std::path::{Path, PathBuf};
use rustc_hash::FxHashSet;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::path_util::display_relative;
use crate::serde_path;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum PnpmWorkspaceOverridesIgnoredCause {
PackageJsonOverrides,
PnpmVersion,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum NodeModulesState {
Present,
Missing,
}
impl NodeModulesState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Present => "present",
Self::Missing => "missing",
}
}
}
#[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,
},
WorkspaceGlobOutsideRoot {
pattern: String,
},
WorkspacePatternDirMissing {
pattern: String,
},
NestedProjectRoot,
MalformedTsconfig {
error: String,
},
TsconfigReferenceDirMissing,
MalformedPnpmWorkspaceYaml {
error: String,
},
SkippedLargeFile {
size_bytes: u64,
},
SkippedMinifiedFile {
size_bytes: u64,
},
SkippedSourceDotdir,
SourceReadFailure {
error: String,
},
SourceParseDegraded {
error_count: u32,
panicked: bool,
},
BunLockbOverrideResolutionSkipped,
BunLockOverrideResolutionSkipped,
PnpmLockOverrideResolutionSkipped,
NpmLockOverrideResolutionSkipped,
BunResolutionsShadowedByOverrides,
PnpmWorkspaceOverridesIgnored {
cause: PnpmWorkspaceOverridesIgnoredCause,
},
NodeModulesMissing,
BoundariesNotConfigured,
RulePacksNotConfigured,
ExcludedByDefaultIgnore {
pattern: String,
file_count: u32,
directory_count: u32,
},
NoSourceFilesAnalyzed {
excluded_file_count: u32,
},
FileScoresUnavailable {
error: String,
},
HotspotsSkipped {
cause: String,
},
ChangeCouplingSkipped {
cause: String,
},
ShallowClone {
ownership_requested: bool,
},
UnpinnedClock,
OwnershipUnavailable {
cause: String,
error: String,
},
TrendSnapshotUnreadable {
error: String,
},
TrendGroupBaselineUnavailable {
cause: String,
},
PluginConfigUnreadable {
plugin: String,
key: String,
reason: String,
},
PluginEffectNotModeled {
plugin: String,
key: String,
reason: String,
},
CoverageAutoDetected,
FlagAgeShallowClone,
FlagAgeUnavailable {
cause: String,
},
IgnoreDependenciesGlobUnmatched {
pattern: String,
},
IgnoreFindingsPatternUnmatched {
pattern: String,
},
BaselineInstallStateMismatch {
baseline_node_modules: NodeModulesState,
run_node_modules: NodeModulesState,
},
}
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::WorkspaceGlobOutsideRoot { .. } => "workspace-glob-outside-root",
Self::WorkspacePatternDirMissing { .. } => "workspace-pattern-dir-missing",
Self::NestedProjectRoot => "nested-project-root",
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::SkippedSourceDotdir => "skipped-source-dotdir",
Self::SourceReadFailure { .. } => "source-read-failure",
Self::SourceParseDegraded { .. } => "source-parse-degraded",
Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
Self::PnpmLockOverrideResolutionSkipped => "pnpm-lock-override-resolution-skipped",
Self::NpmLockOverrideResolutionSkipped => "npm-lock-override-resolution-skipped",
Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
Self::PnpmWorkspaceOverridesIgnored { .. } => "pnpm-workspace-overrides-ignored",
Self::NodeModulesMissing => "node-modules-missing",
Self::BoundariesNotConfigured => "boundaries-not-configured",
Self::RulePacksNotConfigured => "rule-packs-not-configured",
Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
Self::FileScoresUnavailable { .. } => "file-scores-unavailable",
Self::HotspotsSkipped { .. } => "hotspots-skipped",
Self::ChangeCouplingSkipped { .. } => "change-coupling-skipped",
Self::ShallowClone { .. } => "shallow-clone",
Self::UnpinnedClock => "unpinned-clock",
Self::OwnershipUnavailable { .. } => "ownership-unavailable",
Self::TrendSnapshotUnreadable { .. } => "trend-snapshot-unreadable",
Self::TrendGroupBaselineUnavailable { .. } => "trend-group-baseline-unavailable",
Self::PluginConfigUnreadable { .. } => "plugin-config-unreadable",
Self::PluginEffectNotModeled { .. } => "plugin-effect-not-modeled",
Self::CoverageAutoDetected => "coverage-auto-detected",
Self::FlagAgeShallowClone => "flag-age-shallow-clone",
Self::FlagAgeUnavailable { .. } => "flag-age-unavailable",
Self::IgnoreDependenciesGlobUnmatched { .. } => "ignore-dependencies-glob-unmatched",
Self::IgnoreFindingsPatternUnmatched { .. } => "ignore-findings-pattern-unmatched",
Self::BaselineInstallStateMismatch { .. } => "baseline-install-state-mismatch",
}
}
#[must_use]
pub fn unmatched_config_pattern(&self) -> Option<(&'static str, &str)> {
match self {
Self::IgnoreDependenciesGlobUnmatched { pattern } => {
Some(("ignoreDependencies", pattern.as_str()))
}
Self::IgnoreFindingsPatternUnmatched { pattern } => {
Some(("ignoreFindings", pattern.as_str()))
}
_ => None,
}
}
#[must_use]
pub const fn warns_on_stderr(&self) -> bool {
match self {
Self::BoundariesNotConfigured
| Self::RulePacksNotConfigured
| Self::ExcludedByDefaultIgnore { .. }
| Self::PluginEffectNotModeled { .. }
| Self::CoverageAutoDetected
| Self::TrendGroupBaselineUnavailable { .. }
| Self::ChangeCouplingSkipped { .. }
| Self::IgnoreDependenciesGlobUnmatched { .. }
| Self::IgnoreFindingsPatternUnmatched { .. }
| Self::NestedProjectRoot => false,
Self::UndeclaredWorkspace
| Self::MalformedPackageJson { .. }
| Self::GlobMatchedNoPackageJson { .. }
| Self::WorkspaceGlobOutsideRoot { .. }
| Self::WorkspacePatternDirMissing { .. }
| Self::MalformedTsconfig { .. }
| Self::TsconfigReferenceDirMissing
| Self::MalformedPnpmWorkspaceYaml { .. }
| Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SkippedSourceDotdir
| Self::SourceReadFailure { .. }
| Self::SourceParseDegraded { .. }
| Self::BunLockbOverrideResolutionSkipped
| Self::BunLockOverrideResolutionSkipped
| Self::PnpmLockOverrideResolutionSkipped
| Self::NpmLockOverrideResolutionSkipped
| Self::BunResolutionsShadowedByOverrides
| Self::PnpmWorkspaceOverridesIgnored { .. }
| Self::NodeModulesMissing
| Self::NoSourceFilesAnalyzed { .. }
| Self::FileScoresUnavailable { .. }
| Self::HotspotsSkipped { .. }
| Self::ShallowClone { .. }
| Self::UnpinnedClock
| Self::OwnershipUnavailable { .. }
| Self::TrendSnapshotUnreadable { .. }
| Self::PluginConfigUnreadable { .. }
| Self::FlagAgeShallowClone
| Self::FlagAgeUnavailable { .. }
| Self::BaselineInstallStateMismatch { .. } => true,
}
}
#[must_use]
pub const fn is_source_discovery(&self) -> bool {
matches!(
self,
Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SkippedSourceDotdir
| Self::SourceReadFailure { .. }
| Self::SourceParseDegraded { .. }
| Self::NodeModulesMissing
| Self::ExcludedByDefaultIgnore { .. }
| Self::NoSourceFilesAnalyzed { .. }
)
}
#[must_use]
pub const fn is_source_walk_recorded(&self) -> bool {
matches!(
self,
Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SkippedSourceDotdir
| Self::NodeModulesMissing
| Self::ExcludedByDefaultIgnore { .. }
| Self::NoSourceFilesAnalyzed { .. }
)
}
#[must_use]
pub const fn source_never_analyzed(&self) -> bool {
match self {
Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SkippedSourceDotdir
| Self::SourceReadFailure { .. } => true,
Self::UndeclaredWorkspace
| Self::MalformedPackageJson { .. }
| Self::GlobMatchedNoPackageJson { .. }
| Self::WorkspaceGlobOutsideRoot { .. }
| Self::WorkspacePatternDirMissing { .. }
| Self::NestedProjectRoot
| Self::MalformedTsconfig { .. }
| Self::TsconfigReferenceDirMissing
| Self::MalformedPnpmWorkspaceYaml { .. }
| Self::SourceParseDegraded { .. }
| Self::BunLockbOverrideResolutionSkipped
| Self::BunLockOverrideResolutionSkipped
| Self::PnpmLockOverrideResolutionSkipped
| Self::NpmLockOverrideResolutionSkipped
| Self::BunResolutionsShadowedByOverrides
| Self::PnpmWorkspaceOverridesIgnored { .. }
| Self::NodeModulesMissing
| Self::BoundariesNotConfigured
| Self::RulePacksNotConfigured
| Self::ExcludedByDefaultIgnore { .. }
| Self::NoSourceFilesAnalyzed { .. }
| Self::FileScoresUnavailable { .. }
| Self::HotspotsSkipped { .. }
| Self::ChangeCouplingSkipped { .. }
| Self::ShallowClone { .. }
| Self::UnpinnedClock
| Self::OwnershipUnavailable { .. }
| Self::TrendSnapshotUnreadable { .. }
| Self::TrendGroupBaselineUnavailable { .. }
| Self::PluginConfigUnreadable { .. }
| Self::PluginEffectNotModeled { .. }
| Self::CoverageAutoDetected
| Self::FlagAgeShallowClone
| Self::FlagAgeUnavailable { .. }
| Self::IgnoreDependenciesGlobUnmatched { .. }
| Self::IgnoreFindingsPatternUnmatched { .. }
| Self::BaselineInstallStateMismatch { .. } => false,
}
}
#[must_use]
pub const fn is_analysis_stage(&self) -> bool {
match self {
Self::MalformedPnpmWorkspaceYaml { .. }
| Self::BunLockbOverrideResolutionSkipped
| Self::BunLockOverrideResolutionSkipped
| Self::PnpmLockOverrideResolutionSkipped
| Self::NpmLockOverrideResolutionSkipped
| Self::BunResolutionsShadowedByOverrides
| Self::PnpmWorkspaceOverridesIgnored { .. }
| Self::BoundariesNotConfigured
| Self::RulePacksNotConfigured => true,
Self::UndeclaredWorkspace
| Self::MalformedPackageJson { .. }
| Self::GlobMatchedNoPackageJson { .. }
| Self::WorkspaceGlobOutsideRoot { .. }
| Self::WorkspacePatternDirMissing { .. }
| Self::NestedProjectRoot
| Self::MalformedTsconfig { .. }
| Self::TsconfigReferenceDirMissing
| Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SkippedSourceDotdir
| Self::SourceReadFailure { .. }
| Self::SourceParseDegraded { .. }
| Self::NodeModulesMissing
| Self::ExcludedByDefaultIgnore { .. }
| Self::NoSourceFilesAnalyzed { .. }
| Self::FileScoresUnavailable { .. }
| Self::HotspotsSkipped { .. }
| Self::ChangeCouplingSkipped { .. }
| Self::ShallowClone { .. }
| Self::UnpinnedClock
| Self::OwnershipUnavailable { .. }
| Self::TrendSnapshotUnreadable { .. }
| Self::TrendGroupBaselineUnavailable { .. }
| Self::PluginConfigUnreadable { .. }
| Self::PluginEffectNotModeled { .. }
| Self::CoverageAutoDetected
| Self::FlagAgeShallowClone
| Self::FlagAgeUnavailable { .. }
| Self::IgnoreDependenciesGlobUnmatched { .. }
| Self::IgnoreFindingsPatternUnmatched { .. }
| Self::BaselineInstallStateMismatch { .. } => false,
}
}
#[must_use]
pub const fn is_health_stage(&self) -> bool {
match self {
Self::FileScoresUnavailable { .. }
| Self::HotspotsSkipped { .. }
| Self::ChangeCouplingSkipped { .. }
| Self::ShallowClone { .. }
| Self::UnpinnedClock
| Self::OwnershipUnavailable { .. }
| Self::TrendSnapshotUnreadable { .. }
| Self::TrendGroupBaselineUnavailable { .. }
| Self::CoverageAutoDetected => true,
Self::UndeclaredWorkspace
| Self::MalformedPackageJson { .. }
| Self::GlobMatchedNoPackageJson { .. }
| Self::WorkspaceGlobOutsideRoot { .. }
| Self::WorkspacePatternDirMissing { .. }
| Self::NestedProjectRoot
| Self::MalformedTsconfig { .. }
| Self::TsconfigReferenceDirMissing
| Self::MalformedPnpmWorkspaceYaml { .. }
| Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SkippedSourceDotdir
| Self::SourceReadFailure { .. }
| Self::SourceParseDegraded { .. }
| Self::BunLockbOverrideResolutionSkipped
| Self::BunLockOverrideResolutionSkipped
| Self::PnpmLockOverrideResolutionSkipped
| Self::NpmLockOverrideResolutionSkipped
| Self::BunResolutionsShadowedByOverrides
| Self::PnpmWorkspaceOverridesIgnored { .. }
| Self::NodeModulesMissing
| Self::BoundariesNotConfigured
| Self::RulePacksNotConfigured
| Self::ExcludedByDefaultIgnore { .. }
| Self::PluginConfigUnreadable { .. }
| Self::PluginEffectNotModeled { .. }
| Self::NoSourceFilesAnalyzed { .. }
| Self::FlagAgeShallowClone
| Self::FlagAgeUnavailable { .. }
| Self::IgnoreDependenciesGlobUnmatched { .. }
| Self::IgnoreFindingsPatternUnmatched { .. }
| Self::BaselineInstallStateMismatch { .. } => false,
}
}
#[must_use]
pub const fn is_plugin_stage(&self) -> bool {
match self {
Self::PluginConfigUnreadable { .. } | Self::PluginEffectNotModeled { .. } => true,
Self::UndeclaredWorkspace
| Self::MalformedPackageJson { .. }
| Self::GlobMatchedNoPackageJson { .. }
| Self::WorkspaceGlobOutsideRoot { .. }
| Self::WorkspacePatternDirMissing { .. }
| Self::NestedProjectRoot
| Self::MalformedTsconfig { .. }
| Self::TsconfigReferenceDirMissing
| Self::MalformedPnpmWorkspaceYaml { .. }
| Self::SkippedLargeFile { .. }
| Self::SkippedMinifiedFile { .. }
| Self::SkippedSourceDotdir
| Self::SourceReadFailure { .. }
| Self::SourceParseDegraded { .. }
| Self::BunLockbOverrideResolutionSkipped
| Self::BunLockOverrideResolutionSkipped
| Self::PnpmLockOverrideResolutionSkipped
| Self::NpmLockOverrideResolutionSkipped
| Self::BunResolutionsShadowedByOverrides
| Self::PnpmWorkspaceOverridesIgnored { .. }
| Self::NodeModulesMissing
| Self::BoundariesNotConfigured
| Self::RulePacksNotConfigured
| Self::ExcludedByDefaultIgnore { .. }
| Self::NoSourceFilesAnalyzed { .. }
| Self::FileScoresUnavailable { .. }
| Self::HotspotsSkipped { .. }
| Self::ChangeCouplingSkipped { .. }
| Self::ShallowClone { .. }
| Self::UnpinnedClock
| Self::OwnershipUnavailable { .. }
| Self::TrendSnapshotUnreadable { .. }
| Self::TrendGroupBaselineUnavailable { .. }
| Self::CoverageAutoDetected
| Self::FlagAgeShallowClone
| Self::FlagAgeUnavailable { .. }
| Self::IgnoreDependenciesGlobUnmatched { .. }
| Self::IgnoreFindingsPatternUnmatched { .. }
| Self::BaselineInstallStateMismatch { .. } => 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,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub degrades_analysis: bool,
}
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);
let degrades_analysis = kind.warns_on_stderr();
Self {
path,
kind,
message,
degrades_analysis,
}
}
#[must_use]
pub fn into_root_relative(mut self, root: &Path) -> Self {
if let Ok(relative) = self.path.strip_prefix(root) {
self.path = if relative.as_os_str().is_empty() {
PathBuf::from(".")
} else {
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::FileScoresUnavailable { error } => {
WorkspaceDiagnosticKind::FileScoresUnavailable {
error: normalise(error),
}
}
WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
WorkspaceDiagnosticKind::OwnershipUnavailable {
cause,
error: normalise(error),
}
}
WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => {
WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
error: normalise(error),
}
}
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
pattern: canonical_glob_pattern(pattern),
}
}
WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot { pattern } => {
WorkspaceDiagnosticKind::WorkspaceGlobOutsideRoot {
pattern: canonical_glob_pattern(pattern),
}
}
WorkspaceDiagnosticKind::WorkspacePatternDirMissing { pattern } => {
WorkspaceDiagnosticKind::WorkspacePatternDirMissing {
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())
}
#[must_use]
pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
pattern.split('/').find(|segment| {
!segment.is_empty()
&& !segment.contains(['*', '?', '[', ']', '{', '}'])
&& *segment != "."
&& *segment != ".."
})
}
#[must_use]
pub fn workspace_pattern_literal_dir(pattern: &str) -> Option<String> {
let literal: Vec<&str> = pattern
.split(['/', '\\'])
.filter(|segment| !segment.is_empty() && *segment != ".")
.take_while(|segment| !segment.contains(['*', '?', '[', ']', '{', '}']))
.collect();
if literal.is_empty() {
None
} else {
Some(literal.join("/"))
}
}
fn unreadable_situation(reason: &str) -> &'static str {
match reason {
"array-form" => "uses the array form, which is not read yet",
"spread" => "spreads a value that is not statically readable",
"unreadable-entries" => "has entries that hold no statically readable value",
"not-object-literal" => "is not a static object literal",
"unrecognized-call" => "is passed through a call that is not a known config wrapper",
"import-target-unreadable" => "comes from an imported file that is not statically readable",
"dynamic-argument" => "receives an argument that is not a static literal",
_ => "could not be read statically",
}
}
fn unreadable_key_consequence(key: &str, reason: &str) -> (&'static str, &'static str) {
match (key, reason) {
("exposes", "unrecognized-call") => (
"only the targets in the object literal it receives are registered as entry points",
"Name any other exposed files in `dynamicallyLoaded`.",
),
("remotes", "unrecognized-call") => (
"only the aliases in the object literal it receives are treated as provided by a \
remote container",
"Name any other aliases in `ignoreDependencies`.",
),
("exposes", "import-target-unreadable") => (
"the targets that file declares are not registered as entry points",
"Name the exposed files in `dynamicallyLoaded`.",
),
("remotes", "import-target-unreadable") => (
"the aliases that file declares are not treated as provided by a remote container",
"Name the aliases in `ignoreDependencies`.",
),
("registerRemotes", _) => (
"the remotes it registers are not treated as provided by a remote container",
"Name the remote aliases in `ignoreDependencies`, or pass the remote names as \
string literals.",
),
("init" | "createInstance", _) => (
"the remotes its options declare are not treated as provided by a remote container",
"Name the remote aliases in `ignoreDependencies`, or pass `remotes` as an array of \
objects with literal names.",
),
("loadRemote", _) => (
"the remote it loads is not treated as provided by a remote container",
"Name the remote alias in `ignoreDependencies`, or pass the request as a string \
literal.",
),
("exposes", _) => (
"the targets are not registered as entry points",
"Name the exposed files in `dynamicallyLoaded`.",
),
("remotes", _) => (
"the aliases are not treated as provided by a remote container",
"Name the aliases in `ignoreDependencies`, or declare them as the keys of an object \
literal, whose values may be computed.",
),
_ => (
"what it declares is not fully registered",
"Declare the value as a static object literal.",
),
}
}
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::WorkspaceGlobOutsideRoot { pattern } => {
let source = if display.is_empty() {
"the fallow config `workspaces.patterns`".to_owned()
} else {
format!("'{display}'")
};
format!(
"Skipped workspace entry '{pattern}' in {source}: it is an absolute path \
or its '..' segments lead above the project root, so it can match directories \
outside the root. \
Fallow analyzes no package from it. Move the package inside the project root, \
or run fallow with --root set to a directory that holds both."
)
}
WorkspaceDiagnosticKind::WorkspacePatternDirMissing { pattern } => {
let source = if display.is_empty() {
"the fallow config `workspaces.patterns`".to_owned()
} else {
format!("'{display}'")
};
let dir = workspace_pattern_literal_dir(pattern).unwrap_or_else(|| pattern.clone());
format!(
"Workspace pattern '{pattern}' in {source} names directory '{dir}', which does \
not exist. Fallow analyzes no package from it. Unused catalog entries are not \
reported in this run, because packages in that directory can use them. \
Create the directory before the run, or remove the pattern."
)
}
WorkspaceDiagnosticKind::NestedProjectRoot => format!(
"Directory '{display}' has its own pnpm-workspace.yaml, so it is an independent \
project. Fallow analyzes it as a workspace: its package.json owns its files and \
dependencies, and its catalog references are not checked against the root \
catalog. Add '{display}' to ignorePatterns to exclude it."
),
WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
"tsconfig at '{display}' failed to parse ({error}); \
its project references, path aliases and compiler options are 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}' is not valid YAML ({error}). Catalog checks are skipped \
and its override entries are ignored until it parses. pnpm install \
can accept this file, but `pnpm add` and YAML formatters reject it. \
Indent each continuation line of a quoted value or a `[...]` / \
`{{...}}` list deeper than its key."
),
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::SkippedSourceDotdir => format!(
"Skipped hidden directory '{display}': it contains source files but hidden \
directories are not traversed. Its imports and exports are not analyzed. \
A file, export or dependency that only this directory uses can be reported as \
unused. To analyze it, add '!{display}/**' to ignorePatterns. To stop \
that false positive without that, add the file to entry, the export to \
ignoreExports or the dependency to ignoreDependencies. To silence this message, \
add '{display}/**' to ignorePatterns. fallow --root {display} analyzes only \
that directory on its own and does not fix this run."
),
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::SourceParseDegraded {
error_count,
panicked,
} => {
let outcome = if *panicked {
"the parser stopped there"
} else {
"the parser recovered and continued"
};
format!(
"Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
references it did not reach are missing from this run, so files and symbols it \
uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
syntax newer than fallow's parser."
)
}
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::PnpmLockOverrideResolutionSkipped => format!(
"Skipped dependency-override resolution because '{display}' could not be parsed and \
no other parseable lockfile was available, so unused-dependency-overrides findings \
are not reported. Resolve any merge-conflict markers in the lockfile, or run pnpm \
install to regenerate it, then rerun fallow."
),
WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped => format!(
"Skipped dependency-override resolution because '{display}' could not be parsed and \
no other parseable lockfile was available, so unused-dependency-overrides findings \
are not reported. Resolve any merge-conflict markers in the lockfile, or run npm \
install to regenerate it, 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."
),
WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
cause: PnpmWorkspaceOverridesIgnoredCause::PackageJsonOverrides,
} => format!(
"pnpm 10 and earlier ignore the `overrides` in '{display}' because the root package.json \
declares `pnpm.overrides` or `resolutions`, so fallow does not check them. Move the \
entries into one source."
),
WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
} => format!(
"pnpm versions before 10.5.1 do not read the `overrides` in '{display}', so fallow \
does not check them. Update `packageManager` to pnpm 10.5.1 or later, or move the \
entries into `pnpm.overrides` in the root package.json."
),
WorkspaceDiagnosticKind::NodeModulesMissing => format!(
"'{display}' does not exist. Package exports and conditional exports cannot be read, \
framework plugins that activate on an installed package stay inactive, and \
dependency classification degrades, so imports and dependencies can be \
misreported. Run npm install / pnpm install / yarn / bun install first."
),
WorkspaceDiagnosticKind::BoundariesNotConfigured => {
"No architecture boundaries are configured, so the boundary detector did not run and \
its violation counts are zero because nothing was measured. Add `boundaries` to the \
config, or set `boundary-violation` to off to state that the check is not wanted."
.to_string()
}
WorkspaceDiagnosticKind::RulePacksNotConfigured => {
"No rule packs are configured, so the policy detector did not run and its violation \
counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
`policy-violation` to off to state that the check is not wanted."
.to_string()
}
WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
excluded_file_count,
} => {
if *excluded_file_count == 0 {
"No source files were analyzed, so every finding count this run reports is zero \
because nothing was measured. Check the analysis root, ignorePatterns, and any \
path or workspace filter this run applied."
.to_owned()
} else {
format!(
"No source files were analyzed. Fallow's built-in ignore patterns excluded \
{excluded_file_count} candidate files, so every finding count this run \
reports is zero because nothing was measured; run with --explain-skipped \
for the breakdown."
)
}
}
WorkspaceDiagnosticKind::FileScoresUnavailable { error } => format!(
"Could not compute per-file health scores ({error}), so the score list is empty and \
the scored-file count is 0 because nothing was measured rather than because the \
project has nothing to score. Rerun with --no-cache, or scope the run to a \
subdirectory to find the input that fails."
),
WorkspaceDiagnosticKind::HotspotsSkipped { cause } => match cause.as_str() {
"invalid-since" => "Hotspot analysis was skipped because --since could not be read \
as a time window, so the hotspots, churn and ownership sections report nothing \
rather than zero. Spell it as a duration such as 6m or 90d, or drop it to use \
the default window."
.to_owned(),
"no-commits" => "Hotspot analysis was skipped because the current branch has no \
commits yet, so the hotspots, churn and ownership sections report nothing \
rather than zero. Commit the project to give churn a history, or pass \
--churn-file with exported change history."
.to_owned(),
"churn-file-unreadable" => format!(
"Hotspot analysis was skipped because the churn file '{display}' could no longer \
be read after it was validated, so the hotspots, churn and ownership sections \
report nothing rather than zero. Make sure nothing rewrites the file while \
fallow runs, and rerun."
),
_ => "Hotspot analysis was skipped because no git repository was found at the \
project root, so the hotspots, churn and ownership sections report nothing \
rather than zero. Run fallow inside the repository, or pass --churn-file with \
exported change history."
.to_owned(),
},
WorkspaceDiagnosticKind::ChangeCouplingSkipped { cause } => match cause.as_str() {
"shallow-clone" => "Change coupling was not measured because the repository is a \
shallow clone, so no change coupling pairs are reported. Fetch the full \
history with `git fetch --unshallow` to measure it."
.to_owned(),
_ => "Change coupling found no pairs because the history window has fewer \
analyzed commits than the minimum revisions of one file. Widen the window \
with --since, or lower health.changeCoupling.minRevisions."
.to_owned(),
},
WorkspaceDiagnosticKind::ShallowClone {
ownership_requested,
} => {
let ownership = if *ownership_requested {
" Ownership signals are skewed too, because a shallow clone inflates \
single-author dominance."
} else {
""
};
format!(
"This is a shallow clone, so churn covers only the fetched history and every \
hotspot figure is incomplete.{ownership} Run git fetch --unshallow for the full \
history."
)
}
WorkspaceDiagnosticKind::UnpinnedClock => {
"No commit timestamp was available, so churn recency and ownership staleness were \
measured against the wall clock and drift between runs over the same commit. Set \
FALLOW_CLOCK_EPOCH to pin the run clock."
.to_owned()
}
WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
if cause == "codeowners-parse-failed" {
format!(
"Ownership signals are degraded: CODEOWNERS could not be parsed ({error}), \
so hotspot entries carry no declared owner. Fix the CODEOWNERS syntax, or \
drop --ownership for this run."
)
} else {
format!(
"Ownership signals are degraded: health.ownership.botPatterns contains an \
invalid glob ({error}), so no author is classified as a bot and bot commits \
count towards ownership. Fix the pattern, or remove it from the config."
)
}
}
WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => format!(
"Skipped health snapshot '{display}' ({error}), so the trend is computed over fewer \
snapshots than this project has on disk. Delete the unreadable file, or rewrite it \
with fallow health --save-snapshot."
),
WorkspaceDiagnosticKind::TrendGroupBaselineUnavailable { cause } => {
if cause == "grouped-by-mismatch" {
format!(
"Health snapshot '{display}' was saved with a different --group-by mode, so \
the groups have no trend. The project trend is not affected. Save a new \
snapshot with the same --group-by mode."
)
} else {
format!(
"Health snapshot '{display}' holds no group data, so the groups have no \
trend. The project trend is not affected. Save a new snapshot with \
--group-by and --save-snapshot."
)
}
}
WorkspaceDiagnosticKind::CoverageAutoDetected => format!(
"Coverage was auto-detected at '{display}' rather than passed with --coverage, so the \
CRAP scores depend on whichever coverage file is on disk at run time. Pass --coverage \
'{display}' explicitly for reproducible scores."
),
WorkspaceDiagnosticKind::FlagAgeShallowClone => {
"This is a shallow clone, so the flag retirement report gives no flag age. Run git \
fetch --unshallow for the full history, or pass --flag-age off."
.to_owned()
}
WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => format!(
"ignoreDependencies glob '{pattern}' matched no declared dependency in this run, so \
it has no effect. A glob matches package names, such as @scope/*. Fix the glob, or \
remove it from the config."
),
WorkspaceDiagnosticKind::BaselineInstallStateMismatch {
baseline_node_modules,
..
} => {
let (saved, now, remedy) = match baseline_node_modules {
NodeModulesState::Present => (
"present",
"has no node_modules",
"Install dependencies before you compare",
),
NodeModulesState::Missing => (
"missing",
"has node_modules",
"Compare without installed dependencies",
),
};
format!(
"The baseline '{display}' was saved with node_modules {saved}, and this run \
{now}. Findings that need installed packages (dependency, unlisted, \
unresolved-import and script-binary findings) can differ, so the run can report \
baseline findings as new. {remedy}, or save the baseline again in the same \
install state."
)
}
WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => format!(
"ignoreFindings pattern '{pattern}' matched no finding in this run, so it has no \
effect. A pattern is a glob relative to the project root. Fix the pattern, or \
remove it from the config."
),
WorkspaceDiagnosticKind::FlagAgeUnavailable { cause } => {
if cause == "no-commits" {
"The flag retirement report gives no flag age, because the current branch has no \
commit. Commit the code, or pass --flag-age off."
.to_owned()
} else {
"The flag retirement report gives no flag age, because no git repository was \
found at the project root. Run fallow inside the repository, or pass --flag-age \
off."
.to_owned()
}
}
WorkspaceDiagnosticKind::PluginConfigUnreadable {
plugin,
key,
reason,
} => {
let (consequence, advice) = unreadable_key_consequence(key, reason);
format!(
"Plugin '{plugin}': `{key}` in '{display}' {situation}, so {consequence}. {advice}",
situation = unreadable_situation(reason)
)
}
WorkspaceDiagnosticKind::PluginEffectNotModeled {
plugin,
key,
reason,
} => {
let effect = "`autoImports` kept the convention entry patterns for that surface, and \
fallow reports no unused file there. Write the setting as static \
literals, or remove the key to use the framework defaults.";
if key.starts_with('#') {
format!(
"Plugin '{plugin}': fallow cannot read which names '{display}' takes from \
`{key}`, so every name of `{key}` counts as used, and fallow reports no \
unused file for these names. Read each name with a member access such as \
`C.Card`, or import it by name."
)
} else if reason == "config-property-unreadable" {
format!(
"Plugin '{plugin}': fallow cannot read a top-level property in '{display}', so \
it cannot classify the `{key}` surface. {effect}"
)
} else {
format!(
"Plugin '{plugin}': fallow does not model the effect of `{key}` in \
'{display}'. {effect}"
)
}
}
WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
pattern,
file_count,
directory_count,
} => {
let display = if display.is_empty() {
".".to_owned()
} else {
display
};
let location = if *directory_count > 1 {
format!(
"Skipped {file_count} source files across {directory_count} directories, \
the largest group under '{display}'"
)
} else if *file_count == 1 {
format!("Skipped 1 source file under '{display}'")
} else {
format!("Skipped {file_count} source files under '{display}'")
};
let singular = *file_count == 1 && *directory_count <= 1;
let (subject, effect) = if singular {
("it matches", "it imports, exports, or defines")
} else {
("they match", "they import, export, or define")
};
let remedy = if glob_first_literal_segment(pattern).is_some() {
format!(
"To analyze first-party source there, add '!{display}/**' to \
ignorePatterns, or analyze that directory on its own with \
fallow --root {display}."
)
} else {
"This pattern matches a file name rather than a directory, so re-running under \
a different --root excludes the same files again. Rename first-party source \
that only looks generated, dropping the '.min' or '.bundle' infix, or add a \
'!' entry for the file to ignorePatterns."
.to_owned()
};
format!(
"{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
{effect} is visible to this run. {remedy}"
)
}
}
}
#[cfg(test)]
#[path = "workspace_tests.rs"]
mod tests;