1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::extract::MemberKind;
8use crate::serde_path;
9use crate::suppress::IssueKind;
10
11#[derive(Debug, Clone, Default)]
16pub struct EntryPointSummary {
17 pub total: usize,
19 pub by_source: Vec<(String, usize)>,
22}
23
24#[derive(Debug, Default, Clone, Serialize)]
43pub struct AnalysisResults {
44 pub unused_files: Vec<UnusedFile>,
46 pub unused_exports: Vec<UnusedExport>,
48 pub unused_types: Vec<UnusedExport>,
50 pub unused_dependencies: Vec<UnusedDependency>,
52 pub unused_dev_dependencies: Vec<UnusedDependency>,
54 pub unused_optional_dependencies: Vec<UnusedDependency>,
56 pub unused_enum_members: Vec<UnusedMember>,
58 pub unused_class_members: Vec<UnusedMember>,
60 pub unresolved_imports: Vec<UnresolvedImport>,
62 pub unlisted_dependencies: Vec<UnlistedDependency>,
64 pub duplicate_exports: Vec<DuplicateExport>,
66 pub type_only_dependencies: Vec<TypeOnlyDependency>,
69 #[serde(default)]
71 pub test_only_dependencies: Vec<TestOnlyDependency>,
72 pub circular_dependencies: Vec<CircularDependency>,
74 #[serde(default)]
76 pub boundary_violations: Vec<BoundaryViolation>,
77 #[serde(default)]
79 pub stale_suppressions: Vec<StaleSuppression>,
80 #[serde(skip)]
83 pub feature_flags: Vec<FeatureFlag>,
84 #[serde(skip)]
88 pub export_usages: Vec<ExportUsage>,
89 #[serde(skip)]
93 pub entry_point_summary: Option<EntryPointSummary>,
94}
95
96impl AnalysisResults {
97 #[must_use]
121 pub const fn total_issues(&self) -> usize {
122 self.unused_files.len()
123 + self.unused_exports.len()
124 + self.unused_types.len()
125 + self.unused_dependencies.len()
126 + self.unused_dev_dependencies.len()
127 + self.unused_optional_dependencies.len()
128 + self.unused_enum_members.len()
129 + self.unused_class_members.len()
130 + self.unresolved_imports.len()
131 + self.unlisted_dependencies.len()
132 + self.duplicate_exports.len()
133 + self.type_only_dependencies.len()
134 + self.test_only_dependencies.len()
135 + self.circular_dependencies.len()
136 + self.boundary_violations.len()
137 + self.stale_suppressions.len()
138 }
139
140 #[must_use]
142 pub const fn has_issues(&self) -> bool {
143 self.total_issues() > 0
144 }
145
146 pub fn sort(&mut self) {
153 self.unused_files.sort_by(|a, b| a.path.cmp(&b.path));
154
155 self.unused_exports.sort_by(|a, b| {
156 a.path
157 .cmp(&b.path)
158 .then(a.line.cmp(&b.line))
159 .then(a.export_name.cmp(&b.export_name))
160 });
161
162 self.unused_types.sort_by(|a, b| {
163 a.path
164 .cmp(&b.path)
165 .then(a.line.cmp(&b.line))
166 .then(a.export_name.cmp(&b.export_name))
167 });
168
169 self.unused_dependencies.sort_by(|a, b| {
170 a.path
171 .cmp(&b.path)
172 .then(a.line.cmp(&b.line))
173 .then(a.package_name.cmp(&b.package_name))
174 });
175
176 self.unused_dev_dependencies.sort_by(|a, b| {
177 a.path
178 .cmp(&b.path)
179 .then(a.line.cmp(&b.line))
180 .then(a.package_name.cmp(&b.package_name))
181 });
182
183 self.unused_optional_dependencies.sort_by(|a, b| {
184 a.path
185 .cmp(&b.path)
186 .then(a.line.cmp(&b.line))
187 .then(a.package_name.cmp(&b.package_name))
188 });
189
190 self.unused_enum_members.sort_by(|a, b| {
191 a.path
192 .cmp(&b.path)
193 .then(a.line.cmp(&b.line))
194 .then(a.parent_name.cmp(&b.parent_name))
195 .then(a.member_name.cmp(&b.member_name))
196 });
197
198 self.unused_class_members.sort_by(|a, b| {
199 a.path
200 .cmp(&b.path)
201 .then(a.line.cmp(&b.line))
202 .then(a.parent_name.cmp(&b.parent_name))
203 .then(a.member_name.cmp(&b.member_name))
204 });
205
206 self.unresolved_imports.sort_by(|a, b| {
207 a.path
208 .cmp(&b.path)
209 .then(a.line.cmp(&b.line))
210 .then(a.col.cmp(&b.col))
211 .then(a.specifier.cmp(&b.specifier))
212 });
213
214 self.unlisted_dependencies
215 .sort_by(|a, b| a.package_name.cmp(&b.package_name));
216 for dep in &mut self.unlisted_dependencies {
217 dep.imported_from
218 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
219 }
220
221 self.duplicate_exports
222 .sort_by(|a, b| a.export_name.cmp(&b.export_name));
223 for dup in &mut self.duplicate_exports {
224 dup.locations
225 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
226 }
227
228 self.type_only_dependencies.sort_by(|a, b| {
229 a.path
230 .cmp(&b.path)
231 .then(a.line.cmp(&b.line))
232 .then(a.package_name.cmp(&b.package_name))
233 });
234
235 self.test_only_dependencies.sort_by(|a, b| {
236 a.path
237 .cmp(&b.path)
238 .then(a.line.cmp(&b.line))
239 .then(a.package_name.cmp(&b.package_name))
240 });
241
242 self.circular_dependencies
243 .sort_by(|a, b| a.files.cmp(&b.files).then(a.length.cmp(&b.length)));
244
245 self.boundary_violations.sort_by(|a, b| {
246 a.from_path
247 .cmp(&b.from_path)
248 .then(a.line.cmp(&b.line))
249 .then(a.col.cmp(&b.col))
250 .then(a.to_path.cmp(&b.to_path))
251 });
252
253 self.stale_suppressions.sort_by(|a, b| {
254 a.path
255 .cmp(&b.path)
256 .then(a.line.cmp(&b.line))
257 .then(a.col.cmp(&b.col))
258 });
259
260 self.feature_flags.sort_by(|a, b| {
261 a.path
262 .cmp(&b.path)
263 .then(a.line.cmp(&b.line))
264 .then(a.flag_name.cmp(&b.flag_name))
265 });
266
267 for usage in &mut self.export_usages {
268 usage.reference_locations.sort_by(|a, b| {
269 a.path
270 .cmp(&b.path)
271 .then(a.line.cmp(&b.line))
272 .then(a.col.cmp(&b.col))
273 });
274 }
275 self.export_usages.sort_by(|a, b| {
276 a.path
277 .cmp(&b.path)
278 .then(a.line.cmp(&b.line))
279 .then(a.export_name.cmp(&b.export_name))
280 });
281 }
282}
283
284#[derive(Debug, Clone, Serialize)]
286pub struct UnusedFile {
287 #[serde(serialize_with = "serde_path::serialize")]
289 pub path: PathBuf,
290}
291
292#[derive(Debug, Clone, Serialize)]
294pub struct UnusedExport {
295 #[serde(serialize_with = "serde_path::serialize")]
297 pub path: PathBuf,
298 pub export_name: String,
300 pub is_type_only: bool,
302 pub line: u32,
304 pub col: u32,
306 pub span_start: u32,
308 pub is_re_export: bool,
310}
311
312#[derive(Debug, Clone, Serialize)]
314pub struct UnusedDependency {
315 pub package_name: String,
317 pub location: DependencyLocation,
319 #[serde(serialize_with = "serde_path::serialize")]
322 pub path: PathBuf,
323 pub line: u32,
325}
326
327#[derive(Debug, Clone, Serialize)]
344#[serde(rename_all = "camelCase")]
345pub enum DependencyLocation {
346 Dependencies,
348 DevDependencies,
350 OptionalDependencies,
352}
353
354#[derive(Debug, Clone, Serialize)]
356pub struct UnusedMember {
357 #[serde(serialize_with = "serde_path::serialize")]
359 pub path: PathBuf,
360 pub parent_name: String,
362 pub member_name: String,
364 pub kind: MemberKind,
366 pub line: u32,
368 pub col: u32,
370}
371
372#[derive(Debug, Clone, Serialize)]
374pub struct UnresolvedImport {
375 #[serde(serialize_with = "serde_path::serialize")]
377 pub path: PathBuf,
378 pub specifier: String,
380 pub line: u32,
382 pub col: u32,
384 pub specifier_col: u32,
387}
388
389#[derive(Debug, Clone, Serialize)]
391pub struct UnlistedDependency {
392 pub package_name: String,
394 pub imported_from: Vec<ImportSite>,
396}
397
398#[derive(Debug, Clone, Serialize)]
400pub struct ImportSite {
401 #[serde(serialize_with = "serde_path::serialize")]
403 pub path: PathBuf,
404 pub line: u32,
406 pub col: u32,
408}
409
410#[derive(Debug, Clone, Serialize)]
412pub struct DuplicateExport {
413 pub export_name: String,
415 pub locations: Vec<DuplicateLocation>,
417}
418
419#[derive(Debug, Clone, Serialize)]
421pub struct DuplicateLocation {
422 #[serde(serialize_with = "serde_path::serialize")]
424 pub path: PathBuf,
425 pub line: u32,
427 pub col: u32,
429}
430
431#[derive(Debug, Clone, Serialize)]
435pub struct TypeOnlyDependency {
436 pub package_name: String,
438 #[serde(serialize_with = "serde_path::serialize")]
440 pub path: PathBuf,
441 pub line: u32,
443}
444
445#[derive(Debug, Clone, Serialize)]
448pub struct TestOnlyDependency {
449 pub package_name: String,
451 #[serde(serialize_with = "serde_path::serialize")]
453 pub path: PathBuf,
454 pub line: u32,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct CircularDependency {
461 #[serde(serialize_with = "serde_path::serialize_vec")]
463 pub files: Vec<PathBuf>,
464 pub length: usize,
466 #[serde(default)]
468 pub line: u32,
469 #[serde(default)]
471 pub col: u32,
472 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
474 pub is_cross_package: bool,
475}
476
477#[derive(Debug, Clone, Serialize)]
479pub struct BoundaryViolation {
480 #[serde(serialize_with = "serde_path::serialize")]
482 pub from_path: PathBuf,
483 #[serde(serialize_with = "serde_path::serialize")]
485 pub to_path: PathBuf,
486 pub from_zone: String,
488 pub to_zone: String,
490 pub import_specifier: String,
492 pub line: u32,
494 pub col: u32,
496}
497
498#[derive(Debug, Clone, Serialize)]
500#[serde(rename_all = "snake_case", tag = "type")]
501pub enum SuppressionOrigin {
502 Comment {
504 #[serde(skip_serializing_if = "Option::is_none")]
506 issue_kind: Option<String>,
507 is_file_level: bool,
509 },
510 JsdocTag {
512 export_name: String,
514 },
515}
516
517#[derive(Debug, Clone, Serialize)]
519pub struct StaleSuppression {
520 #[serde(serialize_with = "serde_path::serialize")]
522 pub path: PathBuf,
523 pub line: u32,
525 pub col: u32,
527 pub origin: SuppressionOrigin,
529}
530
531impl StaleSuppression {
532 #[must_use]
534 pub fn description(&self) -> String {
535 match &self.origin {
536 SuppressionOrigin::Comment {
537 issue_kind,
538 is_file_level,
539 } => {
540 let directive = if *is_file_level {
541 "fallow-ignore-file"
542 } else {
543 "fallow-ignore-next-line"
544 };
545 match issue_kind {
546 Some(kind) => format!("// {directive} {kind}"),
547 None => format!("// {directive}"),
548 }
549 }
550 SuppressionOrigin::JsdocTag { export_name } => {
551 format!("@expected-unused on {export_name}")
552 }
553 }
554 }
555
556 #[must_use]
558 pub fn explanation(&self) -> String {
559 match &self.origin {
560 SuppressionOrigin::Comment {
561 issue_kind,
562 is_file_level,
563 } => {
564 let scope = if *is_file_level {
565 "in this file"
566 } else {
567 "on the next line"
568 };
569 match issue_kind {
570 Some(kind) => format!("no {kind} issue found {scope}"),
571 None => format!("no issues found {scope}"),
572 }
573 }
574 SuppressionOrigin::JsdocTag { export_name } => {
575 format!("{export_name} is now used")
576 }
577 }
578 }
579
580 #[must_use]
582 pub fn suppressed_kind(&self) -> Option<IssueKind> {
583 match &self.origin {
584 SuppressionOrigin::Comment { issue_kind, .. } => {
585 issue_kind.as_deref().and_then(IssueKind::parse)
586 }
587 SuppressionOrigin::JsdocTag { .. } => None,
588 }
589 }
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
594#[serde(rename_all = "snake_case")]
595pub enum FlagKind {
596 EnvironmentVariable,
598 SdkCall,
600 ConfigObject,
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
606#[serde(rename_all = "snake_case")]
607pub enum FlagConfidence {
608 Low,
610 Medium,
612 High,
614}
615
616#[derive(Debug, Clone, Serialize)]
618pub struct FeatureFlag {
619 #[serde(serialize_with = "serde_path::serialize")]
621 pub path: PathBuf,
622 pub flag_name: String,
624 pub kind: FlagKind,
626 pub confidence: FlagConfidence,
628 pub line: u32,
630 pub col: u32,
632 #[serde(skip)]
634 pub guard_span_start: Option<u32>,
635 #[serde(skip)]
637 pub guard_span_end: Option<u32>,
638 #[serde(skip_serializing_if = "Option::is_none")]
640 pub sdk_name: Option<String>,
641 #[serde(skip)]
644 pub guard_line_start: Option<u32>,
645 #[serde(skip)]
647 pub guard_line_end: Option<u32>,
648 #[serde(skip_serializing_if = "Vec::is_empty")]
651 pub guarded_dead_exports: Vec<String>,
652}
653
654const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
656
657#[derive(Debug, Clone, Serialize)]
660pub struct ExportUsage {
661 #[serde(serialize_with = "serde_path::serialize")]
663 pub path: PathBuf,
664 pub export_name: String,
666 pub line: u32,
668 pub col: u32,
670 pub reference_count: usize,
672 pub reference_locations: Vec<ReferenceLocation>,
675}
676
677#[derive(Debug, Clone, Serialize)]
679pub struct ReferenceLocation {
680 #[serde(serialize_with = "serde_path::serialize")]
682 pub path: PathBuf,
683 pub line: u32,
685 pub col: u32,
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn empty_results_no_issues() {
695 let results = AnalysisResults::default();
696 assert_eq!(results.total_issues(), 0);
697 assert!(!results.has_issues());
698 }
699
700 #[test]
701 fn results_with_unused_file() {
702 let mut results = AnalysisResults::default();
703 results.unused_files.push(UnusedFile {
704 path: PathBuf::from("test.ts"),
705 });
706 assert_eq!(results.total_issues(), 1);
707 assert!(results.has_issues());
708 }
709
710 #[test]
711 fn results_with_unused_export() {
712 let mut results = AnalysisResults::default();
713 results.unused_exports.push(UnusedExport {
714 path: PathBuf::from("test.ts"),
715 export_name: "foo".to_string(),
716 is_type_only: false,
717 line: 1,
718 col: 0,
719 span_start: 0,
720 is_re_export: false,
721 });
722 assert_eq!(results.total_issues(), 1);
723 assert!(results.has_issues());
724 }
725
726 #[test]
727 fn results_total_counts_all_types() {
728 let mut results = AnalysisResults::default();
729 results.unused_files.push(UnusedFile {
730 path: PathBuf::from("a.ts"),
731 });
732 results.unused_exports.push(UnusedExport {
733 path: PathBuf::from("b.ts"),
734 export_name: "x".to_string(),
735 is_type_only: false,
736 line: 1,
737 col: 0,
738 span_start: 0,
739 is_re_export: false,
740 });
741 results.unused_types.push(UnusedExport {
742 path: PathBuf::from("c.ts"),
743 export_name: "T".to_string(),
744 is_type_only: true,
745 line: 1,
746 col: 0,
747 span_start: 0,
748 is_re_export: false,
749 });
750 results.unused_dependencies.push(UnusedDependency {
751 package_name: "dep".to_string(),
752 location: DependencyLocation::Dependencies,
753 path: PathBuf::from("package.json"),
754 line: 5,
755 });
756 results.unused_dev_dependencies.push(UnusedDependency {
757 package_name: "dev".to_string(),
758 location: DependencyLocation::DevDependencies,
759 path: PathBuf::from("package.json"),
760 line: 5,
761 });
762 results.unused_enum_members.push(UnusedMember {
763 path: PathBuf::from("d.ts"),
764 parent_name: "E".to_string(),
765 member_name: "A".to_string(),
766 kind: MemberKind::EnumMember,
767 line: 1,
768 col: 0,
769 });
770 results.unused_class_members.push(UnusedMember {
771 path: PathBuf::from("e.ts"),
772 parent_name: "C".to_string(),
773 member_name: "m".to_string(),
774 kind: MemberKind::ClassMethod,
775 line: 1,
776 col: 0,
777 });
778 results.unresolved_imports.push(UnresolvedImport {
779 path: PathBuf::from("f.ts"),
780 specifier: "./missing".to_string(),
781 line: 1,
782 col: 0,
783 specifier_col: 0,
784 });
785 results.unlisted_dependencies.push(UnlistedDependency {
786 package_name: "unlisted".to_string(),
787 imported_from: vec![ImportSite {
788 path: PathBuf::from("g.ts"),
789 line: 1,
790 col: 0,
791 }],
792 });
793 results.duplicate_exports.push(DuplicateExport {
794 export_name: "dup".to_string(),
795 locations: vec![
796 DuplicateLocation {
797 path: PathBuf::from("h.ts"),
798 line: 15,
799 col: 0,
800 },
801 DuplicateLocation {
802 path: PathBuf::from("i.ts"),
803 line: 30,
804 col: 0,
805 },
806 ],
807 });
808 results.unused_optional_dependencies.push(UnusedDependency {
809 package_name: "optional".to_string(),
810 location: DependencyLocation::OptionalDependencies,
811 path: PathBuf::from("package.json"),
812 line: 5,
813 });
814 results.type_only_dependencies.push(TypeOnlyDependency {
815 package_name: "type-only".to_string(),
816 path: PathBuf::from("package.json"),
817 line: 8,
818 });
819 results.test_only_dependencies.push(TestOnlyDependency {
820 package_name: "test-only".to_string(),
821 path: PathBuf::from("package.json"),
822 line: 9,
823 });
824 results.circular_dependencies.push(CircularDependency {
825 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
826 length: 2,
827 line: 3,
828 col: 0,
829 is_cross_package: false,
830 });
831 results.boundary_violations.push(BoundaryViolation {
832 from_path: PathBuf::from("src/ui/Button.tsx"),
833 to_path: PathBuf::from("src/db/queries.ts"),
834 from_zone: "ui".to_string(),
835 to_zone: "database".to_string(),
836 import_specifier: "../db/queries".to_string(),
837 line: 3,
838 col: 0,
839 });
840
841 assert_eq!(results.total_issues(), 15);
843 assert!(results.has_issues());
844 }
845
846 #[test]
849 fn total_issues_and_has_issues_are_consistent() {
850 let results = AnalysisResults::default();
851 assert_eq!(results.total_issues(), 0);
852 assert!(!results.has_issues());
853 assert_eq!(results.total_issues() > 0, results.has_issues());
854 }
855
856 #[test]
859 fn total_issues_sums_all_categories_independently() {
860 let mut results = AnalysisResults::default();
861 results.unused_files.push(UnusedFile {
862 path: PathBuf::from("a.ts"),
863 });
864 assert_eq!(results.total_issues(), 1);
865
866 results.unused_files.push(UnusedFile {
867 path: PathBuf::from("b.ts"),
868 });
869 assert_eq!(results.total_issues(), 2);
870
871 results.unresolved_imports.push(UnresolvedImport {
872 path: PathBuf::from("c.ts"),
873 specifier: "./missing".to_string(),
874 line: 1,
875 col: 0,
876 specifier_col: 0,
877 });
878 assert_eq!(results.total_issues(), 3);
879 }
880
881 #[test]
884 fn default_results_all_fields_empty() {
885 let r = AnalysisResults::default();
886 assert!(r.unused_files.is_empty());
887 assert!(r.unused_exports.is_empty());
888 assert!(r.unused_types.is_empty());
889 assert!(r.unused_dependencies.is_empty());
890 assert!(r.unused_dev_dependencies.is_empty());
891 assert!(r.unused_optional_dependencies.is_empty());
892 assert!(r.unused_enum_members.is_empty());
893 assert!(r.unused_class_members.is_empty());
894 assert!(r.unresolved_imports.is_empty());
895 assert!(r.unlisted_dependencies.is_empty());
896 assert!(r.duplicate_exports.is_empty());
897 assert!(r.type_only_dependencies.is_empty());
898 assert!(r.test_only_dependencies.is_empty());
899 assert!(r.circular_dependencies.is_empty());
900 assert!(r.boundary_violations.is_empty());
901 assert!(r.export_usages.is_empty());
902 }
903
904 #[test]
907 fn entry_point_summary_default() {
908 let summary = EntryPointSummary::default();
909 assert_eq!(summary.total, 0);
910 assert!(summary.by_source.is_empty());
911 }
912
913 #[test]
914 fn entry_point_summary_not_in_default_results() {
915 let r = AnalysisResults::default();
916 assert!(r.entry_point_summary.is_none());
917 }
918
919 #[test]
920 fn entry_point_summary_some_preserves_data() {
921 let r = AnalysisResults {
922 entry_point_summary: Some(EntryPointSummary {
923 total: 5,
924 by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
925 }),
926 ..AnalysisResults::default()
927 };
928 let summary = r.entry_point_summary.as_ref().unwrap();
929 assert_eq!(summary.total, 5);
930 assert_eq!(summary.by_source.len(), 2);
931 assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
932 }
933
934 #[test]
937 fn sort_unused_files_by_path() {
938 let mut r = AnalysisResults::default();
939 r.unused_files.push(UnusedFile {
940 path: PathBuf::from("z.ts"),
941 });
942 r.unused_files.push(UnusedFile {
943 path: PathBuf::from("a.ts"),
944 });
945 r.unused_files.push(UnusedFile {
946 path: PathBuf::from("m.ts"),
947 });
948 r.sort();
949 let paths: Vec<_> = r
950 .unused_files
951 .iter()
952 .map(|f| f.path.to_string_lossy().to_string())
953 .collect();
954 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
955 }
956
957 #[test]
960 fn sort_unused_exports_by_path_line_name() {
961 let mut r = AnalysisResults::default();
962 let mk = |path: &str, line: u32, name: &str| UnusedExport {
963 path: PathBuf::from(path),
964 export_name: name.to_string(),
965 is_type_only: false,
966 line,
967 col: 0,
968 span_start: 0,
969 is_re_export: false,
970 };
971 r.unused_exports.push(mk("b.ts", 5, "beta"));
972 r.unused_exports.push(mk("a.ts", 10, "zeta"));
973 r.unused_exports.push(mk("a.ts", 10, "alpha"));
974 r.unused_exports.push(mk("a.ts", 1, "gamma"));
975 r.sort();
976 let keys: Vec<_> = r
977 .unused_exports
978 .iter()
979 .map(|e| format!("{}:{}:{}", e.path.to_string_lossy(), e.line, e.export_name))
980 .collect();
981 assert_eq!(
982 keys,
983 vec![
984 "a.ts:1:gamma",
985 "a.ts:10:alpha",
986 "a.ts:10:zeta",
987 "b.ts:5:beta"
988 ]
989 );
990 }
991
992 #[test]
995 fn sort_unused_types_by_path_line_name() {
996 let mut r = AnalysisResults::default();
997 let mk = |path: &str, line: u32, name: &str| UnusedExport {
998 path: PathBuf::from(path),
999 export_name: name.to_string(),
1000 is_type_only: true,
1001 line,
1002 col: 0,
1003 span_start: 0,
1004 is_re_export: false,
1005 };
1006 r.unused_types.push(mk("z.ts", 1, "Z"));
1007 r.unused_types.push(mk("a.ts", 1, "A"));
1008 r.sort();
1009 assert_eq!(r.unused_types[0].path, PathBuf::from("a.ts"));
1010 assert_eq!(r.unused_types[1].path, PathBuf::from("z.ts"));
1011 }
1012
1013 #[test]
1016 fn sort_unused_dependencies_by_path_line_name() {
1017 let mut r = AnalysisResults::default();
1018 let mk = |path: &str, line: u32, name: &str| UnusedDependency {
1019 package_name: name.to_string(),
1020 location: DependencyLocation::Dependencies,
1021 path: PathBuf::from(path),
1022 line,
1023 };
1024 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
1025 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
1026 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
1027 r.sort();
1028 let names: Vec<_> = r
1029 .unused_dependencies
1030 .iter()
1031 .map(|d| d.package_name.as_str())
1032 .collect();
1033 assert_eq!(names, vec!["axios", "react", "zlib"]);
1034 }
1035
1036 #[test]
1039 fn sort_unused_dev_dependencies() {
1040 let mut r = AnalysisResults::default();
1041 r.unused_dev_dependencies.push(UnusedDependency {
1042 package_name: "vitest".to_string(),
1043 location: DependencyLocation::DevDependencies,
1044 path: PathBuf::from("package.json"),
1045 line: 10,
1046 });
1047 r.unused_dev_dependencies.push(UnusedDependency {
1048 package_name: "jest".to_string(),
1049 location: DependencyLocation::DevDependencies,
1050 path: PathBuf::from("package.json"),
1051 line: 5,
1052 });
1053 r.sort();
1054 assert_eq!(r.unused_dev_dependencies[0].package_name, "jest");
1055 assert_eq!(r.unused_dev_dependencies[1].package_name, "vitest");
1056 }
1057
1058 #[test]
1061 fn sort_unused_optional_dependencies() {
1062 let mut r = AnalysisResults::default();
1063 r.unused_optional_dependencies.push(UnusedDependency {
1064 package_name: "zod".to_string(),
1065 location: DependencyLocation::OptionalDependencies,
1066 path: PathBuf::from("package.json"),
1067 line: 3,
1068 });
1069 r.unused_optional_dependencies.push(UnusedDependency {
1070 package_name: "ajv".to_string(),
1071 location: DependencyLocation::OptionalDependencies,
1072 path: PathBuf::from("package.json"),
1073 line: 2,
1074 });
1075 r.sort();
1076 assert_eq!(r.unused_optional_dependencies[0].package_name, "ajv");
1077 assert_eq!(r.unused_optional_dependencies[1].package_name, "zod");
1078 }
1079
1080 #[test]
1083 fn sort_unused_enum_members_by_path_line_parent_member() {
1084 let mut r = AnalysisResults::default();
1085 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1086 path: PathBuf::from(path),
1087 parent_name: parent.to_string(),
1088 member_name: member.to_string(),
1089 kind: MemberKind::EnumMember,
1090 line,
1091 col: 0,
1092 };
1093 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
1094 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
1095 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
1096 r.sort();
1097 let keys: Vec<_> = r
1098 .unused_enum_members
1099 .iter()
1100 .map(|m| format!("{}:{}", m.parent_name, m.member_name))
1101 .collect();
1102 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
1103 }
1104
1105 #[test]
1108 fn sort_unused_class_members() {
1109 let mut r = AnalysisResults::default();
1110 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1111 path: PathBuf::from(path),
1112 parent_name: parent.to_string(),
1113 member_name: member.to_string(),
1114 kind: MemberKind::ClassMethod,
1115 line,
1116 col: 0,
1117 };
1118 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1119 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1120 r.sort();
1121 assert_eq!(r.unused_class_members[0].path, PathBuf::from("a.ts"));
1122 assert_eq!(r.unused_class_members[1].path, PathBuf::from("b.ts"));
1123 }
1124
1125 #[test]
1128 fn sort_unresolved_imports_by_path_line_col_specifier() {
1129 let mut r = AnalysisResults::default();
1130 let mk = |path: &str, line: u32, col: u32, spec: &str| UnresolvedImport {
1131 path: PathBuf::from(path),
1132 specifier: spec.to_string(),
1133 line,
1134 col,
1135 specifier_col: 0,
1136 };
1137 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1138 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1139 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1140 r.sort();
1141 let specs: Vec<_> = r
1142 .unresolved_imports
1143 .iter()
1144 .map(|i| i.specifier.as_str())
1145 .collect();
1146 assert_eq!(specs, vec!["./m", "./a", "./z"]);
1147 }
1148
1149 #[test]
1152 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1153 let mut r = AnalysisResults::default();
1154 r.unlisted_dependencies.push(UnlistedDependency {
1155 package_name: "zod".to_string(),
1156 imported_from: vec![
1157 ImportSite {
1158 path: PathBuf::from("b.ts"),
1159 line: 10,
1160 col: 0,
1161 },
1162 ImportSite {
1163 path: PathBuf::from("a.ts"),
1164 line: 1,
1165 col: 0,
1166 },
1167 ],
1168 });
1169 r.unlisted_dependencies.push(UnlistedDependency {
1170 package_name: "axios".to_string(),
1171 imported_from: vec![ImportSite {
1172 path: PathBuf::from("c.ts"),
1173 line: 1,
1174 col: 0,
1175 }],
1176 });
1177 r.sort();
1178
1179 assert_eq!(r.unlisted_dependencies[0].package_name, "axios");
1181 assert_eq!(r.unlisted_dependencies[1].package_name, "zod");
1182
1183 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1185 .imported_from
1186 .iter()
1187 .map(|s| s.path.to_string_lossy().to_string())
1188 .collect();
1189 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1190 }
1191
1192 #[test]
1195 fn sort_duplicate_exports_by_name_and_inner_locations() {
1196 let mut r = AnalysisResults::default();
1197 r.duplicate_exports.push(DuplicateExport {
1198 export_name: "z".to_string(),
1199 locations: vec![
1200 DuplicateLocation {
1201 path: PathBuf::from("c.ts"),
1202 line: 1,
1203 col: 0,
1204 },
1205 DuplicateLocation {
1206 path: PathBuf::from("a.ts"),
1207 line: 5,
1208 col: 0,
1209 },
1210 ],
1211 });
1212 r.duplicate_exports.push(DuplicateExport {
1213 export_name: "a".to_string(),
1214 locations: vec![DuplicateLocation {
1215 path: PathBuf::from("b.ts"),
1216 line: 1,
1217 col: 0,
1218 }],
1219 });
1220 r.sort();
1221
1222 assert_eq!(r.duplicate_exports[0].export_name, "a");
1224 assert_eq!(r.duplicate_exports[1].export_name, "z");
1225
1226 let z_locs: Vec<_> = r.duplicate_exports[1]
1228 .locations
1229 .iter()
1230 .map(|l| l.path.to_string_lossy().to_string())
1231 .collect();
1232 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1233 }
1234
1235 #[test]
1238 fn sort_type_only_dependencies() {
1239 let mut r = AnalysisResults::default();
1240 r.type_only_dependencies.push(TypeOnlyDependency {
1241 package_name: "zod".to_string(),
1242 path: PathBuf::from("package.json"),
1243 line: 10,
1244 });
1245 r.type_only_dependencies.push(TypeOnlyDependency {
1246 package_name: "ajv".to_string(),
1247 path: PathBuf::from("package.json"),
1248 line: 5,
1249 });
1250 r.sort();
1251 assert_eq!(r.type_only_dependencies[0].package_name, "ajv");
1252 assert_eq!(r.type_only_dependencies[1].package_name, "zod");
1253 }
1254
1255 #[test]
1258 fn sort_test_only_dependencies() {
1259 let mut r = AnalysisResults::default();
1260 r.test_only_dependencies.push(TestOnlyDependency {
1261 package_name: "vitest".to_string(),
1262 path: PathBuf::from("package.json"),
1263 line: 15,
1264 });
1265 r.test_only_dependencies.push(TestOnlyDependency {
1266 package_name: "jest".to_string(),
1267 path: PathBuf::from("package.json"),
1268 line: 10,
1269 });
1270 r.sort();
1271 assert_eq!(r.test_only_dependencies[0].package_name, "jest");
1272 assert_eq!(r.test_only_dependencies[1].package_name, "vitest");
1273 }
1274
1275 #[test]
1278 fn sort_circular_dependencies_by_files_then_length() {
1279 let mut r = AnalysisResults::default();
1280 r.circular_dependencies.push(CircularDependency {
1281 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1282 length: 2,
1283 line: 1,
1284 col: 0,
1285 is_cross_package: false,
1286 });
1287 r.circular_dependencies.push(CircularDependency {
1288 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1289 length: 2,
1290 line: 1,
1291 col: 0,
1292 is_cross_package: true,
1293 });
1294 r.sort();
1295 assert_eq!(r.circular_dependencies[0].files[0], PathBuf::from("a.ts"));
1296 assert_eq!(r.circular_dependencies[1].files[0], PathBuf::from("b.ts"));
1297 }
1298
1299 #[test]
1302 fn sort_boundary_violations() {
1303 let mut r = AnalysisResults::default();
1304 let mk = |from: &str, line: u32, col: u32, to: &str| BoundaryViolation {
1305 from_path: PathBuf::from(from),
1306 to_path: PathBuf::from(to),
1307 from_zone: "a".to_string(),
1308 to_zone: "b".to_string(),
1309 import_specifier: to.to_string(),
1310 line,
1311 col,
1312 };
1313 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
1314 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
1315 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
1316 r.sort();
1317 let from_paths: Vec<_> = r
1318 .boundary_violations
1319 .iter()
1320 .map(|v| format!("{}:{}", v.from_path.to_string_lossy(), v.line))
1321 .collect();
1322 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
1323 }
1324
1325 #[test]
1328 fn sort_export_usages_and_inner_reference_locations() {
1329 let mut r = AnalysisResults::default();
1330 r.export_usages.push(ExportUsage {
1331 path: PathBuf::from("z.ts"),
1332 export_name: "foo".to_string(),
1333 line: 1,
1334 col: 0,
1335 reference_count: 2,
1336 reference_locations: vec![
1337 ReferenceLocation {
1338 path: PathBuf::from("c.ts"),
1339 line: 10,
1340 col: 0,
1341 },
1342 ReferenceLocation {
1343 path: PathBuf::from("a.ts"),
1344 line: 5,
1345 col: 0,
1346 },
1347 ],
1348 });
1349 r.export_usages.push(ExportUsage {
1350 path: PathBuf::from("a.ts"),
1351 export_name: "bar".to_string(),
1352 line: 1,
1353 col: 0,
1354 reference_count: 1,
1355 reference_locations: vec![ReferenceLocation {
1356 path: PathBuf::from("b.ts"),
1357 line: 1,
1358 col: 0,
1359 }],
1360 });
1361 r.sort();
1362
1363 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
1365 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
1366
1367 let refs: Vec<_> = r.export_usages[1]
1369 .reference_locations
1370 .iter()
1371 .map(|l| l.path.to_string_lossy().to_string())
1372 .collect();
1373 assert_eq!(refs, vec!["a.ts", "c.ts"]);
1374 }
1375
1376 #[test]
1379 fn sort_empty_results_is_noop() {
1380 let mut r = AnalysisResults::default();
1381 r.sort(); assert_eq!(r.total_issues(), 0);
1383 }
1384
1385 #[test]
1388 fn sort_single_element_lists_stable() {
1389 let mut r = AnalysisResults::default();
1390 r.unused_files.push(UnusedFile {
1391 path: PathBuf::from("only.ts"),
1392 });
1393 r.sort();
1394 assert_eq!(r.unused_files[0].path, PathBuf::from("only.ts"));
1395 }
1396
1397 #[test]
1400 fn serialize_empty_results() {
1401 let r = AnalysisResults::default();
1402 let json = serde_json::to_value(&r).unwrap();
1403
1404 assert!(json["unused_files"].as_array().unwrap().is_empty());
1406 assert!(json["unused_exports"].as_array().unwrap().is_empty());
1407 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
1408
1409 assert!(json.get("export_usages").is_none());
1411 assert!(json.get("entry_point_summary").is_none());
1412 }
1413
1414 #[test]
1415 fn serialize_unused_file_path() {
1416 let r = UnusedFile {
1417 path: PathBuf::from("src/utils/index.ts"),
1418 };
1419 let json = serde_json::to_value(&r).unwrap();
1420 assert_eq!(json["path"], "src/utils/index.ts");
1421 }
1422
1423 #[test]
1424 fn serialize_dependency_location_camel_case() {
1425 let dep = UnusedDependency {
1426 package_name: "react".to_string(),
1427 location: DependencyLocation::DevDependencies,
1428 path: PathBuf::from("package.json"),
1429 line: 5,
1430 };
1431 let json = serde_json::to_value(&dep).unwrap();
1432 assert_eq!(json["location"], "devDependencies");
1433
1434 let dep2 = UnusedDependency {
1435 package_name: "react".to_string(),
1436 location: DependencyLocation::Dependencies,
1437 path: PathBuf::from("package.json"),
1438 line: 3,
1439 };
1440 let json2 = serde_json::to_value(&dep2).unwrap();
1441 assert_eq!(json2["location"], "dependencies");
1442
1443 let dep3 = UnusedDependency {
1444 package_name: "fsevents".to_string(),
1445 location: DependencyLocation::OptionalDependencies,
1446 path: PathBuf::from("package.json"),
1447 line: 7,
1448 };
1449 let json3 = serde_json::to_value(&dep3).unwrap();
1450 assert_eq!(json3["location"], "optionalDependencies");
1451 }
1452
1453 #[test]
1454 fn serialize_circular_dependency_skips_false_cross_package() {
1455 let cd = CircularDependency {
1456 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1457 length: 2,
1458 line: 1,
1459 col: 0,
1460 is_cross_package: false,
1461 };
1462 let json = serde_json::to_value(&cd).unwrap();
1463 assert!(json.get("is_cross_package").is_none());
1465 }
1466
1467 #[test]
1468 fn serialize_circular_dependency_includes_true_cross_package() {
1469 let cd = CircularDependency {
1470 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1471 length: 2,
1472 line: 1,
1473 col: 0,
1474 is_cross_package: true,
1475 };
1476 let json = serde_json::to_value(&cd).unwrap();
1477 assert_eq!(json["is_cross_package"], true);
1478 }
1479
1480 #[test]
1481 fn serialize_unused_export_fields() {
1482 let e = UnusedExport {
1483 path: PathBuf::from("src/mod.ts"),
1484 export_name: "helper".to_string(),
1485 is_type_only: true,
1486 line: 42,
1487 col: 7,
1488 span_start: 100,
1489 is_re_export: true,
1490 };
1491 let json = serde_json::to_value(&e).unwrap();
1492 assert_eq!(json["path"], "src/mod.ts");
1493 assert_eq!(json["export_name"], "helper");
1494 assert_eq!(json["is_type_only"], true);
1495 assert_eq!(json["line"], 42);
1496 assert_eq!(json["col"], 7);
1497 assert_eq!(json["span_start"], 100);
1498 assert_eq!(json["is_re_export"], true);
1499 }
1500
1501 #[test]
1502 fn serialize_boundary_violation_fields() {
1503 let v = BoundaryViolation {
1504 from_path: PathBuf::from("src/ui/button.tsx"),
1505 to_path: PathBuf::from("src/db/queries.ts"),
1506 from_zone: "ui".to_string(),
1507 to_zone: "db".to_string(),
1508 import_specifier: "../db/queries".to_string(),
1509 line: 3,
1510 col: 0,
1511 };
1512 let json = serde_json::to_value(&v).unwrap();
1513 assert_eq!(json["from_path"], "src/ui/button.tsx");
1514 assert_eq!(json["to_path"], "src/db/queries.ts");
1515 assert_eq!(json["from_zone"], "ui");
1516 assert_eq!(json["to_zone"], "db");
1517 assert_eq!(json["import_specifier"], "../db/queries");
1518 }
1519
1520 #[test]
1521 fn serialize_unlisted_dependency_with_import_sites() {
1522 let d = UnlistedDependency {
1523 package_name: "chalk".to_string(),
1524 imported_from: vec![
1525 ImportSite {
1526 path: PathBuf::from("a.ts"),
1527 line: 1,
1528 col: 0,
1529 },
1530 ImportSite {
1531 path: PathBuf::from("b.ts"),
1532 line: 5,
1533 col: 3,
1534 },
1535 ],
1536 };
1537 let json = serde_json::to_value(&d).unwrap();
1538 assert_eq!(json["package_name"], "chalk");
1539 let sites = json["imported_from"].as_array().unwrap();
1540 assert_eq!(sites.len(), 2);
1541 assert_eq!(sites[0]["path"], "a.ts");
1542 assert_eq!(sites[1]["line"], 5);
1543 }
1544
1545 #[test]
1546 fn serialize_duplicate_export_with_locations() {
1547 let d = DuplicateExport {
1548 export_name: "Button".to_string(),
1549 locations: vec![
1550 DuplicateLocation {
1551 path: PathBuf::from("src/a.ts"),
1552 line: 10,
1553 col: 0,
1554 },
1555 DuplicateLocation {
1556 path: PathBuf::from("src/b.ts"),
1557 line: 20,
1558 col: 5,
1559 },
1560 ],
1561 };
1562 let json = serde_json::to_value(&d).unwrap();
1563 assert_eq!(json["export_name"], "Button");
1564 let locs = json["locations"].as_array().unwrap();
1565 assert_eq!(locs.len(), 2);
1566 assert_eq!(locs[0]["line"], 10);
1567 assert_eq!(locs[1]["col"], 5);
1568 }
1569
1570 #[test]
1571 fn serialize_type_only_dependency() {
1572 let d = TypeOnlyDependency {
1573 package_name: "@types/react".to_string(),
1574 path: PathBuf::from("package.json"),
1575 line: 12,
1576 };
1577 let json = serde_json::to_value(&d).unwrap();
1578 assert_eq!(json["package_name"], "@types/react");
1579 assert_eq!(json["line"], 12);
1580 }
1581
1582 #[test]
1583 fn serialize_test_only_dependency() {
1584 let d = TestOnlyDependency {
1585 package_name: "vitest".to_string(),
1586 path: PathBuf::from("package.json"),
1587 line: 8,
1588 };
1589 let json = serde_json::to_value(&d).unwrap();
1590 assert_eq!(json["package_name"], "vitest");
1591 assert_eq!(json["line"], 8);
1592 }
1593
1594 #[test]
1595 fn serialize_unused_member() {
1596 let m = UnusedMember {
1597 path: PathBuf::from("enums.ts"),
1598 parent_name: "Status".to_string(),
1599 member_name: "Pending".to_string(),
1600 kind: MemberKind::EnumMember,
1601 line: 3,
1602 col: 4,
1603 };
1604 let json = serde_json::to_value(&m).unwrap();
1605 assert_eq!(json["parent_name"], "Status");
1606 assert_eq!(json["member_name"], "Pending");
1607 assert_eq!(json["line"], 3);
1608 }
1609
1610 #[test]
1611 fn serialize_unresolved_import() {
1612 let i = UnresolvedImport {
1613 path: PathBuf::from("app.ts"),
1614 specifier: "./missing-module".to_string(),
1615 line: 7,
1616 col: 0,
1617 specifier_col: 21,
1618 };
1619 let json = serde_json::to_value(&i).unwrap();
1620 assert_eq!(json["specifier"], "./missing-module");
1621 assert_eq!(json["specifier_col"], 21);
1622 }
1623
1624 #[test]
1627 fn deserialize_circular_dependency_with_defaults() {
1628 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
1630 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1631 assert_eq!(cd.files.len(), 2);
1632 assert_eq!(cd.length, 2);
1633 assert_eq!(cd.line, 0);
1634 assert_eq!(cd.col, 0);
1635 assert!(!cd.is_cross_package);
1636 }
1637
1638 #[test]
1639 fn deserialize_circular_dependency_with_all_fields() {
1640 let json =
1641 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
1642 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1643 assert_eq!(cd.line, 5);
1644 assert_eq!(cd.col, 10);
1645 assert!(cd.is_cross_package);
1646 }
1647
1648 #[test]
1651 fn clone_results_are_independent() {
1652 let mut r = AnalysisResults::default();
1653 r.unused_files.push(UnusedFile {
1654 path: PathBuf::from("a.ts"),
1655 });
1656 let mut cloned = r.clone();
1657 cloned.unused_files.push(UnusedFile {
1658 path: PathBuf::from("b.ts"),
1659 });
1660 assert_eq!(r.total_issues(), 1);
1661 assert_eq!(cloned.total_issues(), 2);
1662 }
1663
1664 #[test]
1667 fn export_usages_not_counted_in_total_issues() {
1668 let mut r = AnalysisResults::default();
1669 r.export_usages.push(ExportUsage {
1670 path: PathBuf::from("mod.ts"),
1671 export_name: "foo".to_string(),
1672 line: 1,
1673 col: 0,
1674 reference_count: 3,
1675 reference_locations: vec![],
1676 });
1677 assert_eq!(r.total_issues(), 0);
1679 assert!(!r.has_issues());
1680 }
1681
1682 #[test]
1685 fn entry_point_summary_not_counted_in_total_issues() {
1686 let r = AnalysisResults {
1687 entry_point_summary: Some(EntryPointSummary {
1688 total: 10,
1689 by_source: vec![("config".to_string(), 10)],
1690 }),
1691 ..AnalysisResults::default()
1692 };
1693 assert_eq!(r.total_issues(), 0);
1694 assert!(!r.has_issues());
1695 }
1696}