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 BunLockbOverrideResolutionSkipped,
145 BunLockOverrideResolutionSkipped,
149 BunResolutionsShadowedByOverrides,
153}
154
155impl WorkspaceDiagnosticKind {
156 #[must_use]
158 pub const fn id(&self) -> &'static str {
159 match self {
160 Self::UndeclaredWorkspace => "undeclared-workspace",
161 Self::MalformedPackageJson { .. } => "malformed-package-json",
162 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
163 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
164 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
165 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
166 Self::SkippedLargeFile { .. } => "skipped-large-file",
167 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
168 Self::SkippedSourceDotdir => "skipped-source-dotdir",
169 Self::SourceReadFailure { .. } => "source-read-failure",
170 Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
171 Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
172 Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
173 }
174 }
175
176 #[must_use]
185 pub const fn is_source_discovery(&self) -> bool {
186 matches!(
187 self,
188 Self::SkippedLargeFile { .. }
189 | Self::SkippedMinifiedFile { .. }
190 | Self::SkippedSourceDotdir
191 | Self::SourceReadFailure { .. }
192 )
193 }
194
195 #[must_use]
208 pub const fn is_source_walk_recorded(&self) -> bool {
209 matches!(
210 self,
211 Self::SkippedLargeFile { .. }
212 | Self::SkippedMinifiedFile { .. }
213 | Self::SkippedSourceDotdir
214 )
215 }
216
217 #[must_use]
232 pub const fn is_analysis_stage(&self) -> bool {
233 match self {
234 Self::MalformedPnpmWorkspaceYaml { .. }
235 | Self::BunLockbOverrideResolutionSkipped
236 | Self::BunLockOverrideResolutionSkipped
237 | Self::BunResolutionsShadowedByOverrides => true,
238 Self::UndeclaredWorkspace
239 | Self::MalformedPackageJson { .. }
240 | Self::GlobMatchedNoPackageJson { .. }
241 | Self::MalformedTsconfig { .. }
242 | Self::TsconfigReferenceDirMissing
243 | Self::SkippedLargeFile { .. }
244 | Self::SkippedMinifiedFile { .. }
245 | Self::SkippedSourceDotdir
246 | Self::SourceReadFailure { .. } => false,
247 }
248 }
249}
250
251#[must_use]
254fn format_size_mb(bytes: u64) -> String {
255 #[expect(
256 clippy::cast_precision_loss,
257 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
258 )]
259 let mb = bytes as f64 / (1024.0 * 1024.0);
260 format!("{mb:.1} MB")
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
270#[cfg_attr(feature = "schema", derive(JsonSchema))]
271pub struct WorkspaceDiagnostic {
272 #[serde(serialize_with = "serde_path::serialize")]
274 pub path: PathBuf,
275 #[serde(flatten)]
277 pub kind: WorkspaceDiagnosticKind,
278 pub message: String,
281}
282
283impl WorkspaceDiagnostic {
284 #[must_use]
302 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
303 let path = normalise_diagnostic_path(path);
304 let kind = normalise_payload_paths(root, kind);
305 let message = render_message(root, &path, &kind);
306 Self {
307 path,
308 kind,
309 message,
310 }
311 }
312
313 #[must_use]
326 pub fn into_root_relative(mut self, root: &Path) -> Self {
327 if let Ok(relative) = self.path.strip_prefix(root) {
328 self.path = relative.to_path_buf();
329 }
330 self
331 }
332}
333
334fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
351 let rebuilt: PathBuf = path.components().collect();
352 if rebuilt.as_os_str() == path.as_os_str() {
353 path
354 } else {
355 rebuilt
356 }
357}
358
359fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
372 let root_str = root.display().to_string();
373 let root_alt = root_str.replace('\\', "/");
374 let normalise = |text: String| -> String {
375 let stripped = text
376 .replace(&format!("{root_str}/"), "")
377 .replace(&format!("{root_alt}/"), "");
378 stripped
379 .replace(&format!("{root_str}\\"), "")
380 .replace(&format!("{root_alt}\\"), "")
381 };
382 match kind {
383 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
384 WorkspaceDiagnosticKind::MalformedPackageJson {
385 error: normalise(error),
386 }
387 }
388 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
389 WorkspaceDiagnosticKind::MalformedTsconfig {
390 error: normalise(error),
391 }
392 }
393 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
394 WorkspaceDiagnosticKind::SourceReadFailure {
395 error: normalise(error),
396 }
397 }
398 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
399 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
400 pattern: canonical_glob_pattern(pattern),
401 }
402 }
403 other => other,
404 }
405}
406
407fn canonical_glob_pattern(pattern: String) -> String {
414 for prefix in ["./", ".\\"] {
415 if let Some(rest) = pattern.strip_prefix(prefix)
416 && !rest.is_empty()
417 {
418 return rest.to_owned();
419 }
420 }
421 pattern
422}
423
424#[must_use]
444pub fn merge_workspace_diagnostics(
445 primary: Vec<WorkspaceDiagnostic>,
446 secondary: Vec<WorkspaceDiagnostic>,
447) -> Vec<WorkspaceDiagnostic> {
448 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
449 let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
450 for diagnostic in primary.into_iter().chain(secondary) {
451 let key = (diagnostic.kind.clone(), diagnostic.path.clone());
452 if seen.insert(key) {
453 merged.push(diagnostic);
454 }
455 }
456 merged
457}
458
459#[must_use]
471pub fn dedupe_workspace_diagnostics(
472 diagnostics: Vec<WorkspaceDiagnostic>,
473) -> Vec<WorkspaceDiagnostic> {
474 merge_workspace_diagnostics(diagnostics, Vec::new())
475}
476
477fn display_relative(root: &Path, path: &Path) -> String {
480 path.strip_prefix(root)
481 .unwrap_or(path)
482 .display()
483 .to_string()
484 .replace('\\', "/")
485}
486
487fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
488 let display = display_relative(root, path);
489 match kind {
490 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
491 "Directory '{display}' contains package.json but is not declared as a workspace. \
492 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
493 ),
494 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
495 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
496 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
497 ),
498 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
499 "Glob '{pattern}' matched '{display}' but no package.json is present. \
500 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
501 ),
502 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
503 "tsconfig.json at '{display}' failed to parse ({error}); \
504 project references will be ignored. Fix the JSON syntax."
505 ),
506 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
507 "tsconfig.json references '{display}' but the directory does not exist. \
508 Update or remove the reference, or restore the missing directory."
509 ),
510 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
511 "'{display}' failed to parse ({error}); catalog and override entries \
512 will be ignored. Fix the YAML syntax."
513 ),
514 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
515 "Skipped '{display}' ({size}): exceeds the max file size limit. \
516 Its imports and exports are not analyzed. Raise the limit with \
517 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
518 to ignorePatterns.",
519 size = format_size_mb(*size_bytes)
520 ),
521 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
522 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
523 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
524 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
525 should be analyzed.",
526 size = format_size_mb(*size_bytes)
527 ),
528 WorkspaceDiagnosticKind::SkippedSourceDotdir => format!(
529 "Skipped hidden directory '{display}': it contains source files but hidden \
530 directories are not traversed. Its imports and exports are not analyzed. \
531 There is no config field that adds a directory to traversal. If it holds \
532 first-party source, analyze it on its own with fallow --root {display}; if it \
533 is tool or agent scratch state, add '{display}/**' to ignorePatterns to \
534 silence this."
535 ),
536 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
537 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
538 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
539 ),
540 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
541 "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
542 sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
543 (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
544 use instead, so unused-dependency-overrides findings are not reported. Run bun install \
545 --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
546 bun.lockb if this repository no longer uses bun."
547 ),
548 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
549 "Skipped dependency-override resolution because '{display}' could not be parsed and \
550 no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
551 findings are not reported. Run bun install to regenerate the text lockfile, then \
552 rerun fallow."
553 ),
554 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
555 "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
556 `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
557 remove the shadowed `resolutions` entries."
558 ),
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 #[test]
567 fn skipped_large_file_diagnostic_id_and_message() {
568 let root = Path::new("/project");
569 let diag = WorkspaceDiagnostic::new(
570 root,
571 root.join("src/vendor/app.bundle.js"),
572 WorkspaceDiagnosticKind::SkippedLargeFile {
573 size_bytes: 6 * 1024 * 1024,
574 },
575 );
576 assert_eq!(diag.kind.id(), "skipped-large-file");
577 assert!(
578 diag.message.contains("src/vendor/app.bundle.js"),
579 "message names the project-relative path: {}",
580 diag.message
581 );
582 assert!(
583 diag.message.contains("6.0 MB"),
584 "message reports the size: {}",
585 diag.message
586 );
587 assert!(
588 diag.message.contains("--max-file-size"),
589 "message names the override flag: {}",
590 diag.message
591 );
592 }
593
594 #[test]
595 fn skipped_minified_file_diagnostic_id_and_message() {
596 let root = Path::new("/project");
597 let diag = WorkspaceDiagnostic::new(
598 root,
599 root.join("src/assets/index-abc123.js"),
600 WorkspaceDiagnosticKind::SkippedMinifiedFile {
601 size_bytes: 2 * 1024 * 1024,
602 },
603 );
604 assert_eq!(diag.kind.id(), "skipped-minified-file");
605 assert!(
606 diag.message.contains("src/assets/index-abc123.js"),
607 "message names the project-relative path: {}",
608 diag.message
609 );
610 assert!(
611 diag.message.contains("2.0 MB"),
612 "message reports the size: {}",
613 diag.message
614 );
615 assert!(
616 diag.message.contains("--max-file-size 0"),
617 "message names the opt-out: {}",
618 diag.message
619 );
620 }
621
622 #[test]
623 fn skipped_source_dotdir_diagnostic_id_and_message() {
624 let root = Path::new("/project");
625 let diag = WorkspaceDiagnostic::new(
626 root,
627 root.join(".claude"),
628 WorkspaceDiagnosticKind::SkippedSourceDotdir,
629 );
630 assert_eq!(diag.kind.id(), "skipped-source-dotdir");
631 assert!(
632 diag.message.contains(".claude"),
633 "message names the project-relative path: {}",
634 diag.message
635 );
636 assert!(
637 diag.message
638 .contains("Its imports and exports are not analyzed."),
639 "message states the consequence: {}",
640 diag.message
641 );
642 assert!(
643 diag.message.contains("--root"),
644 "message names the real remedy: {}",
645 diag.message
646 );
647 assert!(
648 diag.message.contains("ignorePatterns"),
649 "message names the silencing route: {}",
650 diag.message
651 );
652 assert!(
653 diag.message.contains("no config field"),
654 "the message must say plainly that no config field traverses it: {}",
655 diag.message
656 );
657 assert_eq!(
658 serde_json::to_value(&diag).expect("serializes")["kind"],
659 "skipped-source-dotdir",
660 "id() must byte-match the serde kebab-case tag"
661 );
662 }
663
664 #[cfg(feature = "schema")]
665 #[test]
666 fn workspace_diagnostic_schema_includes_skipped_source_dotdir() {
667 let schema = schemars::schema_for!(WorkspaceDiagnostic);
668 let json = serde_json::to_string(&schema).expect("schema serializes");
669 assert!(json.contains("skipped-source-dotdir"));
670 }
671
672 #[test]
673 fn source_read_failure_serializes_typed_error_payload() {
674 let root = Path::new("/project");
675 let diagnostic = WorkspaceDiagnostic::new(
676 root,
677 root.join("src/removed.ts"),
678 WorkspaceDiagnosticKind::SourceReadFailure {
679 error: "No such file or directory".to_string(),
680 },
681 );
682
683 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
684 assert_eq!(json["kind"], "source-read-failure");
685 assert_eq!(
686 json["path"],
687 root.join("src/removed.ts")
688 .display()
689 .to_string()
690 .replace('\\', "/")
691 );
692 assert_eq!(json["error"], "No such file or directory");
693 assert!(
694 json["message"]
695 .as_str()
696 .is_some_and(|message| message.contains("src/removed.ts"))
697 );
698 }
699
700 #[cfg(feature = "schema")]
701 #[test]
702 fn workspace_diagnostic_schema_includes_source_read_failure() {
703 let schema = schemars::schema_for!(WorkspaceDiagnostic);
704 let json = serde_json::to_string(&schema).expect("schema serializes");
705 assert!(json.contains("source-read-failure"));
706 assert!(json.contains("error"));
707 }
708
709 #[test]
710 fn bun_lockb_override_resolution_skipped_id_and_message() {
711 let root = Path::new("/project");
712 let diag = WorkspaceDiagnostic::new(
713 root,
714 root.join("package.json"),
715 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
716 );
717 assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
718 assert!(
719 diag.message.contains("'package.json'"),
720 "message names the project-relative manifest: {}",
721 diag.message
722 );
723 assert!(
724 diag.message.contains("no parseable text lockfile"),
725 "message states the cause: {}",
726 diag.message
727 );
728 assert!(
729 !diag.message.contains("only bun.lockb"),
730 "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
731 bun.lock may sit beside it: {}",
732 diag.message
733 );
734 assert!(
735 diag.message.contains("bun install --save-text-lockfile")
736 && diag.message.contains("delete the stale bun.lockb"),
737 "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
738 diag.message
739 );
740 let json = serde_json::to_value(&diag).expect("diagnostic serializes");
741 assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
742 }
743
744 #[test]
745 fn bun_override_diagnostic_ids_and_messages_are_actionable() {
746 let root = Path::new("/project");
747 let malformed = WorkspaceDiagnostic::new(
748 root,
749 root.join("bun.lock"),
750 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
751 );
752 assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
753 assert!(malformed.message.contains("regenerate"));
754
755 let shadowed = WorkspaceDiagnostic::new(
756 root,
757 root.join("package.json"),
758 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
759 );
760 assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
761 assert!(shadowed.message.contains("ignores `resolutions`"));
762 }
763
764 #[test]
765 fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
766 let root = Path::new("/project");
767 let inside = WorkspaceDiagnostic::new(
768 root,
769 root.join("packages/inner"),
770 WorkspaceDiagnosticKind::UndeclaredWorkspace,
771 )
772 .into_root_relative(root);
773 assert_eq!(inside.path, Path::new("packages/inner"));
774
775 let outside = WorkspaceDiagnostic::new(
776 root,
777 PathBuf::from("/elsewhere/packages/inner"),
778 WorkspaceDiagnosticKind::UndeclaredWorkspace,
779 )
780 .into_root_relative(root);
781 assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
782 }
783
784 #[test]
785 fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
786 let analysis_stage = [
787 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
788 error: "bad yaml".to_owned(),
789 },
790 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
791 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
792 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
793 ];
794 for kind in &analysis_stage {
795 assert!(
796 kind.is_analysis_stage() && !kind.is_source_discovery(),
797 "{} is recorded by the analyze stage only",
798 kind.id()
799 );
800 }
801
802 let other = [
803 WorkspaceDiagnosticKind::UndeclaredWorkspace,
804 WorkspaceDiagnosticKind::MalformedPackageJson {
805 error: "trailing comma".to_owned(),
806 },
807 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
808 pattern: "packages/*".to_owned(),
809 },
810 WorkspaceDiagnosticKind::MalformedTsconfig {
811 error: "unexpected token".to_owned(),
812 },
813 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
814 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
815 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
816 WorkspaceDiagnosticKind::SkippedSourceDotdir,
817 WorkspaceDiagnosticKind::SourceReadFailure {
818 error: "permission denied".to_owned(),
819 },
820 ];
821 for kind in &other {
822 assert!(
823 !kind.is_analysis_stage(),
824 "{} is a discovery kind, not an analyze-stage kind",
825 kind.id()
826 );
827 }
828 }
829
830 #[test]
831 fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
832 let root = Path::new("/project");
833 let first = WorkspaceDiagnostic::new(
834 root,
835 root.join("packages/aaa"),
836 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
837 pattern: "packages/*".to_owned(),
838 },
839 );
840 let second = WorkspaceDiagnostic::new(
841 root,
842 root.join("packages/aaa"),
843 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
844 pattern: "packages/a*".to_owned(),
845 },
846 );
847
848 let merged =
849 merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
850
851 let patterns: Vec<String> = merged
852 .iter()
853 .map(|diagnostic| match &diagnostic.kind {
854 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
855 other => panic!("unexpected kind {}", other.id()),
856 })
857 .collect();
858 assert_eq!(
859 patterns,
860 ["packages/*", "packages/a*"],
861 "two overlapping globs report the same directory twice, with their own pattern; \
862 the same entry seen from two observation points still folds to one"
863 );
864 }
865
866 #[test]
871 fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
872 let root = Path::new("/project");
873 let dotted = WorkspaceDiagnostic::new(
874 root,
875 root.join("apps/site/.next/cache"),
876 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
877 pattern: "./apps/**".to_owned(),
878 },
879 );
880 let bare = WorkspaceDiagnostic::new(
881 root,
882 root.join("apps/site/.next/cache"),
883 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
884 pattern: "apps/**".to_owned(),
885 },
886 );
887 assert_eq!(
888 dotted.kind, bare.kind,
889 "the no-op ./ prefix is normalised out of the recorded pattern"
890 );
891 assert!(
892 dotted.message.contains("Glob 'apps/**'"),
893 "the message renders the normalised pattern: {}",
894 dotted.message
895 );
896
897 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
898 assert_eq!(
899 merged.len(),
900 1,
901 "one glob declared twice is one diagnostic: {merged:?}"
902 );
903 }
904
905 #[test]
909 fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
910 let root = Path::new("/project");
911 let recorded = |pattern: &str| {
912 let diagnostic = WorkspaceDiagnostic::new(
913 root,
914 root.join("pkgs"),
915 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
916 pattern: pattern.to_owned(),
917 },
918 );
919 let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
920 else {
921 panic!("constructed a glob-matched-no-package-json diagnostic");
922 };
923 (pattern, diagnostic.message)
924 };
925
926 let (root_pattern, root_message) = recorded("./");
927 assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
928 assert!(
929 root_message.contains("Glob './'"),
930 "the warning names the glob the manifest declared: {root_message}"
931 );
932 assert_eq!(recorded(".\\").0, ".\\");
933 assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
934 assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
935 }
936
937 #[test]
944 fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
945 let root = Path::new("/project");
946 let dotted = WorkspaceDiagnostic::new(
947 root,
948 root.join("./pkgs/aaa"),
949 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
950 pattern: "./pkgs/*".to_owned(),
951 },
952 );
953 let bare = WorkspaceDiagnostic::new(
954 root,
955 root.join("pkgs/aaa"),
956 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
957 pattern: "pkgs/*".to_owned(),
958 },
959 );
960
961 let spelling = |diagnostic: &WorkspaceDiagnostic| {
962 diagnostic.path.display().to_string().replace('\\', "/")
963 };
964 assert_eq!(
965 spelling(&dotted),
966 "/project/pkgs/aaa",
967 "the stored path drops the no-op . component, which Path equality \
968 hides but serialization does not"
969 );
970 assert_eq!(spelling(&dotted), spelling(&bare));
971 assert_eq!(
972 spelling(&dotted.clone().into_root_relative(root)),
973 "pkgs/aaa"
974 );
975
976 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
977 assert_eq!(
978 merged.len(),
979 1,
980 "one directory reached through two spellings of one glob: {merged:?}"
981 );
982 }
983
984 #[test]
987 fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
988 let root = Path::new("/project");
989 let glob = |pattern: &str, relative: &str| {
990 WorkspaceDiagnostic::new(
991 root,
992 root.join(relative),
993 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
994 pattern: pattern.to_owned(),
995 },
996 )
997 };
998
999 let deduped = dedupe_workspace_diagnostics(vec![
1000 glob("pkgs/*", "pkgs/aaa"),
1001 glob("pkgs/*", "pkgs/bbb"),
1002 glob("./pkgs/*", "./pkgs/aaa"),
1003 glob("pkgs/a*", "pkgs/aaa"),
1004 ]);
1005
1006 let reported: Vec<(String, String)> = deduped
1007 .iter()
1008 .map(|diagnostic| match &diagnostic.kind {
1009 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
1010 pattern.clone(),
1011 diagnostic.path.display().to_string().replace('\\', "/"),
1012 ),
1013 other => panic!("unexpected kind {}", other.id()),
1014 })
1015 .collect();
1016
1017 assert_eq!(
1018 reported,
1019 vec![
1020 ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
1021 ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
1022 ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
1023 ],
1024 "the duplicate spelling folds away and the overlapping glob stays"
1025 );
1026 }
1027
1028 #[test]
1029 fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
1030 for kind in [
1031 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
1032 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
1033 WorkspaceDiagnosticKind::SkippedSourceDotdir,
1034 ] {
1035 assert!(
1036 kind.is_source_walk_recorded() && kind.is_source_discovery(),
1037 "{} is written by the source walk",
1038 kind.id()
1039 );
1040 }
1041
1042 let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
1043 error: "permission denied".to_owned(),
1044 };
1045 assert!(
1046 read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
1047 "the parse stage records source-read-failure after the walk, so it must keep \
1048 reaching sessions through the registry"
1049 );
1050
1051 for kind in [
1052 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1053 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
1054 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
1055 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
1056 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
1057 ] {
1058 assert!(
1059 !kind.is_source_walk_recorded(),
1060 "{} is not written by the source walk",
1061 kind.id()
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn format_size_mb_one_decimal() {
1068 assert_eq!(format_size_mb(0), "0.0 MB");
1069 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
1070 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
1071 }
1072
1073 #[test]
1074 fn undeclared_workspace_message_has_next_step() {
1075 let root = Path::new("/project");
1076 let diag = WorkspaceDiagnostic::new(
1077 root,
1078 root.join("packages/legacy"),
1079 WorkspaceDiagnosticKind::UndeclaredWorkspace,
1080 );
1081 assert_eq!(diag.kind.id(), "undeclared-workspace");
1082 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
1083 assert!(
1084 diag.message.contains("ignorePatterns"),
1085 "next-step hint preserved: {}",
1086 diag.message
1087 );
1088 }
1089}