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 ChangeCouplingSkipped {
373 cause: String,
377 },
378 ShallowClone {
381 ownership_requested: bool,
384 },
385 UnpinnedClock,
389 OwnershipUnavailable {
392 cause: String,
395 error: String,
397 },
398 TrendSnapshotUnreadable {
402 error: String,
404 },
405 TrendGroupBaselineUnavailable {
409 cause: String,
412 },
413 PluginConfigUnreadable {
439 plugin: String,
444 key: String,
449 reason: String,
459 },
460 PluginEffectNotModeled {
482 plugin: String,
484 key: String,
488 reason: String,
494 },
495 CoverageAutoDetected,
503 FlagAgeShallowClone,
507 FlagAgeUnavailable {
510 cause: String,
513 },
514 IgnoreDependenciesGlobUnmatched {
526 pattern: String,
528 },
529 IgnoreFindingsPatternUnmatched {
540 pattern: String,
542 },
543}
544
545impl WorkspaceDiagnosticKind {
546 #[must_use]
548 pub const fn id(&self) -> &'static str {
549 match self {
550 Self::UndeclaredWorkspace => "undeclared-workspace",
551 Self::MalformedPackageJson { .. } => "malformed-package-json",
552 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
553 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
554 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
555 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
556 Self::SkippedLargeFile { .. } => "skipped-large-file",
557 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
558 Self::SkippedSourceDotdir => "skipped-source-dotdir",
559 Self::SourceReadFailure { .. } => "source-read-failure",
560 Self::SourceParseDegraded { .. } => "source-parse-degraded",
561 Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
562 Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
563 Self::PnpmLockOverrideResolutionSkipped => "pnpm-lock-override-resolution-skipped",
564 Self::NpmLockOverrideResolutionSkipped => "npm-lock-override-resolution-skipped",
565 Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
566 Self::PnpmWorkspaceOverridesIgnored { .. } => "pnpm-workspace-overrides-ignored",
567 Self::NodeModulesMissing => "node-modules-missing",
568 Self::BoundariesNotConfigured => "boundaries-not-configured",
569 Self::RulePacksNotConfigured => "rule-packs-not-configured",
570 Self::ExcludedByDefaultIgnore { .. } => "excluded-by-default-ignore",
571 Self::NoSourceFilesAnalyzed { .. } => "no-source-files-analyzed",
572 Self::FileScoresUnavailable { .. } => "file-scores-unavailable",
573 Self::HotspotsSkipped { .. } => "hotspots-skipped",
574 Self::ChangeCouplingSkipped { .. } => "change-coupling-skipped",
575 Self::ShallowClone { .. } => "shallow-clone",
576 Self::UnpinnedClock => "unpinned-clock",
577 Self::OwnershipUnavailable { .. } => "ownership-unavailable",
578 Self::TrendSnapshotUnreadable { .. } => "trend-snapshot-unreadable",
579 Self::TrendGroupBaselineUnavailable { .. } => "trend-group-baseline-unavailable",
580 Self::PluginConfigUnreadable { .. } => "plugin-config-unreadable",
581 Self::PluginEffectNotModeled { .. } => "plugin-effect-not-modeled",
582 Self::CoverageAutoDetected => "coverage-auto-detected",
583 Self::FlagAgeShallowClone => "flag-age-shallow-clone",
584 Self::FlagAgeUnavailable { .. } => "flag-age-unavailable",
585 Self::IgnoreDependenciesGlobUnmatched { .. } => "ignore-dependencies-glob-unmatched",
586 Self::IgnoreFindingsPatternUnmatched { .. } => "ignore-findings-pattern-unmatched",
587 }
588 }
589
590 #[must_use]
597 pub fn unmatched_config_pattern(&self) -> Option<(&'static str, &str)> {
598 match self {
599 Self::IgnoreDependenciesGlobUnmatched { pattern } => {
600 Some(("ignoreDependencies", pattern.as_str()))
601 }
602 Self::IgnoreFindingsPatternUnmatched { pattern } => {
603 Some(("ignoreFindings", pattern.as_str()))
604 }
605 _ => None,
606 }
607 }
608
609 #[must_use]
635 pub const fn warns_on_stderr(&self) -> bool {
636 match self {
637 Self::BoundariesNotConfigured
638 | Self::RulePacksNotConfigured
639 | Self::ExcludedByDefaultIgnore { .. }
640 | Self::PluginEffectNotModeled { .. }
641 | Self::CoverageAutoDetected
642 | Self::TrendGroupBaselineUnavailable { .. }
643 | Self::ChangeCouplingSkipped { .. }
644 | Self::IgnoreDependenciesGlobUnmatched { .. }
645 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
646 Self::UndeclaredWorkspace
647 | Self::MalformedPackageJson { .. }
648 | Self::GlobMatchedNoPackageJson { .. }
649 | Self::MalformedTsconfig { .. }
650 | Self::TsconfigReferenceDirMissing
651 | Self::MalformedPnpmWorkspaceYaml { .. }
652 | Self::SkippedLargeFile { .. }
653 | Self::SkippedMinifiedFile { .. }
654 | Self::SkippedSourceDotdir
655 | Self::SourceReadFailure { .. }
656 | Self::SourceParseDegraded { .. }
657 | Self::BunLockbOverrideResolutionSkipped
658 | Self::BunLockOverrideResolutionSkipped
659 | Self::PnpmLockOverrideResolutionSkipped
660 | Self::NpmLockOverrideResolutionSkipped
661 | Self::BunResolutionsShadowedByOverrides
662 | Self::PnpmWorkspaceOverridesIgnored { .. }
663 | Self::NodeModulesMissing
664 | Self::NoSourceFilesAnalyzed { .. }
665 | Self::FileScoresUnavailable { .. }
666 | Self::HotspotsSkipped { .. }
667 | Self::ShallowClone { .. }
668 | Self::UnpinnedClock
669 | Self::OwnershipUnavailable { .. }
670 | Self::TrendSnapshotUnreadable { .. }
671 | Self::PluginConfigUnreadable { .. }
672 | Self::FlagAgeShallowClone
673 | Self::FlagAgeUnavailable { .. } => true,
674 }
675 }
676
677 #[must_use]
686 pub const fn is_source_discovery(&self) -> bool {
687 matches!(
688 self,
689 Self::SkippedLargeFile { .. }
690 | Self::SkippedMinifiedFile { .. }
691 | Self::SkippedSourceDotdir
692 | Self::SourceReadFailure { .. }
693 | Self::SourceParseDegraded { .. }
694 | Self::NodeModulesMissing
695 | Self::ExcludedByDefaultIgnore { .. }
696 | Self::NoSourceFilesAnalyzed { .. }
697 )
698 }
699
700 #[must_use]
713 pub const fn is_source_walk_recorded(&self) -> bool {
714 matches!(
715 self,
716 Self::SkippedLargeFile { .. }
717 | Self::SkippedMinifiedFile { .. }
718 | Self::SkippedSourceDotdir
719 | Self::NodeModulesMissing
720 | Self::ExcludedByDefaultIgnore { .. }
721 | Self::NoSourceFilesAnalyzed { .. }
722 )
723 }
724
725 #[must_use]
766 pub const fn source_never_analyzed(&self) -> bool {
767 match self {
768 Self::SkippedLargeFile { .. }
769 | Self::SkippedMinifiedFile { .. }
770 | Self::SkippedSourceDotdir
771 | Self::SourceReadFailure { .. } => true,
772 Self::UndeclaredWorkspace
773 | Self::MalformedPackageJson { .. }
774 | Self::GlobMatchedNoPackageJson { .. }
775 | Self::MalformedTsconfig { .. }
776 | Self::TsconfigReferenceDirMissing
777 | Self::MalformedPnpmWorkspaceYaml { .. }
778 | Self::SourceParseDegraded { .. }
779 | Self::BunLockbOverrideResolutionSkipped
780 | Self::BunLockOverrideResolutionSkipped
781 | Self::PnpmLockOverrideResolutionSkipped
782 | Self::NpmLockOverrideResolutionSkipped
783 | Self::BunResolutionsShadowedByOverrides
784 | Self::PnpmWorkspaceOverridesIgnored { .. }
785 | Self::NodeModulesMissing
786 | Self::BoundariesNotConfigured
787 | Self::RulePacksNotConfigured
788 | Self::ExcludedByDefaultIgnore { .. }
789 | Self::NoSourceFilesAnalyzed { .. }
790 | Self::FileScoresUnavailable { .. }
791 | Self::HotspotsSkipped { .. }
792 | Self::ChangeCouplingSkipped { .. }
793 | Self::ShallowClone { .. }
794 | Self::UnpinnedClock
795 | Self::OwnershipUnavailable { .. }
796 | Self::TrendSnapshotUnreadable { .. }
797 | Self::TrendGroupBaselineUnavailable { .. }
798 | Self::PluginConfigUnreadable { .. }
799 | Self::PluginEffectNotModeled { .. }
800 | Self::CoverageAutoDetected
801 | Self::FlagAgeShallowClone
802 | Self::FlagAgeUnavailable { .. }
803 | Self::IgnoreDependenciesGlobUnmatched { .. }
804 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
805 }
806 }
807
808 #[must_use]
823 pub const fn is_analysis_stage(&self) -> bool {
824 match self {
825 Self::MalformedPnpmWorkspaceYaml { .. }
826 | Self::BunLockbOverrideResolutionSkipped
827 | Self::BunLockOverrideResolutionSkipped
828 | Self::PnpmLockOverrideResolutionSkipped
829 | Self::NpmLockOverrideResolutionSkipped
830 | Self::BunResolutionsShadowedByOverrides
831 | Self::PnpmWorkspaceOverridesIgnored { .. }
832 | Self::BoundariesNotConfigured
833 | Self::RulePacksNotConfigured => true,
834 Self::UndeclaredWorkspace
835 | Self::MalformedPackageJson { .. }
836 | Self::GlobMatchedNoPackageJson { .. }
837 | Self::MalformedTsconfig { .. }
838 | Self::TsconfigReferenceDirMissing
839 | Self::SkippedLargeFile { .. }
840 | Self::SkippedMinifiedFile { .. }
841 | Self::SkippedSourceDotdir
842 | Self::SourceReadFailure { .. }
843 | Self::SourceParseDegraded { .. }
844 | Self::NodeModulesMissing
845 | Self::ExcludedByDefaultIgnore { .. }
846 | Self::NoSourceFilesAnalyzed { .. }
847 | Self::FileScoresUnavailable { .. }
848 | Self::HotspotsSkipped { .. }
849 | Self::ChangeCouplingSkipped { .. }
850 | Self::ShallowClone { .. }
851 | Self::UnpinnedClock
852 | Self::OwnershipUnavailable { .. }
853 | Self::TrendSnapshotUnreadable { .. }
854 | Self::TrendGroupBaselineUnavailable { .. }
855 | Self::PluginConfigUnreadable { .. }
856 | Self::PluginEffectNotModeled { .. }
857 | Self::CoverageAutoDetected
858 | Self::FlagAgeShallowClone
859 | Self::FlagAgeUnavailable { .. }
860 | Self::IgnoreDependenciesGlobUnmatched { .. }
861 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
862 }
863 }
864
865 #[must_use]
882 pub const fn is_health_stage(&self) -> bool {
883 match self {
884 Self::FileScoresUnavailable { .. }
885 | Self::HotspotsSkipped { .. }
886 | Self::ChangeCouplingSkipped { .. }
887 | Self::ShallowClone { .. }
888 | Self::UnpinnedClock
889 | Self::OwnershipUnavailable { .. }
890 | Self::TrendSnapshotUnreadable { .. }
891 | Self::TrendGroupBaselineUnavailable { .. }
892 | Self::CoverageAutoDetected => true,
893 Self::UndeclaredWorkspace
894 | Self::MalformedPackageJson { .. }
895 | Self::GlobMatchedNoPackageJson { .. }
896 | Self::MalformedTsconfig { .. }
897 | Self::TsconfigReferenceDirMissing
898 | Self::MalformedPnpmWorkspaceYaml { .. }
899 | Self::SkippedLargeFile { .. }
900 | Self::SkippedMinifiedFile { .. }
901 | Self::SkippedSourceDotdir
902 | Self::SourceReadFailure { .. }
903 | Self::SourceParseDegraded { .. }
904 | Self::BunLockbOverrideResolutionSkipped
905 | Self::BunLockOverrideResolutionSkipped
906 | Self::PnpmLockOverrideResolutionSkipped
907 | Self::NpmLockOverrideResolutionSkipped
908 | Self::BunResolutionsShadowedByOverrides
909 | Self::PnpmWorkspaceOverridesIgnored { .. }
910 | Self::NodeModulesMissing
911 | Self::BoundariesNotConfigured
912 | Self::RulePacksNotConfigured
913 | Self::ExcludedByDefaultIgnore { .. }
914 | Self::PluginConfigUnreadable { .. }
915 | Self::PluginEffectNotModeled { .. }
916 | Self::NoSourceFilesAnalyzed { .. }
917 | Self::FlagAgeShallowClone
918 | Self::FlagAgeUnavailable { .. }
919 | Self::IgnoreDependenciesGlobUnmatched { .. }
920 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
921 }
922 }
923
924 #[must_use]
943 pub const fn is_plugin_stage(&self) -> bool {
944 match self {
945 Self::PluginConfigUnreadable { .. } | Self::PluginEffectNotModeled { .. } => true,
946 Self::UndeclaredWorkspace
947 | Self::MalformedPackageJson { .. }
948 | Self::GlobMatchedNoPackageJson { .. }
949 | Self::MalformedTsconfig { .. }
950 | Self::TsconfigReferenceDirMissing
951 | Self::MalformedPnpmWorkspaceYaml { .. }
952 | Self::SkippedLargeFile { .. }
953 | Self::SkippedMinifiedFile { .. }
954 | Self::SkippedSourceDotdir
955 | Self::SourceReadFailure { .. }
956 | Self::SourceParseDegraded { .. }
957 | Self::BunLockbOverrideResolutionSkipped
958 | Self::BunLockOverrideResolutionSkipped
959 | Self::PnpmLockOverrideResolutionSkipped
960 | Self::NpmLockOverrideResolutionSkipped
961 | Self::BunResolutionsShadowedByOverrides
962 | Self::PnpmWorkspaceOverridesIgnored { .. }
963 | Self::NodeModulesMissing
964 | Self::BoundariesNotConfigured
965 | Self::RulePacksNotConfigured
966 | Self::ExcludedByDefaultIgnore { .. }
967 | Self::NoSourceFilesAnalyzed { .. }
968 | Self::FileScoresUnavailable { .. }
969 | Self::HotspotsSkipped { .. }
970 | Self::ChangeCouplingSkipped { .. }
971 | Self::ShallowClone { .. }
972 | Self::UnpinnedClock
973 | Self::OwnershipUnavailable { .. }
974 | Self::TrendSnapshotUnreadable { .. }
975 | Self::TrendGroupBaselineUnavailable { .. }
976 | Self::CoverageAutoDetected
977 | Self::FlagAgeShallowClone
978 | Self::FlagAgeUnavailable { .. }
979 | Self::IgnoreDependenciesGlobUnmatched { .. }
980 | Self::IgnoreFindingsPatternUnmatched { .. } => false,
981 }
982 }
983}
984
985#[must_use]
988fn format_size_mb(bytes: u64) -> String {
989 #[expect(
990 clippy::cast_precision_loss,
991 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
992 )]
993 let mb = bytes as f64 / (1024.0 * 1024.0);
994 format!("{mb:.1} MB")
995}
996
997#[derive(Debug, Clone, Serialize, Deserialize)]
1004#[cfg_attr(feature = "schema", derive(JsonSchema))]
1005pub struct WorkspaceDiagnostic {
1006 #[serde(serialize_with = "serde_path::serialize")]
1008 pub path: PathBuf,
1009 #[serde(flatten)]
1011 pub kind: WorkspaceDiagnosticKind,
1012 pub message: String,
1015 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1032 pub degrades_analysis: bool,
1033}
1034
1035impl WorkspaceDiagnostic {
1036 #[must_use]
1054 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
1055 let path = normalise_diagnostic_path(path);
1056 let kind = normalise_payload_paths(root, kind);
1057 let message = render_message(root, &path, &kind);
1058 let degrades_analysis = kind.warns_on_stderr();
1059 Self {
1060 path,
1061 kind,
1062 message,
1063 degrades_analysis,
1064 }
1065 }
1066
1067 #[must_use]
1085 pub fn into_root_relative(mut self, root: &Path) -> Self {
1086 if let Ok(relative) = self.path.strip_prefix(root) {
1087 self.path = if relative.as_os_str().is_empty() {
1088 PathBuf::from(".")
1089 } else {
1090 relative.to_path_buf()
1091 };
1092 }
1093 self
1094 }
1095}
1096
1097fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
1114 let rebuilt: PathBuf = path.components().collect();
1115 if rebuilt.as_os_str() == path.as_os_str() {
1116 path
1117 } else {
1118 rebuilt
1119 }
1120}
1121
1122fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
1135 let root_str = root.display().to_string();
1136 let root_alt = root_str.replace('\\', "/");
1137 let normalise = |text: String| -> String {
1138 let stripped = text
1139 .replace(&format!("{root_str}/"), "")
1140 .replace(&format!("{root_alt}/"), "");
1141 stripped
1142 .replace(&format!("{root_str}\\"), "")
1143 .replace(&format!("{root_alt}\\"), "")
1144 };
1145 match kind {
1146 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
1147 WorkspaceDiagnosticKind::MalformedPackageJson {
1148 error: normalise(error),
1149 }
1150 }
1151 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
1152 WorkspaceDiagnosticKind::MalformedTsconfig {
1153 error: normalise(error),
1154 }
1155 }
1156 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
1157 WorkspaceDiagnosticKind::SourceReadFailure {
1158 error: normalise(error),
1159 }
1160 }
1161 WorkspaceDiagnosticKind::FileScoresUnavailable { error } => {
1162 WorkspaceDiagnosticKind::FileScoresUnavailable {
1163 error: normalise(error),
1164 }
1165 }
1166 WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1167 WorkspaceDiagnosticKind::OwnershipUnavailable {
1168 cause,
1169 error: normalise(error),
1170 }
1171 }
1172 WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => {
1173 WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
1174 error: normalise(error),
1175 }
1176 }
1177 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
1178 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1179 pattern: canonical_glob_pattern(pattern),
1180 }
1181 }
1182 other => other,
1183 }
1184}
1185
1186fn canonical_glob_pattern(pattern: String) -> String {
1193 for prefix in ["./", ".\\"] {
1194 if let Some(rest) = pattern.strip_prefix(prefix)
1195 && !rest.is_empty()
1196 {
1197 return rest.to_owned();
1198 }
1199 }
1200 pattern
1201}
1202
1203#[must_use]
1223pub fn merge_workspace_diagnostics(
1224 primary: Vec<WorkspaceDiagnostic>,
1225 secondary: Vec<WorkspaceDiagnostic>,
1226) -> Vec<WorkspaceDiagnostic> {
1227 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
1228 let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
1229 for diagnostic in primary.into_iter().chain(secondary) {
1230 let key = (diagnostic.kind.clone(), diagnostic.path.clone());
1231 if seen.insert(key) {
1232 merged.push(diagnostic);
1233 }
1234 }
1235 merged
1236}
1237
1238#[must_use]
1250pub fn dedupe_workspace_diagnostics(
1251 diagnostics: Vec<WorkspaceDiagnostic>,
1252) -> Vec<WorkspaceDiagnostic> {
1253 merge_workspace_diagnostics(diagnostics, Vec::new())
1254}
1255
1256#[must_use]
1268pub fn glob_first_literal_segment(pattern: &str) -> Option<&str> {
1269 pattern.split('/').find(|segment| {
1270 !segment.is_empty()
1271 && !segment.contains(['*', '?', '[', ']', '{', '}'])
1272 && *segment != "."
1273 && *segment != ".."
1274 })
1275}
1276
1277fn unreadable_situation(reason: &str) -> &'static str {
1284 match reason {
1285 "array-form" => "uses the array form, which is not read yet",
1286 "spread" => "spreads a value that is not statically readable",
1287 "unreadable-entries" => "has entries that hold no statically readable value",
1288 "not-object-literal" => "is not a static object literal",
1289 "unrecognized-call" => "is passed through a call that is not a known config wrapper",
1290 "import-target-unreadable" => "comes from an imported file that is not statically readable",
1291 "dynamic-argument" => "receives an argument that is not a static literal",
1292 _ => "could not be read statically",
1293 }
1294}
1295
1296fn unreadable_key_consequence(key: &str, reason: &str) -> (&'static str, &'static str) {
1312 match (key, reason) {
1313 ("exposes", "unrecognized-call") => (
1314 "only the targets in the object literal it receives are registered as entry points",
1315 "Name any other exposed files in `dynamicallyLoaded`.",
1316 ),
1317 ("remotes", "unrecognized-call") => (
1318 "only the aliases in the object literal it receives are treated as provided by a \
1319 remote container",
1320 "Name any other aliases in `ignoreDependencies`.",
1321 ),
1322 ("exposes", "import-target-unreadable") => (
1323 "the targets that file declares are not registered as entry points",
1324 "Name the exposed files in `dynamicallyLoaded`.",
1325 ),
1326 ("remotes", "import-target-unreadable") => (
1327 "the aliases that file declares are not treated as provided by a remote container",
1328 "Name the aliases in `ignoreDependencies`.",
1329 ),
1330 ("registerRemotes", _) => (
1331 "the remotes it registers are not treated as provided by a remote container",
1332 "Name the remote aliases in `ignoreDependencies`, or pass the remote names as \
1333 string literals.",
1334 ),
1335 ("init" | "createInstance", _) => (
1336 "the remotes its options declare are not treated as provided by a remote container",
1337 "Name the remote aliases in `ignoreDependencies`, or pass `remotes` as an array of \
1338 objects with literal names.",
1339 ),
1340 ("loadRemote", _) => (
1341 "the remote it loads is not treated as provided by a remote container",
1342 "Name the remote alias in `ignoreDependencies`, or pass the request as a string \
1343 literal.",
1344 ),
1345 ("exposes", _) => (
1346 "the targets are not registered as entry points",
1347 "Name the exposed files in `dynamicallyLoaded`.",
1348 ),
1349 ("remotes", _) => (
1350 "the aliases are not treated as provided by a remote container",
1351 "Name the aliases in `ignoreDependencies`, or declare them as the keys of an object \
1352 literal, whose values may be computed.",
1353 ),
1354 _ => (
1355 "what it declares is not fully registered",
1356 "Declare the value as a static object literal.",
1357 ),
1358 }
1359}
1360
1361fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
1362 let display = display_relative(root, path);
1363 match kind {
1364 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
1365 "Directory '{display}' contains package.json but is not declared as a workspace. \
1366 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
1367 ),
1368 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
1369 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
1370 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
1371 ),
1372 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
1373 "Glob '{pattern}' matched '{display}' but no package.json is present. \
1374 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
1375 ),
1376 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
1377 "tsconfig at '{display}' failed to parse ({error}); \
1378 its project references, path aliases and compiler options are ignored. \
1379 Fix the JSON syntax."
1380 ),
1381 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
1382 "tsconfig.json references '{display}' but the directory does not exist. \
1383 Update or remove the reference, or restore the missing directory."
1384 ),
1385 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
1386 "'{display}' is not valid YAML ({error}). Catalog checks are skipped \
1387 and its override entries are ignored until it parses. pnpm install \
1388 can accept this file, but `pnpm add` and YAML formatters reject it. \
1389 Indent each continuation line of a quoted value or a `[...]` / \
1390 `{{...}}` list deeper than its key."
1391 ),
1392 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
1393 "Skipped '{display}' ({size}): exceeds the max file size limit. \
1394 Its imports and exports are not analyzed. Raise the limit with \
1395 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
1396 to ignorePatterns.",
1397 size = format_size_mb(*size_bytes)
1398 ),
1399 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
1400 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
1401 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
1402 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
1403 should be analyzed.",
1404 size = format_size_mb(*size_bytes)
1405 ),
1406 WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
1407 "Skipped hidden directory '{display}': it contains source files but hidden \
1408 directories are not traversed. Its imports and exports are not analyzed. \
1409 A file, export or dependency that only this directory uses can be reported as \
1410 unused. To analyze it, add '!{display}/**' to ignorePatterns. To stop \
1411 that false positive without that, add the file to entry, the export to \
1412 ignoreExports or the dependency to ignoreDependencies. To silence this message, \
1413 add '{display}/**' to ignorePatterns. fallow --root {display} analyzes only \
1414 that directory on its own and does not fix this run."
1415 ),
1416 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
1417 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
1418 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
1419 ),
1420 WorkspaceDiagnosticKind::SourceParseDegraded {
1421 error_count,
1422 panicked,
1423 } => {
1424 let outcome = if *panicked {
1425 "the parser stopped there"
1426 } else {
1427 "the parser recovered and continued"
1428 };
1429 format!(
1430 "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
1431 references it did not reach are missing from this run, so files and symbols it \
1432 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
1433 syntax newer than fallow's parser."
1434 )
1435 }
1436 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
1437 "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
1438 sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
1439 (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
1440 use instead, so unused-dependency-overrides findings are not reported. Run bun install \
1441 --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
1442 bun.lockb if this repository no longer uses bun."
1443 ),
1444 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
1445 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1446 no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
1447 findings are not reported. Run bun install to regenerate the text lockfile, then \
1448 rerun fallow."
1449 ),
1450 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped => format!(
1451 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1452 no other parseable lockfile was available, so unused-dependency-overrides findings \
1453 are not reported. Resolve any merge-conflict markers in the lockfile, or run pnpm \
1454 install to regenerate it, then rerun fallow."
1455 ),
1456 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped => format!(
1457 "Skipped dependency-override resolution because '{display}' could not be parsed and \
1458 no other parseable lockfile was available, so unused-dependency-overrides findings \
1459 are not reported. Resolve any merge-conflict markers in the lockfile, or run npm \
1460 install to regenerate it, then rerun fallow."
1461 ),
1462 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
1463 "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
1464 `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
1465 remove the shadowed `resolutions` entries."
1466 ),
1467 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1468 cause: PnpmWorkspaceOverridesIgnoredCause::PackageJsonOverrides,
1469 } => format!(
1470 "pnpm 10 and earlier ignore the `overrides` in '{display}' because the root package.json \
1471 declares `pnpm.overrides` or `resolutions`, so fallow does not check them. Move the \
1472 entries into one source."
1473 ),
1474 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1475 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
1476 } => format!(
1477 "pnpm versions before 10.5.1 do not read the `overrides` in '{display}', so fallow \
1478 does not check them. Update `packageManager` to pnpm 10.5.1 or later, or move the \
1479 entries into `pnpm.overrides` in the root package.json."
1480 ),
1481 WorkspaceDiagnosticKind::NodeModulesMissing => format!(
1482 "'{display}' does not exist. Package exports and conditional exports cannot be read, \
1483 framework plugins that activate on an installed package stay inactive, and \
1484 dependency classification degrades, so imports and dependencies can be \
1485 misreported. Run npm install / pnpm install / yarn / bun install first."
1486 ),
1487 WorkspaceDiagnosticKind::BoundariesNotConfigured => {
1488 "No architecture boundaries are configured, so the boundary detector did not run and \
1489 its violation counts are zero because nothing was measured. Add `boundaries` to the \
1490 config, or set `boundary-violation` to off to state that the check is not wanted."
1491 .to_string()
1492 }
1493 WorkspaceDiagnosticKind::RulePacksNotConfigured => {
1494 "No rule packs are configured, so the policy detector did not run and its violation \
1495 counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
1496 `policy-violation` to off to state that the check is not wanted."
1497 .to_string()
1498 }
1499 WorkspaceDiagnosticKind::NoSourceFilesAnalyzed {
1500 excluded_file_count,
1501 } => {
1502 if *excluded_file_count == 0 {
1503 "No source files were analyzed, so every finding count this run reports is zero \
1504 because nothing was measured. Check the analysis root, ignorePatterns, and any \
1505 path or workspace filter this run applied."
1506 .to_owned()
1507 } else {
1508 format!(
1509 "No source files were analyzed. Fallow's built-in ignore patterns excluded \
1510 {excluded_file_count} candidate files, so every finding count this run \
1511 reports is zero because nothing was measured; run with --explain-skipped \
1512 for the breakdown."
1513 )
1514 }
1515 }
1516 WorkspaceDiagnosticKind::FileScoresUnavailable { error } => format!(
1517 "Could not compute per-file health scores ({error}), so the score list is empty and \
1518 the scored-file count is 0 because nothing was measured rather than because the \
1519 project has nothing to score. Rerun with --no-cache, or scope the run to a \
1520 subdirectory to find the input that fails."
1521 ),
1522 WorkspaceDiagnosticKind::HotspotsSkipped { cause } => match cause.as_str() {
1523 "invalid-since" => "Hotspot analysis was skipped because --since could not be read \
1524 as a time window, so the hotspots, churn and ownership sections report nothing \
1525 rather than zero. Spell it as a duration such as 6m or 90d, or drop it to use \
1526 the default window."
1527 .to_owned(),
1528 "no-commits" => "Hotspot analysis was skipped because the current branch has no \
1529 commits yet, so the hotspots, churn and ownership sections report nothing \
1530 rather than zero. Commit the project to give churn a history, or pass \
1531 --churn-file with exported change history."
1532 .to_owned(),
1533 "churn-file-unreadable" => format!(
1534 "Hotspot analysis was skipped because the churn file '{display}' could no longer \
1535 be read after it was validated, so the hotspots, churn and ownership sections \
1536 report nothing rather than zero. Make sure nothing rewrites the file while \
1537 fallow runs, and rerun."
1538 ),
1539 _ => "Hotspot analysis was skipped because no git repository was found at the \
1543 project root, so the hotspots, churn and ownership sections report nothing \
1544 rather than zero. Run fallow inside the repository, or pass --churn-file with \
1545 exported change history."
1546 .to_owned(),
1547 },
1548 WorkspaceDiagnosticKind::ChangeCouplingSkipped { cause } => match cause.as_str() {
1549 "shallow-clone" => "Change coupling was not measured because the repository is a \
1550 shallow clone, so no change coupling pairs are reported. Fetch the full \
1551 history with `git fetch --unshallow` to measure it."
1552 .to_owned(),
1553 _ => "Change coupling found no pairs because the history window has fewer \
1554 analyzed commits than the minimum revisions of one file. Widen the window \
1555 with --since, or lower health.changeCoupling.minRevisions."
1556 .to_owned(),
1557 },
1558 WorkspaceDiagnosticKind::ShallowClone {
1559 ownership_requested,
1560 } => {
1561 let ownership = if *ownership_requested {
1562 " Ownership signals are skewed too, because a shallow clone inflates \
1563 single-author dominance."
1564 } else {
1565 ""
1566 };
1567 format!(
1568 "This is a shallow clone, so churn covers only the fetched history and every \
1569 hotspot figure is incomplete.{ownership} Run git fetch --unshallow for the full \
1570 history."
1571 )
1572 }
1573 WorkspaceDiagnosticKind::UnpinnedClock => {
1574 "No commit timestamp was available, so churn recency and ownership staleness were \
1575 measured against the wall clock and drift between runs over the same commit. Set \
1576 FALLOW_CLOCK_EPOCH to pin the run clock."
1577 .to_owned()
1578 }
1579 WorkspaceDiagnosticKind::OwnershipUnavailable { cause, error } => {
1580 if cause == "codeowners-parse-failed" {
1581 format!(
1582 "Ownership signals are degraded: CODEOWNERS could not be parsed ({error}), \
1583 so hotspot entries carry no declared owner. Fix the CODEOWNERS syntax, or \
1584 drop --ownership for this run."
1585 )
1586 } else {
1587 format!(
1588 "Ownership signals are degraded: health.ownership.botPatterns contains an \
1589 invalid glob ({error}), so no author is classified as a bot and bot commits \
1590 count towards ownership. Fix the pattern, or remove it from the config."
1591 )
1592 }
1593 }
1594 WorkspaceDiagnosticKind::TrendSnapshotUnreadable { error } => format!(
1595 "Skipped health snapshot '{display}' ({error}), so the trend is computed over fewer \
1596 snapshots than this project has on disk. Delete the unreadable file, or rewrite it \
1597 with fallow health --save-snapshot."
1598 ),
1599 WorkspaceDiagnosticKind::TrendGroupBaselineUnavailable { cause } => {
1600 if cause == "grouped-by-mismatch" {
1601 format!(
1602 "Health snapshot '{display}' was saved with a different --group-by mode, so \
1603 the groups have no trend. The project trend is not affected. Save a new \
1604 snapshot with the same --group-by mode."
1605 )
1606 } else {
1607 format!(
1608 "Health snapshot '{display}' holds no group data, so the groups have no \
1609 trend. The project trend is not affected. Save a new snapshot with \
1610 --group-by and --save-snapshot."
1611 )
1612 }
1613 }
1614 WorkspaceDiagnosticKind::CoverageAutoDetected => format!(
1615 "Coverage was auto-detected at '{display}' rather than passed with --coverage, so the \
1616 CRAP scores depend on whichever coverage file is on disk at run time. Pass --coverage \
1617 '{display}' explicitly for reproducible scores."
1618 ),
1619 WorkspaceDiagnosticKind::FlagAgeShallowClone => {
1620 "This is a shallow clone, so the flag retirement report gives no flag age. Run git \
1621 fetch --unshallow for the full history, or pass --flag-age off."
1622 .to_owned()
1623 }
1624 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched { pattern } => format!(
1625 "ignoreDependencies glob '{pattern}' matched no declared dependency in this run, so \
1626 it has no effect. A glob matches package names, such as @scope/*. Fix the glob, or \
1627 remove it from the config."
1628 ),
1629 WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched { pattern } => format!(
1630 "ignoreFindings pattern '{pattern}' matched no finding in this run, so it has no \
1631 effect. A pattern is a glob relative to the project root. Fix the pattern, or \
1632 remove it from the config."
1633 ),
1634 WorkspaceDiagnosticKind::FlagAgeUnavailable { cause } => {
1635 if cause == "no-commits" {
1636 "The flag retirement report gives no flag age, because the current branch has no \
1637 commit. Commit the code, or pass --flag-age off."
1638 .to_owned()
1639 } else {
1640 "The flag retirement report gives no flag age, because no git repository was \
1641 found at the project root. Run fallow inside the repository, or pass --flag-age \
1642 off."
1643 .to_owned()
1644 }
1645 }
1646 WorkspaceDiagnosticKind::PluginConfigUnreadable {
1647 plugin,
1648 key,
1649 reason,
1650 } => {
1651 let (consequence, advice) = unreadable_key_consequence(key, reason);
1652 format!(
1653 "Plugin '{plugin}': `{key}` in '{display}' {situation}, so {consequence}. {advice}",
1654 situation = unreadable_situation(reason)
1655 )
1656 }
1657 WorkspaceDiagnosticKind::PluginEffectNotModeled {
1658 plugin,
1659 key,
1660 reason,
1661 } => {
1662 let effect = "`autoImports` kept the convention entry patterns for that surface, and \
1667 fallow reports no unused file there. Write the setting as static \
1668 literals, or remove the key to use the framework defaults.";
1669 if key.starts_with('#') {
1670 format!(
1671 "Plugin '{plugin}': fallow cannot read which names '{display}' takes from \
1672 `{key}`, so every name of `{key}` counts as used, and fallow reports no \
1673 unused file for these names. Read each name with a member access such as \
1674 `C.Card`, or import it by name."
1675 )
1676 } else if reason == "config-property-unreadable" {
1677 format!(
1678 "Plugin '{plugin}': fallow cannot read a top-level property in '{display}', so \
1679 it cannot classify the `{key}` surface. {effect}"
1680 )
1681 } else {
1682 format!(
1683 "Plugin '{plugin}': fallow does not model the effect of `{key}` in \
1684 '{display}'. {effect}"
1685 )
1686 }
1687 }
1688 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
1689 pattern,
1690 file_count,
1691 directory_count,
1692 } => {
1693 let display = if display.is_empty() {
1697 ".".to_owned()
1698 } else {
1699 display
1700 };
1701 let location = if *directory_count > 1 {
1707 format!(
1708 "Skipped {file_count} source files across {directory_count} directories, \
1709 the largest group under '{display}'"
1710 )
1711 } else if *file_count == 1 {
1712 format!("Skipped 1 source file under '{display}'")
1713 } else {
1714 format!("Skipped {file_count} source files under '{display}'")
1715 };
1716 let singular = *file_count == 1 && *directory_count <= 1;
1717 let (subject, effect) = if singular {
1718 ("it matches", "it imports, exports, or defines")
1719 } else {
1720 ("they match", "they import, export, or define")
1721 };
1722 let remedy = if glob_first_literal_segment(pattern).is_some() {
1726 format!(
1727 "To analyze first-party source there, add '!{display}/**' to \
1728 ignorePatterns, or analyze that directory on its own with \
1729 fallow --root {display}."
1730 )
1731 } else {
1732 "This pattern matches a file name rather than a directory, so re-running under \
1733 a different --root excludes the same files again. Rename first-party source \
1734 that only looks generated, dropping the '.min' or '.bundle' infix, or add a \
1735 '!' entry for the file to ignorePatterns."
1736 .to_owned()
1737 };
1738 format!(
1739 "{location}: {subject} fallow's built-in ignore pattern '{pattern}', so nothing \
1740 {effect} is visible to this run. {remedy}"
1741 )
1742 }
1743 }
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748 use super::*;
1749
1750 #[test]
1751 fn skipped_large_file_diagnostic_id_and_message() {
1752 let root = Path::new("/project");
1753 let diag = WorkspaceDiagnostic::new(
1754 root,
1755 root.join("src/vendor/app.bundle.js"),
1756 WorkspaceDiagnosticKind::SkippedLargeFile {
1757 size_bytes: 6 * 1024 * 1024,
1758 },
1759 );
1760 assert_eq!(diag.kind.id(), "skipped-large-file");
1761 assert!(
1762 diag.message.contains("src/vendor/app.bundle.js"),
1763 "message names the project-relative path: {}",
1764 diag.message
1765 );
1766 assert!(
1767 diag.message.contains("6.0 MB"),
1768 "message reports the size: {}",
1769 diag.message
1770 );
1771 assert!(
1772 diag.message.contains("--max-file-size"),
1773 "message names the override flag: {}",
1774 diag.message
1775 );
1776 }
1777
1778 #[test]
1779 fn skipped_minified_file_diagnostic_id_and_message() {
1780 let root = Path::new("/project");
1781 let diag = WorkspaceDiagnostic::new(
1782 root,
1783 root.join("src/assets/index-abc123.js"),
1784 WorkspaceDiagnosticKind::SkippedMinifiedFile {
1785 size_bytes: 2 * 1024 * 1024,
1786 },
1787 );
1788 assert_eq!(diag.kind.id(), "skipped-minified-file");
1789 assert!(
1790 diag.message.contains("src/assets/index-abc123.js"),
1791 "message names the project-relative path: {}",
1792 diag.message
1793 );
1794 assert!(
1795 diag.message.contains("2.0 MB"),
1796 "message reports the size: {}",
1797 diag.message
1798 );
1799 assert!(
1800 diag.message.contains("--max-file-size 0"),
1801 "message names the opt-out: {}",
1802 diag.message
1803 );
1804 }
1805
1806 #[test]
1807 fn skipped_source_dotdir_diagnostic_id_and_message() {
1808 let root = Path::new("/project");
1809 let diag = WorkspaceDiagnostic::new(
1810 root,
1811 root.join(".claude"),
1812 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1813 );
1814 assert_eq!(diag.kind.id(), "skipped-source-dotdir");
1815 assert!(
1816 diag.message.contains(".claude"),
1817 "message names the project-relative path: {}",
1818 diag.message
1819 );
1820 assert!(
1821 diag.message
1822 .contains("Its imports and exports are not analyzed."),
1823 "message states the consequence: {}",
1824 diag.message
1825 );
1826 for remedy in [
1827 "add the file to entry",
1828 "the export to ignoreExports",
1829 "the dependency to ignoreDependencies",
1830 ] {
1831 assert!(
1832 diag.message.contains(remedy),
1833 "message names the remedy `{remedy}`: {}",
1834 diag.message
1835 );
1836 }
1837 assert!(
1838 diag.message
1839 .contains("fallow --root .claude analyzes only that directory")
1840 && diag.message.contains("does not fix this run"),
1841 "message must not imply that --root fixes this run: {}",
1842 diag.message
1843 );
1844 assert!(
1845 diag.message.contains("ignorePatterns"),
1846 "message names the silencing route: {}",
1847 diag.message
1848 );
1849 assert!(
1850 diag.message.contains("add '!.claude/**' to ignorePatterns"),
1851 "the message names the exception that traverses it (issue #2452): {}",
1852 diag.message
1853 );
1854 assert_eq!(
1855 serde_json::to_value(&diag).expect("serializes")["kind"],
1856 "skipped-source-dotdir",
1857 "id() must byte-match the serde kebab-case tag"
1858 );
1859 }
1860
1861 #[cfg(feature = "schema")]
1862 #[test]
1863 fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
1864 let schema = schemars::schema_for!(WorkspaceDiagnostic);
1865 let json = serde_json::to_string(&schema).expect("schema serializes");
1866 assert!(json.contains("skipped-source-dotdir"));
1867 }
1868
1869 #[test]
1870 fn source_read_failure_serializes_typed_error_payload() {
1871 let root = Path::new("/project");
1872 let diagnostic = WorkspaceDiagnostic::new(
1873 root,
1874 root.join("src/removed.ts"),
1875 WorkspaceDiagnosticKind::SourceReadFailure {
1876 error: "No such file or directory".to_string(),
1877 },
1878 );
1879
1880 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
1881 assert_eq!(json["kind"], "source-read-failure");
1882 assert_eq!(
1883 json["path"],
1884 root.join("src/removed.ts")
1885 .display()
1886 .to_string()
1887 .replace('\\', "/")
1888 );
1889 assert_eq!(json["error"], "No such file or directory");
1890 assert!(
1891 json["message"]
1892 .as_str()
1893 .is_some_and(|message| message.contains("src/removed.ts"))
1894 );
1895 }
1896
1897 #[cfg(feature = "schema")]
1898 #[test]
1899 fn workspace_diagnostic_schema_includes_source_read_failure() {
1900 let schema = schemars::schema_for!(WorkspaceDiagnostic);
1901 let json = serde_json::to_string(&schema).expect("schema serializes");
1902 assert!(json.contains("source-read-failure"));
1903 assert!(json.contains("error"));
1904 }
1905
1906 #[test]
1907 fn bun_lockb_override_resolution_skipped_id_and_message() {
1908 let root = Path::new("/project");
1909 let diag = WorkspaceDiagnostic::new(
1910 root,
1911 root.join("package.json"),
1912 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1913 );
1914 assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
1915 assert!(
1916 diag.message.contains("'package.json'"),
1917 "message names the project-relative manifest: {}",
1918 diag.message
1919 );
1920 assert!(
1921 diag.message.contains("no parseable text lockfile"),
1922 "message states the cause: {}",
1923 diag.message
1924 );
1925 assert!(
1926 !diag.message.contains("only bun.lockb"),
1927 "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
1928 bun.lock may sit beside it: {}",
1929 diag.message
1930 );
1931 assert!(
1932 diag.message.contains("bun install --save-text-lockfile")
1933 && diag.message.contains("delete the stale bun.lockb"),
1934 "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
1935 diag.message
1936 );
1937 let json = serde_json::to_value(&diag).expect("diagnostic serializes");
1938 assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
1939 }
1940
1941 #[test]
1942 fn lockfile_override_diagnostic_ids_and_messages_are_actionable() {
1943 let root = Path::new("/project");
1944 let malformed = WorkspaceDiagnostic::new(
1945 root,
1946 root.join("bun.lock"),
1947 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1948 );
1949 assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
1950 assert!(malformed.message.contains("regenerate"));
1951
1952 let pnpm = WorkspaceDiagnostic::new(
1953 root,
1954 root.join("pnpm-lock.yaml"),
1955 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
1956 );
1957 assert_eq!(pnpm.kind.id(), "pnpm-lock-override-resolution-skipped");
1958 assert!(pnpm.message.contains("merge-conflict markers"));
1959 assert!(pnpm.message.contains("pnpm install"));
1960
1961 let npm = WorkspaceDiagnostic::new(
1962 root,
1963 root.join("package-lock.json"),
1964 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
1965 );
1966 assert_eq!(npm.kind.id(), "npm-lock-override-resolution-skipped");
1967 assert!(npm.message.contains("package-lock.json"));
1968 assert!(npm.message.contains("merge-conflict markers"));
1969 assert!(npm.message.contains("npm install"));
1970
1971 let shadowed = WorkspaceDiagnostic::new(
1972 root,
1973 root.join("package.json"),
1974 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1975 );
1976 assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
1977 assert!(shadowed.message.contains("ignores `resolutions`"));
1978
1979 let ignored = WorkspaceDiagnostic::new(
1980 root,
1981 root.join("pnpm-workspace.yaml"),
1982 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1983 cause: PnpmWorkspaceOverridesIgnoredCause::PackageJsonOverrides,
1984 },
1985 );
1986 assert_eq!(ignored.kind.id(), "pnpm-workspace-overrides-ignored");
1987 assert!(ignored.message.contains("pnpm-workspace.yaml"));
1988 assert!(
1989 ignored
1990 .message
1991 .contains("pnpm 10 and earlier ignore the `overrides`")
1992 );
1993
1994 let too_old = WorkspaceDiagnostic::new(
1995 root,
1996 root.join("pnpm-workspace.yaml"),
1997 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
1998 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
1999 },
2000 );
2001 assert_eq!(too_old.kind.id(), "pnpm-workspace-overrides-ignored");
2002 assert!(too_old.message.contains("pnpm-workspace.yaml"));
2003 assert!(
2004 too_old
2005 .message
2006 .contains("pnpm versions before 10.5.1 do not read the `overrides`")
2007 );
2008 let json = serde_json::to_value(&too_old).expect("diagnostic serializes");
2009 assert_eq!(json["kind"], "pnpm-workspace-overrides-ignored");
2010 assert_eq!(json["cause"], "pnpm-version");
2011 let json = serde_json::to_value(&ignored).expect("diagnostic serializes");
2012 assert_eq!(json["cause"], "package-json-overrides");
2013 }
2014
2015 #[test]
2016 fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
2017 let root = Path::new("/project");
2018 let inside = WorkspaceDiagnostic::new(
2019 root,
2020 root.join("packages/inner"),
2021 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2022 )
2023 .into_root_relative(root);
2024 assert_eq!(inside.path, Path::new("packages/inner"));
2025
2026 let outside = WorkspaceDiagnostic::new(
2027 root,
2028 PathBuf::from("/elsewhere/packages/inner"),
2029 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2030 )
2031 .into_root_relative(root);
2032 assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
2033 }
2034
2035 #[test]
2036 fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
2037 let analysis_stage = [
2038 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
2039 error: "bad yaml".to_owned(),
2040 },
2041 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
2042 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
2043 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
2044 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
2045 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
2046 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
2047 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
2048 },
2049 ];
2050 for kind in &analysis_stage {
2051 assert!(
2052 kind.is_analysis_stage() && !kind.is_source_discovery(),
2053 "{} is recorded by the analyze stage only",
2054 kind.id()
2055 );
2056 }
2057
2058 let other = [
2059 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2060 WorkspaceDiagnosticKind::MalformedPackageJson {
2061 error: "trailing comma".to_owned(),
2062 },
2063 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2064 pattern: "packages/*".to_owned(),
2065 },
2066 WorkspaceDiagnosticKind::MalformedTsconfig {
2067 error: "unexpected token".to_owned(),
2068 },
2069 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
2070 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
2071 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
2072 WorkspaceDiagnosticKind::SkippedSourceDotdir,
2073 WorkspaceDiagnosticKind::SourceReadFailure {
2074 error: "permission denied".to_owned(),
2075 },
2076 ];
2077 for kind in &other {
2078 assert!(
2079 !kind.is_analysis_stage(),
2080 "{} is a discovery kind, not an analyze-stage kind",
2081 kind.id()
2082 );
2083 }
2084 }
2085
2086 #[test]
2087 fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
2088 let root = Path::new("/project");
2089 let first = WorkspaceDiagnostic::new(
2090 root,
2091 root.join("packages/aaa"),
2092 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2093 pattern: "packages/*".to_owned(),
2094 },
2095 );
2096 let second = WorkspaceDiagnostic::new(
2097 root,
2098 root.join("packages/aaa"),
2099 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2100 pattern: "packages/a*".to_owned(),
2101 },
2102 );
2103
2104 let merged =
2105 merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
2106
2107 let patterns: Vec<String> = merged
2108 .iter()
2109 .map(|diagnostic| match &diagnostic.kind {
2110 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
2111 other => panic!("unexpected kind {}", other.id()),
2112 })
2113 .collect();
2114 assert_eq!(
2115 patterns,
2116 ["packages/*", "packages/a*"],
2117 "two overlapping globs report the same directory twice, with their own pattern; \
2118 the same entry seen from two observation points still folds to one"
2119 );
2120 }
2121
2122 #[test]
2127 fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
2128 let root = Path::new("/project");
2129 let dotted = WorkspaceDiagnostic::new(
2130 root,
2131 root.join("apps/site/.next/cache"),
2132 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2133 pattern: "./apps/**".to_owned(),
2134 },
2135 );
2136 let bare = WorkspaceDiagnostic::new(
2137 root,
2138 root.join("apps/site/.next/cache"),
2139 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2140 pattern: "apps/**".to_owned(),
2141 },
2142 );
2143 assert_eq!(
2144 dotted.kind, bare.kind,
2145 "the no-op ./ prefix is normalised out of the recorded pattern"
2146 );
2147 assert!(
2148 dotted.message.contains("Glob 'apps/**'"),
2149 "the message renders the normalised pattern: {}",
2150 dotted.message
2151 );
2152
2153 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
2154 assert_eq!(
2155 merged.len(),
2156 1,
2157 "one glob declared twice is one diagnostic: {merged:?}"
2158 );
2159 }
2160
2161 #[test]
2165 fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
2166 let root = Path::new("/project");
2167 let recorded = |pattern: &str| {
2168 let diagnostic = WorkspaceDiagnostic::new(
2169 root,
2170 root.join("pkgs"),
2171 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2172 pattern: pattern.to_owned(),
2173 },
2174 );
2175 let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
2176 else {
2177 panic!("constructed a glob-matched-no-package-json diagnostic");
2178 };
2179 (pattern, diagnostic.message)
2180 };
2181
2182 let (root_pattern, root_message) = recorded("./");
2183 assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
2184 assert!(
2185 root_message.contains("Glob './'"),
2186 "the warning names the glob the manifest declared: {root_message}"
2187 );
2188 assert_eq!(recorded(".\\").0, ".\\");
2189 assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
2190 assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
2191 }
2192
2193 #[test]
2200 fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
2201 let root = Path::new("/project");
2202 let dotted = WorkspaceDiagnostic::new(
2203 root,
2204 root.join("./pkgs/aaa"),
2205 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2206 pattern: "./pkgs/*".to_owned(),
2207 },
2208 );
2209 let bare = WorkspaceDiagnostic::new(
2210 root,
2211 root.join("pkgs/aaa"),
2212 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2213 pattern: "pkgs/*".to_owned(),
2214 },
2215 );
2216
2217 let spelling = |diagnostic: &WorkspaceDiagnostic| {
2218 diagnostic.path.display().to_string().replace('\\', "/")
2219 };
2220 assert_eq!(
2221 spelling(&dotted),
2222 "/project/pkgs/aaa",
2223 "the stored path drops the no-op . component, which Path equality \
2224 hides but serialization does not"
2225 );
2226 assert_eq!(spelling(&dotted), spelling(&bare));
2227 assert_eq!(
2228 spelling(&dotted.clone().into_root_relative(root)),
2229 "pkgs/aaa"
2230 );
2231
2232 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
2233 assert_eq!(
2234 merged.len(),
2235 1,
2236 "one directory reached through two spellings of one glob: {merged:?}"
2237 );
2238 }
2239
2240 #[test]
2243 fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
2244 let root = Path::new("/project");
2245 let glob = |pattern: &str, relative: &str| {
2246 WorkspaceDiagnostic::new(
2247 root,
2248 root.join(relative),
2249 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2250 pattern: pattern.to_owned(),
2251 },
2252 )
2253 };
2254
2255 let deduped = dedupe_workspace_diagnostics(vec![
2256 glob("pkgs/*", "pkgs/aaa"),
2257 glob("pkgs/*", "pkgs/bbb"),
2258 glob("./pkgs/*", "./pkgs/aaa"),
2259 glob("pkgs/a*", "pkgs/aaa"),
2260 ]);
2261
2262 let reported: Vec<(String, String)> = deduped
2263 .iter()
2264 .map(|diagnostic| match &diagnostic.kind {
2265 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
2266 pattern.clone(),
2267 diagnostic.path.display().to_string().replace('\\', "/"),
2268 ),
2269 other => panic!("unexpected kind {}", other.id()),
2270 })
2271 .collect();
2272
2273 assert_eq!(
2274 reported,
2275 vec![
2276 ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
2277 ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
2278 ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
2279 ],
2280 "the duplicate spelling folds away and the overlapping glob stays"
2281 );
2282 }
2283
2284 #[test]
2291 fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
2292 for kind in [
2293 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
2294 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
2295 WorkspaceDiagnosticKind::SkippedSourceDotdir,
2296 WorkspaceDiagnosticKind::SourceReadFailure {
2297 error: "permission denied".to_owned(),
2298 },
2299 ] {
2300 assert!(
2301 kind.source_never_analyzed(),
2302 "{} names a source file this run never read",
2303 kind.id()
2304 );
2305 }
2306
2307 let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
2308 error_count: 3,
2309 panicked: false,
2310 };
2311 assert!(
2312 !degraded.source_never_analyzed(),
2313 "a degraded parse read the file, so it has a graph node and its reachability is \
2314 observable; the caveat pass narrows it instead of treating it as unread"
2315 );
2316
2317 for kind in [
2318 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2319 WorkspaceDiagnosticKind::MalformedPackageJson {
2320 error: "trailing comma".to_owned(),
2321 },
2322 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
2323 pattern: "packages/*".to_owned(),
2324 },
2325 WorkspaceDiagnosticKind::MalformedTsconfig {
2326 error: "unexpected token".to_owned(),
2327 },
2328 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
2329 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
2330 error: "bad indent".to_owned(),
2331 },
2332 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
2333 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
2334 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
2335 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
2336 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
2337 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
2338 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
2339 },
2340 WorkspaceDiagnosticKind::NodeModulesMissing,
2341 WorkspaceDiagnosticKind::BoundariesNotConfigured,
2342 WorkspaceDiagnosticKind::RulePacksNotConfigured,
2343 ] {
2344 assert!(
2345 !kind.source_never_analyzed(),
2346 "{} says nothing about a source file's imports going unseen",
2347 kind.id()
2348 );
2349 }
2350 }
2351
2352 #[test]
2353 fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
2354 for kind in [
2355 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
2356 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
2357 WorkspaceDiagnosticKind::SkippedSourceDotdir,
2358 ] {
2359 assert!(
2360 kind.is_source_walk_recorded() && kind.is_source_discovery(),
2361 "{} is written by the source walk",
2362 kind.id()
2363 );
2364 }
2365
2366 let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
2367 error: "permission denied".to_owned(),
2368 };
2369 assert!(
2370 read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
2371 "the parse stage records source-read-failure after the walk, so it must keep \
2372 reaching sessions through the registry"
2373 );
2374
2375 for kind in [
2376 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2377 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
2378 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
2379 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
2380 WorkspaceDiagnosticKind::PnpmLockOverrideResolutionSkipped,
2381 WorkspaceDiagnosticKind::NpmLockOverrideResolutionSkipped,
2382 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
2383 WorkspaceDiagnosticKind::PnpmWorkspaceOverridesIgnored {
2384 cause: PnpmWorkspaceOverridesIgnoredCause::PnpmVersion,
2385 },
2386 ] {
2387 assert!(
2388 !kind.is_source_walk_recorded(),
2389 "{} is not written by the source walk",
2390 kind.id()
2391 );
2392 }
2393 }
2394
2395 #[test]
2403 fn a_built_in_ignore_exclusion_is_not_a_file_the_run_failed_to_analyze() {
2404 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2405 pattern: "**/build/**".to_owned(),
2406 file_count: 3,
2407 directory_count: 1,
2408 };
2409 assert!(!kind.source_never_analyzed());
2410 }
2411
2412 #[test]
2417 fn a_built_in_ignore_exclusion_does_not_warn_on_stderr_by_default() {
2418 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2419 pattern: "**/build/**".to_owned(),
2420 file_count: 3,
2421 directory_count: 1,
2422 };
2423 assert!(!kind.warns_on_stderr());
2424 }
2425
2426 #[test]
2431 fn a_built_in_ignore_exclusion_is_walk_recorded_source_discovery() {
2432 let kind = WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2433 pattern: "**/build/**".to_owned(),
2434 file_count: 3,
2435 directory_count: 1,
2436 };
2437 assert!(kind.is_source_discovery());
2438 assert!(kind.is_source_walk_recorded());
2439 assert!(!kind.is_analysis_stage());
2440 assert_eq!(kind.id(), "excluded-by-default-ignore");
2441 }
2442
2443 #[test]
2447 fn a_built_in_ignore_exclusion_message_names_the_pattern_and_the_root_remedy() {
2448 let root = Path::new("/project");
2449 let diag = WorkspaceDiagnostic::new(
2450 root,
2451 root.join("packages/web/build"),
2452 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2453 pattern: "**/build/**".to_owned(),
2454 file_count: 4,
2455 directory_count: 1,
2456 },
2457 );
2458 assert!(diag.message.contains("**/build/**"), "{}", diag.message);
2459 assert!(
2460 diag.message.contains("packages/web/build"),
2461 "{}",
2462 diag.message
2463 );
2464 assert!(
2465 diag.message.contains("fallow --root packages/web/build"),
2466 "the remedy is copy-pasteable: {}",
2467 diag.message
2468 );
2469 assert!(
2470 diag.message
2471 .contains("add '!packages/web/build/**' to ignorePatterns"),
2472 "the message names the exception that lifts the built-in (issue #2940): {}",
2473 diag.message
2474 );
2475 }
2476
2477 #[test]
2479 fn a_single_excluded_file_message_is_singular() {
2480 let root = Path::new("/project");
2481 let diag = WorkspaceDiagnostic::new(
2482 root,
2483 root.join("dist"),
2484 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2485 pattern: "**/dist/**".to_owned(),
2486 file_count: 1,
2487 directory_count: 1,
2488 },
2489 );
2490 assert!(
2491 diag.message
2492 .starts_with("Skipped 1 source file under 'dist'"),
2493 "{}",
2494 diag.message
2495 );
2496 assert!(diag.message.contains("it matches"), "{}", diag.message);
2497 assert!(
2498 diag.message
2499 .contains("nothing it imports, exports, or defines"),
2500 "the whole sentence agrees in number, not just its first clause: {}",
2501 diag.message
2502 );
2503 }
2504
2505 #[test]
2510 fn a_scattered_exclusion_names_the_largest_group_and_counts_the_directories() {
2511 let root = Path::new("/project");
2512 let diag = WorkspaceDiagnostic::new(
2513 root,
2514 root.join("packages/a/dist"),
2515 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2516 pattern: "**/dist/**".to_owned(),
2517 file_count: 10,
2518 directory_count: 10,
2519 },
2520 );
2521 assert!(
2522 diag.message.starts_with(
2523 "Skipped 10 source files across 10 directories, the largest group under \
2524 'packages/a/dist'"
2525 ),
2526 "{}",
2527 diag.message
2528 );
2529 assert!(
2530 !diag.message.contains("the most of them"),
2531 "a max-of-group is not a majority: {}",
2532 diag.message
2533 );
2534 }
2535
2536 #[test]
2540 fn a_file_shaped_pattern_does_not_advertise_the_root_remedy() {
2541 let root = Path::new("/project");
2542 let diag = WorkspaceDiagnostic::new(
2543 root,
2544 root.join("vendor"),
2545 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2546 pattern: "**/*.min.js".to_owned(),
2547 file_count: 2,
2548 directory_count: 1,
2549 },
2550 );
2551 assert!(
2552 !diag.message.contains("fallow --root"),
2553 "the message explains why re-rooting fails, it does not prescribe it: {}",
2554 diag.message
2555 );
2556 assert!(
2557 diag.message.contains("matches a file name"),
2558 "the message says why: {}",
2559 diag.message
2560 );
2561 assert!(
2562 diag.message.contains("Rename"),
2563 "and names the remedy that does work: {}",
2564 diag.message
2565 );
2566 }
2567
2568 #[test]
2571 fn a_root_anchored_exclusion_renders_its_location_as_dot() {
2572 let root = Path::new("/project");
2573 let diag = WorkspaceDiagnostic::new(
2574 root,
2575 root.to_path_buf(),
2576 WorkspaceDiagnosticKind::ExcludedByDefaultIgnore {
2577 pattern: "**/*.min.js".to_owned(),
2578 file_count: 1,
2579 directory_count: 1,
2580 },
2581 );
2582 assert!(
2583 diag.message.starts_with("Skipped 1 source file under '.'"),
2584 "{}",
2585 diag.message
2586 );
2587 }
2588
2589 #[test]
2590 fn glob_first_literal_segment_skips_wildcards_and_dot_components() {
2591 assert_eq!(glob_first_literal_segment("**/build/**"), Some("build"));
2592 assert_eq!(glob_first_literal_segment("./dist/**"), Some("dist"));
2593 assert_eq!(glob_first_literal_segment("**/*.min.js"), None);
2594 assert_eq!(glob_first_literal_segment("**/*.bundle.js"), None);
2595 assert_eq!(glob_first_literal_segment("**/{a,b}/**"), None);
2596 }
2597
2598 #[test]
2599 fn format_size_mb_one_decimal() {
2600 assert_eq!(format_size_mb(0), "0.0 MB");
2601 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
2602 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
2603 }
2604
2605 #[test]
2606 fn unmatched_config_pattern_kinds_are_advisory_root_notes() {
2607 let root = Path::new("/project");
2608 for (kind, id, setting, pattern) in [
2609 (
2610 WorkspaceDiagnosticKind::IgnoreDependenciesGlobUnmatched {
2611 pattern: "@typo/*".to_owned(),
2612 },
2613 "ignore-dependencies-glob-unmatched",
2614 "ignoreDependencies",
2615 "@typo/*",
2616 ),
2617 (
2618 WorkspaceDiagnosticKind::IgnoreFindingsPatternUnmatched {
2619 pattern: "src/legcy/**".to_owned(),
2620 },
2621 "ignore-findings-pattern-unmatched",
2622 "ignoreFindings",
2623 "src/legcy/**",
2624 ),
2625 ] {
2626 let diag = WorkspaceDiagnostic::new(root, root.to_path_buf(), kind);
2627 assert_eq!(diag.kind.id(), id);
2628 assert_eq!(
2629 diag.kind.unmatched_config_pattern(),
2630 Some((setting, pattern))
2631 );
2632 assert!(!diag.degrades_analysis, "{id} does not degrade the run");
2633 assert!(!diag.kind.source_never_analyzed());
2634 assert!(
2635 !diag.kind.is_analysis_stage(),
2636 "{id} is not in the registry"
2637 );
2638 assert!(!diag.kind.is_health_stage());
2639 assert!(!diag.kind.is_plugin_stage());
2640 assert!(!diag.kind.is_source_discovery());
2641 assert!(diag.message.contains(setting), "{}", diag.message);
2642 assert!(diag.message.contains(pattern), "{}", diag.message);
2643 assert!(
2644 diag.message.contains("remove it from the config"),
2645 "next step: {}",
2646 diag.message
2647 );
2648 let json = serde_json::to_value(&diag).expect("serializes");
2649 assert_eq!(json["kind"], id);
2650 assert_eq!(json["pattern"], pattern);
2651 assert!(json.get("degrades_analysis").is_none());
2652 }
2653 assert!(
2654 WorkspaceDiagnosticKind::UndeclaredWorkspace
2655 .unmatched_config_pattern()
2656 .is_none()
2657 );
2658 }
2659
2660 #[test]
2661 fn undeclared_workspace_message_has_next_step() {
2662 let root = Path::new("/project");
2663 let diag = WorkspaceDiagnostic::new(
2664 root,
2665 root.join("packages/legacy"),
2666 WorkspaceDiagnosticKind::UndeclaredWorkspace,
2667 );
2668 assert_eq!(diag.kind.id(), "undeclared-workspace");
2669 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
2670 assert!(
2671 diag.message.contains("ignorePatterns"),
2672 "next-step hint preserved: {}",
2673 diag.message
2674 );
2675 }
2676 #[test]
2682 fn health_stage_kinds_are_classified_as_health_stage_and_nothing_else() {
2683 for kind in [
2684 WorkspaceDiagnosticKind::FileScoresUnavailable {
2685 error: "boom".to_owned(),
2686 },
2687 WorkspaceDiagnosticKind::HotspotsSkipped {
2688 cause: "not-a-repository".to_owned(),
2689 },
2690 WorkspaceDiagnosticKind::ShallowClone {
2691 ownership_requested: true,
2692 },
2693 WorkspaceDiagnosticKind::UnpinnedClock,
2694 WorkspaceDiagnosticKind::OwnershipUnavailable {
2695 cause: "codeowners-parse-failed".to_owned(),
2696 error: "boom".to_owned(),
2697 },
2698 WorkspaceDiagnosticKind::TrendSnapshotUnreadable {
2699 error: "boom".to_owned(),
2700 },
2701 WorkspaceDiagnosticKind::CoverageAutoDetected,
2702 ] {
2703 let id = kind.id();
2704 assert!(kind.is_health_stage(), "{id} must be health-stage");
2705 assert!(!kind.is_analysis_stage(), "{id} must not be analysis-stage");
2706 assert!(
2707 !kind.is_source_discovery(),
2708 "{id} must not be source-discovery"
2709 );
2710 assert!(
2711 !kind.is_source_walk_recorded(),
2712 "{id} must not be walk-recorded"
2713 );
2714 assert!(
2715 !kind.source_never_analyzed(),
2716 "{id} reports an input, not an unread source file"
2717 );
2718 }
2719 }
2720
2721 #[test]
2725 fn only_the_coverage_provenance_kind_does_not_degrade_the_analysis() {
2726 assert!(
2727 WorkspaceDiagnosticKind::HotspotsSkipped {
2728 cause: "invalid-since".to_owned(),
2729 }
2730 .warns_on_stderr(),
2731 "a skipped hotspot section is a degraded result"
2732 );
2733 assert!(
2734 WorkspaceDiagnosticKind::UnpinnedClock.warns_on_stderr(),
2735 "a drifting measurement is a degraded result"
2736 );
2737 assert!(
2738 !WorkspaceDiagnosticKind::CoverageAutoDetected.warns_on_stderr(),
2739 "auto-detected coverage loaded fine and degraded nothing"
2740 );
2741 }
2742
2743 #[test]
2747 fn every_hotspot_skip_cause_renders_its_own_remedy() {
2748 let root = Path::new("/project");
2749 let skipped = |cause: &str, path: PathBuf| {
2750 WorkspaceDiagnostic::new(
2751 root,
2752 path,
2753 WorkspaceDiagnosticKind::HotspotsSkipped {
2754 cause: cause.to_owned(),
2755 },
2756 )
2757 };
2758
2759 let no_repo = skipped("not-a-repository", root.to_path_buf());
2760 assert_eq!(
2761 no_repo.message,
2762 "Hotspot analysis was skipped because no git repository was found at the project \
2763 root, so the hotspots, churn and ownership sections report nothing rather than \
2764 zero. Run fallow inside the repository, or pass --churn-file with exported change \
2765 history."
2766 );
2767
2768 let bad_since = skipped("invalid-since", root.to_path_buf());
2769 assert!(
2770 bad_since.message.contains("--since")
2771 && bad_since.message.contains("6m or 90d")
2772 && !bad_since.message.contains("no git repository"),
2773 "a malformed window is respelled, not moved into a repository: {}",
2774 bad_since.message
2775 );
2776
2777 let churn = skipped("churn-file-unreadable", root.join("build/churn.json"));
2778 assert!(
2779 churn.message.contains("'build/churn.json'") && churn.message.contains("rerun"),
2780 "the remedy names the file that changed under the run: {}",
2781 churn.message
2782 );
2783
2784 let unborn = skipped("no-commits", root.to_path_buf());
2785 assert!(
2786 unborn.message.contains("no commits")
2787 && unborn.message.contains("--churn-file")
2788 && !unborn.message.contains("no git repository"),
2789 "a branch without a commit is told to commit, not to move: {}",
2790 unborn.message
2791 );
2792
2793 for diagnostic in [&no_repo, &unborn, &bad_since, &churn] {
2794 assert!(
2795 diagnostic.degrades_analysis,
2796 "every skip leaves the hotspot sections unmeasured: {}",
2797 diagnostic.message
2798 );
2799 }
2800 }
2801
2802 #[test]
2805 fn health_stage_messages_name_a_next_step() {
2806 let root = Path::new("/project");
2807 let shallow = WorkspaceDiagnostic::new(
2808 root,
2809 root.to_path_buf(),
2810 WorkspaceDiagnosticKind::ShallowClone {
2811 ownership_requested: true,
2812 },
2813 );
2814 assert!(
2815 shallow.message.contains("git fetch --unshallow"),
2816 "{}",
2817 shallow.message
2818 );
2819 assert!(
2820 shallow.message.contains("Ownership signals are skewed too"),
2821 "a run that asked for ownership is told what else it costs: {}",
2822 shallow.message
2823 );
2824 let without_ownership = WorkspaceDiagnostic::new(
2825 root,
2826 root.to_path_buf(),
2827 WorkspaceDiagnosticKind::ShallowClone {
2828 ownership_requested: false,
2829 },
2830 );
2831 assert!(
2832 !without_ownership.message.contains("Ownership"),
2833 "a run that did not ask for ownership is not told about it: {}",
2834 without_ownership.message
2835 );
2836
2837 let coverage = WorkspaceDiagnostic::new(
2838 root,
2839 root.join("coverage/coverage-final.json"),
2840 WorkspaceDiagnosticKind::CoverageAutoDetected,
2841 );
2842 assert_eq!(coverage.kind.id(), "coverage-auto-detected");
2843 assert!(
2844 coverage
2845 .message
2846 .contains("--coverage 'coverage/coverage-final.json'"),
2847 "the remedy names the file that fed the score: {}",
2848 coverage.message
2849 );
2850 assert!(
2851 !coverage.degrades_analysis,
2852 "provenance is not a degraded run"
2853 );
2854
2855 let ownership = WorkspaceDiagnostic::new(
2856 root,
2857 root.to_path_buf(),
2858 WorkspaceDiagnosticKind::OwnershipUnavailable {
2859 cause: "invalid-bot-pattern".to_owned(),
2860 error: "unclosed".to_owned(),
2861 },
2862 );
2863 assert!(
2864 ownership.message.contains("botPatterns"),
2865 "the two causes render different remedies: {}",
2866 ownership.message
2867 );
2868 }
2869
2870 fn plugin_unreadable(key: &str, reason: &str) -> WorkspaceDiagnostic {
2871 WorkspaceDiagnostic::new(
2872 Path::new("/project"),
2873 PathBuf::from("/project/module-federation.config.ts"),
2874 WorkspaceDiagnosticKind::PluginConfigUnreadable {
2875 plugin: "module-federation".to_owned(),
2876 key: key.to_owned(),
2877 reason: reason.to_owned(),
2878 },
2879 )
2880 }
2881
2882 fn plugin_not_modeled(key: &str, reason: &str) -> WorkspaceDiagnostic {
2883 WorkspaceDiagnostic::new(
2884 Path::new("/project"),
2885 PathBuf::from("/project/nuxt.config.ts"),
2886 WorkspaceDiagnosticKind::PluginEffectNotModeled {
2887 plugin: "nuxt".to_owned(),
2888 key: key.to_owned(),
2889 reason: reason.to_owned(),
2890 },
2891 )
2892 }
2893
2894 #[test]
2897 fn plugin_stage_kinds_are_classified_as_plugin_stage_and_nothing_else() {
2898 for kind in [
2899 WorkspaceDiagnosticKind::PluginConfigUnreadable {
2900 plugin: "module-federation".to_owned(),
2901 key: "exposes".to_owned(),
2902 reason: "not-object-literal".to_owned(),
2903 },
2904 WorkspaceDiagnosticKind::PluginEffectNotModeled {
2905 plugin: "nuxt".to_owned(),
2906 key: "components".to_owned(),
2907 reason: "key-effect-not-modeled".to_owned(),
2908 },
2909 ] {
2910 let id = kind.id();
2911 assert!(kind.is_plugin_stage(), "{id} must be plugin-stage");
2912 assert!(!kind.is_analysis_stage(), "{id} must not be analysis-stage");
2913 assert!(!kind.is_health_stage(), "{id} must not be health-stage");
2914 assert!(
2915 !kind.is_source_discovery(),
2916 "{id} must not be source-discovery"
2917 );
2918 assert!(
2919 !kind.is_source_walk_recorded(),
2920 "{id} must not be walk-recorded"
2921 );
2922 assert!(
2923 !kind.source_never_analyzed(),
2924 "{id} reports a config file, not an unread source file"
2925 );
2926 }
2927 assert!(
2928 !WorkspaceDiagnosticKind::UnpinnedClock.is_plugin_stage(),
2929 "another stage's kind must not answer the plugin predicate"
2930 );
2931 }
2932
2933 #[test]
2937 fn only_the_unreadable_plugin_config_degrades_the_analysis() {
2938 let unreadable = plugin_unreadable("exposes", "not-object-literal");
2939 assert!(
2940 unreadable.degrades_analysis,
2941 "an unread declaration did not reach the analysis: {}",
2942 unreadable.message
2943 );
2944 let not_modeled = plugin_not_modeled("components", "key-effect-not-modeled");
2945 assert!(
2946 !not_modeled.degrades_analysis,
2947 "the config was readable and the patterns stayed: {}",
2948 not_modeled.message
2949 );
2950 }
2951
2952 #[test]
2955 fn every_unreadable_reason_renders_its_own_situation() {
2956 let cases = [
2957 ("not-object-literal", "is not a static object literal"),
2958 ("array-form", "uses the array form"),
2959 ("spread", "spreads a value that is not statically readable"),
2960 (
2961 "unreadable-entries",
2962 "has entries that hold no statically readable value",
2963 ),
2964 (
2965 "unrecognized-call",
2966 "is passed through a call that is not a known config wrapper",
2967 ),
2968 (
2969 "import-target-unreadable",
2970 "comes from an imported file that is not statically readable",
2971 ),
2972 (
2973 "dynamic-argument",
2974 "receives an argument that is not a static literal",
2975 ),
2976 ];
2977 for (reason, expected) in cases {
2978 let diagnostic = plugin_unreadable("exposes", reason);
2979 assert!(
2980 diagnostic.message.contains(expected),
2981 "`{reason}` must render its own situation: {}",
2982 diagnostic.message
2983 );
2984 }
2985 let unknown = plugin_unreadable("exposes", "reason-from-a-later-release");
2986 assert!(
2987 unknown.message.contains("could not be read statically"),
2988 "an unrecognised token still renders a sentence: {}",
2989 unknown.message
2990 );
2991 }
2992
2993 #[test]
2997 fn unreadable_plugin_messages_name_the_config_file_and_the_option() {
2998 let exposes = plugin_unreadable("exposes", "not-object-literal");
2999 assert!(
3000 exposes
3001 .message
3002 .contains("`exposes` in 'module-federation.config.ts'")
3003 && exposes.message.contains("dynamicallyLoaded")
3004 && exposes.message.starts_with("Plugin 'module-federation':"),
3005 "{}",
3006 exposes.message
3007 );
3008 let remotes = plugin_unreadable("remotes", "spread");
3009 assert!(
3010 remotes.message.contains("ignoreDependencies"),
3011 "the two keys have different remedies: {}",
3012 remotes.message
3013 );
3014 let unknown_key = plugin_unreadable("shared", "not-object-literal");
3015 assert!(
3016 !unknown_key.message.contains("dynamicallyLoaded")
3017 && !unknown_key.message.contains("ignoreDependencies")
3018 && unknown_key
3019 .message
3020 .contains("Declare the value as a static object literal."),
3021 "a key with no documented consequence falls back to the general claim: {}",
3022 unknown_key.message
3023 );
3024 assert!(
3025 !exposes.message.contains('\n'),
3026 "the sentence travels into a CI annotation and stays on one line: {}",
3027 exposes.message
3028 );
3029 }
3030
3031 #[test]
3035 fn the_call_and_import_reasons_render_their_own_remedy() {
3036 let call = plugin_unreadable("exposes", "unrecognized-call");
3037 assert!(
3038 call.message
3039 .contains("only the targets in the object literal it receives")
3040 && call
3041 .message
3042 .contains("Name any other exposed files in `dynamicallyLoaded`."),
3043 "{}",
3044 call.message
3045 );
3046 let call = plugin_unreadable("remotes", "unrecognized-call");
3047 assert!(
3048 call.message
3049 .contains("Name any other aliases in `ignoreDependencies`."),
3050 "{}",
3051 call.message
3052 );
3053 for key in ["exposes", "remotes"] {
3054 let import = plugin_unreadable(key, "import-target-unreadable");
3055 assert!(
3056 !import.message.contains("object literal"),
3057 "an import target is not fixed by writing an object literal: {}",
3058 import.message
3059 );
3060 }
3061 let import = plugin_unreadable("exposes", "import-target-unreadable");
3062 assert!(
3063 import.message.contains("`dynamicallyLoaded`"),
3064 "{}",
3065 import.message
3066 );
3067 }
3068
3069 #[test]
3072 fn a_dynamic_runtime_call_renders_its_own_remedy() {
3073 for (key, consequence) in [
3074 (
3075 "registerRemotes",
3076 "the remotes it registers are not treated as provided",
3077 ),
3078 (
3079 "loadRemote",
3080 "the remote it loads is not treated as provided",
3081 ),
3082 (
3083 "init",
3084 "the remotes its options declare are not treated as provided",
3085 ),
3086 (
3087 "createInstance",
3088 "the remotes its options declare are not treated as provided",
3089 ),
3090 ] {
3091 let diagnostic = plugin_unreadable(key, "dynamic-argument");
3092 assert!(
3093 diagnostic.message.contains(&format!("`{key}` in"))
3094 && diagnostic.message.contains(consequence)
3095 && diagnostic.message.contains("`ignoreDependencies`")
3096 && !diagnostic.message.contains("object literal"),
3097 "{}",
3098 diagnostic.message
3099 );
3100 }
3101 }
3102
3103 #[test]
3106 fn two_unreadable_keys_in_one_file_are_two_diagnostics() {
3107 let merged = dedupe_workspace_diagnostics(vec![
3108 plugin_unreadable("exposes", "not-object-literal"),
3109 plugin_unreadable("remotes", "spread"),
3110 ]);
3111 assert_eq!(merged.len(), 2, "{merged:?}");
3112 }
3113
3114 #[test]
3118 fn the_not_modeled_reasons_render_different_causes() {
3119 let key = plugin_not_modeled("components", "key-effect-not-modeled");
3120 assert!(
3121 key.message
3122 .contains("fallow does not model the effect of `components` in 'nuxt.config.ts'.")
3123 && key
3124 .message
3125 .contains("`autoImports` kept the convention entry patterns"),
3126 "{}",
3127 key.message
3128 );
3129 let property = plugin_not_modeled("imports", "config-property-unreadable");
3130 assert!(
3131 property.message.contains(
3132 "fallow cannot read a top-level property in 'nuxt.config.ts', so it cannot \
3133 classify the `imports` surface. `autoImports` kept the convention entry patterns \
3134 for that surface, and fallow reports no unused file there."
3135 ),
3136 "{}",
3137 property.message
3138 );
3139 assert_eq!(
3140 property.message.matches(", so ").count(),
3141 1,
3142 "one cause per sentence: {}",
3143 property.message
3144 );
3145 }
3146
3147 #[test]
3150 fn an_unreadable_virtual_module_read_names_the_module_and_a_source_remedy() {
3151 let read = WorkspaceDiagnostic::new(
3152 Path::new("/project"),
3153 PathBuf::from("/project/app/lib/registry.ts"),
3154 WorkspaceDiagnosticKind::PluginEffectNotModeled {
3155 plugin: "nuxt".to_owned(),
3156 key: "#components".to_owned(),
3157 reason: "key-effect-not-modeled".to_owned(),
3158 },
3159 );
3160 assert!(
3161 read.message.contains(
3162 "fallow cannot read which names 'app/lib/registry.ts' takes from `#components`"
3163 ) && read.message.contains("member access")
3164 && !read.message.contains("entry patterns"),
3165 "{}",
3166 read.message
3167 );
3168 }
3169}