1use std::path::{Path, PathBuf};
15
16use rustc_hash::FxHashSet;
17#[cfg(feature = "schema")]
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::path_util::display_relative;
22use crate::serde_path;
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
27#[cfg_attr(feature = "schema", derive(JsonSchema))]
28#[serde(rename_all = "kebab-case")]
29pub enum PnpmWorkspaceOverridesIgnoredCause {
30 PackageJsonOverrides,
34 PnpmVersion,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
45#[cfg_attr(feature = "schema", derive(JsonSchema))]
46#[serde(tag = "kind", rename_all = "kebab-case")]
47pub enum WorkspaceDiagnosticKind {
48 UndeclaredWorkspace,
53 MalformedPackageJson {
56 error: String,
58 },
59 GlobMatchedNoPackageJson {
63 pattern: String,
65 },
66 MalformedTsconfig {
70 error: String,
72 },
73 TsconfigReferenceDirMissing,
76 MalformedPnpmWorkspaceYaml {
80 error: String,
82 },
83 SkippedLargeFile {
91 size_bytes: u64,
93 },
94 SkippedMinifiedFile {
100 size_bytes: u64,
102 },
103 SkippedSourceDotdir,
145 SourceReadFailure {
149 error: String,
151 },
152 SourceParseDegraded {
165 error_count: u32,
167 panicked: bool,
170 },
171 BunLockbOverrideResolutionSkipped,
185 BunLockOverrideResolutionSkipped,
189 PnpmLockOverrideResolutionSkipped,
194 NpmLockOverrideResolutionSkipped,
200 BunResolutionsShadowedByOverrides,
204 PnpmWorkspaceOverridesIgnored {
209 cause: PnpmWorkspaceOverridesIgnoredCause,
211 },
212 NodeModulesMissing,
228 BoundariesNotConfigured,
237 RulePacksNotConfigured,
242 ExcludedByDefaultIgnore {
303 pattern: String,
306 file_count: u32,
310 directory_count: u32,
320 },
321 NoSourceFilesAnalyzed {
339 excluded_file_count: u32,
343 },
344 FileScoresUnavailable {
350 error: String,
352 },
353 HotspotsSkipped {
357 cause: String,
368 },
369 ShallowClone {
372 ownership_requested: bool,
375 },
376 UnpinnedClock,
380 OwnershipUnavailable {
383 cause: String,
386 error: String,
388 },
389 TrendSnapshotUnreadable {
393 error: String,
395 },
396 TrendGroupBaselineUnavailable {
400 cause: String,
403 },
404 PluginConfigUnreadable {
430 plugin: String,
435 key: String,
440 reason: String,
450 },
451 PluginEffectNotModeled {
473 plugin: String,
475 key: String,
479 reason: String,
485 },
486 CoverageAutoDetected,
494 FlagAgeShallowClone,
498 FlagAgeUnavailable {
501 cause: String,
504 },
505 IgnoreDependenciesGlobUnmatched {
517 pattern: String,
519 },
520 IgnoreFindingsPatternUnmatched {
531 pattern: String,
533 },
534}
535
536impl WorkspaceDiagnosticKind {
537 #[must_use]
539 pub const fn id(&self) -> &'static str {
540 match self {
541 Self::UndeclaredWorkspace => "undeclared-workspace",
542 Self::MalformedPackageJson { .. } => "malformed-package-json",
543 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
544 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
545 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
546 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
547 Self::SkippedLargeFile { .. } => "skipped-large-file",
548 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
549 Self::SkippedSourceDotdir => "skipped-source-dotdir",
550 Self::SourceReadFailure { .. } => "source-read-failure",
551 Self::SourceParseDegraded { .. } => "source-parse-degraded",
552 Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
553 Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
554 Self::PnpmLockOverrideResolutionSkipped => "pnpm-lock-override-resolution-skipped",
555 Self::NpmLockOverrideResolutionSkipped => "npm-lock-override-resolution-skipped",
556 Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
557 Self::PnpmWorkspaceOverridesIgnored { .. } => "pnpm-workspace-overrides-ignored",
558 Self::NodeModulesMissing => "node-modules-missing",
559 Self::BoundariesNotConfigured => "boundaries-not-configured",
560 Self::RulePacksNotConfigured => "rule-packs-not-configured",
561 Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
562 Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
563 Self::FileScoresUnavailable { .. } => "file-scores-unavailable",
564 Self::HotspotsSkipped { .. } => "hotspots-skipped",
565 Self::ShallowClone { .. } => "shallow-clone",
566 Self::UnpinnedClock => "unpinned-clock",
567 Self::OwnershipUnavailable { .. } => "ownership-unavailable",
568 Self::TrendSnapshotUnreadable { .. } => "trend-snapshot-unreadable",
569 Self::TrendGroupBaselineUnavailable { .. } => "trend-group-baseline-unavailable",
570 Self::PluginConfigUnreadable { .. } => "plugin-config-unreadable",
571 Self::PluginEffectNotModeled { .. } => "plugin-effect-not-modeled",
572 Self::CoverageAutoDetected => "coverage-auto-detected",
573 Self::FlagAgeShallowClone => "flag-age-shallow-clone",
574 Self::FlagAgeUnavailable { .. } => "flag-age-unavailable",
575 Self::IgnoreDependenciesGlobUnmatched { .. } => "ignore-dependencies-glob-unmatched",
576 Self::IgnoreFindingsPatternUnmatched { .. } => "ignore-findings-pattern-unmatched",
577 }
578 }
579
580 #[must_use]
587 pub fn unmatched_config_pattern(&self) -> Option<(&'static str, &str)> {
588 match self {
589 Self::IgnoreDependenciesGlobUnmatched { pattern } => {
590 Some(("ignoreDependencies", pattern.as_str()))
591 }
592 Self::IgnoreFindingsPatternUnmatched { pattern } => {
593 Some(("ignoreFindings", pattern.as_str()))
594 }
595 _ => None,
596 }
597 }
598
599 #[must_use]
625 pub const fn warns_on_stderr(&self) -> bool {
626 match self {
627 Self::BoundariesNotConfigured
628 | Self::RulePacksNotConfigured
629 | Self::ExcludedByDefaultIgnore { .. }
630 | Self::PluginEffectNotModeled { .. }
631 | Self::CoverageAutoDetected
632 | Self::TrendGroupBaselineUnavailable { .. }
633 | Self::IgnoreDependenciesGlobUnmatched { .. }
634 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
635 Self::UndeclaredWorkspace
636 | Self::MalformedPackageJson { .. }
637 | Self::GlobMatchedNoPackageJson { .. }
638 | Self::MalformedTsconfig { .. }
639 | Self::TsconfigReferenceDirMissing
640 | Self::MalformedPnpmWorkspaceYaml { .. }
641 | Self::SkippedLargeFile { .. }
642 | Self::SkippedMinifiedFile { .. }
643 | Self::SkippedSourceDotdir
644 | Self::SourceReadFailure { .. }
645 | Self::SourceParseDegraded { .. }
646 | Self::BunLockbOverrideResolutionSkipped
647 | Self::BunLockOverrideResolutionSkipped
648 | Self::PnpmLockOverrideResolutionSkipped
649 | Self::NpmLockOverrideResolutionSkipped
650 | Self::BunResolutionsShadowedByOverrides
651 | Self::PnpmWorkspaceOverridesIgnored { .. }
652 | Self::NodeModulesMissing
653 | Self::NoSourceFilesAnalyzed { .. }
654 | Self::FileScoresUnavailable { .. }
655 | Self::HotspotsSkipped { .. }
656 | Self::ShallowClone { .. }
657 | Self::UnpinnedClock
658 | Self::OwnershipUnavailable { .. }
659 | Self::TrendSnapshotUnreadable { .. }
660 | Self::PluginConfigUnreadable { .. }
661 | Self::FlagAgeShallowClone
662 | Self::FlagAgeUnavailable { .. } => true,
663 }
664 }
665
666 #[must_use]
675 pub const fn is_source_discovery(&self) -> bool {
676 matches!(
677 self,
678 Self::SkippedLargeFile { .. }
679 | Self::SkippedMinifiedFile { .. }
680 | Self::SkippedSourceDotdir
681 | Self::SourceReadFailure { .. }
682 | Self::SourceParseDegraded { .. }
683 | Self::NodeModulesMissing
684 | Self::ExcludedByDefaultIgnore { .. }
685 | Self::NoSourceFilesAnalyzed { .. }
686 )
687 }
688
689 #[must_use]
702 pub const fn is_source_walk_recorded(&self) -> bool {
703 matches!(
704 self,
705 Self::SkippedLargeFile { .. }
706 | Self::SkippedMinifiedFile { .. }
707 | Self::SkippedSourceDotdir
708 | Self::NodeModulesMissing
709 | Self::ExcludedByDefaultIgnore { .. }
710 | Self::NoSourceFilesAnalyzed { .. }
711 )
712 }
713
714 #[must_use]
755 pub const fn source_never_analyzed(&self) -> bool {
756 match self {
757 Self::SkippedLargeFile { .. }
758 | Self::SkippedMinifiedFile { .. }
759 | Self::SkippedSourceDotdir
760 | Self::SourceReadFailure { .. } => true,
761 Self::UndeclaredWorkspace
762 | Self::MalformedPackageJson { .. }
763 | Self::GlobMatchedNoPackageJson { .. }
764 | Self::MalformedTsconfig { .. }
765 | Self::TsconfigReferenceDirMissing
766 | Self::MalformedPnpmWorkspaceYaml { .. }
767 | Self::SourceParseDegraded { .. }
768 | Self::BunLockbOverrideResolutionSkipped
769 | Self::BunLockOverrideResolutionSkipped
770 | Self::PnpmLockOverrideResolutionSkipped
771 | Self::NpmLockOverrideResolutionSkipped
772 | Self::BunResolutionsShadowedByOverrides
773 | Self::PnpmWorkspaceOverridesIgnored { .. }
774 | Self::NodeModulesMissing
775 | Self::BoundariesNotConfigured
776 | Self::RulePacksNotConfigured
777 | Self::ExcludedByDefaultIgnore { .. }
778 | Self::NoSourceFilesAnalyzed { .. }
779 | Self::FileScoresUnavailable { .. }
780 | Self::HotspotsSkipped { .. }
781 | Self::ShallowClone { .. }
782 | Self::UnpinnedClock
783 | Self::OwnershipUnavailable { .. }
784 | Self::TrendSnapshotUnreadable { .. }
785 | Self::TrendGroupBaselineUnavailable { .. }
786 | Self::PluginConfigUnreadable { .. }
787 | Self::PluginEffectNotModeled { .. }
788 | Self::CoverageAutoDetected
789 | Self::FlagAgeShallowClone
790 | Self::FlagAgeUnavailable { .. }
791 | Self::IgnoreDependenciesGlobUnmatched { .. }
792 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
793 }
794 }
795
796 #[must_use]
811 pub const fn is_analysis_stage(&self) -> bool {
812 match self {
813 Self::MalformedPnpmWorkspaceYaml { .. }
814 | Self::BunLockbOverrideResolutionSkipped
815 | Self::BunLockOverrideResolutionSkipped
816 | Self::PnpmLockOverrideResolutionSkipped
817 | Self::NpmLockOverrideResolutionSkipped
818 | Self::BunResolutionsShadowedByOverrides
819 | Self::PnpmWorkspaceOverridesIgnored { .. }
820 | Self::BoundariesNotConfigured
821 | Self::RulePacksNotConfigured => true,
822 Self::UndeclaredWorkspace
823 | Self::MalformedPackageJson { .. }
824 | Self::GlobMatchedNoPackageJson { .. }
825 | Self::MalformedTsconfig { .. }
826 | Self::TsconfigReferenceDirMissing
827 | Self::SkippedLargeFile { .. }
828 | Self::SkippedMinifiedFile { .. }
829 | Self::SkippedSourceDotdir
830 | Self::SourceReadFailure { .. }
831 | Self::SourceParseDegraded { .. }
832 | Self::NodeModulesMissing
833 | Self::ExcludedByDefaultIgnore { .. }
834 | Self::NoSourceFilesAnalyzed { .. }
835 | Self::FileScoresUnavailable { .. }
836 | Self::HotspotsSkipped { .. }
837 | Self::ShallowClone { .. }
838 | Self::UnpinnedClock
839 | Self::OwnershipUnavailable { .. }
840 | Self::TrendSnapshotUnreadable { .. }
841 | Self::TrendGroupBaselineUnavailable { .. }
842 | Self::PluginConfigUnreadable { .. }
843 | Self::PluginEffectNotModeled { .. }
844 | Self::CoverageAutoDetected
845 | Self::FlagAgeShallowClone
846 | Self::FlagAgeUnavailable { .. }
847 | Self::IgnoreDependenciesGlobUnmatched { .. }
848 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
849 }
850 }
851
852 #[must_use]
869 pub const fn is_health_stage(&self) -> bool {
870 match self {
871 Self::FileScoresUnavailable { .. }
872 | Self::HotspotsSkipped { .. }
873 | Self::ShallowClone { .. }
874 | Self::UnpinnedClock
875 | Self::OwnershipUnavailable { .. }
876 | Self::TrendSnapshotUnreadable { .. }
877 | Self::TrendGroupBaselineUnavailable { .. }
878 | Self::CoverageAutoDetected => true,
879 Self::UndeclaredWorkspace
880 | Self::MalformedPackageJson { .. }
881 | Self::GlobMatchedNoPackageJson { .. }
882 | Self::MalformedTsconfig { .. }
883 | Self::TsconfigReferenceDirMissing
884 | Self::MalformedPnpmWorkspaceYaml { .. }
885 | Self::SkippedLargeFile { .. }
886 | Self::SkippedMinifiedFile { .. }
887 | Self::SkippedSourceDotdir
888 | Self::SourceReadFailure { .. }
889 | Self::SourceParseDegraded { .. }
890 | Self::BunLockbOverrideResolutionSkipped
891 | Self::BunLockOverrideResolutionSkipped
892 | Self::PnpmLockOverrideResolutionSkipped
893 | Self::NpmLockOverrideResolutionSkipped
894 | Self::BunResolutionsShadowedByOverrides
895 | Self::PnpmWorkspaceOverridesIgnored { .. }
896 | Self::NodeModulesMissing
897 | Self::BoundariesNotConfigured
898 | Self::RulePacksNotConfigured
899 | Self::ExcludedByDefaultIgnore { .. }
900 | Self::PluginConfigUnreadable { .. }
901 | Self::PluginEffectNotModeled { .. }
902 | Self::NoSourceFilesAnalyzed { .. }
903 | Self::FlagAgeShallowClone
904 | Self::FlagAgeUnavailable { .. }
905 | Self::IgnoreDependenciesGlobUnmatched { .. }
906 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
907 }
908 }
909
910 #[must_use]
929 pub const fn is_plugin_stage(&self) -> bool {
930 match self {
931 Self::PluginConfigUnreadable { .. } | Self::PluginEffectNotModeled { .. } => true,
932 Self::UndeclaredWorkspace
933 | Self::MalformedPackageJson { .. }
934 | Self::GlobMatchedNoPackageJson { .. }
935 | Self::MalformedTsconfig { .. }
936 | Self::TsconfigReferenceDirMissing
937 | Self::MalformedPnpmWorkspaceYaml { .. }
938 | Self::SkippedLargeFile { .. }
939 | Self::SkippedMinifiedFile { .. }
940 | Self::SkippedSourceDotdir
941 | Self::SourceReadFailure { .. }
942 | Self::SourceParseDegraded { .. }
943 | Self::BunLockbOverrideResolutionSkipped
944 | Self::BunLockOverrideResolutionSkipped
945 | Self::PnpmLockOverrideResolutionSkipped
946 | Self::NpmLockOverrideResolutionSkipped
947 | Self::BunResolutionsShadowedByOverrides
948 | Self::PnpmWorkspaceOverridesIgnored { .. }
949 | Self::NodeModulesMissing
950 | Self::BoundariesNotConfigured
951 | Self::RulePacksNotConfigured
952 | Self::ExcludedByDefaultIgnore { .. }
953 | Self::NoSourceFilesAnalyzed { .. }
954 | Self::FileScoresUnavailable { .. }
955 | Self::HotspotsSkipped { .. }
956 | Self::ShallowClone { .. }
957 | Self::UnpinnedClock
958 | Self::OwnershipUnavailable { .. }
959 | Self::TrendSnapshotUnreadable { .. }
960 | Self::TrendGroupBaselineUnavailable { .. }
961 | Self::CoverageAutoDetected
962 | Self::FlagAgeShallowClone
963 | Self::FlagAgeUnavailable { .. }
964 | Self::IgnoreDependenciesGlobUnmatched { .. }
965 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
966 }
967 }
968}
969
970#[must_use]
973fn format_size_mb(bytes: u64) -> String {
974 #[expect(
975 clippy::cast_precision_loss,
976 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
977 )]
978 let mb = bytes as f64 / (1024.0 * 1024.0);
979 format!("{mb:.1} MB")
980}
981
982#[derive(Debug, Clone, Serialize, Deserialize)]
989#[cfg_attr(feature = "schema", derive(JsonSchema))]
990pub struct WorkspaceDiagnostic {
991 #[serde(serialize_with = "serde_path::serialize")]
993 pub path: PathBuf,
994 #[serde(flatten)]
996 pub kind: WorkspaceDiagnosticKind,
997 pub message: String,
1000 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1017 pub degrades_analysis: bool,
1018}
1019
1020impl WorkspaceDiagnostic {
1021 #[must_use]
1039 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
1040 let path = normalise_diagnostic_path(path);
1041 let kind = normalise_payload_paths(root, kind);
1042 let message = render_message(root, &path, &kind);
1043 let degrades_analysis = kind.warns_on_stderr();
1044 Self {
1045 path,
1046 kind,
1047 message,
1048 degrades_analysis,
1049 }
1050 }
1051
1052 #[must_use]
1070 pub fn into_root_relative(mut self, root: &Path) -> Self {
1071 if let Ok(relative) = self.path.strip_prefix(root) {
1072 self.path = if relative.as_os_str().is_empty() {
1073 PathBuf::from(".")
1074 } else {
1075 relative.to_path_buf()
1076 };
1077 }
1078 self
1079 }
1080}
1081
1082fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
1099 let rebuilt: PathBuf = path.components().collect();
1100 if rebuilt.as_os_str() == path.as_os_str() {
1101 path
1102 } else {
1103 rebuilt
1104 }
1105}
1106
1107fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
1120 let root_str = root.display().to_string();
1121 let root_alt = root_str.replace('\\', "/");
1122 let normalise = |text: String| -> String {
1123 let stripped = text
1124 .replace(&format!("{root_str}/"), "")
1125 .replace(&format!("{root_alt}/"), "");
1126 stripped
1127 .replace(&format!("{root_str}\\"), "")
1128 .replace(&format!("{root_alt}\\"), "")
1129 };
1130 match kind {
1131 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
1132 WorkspaceDiagnosticKind::MalformedPackageJson {
1133 error: normalise(error),
1134 }
1135 }
1136 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
1137 WorkspaceDiagnosticKind::MalformedTsconfig {
1138 error: normalise(error),
1139 }
1140 }
1141 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
1142 WorkspaceDiagnosticKind::SourceReadFailure {
1143 error: normalise(error),
1144 }
1145 }
1146 WorkspaceDiagnosticKind::FileScoresUnavailable { error } => {
1147 WorkspaceDiagnosticKind::FileScoresUnavailable {
1148 error: normalise(error),
1149 }
1150 }
1151 WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1152 WorkspaceDiagnosticKind::OwnershipUnavailable {
1153 cause,
1154 error: normalise(error),
1155 }
1156 }
1157 WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => {
1158 WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
1159 error: normalise(error),
1160 }
1161 }
1162 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1163 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1164 pattern: canonical_glob_pattern(pattern),
1165 }
1166 }
1167 other => other,
1168 }
1169}
1170
1171fn canonical_glob_pattern(pattern: String) -> String {
1178 for prefix in ["./", ".\\"] {
1179 if let Some(rest) = pattern.strip_prefix(prefix)
1180 && !rest.is_empty()
1181 {
1182 return rest.to_owned();
1183 }
1184 }
1185 pattern
1186}
1187
1188#[must_use]
1208pub fn merge_workspace_diagnostics(
1209 primary: Vec<WorkspaceDiagnostic>,
1210 secondary: Vec<WorkspaceDiagnostic>,
1211) -> Vec<WorkspaceDiagnostic> {
1212 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
1213 let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
1214 for diagnostic in primary.into_iter().chain(secondary) {
1215 let key = (diagnostic.kind.clone(), diagnostic.path.clone());
1216 if seen.insert(key) {
1217 merged.push(diagnostic);
1218 }
1219 }
1220 merged
1221}
1222
1223#[must_use]
1235pub fn dedupe_workspace_diagnostics(
1236 diagnostics: Vec<WorkspaceDiagnostic>,
1237) -> Vec<WorkspaceDiagnostic> {
1238 merge_workspace_diagnostics(diagnostics, Vec::new())
1239}
1240
1241#[must_use]
1253pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
1254 pattern.split('/').find(|segment| {
1255 !segment.is_empty()
1256 && !segment.contains(['*', '?', '[', ']', '{', '}'])
1257 && *segment != "."
1258 && *segment != ".."
1259 })
1260}
1261
1262fn unreadable_situation(reason: &str) -> &'static str {
1269 match reason {
1270 "array-form" => "uses the array form, which is not read yet",
1271 "spread" => "spreads a value that is not statically readable",
1272 "unreadable-entries" => "has entries that hold no statically readable value",
1273 "not-object-literal" => "is not a static object literal",
1274 "unrecognized-call" => "is passed through a call that is not a known config wrapper",
1275 "import-target-unreadable" => "comes from an imported file that is not statically readable",
1276 "dynamic-argument" => "receives an argument that is not a static literal",
1277 _ => "could not be read statically",
1278 }
1279}
1280
1281fn unreadable_key_consequence(key: &str, reason: &str) -> (&'static str, &'static str) {
1297 match (key, reason) {
1298 ("exposes", "unrecognized-call") => (
1299 "only the targets in the object literal it receives are registered as entry points",
1300 "Name any other exposed files in `dynamicallyLoaded`.",
1301 ),
1302 ("remotes", "unrecognized-call") => (
1303 "only the aliases in the object literal it receives are treated as provided by a \
1304 remote container",
1305 "Name any other aliases in `ignoreDependencies`.",
1306 ),
1307 ("exposes", "import-target-unreadable") => (
1308 "the targets that file declares are not registered as entry points",
1309 "Name the exposed files in `dynamicallyLoaded`.",
1310 ),
1311 ("remotes", "import-target-unreadable") => (
1312 "the aliases that file declares are not treated as provided by a remote container",
1313 "Name the aliases in `ignoreDependencies`.",
1314 ),
1315 ("registerRemotes", _) => (
1316 "the remotes it registers are not treated as provided by a remote container",
1317 "Name the remote aliases in `ignoreDependencies`, or pass the remote names as \
1318 string literals.",
1319 ),
1320 ("init" | "createInstance", _) => (
1321 "the remotes its options declare are not treated as provided by a remote container",
1322 "Name the remote aliases in `ignoreDependencies`, or pass `remotes` as an array of \
1323 objects with literal names.",
1324 ),
1325 ("loadRemote", _) => (
1326 "the remote it loads is not treated as provided by a remote container",
1327 "Name the remote alias in `ignoreDependencies`, or pass the request as a string \
1328 literal.",
1329 ),
1330 ("exposes", _) => (
1331 "the targets are not registered as entry points",
1332 "Name the exposed files in `dynamicallyLoaded`.",
1333 ),
1334 ("remotes", _) => (
1335 "the aliases are not treated as provided by a remote container",
1336 "Name the aliases in `ignoreDependencies`, or declare them as the keys of an object \
1337 literal, whose values may be computed.",
1338 ),
1339 _ => (
1340 "what it declares is not fully registered",
1341 "Declare the value as a static object literal.",
1342 ),
1343 }
1344}
1345
1346fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
1347 let display = display_relative(root, path);
1348 match kind {
1349 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
1350 "Directory '{display}' contains package.json but is not declared as a workspace. \
1351 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
1352 ),
1353 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
1354 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
1355 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
1356 ),
1357 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
1358 "Glob '{pattern}' matched '{display}' but no package.json is present. \
1359 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
1360 ),
1361 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
1362 "tsconfig at '{display}' failed to parse ({error}); \
1363 its project references, path aliases and compiler options are ignored. \
1364 Fix the JSON syntax."
1365 ),
1366 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
1367 "tsconfig.json references '{display}' but the directory does not exist. \
1368 Update or remove the reference, or restore the missing directory."
1369 ),
1370 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
1371 "'{display}' is not valid YAML ({error}). Catalog checks are skipped \
1372 and its override entries are ignored until it parses. pnpm install \
1373 can accept this file, but `pnpm add` and YAML formatters reject it. \
1374 Indent each continuation line of a quoted value or a `[...]` / \
1375 `{{...}}` list deeper than its key."
1376 ),
1377 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
1378 "Skipped '{display}' ({size}): exceeds the max file size limit. \
1379 Its imports and exports are not analyzed. Raise the limit with \
1380 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
1381 to ignorePatterns.",
1382 size = format_size_mb(*size_bytes)
1383 ),
1384 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
1385 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
1386 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
1387 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
1388 should be analyzed.",
1389 size = format_size_mb(*size_bytes)
1390 ),
1391 WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
1392 "Skipped hidden directory '{display}': it contains source files but hidden \
1393 directories are not traversed. Its imports and exports are not analyzed. \
1394 A file, export or dependency that only this directory uses can be reported as \
1395 unused. To analyze it, add '!{display}/**' to ignorePatterns. To stop \
1396 that false positive without that, add the file to entry, the export to \
1397 ignoreExports or the dependency to ignoreDependencies. To silence this message, \
1398 add '{display}/**' to ignorePatterns. fallow --root {display} analyzes only \
1399 that directory on its own and does not fix this run."
1400 ),
1401 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
1402 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
1403 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
1404 ),
1405 WorkspaceDiagnosticKind::SourceParseDegraded {
1406 error_count,
1407 panicked,
1408 } => {
1409 let outcome = if *panicked {
1410 "the parser stopped there"
1411 } else {
1412 "the parser recovered and continued"
1413 };
1414 format!(
1415 "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
1416 references it did not reach are missing from this run, so files and symbols it \
1417 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
1418 syntax newer than fallow's parser."
1419 )
1420 }
1421 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
1422 "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
1423 sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
1424 (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
1425 use instead, so unused-dependency-overrides findings are not reported. Run bun install \
1426 --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
1427 bun.lockb if this repository no longer uses bun."
1428 ),
1429 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
1430 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1431 no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
1432 findings are not reported. Run bun install to regenerate the text lockfile, then \
1433 rerun fallow."
1434 ),
1435 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped => format!(
1436 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1437 no other parseable lockfile was available, so unused-dependency-overrides findings \
1438 are not reported. Resolve any merge-conflict markers in the lockfile, or run pnpm \
1439 install to regenerate it, then rerun fallow."
1440 ),
1441 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped => format!(
1442 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1443 no other parseable lockfile was available, so unused-dependency-overrides findings \
1444 are not reported. Resolve any merge-conflict markers in the lockfile, or run npm \
1445 install to regenerate it, then rerun fallow."
1446 ),
1447 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
1448 "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
1449 `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
1450 remove the shadowed `resolutions` entries."
1451 ),
1452 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1453 cause: PnpmWorkspaceOverridesIgnoredCause::PackageJsonOverrides,
1454 } => format!(
1455 "pnpm 10 and earlier ignore the `overrides` in '{display}' because the root package.json \
1456 declares `pnpm.overrides` or `resolutions`, so fallow does not check them. Move the \
1457 entries into one source."
1458 ),
1459 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1460 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
1461 } => format!(
1462 "pnpm versions before 10.5.1 do not read the `overrides` in '{display}', so fallow \
1463 does not check them. Update `packageManager` to pnpm 10.5.1 or later, or move the \
1464 entries into `pnpm.overrides` in the root package.json."
1465 ),
1466 WorkspaceDiagnosticKind::NodeModulesMissing => format!(
1467 "'{display}' does not exist. Package exports and conditional exports cannot be read, \
1468 framework plugins that activate on an installed package stay inactive, and \
1469 dependency classification degrades, so imports and dependencies can be \
1470 misreported. Run npm install / pnpm install / yarn / bun install first."
1471 ),
1472 WorkspaceDiagnosticKind::BoundariesNotConfigured => {
1473 "No architecture boundaries are configured, so the boundary detector did not run and \
1474 its violation counts are zero because nothing was measured. Add `boundaries` to the \
1475 config, or set `boundary-violation` to off to state that the check is not wanted."
1476 .to_string()
1477 }
1478 WorkspaceDiagnosticKind::RulePacksNotConfigured => {
1479 "No rule packs are configured, so the policy detector did not run and its violation \
1480 counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
1481 `policy-violation` to off to state that the check is not wanted."
1482 .to_string()
1483 }
1484 WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
1485 excluded_file_count,
1486 } => {
1487 if *excluded_file_count == 0 {
1488 "No source files were analyzed, so every finding count this run reports is zero \
1489 because nothing was measured. Check the analysis root, ignorePatterns, and any \
1490 path or workspace filter this run applied."
1491 .to_owned()
1492 } else {
1493 format!(
1494 "No source files were analyzed. Fallow's built-in ignore patterns excluded \
1495 {excluded_file_count} candidate files, so every finding count this run \
1496 reports is zero because nothing was measured; run with --explain-skipped \
1497 for the breakdown."
1498 )
1499 }
1500 }
1501 WorkspaceDiagnosticKind::FileScoresUnavailable { error } => format!(
1502 "Could not compute per-file health scores ({error}), so the score list is empty and \
1503 the scored-file count is 0 because nothing was measured rather than because the \
1504 project has nothing to score. Rerun with --no-cache, or scope the run to a \
1505 subdirectory to find the input that fails."
1506 ),
1507 WorkspaceDiagnosticKind::HotspotsSkipped { cause } => match cause.as_str() {
1508 "invalid-since" => "Hotspot analysis was skipped because --since could not be read \
1509 as a time window, so the hotspots, churn and ownership sections report nothing \
1510 rather than zero. Spell it as a duration such as 6m or 90d, or drop it to use \
1511 the default window."
1512 .to_owned(),
1513 "no-commits" => "Hotspot analysis was skipped because the current branch has no \
1514 commits yet, so the hotspots, churn and ownership sections report nothing \
1515 rather than zero. Commit the project to give churn a history, or pass \
1516 --churn-file with exported change history."
1517 .to_owned(),
1518 "churn-file-unreadable" => format!(
1519 "Hotspot analysis was skipped because the churn file '{display}' could no longer \
1520 be read after it was validated, so the hotspots, churn and ownership sections \
1521 report nothing rather than zero. Make sure nothing rewrites the file while \
1522 fallow runs, and rerun."
1523 ),
1524 _ => "Hotspot analysis was skipped because no git repository was found at the \
1528 project root, so the hotspots, churn and ownership sections report nothing \
1529 rather than zero. Run fallow inside the repository, or pass --churn-file with \
1530 exported change history."
1531 .to_owned(),
1532 },
1533 WorkspaceDiagnosticKind::ShallowClone {
1534 ownership_requested,
1535 } => {
1536 let ownership = if *ownership_requested {
1537 " Ownership signals are skewed too, because a shallow clone inflates \
1538 single-author dominance."
1539 } else {
1540 ""
1541 };
1542 format!(
1543 "This is a shallow clone, so churn covers only the fetched history and every \
1544 hotspot figure is incomplete.{ownership} Run git fetch --unshallow for the full \
1545 history."
1546 )
1547 }
1548 WorkspaceDiagnosticKind::UnpinnedClock => {
1549 "No commit timestamp was available, so churn recency and ownership staleness were \
1550 measured against the wall clock and drift between runs over the same commit. Set \
1551 FALLOW_CLOCK_EPOCH to pin the run clock."
1552 .to_owned()
1553 }
1554 WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1555 if cause == "codeowners-parse-failed" {
1556 format!(
1557 "Ownership signals are degraded: CODEOWNERS could not be parsed ({error}), \
1558 so hotspot entries carry no declared owner. Fix the CODEOWNERS syntax, or \
1559 drop --ownership for this run."
1560 )
1561 } else {
1562 format!(
1563 "Ownership signals are degraded: health.ownership.botPatterns contains an \
1564 invalid glob ({error}), so no author is classified as a bot and bot commits \
1565 count towards ownership. Fix the pattern, or remove it from the config."
1566 )
1567 }
1568 }
1569 WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => format!(
1570 "Skipped health snapshot '{display}' ({error}), so the trend is computed over fewer \
1571 snapshots than this project has on disk. Delete the unreadable file, or rewrite it \
1572 with fallow health --save-snapshot."
1573 ),
1574 WorkspaceDiagnosticKind::TrendGroupBaselineUnavailable { cause } => {
1575 if cause == "grouped-by-mismatch" {
1576 format!(
1577 "Health snapshot '{display}' was saved with a different --group-by mode, so \
1578 the groups have no trend. The project trend is not affected. Save a new \
1579 snapshot with the same --group-by mode."
1580 )
1581 } else {
1582 format!(
1583 "Health snapshot '{display}' holds no group data, so the groups have no \
1584 trend. The project trend is not affected. Save a new snapshot with \
1585 --group-by and --save-snapshot."
1586 )
1587 }
1588 }
1589 WorkspaceDiagnosticKind::CoverageAutoDetected => format!(
1590 "Coverage was auto-detected at '{display}' rather than passed with --coverage, so the \
1591 CRAP scores depend on whichever coverage file is on disk at run time. Pass --coverage \
1592 '{display}' explicitly for reproducible scores."
1593 ),
1594 WorkspaceDiagnosticKind::FlagAgeShallowClone => {
1595 "This is a shallow clone, so the flag retirement report gives no flag age. Run git \
1596 fetch --unshallow for the full history, or pass --flag-age off."
1597 .to_owned()
1598 }
1599 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => format!(
1600 "ignoreDependencies glob '{pattern}' matched no declared dependency in this run, so \
1601 it has no effect. A glob matches package names, such as @scope/*. Fix the glob, or \
1602 remove it from the config."
1603 ),
1604 WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => format!(
1605 "ignoreFindings pattern '{pattern}' matched no finding in this run, so it has no \
1606 effect. A pattern is a glob relative to the project root. Fix the pattern, or \
1607 remove it from the config."
1608 ),
1609 WorkspaceDiagnosticKind::FlagAgeUnavailable { cause } => {
1610 if cause == "no-commits" {
1611 "The flag retirement report gives no flag age, because the current branch has no \
1612 commit. Commit the code, or pass --flag-age off."
1613 .to_owned()
1614 } else {
1615 "The flag retirement report gives no flag age, because no git repository was \
1616 found at the project root. Run fallow inside the repository, or pass --flag-age \
1617 off."
1618 .to_owned()
1619 }
1620 }
1621 WorkspaceDiagnosticKind::PluginConfigUnreadable {
1622 plugin,
1623 key,
1624 reason,
1625 } => {
1626 let (consequence, advice) = unreadable_key_consequence(key, reason);
1627 format!(
1628 "Plugin '{plugin}': `{key}` in '{display}' {situation}, so {consequence}. {advice}",
1629 situation = unreadable_situation(reason)
1630 )
1631 }
1632 WorkspaceDiagnosticKind::PluginEffectNotModeled {
1633 plugin,
1634 key,
1635 reason,
1636 } => {
1637 let effect = "`autoImports` kept the convention entry patterns for that surface, and \
1642 fallow reports no unused file there. Write the setting as static \
1643 literals, or remove the key to use the framework defaults.";
1644 if key.starts_with('#') {
1645 format!(
1646 "Plugin '{plugin}': fallow cannot read which names '{display}' takes from \
1647 `{key}`, so every name of `{key}` counts as used, and fallow reports no \
1648 unused file for these names. Read each name with a member access such as \
1649 `C.Card`, or import it by name."
1650 )
1651 } else if reason == "config-property-unreadable" {
1652 format!(
1653 "Plugin '{plugin}': fallow cannot read a top-level property in '{display}', so \
1654 it cannot classify the `{key}` surface. {effect}"
1655 )
1656 } else {
1657 format!(
1658 "Plugin '{plugin}': fallow does not model the effect of `{key}` in \
1659 '{display}'. {effect}"
1660 )
1661 }
1662 }
1663 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1664 pattern,
1665 file_count,
1666 directory_count,
1667 } => {
1668 let display = if display.is_empty() {
1672 ".".to_owned()
1673 } else {
1674 display
1675 };
1676 let location = if *directory_count > 1 {
1682 format!(
1683 "Skipped {file_count} source files across {directory_count} directories, \
1684 the largest group under '{display}'"
1685 )
1686 } else if *file_count == 1 {
1687 format!("Skipped 1 source file under '{display}'")
1688 } else {
1689 format!("Skipped {file_count} source files under '{display}'")
1690 };
1691 let singular = *file_count == 1 && *directory_count <= 1;
1692 let (subject, effect) = if singular {
1693 ("it matches", "it imports, exports, or defines")
1694 } else {
1695 ("they match", "they import, export, or define")
1696 };
1697 let remedy = if glob_first_literal_segment(pattern).is_some() {
1701 format!(
1702 "To analyze first-party source there, add '!{display}/**' to \
1703 ignorePatterns, or analyze that directory on its own with \
1704 fallow --root {display}."
1705 )
1706 } else {
1707 "This pattern matches a file name rather than a directory, so re-running under \
1708 a different --root excludes the same files again. Rename first-party source \
1709 that only looks generated, dropping the '.min' or '.bundle' infix, or add a \
1710 '!' entry for the file to ignorePatterns."
1711 .to_owned()
1712 };
1713 format!(
1714 "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
1715 {effect} is visible to this run. {remedy}"
1716 )
1717 }
1718 }
1719}
1720
1721#[cfg(test)]
1722mod tests {
1723 use super::*;
1724
1725 #[test]
1726 fn skipped_large_file_diagnostic_id_and_message() {
1727 let root = Path::new("/project");
1728 let diag = WorkspaceDiagnostic::new(
1729 root,
1730 root.join("src/vendor/app.bundle.js"),
1731 WorkspaceDiagnosticKind::SkippedLargeFile {
1732 size_bytes: 6 * 1024 * 1024,
1733 },
1734 );
1735 assert_eq!(diag.kind.id(), "skipped-large-file");
1736 assert!(
1737 diag.message.contains("src/vendor/app.bundle.js"),
1738 "message names the project-relative path: {}",
1739 diag.message
1740 );
1741 assert!(
1742 diag.message.contains("6.0 MB"),
1743 "message reports the size: {}",
1744 diag.message
1745 );
1746 assert!(
1747 diag.message.contains("--max-file-size"),
1748 "message names the override flag: {}",
1749 diag.message
1750 );
1751 }
1752
1753 #[test]
1754 fn skipped_minified_file_diagnostic_id_and_message() {
1755 let root = Path::new("/project");
1756 let diag = WorkspaceDiagnostic::new(
1757 root,
1758 root.join("src/assets/index-abc123.js"),
1759 WorkspaceDiagnosticKind::SkippedMinifiedFile {
1760 size_bytes: 2 * 1024 * 1024,
1761 },
1762 );
1763 assert_eq!(diag.kind.id(), "skipped-minified-file");
1764 assert!(
1765 diag.message.contains("src/assets/index-abc123.js"),
1766 "message names the project-relative path: {}",
1767 diag.message
1768 );
1769 assert!(
1770 diag.message.contains("2.0 MB"),
1771 "message reports the size: {}",
1772 diag.message
1773 );
1774 assert!(
1775 diag.message.contains("--max-file-size 0"),
1776 "message names the opt-out: {}",
1777 diag.message
1778 );
1779 }
1780
1781 #[test]
1782 fn skipped_source_dotdir_diagnostic_id_and_message() {
1783 let root = Path::new("/project");
1784 let diag = WorkspaceDiagnostic::new(
1785 root,
1786 root.join(".claude"),
1787 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1788 );
1789 assert_eq!(diag.kind.id(), "skipped-source-dotdir");
1790 assert!(
1791 diag.message.contains(".claude"),
1792 "message names the project-relative path: {}",
1793 diag.message
1794 );
1795 assert!(
1796 diag.message
1797 .contains("Its imports and exports are not analyzed."),
1798 "message states the consequence: {}",
1799 diag.message
1800 );
1801 for remedy in [
1802 "add the file to entry",
1803 "the export to ignoreExports",
1804 "the dependency to ignoreDependencies",
1805 ] {
1806 assert!(
1807 diag.message.contains(remedy),
1808 "message names the remedy `{remedy}`: {}",
1809 diag.message
1810 );
1811 }
1812 assert!(
1813 diag.message
1814 .contains("fallow --root .claude analyzes only that directory")
1815 && diag.message.contains("does not fix this run"),
1816 "message must not imply that --root fixes this run: {}",
1817 diag.message
1818 );
1819 assert!(
1820 diag.message.contains("ignorePatterns"),
1821 "message names the silencing route: {}",
1822 diag.message
1823 );
1824 assert!(
1825 diag.message.contains("add '!.claude/**' to ignorePatterns"),
1826 "the message names the exception that traverses it (issue #2452): {}",
1827 diag.message
1828 );
1829 assert_eq!(
1830 serde_json::to_value(&diag).expect("serializes")["kind"],
1831 "skipped-source-dotdir",
1832 "id() must byte-match the serde kebab-case tag"
1833 );
1834 }
1835
1836 #[cfg(feature = "schema")]
1837 #[test]
1838 fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
1839 let schema = schemars::schema_for!(WorkspaceDiagnostic);
1840 let json = serde_json::to_string(&schema).expect("schema serializes");
1841 assert!(json.contains("skipped-source-dotdir"));
1842 }
1843
1844 #[test]
1845 fn source_read_failure_serializes_typed_error_payload() {
1846 let root = Path::new("/project");
1847 let diagnostic = WorkspaceDiagnostic::new(
1848 root,
1849 root.join("src/removed.ts"),
1850 WorkspaceDiagnosticKind::SourceReadFailure {
1851 error: "No such file or directory".to_string(),
1852 },
1853 );
1854
1855 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
1856 assert_eq!(json["kind"], "source-read-failure");
1857 assert_eq!(
1858 json["path"],
1859 root.join("src/removed.ts")
1860 .display()
1861 .to_string()
1862 .replace('\\', "/")
1863 );
1864 assert_eq!(json["error"], "No such file or directory");
1865 assert!(
1866 json["message"]
1867 .as_str()
1868 .is_some_and(|message| message.contains("src/removed.ts"))
1869 );
1870 }
1871
1872 #[cfg(feature = "schema")]
1873 #[test]
1874 fn workspace_diagnostic_schema_includes_source_read_failure() {
1875 let schema = schemars::schema_for!(WorkspaceDiagnostic);
1876 let json = serde_json::to_string(&schema).expect("schema serializes");
1877 assert!(json.contains("source-read-failure"));
1878 assert!(json.contains("error"));
1879 }
1880
1881 #[test]
1882 fn bun_lockb_override_resolution_skipped_id_and_message() {
1883 let root = Path::new("/project");
1884 let diag = WorkspaceDiagnostic::new(
1885 root,
1886 root.join("package.json"),
1887 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1888 );
1889 assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
1890 assert!(
1891 diag.message.contains("'package.json'"),
1892 "message names the project-relative manifest: {}",
1893 diag.message
1894 );
1895 assert!(
1896 diag.message.contains("no parseable text lockfile"),
1897 "message states the cause: {}",
1898 diag.message
1899 );
1900 assert!(
1901 !diag.message.contains("only bun.lockb"),
1902 "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
1903 bun.lock may sit beside it: {}",
1904 diag.message
1905 );
1906 assert!(
1907 diag.message.contains("bun install --save-text-lockfile")
1908 && diag.message.contains("delete the stale bun.lockb"),
1909 "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
1910 diag.message
1911 );
1912 let json = serde_json::to_value(&diag).expect("diagnostic serializes");
1913 assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
1914 }
1915
1916 #[test]
1917 fn lockfile_override_diagnostic_ids_and_messages_are_actionable() {
1918 let root = Path::new("/project");
1919 let malformed = WorkspaceDiagnostic::new(
1920 root,
1921 root.join("bun.lock"),
1922 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1923 );
1924 assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
1925 assert!(malformed.message.contains("regenerate"));
1926
1927 let pnpm = WorkspaceDiagnostic::new(
1928 root,
1929 root.join("pnpm-lock.yaml"),
1930 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
1931 );
1932 assert_eq!(pnpm.kind.id(), "pnpm-lock-override-resolution-skipped");
1933 assert!(pnpm.message.contains("merge-conflict markers"));
1934 assert!(pnpm.message.contains("pnpm install"));
1935
1936 let npm = WorkspaceDiagnostic::new(
1937 root,
1938 root.join("package-lock.json"),
1939 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
1940 );
1941 assert_eq!(npm.kind.id(), "npm-lock-override-resolution-skipped");
1942 assert!(npm.message.contains("package-lock.json"));
1943 assert!(npm.message.contains("merge-conflict markers"));
1944 assert!(npm.message.contains("npm install"));
1945
1946 let shadowed = WorkspaceDiagnostic::new(
1947 root,
1948 root.join("package.json"),
1949 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1950 );
1951 assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
1952 assert!(shadowed.message.contains("ignores `resolutions`"));
1953
1954 let ignored = WorkspaceDiagnostic::new(
1955 root,
1956 root.join("pnpm-workspace.yaml"),
1957 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1958 cause: PnpmWorkspaceOverridesIgnoredCause::PackageJsonOverrides,
1959 },
1960 );
1961 assert_eq!(ignored.kind.id(), "pnpm-workspace-overrides-ignored");
1962 assert!(ignored.message.contains("pnpm-workspace.yaml"));
1963 assert!(
1964 ignored
1965 .message
1966 .contains("pnpm 10 and earlier ignore the `overrides`")
1967 );
1968
1969 let too_old = WorkspaceDiagnostic::new(
1970 root,
1971 root.join("pnpm-workspace.yaml"),
1972 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1973 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
1974 },
1975 );
1976 assert_eq!(too_old.kind.id(), "pnpm-workspace-overrides-ignored");
1977 assert!(too_old.message.contains("pnpm-workspace.yaml"));
1978 assert!(
1979 too_old
1980 .message
1981 .contains("pnpm versions before 10.5.1 do not read the `overrides`")
1982 );
1983 let json = serde_json::to_value(&too_old).expect("diagnostic serializes");
1984 assert_eq!(json["kind"], "pnpm-workspace-overrides-ignored");
1985 assert_eq!(json["cause"], "pnpm-version");
1986 let json = serde_json::to_value(&ignored).expect("diagnostic serializes");
1987 assert_eq!(json["cause"], "package-json-overrides");
1988 }
1989
1990 #[test]
1991 fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
1992 let root = Path::new("/project");
1993 let inside = WorkspaceDiagnostic::new(
1994 root,
1995 root.join("packages/inner"),
1996 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1997 )
1998 .into_root_relative(root);
1999 assert_eq!(inside.path, Path::new("packages/inner"));
2000
2001 let outside = WorkspaceDiagnostic::new(
2002 root,
2003 PathBuf::from("/elsewhere/packages/inner"),
2004 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2005 )
2006 .into_root_relative(root);
2007 assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
2008 }
2009
2010 #[test]
2011 fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
2012 let analysis_stage = [
2013 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
2014 error: "bad yaml".to_owned(),
2015 },
2016 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
2017 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
2018 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
2019 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
2020 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
2021 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
2022 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
2023 },
2024 ];
2025 for kind in &analysis_stage {
2026 assert!(
2027 kind.is_analysis_stage() && !kind.is_source_discovery(),
2028 "{} is recorded by the analyze stage only",
2029 kind.id()
2030 );
2031 }
2032
2033 let other = [
2034 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2035 WorkspaceDiagnosticKind::MalformedPackageJson {
2036 error: "trailing comma".to_owned(),
2037 },
2038 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2039 pattern: "packages/*".to_owned(),
2040 },
2041 WorkspaceDiagnosticKind::MalformedTsconfig {
2042 error: "unexpected token".to_owned(),
2043 },
2044 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
2045 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
2046 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
2047 WorkspaceDiagnosticKind::SkippedSourceDotdir,
2048 WorkspaceDiagnosticKind::SourceReadFailure {
2049 error: "permission denied".to_owned(),
2050 },
2051 ];
2052 for kind in &other {
2053 assert!(
2054 !kind.is_analysis_stage(),
2055 "{} is a discovery kind, not an analyze-stage kind",
2056 kind.id()
2057 );
2058 }
2059 }
2060
2061 #[test]
2062 fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
2063 let root = Path::new("/project");
2064 let first = WorkspaceDiagnostic::new(
2065 root,
2066 root.join("packages/aaa"),
2067 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2068 pattern: "packages/*".to_owned(),
2069 },
2070 );
2071 let second = WorkspaceDiagnostic::new(
2072 root,
2073 root.join("packages/aaa"),
2074 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2075 pattern: "packages/a*".to_owned(),
2076 },
2077 );
2078
2079 let merged =
2080 merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
2081
2082 let patterns: Vec<String> = merged
2083 .iter()
2084 .map(|diagnostic| match &diagnostic.kind {
2085 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
2086 other => panic!("unexpected kind {}", other.id()),
2087 })
2088 .collect();
2089 assert_eq!(
2090 patterns,
2091 ["packages/*", "packages/a*"],
2092 "two overlapping globs report the same directory twice, with their own pattern; \
2093 the same entry seen from two observation points still folds to one"
2094 );
2095 }
2096
2097 #[test]
2102 fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
2103 let root = Path::new("/project");
2104 let dotted = WorkspaceDiagnostic::new(
2105 root,
2106 root.join("apps/site/.next/cache"),
2107 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2108 pattern: "./apps/**".to_owned(),
2109 },
2110 );
2111 let bare = WorkspaceDiagnostic::new(
2112 root,
2113 root.join("apps/site/.next/cache"),
2114 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2115 pattern: "apps/**".to_owned(),
2116 },
2117 );
2118 assert_eq!(
2119 dotted.kind, bare.kind,
2120 "the no-op ./ prefix is normalised out of the recorded pattern"
2121 );
2122 assert!(
2123 dotted.message.contains("Glob 'apps/**'"),
2124 "the message renders the normalised pattern: {}",
2125 dotted.message
2126 );
2127
2128 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
2129 assert_eq!(
2130 merged.len(),
2131 1,
2132 "one glob declared twice is one diagnostic: {merged:?}"
2133 );
2134 }
2135
2136 #[test]
2140 fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
2141 let root = Path::new("/project");
2142 let recorded = |pattern: &str| {
2143 let diagnostic = WorkspaceDiagnostic::new(
2144 root,
2145 root.join("pkgs"),
2146 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2147 pattern: pattern.to_owned(),
2148 },
2149 );
2150 let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
2151 else {
2152 panic!("constructed a glob-matched-no-package-json diagnostic");
2153 };
2154 (pattern, diagnostic.message)
2155 };
2156
2157 let (root_pattern, root_message) = recorded("./");
2158 assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
2159 assert!(
2160 root_message.contains("Glob './'"),
2161 "the warning names the glob the manifest declared: {root_message}"
2162 );
2163 assert_eq!(recorded(".\\").0, ".\\");
2164 assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
2165 assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
2166 }
2167
2168 #[test]
2175 fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
2176 let root = Path::new("/project");
2177 let dotted = WorkspaceDiagnostic::new(
2178 root,
2179 root.join("./pkgs/aaa"),
2180 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2181 pattern: "./pkgs/*".to_owned(),
2182 },
2183 );
2184 let bare = WorkspaceDiagnostic::new(
2185 root,
2186 root.join("pkgs/aaa"),
2187 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2188 pattern: "pkgs/*".to_owned(),
2189 },
2190 );
2191
2192 let spelling = |diagnostic: &WorkspaceDiagnostic| {
2193 diagnostic.path.display().to_string().replace('\\', "/")
2194 };
2195 assert_eq!(
2196 spelling(&dotted),
2197 "/project/pkgs/aaa",
2198 "the stored path drops the no-op . component, which Path equality \
2199 hides but serialization does not"
2200 );
2201 assert_eq!(spelling(&dotted), spelling(&bare));
2202 assert_eq!(
2203 spelling(&dotted.clone().into_root_relative(root)),
2204 "pkgs/aaa"
2205 );
2206
2207 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
2208 assert_eq!(
2209 merged.len(),
2210 1,
2211 "one directory reached through two spellings of one glob: {merged:?}"
2212 );
2213 }
2214
2215 #[test]
2218 fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
2219 let root = Path::new("/project");
2220 let glob = |pattern: &str, relative: &str| {
2221 WorkspaceDiagnostic::new(
2222 root,
2223 root.join(relative),
2224 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2225 pattern: pattern.to_owned(),
2226 },
2227 )
2228 };
2229
2230 let deduped = dedupe_workspace_diagnostics(vec![
2231 glob("pkgs/*", "pkgs/aaa"),
2232 glob("pkgs/*", "pkgs/bbb"),
2233 glob("./pkgs/*", "./pkgs/aaa"),
2234 glob("pkgs/a*", "pkgs/aaa"),
2235 ]);
2236
2237 let reported: Vec<(String, String)> = deduped
2238 .iter()
2239 .map(|diagnostic| match &diagnostic.kind {
2240 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
2241 pattern.clone(),
2242 diagnostic.path.display().to_string().replace('\\', "/"),
2243 ),
2244 other => panic!("unexpected kind {}", other.id()),
2245 })
2246 .collect();
2247
2248 assert_eq!(
2249 reported,
2250 vec![
2251 ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
2252 ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
2253 ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
2254 ],
2255 "the duplicate spelling folds away and the overlapping glob stays"
2256 );
2257 }
2258
2259 #[test]
2266 fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
2267 for kind in [
2268 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
2269 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
2270 WorkspaceDiagnosticKind::SkippedSourceDotdir,
2271 WorkspaceDiagnosticKind::SourceReadFailure {
2272 error: "permission denied".to_owned(),
2273 },
2274 ] {
2275 assert!(
2276 kind.source_never_analyzed(),
2277 "{} names a source file this run never read",
2278 kind.id()
2279 );
2280 }
2281
2282 let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
2283 error_count: 3,
2284 panicked: false,
2285 };
2286 assert!(
2287 !degraded.source_never_analyzed(),
2288 "a degraded parse read the file, so it has a graph node and its reachability is \
2289 observable; the caveat pass narrows it instead of treating it as unread"
2290 );
2291
2292 for kind in [
2293 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2294 WorkspaceDiagnosticKind::MalformedPackageJson {
2295 error: "trailing comma".to_owned(),
2296 },
2297 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2298 pattern: "packages/*".to_owned(),
2299 },
2300 WorkspaceDiagnosticKind::MalformedTsconfig {
2301 error: "unexpected token".to_owned(),
2302 },
2303 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
2304 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
2305 error: "bad indent".to_owned(),
2306 },
2307 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
2308 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
2309 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
2310 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
2311 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
2312 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
2313 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
2314 },
2315 WorkspaceDiagnosticKind::NodeModulesMissing,
2316 WorkspaceDiagnosticKind::BoundariesNotConfigured,
2317 WorkspaceDiagnosticKind::RulePacksNotConfigured,
2318 ] {
2319 assert!(
2320 !kind.source_never_analyzed(),
2321 "{} says nothing about a source file's imports going unseen",
2322 kind.id()
2323 );
2324 }
2325 }
2326
2327 #[test]
2328 fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
2329 for kind in [
2330 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
2331 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
2332 WorkspaceDiagnosticKind::SkippedSourceDotdir,
2333 ] {
2334 assert!(
2335 kind.is_source_walk_recorded() && kind.is_source_discovery(),
2336 "{} is written by the source walk",
2337 kind.id()
2338 );
2339 }
2340
2341 let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
2342 error: "permission denied".to_owned(),
2343 };
2344 assert!(
2345 read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
2346 "the parse stage records source-read-failure after the walk, so it must keep \
2347 reaching sessions through the registry"
2348 );
2349
2350 for kind in [
2351 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2352 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
2353 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
2354 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
2355 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
2356 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
2357 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
2358 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
2359 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
2360 },
2361 ] {
2362 assert!(
2363 !kind.is_source_walk_recorded(),
2364 "{} is not written by the source walk",
2365 kind.id()
2366 );
2367 }
2368 }
2369
2370 #[test]
2378 fn a_built_in_ignore_exclusion_is_not_a_file_the_run_failed_to_analyze() {
2379 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2380 pattern: "**/build/**".to_owned(),
2381 file_count: 3,
2382 directory_count: 1,
2383 };
2384 assert!(!kind.source_never_analyzed());
2385 }
2386
2387 #[test]
2392 fn a_built_in_ignore_exclusion_does_not_warn_on_stderr_by_default() {
2393 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2394 pattern: "**/build/**".to_owned(),
2395 file_count: 3,
2396 directory_count: 1,
2397 };
2398 assert!(!kind.warns_on_stderr());
2399 }
2400
2401 #[test]
2406 fn a_built_in_ignore_exclusion_is_walk_recorded_source_discovery() {
2407 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2408 pattern: "**/build/**".to_owned(),
2409 file_count: 3,
2410 directory_count: 1,
2411 };
2412 assert!(kind.is_source_discovery());
2413 assert!(kind.is_source_walk_recorded());
2414 assert!(!kind.is_analysis_stage());
2415 assert_eq!(kind.id(), "excluded-by-default-ignore");
2416 }
2417
2418 #[test]
2422 fn a_built_in_ignore_exclusion_message_names_the_pattern_and_the_root_remedy() {
2423 let root = Path::new("/project");
2424 let diag = WorkspaceDiagnostic::new(
2425 root,
2426 root.join("packages/web/build"),
2427 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2428 pattern: "**/build/**".to_owned(),
2429 file_count: 4,
2430 directory_count: 1,
2431 },
2432 );
2433 assert!(diag.message.contains("**/build/**"), "{}", diag.message);
2434 assert!(
2435 diag.message.contains("packages/web/build"),
2436 "{}",
2437 diag.message
2438 );
2439 assert!(
2440 diag.message.contains("fallow --root packages/web/build"),
2441 "the remedy is copy-pasteable: {}",
2442 diag.message
2443 );
2444 assert!(
2445 diag.message
2446 .contains("add '!packages/web/build/**' to ignorePatterns"),
2447 "the message names the exception that lifts the built-in (issue #2940): {}",
2448 diag.message
2449 );
2450 }
2451
2452 #[test]
2454 fn a_single_excluded_file_message_is_singular() {
2455 let root = Path::new("/project");
2456 let diag = WorkspaceDiagnostic::new(
2457 root,
2458 root.join("dist"),
2459 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2460 pattern: "**/dist/**".to_owned(),
2461 file_count: 1,
2462 directory_count: 1,
2463 },
2464 );
2465 assert!(
2466 diag.message
2467 .starts_with("Skipped 1 source file under 'dist'"),
2468 "{}",
2469 diag.message
2470 );
2471 assert!(diag.message.contains("it matches"), "{}", diag.message);
2472 assert!(
2473 diag.message
2474 .contains("nothing it imports, exports, or defines"),
2475 "the whole sentence agrees in number, not just its first clause: {}",
2476 diag.message
2477 );
2478 }
2479
2480 #[test]
2485 fn a_scattered_exclusion_names_the_largest_group_and_counts_the_directories() {
2486 let root = Path::new("/project");
2487 let diag = WorkspaceDiagnostic::new(
2488 root,
2489 root.join("packages/a/dist"),
2490 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2491 pattern: "**/dist/**".to_owned(),
2492 file_count: 10,
2493 directory_count: 10,
2494 },
2495 );
2496 assert!(
2497 diag.message.starts_with(
2498 "Skipped 10 source files across 10 directories, the largest group under \
2499 'packages/a/dist'"
2500 ),
2501 "{}",
2502 diag.message
2503 );
2504 assert!(
2505 !diag.message.contains("the most of them"),
2506 "a max-of-group is not a majority: {}",
2507 diag.message
2508 );
2509 }
2510
2511 #[test]
2515 fn a_file_shaped_pattern_does_not_advertise_the_root_remedy() {
2516 let root = Path::new("/project");
2517 let diag = WorkspaceDiagnostic::new(
2518 root,
2519 root.join("vendor"),
2520 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2521 pattern: "**/*.min.js".to_owned(),
2522 file_count: 2,
2523 directory_count: 1,
2524 },
2525 );
2526 assert!(
2527 !diag.message.contains("fallow --root"),
2528 "the message explains why re-rooting fails, it does not prescribe it: {}",
2529 diag.message
2530 );
2531 assert!(
2532 diag.message.contains("matches a file name"),
2533 "the message says why: {}",
2534 diag.message
2535 );
2536 assert!(
2537 diag.message.contains("Rename"),
2538 "and names the remedy that does work: {}",
2539 diag.message
2540 );
2541 }
2542
2543 #[test]
2546 fn a_root_anchored_exclusion_renders_its_location_as_dot() {
2547 let root = Path::new("/project");
2548 let diag = WorkspaceDiagnostic::new(
2549 root,
2550 root.to_path_buf(),
2551 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2552 pattern: "**/*.min.js".to_owned(),
2553 file_count: 1,
2554 directory_count: 1,
2555 },
2556 );
2557 assert!(
2558 diag.message.starts_with("Skipped 1 source file under '.'"),
2559 "{}",
2560 diag.message
2561 );
2562 }
2563
2564 #[test]
2565 fn glob_first_literal_segment_skips_wildcards_and_dot_components() {
2566 assert_eq!(glob_first_literal_segment("**/build/**"), Some("build"));
2567 assert_eq!(glob_first_literal_segment("./dist/**"), Some("dist"));
2568 assert_eq!(glob_first_literal_segment("**/*.min.js"), None);
2569 assert_eq!(glob_first_literal_segment("**/*.bundle.js"), None);
2570 assert_eq!(glob_first_literal_segment("**/{a,b}/**"), None);
2571 }
2572
2573 #[test]
2574 fn format_size_mb_one_decimal() {
2575 assert_eq!(format_size_mb(0), "0.0 MB");
2576 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
2577 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
2578 }
2579
2580 #[test]
2581 fn unmatched_config_pattern_kinds_are_advisory_root_notes() {
2582 let root = Path::new("/project");
2583 for (kind, id, setting, pattern) in [
2584 (
2585 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched {
2586 pattern: "@typo/*".to_owned(),
2587 },
2588 "ignore-dependencies-glob-unmatched",
2589 "ignoreDependencies",
2590 "@typo/*",
2591 ),
2592 (
2593 WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched {
2594 pattern: "src/legcy/**".to_owned(),
2595 },
2596 "ignore-findings-pattern-unmatched",
2597 "ignoreFindings",
2598 "src/legcy/**",
2599 ),
2600 ] {
2601 let diag = WorkspaceDiagnostic::new(root, root.to_path_buf(), kind);
2602 assert_eq!(diag.kind.id(), id);
2603 assert_eq!(
2604 diag.kind.unmatched_config_pattern(),
2605 Some((setting, pattern))
2606 );
2607 assert!(!diag.degrades_analysis, "{id} does not degrade the run");
2608 assert!(!diag.kind.source_never_analyzed());
2609 assert!(
2610 !diag.kind.is_analysis_stage(),
2611 "{id} is not in the registry"
2612 );
2613 assert!(!diag.kind.is_health_stage());
2614 assert!(!diag.kind.is_plugin_stage());
2615 assert!(!diag.kind.is_source_discovery());
2616 assert!(diag.message.contains(setting), "{}", diag.message);
2617 assert!(diag.message.contains(pattern), "{}", diag.message);
2618 assert!(
2619 diag.message.contains("remove it from the config"),
2620 "next step: {}",
2621 diag.message
2622 );
2623 let json = serde_json::to_value(&diag).expect("serializes");
2624 assert_eq!(json["kind"], id);
2625 assert_eq!(json["pattern"], pattern);
2626 assert!(json.get("degrades_analysis").is_none());
2627 }
2628 assert!(
2629 WorkspaceDiagnosticKind::UndeclaredWorkspace
2630 .unmatched_config_pattern()
2631 .is_none()
2632 );
2633 }
2634
2635 #[test]
2636 fn undeclared_workspace_message_has_next_step() {
2637 let root = Path::new("/project");
2638 let diag = WorkspaceDiagnostic::new(
2639 root,
2640 root.join("packages/legacy"),
2641 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2642 );
2643 assert_eq!(diag.kind.id(), "undeclared-workspace");
2644 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
2645 assert!(
2646 diag.message.contains("ignorePatterns"),
2647 "next-step hint preserved: {}",
2648 diag.message
2649 );
2650 }
2651 #[test]
2657 fn health_stage_kinds_are_classified_as_health_stage_and_nothing_else() {
2658 for kind in [
2659 WorkspaceDiagnosticKind::FileScoresUnavailable {
2660 error: "boom".to_owned(),
2661 },
2662 WorkspaceDiagnosticKind::HotspotsSkipped {
2663 cause: "not-a-repository".to_owned(),
2664 },
2665 WorkspaceDiagnosticKind::ShallowClone {
2666 ownership_requested: true,
2667 },
2668 WorkspaceDiagnosticKind::UnpinnedClock,
2669 WorkspaceDiagnosticKind::OwnershipUnavailable {
2670 cause: "codeowners-parse-failed".to_owned(),
2671 error: "boom".to_owned(),
2672 },
2673 WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
2674 error: "boom".to_owned(),
2675 },
2676 WorkspaceDiagnosticKind::CoverageAutoDetected,
2677 ] {
2678 let id = kind.id();
2679 assert!(kind.is_health_stage(), "{id} must be health-stage");
2680 assert!(!kind.is_analysis_stage(), "{id} must not be analysis-stage");
2681 assert!(
2682 !kind.is_source_discovery(),
2683 "{id} must not be source-discovery"
2684 );
2685 assert!(
2686 !kind.is_source_walk_recorded(),
2687 "{id} must not be walk-recorded"
2688 );
2689 assert!(
2690 !kind.source_never_analyzed(),
2691 "{id} reports an input, not an unread source file"
2692 );
2693 }
2694 }
2695
2696 #[test]
2700 fn only_the_coverage_provenance_kind_does_not_degrade_the_analysis() {
2701 assert!(
2702 WorkspaceDiagnosticKind::HotspotsSkipped {
2703 cause: "invalid-since".to_owned(),
2704 }
2705 .warns_on_stderr(),
2706 "a skipped hotspot section is a degraded result"
2707 );
2708 assert!(
2709 WorkspaceDiagnosticKind::UnpinnedClock.warns_on_stderr(),
2710 "a drifting measurement is a degraded result"
2711 );
2712 assert!(
2713 !WorkspaceDiagnosticKind::CoverageAutoDetected.warns_on_stderr(),
2714 "auto-detected coverage loaded fine and degraded nothing"
2715 );
2716 }
2717
2718 #[test]
2722 fn every_hotspot_skip_cause_renders_its_own_remedy() {
2723 let root = Path::new("/project");
2724 let skipped = |cause: &str, path: PathBuf| {
2725 WorkspaceDiagnostic::new(
2726 root,
2727 path,
2728 WorkspaceDiagnosticKind::HotspotsSkipped {
2729 cause: cause.to_owned(),
2730 },
2731 )
2732 };
2733
2734 let no_repo = skipped("not-a-repository", root.to_path_buf());
2735 assert_eq!(
2736 no_repo.message,
2737 "Hotspot analysis was skipped because no git repository was found at the project \
2738 root, so the hotspots, churn and ownership sections report nothing rather than \
2739 zero. Run fallow inside the repository, or pass --churn-file with exported change \
2740 history."
2741 );
2742
2743 let bad_since = skipped("invalid-since", root.to_path_buf());
2744 assert!(
2745 bad_since.message.contains("--since")
2746 && bad_since.message.contains("6m or 90d")
2747 && !bad_since.message.contains("no git repository"),
2748 "a malformed window is respelled, not moved into a repository: {}",
2749 bad_since.message
2750 );
2751
2752 let churn = skipped("churn-file-unreadable", root.join("build/churn.json"));
2753 assert!(
2754 churn.message.contains("'build/churn.json'") && churn.message.contains("rerun"),
2755 "the remedy names the file that changed under the run: {}",
2756 churn.message
2757 );
2758
2759 let unborn = skipped("no-commits", root.to_path_buf());
2760 assert!(
2761 unborn.message.contains("no commits")
2762 && unborn.message.contains("--churn-file")
2763 && !unborn.message.contains("no git repository"),
2764 "a branch without a commit is told to commit, not to move: {}",
2765 unborn.message
2766 );
2767
2768 for diagnostic in [&no_repo, &unborn, &bad_since, &churn] {
2769 assert!(
2770 diagnostic.degrades_analysis,
2771 "every skip leaves the hotspot sections unmeasured: {}",
2772 diagnostic.message
2773 );
2774 }
2775 }
2776
2777 #[test]
2780 fn health_stage_messages_name_a_next_step() {
2781 let root = Path::new("/project");
2782 let shallow = WorkspaceDiagnostic::new(
2783 root,
2784 root.to_path_buf(),
2785 WorkspaceDiagnosticKind::ShallowClone {
2786 ownership_requested: true,
2787 },
2788 );
2789 assert!(
2790 shallow.message.contains("git fetch --unshallow"),
2791 "{}",
2792 shallow.message
2793 );
2794 assert!(
2795 shallow.message.contains("Ownership signals are skewed too"),
2796 "a run that asked for ownership is told what else it costs: {}",
2797 shallow.message
2798 );
2799 let without_ownership = WorkspaceDiagnostic::new(
2800 root,
2801 root.to_path_buf(),
2802 WorkspaceDiagnosticKind::ShallowClone {
2803 ownership_requested: false,
2804 },
2805 );
2806 assert!(
2807 !without_ownership.message.contains("Ownership"),
2808 "a run that did not ask for ownership is not told about it: {}",
2809 without_ownership.message
2810 );
2811
2812 let coverage = WorkspaceDiagnostic::new(
2813 root,
2814 root.join("coverage/coverage-final.json"),
2815 WorkspaceDiagnosticKind::CoverageAutoDetected,
2816 );
2817 assert_eq!(coverage.kind.id(), "coverage-auto-detected");
2818 assert!(
2819 coverage
2820 .message
2821 .contains("--coverage 'coverage/coverage-final.json'"),
2822 "the remedy names the file that fed the score: {}",
2823 coverage.message
2824 );
2825 assert!(
2826 !coverage.degrades_analysis,
2827 "provenance is not a degraded run"
2828 );
2829
2830 let ownership = WorkspaceDiagnostic::new(
2831 root,
2832 root.to_path_buf(),
2833 WorkspaceDiagnosticKind::OwnershipUnavailable {
2834 cause: "invalid-bot-pattern".to_owned(),
2835 error: "unclosed".to_owned(),
2836 },
2837 );
2838 assert!(
2839 ownership.message.contains("botPatterns"),
2840 "the two causes render different remedies: {}",
2841 ownership.message
2842 );
2843 }
2844
2845 fn plugin_unreadable(key: &str, reason: &str) -> WorkspaceDiagnostic {
2846 WorkspaceDiagnostic::new(
2847 Path::new("/project"),
2848 PathBuf::from("/project/module-federation.config.ts"),
2849 WorkspaceDiagnosticKind::PluginConfigUnreadable {
2850 plugin: "module-federation".to_owned(),
2851 key: key.to_owned(),
2852 reason: reason.to_owned(),
2853 },
2854 )
2855 }
2856
2857 fn plugin_not_modeled(key: &str, reason: &str) -> WorkspaceDiagnostic {
2858 WorkspaceDiagnostic::new(
2859 Path::new("/project"),
2860 PathBuf::from("/project/nuxt.config.ts"),
2861 WorkspaceDiagnosticKind::PluginEffectNotModeled {
2862 plugin: "nuxt".to_owned(),
2863 key: key.to_owned(),
2864 reason: reason.to_owned(),
2865 },
2866 )
2867 }
2868
2869 #[test]
2872 fn plugin_stage_kinds_are_classified_as_plugin_stage_and_nothing_else() {
2873 for kind in [
2874 WorkspaceDiagnosticKind::PluginConfigUnreadable {
2875 plugin: "module-federation".to_owned(),
2876 key: "exposes".to_owned(),
2877 reason: "not-object-literal".to_owned(),
2878 },
2879 WorkspaceDiagnosticKind::PluginEffectNotModeled {
2880 plugin: "nuxt".to_owned(),
2881 key: "components".to_owned(),
2882 reason: "key-effect-not-modeled".to_owned(),
2883 },
2884 ] {
2885 let id = kind.id();
2886 assert!(kind.is_plugin_stage(), "{id} must be plugin-stage");
2887 assert!(!kind.is_analysis_stage(), "{id} must not be analysis-stage");
2888 assert!(!kind.is_health_stage(), "{id} must not be health-stage");
2889 assert!(
2890 !kind.is_source_discovery(),
2891 "{id} must not be source-discovery"
2892 );
2893 assert!(
2894 !kind.is_source_walk_recorded(),
2895 "{id} must not be walk-recorded"
2896 );
2897 assert!(
2898 !kind.source_never_analyzed(),
2899 "{id} reports a config file, not an unread source file"
2900 );
2901 }
2902 assert!(
2903 !WorkspaceDiagnosticKind::UnpinnedClock.is_plugin_stage(),
2904 "another stage's kind must not answer the plugin predicate"
2905 );
2906 }
2907
2908 #[test]
2912 fn only_the_unreadable_plugin_config_degrades_the_analysis() {
2913 let unreadable = plugin_unreadable("exposes", "not-object-literal");
2914 assert!(
2915 unreadable.degrades_analysis,
2916 "an unread declaration did not reach the analysis: {}",
2917 unreadable.message
2918 );
2919 let not_modeled = plugin_not_modeled("components", "key-effect-not-modeled");
2920 assert!(
2921 !not_modeled.degrades_analysis,
2922 "the config was readable and the patterns stayed: {}",
2923 not_modeled.message
2924 );
2925 }
2926
2927 #[test]
2930 fn every_unreadable_reason_renders_its_own_situation() {
2931 let cases = [
2932 ("not-object-literal", "is not a static object literal"),
2933 ("array-form", "uses the array form"),
2934 ("spread", "spreads a value that is not statically readable"),
2935 (
2936 "unreadable-entries",
2937 "has entries that hold no statically readable value",
2938 ),
2939 (
2940 "unrecognized-call",
2941 "is passed through a call that is not a known config wrapper",
2942 ),
2943 (
2944 "import-target-unreadable",
2945 "comes from an imported file that is not statically readable",
2946 ),
2947 (
2948 "dynamic-argument",
2949 "receives an argument that is not a static literal",
2950 ),
2951 ];
2952 for (reason, expected) in cases {
2953 let diagnostic = plugin_unreadable("exposes", reason);
2954 assert!(
2955 diagnostic.message.contains(expected),
2956 "`{reason}` must render its own situation: {}",
2957 diagnostic.message
2958 );
2959 }
2960 let unknown = plugin_unreadable("exposes", "reason-from-a-later-release");
2961 assert!(
2962 unknown.message.contains("could not be read statically"),
2963 "an unrecognised token still renders a sentence: {}",
2964 unknown.message
2965 );
2966 }
2967
2968 #[test]
2972 fn unreadable_plugin_messages_name_the_config_file_and_the_option() {
2973 let exposes = plugin_unreadable("exposes", "not-object-literal");
2974 assert!(
2975 exposes
2976 .message
2977 .contains("`exposes` in 'module-federation.config.ts'")
2978 && exposes.message.contains("dynamicallyLoaded")
2979 && exposes.message.starts_with("Plugin 'module-federation':"),
2980 "{}",
2981 exposes.message
2982 );
2983 let remotes = plugin_unreadable("remotes", "spread");
2984 assert!(
2985 remotes.message.contains("ignoreDependencies"),
2986 "the two keys have different remedies: {}",
2987 remotes.message
2988 );
2989 let unknown_key = plugin_unreadable("shared", "not-object-literal");
2990 assert!(
2991 !unknown_key.message.contains("dynamicallyLoaded")
2992 && !unknown_key.message.contains("ignoreDependencies")
2993 && unknown_key
2994 .message
2995 .contains("Declare the value as a static object literal."),
2996 "a key with no documented consequence falls back to the general claim: {}",
2997 unknown_key.message
2998 );
2999 assert!(
3000 !exposes.message.contains('\n'),
3001 "the sentence travels into a CI annotation and stays on one line: {}",
3002 exposes.message
3003 );
3004 }
3005
3006 #[test]
3010 fn the_call_and_import_reasons_render_their_own_remedy() {
3011 let call = plugin_unreadable("exposes", "unrecognized-call");
3012 assert!(
3013 call.message
3014 .contains("only the targets in the object literal it receives")
3015 && call
3016 .message
3017 .contains("Name any other exposed files in `dynamicallyLoaded`."),
3018 "{}",
3019 call.message
3020 );
3021 let call = plugin_unreadable("remotes", "unrecognized-call");
3022 assert!(
3023 call.message
3024 .contains("Name any other aliases in `ignoreDependencies`."),
3025 "{}",
3026 call.message
3027 );
3028 for key in ["exposes", "remotes"] {
3029 let import = plugin_unreadable(key, "import-target-unreadable");
3030 assert!(
3031 !import.message.contains("object literal"),
3032 "an import target is not fixed by writing an object literal: {}",
3033 import.message
3034 );
3035 }
3036 let import = plugin_unreadable("exposes", "import-target-unreadable");
3037 assert!(
3038 import.message.contains("`dynamicallyLoaded`"),
3039 "{}",
3040 import.message
3041 );
3042 }
3043
3044 #[test]
3047 fn a_dynamic_runtime_call_renders_its_own_remedy() {
3048 for (key, consequence) in [
3049 (
3050 "registerRemotes",
3051 "the remotes it registers are not treated as provided",
3052 ),
3053 (
3054 "loadRemote",
3055 "the remote it loads is not treated as provided",
3056 ),
3057 (
3058 "init",
3059 "the remotes its options declare are not treated as provided",
3060 ),
3061 (
3062 "createInstance",
3063 "the remotes its options declare are not treated as provided",
3064 ),
3065 ] {
3066 let diagnostic = plugin_unreadable(key, "dynamic-argument");
3067 assert!(
3068 diagnostic.message.contains(&format!("`{key}` in"))
3069 && diagnostic.message.contains(consequence)
3070 && diagnostic.message.contains("`ignoreDependencies`")
3071 && !diagnostic.message.contains("object literal"),
3072 "{}",
3073 diagnostic.message
3074 );
3075 }
3076 }
3077
3078 #[test]
3081 fn two_unreadable_keys_in_one_file_are_two_diagnostics() {
3082 let merged = dedupe_workspace_diagnostics(vec![
3083 plugin_unreadable("exposes", "not-object-literal"),
3084 plugin_unreadable("remotes", "spread"),
3085 ]);
3086 assert_eq!(merged.len(), 2, "{merged:?}");
3087 }
3088
3089 #[test]
3093 fn the_not_modeled_reasons_render_different_causes() {
3094 let key = plugin_not_modeled("components", "key-effect-not-modeled");
3095 assert!(
3096 key.message
3097 .contains("fallow does not model the effect of `components` in 'nuxt.config.ts'.")
3098 && key
3099 .message
3100 .contains("`autoImports` kept the convention entry patterns"),
3101 "{}",
3102 key.message
3103 );
3104 let property = plugin_not_modeled("imports", "config-property-unreadable");
3105 assert!(
3106 property.message.contains(
3107 "fallow cannot read a top-level property in 'nuxt.config.ts', so it cannot \
3108 classify the `imports` surface. `autoImports` kept the convention entry patterns \
3109 for that surface, and fallow reports no unused file there."
3110 ),
3111 "{}",
3112 property.message
3113 );
3114 assert_eq!(
3115 property.message.matches(", so ").count(),
3116 1,
3117 "one cause per sentence: {}",
3118 property.message
3119 );
3120 }
3121
3122 #[test]
3125 fn an_unreadable_virtual_module_read_names_the_module_and_a_source_remedy() {
3126 let read = WorkspaceDiagnostic::new(
3127 Path::new("/project"),
3128 PathBuf::from("/project/app/lib/registry.ts"),
3129 WorkspaceDiagnosticKind::PluginEffectNotModeled {
3130 plugin: "nuxt".to_owned(),
3131 key: "#components".to_owned(),
3132 reason: "key-effect-not-modeled".to_owned(),
3133 },
3134 );
3135 assert!(
3136 read.message.contains(
3137 "fallow cannot read which names 'app/lib/registry.ts' takes from `#components`"
3138 ) && read.message.contains("member access")
3139 && !read.message.contains("entry patterns"),
3140 "{}",
3141 read.message
3142 );
3143 }
3144}