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