1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::extract::MemberKind;
8use crate::serde_path;
9
10#[derive(Debug, Clone, Default)]
15pub struct EntryPointSummary {
16 pub total: usize,
18 pub by_source: Vec<(String, usize)>,
21}
22
23#[derive(Debug, Default, Clone, Serialize)]
42pub struct AnalysisResults {
43 pub unused_files: Vec<UnusedFile>,
45 pub unused_exports: Vec<UnusedExport>,
47 pub unused_types: Vec<UnusedExport>,
49 pub unused_dependencies: Vec<UnusedDependency>,
51 pub unused_dev_dependencies: Vec<UnusedDependency>,
53 pub unused_optional_dependencies: Vec<UnusedDependency>,
55 pub unused_enum_members: Vec<UnusedMember>,
57 pub unused_class_members: Vec<UnusedMember>,
59 pub unresolved_imports: Vec<UnresolvedImport>,
61 pub unlisted_dependencies: Vec<UnlistedDependency>,
63 pub duplicate_exports: Vec<DuplicateExport>,
65 pub type_only_dependencies: Vec<TypeOnlyDependency>,
68 #[serde(default)]
70 pub test_only_dependencies: Vec<TestOnlyDependency>,
71 pub circular_dependencies: Vec<CircularDependency>,
73 #[serde(default)]
75 pub boundary_violations: Vec<BoundaryViolation>,
76 #[serde(skip)]
79 pub feature_flags: Vec<FeatureFlag>,
80 #[serde(skip)]
84 pub export_usages: Vec<ExportUsage>,
85 #[serde(skip)]
89 pub entry_point_summary: Option<EntryPointSummary>,
90}
91
92impl AnalysisResults {
93 #[must_use]
117 pub const fn total_issues(&self) -> usize {
118 self.unused_files.len()
119 + self.unused_exports.len()
120 + self.unused_types.len()
121 + self.unused_dependencies.len()
122 + self.unused_dev_dependencies.len()
123 + self.unused_optional_dependencies.len()
124 + self.unused_enum_members.len()
125 + self.unused_class_members.len()
126 + self.unresolved_imports.len()
127 + self.unlisted_dependencies.len()
128 + self.duplicate_exports.len()
129 + self.type_only_dependencies.len()
130 + self.test_only_dependencies.len()
131 + self.circular_dependencies.len()
132 + self.boundary_violations.len()
133 }
134
135 #[must_use]
137 pub const fn has_issues(&self) -> bool {
138 self.total_issues() > 0
139 }
140
141 pub fn sort(&mut self) {
148 self.unused_files.sort_by(|a, b| a.path.cmp(&b.path));
149
150 self.unused_exports.sort_by(|a, b| {
151 a.path
152 .cmp(&b.path)
153 .then(a.line.cmp(&b.line))
154 .then(a.export_name.cmp(&b.export_name))
155 });
156
157 self.unused_types.sort_by(|a, b| {
158 a.path
159 .cmp(&b.path)
160 .then(a.line.cmp(&b.line))
161 .then(a.export_name.cmp(&b.export_name))
162 });
163
164 self.unused_dependencies.sort_by(|a, b| {
165 a.path
166 .cmp(&b.path)
167 .then(a.line.cmp(&b.line))
168 .then(a.package_name.cmp(&b.package_name))
169 });
170
171 self.unused_dev_dependencies.sort_by(|a, b| {
172 a.path
173 .cmp(&b.path)
174 .then(a.line.cmp(&b.line))
175 .then(a.package_name.cmp(&b.package_name))
176 });
177
178 self.unused_optional_dependencies.sort_by(|a, b| {
179 a.path
180 .cmp(&b.path)
181 .then(a.line.cmp(&b.line))
182 .then(a.package_name.cmp(&b.package_name))
183 });
184
185 self.unused_enum_members.sort_by(|a, b| {
186 a.path
187 .cmp(&b.path)
188 .then(a.line.cmp(&b.line))
189 .then(a.parent_name.cmp(&b.parent_name))
190 .then(a.member_name.cmp(&b.member_name))
191 });
192
193 self.unused_class_members.sort_by(|a, b| {
194 a.path
195 .cmp(&b.path)
196 .then(a.line.cmp(&b.line))
197 .then(a.parent_name.cmp(&b.parent_name))
198 .then(a.member_name.cmp(&b.member_name))
199 });
200
201 self.unresolved_imports.sort_by(|a, b| {
202 a.path
203 .cmp(&b.path)
204 .then(a.line.cmp(&b.line))
205 .then(a.col.cmp(&b.col))
206 .then(a.specifier.cmp(&b.specifier))
207 });
208
209 self.unlisted_dependencies
210 .sort_by(|a, b| a.package_name.cmp(&b.package_name));
211 for dep in &mut self.unlisted_dependencies {
212 dep.imported_from
213 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
214 }
215
216 self.duplicate_exports
217 .sort_by(|a, b| a.export_name.cmp(&b.export_name));
218 for dup in &mut self.duplicate_exports {
219 dup.locations
220 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
221 }
222
223 self.type_only_dependencies.sort_by(|a, b| {
224 a.path
225 .cmp(&b.path)
226 .then(a.line.cmp(&b.line))
227 .then(a.package_name.cmp(&b.package_name))
228 });
229
230 self.test_only_dependencies.sort_by(|a, b| {
231 a.path
232 .cmp(&b.path)
233 .then(a.line.cmp(&b.line))
234 .then(a.package_name.cmp(&b.package_name))
235 });
236
237 self.circular_dependencies
238 .sort_by(|a, b| a.files.cmp(&b.files).then(a.length.cmp(&b.length)));
239
240 self.boundary_violations.sort_by(|a, b| {
241 a.from_path
242 .cmp(&b.from_path)
243 .then(a.line.cmp(&b.line))
244 .then(a.col.cmp(&b.col))
245 .then(a.to_path.cmp(&b.to_path))
246 });
247
248 self.feature_flags.sort_by(|a, b| {
249 a.path
250 .cmp(&b.path)
251 .then(a.line.cmp(&b.line))
252 .then(a.flag_name.cmp(&b.flag_name))
253 });
254
255 for usage in &mut self.export_usages {
256 usage.reference_locations.sort_by(|a, b| {
257 a.path
258 .cmp(&b.path)
259 .then(a.line.cmp(&b.line))
260 .then(a.col.cmp(&b.col))
261 });
262 }
263 self.export_usages.sort_by(|a, b| {
264 a.path
265 .cmp(&b.path)
266 .then(a.line.cmp(&b.line))
267 .then(a.export_name.cmp(&b.export_name))
268 });
269 }
270}
271
272#[derive(Debug, Clone, Serialize)]
274pub struct UnusedFile {
275 #[serde(serialize_with = "serde_path::serialize")]
277 pub path: PathBuf,
278}
279
280#[derive(Debug, Clone, Serialize)]
282pub struct UnusedExport {
283 #[serde(serialize_with = "serde_path::serialize")]
285 pub path: PathBuf,
286 pub export_name: String,
288 pub is_type_only: bool,
290 pub line: u32,
292 pub col: u32,
294 pub span_start: u32,
296 pub is_re_export: bool,
298}
299
300#[derive(Debug, Clone, Serialize)]
302pub struct UnusedDependency {
303 pub package_name: String,
305 pub location: DependencyLocation,
307 #[serde(serialize_with = "serde_path::serialize")]
310 pub path: PathBuf,
311 pub line: u32,
313}
314
315#[derive(Debug, Clone, Serialize)]
332#[serde(rename_all = "camelCase")]
333pub enum DependencyLocation {
334 Dependencies,
336 DevDependencies,
338 OptionalDependencies,
340}
341
342#[derive(Debug, Clone, Serialize)]
344pub struct UnusedMember {
345 #[serde(serialize_with = "serde_path::serialize")]
347 pub path: PathBuf,
348 pub parent_name: String,
350 pub member_name: String,
352 pub kind: MemberKind,
354 pub line: u32,
356 pub col: u32,
358}
359
360#[derive(Debug, Clone, Serialize)]
362pub struct UnresolvedImport {
363 #[serde(serialize_with = "serde_path::serialize")]
365 pub path: PathBuf,
366 pub specifier: String,
368 pub line: u32,
370 pub col: u32,
372 pub specifier_col: u32,
375}
376
377#[derive(Debug, Clone, Serialize)]
379pub struct UnlistedDependency {
380 pub package_name: String,
382 pub imported_from: Vec<ImportSite>,
384}
385
386#[derive(Debug, Clone, Serialize)]
388pub struct ImportSite {
389 #[serde(serialize_with = "serde_path::serialize")]
391 pub path: PathBuf,
392 pub line: u32,
394 pub col: u32,
396}
397
398#[derive(Debug, Clone, Serialize)]
400pub struct DuplicateExport {
401 pub export_name: String,
403 pub locations: Vec<DuplicateLocation>,
405}
406
407#[derive(Debug, Clone, Serialize)]
409pub struct DuplicateLocation {
410 #[serde(serialize_with = "serde_path::serialize")]
412 pub path: PathBuf,
413 pub line: u32,
415 pub col: u32,
417}
418
419#[derive(Debug, Clone, Serialize)]
423pub struct TypeOnlyDependency {
424 pub package_name: String,
426 #[serde(serialize_with = "serde_path::serialize")]
428 pub path: PathBuf,
429 pub line: u32,
431}
432
433#[derive(Debug, Clone, Serialize)]
436pub struct TestOnlyDependency {
437 pub package_name: String,
439 #[serde(serialize_with = "serde_path::serialize")]
441 pub path: PathBuf,
442 pub line: u32,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct CircularDependency {
449 #[serde(serialize_with = "serde_path::serialize_vec")]
451 pub files: Vec<PathBuf>,
452 pub length: usize,
454 #[serde(default)]
456 pub line: u32,
457 #[serde(default)]
459 pub col: u32,
460 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
462 pub is_cross_package: bool,
463}
464
465#[derive(Debug, Clone, Serialize)]
467pub struct BoundaryViolation {
468 #[serde(serialize_with = "serde_path::serialize")]
470 pub from_path: PathBuf,
471 #[serde(serialize_with = "serde_path::serialize")]
473 pub to_path: PathBuf,
474 pub from_zone: String,
476 pub to_zone: String,
478 pub import_specifier: String,
480 pub line: u32,
482 pub col: u32,
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
488#[serde(rename_all = "snake_case")]
489pub enum FlagKind {
490 EnvironmentVariable,
492 SdkCall,
494 ConfigObject,
496}
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
500#[serde(rename_all = "snake_case")]
501pub enum FlagConfidence {
502 Low,
504 Medium,
506 High,
508}
509
510#[derive(Debug, Clone, Serialize)]
512pub struct FeatureFlag {
513 #[serde(serialize_with = "serde_path::serialize")]
515 pub path: PathBuf,
516 pub flag_name: String,
518 pub kind: FlagKind,
520 pub confidence: FlagConfidence,
522 pub line: u32,
524 pub col: u32,
526 #[serde(skip)]
528 pub guard_span_start: Option<u32>,
529 #[serde(skip)]
531 pub guard_span_end: Option<u32>,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub sdk_name: Option<String>,
535 #[serde(skip)]
538 pub guard_line_start: Option<u32>,
539 #[serde(skip)]
541 pub guard_line_end: Option<u32>,
542 #[serde(skip_serializing_if = "Vec::is_empty")]
545 pub guarded_dead_exports: Vec<String>,
546}
547
548const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
550
551#[derive(Debug, Clone, Serialize)]
554pub struct ExportUsage {
555 #[serde(serialize_with = "serde_path::serialize")]
557 pub path: PathBuf,
558 pub export_name: String,
560 pub line: u32,
562 pub col: u32,
564 pub reference_count: usize,
566 pub reference_locations: Vec<ReferenceLocation>,
569}
570
571#[derive(Debug, Clone, Serialize)]
573pub struct ReferenceLocation {
574 #[serde(serialize_with = "serde_path::serialize")]
576 pub path: PathBuf,
577 pub line: u32,
579 pub col: u32,
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn empty_results_no_issues() {
589 let results = AnalysisResults::default();
590 assert_eq!(results.total_issues(), 0);
591 assert!(!results.has_issues());
592 }
593
594 #[test]
595 fn results_with_unused_file() {
596 let mut results = AnalysisResults::default();
597 results.unused_files.push(UnusedFile {
598 path: PathBuf::from("test.ts"),
599 });
600 assert_eq!(results.total_issues(), 1);
601 assert!(results.has_issues());
602 }
603
604 #[test]
605 fn results_with_unused_export() {
606 let mut results = AnalysisResults::default();
607 results.unused_exports.push(UnusedExport {
608 path: PathBuf::from("test.ts"),
609 export_name: "foo".to_string(),
610 is_type_only: false,
611 line: 1,
612 col: 0,
613 span_start: 0,
614 is_re_export: false,
615 });
616 assert_eq!(results.total_issues(), 1);
617 assert!(results.has_issues());
618 }
619
620 #[test]
621 fn results_total_counts_all_types() {
622 let mut results = AnalysisResults::default();
623 results.unused_files.push(UnusedFile {
624 path: PathBuf::from("a.ts"),
625 });
626 results.unused_exports.push(UnusedExport {
627 path: PathBuf::from("b.ts"),
628 export_name: "x".to_string(),
629 is_type_only: false,
630 line: 1,
631 col: 0,
632 span_start: 0,
633 is_re_export: false,
634 });
635 results.unused_types.push(UnusedExport {
636 path: PathBuf::from("c.ts"),
637 export_name: "T".to_string(),
638 is_type_only: true,
639 line: 1,
640 col: 0,
641 span_start: 0,
642 is_re_export: false,
643 });
644 results.unused_dependencies.push(UnusedDependency {
645 package_name: "dep".to_string(),
646 location: DependencyLocation::Dependencies,
647 path: PathBuf::from("package.json"),
648 line: 5,
649 });
650 results.unused_dev_dependencies.push(UnusedDependency {
651 package_name: "dev".to_string(),
652 location: DependencyLocation::DevDependencies,
653 path: PathBuf::from("package.json"),
654 line: 5,
655 });
656 results.unused_enum_members.push(UnusedMember {
657 path: PathBuf::from("d.ts"),
658 parent_name: "E".to_string(),
659 member_name: "A".to_string(),
660 kind: MemberKind::EnumMember,
661 line: 1,
662 col: 0,
663 });
664 results.unused_class_members.push(UnusedMember {
665 path: PathBuf::from("e.ts"),
666 parent_name: "C".to_string(),
667 member_name: "m".to_string(),
668 kind: MemberKind::ClassMethod,
669 line: 1,
670 col: 0,
671 });
672 results.unresolved_imports.push(UnresolvedImport {
673 path: PathBuf::from("f.ts"),
674 specifier: "./missing".to_string(),
675 line: 1,
676 col: 0,
677 specifier_col: 0,
678 });
679 results.unlisted_dependencies.push(UnlistedDependency {
680 package_name: "unlisted".to_string(),
681 imported_from: vec![ImportSite {
682 path: PathBuf::from("g.ts"),
683 line: 1,
684 col: 0,
685 }],
686 });
687 results.duplicate_exports.push(DuplicateExport {
688 export_name: "dup".to_string(),
689 locations: vec![
690 DuplicateLocation {
691 path: PathBuf::from("h.ts"),
692 line: 15,
693 col: 0,
694 },
695 DuplicateLocation {
696 path: PathBuf::from("i.ts"),
697 line: 30,
698 col: 0,
699 },
700 ],
701 });
702 results.unused_optional_dependencies.push(UnusedDependency {
703 package_name: "optional".to_string(),
704 location: DependencyLocation::OptionalDependencies,
705 path: PathBuf::from("package.json"),
706 line: 5,
707 });
708 results.type_only_dependencies.push(TypeOnlyDependency {
709 package_name: "type-only".to_string(),
710 path: PathBuf::from("package.json"),
711 line: 8,
712 });
713 results.test_only_dependencies.push(TestOnlyDependency {
714 package_name: "test-only".to_string(),
715 path: PathBuf::from("package.json"),
716 line: 9,
717 });
718 results.circular_dependencies.push(CircularDependency {
719 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
720 length: 2,
721 line: 3,
722 col: 0,
723 is_cross_package: false,
724 });
725 results.boundary_violations.push(BoundaryViolation {
726 from_path: PathBuf::from("src/ui/Button.tsx"),
727 to_path: PathBuf::from("src/db/queries.ts"),
728 from_zone: "ui".to_string(),
729 to_zone: "database".to_string(),
730 import_specifier: "../db/queries".to_string(),
731 line: 3,
732 col: 0,
733 });
734
735 assert_eq!(results.total_issues(), 15);
737 assert!(results.has_issues());
738 }
739
740 #[test]
743 fn total_issues_and_has_issues_are_consistent() {
744 let results = AnalysisResults::default();
745 assert_eq!(results.total_issues(), 0);
746 assert!(!results.has_issues());
747 assert_eq!(results.total_issues() > 0, results.has_issues());
748 }
749
750 #[test]
753 fn total_issues_sums_all_categories_independently() {
754 let mut results = AnalysisResults::default();
755 results.unused_files.push(UnusedFile {
756 path: PathBuf::from("a.ts"),
757 });
758 assert_eq!(results.total_issues(), 1);
759
760 results.unused_files.push(UnusedFile {
761 path: PathBuf::from("b.ts"),
762 });
763 assert_eq!(results.total_issues(), 2);
764
765 results.unresolved_imports.push(UnresolvedImport {
766 path: PathBuf::from("c.ts"),
767 specifier: "./missing".to_string(),
768 line: 1,
769 col: 0,
770 specifier_col: 0,
771 });
772 assert_eq!(results.total_issues(), 3);
773 }
774
775 #[test]
778 fn default_results_all_fields_empty() {
779 let r = AnalysisResults::default();
780 assert!(r.unused_files.is_empty());
781 assert!(r.unused_exports.is_empty());
782 assert!(r.unused_types.is_empty());
783 assert!(r.unused_dependencies.is_empty());
784 assert!(r.unused_dev_dependencies.is_empty());
785 assert!(r.unused_optional_dependencies.is_empty());
786 assert!(r.unused_enum_members.is_empty());
787 assert!(r.unused_class_members.is_empty());
788 assert!(r.unresolved_imports.is_empty());
789 assert!(r.unlisted_dependencies.is_empty());
790 assert!(r.duplicate_exports.is_empty());
791 assert!(r.type_only_dependencies.is_empty());
792 assert!(r.test_only_dependencies.is_empty());
793 assert!(r.circular_dependencies.is_empty());
794 assert!(r.boundary_violations.is_empty());
795 assert!(r.export_usages.is_empty());
796 }
797
798 #[test]
801 fn entry_point_summary_default() {
802 let summary = EntryPointSummary::default();
803 assert_eq!(summary.total, 0);
804 assert!(summary.by_source.is_empty());
805 }
806
807 #[test]
808 fn entry_point_summary_not_in_default_results() {
809 let r = AnalysisResults::default();
810 assert!(r.entry_point_summary.is_none());
811 }
812
813 #[test]
814 fn entry_point_summary_some_preserves_data() {
815 let r = AnalysisResults {
816 entry_point_summary: Some(EntryPointSummary {
817 total: 5,
818 by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
819 }),
820 ..AnalysisResults::default()
821 };
822 let summary = r.entry_point_summary.as_ref().unwrap();
823 assert_eq!(summary.total, 5);
824 assert_eq!(summary.by_source.len(), 2);
825 assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
826 }
827
828 #[test]
831 fn sort_unused_files_by_path() {
832 let mut r = AnalysisResults::default();
833 r.unused_files.push(UnusedFile {
834 path: PathBuf::from("z.ts"),
835 });
836 r.unused_files.push(UnusedFile {
837 path: PathBuf::from("a.ts"),
838 });
839 r.unused_files.push(UnusedFile {
840 path: PathBuf::from("m.ts"),
841 });
842 r.sort();
843 let paths: Vec<_> = r
844 .unused_files
845 .iter()
846 .map(|f| f.path.to_string_lossy().to_string())
847 .collect();
848 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
849 }
850
851 #[test]
854 fn sort_unused_exports_by_path_line_name() {
855 let mut r = AnalysisResults::default();
856 let mk = |path: &str, line: u32, name: &str| UnusedExport {
857 path: PathBuf::from(path),
858 export_name: name.to_string(),
859 is_type_only: false,
860 line,
861 col: 0,
862 span_start: 0,
863 is_re_export: false,
864 };
865 r.unused_exports.push(mk("b.ts", 5, "beta"));
866 r.unused_exports.push(mk("a.ts", 10, "zeta"));
867 r.unused_exports.push(mk("a.ts", 10, "alpha"));
868 r.unused_exports.push(mk("a.ts", 1, "gamma"));
869 r.sort();
870 let keys: Vec<_> = r
871 .unused_exports
872 .iter()
873 .map(|e| format!("{}:{}:{}", e.path.to_string_lossy(), e.line, e.export_name))
874 .collect();
875 assert_eq!(
876 keys,
877 vec![
878 "a.ts:1:gamma",
879 "a.ts:10:alpha",
880 "a.ts:10:zeta",
881 "b.ts:5:beta"
882 ]
883 );
884 }
885
886 #[test]
889 fn sort_unused_types_by_path_line_name() {
890 let mut r = AnalysisResults::default();
891 let mk = |path: &str, line: u32, name: &str| UnusedExport {
892 path: PathBuf::from(path),
893 export_name: name.to_string(),
894 is_type_only: true,
895 line,
896 col: 0,
897 span_start: 0,
898 is_re_export: false,
899 };
900 r.unused_types.push(mk("z.ts", 1, "Z"));
901 r.unused_types.push(mk("a.ts", 1, "A"));
902 r.sort();
903 assert_eq!(r.unused_types[0].path, PathBuf::from("a.ts"));
904 assert_eq!(r.unused_types[1].path, PathBuf::from("z.ts"));
905 }
906
907 #[test]
910 fn sort_unused_dependencies_by_path_line_name() {
911 let mut r = AnalysisResults::default();
912 let mk = |path: &str, line: u32, name: &str| UnusedDependency {
913 package_name: name.to_string(),
914 location: DependencyLocation::Dependencies,
915 path: PathBuf::from(path),
916 line,
917 };
918 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
919 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
920 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
921 r.sort();
922 let names: Vec<_> = r
923 .unused_dependencies
924 .iter()
925 .map(|d| d.package_name.as_str())
926 .collect();
927 assert_eq!(names, vec!["axios", "react", "zlib"]);
928 }
929
930 #[test]
933 fn sort_unused_dev_dependencies() {
934 let mut r = AnalysisResults::default();
935 r.unused_dev_dependencies.push(UnusedDependency {
936 package_name: "vitest".to_string(),
937 location: DependencyLocation::DevDependencies,
938 path: PathBuf::from("package.json"),
939 line: 10,
940 });
941 r.unused_dev_dependencies.push(UnusedDependency {
942 package_name: "jest".to_string(),
943 location: DependencyLocation::DevDependencies,
944 path: PathBuf::from("package.json"),
945 line: 5,
946 });
947 r.sort();
948 assert_eq!(r.unused_dev_dependencies[0].package_name, "jest");
949 assert_eq!(r.unused_dev_dependencies[1].package_name, "vitest");
950 }
951
952 #[test]
955 fn sort_unused_optional_dependencies() {
956 let mut r = AnalysisResults::default();
957 r.unused_optional_dependencies.push(UnusedDependency {
958 package_name: "zod".to_string(),
959 location: DependencyLocation::OptionalDependencies,
960 path: PathBuf::from("package.json"),
961 line: 3,
962 });
963 r.unused_optional_dependencies.push(UnusedDependency {
964 package_name: "ajv".to_string(),
965 location: DependencyLocation::OptionalDependencies,
966 path: PathBuf::from("package.json"),
967 line: 2,
968 });
969 r.sort();
970 assert_eq!(r.unused_optional_dependencies[0].package_name, "ajv");
971 assert_eq!(r.unused_optional_dependencies[1].package_name, "zod");
972 }
973
974 #[test]
977 fn sort_unused_enum_members_by_path_line_parent_member() {
978 let mut r = AnalysisResults::default();
979 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
980 path: PathBuf::from(path),
981 parent_name: parent.to_string(),
982 member_name: member.to_string(),
983 kind: MemberKind::EnumMember,
984 line,
985 col: 0,
986 };
987 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
988 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
989 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
990 r.sort();
991 let keys: Vec<_> = r
992 .unused_enum_members
993 .iter()
994 .map(|m| format!("{}:{}", m.parent_name, m.member_name))
995 .collect();
996 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
997 }
998
999 #[test]
1002 fn sort_unused_class_members() {
1003 let mut r = AnalysisResults::default();
1004 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1005 path: PathBuf::from(path),
1006 parent_name: parent.to_string(),
1007 member_name: member.to_string(),
1008 kind: MemberKind::ClassMethod,
1009 line,
1010 col: 0,
1011 };
1012 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1013 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1014 r.sort();
1015 assert_eq!(r.unused_class_members[0].path, PathBuf::from("a.ts"));
1016 assert_eq!(r.unused_class_members[1].path, PathBuf::from("b.ts"));
1017 }
1018
1019 #[test]
1022 fn sort_unresolved_imports_by_path_line_col_specifier() {
1023 let mut r = AnalysisResults::default();
1024 let mk = |path: &str, line: u32, col: u32, spec: &str| UnresolvedImport {
1025 path: PathBuf::from(path),
1026 specifier: spec.to_string(),
1027 line,
1028 col,
1029 specifier_col: 0,
1030 };
1031 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1032 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1033 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1034 r.sort();
1035 let specs: Vec<_> = r
1036 .unresolved_imports
1037 .iter()
1038 .map(|i| i.specifier.as_str())
1039 .collect();
1040 assert_eq!(specs, vec!["./m", "./a", "./z"]);
1041 }
1042
1043 #[test]
1046 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1047 let mut r = AnalysisResults::default();
1048 r.unlisted_dependencies.push(UnlistedDependency {
1049 package_name: "zod".to_string(),
1050 imported_from: vec![
1051 ImportSite {
1052 path: PathBuf::from("b.ts"),
1053 line: 10,
1054 col: 0,
1055 },
1056 ImportSite {
1057 path: PathBuf::from("a.ts"),
1058 line: 1,
1059 col: 0,
1060 },
1061 ],
1062 });
1063 r.unlisted_dependencies.push(UnlistedDependency {
1064 package_name: "axios".to_string(),
1065 imported_from: vec![ImportSite {
1066 path: PathBuf::from("c.ts"),
1067 line: 1,
1068 col: 0,
1069 }],
1070 });
1071 r.sort();
1072
1073 assert_eq!(r.unlisted_dependencies[0].package_name, "axios");
1075 assert_eq!(r.unlisted_dependencies[1].package_name, "zod");
1076
1077 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1079 .imported_from
1080 .iter()
1081 .map(|s| s.path.to_string_lossy().to_string())
1082 .collect();
1083 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1084 }
1085
1086 #[test]
1089 fn sort_duplicate_exports_by_name_and_inner_locations() {
1090 let mut r = AnalysisResults::default();
1091 r.duplicate_exports.push(DuplicateExport {
1092 export_name: "z".to_string(),
1093 locations: vec![
1094 DuplicateLocation {
1095 path: PathBuf::from("c.ts"),
1096 line: 1,
1097 col: 0,
1098 },
1099 DuplicateLocation {
1100 path: PathBuf::from("a.ts"),
1101 line: 5,
1102 col: 0,
1103 },
1104 ],
1105 });
1106 r.duplicate_exports.push(DuplicateExport {
1107 export_name: "a".to_string(),
1108 locations: vec![DuplicateLocation {
1109 path: PathBuf::from("b.ts"),
1110 line: 1,
1111 col: 0,
1112 }],
1113 });
1114 r.sort();
1115
1116 assert_eq!(r.duplicate_exports[0].export_name, "a");
1118 assert_eq!(r.duplicate_exports[1].export_name, "z");
1119
1120 let z_locs: Vec<_> = r.duplicate_exports[1]
1122 .locations
1123 .iter()
1124 .map(|l| l.path.to_string_lossy().to_string())
1125 .collect();
1126 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1127 }
1128
1129 #[test]
1132 fn sort_type_only_dependencies() {
1133 let mut r = AnalysisResults::default();
1134 r.type_only_dependencies.push(TypeOnlyDependency {
1135 package_name: "zod".to_string(),
1136 path: PathBuf::from("package.json"),
1137 line: 10,
1138 });
1139 r.type_only_dependencies.push(TypeOnlyDependency {
1140 package_name: "ajv".to_string(),
1141 path: PathBuf::from("package.json"),
1142 line: 5,
1143 });
1144 r.sort();
1145 assert_eq!(r.type_only_dependencies[0].package_name, "ajv");
1146 assert_eq!(r.type_only_dependencies[1].package_name, "zod");
1147 }
1148
1149 #[test]
1152 fn sort_test_only_dependencies() {
1153 let mut r = AnalysisResults::default();
1154 r.test_only_dependencies.push(TestOnlyDependency {
1155 package_name: "vitest".to_string(),
1156 path: PathBuf::from("package.json"),
1157 line: 15,
1158 });
1159 r.test_only_dependencies.push(TestOnlyDependency {
1160 package_name: "jest".to_string(),
1161 path: PathBuf::from("package.json"),
1162 line: 10,
1163 });
1164 r.sort();
1165 assert_eq!(r.test_only_dependencies[0].package_name, "jest");
1166 assert_eq!(r.test_only_dependencies[1].package_name, "vitest");
1167 }
1168
1169 #[test]
1172 fn sort_circular_dependencies_by_files_then_length() {
1173 let mut r = AnalysisResults::default();
1174 r.circular_dependencies.push(CircularDependency {
1175 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1176 length: 2,
1177 line: 1,
1178 col: 0,
1179 is_cross_package: false,
1180 });
1181 r.circular_dependencies.push(CircularDependency {
1182 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1183 length: 2,
1184 line: 1,
1185 col: 0,
1186 is_cross_package: true,
1187 });
1188 r.sort();
1189 assert_eq!(r.circular_dependencies[0].files[0], PathBuf::from("a.ts"));
1190 assert_eq!(r.circular_dependencies[1].files[0], PathBuf::from("b.ts"));
1191 }
1192
1193 #[test]
1196 fn sort_boundary_violations() {
1197 let mut r = AnalysisResults::default();
1198 let mk = |from: &str, line: u32, col: u32, to: &str| BoundaryViolation {
1199 from_path: PathBuf::from(from),
1200 to_path: PathBuf::from(to),
1201 from_zone: "a".to_string(),
1202 to_zone: "b".to_string(),
1203 import_specifier: to.to_string(),
1204 line,
1205 col,
1206 };
1207 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
1208 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
1209 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
1210 r.sort();
1211 let from_paths: Vec<_> = r
1212 .boundary_violations
1213 .iter()
1214 .map(|v| format!("{}:{}", v.from_path.to_string_lossy(), v.line))
1215 .collect();
1216 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
1217 }
1218
1219 #[test]
1222 fn sort_export_usages_and_inner_reference_locations() {
1223 let mut r = AnalysisResults::default();
1224 r.export_usages.push(ExportUsage {
1225 path: PathBuf::from("z.ts"),
1226 export_name: "foo".to_string(),
1227 line: 1,
1228 col: 0,
1229 reference_count: 2,
1230 reference_locations: vec![
1231 ReferenceLocation {
1232 path: PathBuf::from("c.ts"),
1233 line: 10,
1234 col: 0,
1235 },
1236 ReferenceLocation {
1237 path: PathBuf::from("a.ts"),
1238 line: 5,
1239 col: 0,
1240 },
1241 ],
1242 });
1243 r.export_usages.push(ExportUsage {
1244 path: PathBuf::from("a.ts"),
1245 export_name: "bar".to_string(),
1246 line: 1,
1247 col: 0,
1248 reference_count: 1,
1249 reference_locations: vec![ReferenceLocation {
1250 path: PathBuf::from("b.ts"),
1251 line: 1,
1252 col: 0,
1253 }],
1254 });
1255 r.sort();
1256
1257 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
1259 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
1260
1261 let refs: Vec<_> = r.export_usages[1]
1263 .reference_locations
1264 .iter()
1265 .map(|l| l.path.to_string_lossy().to_string())
1266 .collect();
1267 assert_eq!(refs, vec!["a.ts", "c.ts"]);
1268 }
1269
1270 #[test]
1273 fn sort_empty_results_is_noop() {
1274 let mut r = AnalysisResults::default();
1275 r.sort(); assert_eq!(r.total_issues(), 0);
1277 }
1278
1279 #[test]
1282 fn sort_single_element_lists_stable() {
1283 let mut r = AnalysisResults::default();
1284 r.unused_files.push(UnusedFile {
1285 path: PathBuf::from("only.ts"),
1286 });
1287 r.sort();
1288 assert_eq!(r.unused_files[0].path, PathBuf::from("only.ts"));
1289 }
1290
1291 #[test]
1294 fn serialize_empty_results() {
1295 let r = AnalysisResults::default();
1296 let json = serde_json::to_value(&r).unwrap();
1297
1298 assert!(json["unused_files"].as_array().unwrap().is_empty());
1300 assert!(json["unused_exports"].as_array().unwrap().is_empty());
1301 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
1302
1303 assert!(json.get("export_usages").is_none());
1305 assert!(json.get("entry_point_summary").is_none());
1306 }
1307
1308 #[test]
1309 fn serialize_unused_file_path() {
1310 let r = UnusedFile {
1311 path: PathBuf::from("src/utils/index.ts"),
1312 };
1313 let json = serde_json::to_value(&r).unwrap();
1314 assert_eq!(json["path"], "src/utils/index.ts");
1315 }
1316
1317 #[test]
1318 fn serialize_dependency_location_camel_case() {
1319 let dep = UnusedDependency {
1320 package_name: "react".to_string(),
1321 location: DependencyLocation::DevDependencies,
1322 path: PathBuf::from("package.json"),
1323 line: 5,
1324 };
1325 let json = serde_json::to_value(&dep).unwrap();
1326 assert_eq!(json["location"], "devDependencies");
1327
1328 let dep2 = UnusedDependency {
1329 package_name: "react".to_string(),
1330 location: DependencyLocation::Dependencies,
1331 path: PathBuf::from("package.json"),
1332 line: 3,
1333 };
1334 let json2 = serde_json::to_value(&dep2).unwrap();
1335 assert_eq!(json2["location"], "dependencies");
1336
1337 let dep3 = UnusedDependency {
1338 package_name: "fsevents".to_string(),
1339 location: DependencyLocation::OptionalDependencies,
1340 path: PathBuf::from("package.json"),
1341 line: 7,
1342 };
1343 let json3 = serde_json::to_value(&dep3).unwrap();
1344 assert_eq!(json3["location"], "optionalDependencies");
1345 }
1346
1347 #[test]
1348 fn serialize_circular_dependency_skips_false_cross_package() {
1349 let cd = CircularDependency {
1350 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1351 length: 2,
1352 line: 1,
1353 col: 0,
1354 is_cross_package: false,
1355 };
1356 let json = serde_json::to_value(&cd).unwrap();
1357 assert!(json.get("is_cross_package").is_none());
1359 }
1360
1361 #[test]
1362 fn serialize_circular_dependency_includes_true_cross_package() {
1363 let cd = CircularDependency {
1364 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1365 length: 2,
1366 line: 1,
1367 col: 0,
1368 is_cross_package: true,
1369 };
1370 let json = serde_json::to_value(&cd).unwrap();
1371 assert_eq!(json["is_cross_package"], true);
1372 }
1373
1374 #[test]
1375 fn serialize_unused_export_fields() {
1376 let e = UnusedExport {
1377 path: PathBuf::from("src/mod.ts"),
1378 export_name: "helper".to_string(),
1379 is_type_only: true,
1380 line: 42,
1381 col: 7,
1382 span_start: 100,
1383 is_re_export: true,
1384 };
1385 let json = serde_json::to_value(&e).unwrap();
1386 assert_eq!(json["path"], "src/mod.ts");
1387 assert_eq!(json["export_name"], "helper");
1388 assert_eq!(json["is_type_only"], true);
1389 assert_eq!(json["line"], 42);
1390 assert_eq!(json["col"], 7);
1391 assert_eq!(json["span_start"], 100);
1392 assert_eq!(json["is_re_export"], true);
1393 }
1394
1395 #[test]
1396 fn serialize_boundary_violation_fields() {
1397 let v = BoundaryViolation {
1398 from_path: PathBuf::from("src/ui/button.tsx"),
1399 to_path: PathBuf::from("src/db/queries.ts"),
1400 from_zone: "ui".to_string(),
1401 to_zone: "db".to_string(),
1402 import_specifier: "../db/queries".to_string(),
1403 line: 3,
1404 col: 0,
1405 };
1406 let json = serde_json::to_value(&v).unwrap();
1407 assert_eq!(json["from_path"], "src/ui/button.tsx");
1408 assert_eq!(json["to_path"], "src/db/queries.ts");
1409 assert_eq!(json["from_zone"], "ui");
1410 assert_eq!(json["to_zone"], "db");
1411 assert_eq!(json["import_specifier"], "../db/queries");
1412 }
1413
1414 #[test]
1415 fn serialize_unlisted_dependency_with_import_sites() {
1416 let d = UnlistedDependency {
1417 package_name: "chalk".to_string(),
1418 imported_from: vec![
1419 ImportSite {
1420 path: PathBuf::from("a.ts"),
1421 line: 1,
1422 col: 0,
1423 },
1424 ImportSite {
1425 path: PathBuf::from("b.ts"),
1426 line: 5,
1427 col: 3,
1428 },
1429 ],
1430 };
1431 let json = serde_json::to_value(&d).unwrap();
1432 assert_eq!(json["package_name"], "chalk");
1433 let sites = json["imported_from"].as_array().unwrap();
1434 assert_eq!(sites.len(), 2);
1435 assert_eq!(sites[0]["path"], "a.ts");
1436 assert_eq!(sites[1]["line"], 5);
1437 }
1438
1439 #[test]
1440 fn serialize_duplicate_export_with_locations() {
1441 let d = DuplicateExport {
1442 export_name: "Button".to_string(),
1443 locations: vec![
1444 DuplicateLocation {
1445 path: PathBuf::from("src/a.ts"),
1446 line: 10,
1447 col: 0,
1448 },
1449 DuplicateLocation {
1450 path: PathBuf::from("src/b.ts"),
1451 line: 20,
1452 col: 5,
1453 },
1454 ],
1455 };
1456 let json = serde_json::to_value(&d).unwrap();
1457 assert_eq!(json["export_name"], "Button");
1458 let locs = json["locations"].as_array().unwrap();
1459 assert_eq!(locs.len(), 2);
1460 assert_eq!(locs[0]["line"], 10);
1461 assert_eq!(locs[1]["col"], 5);
1462 }
1463
1464 #[test]
1465 fn serialize_type_only_dependency() {
1466 let d = TypeOnlyDependency {
1467 package_name: "@types/react".to_string(),
1468 path: PathBuf::from("package.json"),
1469 line: 12,
1470 };
1471 let json = serde_json::to_value(&d).unwrap();
1472 assert_eq!(json["package_name"], "@types/react");
1473 assert_eq!(json["line"], 12);
1474 }
1475
1476 #[test]
1477 fn serialize_test_only_dependency() {
1478 let d = TestOnlyDependency {
1479 package_name: "vitest".to_string(),
1480 path: PathBuf::from("package.json"),
1481 line: 8,
1482 };
1483 let json = serde_json::to_value(&d).unwrap();
1484 assert_eq!(json["package_name"], "vitest");
1485 assert_eq!(json["line"], 8);
1486 }
1487
1488 #[test]
1489 fn serialize_unused_member() {
1490 let m = UnusedMember {
1491 path: PathBuf::from("enums.ts"),
1492 parent_name: "Status".to_string(),
1493 member_name: "Pending".to_string(),
1494 kind: MemberKind::EnumMember,
1495 line: 3,
1496 col: 4,
1497 };
1498 let json = serde_json::to_value(&m).unwrap();
1499 assert_eq!(json["parent_name"], "Status");
1500 assert_eq!(json["member_name"], "Pending");
1501 assert_eq!(json["line"], 3);
1502 }
1503
1504 #[test]
1505 fn serialize_unresolved_import() {
1506 let i = UnresolvedImport {
1507 path: PathBuf::from("app.ts"),
1508 specifier: "./missing-module".to_string(),
1509 line: 7,
1510 col: 0,
1511 specifier_col: 21,
1512 };
1513 let json = serde_json::to_value(&i).unwrap();
1514 assert_eq!(json["specifier"], "./missing-module");
1515 assert_eq!(json["specifier_col"], 21);
1516 }
1517
1518 #[test]
1521 fn deserialize_circular_dependency_with_defaults() {
1522 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
1524 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1525 assert_eq!(cd.files.len(), 2);
1526 assert_eq!(cd.length, 2);
1527 assert_eq!(cd.line, 0);
1528 assert_eq!(cd.col, 0);
1529 assert!(!cd.is_cross_package);
1530 }
1531
1532 #[test]
1533 fn deserialize_circular_dependency_with_all_fields() {
1534 let json =
1535 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
1536 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1537 assert_eq!(cd.line, 5);
1538 assert_eq!(cd.col, 10);
1539 assert!(cd.is_cross_package);
1540 }
1541
1542 #[test]
1545 fn clone_results_are_independent() {
1546 let mut r = AnalysisResults::default();
1547 r.unused_files.push(UnusedFile {
1548 path: PathBuf::from("a.ts"),
1549 });
1550 let mut cloned = r.clone();
1551 cloned.unused_files.push(UnusedFile {
1552 path: PathBuf::from("b.ts"),
1553 });
1554 assert_eq!(r.total_issues(), 1);
1555 assert_eq!(cloned.total_issues(), 2);
1556 }
1557
1558 #[test]
1561 fn export_usages_not_counted_in_total_issues() {
1562 let mut r = AnalysisResults::default();
1563 r.export_usages.push(ExportUsage {
1564 path: PathBuf::from("mod.ts"),
1565 export_name: "foo".to_string(),
1566 line: 1,
1567 col: 0,
1568 reference_count: 3,
1569 reference_locations: vec![],
1570 });
1571 assert_eq!(r.total_issues(), 0);
1573 assert!(!r.has_issues());
1574 }
1575
1576 #[test]
1579 fn entry_point_summary_not_counted_in_total_issues() {
1580 let r = AnalysisResults {
1581 entry_point_summary: Some(EntryPointSummary {
1582 total: 10,
1583 by_source: vec![("config".to_string(), 10)],
1584 }),
1585 ..AnalysisResults::default()
1586 };
1587 assert_eq!(r.total_issues(), 0);
1588 assert!(!r.has_issues());
1589 }
1590}