1use std::path::{Path, PathBuf};
15
16use rustc_hash::FxHashSet;
17#[cfg(feature = "schema")]
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::serde_path;
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "schema", derive(JsonSchema))]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31pub enum WorkspaceDiagnosticKind {
32 UndeclaredWorkspace,
37 MalformedPackageJson {
40 error: String,
42 },
43 GlobMatchedNoPackageJson {
47 pattern: String,
49 },
50 MalformedTsconfig {
53 error: String,
55 },
56 TsconfigReferenceDirMissing,
59 MalformedPnpmWorkspaceYaml {
64 error: String,
66 },
67 SkippedLargeFile {
75 size_bytes: u64,
77 },
78 SkippedMinifiedFile {
84 size_bytes: u64,
86 },
87 SkippedSourceDotdir,
124 SourceReadFailure {
128 error: String,
130 },
131 SourceParseDegraded {
144 error_count: u32,
146 panicked: bool,
149 },
150 BunLockbOverrideResolutionSkipped,
164 BunLockOverrideResolutionSkipped,
168 BunResolutionsShadowedByOverrides,
172 NodeModulesMissing,
188 BoundariesNotConfigured,
197 RulePacksNotConfigured,
202}
203
204impl WorkspaceDiagnosticKind {
205 #[must_use]
207 pub const fn id(&self) -> &'static str {
208 match self {
209 Self::UndeclaredWorkspace => "undeclared-workspace",
210 Self::MalformedPackageJson { .. } => "malformed-package-json",
211 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
212 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
213 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
214 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
215 Self::SkippedLargeFile { .. } => "skipped-large-file",
216 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
217 Self::SkippedSourceDotdir => "skipped-source-dotdir",
218 Self::SourceReadFailure { .. } => "source-read-failure",
219 Self::SourceParseDegraded { .. } => "source-parse-degraded",
220 Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
221 Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
222 Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
223 Self::NodeModulesMissing => "node-modules-missing",
224 Self::BoundariesNotConfigured => "boundaries-not-configured",
225 Self::RulePacksNotConfigured => "rule-packs-not-configured",
226 }
227 }
228
229 #[must_use]
243 pub const fn warns_on_stderr(&self) -> bool {
244 match self {
245 Self::BoundariesNotConfigured | Self::RulePacksNotConfigured => false,
246 Self::UndeclaredWorkspace
247 | Self::MalformedPackageJson { .. }
248 | Self::GlobMatchedNoPackageJson { .. }
249 | Self::MalformedTsconfig { .. }
250 | Self::TsconfigReferenceDirMissing
251 | Self::MalformedPnpmWorkspaceYaml { .. }
252 | Self::SkippedLargeFile { .. }
253 | Self::SkippedMinifiedFile { .. }
254 | Self::SkippedSourceDotdir
255 | Self::SourceReadFailure { .. }
256 | Self::SourceParseDegraded { .. }
257 | Self::BunLockbOverrideResolutionSkipped
258 | Self::BunLockOverrideResolutionSkipped
259 | Self::BunResolutionsShadowedByOverrides
260 | Self::NodeModulesMissing => true,
261 }
262 }
263
264 #[must_use]
273 pub const fn is_source_discovery(&self) -> bool {
274 matches!(
275 self,
276 Self::SkippedLargeFile { .. }
277 | Self::SkippedMinifiedFile { .. }
278 | Self::SkippedSourceDotdir
279 | Self::SourceReadFailure { .. }
280 | Self::SourceParseDegraded { .. }
281 | Self::NodeModulesMissing
282 )
283 }
284
285 #[must_use]
298 pub const fn is_source_walk_recorded(&self) -> bool {
299 matches!(
300 self,
301 Self::SkippedLargeFile { .. }
302 | Self::SkippedMinifiedFile { .. }
303 | Self::SkippedSourceDotdir
304 | Self::NodeModulesMissing
305 )
306 }
307
308 #[must_use]
344 pub const fn source_never_analyzed(&self) -> bool {
345 match self {
346 Self::SkippedLargeFile { .. }
347 | Self::SkippedMinifiedFile { .. }
348 | Self::SkippedSourceDotdir
349 | Self::SourceReadFailure { .. } => true,
350 Self::UndeclaredWorkspace
351 | Self::MalformedPackageJson { .. }
352 | Self::GlobMatchedNoPackageJson { .. }
353 | Self::MalformedTsconfig { .. }
354 | Self::TsconfigReferenceDirMissing
355 | Self::MalformedPnpmWorkspaceYaml { .. }
356 | Self::SourceParseDegraded { .. }
357 | Self::BunLockbOverrideResolutionSkipped
358 | Self::BunLockOverrideResolutionSkipped
359 | Self::BunResolutionsShadowedByOverrides
360 | Self::NodeModulesMissing
361 | Self::BoundariesNotConfigured
362 | Self::RulePacksNotConfigured => false,
363 }
364 }
365
366 #[must_use]
381 pub const fn is_analysis_stage(&self) -> bool {
382 match self {
383 Self::MalformedPnpmWorkspaceYaml { .. }
384 | Self::BunLockbOverrideResolutionSkipped
385 | Self::BunLockOverrideResolutionSkipped
386 | Self::BunResolutionsShadowedByOverrides
387 | Self::BoundariesNotConfigured
388 | Self::RulePacksNotConfigured => true,
389 Self::UndeclaredWorkspace
390 | Self::MalformedPackageJson { .. }
391 | Self::GlobMatchedNoPackageJson { .. }
392 | Self::MalformedTsconfig { .. }
393 | Self::TsconfigReferenceDirMissing
394 | Self::SkippedLargeFile { .. }
395 | Self::SkippedMinifiedFile { .. }
396 | Self::SkippedSourceDotdir
397 | Self::SourceReadFailure { .. }
398 | Self::SourceParseDegraded { .. }
399 | Self::NodeModulesMissing => false,
400 }
401 }
402}
403
404#[must_use]
407fn format_size_mb(bytes: u64) -> String {
408 #[expect(
409 clippy::cast_precision_loss,
410 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
411 )]
412 let mb = bytes as f64 / (1024.0 * 1024.0);
413 format!("{mb:.1} MB")
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
423#[cfg_attr(feature = "schema", derive(JsonSchema))]
424pub struct WorkspaceDiagnostic {
425 #[serde(serialize_with = "serde_path::serialize")]
427 pub path: PathBuf,
428 #[serde(flatten)]
430 pub kind: WorkspaceDiagnosticKind,
431 pub message: String,
434}
435
436impl WorkspaceDiagnostic {
437 #[must_use]
455 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
456 let path = normalise_diagnostic_path(path);
457 let kind = normalise_payload_paths(root, kind);
458 let message = render_message(root, &path, &kind);
459 Self {
460 path,
461 kind,
462 message,
463 }
464 }
465
466 #[must_use]
484 pub fn into_root_relative(mut self, root: &Path) -> Self {
485 if let Ok(relative) = self.path.strip_prefix(root) {
486 self.path = if relative.as_os_str().is_empty() {
487 PathBuf::from(".")
488 } else {
489 relative.to_path_buf()
490 };
491 }
492 self
493 }
494}
495
496fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
513 let rebuilt: PathBuf = path.components().collect();
514 if rebuilt.as_os_str() == path.as_os_str() {
515 path
516 } else {
517 rebuilt
518 }
519}
520
521fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
534 let root_str = root.display().to_string();
535 let root_alt = root_str.replace('\\', "/");
536 let normalise = |text: String| -> String {
537 let stripped = text
538 .replace(&format!("{root_str}/"), "")
539 .replace(&format!("{root_alt}/"), "");
540 stripped
541 .replace(&format!("{root_str}\\"), "")
542 .replace(&format!("{root_alt}\\"), "")
543 };
544 match kind {
545 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
546 WorkspaceDiagnosticKind::MalformedPackageJson {
547 error: normalise(error),
548 }
549 }
550 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
551 WorkspaceDiagnosticKind::MalformedTsconfig {
552 error: normalise(error),
553 }
554 }
555 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
556 WorkspaceDiagnosticKind::SourceReadFailure {
557 error: normalise(error),
558 }
559 }
560 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
561 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
562 pattern: canonical_glob_pattern(pattern),
563 }
564 }
565 other => other,
566 }
567}
568
569fn canonical_glob_pattern(pattern: String) -> String {
576 for prefix in ["./", ".\\"] {
577 if let Some(rest) = pattern.strip_prefix(prefix)
578 && !rest.is_empty()
579 {
580 return rest.to_owned();
581 }
582 }
583 pattern
584}
585
586#[must_use]
606pub fn merge_workspace_diagnostics(
607 primary: Vec<WorkspaceDiagnostic>,
608 secondary: Vec<WorkspaceDiagnostic>,
609) -> Vec<WorkspaceDiagnostic> {
610 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
611 let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
612 for diagnostic in primary.into_iter().chain(secondary) {
613 let key = (diagnostic.kind.clone(), diagnostic.path.clone());
614 if seen.insert(key) {
615 merged.push(diagnostic);
616 }
617 }
618 merged
619}
620
621#[must_use]
633pub fn dedupe_workspace_diagnostics(
634 diagnostics: Vec<WorkspaceDiagnostic>,
635) -> Vec<WorkspaceDiagnostic> {
636 merge_workspace_diagnostics(diagnostics, Vec::new())
637}
638
639fn display_relative(root: &Path, path: &Path) -> String {
642 path.strip_prefix(root)
643 .unwrap_or(path)
644 .display()
645 .to_string()
646 .replace('\\', "/")
647}
648
649fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
650 let display = display_relative(root, path);
651 match kind {
652 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
653 "Directory '{display}' contains package.json but is not declared as a workspace. \
654 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
655 ),
656 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
657 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
658 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
659 ),
660 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
661 "Glob '{pattern}' matched '{display}' but no package.json is present. \
662 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
663 ),
664 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
665 "tsconfig.json at '{display}' failed to parse ({error}); \
666 project references will be ignored. Fix the JSON syntax."
667 ),
668 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
669 "tsconfig.json references '{display}' but the directory does not exist. \
670 Update or remove the reference, or restore the missing directory."
671 ),
672 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
673 "'{display}' failed to parse ({error}); catalog and override entries \
674 will be ignored. Fix the YAML syntax."
675 ),
676 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
677 "Skipped '{display}' ({size}): exceeds the max file size limit. \
678 Its imports and exports are not analyzed. Raise the limit with \
679 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
680 to ignorePatterns.",
681 size = format_size_mb(*size_bytes)
682 ),
683 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
684 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
685 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
686 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
687 should be analyzed.",
688 size = format_size_mb(*size_bytes)
689 ),
690 WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
691 "Skipped hidden directory '{display}': it contains source files but hidden \
692 directories are not traversed. Its imports and exports are not analyzed. \
693 There is no config field that adds a directory to traversal. If it holds \
694 first-party source, analyze it on its own with fallow --root {display}; if it \
695 is tool or agent scratch state, add '{display}/**' to ignorePatterns to \
696 silence this."
697 ),
698 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
699 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
700 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
701 ),
702 WorkspaceDiagnosticKind::SourceParseDegraded {
703 error_count,
704 panicked,
705 } => {
706 let outcome = if *panicked {
707 "the parser stopped there"
708 } else {
709 "the parser recovered and continued"
710 };
711 format!(
712 "Parsed '{display}' with {error_count} error(s); {outcome}. Imports, exports, and \
713 references it did not reach are missing from this run, so files and symbols it \
714 uses can be reported as unused. Fix the syntax, or ignore this if the file uses \
715 syntax newer than fallow's parser."
716 )
717 }
718 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
719 "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
720 sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
721 (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
722 use instead, so unused-dependency-overrides findings are not reported. Run bun install \
723 --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
724 bun.lockb if this repository no longer uses bun."
725 ),
726 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
727 "Skipped dependency-override resolution because '{display}' could not be parsed and \
728 no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
729 findings are not reported. Run bun install to regenerate the text lockfile, then \
730 rerun fallow."
731 ),
732 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
733 "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
734 `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
735 remove the shadowed `resolutions` entries."
736 ),
737 WorkspaceDiagnosticKind::NodeModulesMissing => format!(
738 "'{display}' does not exist. Package exports and conditional exports cannot be read, \
739 framework plugins that activate on an installed package stay inactive, and \
740 dependency classification degrades, so imports and dependencies can be \
741 misreported. Run npm install / pnpm install / yarn / bun install first."
742 ),
743 WorkspaceDiagnosticKind::BoundariesNotConfigured => {
744 "No architecture boundaries are configured, so the boundary detector did not run and \
745 its violation counts are zero because nothing was measured. Add `boundaries` to the \
746 config, or set `boundary-violation` to off to state that the check is not wanted."
747 .to_string()
748 }
749 WorkspaceDiagnosticKind::RulePacksNotConfigured => {
750 "No rule packs are configured, so the policy detector did not run and its violation \
751 counts are zero because nothing was measured. Add `rulePacks` to the config, or set \
752 `policy-violation` to off to state that the check is not wanted."
753 .to_string()
754 }
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761
762 #[test]
763 fn skipped_large_file_diagnostic_id_and_message() {
764 let root = Path::new("/project");
765 let diag = WorkspaceDiagnostic::new(
766 root,
767 root.join("src/vendor/app.bundle.js"),
768 WorkspaceDiagnosticKind::SkippedLargeFile {
769 size_bytes: 6 * 1024 * 1024,
770 },
771 );
772 assert_eq!(diag.kind.id(), "skipped-large-file");
773 assert!(
774 diag.message.contains("src/vendor/app.bundle.js"),
775 "message names the project-relative path: {}",
776 diag.message
777 );
778 assert!(
779 diag.message.contains("6.0 MB"),
780 "message reports the size: {}",
781 diag.message
782 );
783 assert!(
784 diag.message.contains("--max-file-size"),
785 "message names the override flag: {}",
786 diag.message
787 );
788 }
789
790 #[test]
791 fn skipped_minified_file_diagnostic_id_and_message() {
792 let root = Path::new("/project");
793 let diag = WorkspaceDiagnostic::new(
794 root,
795 root.join("src/assets/index-abc123.js"),
796 WorkspaceDiagnosticKind::SkippedMinifiedFile {
797 size_bytes: 2 * 1024 * 1024,
798 },
799 );
800 assert_eq!(diag.kind.id(), "skipped-minified-file");
801 assert!(
802 diag.message.contains("src/assets/index-abc123.js"),
803 "message names the project-relative path: {}",
804 diag.message
805 );
806 assert!(
807 diag.message.contains("2.0 MB"),
808 "message reports the size: {}",
809 diag.message
810 );
811 assert!(
812 diag.message.contains("--max-file-size 0"),
813 "message names the opt-out: {}",
814 diag.message
815 );
816 }
817
818 #[test]
819 fn skipped_source_dotdir_diagnostic_id_and_message() {
820 let root = Path::new("/project");
821 let diag = WorkspaceDiagnostic::new(
822 root,
823 root.join(".claude"),
824 WorkspaceDiagnosticKind::SkippedSourceDotdir,
825 );
826 assert_eq!(diag.kind.id(), "skipped-source-dotdir");
827 assert!(
828 diag.message.contains(".claude"),
829 "message names the project-relative path: {}",
830 diag.message
831 );
832 assert!(
833 diag.message
834 .contains("Its imports and exports are not analyzed."),
835 "message states the consequence: {}",
836 diag.message
837 );
838 assert!(
839 diag.message.contains("--root"),
840 "message names the real remedy: {}",
841 diag.message
842 );
843 assert!(
844 diag.message.contains("ignorePatterns"),
845 "message names the silencing route: {}",
846 diag.message
847 );
848 assert!(
849 diag.message.contains("no config field"),
850 "the message must say plainly that no config field traverses it: {}",
851 diag.message
852 );
853 assert_eq!(
854 serde_json::to_value(&diag).expect("serializes")["kind"],
855 "skipped-source-dotdir",
856 "id() must byte-match the serde kebab-case tag"
857 );
858 }
859
860 #[cfg(feature = "schema")]
861 #[test]
862 fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
863 let schema = schemars::schema_for!(WorkspaceDiagnostic);
864 let json = serde_json::to_string(&schema).expect("schema serializes");
865 assert!(json.contains("skipped-source-dotdir"));
866 }
867
868 #[test]
869 fn source_read_failure_serializes_typed_error_payload() {
870 let root = Path::new("/project");
871 let diagnostic = WorkspaceDiagnostic::new(
872 root,
873 root.join("src/removed.ts"),
874 WorkspaceDiagnosticKind::SourceReadFailure {
875 error: "No such file or directory".to_string(),
876 },
877 );
878
879 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
880 assert_eq!(json["kind"], "source-read-failure");
881 assert_eq!(
882 json["path"],
883 root.join("src/removed.ts")
884 .display()
885 .to_string()
886 .replace('\\', "/")
887 );
888 assert_eq!(json["error"], "No such file or directory");
889 assert!(
890 json["message"]
891 .as_str()
892 .is_some_and(|message| message.contains("src/removed.ts"))
893 );
894 }
895
896 #[cfg(feature = "schema")]
897 #[test]
898 fn workspace_diagnostic_schema_includes_source_read_failure() {
899 let schema = schemars::schema_for!(WorkspaceDiagnostic);
900 let json = serde_json::to_string(&schema).expect("schema serializes");
901 assert!(json.contains("source-read-failure"));
902 assert!(json.contains("error"));
903 }
904
905 #[test]
906 fn bun_lockb_override_resolution_skipped_id_and_message() {
907 let root = Path::new("/project");
908 let diag = WorkspaceDiagnostic::new(
909 root,
910 root.join("package.json"),
911 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
912 );
913 assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
914 assert!(
915 diag.message.contains("'package.json'"),
916 "message names the project-relative manifest: {}",
917 diag.message
918 );
919 assert!(
920 diag.message.contains("no parseable text lockfile"),
921 "message states the cause: {}",
922 diag.message
923 );
924 assert!(
925 !diag.message.contains("only bun.lockb"),
926 "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
927 bun.lock may sit beside it: {}",
928 diag.message
929 );
930 assert!(
931 diag.message.contains("bun install --save-text-lockfile")
932 && diag.message.contains("delete the stale bun.lockb"),
933 "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
934 diag.message
935 );
936 let json = serde_json::to_value(&diag).expect("diagnostic serializes");
937 assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
938 }
939
940 #[test]
941 fn bun_override_diagnostic_ids_and_messages_are_actionable() {
942 let root = Path::new("/project");
943 let malformed = WorkspaceDiagnostic::new(
944 root,
945 root.join("bun.lock"),
946 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
947 );
948 assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
949 assert!(malformed.message.contains("regenerate"));
950
951 let shadowed = WorkspaceDiagnostic::new(
952 root,
953 root.join("package.json"),
954 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
955 );
956 assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
957 assert!(shadowed.message.contains("ignores `resolutions`"));
958 }
959
960 #[test]
961 fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
962 let root = Path::new("/project");
963 let inside = WorkspaceDiagnostic::new(
964 root,
965 root.join("packages/inner"),
966 WorkspaceDiagnosticKind::UndeclaredWorkspace,
967 )
968 .into_root_relative(root);
969 assert_eq!(inside.path, Path::new("packages/inner"));
970
971 let outside = WorkspaceDiagnostic::new(
972 root,
973 PathBuf::from("/elsewhere/packages/inner"),
974 WorkspaceDiagnosticKind::UndeclaredWorkspace,
975 )
976 .into_root_relative(root);
977 assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
978 }
979
980 #[test]
981 fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
982 let analysis_stage = [
983 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
984 error: "bad yaml".to_owned(),
985 },
986 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
987 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
988 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
989 ];
990 for kind in &analysis_stage {
991 assert!(
992 kind.is_analysis_stage() && !kind.is_source_discovery(),
993 "{} is recorded by the analyze stage only",
994 kind.id()
995 );
996 }
997
998 let other = [
999 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1000 WorkspaceDiagnosticKind::MalformedPackageJson {
1001 error: "trailing comma".to_owned(),
1002 },
1003 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1004 pattern: "packages/*".to_owned(),
1005 },
1006 WorkspaceDiagnosticKind::MalformedTsconfig {
1007 error: "unexpected token".to_owned(),
1008 },
1009 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1010 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1011 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1012 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1013 WorkspaceDiagnosticKind::SourceReadFailure {
1014 error: "permission denied".to_owned(),
1015 },
1016 ];
1017 for kind in &other {
1018 assert!(
1019 !kind.is_analysis_stage(),
1020 "{} is a discovery kind, not an analyze-stage kind",
1021 kind.id()
1022 );
1023 }
1024 }
1025
1026 #[test]
1027 fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
1028 let root = Path::new("/project");
1029 let first = WorkspaceDiagnostic::new(
1030 root,
1031 root.join("packages/aaa"),
1032 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1033 pattern: "packages/*".to_owned(),
1034 },
1035 );
1036 let second = WorkspaceDiagnostic::new(
1037 root,
1038 root.join("packages/aaa"),
1039 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1040 pattern: "packages/a*".to_owned(),
1041 },
1042 );
1043
1044 let merged =
1045 merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
1046
1047 let patterns: Vec<String> = merged
1048 .iter()
1049 .map(|diagnostic| match &diagnostic.kind {
1050 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
1051 other => panic!("unexpected kind {}", other.id()),
1052 })
1053 .collect();
1054 assert_eq!(
1055 patterns,
1056 ["packages/*", "packages/a*"],
1057 "two overlapping globs report the same directory twice, with their own pattern; \
1058 the same entry seen from two observation points still folds to one"
1059 );
1060 }
1061
1062 #[test]
1067 fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
1068 let root = Path::new("/project");
1069 let dotted = WorkspaceDiagnostic::new(
1070 root,
1071 root.join("apps/site/.next/cache"),
1072 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1073 pattern: "./apps/**".to_owned(),
1074 },
1075 );
1076 let bare = WorkspaceDiagnostic::new(
1077 root,
1078 root.join("apps/site/.next/cache"),
1079 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1080 pattern: "apps/**".to_owned(),
1081 },
1082 );
1083 assert_eq!(
1084 dotted.kind, bare.kind,
1085 "the no-op ./ prefix is normalised out of the recorded pattern"
1086 );
1087 assert!(
1088 dotted.message.contains("Glob 'apps/**'"),
1089 "the message renders the normalised pattern: {}",
1090 dotted.message
1091 );
1092
1093 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1094 assert_eq!(
1095 merged.len(),
1096 1,
1097 "one glob declared twice is one diagnostic: {merged:?}"
1098 );
1099 }
1100
1101 #[test]
1105 fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
1106 let root = Path::new("/project");
1107 let recorded = |pattern: &str| {
1108 let diagnostic = WorkspaceDiagnostic::new(
1109 root,
1110 root.join("pkgs"),
1111 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1112 pattern: pattern.to_owned(),
1113 },
1114 );
1115 let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
1116 else {
1117 panic!("constructed a glob-matched-no-package-json diagnostic");
1118 };
1119 (pattern, diagnostic.message)
1120 };
1121
1122 let (root_pattern, root_message) = recorded("./");
1123 assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
1124 assert!(
1125 root_message.contains("Glob './'"),
1126 "the warning names the glob the manifest declared: {root_message}"
1127 );
1128 assert_eq!(recorded(".\\").0, ".\\");
1129 assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
1130 assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
1131 }
1132
1133 #[test]
1140 fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
1141 let root = Path::new("/project");
1142 let dotted = WorkspaceDiagnostic::new(
1143 root,
1144 root.join("./pkgs/aaa"),
1145 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1146 pattern: "./pkgs/*".to_owned(),
1147 },
1148 );
1149 let bare = WorkspaceDiagnostic::new(
1150 root,
1151 root.join("pkgs/aaa"),
1152 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1153 pattern: "pkgs/*".to_owned(),
1154 },
1155 );
1156
1157 let spelling = |diagnostic: &WorkspaceDiagnostic| {
1158 diagnostic.path.display().to_string().replace('\\', "/")
1159 };
1160 assert_eq!(
1161 spelling(&dotted),
1162 "/project/pkgs/aaa",
1163 "the stored path drops the no-op . component, which Path equality \
1164 hides but serialization does not"
1165 );
1166 assert_eq!(spelling(&dotted), spelling(&bare));
1167 assert_eq!(
1168 spelling(&dotted.clone().into_root_relative(root)),
1169 "pkgs/aaa"
1170 );
1171
1172 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
1173 assert_eq!(
1174 merged.len(),
1175 1,
1176 "one directory reached through two spellings of one glob: {merged:?}"
1177 );
1178 }
1179
1180 #[test]
1183 fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
1184 let root = Path::new("/project");
1185 let glob = |pattern: &str, relative: &str| {
1186 WorkspaceDiagnostic::new(
1187 root,
1188 root.join(relative),
1189 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1190 pattern: pattern.to_owned(),
1191 },
1192 )
1193 };
1194
1195 let deduped = dedupe_workspace_diagnostics(vec![
1196 glob("pkgs/*", "pkgs/aaa"),
1197 glob("pkgs/*", "pkgs/bbb"),
1198 glob("./pkgs/*", "./pkgs/aaa"),
1199 glob("pkgs/a*", "pkgs/aaa"),
1200 ]);
1201
1202 let reported: Vec<(String, String)> = deduped
1203 .iter()
1204 .map(|diagnostic| match &diagnostic.kind {
1205 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
1206 pattern.clone(),
1207 diagnostic.path.display().to_string().replace('\\', "/"),
1208 ),
1209 other => panic!("unexpected kind {}", other.id()),
1210 })
1211 .collect();
1212
1213 assert_eq!(
1214 reported,
1215 vec![
1216 ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
1217 ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
1218 ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
1219 ],
1220 "the duplicate spelling folds away and the overlapping glob stays"
1221 );
1222 }
1223
1224 #[test]
1231 fn source_never_analyzed_covers_every_file_the_run_did_not_read() {
1232 for kind in [
1233 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1234 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1235 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1236 WorkspaceDiagnosticKind::SourceReadFailure {
1237 error: "permission denied".to_owned(),
1238 },
1239 ] {
1240 assert!(
1241 kind.source_never_analyzed(),
1242 "{} names a source file this run never read",
1243 kind.id()
1244 );
1245 }
1246
1247 let degraded = WorkspaceDiagnosticKind::SourceParseDegraded {
1248 error_count: 3,
1249 panicked: false,
1250 };
1251 assert!(
1252 !degraded.source_never_analyzed(),
1253 "a degraded parse read the file, so it has a graph node and its reachability is \
1254 observable; the caveat pass narrows it instead of treating it as unread"
1255 );
1256
1257 for kind in [
1258 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1259 WorkspaceDiagnosticKind::MalformedPackageJson {
1260 error: "trailing comma".to_owned(),
1261 },
1262 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
1263 pattern: "packages/*".to_owned(),
1264 },
1265 WorkspaceDiagnosticKind::MalformedTsconfig {
1266 error: "unexpected token".to_owned(),
1267 },
1268 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1269 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
1270 error: "bad indent".to_owned(),
1271 },
1272 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1273 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1274 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1275 WorkspaceDiagnosticKind::NodeModulesMissing,
1276 WorkspaceDiagnosticKind::BoundariesNotConfigured,
1277 WorkspaceDiagnosticKind::RulePacksNotConfigured,
1278 ] {
1279 assert!(
1280 !kind.source_never_analyzed(),
1281 "{} says nothing about a source file's imports going unseen",
1282 kind.id()
1283 );
1284 }
1285 }
1286
1287 #[test]
1288 fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
1289 for kind in [
1290 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1291 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1292 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1293 ] {
1294 assert!(
1295 kind.is_source_walk_recorded() && kind.is_source_discovery(),
1296 "{} is written by the source walk",
1297 kind.id()
1298 );
1299 }
1300
1301 let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
1302 error: "permission denied".to_owned(),
1303 };
1304 assert!(
1305 read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
1306 "the parse stage records source-read-failure after the walk, so it must keep \
1307 reaching sessions through the registry"
1308 );
1309
1310 for kind in [
1311 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1312 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1313 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1314 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1315 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1316 ] {
1317 assert!(
1318 !kind.is_source_walk_recorded(),
1319 "{} is not written by the source walk",
1320 kind.id()
1321 );
1322 }
1323 }
1324
1325 #[test]
1326 fn format_size_mb_one_decimal() {
1327 assert_eq!(format_size_mb(0), "0.0 MB");
1328 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1329 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1330 }
1331
1332 #[test]
1333 fn undeclared_workspace_message_has_next_step() {
1334 let root = Path::new("/project");
1335 let diag = WorkspaceDiagnostic::new(
1336 root,
1337 root.join("packages/legacy"),
1338 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1339 );
1340 assert_eq!(diag.kind.id(), "undeclared-workspace");
1341 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
1342 assert!(
1343 diag.message.contains("ignorePatterns"),
1344 "next-step hint preserved: {}",
1345 diag.message
1346 );
1347 }
1348}