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 SourceReadFailure {
91 error: String,
93 },
94 BunLockbOverrideResolutionSkipped,
108 BunLockOverrideResolutionSkipped,
112 BunResolutionsShadowedByOverrides,
116}
117
118impl WorkspaceDiagnosticKind {
119 #[must_use]
121 pub const fn id(&self) -> &'static str {
122 match self {
123 Self::UndeclaredWorkspace => "undeclared-workspace",
124 Self::MalformedPackageJson { .. } => "malformed-package-json",
125 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
126 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
127 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
128 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
129 Self::SkippedLargeFile { .. } => "skipped-large-file",
130 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
131 Self::SourceReadFailure { .. } => "source-read-failure",
132 Self::BunLockbOverrideResolutionSkipped => "bun-lockb-override-resolution-skipped",
133 Self::BunLockOverrideResolutionSkipped => "bun-lock-override-resolution-skipped",
134 Self::BunResolutionsShadowedByOverrides => "bun-resolutions-shadowed-by-overrides",
135 }
136 }
137
138 #[must_use]
147 pub const fn is_source_discovery(&self) -> bool {
148 matches!(
149 self,
150 Self::SkippedLargeFile { .. }
151 | Self::SkippedMinifiedFile { .. }
152 | Self::SourceReadFailure { .. }
153 )
154 }
155
156 #[must_use]
169 pub const fn is_source_walk_recorded(&self) -> bool {
170 matches!(
171 self,
172 Self::SkippedLargeFile { .. } | Self::SkippedMinifiedFile { .. }
173 )
174 }
175
176 #[must_use]
191 pub const fn is_analysis_stage(&self) -> bool {
192 match self {
193 Self::MalformedPnpmWorkspaceYaml { .. }
194 | Self::BunLockbOverrideResolutionSkipped
195 | Self::BunLockOverrideResolutionSkipped
196 | Self::BunResolutionsShadowedByOverrides => true,
197 Self::UndeclaredWorkspace
198 | Self::MalformedPackageJson { .. }
199 | Self::GlobMatchedNoPackageJson { .. }
200 | Self::MalformedTsconfig { .. }
201 | Self::TsconfigReferenceDirMissing
202 | Self::SkippedLargeFile { .. }
203 | Self::SkippedMinifiedFile { .. }
204 | Self::SourceReadFailure { .. } => false,
205 }
206 }
207}
208
209#[must_use]
212fn format_size_mb(bytes: u64) -> String {
213 #[expect(
214 clippy::cast_precision_loss,
215 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
216 )]
217 let mb = bytes as f64 / (1024.0 * 1024.0);
218 format!("{mb:.1} MB")
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
228#[cfg_attr(feature = "schema", derive(JsonSchema))]
229pub struct WorkspaceDiagnostic {
230 #[serde(serialize_with = "serde_path::serialize")]
232 pub path: PathBuf,
233 #[serde(flatten)]
235 pub kind: WorkspaceDiagnosticKind,
236 pub message: String,
239}
240
241impl WorkspaceDiagnostic {
242 #[must_use]
260 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
261 let path = normalise_diagnostic_path(path);
262 let kind = normalise_payload_paths(root, kind);
263 let message = render_message(root, &path, &kind);
264 Self {
265 path,
266 kind,
267 message,
268 }
269 }
270
271 #[must_use]
284 pub fn into_root_relative(mut self, root: &Path) -> Self {
285 if let Ok(relative) = self.path.strip_prefix(root) {
286 self.path = relative.to_path_buf();
287 }
288 self
289 }
290}
291
292fn normalise_diagnostic_path(path: PathBuf) -> PathBuf {
309 let rebuilt: PathBuf = path.components().collect();
310 if rebuilt.as_os_str() == path.as_os_str() {
311 path
312 } else {
313 rebuilt
314 }
315}
316
317fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
330 let root_str = root.display().to_string();
331 let root_alt = root_str.replace('\\', "/");
332 let normalise = |text: String| -> String {
333 let stripped = text
334 .replace(&format!("{root_str}/"), "")
335 .replace(&format!("{root_alt}/"), "");
336 stripped
337 .replace(&format!("{root_str}\\"), "")
338 .replace(&format!("{root_alt}\\"), "")
339 };
340 match kind {
341 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
342 WorkspaceDiagnosticKind::MalformedPackageJson {
343 error: normalise(error),
344 }
345 }
346 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
347 WorkspaceDiagnosticKind::MalformedTsconfig {
348 error: normalise(error),
349 }
350 }
351 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
352 WorkspaceDiagnosticKind::SourceReadFailure {
353 error: normalise(error),
354 }
355 }
356 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
357 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
358 pattern: canonical_glob_pattern(pattern),
359 }
360 }
361 other => other,
362 }
363}
364
365fn canonical_glob_pattern(pattern: String) -> String {
372 for prefix in ["./", ".\\"] {
373 if let Some(rest) = pattern.strip_prefix(prefix)
374 && !rest.is_empty()
375 {
376 return rest.to_owned();
377 }
378 }
379 pattern
380}
381
382#[must_use]
402pub fn merge_workspace_diagnostics(
403 primary: Vec<WorkspaceDiagnostic>,
404 secondary: Vec<WorkspaceDiagnostic>,
405) -> Vec<WorkspaceDiagnostic> {
406 let mut merged = Vec::with_capacity(primary.len() + secondary.len());
407 let mut seen: FxHashSet<(WorkspaceDiagnosticKind, PathBuf)> = FxHashSet::default();
408 for diagnostic in primary.into_iter().chain(secondary) {
409 let key = (diagnostic.kind.clone(), diagnostic.path.clone());
410 if seen.insert(key) {
411 merged.push(diagnostic);
412 }
413 }
414 merged
415}
416
417#[must_use]
429pub fn dedupe_workspace_diagnostics(
430 diagnostics: Vec<WorkspaceDiagnostic>,
431) -> Vec<WorkspaceDiagnostic> {
432 merge_workspace_diagnostics(diagnostics, Vec::new())
433}
434
435fn display_relative(root: &Path, path: &Path) -> String {
438 path.strip_prefix(root)
439 .unwrap_or(path)
440 .display()
441 .to_string()
442 .replace('\\', "/")
443}
444
445fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
446 let display = display_relative(root, path);
447 match kind {
448 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
449 "Directory '{display}' contains package.json but is not declared as a workspace. \
450 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
451 ),
452 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
453 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
454 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
455 ),
456 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
457 "Glob '{pattern}' matched '{display}' but no package.json is present. \
458 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
459 ),
460 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
461 "tsconfig.json at '{display}' failed to parse ({error}); \
462 project references will be ignored. Fix the JSON syntax."
463 ),
464 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
465 "tsconfig.json references '{display}' but the directory does not exist. \
466 Update or remove the reference, or restore the missing directory."
467 ),
468 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
469 "'{display}' failed to parse ({error}); catalog and override entries \
470 will be ignored. Fix the YAML syntax."
471 ),
472 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
473 "Skipped '{display}' ({size}): exceeds the max file size limit. \
474 Its imports and exports are not analyzed. Raise the limit with \
475 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
476 to ignorePatterns.",
477 size = format_size_mb(*size_bytes)
478 ),
479 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
480 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
481 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
482 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
483 should be analyzed.",
484 size = format_size_mb(*size_bytes)
485 ),
486 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
487 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
488 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
489 ),
490 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped => format!(
491 "Skipped dependency-override resolution for '{display}': bun's legacy binary bun.lockb \
492 sits next to it, fallow cannot read the binary format, and no parseable text lockfile \
493 (bun.lock, pnpm-lock.yaml, package-lock.json, or npm-shrinkwrap.json) was found to \
494 use instead, so unused-dependency-overrides findings are not reported. Run bun install \
495 --save-text-lockfile (bun 1.2 or newer) to write a text bun.lock, or delete the stale \
496 bun.lockb if this repository no longer uses bun."
497 ),
498 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped => format!(
499 "Skipped dependency-override resolution because '{display}' could not be parsed and \
500 no readable pnpm or npm lockfile was available, so unused-dependency-overrides \
501 findings are not reported. Run bun install to regenerate the text lockfile, then \
502 rerun fallow."
503 ),
504 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides => format!(
505 "'{display}' declares both `overrides` and non-empty `resolutions`; bun applies \
506 `overrides` and ignores `resolutions`. Move the intended pins into `overrides` or \
507 remove the shadowed `resolutions` entries."
508 ),
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn skipped_large_file_diagnostic_id_and_message() {
518 let root = Path::new("/project");
519 let diag = WorkspaceDiagnostic::new(
520 root,
521 root.join("src/vendor/app.bundle.js"),
522 WorkspaceDiagnosticKind::SkippedLargeFile {
523 size_bytes: 6 * 1024 * 1024,
524 },
525 );
526 assert_eq!(diag.kind.id(), "skipped-large-file");
527 assert!(
528 diag.message.contains("src/vendor/app.bundle.js"),
529 "message names the project-relative path: {}",
530 diag.message
531 );
532 assert!(
533 diag.message.contains("6.0 MB"),
534 "message reports the size: {}",
535 diag.message
536 );
537 assert!(
538 diag.message.contains("--max-file-size"),
539 "message names the override flag: {}",
540 diag.message
541 );
542 }
543
544 #[test]
545 fn skipped_minified_file_diagnostic_id_and_message() {
546 let root = Path::new("/project");
547 let diag = WorkspaceDiagnostic::new(
548 root,
549 root.join("src/assets/index-abc123.js"),
550 WorkspaceDiagnosticKind::SkippedMinifiedFile {
551 size_bytes: 2 * 1024 * 1024,
552 },
553 );
554 assert_eq!(diag.kind.id(), "skipped-minified-file");
555 assert!(
556 diag.message.contains("src/assets/index-abc123.js"),
557 "message names the project-relative path: {}",
558 diag.message
559 );
560 assert!(
561 diag.message.contains("2.0 MB"),
562 "message reports the size: {}",
563 diag.message
564 );
565 assert!(
566 diag.message.contains("--max-file-size 0"),
567 "message names the opt-out: {}",
568 diag.message
569 );
570 }
571
572 #[test]
573 fn source_read_failure_serializes_typed_error_payload() {
574 let root = Path::new("/project");
575 let diagnostic = WorkspaceDiagnostic::new(
576 root,
577 root.join("src/removed.ts"),
578 WorkspaceDiagnosticKind::SourceReadFailure {
579 error: "No such file or directory".to_string(),
580 },
581 );
582
583 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
584 assert_eq!(json["kind"], "source-read-failure");
585 assert_eq!(
586 json["path"],
587 root.join("src/removed.ts")
588 .display()
589 .to_string()
590 .replace('\\', "/")
591 );
592 assert_eq!(json["error"], "No such file or directory");
593 assert!(
594 json["message"]
595 .as_str()
596 .is_some_and(|message| message.contains("src/removed.ts"))
597 );
598 }
599
600 #[cfg(feature = "schema")]
601 #[test]
602 fn workspace_diagnostic_schema_includes_source_read_failure() {
603 let schema = schemars::schema_for!(WorkspaceDiagnostic);
604 let json = serde_json::to_string(&schema).expect("schema serializes");
605 assert!(json.contains("source-read-failure"));
606 assert!(json.contains("error"));
607 }
608
609 #[test]
610 fn bun_lockb_override_resolution_skipped_id_and_message() {
611 let root = Path::new("/project");
612 let diag = WorkspaceDiagnostic::new(
613 root,
614 root.join("package.json"),
615 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
616 );
617 assert_eq!(diag.kind.id(), "bun-lockb-override-resolution-skipped");
618 assert!(
619 diag.message.contains("'package.json'"),
620 "message names the project-relative manifest: {}",
621 diag.message
622 );
623 assert!(
624 diag.message.contains("no parseable text lockfile"),
625 "message states the cause: {}",
626 diag.message
627 );
628 assert!(
629 !diag.message.contains("only bun.lockb"),
630 "message must not claim bun.lockb is the only lockfile; yarn.lock or an unparseable \
631 bun.lock may sit beside it: {}",
632 diag.message
633 );
634 assert!(
635 diag.message.contains("bun install --save-text-lockfile")
636 && diag.message.contains("delete the stale bun.lockb"),
637 "message ends with the text-lockfile next step and the stale-lockb alternative: {}",
638 diag.message
639 );
640 let json = serde_json::to_value(&diag).expect("diagnostic serializes");
641 assert_eq!(json["kind"], "bun-lockb-override-resolution-skipped");
642 }
643
644 #[test]
645 fn bun_override_diagnostic_ids_and_messages_are_actionable() {
646 let root = Path::new("/project");
647 let malformed = WorkspaceDiagnostic::new(
648 root,
649 root.join("bun.lock"),
650 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
651 );
652 assert_eq!(malformed.kind.id(), "bun-lock-override-resolution-skipped");
653 assert!(malformed.message.contains("regenerate"));
654
655 let shadowed = WorkspaceDiagnostic::new(
656 root,
657 root.join("package.json"),
658 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
659 );
660 assert_eq!(shadowed.kind.id(), "bun-resolutions-shadowed-by-overrides");
661 assert!(shadowed.message.contains("ignores `resolutions`"));
662 }
663
664 #[test]
665 fn into_root_relative_strips_the_root_and_keeps_outside_paths_absolute() {
666 let root = Path::new("/project");
667 let inside = WorkspaceDiagnostic::new(
668 root,
669 root.join("packages/inner"),
670 WorkspaceDiagnosticKind::UndeclaredWorkspace,
671 )
672 .into_root_relative(root);
673 assert_eq!(inside.path, Path::new("packages/inner"));
674
675 let outside = WorkspaceDiagnostic::new(
676 root,
677 PathBuf::from("/elsewhere/packages/inner"),
678 WorkspaceDiagnosticKind::UndeclaredWorkspace,
679 )
680 .into_root_relative(root);
681 assert_eq!(outside.path, Path::new("/elsewhere/packages/inner"));
682 }
683
684 #[test]
685 fn analysis_stage_classification_covers_only_analyze_stage_kinds() {
686 let analysis_stage = [
687 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml {
688 error: "bad yaml".to_owned(),
689 },
690 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
691 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
692 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
693 ];
694 for kind in &analysis_stage {
695 assert!(
696 kind.is_analysis_stage() && !kind.is_source_discovery(),
697 "{} is recorded by the analyze stage only",
698 kind.id()
699 );
700 }
701
702 let other = [
703 WorkspaceDiagnosticKind::UndeclaredWorkspace,
704 WorkspaceDiagnosticKind::MalformedPackageJson {
705 error: "trailing comma".to_owned(),
706 },
707 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
708 pattern: "packages/*".to_owned(),
709 },
710 WorkspaceDiagnosticKind::MalformedTsconfig {
711 error: "unexpected token".to_owned(),
712 },
713 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
714 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
715 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
716 WorkspaceDiagnosticKind::SourceReadFailure {
717 error: "permission denied".to_owned(),
718 },
719 ];
720 for kind in &other {
721 assert!(
722 !kind.is_analysis_stage(),
723 "{} is a discovery kind, not an analyze-stage kind",
724 kind.id()
725 );
726 }
727 }
728
729 #[test]
730 fn merge_keeps_two_diagnostics_that_share_a_kind_id_and_path() {
731 let root = Path::new("/project");
732 let first = WorkspaceDiagnostic::new(
733 root,
734 root.join("packages/aaa"),
735 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
736 pattern: "packages/*".to_owned(),
737 },
738 );
739 let second = WorkspaceDiagnostic::new(
740 root,
741 root.join("packages/aaa"),
742 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
743 pattern: "packages/a*".to_owned(),
744 },
745 );
746
747 let merged =
748 merge_workspace_diagnostics(vec![first.clone(), second.clone()], vec![first, second]);
749
750 let patterns: Vec<String> = merged
751 .iter()
752 .map(|diagnostic| match &diagnostic.kind {
753 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => pattern.clone(),
754 other => panic!("unexpected kind {}", other.id()),
755 })
756 .collect();
757 assert_eq!(
758 patterns,
759 ["packages/*", "packages/a*"],
760 "two overlapping globs report the same directory twice, with their own pattern; \
761 the same entry seen from two observation points still folds to one"
762 );
763 }
764
765 #[test]
770 fn merge_folds_two_spellings_of_one_glob_into_one_diagnostic() {
771 let root = Path::new("/project");
772 let dotted = WorkspaceDiagnostic::new(
773 root,
774 root.join("apps/site/.next/cache"),
775 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
776 pattern: "./apps/**".to_owned(),
777 },
778 );
779 let bare = WorkspaceDiagnostic::new(
780 root,
781 root.join("apps/site/.next/cache"),
782 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
783 pattern: "apps/**".to_owned(),
784 },
785 );
786 assert_eq!(
787 dotted.kind, bare.kind,
788 "the no-op ./ prefix is normalised out of the recorded pattern"
789 );
790 assert!(
791 dotted.message.contains("Glob 'apps/**'"),
792 "the message renders the normalised pattern: {}",
793 dotted.message
794 );
795
796 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
797 assert_eq!(
798 merged.len(),
799 1,
800 "one glob declared twice is one diagnostic: {merged:?}"
801 );
802 }
803
804 #[test]
808 fn new_keeps_a_root_only_glob_spelling_and_still_strips_a_real_prefix() {
809 let root = Path::new("/project");
810 let recorded = |pattern: &str| {
811 let diagnostic = WorkspaceDiagnostic::new(
812 root,
813 root.join("pkgs"),
814 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
815 pattern: pattern.to_owned(),
816 },
817 );
818 let WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } = diagnostic.kind
819 else {
820 panic!("constructed a glob-matched-no-package-json diagnostic");
821 };
822 (pattern, diagnostic.message)
823 };
824
825 let (root_pattern, root_message) = recorded("./");
826 assert_eq!(root_pattern, "./", "a root-only glob keeps its spelling");
827 assert!(
828 root_message.contains("Glob './'"),
829 "the warning names the glob the manifest declared: {root_message}"
830 );
831 assert_eq!(recorded(".\\").0, ".\\");
832 assert_eq!(recorded("./pkgs/*").0, "pkgs/*");
833 assert_eq!(recorded(".\\pkgs\\*").0, "pkgs\\*");
834 }
835
836 #[test]
843 fn new_stores_one_spelling_for_a_directory_reached_through_a_dotted_glob() {
844 let root = Path::new("/project");
845 let dotted = WorkspaceDiagnostic::new(
846 root,
847 root.join("./pkgs/aaa"),
848 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
849 pattern: "./pkgs/*".to_owned(),
850 },
851 );
852 let bare = WorkspaceDiagnostic::new(
853 root,
854 root.join("pkgs/aaa"),
855 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
856 pattern: "pkgs/*".to_owned(),
857 },
858 );
859
860 let spelling = |diagnostic: &WorkspaceDiagnostic| {
861 diagnostic.path.display().to_string().replace('\\', "/")
862 };
863 assert_eq!(
864 spelling(&dotted),
865 "/project/pkgs/aaa",
866 "the stored path drops the no-op . component, which Path equality \
867 hides but serialization does not"
868 );
869 assert_eq!(spelling(&dotted), spelling(&bare));
870 assert_eq!(
871 spelling(&dotted.clone().into_root_relative(root)),
872 "pkgs/aaa"
873 );
874
875 let merged = merge_workspace_diagnostics(vec![dotted], vec![bare]);
876 assert_eq!(
877 merged.len(),
878 1,
879 "one directory reached through two spellings of one glob: {merged:?}"
880 );
881 }
882
883 #[test]
886 fn dedupe_keeps_first_of_each_pair_and_every_distinct_payload() {
887 let root = Path::new("/project");
888 let glob = |pattern: &str, relative: &str| {
889 WorkspaceDiagnostic::new(
890 root,
891 root.join(relative),
892 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
893 pattern: pattern.to_owned(),
894 },
895 )
896 };
897
898 let deduped = dedupe_workspace_diagnostics(vec![
899 glob("pkgs/*", "pkgs/aaa"),
900 glob("pkgs/*", "pkgs/bbb"),
901 glob("./pkgs/*", "./pkgs/aaa"),
902 glob("pkgs/a*", "pkgs/aaa"),
903 ]);
904
905 let reported: Vec<(String, String)> = deduped
906 .iter()
907 .map(|diagnostic| match &diagnostic.kind {
908 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => (
909 pattern.clone(),
910 diagnostic.path.display().to_string().replace('\\', "/"),
911 ),
912 other => panic!("unexpected kind {}", other.id()),
913 })
914 .collect();
915
916 assert_eq!(
917 reported,
918 vec![
919 ("pkgs/*".to_owned(), "/project/pkgs/aaa".to_owned()),
920 ("pkgs/*".to_owned(), "/project/pkgs/bbb".to_owned()),
921 ("pkgs/a*".to_owned(), "/project/pkgs/aaa".to_owned()),
922 ],
923 "the duplicate spelling folds away and the overlapping glob stays"
924 );
925 }
926
927 #[test]
928 fn source_walk_recorded_covers_only_the_kinds_a_walk_replaces() {
929 for kind in [
930 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes: 1 },
931 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes: 1 },
932 ] {
933 assert!(
934 kind.is_source_walk_recorded() && kind.is_source_discovery(),
935 "{} is written by the source walk",
936 kind.id()
937 );
938 }
939
940 let read_failure = WorkspaceDiagnosticKind::SourceReadFailure {
941 error: "permission denied".to_owned(),
942 };
943 assert!(
944 read_failure.is_source_discovery() && !read_failure.is_source_walk_recorded(),
945 "the parse stage records source-read-failure after the walk, so it must keep \
946 reaching sessions through the registry"
947 );
948
949 for kind in [
950 WorkspaceDiagnosticKind::UndeclaredWorkspace,
951 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
952 WorkspaceDiagnosticKind::BunLockbOverrideResolutionSkipped,
953 WorkspaceDiagnosticKind::BunLockOverrideResolutionSkipped,
954 WorkspaceDiagnosticKind::BunResolutionsShadowedByOverrides,
955 ] {
956 assert!(
957 !kind.is_source_walk_recorded(),
958 "{} is not written by the source walk",
959 kind.id()
960 );
961 }
962 }
963
964 #[test]
965 fn format_size_mb_one_decimal() {
966 assert_eq!(format_size_mb(0), "0.0 MB");
967 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
968 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
969 }
970
971 #[test]
972 fn undeclared_workspace_message_has_next_step() {
973 let root = Path::new("/project");
974 let diag = WorkspaceDiagnostic::new(
975 root,
976 root.join("packages/legacy"),
977 WorkspaceDiagnosticKind::UndeclaredWorkspace,
978 );
979 assert_eq!(diag.kind.id(), "undeclared-workspace");
980 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
981 assert!(
982 diag.message.contains("ignorePatterns"),
983 "next-step hint preserved: {}",
984 diag.message
985 );
986 }
987}