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 private_type_leaks: Vec<PrivateTypeLeak>,
52 pub unused_dependencies: Vec<UnusedDependency>,
54 pub unused_dev_dependencies: Vec<UnusedDependency>,
56 pub unused_optional_dependencies: Vec<UnusedDependency>,
58 pub unused_enum_members: Vec<UnusedMember>,
60 pub unused_class_members: Vec<UnusedMember>,
62 pub unresolved_imports: Vec<UnresolvedImport>,
64 pub unlisted_dependencies: Vec<UnlistedDependency>,
66 pub duplicate_exports: Vec<DuplicateExport>,
68 pub type_only_dependencies: Vec<TypeOnlyDependency>,
71 #[serde(default)]
73 pub test_only_dependencies: Vec<TestOnlyDependency>,
74 pub circular_dependencies: Vec<CircularDependency>,
76 #[serde(default)]
78 pub boundary_violations: Vec<BoundaryViolation>,
79 #[serde(default)]
81 pub stale_suppressions: Vec<StaleSuppression>,
82 #[serde(default)]
84 pub unused_catalog_entries: Vec<UnusedCatalogEntry>,
85 #[serde(default)]
87 pub empty_catalog_groups: Vec<EmptyCatalogGroup>,
88 #[serde(default)]
90 pub unresolved_catalog_references: Vec<UnresolvedCatalogReference>,
91 #[serde(default)]
94 pub unused_dependency_overrides: Vec<UnusedDependencyOverride>,
95 #[serde(default)]
97 pub misconfigured_dependency_overrides: Vec<MisconfiguredDependencyOverride>,
98 #[serde(skip)]
102 pub suppression_count: usize,
103 #[serde(skip)]
106 pub feature_flags: Vec<FeatureFlag>,
107 #[serde(skip)]
111 pub export_usages: Vec<ExportUsage>,
112 #[serde(skip)]
116 pub entry_point_summary: Option<EntryPointSummary>,
117}
118
119impl AnalysisResults {
120 #[must_use]
144 pub const fn total_issues(&self) -> usize {
145 self.unused_files.len()
146 + self.unused_exports.len()
147 + self.unused_types.len()
148 + self.private_type_leaks.len()
149 + self.unused_dependencies.len()
150 + self.unused_dev_dependencies.len()
151 + self.unused_optional_dependencies.len()
152 + self.unused_enum_members.len()
153 + self.unused_class_members.len()
154 + self.unresolved_imports.len()
155 + self.unlisted_dependencies.len()
156 + self.duplicate_exports.len()
157 + self.type_only_dependencies.len()
158 + self.test_only_dependencies.len()
159 + self.circular_dependencies.len()
160 + self.boundary_violations.len()
161 + self.stale_suppressions.len()
162 + self.unused_catalog_entries.len()
163 + self.empty_catalog_groups.len()
164 + self.unresolved_catalog_references.len()
165 + self.unused_dependency_overrides.len()
166 + self.misconfigured_dependency_overrides.len()
167 }
168
169 #[must_use]
171 pub const fn has_issues(&self) -> bool {
172 self.total_issues() > 0
173 }
174
175 #[expect(
182 clippy::too_many_lines,
183 reason = "one short sort_by per result array; splitting would add indirection without clarity"
184 )]
185 pub fn sort(&mut self) {
186 self.unused_files.sort_by(|a, b| a.path.cmp(&b.path));
187
188 self.unused_exports.sort_by(|a, b| {
189 a.path
190 .cmp(&b.path)
191 .then(a.line.cmp(&b.line))
192 .then(a.export_name.cmp(&b.export_name))
193 });
194
195 self.unused_types.sort_by(|a, b| {
196 a.path
197 .cmp(&b.path)
198 .then(a.line.cmp(&b.line))
199 .then(a.export_name.cmp(&b.export_name))
200 });
201
202 self.private_type_leaks.sort_by(|a, b| {
203 a.path
204 .cmp(&b.path)
205 .then(a.line.cmp(&b.line))
206 .then(a.export_name.cmp(&b.export_name))
207 .then(a.type_name.cmp(&b.type_name))
208 });
209
210 self.unused_dependencies.sort_by(|a, b| {
211 a.path
212 .cmp(&b.path)
213 .then(a.line.cmp(&b.line))
214 .then(a.package_name.cmp(&b.package_name))
215 });
216
217 self.unused_dev_dependencies.sort_by(|a, b| {
218 a.path
219 .cmp(&b.path)
220 .then(a.line.cmp(&b.line))
221 .then(a.package_name.cmp(&b.package_name))
222 });
223
224 self.unused_optional_dependencies.sort_by(|a, b| {
225 a.path
226 .cmp(&b.path)
227 .then(a.line.cmp(&b.line))
228 .then(a.package_name.cmp(&b.package_name))
229 });
230
231 self.unused_enum_members.sort_by(|a, b| {
232 a.path
233 .cmp(&b.path)
234 .then(a.line.cmp(&b.line))
235 .then(a.parent_name.cmp(&b.parent_name))
236 .then(a.member_name.cmp(&b.member_name))
237 });
238
239 self.unused_class_members.sort_by(|a, b| {
240 a.path
241 .cmp(&b.path)
242 .then(a.line.cmp(&b.line))
243 .then(a.parent_name.cmp(&b.parent_name))
244 .then(a.member_name.cmp(&b.member_name))
245 });
246
247 self.unresolved_imports.sort_by(|a, b| {
248 a.path
249 .cmp(&b.path)
250 .then(a.line.cmp(&b.line))
251 .then(a.col.cmp(&b.col))
252 .then(a.specifier.cmp(&b.specifier))
253 });
254
255 self.unlisted_dependencies
256 .sort_by(|a, b| a.package_name.cmp(&b.package_name));
257 for dep in &mut self.unlisted_dependencies {
258 dep.imported_from
259 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
260 }
261
262 self.duplicate_exports
263 .sort_by(|a, b| a.export_name.cmp(&b.export_name));
264 for dup in &mut self.duplicate_exports {
265 dup.locations
266 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
267 }
268
269 self.type_only_dependencies.sort_by(|a, b| {
270 a.path
271 .cmp(&b.path)
272 .then(a.line.cmp(&b.line))
273 .then(a.package_name.cmp(&b.package_name))
274 });
275
276 self.test_only_dependencies.sort_by(|a, b| {
277 a.path
278 .cmp(&b.path)
279 .then(a.line.cmp(&b.line))
280 .then(a.package_name.cmp(&b.package_name))
281 });
282
283 self.circular_dependencies
284 .sort_by(|a, b| a.files.cmp(&b.files).then(a.length.cmp(&b.length)));
285
286 self.boundary_violations.sort_by(|a, b| {
287 a.from_path
288 .cmp(&b.from_path)
289 .then(a.line.cmp(&b.line))
290 .then(a.col.cmp(&b.col))
291 .then(a.to_path.cmp(&b.to_path))
292 });
293
294 self.stale_suppressions.sort_by(|a, b| {
295 a.path
296 .cmp(&b.path)
297 .then(a.line.cmp(&b.line))
298 .then(a.col.cmp(&b.col))
299 });
300
301 self.unused_catalog_entries.sort_by(|a, b| {
302 a.path
303 .cmp(&b.path)
304 .then_with(|| {
305 catalog_sort_key(&a.catalog_name).cmp(&catalog_sort_key(&b.catalog_name))
306 })
307 .then(a.catalog_name.cmp(&b.catalog_name))
308 .then(a.entry_name.cmp(&b.entry_name))
309 });
310 for entry in &mut self.unused_catalog_entries {
311 entry.hardcoded_consumers.sort();
312 entry.hardcoded_consumers.dedup();
313 }
314
315 self.empty_catalog_groups.sort_by(|a, b| {
316 a.path
317 .cmp(&b.path)
318 .then_with(|| {
319 catalog_sort_key(&a.catalog_name).cmp(&catalog_sort_key(&b.catalog_name))
320 })
321 .then(a.catalog_name.cmp(&b.catalog_name))
322 .then(a.line.cmp(&b.line))
323 });
324
325 self.unresolved_catalog_references.sort_by(|a, b| {
326 a.path
327 .cmp(&b.path)
328 .then(a.line.cmp(&b.line))
329 .then_with(|| {
330 catalog_sort_key(&a.catalog_name).cmp(&catalog_sort_key(&b.catalog_name))
331 })
332 .then(a.catalog_name.cmp(&b.catalog_name))
333 .then(a.entry_name.cmp(&b.entry_name))
334 });
335 for finding in &mut self.unresolved_catalog_references {
336 finding.available_in_catalogs.sort();
337 finding.available_in_catalogs.dedup();
338 }
339
340 self.unused_dependency_overrides.sort_by(|a, b| {
341 a.path
342 .cmp(&b.path)
343 .then(a.line.cmp(&b.line))
344 .then(a.raw_key.cmp(&b.raw_key))
345 });
346
347 self.misconfigured_dependency_overrides.sort_by(|a, b| {
348 a.path
349 .cmp(&b.path)
350 .then(a.line.cmp(&b.line))
351 .then(a.raw_key.cmp(&b.raw_key))
352 });
353
354 self.feature_flags.sort_by(|a, b| {
355 a.path
356 .cmp(&b.path)
357 .then(a.line.cmp(&b.line))
358 .then(a.flag_name.cmp(&b.flag_name))
359 });
360
361 for usage in &mut self.export_usages {
362 usage.reference_locations.sort_by(|a, b| {
363 a.path
364 .cmp(&b.path)
365 .then(a.line.cmp(&b.line))
366 .then(a.col.cmp(&b.col))
367 });
368 }
369 self.export_usages.sort_by(|a, b| {
370 a.path
371 .cmp(&b.path)
372 .then(a.line.cmp(&b.line))
373 .then(a.export_name.cmp(&b.export_name))
374 });
375 }
376}
377
378fn catalog_sort_key(name: &str) -> (u8, &str) {
380 if name == "default" {
381 (0, name)
382 } else {
383 (1, name)
384 }
385}
386
387#[derive(Debug, Clone, Serialize)]
389pub struct UnusedFile {
390 #[serde(serialize_with = "serde_path::serialize")]
392 pub path: PathBuf,
393}
394
395#[derive(Debug, Clone, Serialize)]
397pub struct UnusedExport {
398 #[serde(serialize_with = "serde_path::serialize")]
400 pub path: PathBuf,
401 pub export_name: String,
403 pub is_type_only: bool,
405 pub line: u32,
407 pub col: u32,
409 pub span_start: u32,
411 pub is_re_export: bool,
413}
414
415#[derive(Debug, Clone, Serialize)]
417pub struct PrivateTypeLeak {
418 #[serde(serialize_with = "serde_path::serialize")]
420 pub path: PathBuf,
421 pub export_name: String,
423 pub type_name: String,
425 pub line: u32,
427 pub col: u32,
429 pub span_start: u32,
431}
432
433#[derive(Debug, Clone, Serialize)]
435pub struct UnusedDependency {
436 pub package_name: String,
438 pub location: DependencyLocation,
440 #[serde(serialize_with = "serde_path::serialize")]
443 pub path: PathBuf,
444 pub line: u32,
446 #[serde(
448 serialize_with = "serde_path::serialize_vec",
449 skip_serializing_if = "Vec::is_empty"
450 )]
451 pub used_in_workspaces: Vec<PathBuf>,
452}
453
454#[derive(Debug, Clone, Serialize)]
471#[serde(rename_all = "camelCase")]
472pub enum DependencyLocation {
473 Dependencies,
475 DevDependencies,
477 OptionalDependencies,
479}
480
481#[derive(Debug, Clone, Serialize)]
483pub struct UnusedMember {
484 #[serde(serialize_with = "serde_path::serialize")]
486 pub path: PathBuf,
487 pub parent_name: String,
489 pub member_name: String,
491 pub kind: MemberKind,
493 pub line: u32,
495 pub col: u32,
497}
498
499#[derive(Debug, Clone, Serialize)]
501pub struct UnresolvedImport {
502 #[serde(serialize_with = "serde_path::serialize")]
504 pub path: PathBuf,
505 pub specifier: String,
507 pub line: u32,
509 pub col: u32,
511 pub specifier_col: u32,
514}
515
516#[derive(Debug, Clone, Serialize)]
518pub struct UnlistedDependency {
519 pub package_name: String,
521 pub imported_from: Vec<ImportSite>,
523}
524
525#[derive(Debug, Clone, Serialize)]
527pub struct ImportSite {
528 #[serde(serialize_with = "serde_path::serialize")]
530 pub path: PathBuf,
531 pub line: u32,
533 pub col: u32,
535}
536
537#[derive(Debug, Clone, Serialize)]
539pub struct DuplicateExport {
540 pub export_name: String,
542 pub locations: Vec<DuplicateLocation>,
544}
545
546#[derive(Debug, Clone, Serialize)]
548pub struct DuplicateLocation {
549 #[serde(serialize_with = "serde_path::serialize")]
551 pub path: PathBuf,
552 pub line: u32,
554 pub col: u32,
556}
557
558#[derive(Debug, Clone, Serialize)]
562pub struct TypeOnlyDependency {
563 pub package_name: String,
565 #[serde(serialize_with = "serde_path::serialize")]
567 pub path: PathBuf,
568 pub line: u32,
570}
571
572#[derive(Debug, Clone, Serialize)]
578pub struct UnusedCatalogEntry {
579 pub entry_name: String,
581 pub catalog_name: String,
584 #[serde(serialize_with = "serde_path::serialize")]
586 pub path: PathBuf,
587 pub line: u32,
589 #[serde(
594 default,
595 serialize_with = "serde_path::serialize_vec",
596 skip_serializing_if = "Vec::is_empty"
597 )]
598 pub hardcoded_consumers: Vec<PathBuf>,
599}
600
601#[derive(Debug, Clone, Serialize)]
603pub struct EmptyCatalogGroup {
604 pub catalog_name: String,
606 #[serde(serialize_with = "serde_path::serialize")]
608 pub path: PathBuf,
609 pub line: u32,
611}
612
613#[derive(Debug, Clone, Serialize)]
624pub struct UnresolvedCatalogReference {
625 pub entry_name: String,
627 pub catalog_name: String,
630 #[serde(serialize_with = "serde_path::serialize")]
637 pub path: PathBuf,
638 pub line: u32,
640 #[serde(default, skip_serializing_if = "Vec::is_empty")]
645 pub available_in_catalogs: Vec<String>,
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
652pub enum DependencyOverrideSource {
653 #[serde(rename = "pnpm-workspace.yaml")]
655 PnpmWorkspaceYaml,
656 #[serde(rename = "package.json")]
658 PnpmPackageJson,
659}
660
661impl DependencyOverrideSource {
662 #[must_use]
665 pub const fn as_label(&self) -> &'static str {
666 match self {
667 Self::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
668 Self::PnpmPackageJson => "package.json",
669 }
670 }
671}
672
673impl std::fmt::Display for DependencyOverrideSource {
674 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675 f.write_str(self.as_label())
676 }
677}
678
679#[derive(Debug, Clone, Serialize)]
685pub struct UnusedDependencyOverride {
686 pub raw_key: String,
690 pub target_package: String,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub parent_package: Option<String>,
696 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub version_constraint: Option<String>,
700 pub version_range: String,
702 pub source: DependencyOverrideSource,
704 #[serde(serialize_with = "serde_path::serialize")]
711 pub path: PathBuf,
712 pub line: u32,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub hint: Option<String>,
720}
721
722#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
726#[serde(rename_all = "kebab-case")]
727pub enum DependencyOverrideMisconfigReason {
728 UnparsableKey,
731 EmptyValue,
733}
734
735impl DependencyOverrideMisconfigReason {
736 #[must_use]
738 pub const fn describe(self) -> &'static str {
739 match self {
740 Self::UnparsableKey => "override key cannot be parsed",
741 Self::EmptyValue => "override value is missing or empty",
742 }
743 }
744}
745
746#[derive(Debug, Clone, Serialize)]
750pub struct MisconfiguredDependencyOverride {
751 pub raw_key: String,
753 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub target_package: Option<String>,
762 pub raw_value: String,
765 pub reason: DependencyOverrideMisconfigReason,
767 pub source: DependencyOverrideSource,
769 #[serde(serialize_with = "serde_path::serialize")]
773 pub path: PathBuf,
774 pub line: u32,
776}
777
778#[derive(Debug, Clone, Serialize)]
781pub struct TestOnlyDependency {
782 pub package_name: String,
784 #[serde(serialize_with = "serde_path::serialize")]
786 pub path: PathBuf,
787 pub line: u32,
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize)]
793pub struct CircularDependency {
794 #[serde(serialize_with = "serde_path::serialize_vec")]
796 pub files: Vec<PathBuf>,
797 pub length: usize,
799 #[serde(default)]
801 pub line: u32,
802 #[serde(default)]
804 pub col: u32,
805 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
807 pub is_cross_package: bool,
808}
809
810#[derive(Debug, Clone, Serialize)]
812pub struct BoundaryViolation {
813 #[serde(serialize_with = "serde_path::serialize")]
815 pub from_path: PathBuf,
816 #[serde(serialize_with = "serde_path::serialize")]
818 pub to_path: PathBuf,
819 pub from_zone: String,
821 pub to_zone: String,
823 pub import_specifier: String,
825 pub line: u32,
827 pub col: u32,
829}
830
831#[derive(Debug, Clone, Serialize)]
833#[serde(rename_all = "snake_case", tag = "type")]
834pub enum SuppressionOrigin {
835 Comment {
837 #[serde(skip_serializing_if = "Option::is_none")]
839 issue_kind: Option<String>,
840 is_file_level: bool,
842 },
843 JsdocTag {
845 export_name: String,
847 },
848}
849
850#[derive(Debug, Clone, Serialize)]
852pub struct StaleSuppression {
853 #[serde(serialize_with = "serde_path::serialize")]
855 pub path: PathBuf,
856 pub line: u32,
858 pub col: u32,
860 pub origin: SuppressionOrigin,
862}
863
864impl StaleSuppression {
865 #[must_use]
867 pub fn description(&self) -> String {
868 match &self.origin {
869 SuppressionOrigin::Comment {
870 issue_kind,
871 is_file_level,
872 } => {
873 let directive = if *is_file_level {
874 "fallow-ignore-file"
875 } else {
876 "fallow-ignore-next-line"
877 };
878 match issue_kind {
879 Some(kind) => format!("// {directive} {kind}"),
880 None => format!("// {directive}"),
881 }
882 }
883 SuppressionOrigin::JsdocTag { export_name } => {
884 format!("@expected-unused on {export_name}")
885 }
886 }
887 }
888
889 #[must_use]
891 pub fn explanation(&self) -> String {
892 match &self.origin {
893 SuppressionOrigin::Comment {
894 issue_kind,
895 is_file_level,
896 } => {
897 let scope = if *is_file_level {
898 "in this file"
899 } else {
900 "on the next line"
901 };
902 match issue_kind {
903 Some(kind) => format!("no {kind} issue found {scope}"),
904 None => format!("no issues found {scope}"),
905 }
906 }
907 SuppressionOrigin::JsdocTag { export_name } => {
908 format!("{export_name} is now used")
909 }
910 }
911 }
912
913 #[must_use]
915 pub fn suppressed_kind(&self) -> Option<IssueKind> {
916 match &self.origin {
917 SuppressionOrigin::Comment { issue_kind, .. } => {
918 issue_kind.as_deref().and_then(IssueKind::parse)
919 }
920 SuppressionOrigin::JsdocTag { .. } => None,
921 }
922 }
923}
924
925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
927#[serde(rename_all = "snake_case")]
928pub enum FlagKind {
929 EnvironmentVariable,
931 SdkCall,
933 ConfigObject,
935}
936
937#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
939#[serde(rename_all = "snake_case")]
940pub enum FlagConfidence {
941 Low,
943 Medium,
945 High,
947}
948
949#[derive(Debug, Clone, Serialize)]
951pub struct FeatureFlag {
952 #[serde(serialize_with = "serde_path::serialize")]
954 pub path: PathBuf,
955 pub flag_name: String,
957 pub kind: FlagKind,
959 pub confidence: FlagConfidence,
961 pub line: u32,
963 pub col: u32,
965 #[serde(skip)]
967 pub guard_span_start: Option<u32>,
968 #[serde(skip)]
970 pub guard_span_end: Option<u32>,
971 #[serde(skip_serializing_if = "Option::is_none")]
973 pub sdk_name: Option<String>,
974 #[serde(skip)]
977 pub guard_line_start: Option<u32>,
978 #[serde(skip)]
980 pub guard_line_end: Option<u32>,
981 #[serde(skip_serializing_if = "Vec::is_empty")]
984 pub guarded_dead_exports: Vec<String>,
985}
986
987const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
989
990#[derive(Debug, Clone, Serialize)]
993pub struct ExportUsage {
994 #[serde(serialize_with = "serde_path::serialize")]
996 pub path: PathBuf,
997 pub export_name: String,
999 pub line: u32,
1001 pub col: u32,
1003 pub reference_count: usize,
1005 pub reference_locations: Vec<ReferenceLocation>,
1008}
1009
1010#[derive(Debug, Clone, Serialize)]
1012pub struct ReferenceLocation {
1013 #[serde(serialize_with = "serde_path::serialize")]
1015 pub path: PathBuf,
1016 pub line: u32,
1018 pub col: u32,
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025
1026 #[test]
1027 fn empty_results_no_issues() {
1028 let results = AnalysisResults::default();
1029 assert_eq!(results.total_issues(), 0);
1030 assert!(!results.has_issues());
1031 }
1032
1033 #[test]
1034 fn results_with_unused_file() {
1035 let mut results = AnalysisResults::default();
1036 results.unused_files.push(UnusedFile {
1037 path: PathBuf::from("test.ts"),
1038 });
1039 assert_eq!(results.total_issues(), 1);
1040 assert!(results.has_issues());
1041 }
1042
1043 #[test]
1044 fn results_with_unused_export() {
1045 let mut results = AnalysisResults::default();
1046 results.unused_exports.push(UnusedExport {
1047 path: PathBuf::from("test.ts"),
1048 export_name: "foo".to_string(),
1049 is_type_only: false,
1050 line: 1,
1051 col: 0,
1052 span_start: 0,
1053 is_re_export: false,
1054 });
1055 assert_eq!(results.total_issues(), 1);
1056 assert!(results.has_issues());
1057 }
1058
1059 #[test]
1060 fn results_total_counts_all_types() {
1061 let mut results = AnalysisResults::default();
1062 results.unused_files.push(UnusedFile {
1063 path: PathBuf::from("a.ts"),
1064 });
1065 results.unused_exports.push(UnusedExport {
1066 path: PathBuf::from("b.ts"),
1067 export_name: "x".to_string(),
1068 is_type_only: false,
1069 line: 1,
1070 col: 0,
1071 span_start: 0,
1072 is_re_export: false,
1073 });
1074 results.unused_types.push(UnusedExport {
1075 path: PathBuf::from("c.ts"),
1076 export_name: "T".to_string(),
1077 is_type_only: true,
1078 line: 1,
1079 col: 0,
1080 span_start: 0,
1081 is_re_export: false,
1082 });
1083 results.unused_dependencies.push(UnusedDependency {
1084 package_name: "dep".to_string(),
1085 location: DependencyLocation::Dependencies,
1086 path: PathBuf::from("package.json"),
1087 line: 5,
1088 used_in_workspaces: Vec::new(),
1089 });
1090 results.unused_dev_dependencies.push(UnusedDependency {
1091 package_name: "dev".to_string(),
1092 location: DependencyLocation::DevDependencies,
1093 path: PathBuf::from("package.json"),
1094 line: 5,
1095 used_in_workspaces: Vec::new(),
1096 });
1097 results.unused_enum_members.push(UnusedMember {
1098 path: PathBuf::from("d.ts"),
1099 parent_name: "E".to_string(),
1100 member_name: "A".to_string(),
1101 kind: MemberKind::EnumMember,
1102 line: 1,
1103 col: 0,
1104 });
1105 results.unused_class_members.push(UnusedMember {
1106 path: PathBuf::from("e.ts"),
1107 parent_name: "C".to_string(),
1108 member_name: "m".to_string(),
1109 kind: MemberKind::ClassMethod,
1110 line: 1,
1111 col: 0,
1112 });
1113 results.unresolved_imports.push(UnresolvedImport {
1114 path: PathBuf::from("f.ts"),
1115 specifier: "./missing".to_string(),
1116 line: 1,
1117 col: 0,
1118 specifier_col: 0,
1119 });
1120 results.unlisted_dependencies.push(UnlistedDependency {
1121 package_name: "unlisted".to_string(),
1122 imported_from: vec![ImportSite {
1123 path: PathBuf::from("g.ts"),
1124 line: 1,
1125 col: 0,
1126 }],
1127 });
1128 results.duplicate_exports.push(DuplicateExport {
1129 export_name: "dup".to_string(),
1130 locations: vec![
1131 DuplicateLocation {
1132 path: PathBuf::from("h.ts"),
1133 line: 15,
1134 col: 0,
1135 },
1136 DuplicateLocation {
1137 path: PathBuf::from("i.ts"),
1138 line: 30,
1139 col: 0,
1140 },
1141 ],
1142 });
1143 results.unused_optional_dependencies.push(UnusedDependency {
1144 package_name: "optional".to_string(),
1145 location: DependencyLocation::OptionalDependencies,
1146 path: PathBuf::from("package.json"),
1147 line: 5,
1148 used_in_workspaces: Vec::new(),
1149 });
1150 results.type_only_dependencies.push(TypeOnlyDependency {
1151 package_name: "type-only".to_string(),
1152 path: PathBuf::from("package.json"),
1153 line: 8,
1154 });
1155 results.test_only_dependencies.push(TestOnlyDependency {
1156 package_name: "test-only".to_string(),
1157 path: PathBuf::from("package.json"),
1158 line: 9,
1159 });
1160 results.circular_dependencies.push(CircularDependency {
1161 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1162 length: 2,
1163 line: 3,
1164 col: 0,
1165 is_cross_package: false,
1166 });
1167 results.boundary_violations.push(BoundaryViolation {
1168 from_path: PathBuf::from("src/ui/Button.tsx"),
1169 to_path: PathBuf::from("src/db/queries.ts"),
1170 from_zone: "ui".to_string(),
1171 to_zone: "database".to_string(),
1172 import_specifier: "../db/queries".to_string(),
1173 line: 3,
1174 col: 0,
1175 });
1176
1177 assert_eq!(results.total_issues(), 15);
1179 assert!(results.has_issues());
1180 }
1181
1182 #[test]
1185 fn total_issues_and_has_issues_are_consistent() {
1186 let results = AnalysisResults::default();
1187 assert_eq!(results.total_issues(), 0);
1188 assert!(!results.has_issues());
1189 assert_eq!(results.total_issues() > 0, results.has_issues());
1190 }
1191
1192 #[test]
1195 fn total_issues_sums_all_categories_independently() {
1196 let mut results = AnalysisResults::default();
1197 results.unused_files.push(UnusedFile {
1198 path: PathBuf::from("a.ts"),
1199 });
1200 assert_eq!(results.total_issues(), 1);
1201
1202 results.unused_files.push(UnusedFile {
1203 path: PathBuf::from("b.ts"),
1204 });
1205 assert_eq!(results.total_issues(), 2);
1206
1207 results.unresolved_imports.push(UnresolvedImport {
1208 path: PathBuf::from("c.ts"),
1209 specifier: "./missing".to_string(),
1210 line: 1,
1211 col: 0,
1212 specifier_col: 0,
1213 });
1214 assert_eq!(results.total_issues(), 3);
1215 }
1216
1217 #[test]
1220 fn default_results_all_fields_empty() {
1221 let r = AnalysisResults::default();
1222 assert!(r.unused_files.is_empty());
1223 assert!(r.unused_exports.is_empty());
1224 assert!(r.unused_types.is_empty());
1225 assert!(r.unused_dependencies.is_empty());
1226 assert!(r.unused_dev_dependencies.is_empty());
1227 assert!(r.unused_optional_dependencies.is_empty());
1228 assert!(r.unused_enum_members.is_empty());
1229 assert!(r.unused_class_members.is_empty());
1230 assert!(r.unresolved_imports.is_empty());
1231 assert!(r.unlisted_dependencies.is_empty());
1232 assert!(r.duplicate_exports.is_empty());
1233 assert!(r.type_only_dependencies.is_empty());
1234 assert!(r.test_only_dependencies.is_empty());
1235 assert!(r.circular_dependencies.is_empty());
1236 assert!(r.boundary_violations.is_empty());
1237 assert!(r.unused_catalog_entries.is_empty());
1238 assert!(r.unresolved_catalog_references.is_empty());
1239 assert!(r.export_usages.is_empty());
1240 }
1241
1242 #[test]
1245 fn entry_point_summary_default() {
1246 let summary = EntryPointSummary::default();
1247 assert_eq!(summary.total, 0);
1248 assert!(summary.by_source.is_empty());
1249 }
1250
1251 #[test]
1252 fn entry_point_summary_not_in_default_results() {
1253 let r = AnalysisResults::default();
1254 assert!(r.entry_point_summary.is_none());
1255 }
1256
1257 #[test]
1258 fn entry_point_summary_some_preserves_data() {
1259 let r = AnalysisResults {
1260 entry_point_summary: Some(EntryPointSummary {
1261 total: 5,
1262 by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
1263 }),
1264 ..AnalysisResults::default()
1265 };
1266 let summary = r.entry_point_summary.as_ref().unwrap();
1267 assert_eq!(summary.total, 5);
1268 assert_eq!(summary.by_source.len(), 2);
1269 assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
1270 }
1271
1272 #[test]
1275 fn sort_unused_files_by_path() {
1276 let mut r = AnalysisResults::default();
1277 r.unused_files.push(UnusedFile {
1278 path: PathBuf::from("z.ts"),
1279 });
1280 r.unused_files.push(UnusedFile {
1281 path: PathBuf::from("a.ts"),
1282 });
1283 r.unused_files.push(UnusedFile {
1284 path: PathBuf::from("m.ts"),
1285 });
1286 r.sort();
1287 let paths: Vec<_> = r
1288 .unused_files
1289 .iter()
1290 .map(|f| f.path.to_string_lossy().to_string())
1291 .collect();
1292 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
1293 }
1294
1295 #[test]
1298 fn sort_unused_exports_by_path_line_name() {
1299 let mut r = AnalysisResults::default();
1300 let mk = |path: &str, line: u32, name: &str| UnusedExport {
1301 path: PathBuf::from(path),
1302 export_name: name.to_string(),
1303 is_type_only: false,
1304 line,
1305 col: 0,
1306 span_start: 0,
1307 is_re_export: false,
1308 };
1309 r.unused_exports.push(mk("b.ts", 5, "beta"));
1310 r.unused_exports.push(mk("a.ts", 10, "zeta"));
1311 r.unused_exports.push(mk("a.ts", 10, "alpha"));
1312 r.unused_exports.push(mk("a.ts", 1, "gamma"));
1313 r.sort();
1314 let keys: Vec<_> = r
1315 .unused_exports
1316 .iter()
1317 .map(|e| format!("{}:{}:{}", e.path.to_string_lossy(), e.line, e.export_name))
1318 .collect();
1319 assert_eq!(
1320 keys,
1321 vec![
1322 "a.ts:1:gamma",
1323 "a.ts:10:alpha",
1324 "a.ts:10:zeta",
1325 "b.ts:5:beta"
1326 ]
1327 );
1328 }
1329
1330 #[test]
1333 fn sort_unused_types_by_path_line_name() {
1334 let mut r = AnalysisResults::default();
1335 let mk = |path: &str, line: u32, name: &str| UnusedExport {
1336 path: PathBuf::from(path),
1337 export_name: name.to_string(),
1338 is_type_only: true,
1339 line,
1340 col: 0,
1341 span_start: 0,
1342 is_re_export: false,
1343 };
1344 r.unused_types.push(mk("z.ts", 1, "Z"));
1345 r.unused_types.push(mk("a.ts", 1, "A"));
1346 r.sort();
1347 assert_eq!(r.unused_types[0].path, PathBuf::from("a.ts"));
1348 assert_eq!(r.unused_types[1].path, PathBuf::from("z.ts"));
1349 }
1350
1351 #[test]
1354 fn sort_unused_dependencies_by_path_line_name() {
1355 let mut r = AnalysisResults::default();
1356 let mk = |path: &str, line: u32, name: &str| UnusedDependency {
1357 package_name: name.to_string(),
1358 location: DependencyLocation::Dependencies,
1359 path: PathBuf::from(path),
1360 line,
1361 used_in_workspaces: Vec::new(),
1362 };
1363 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
1364 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
1365 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
1366 r.sort();
1367 let names: Vec<_> = r
1368 .unused_dependencies
1369 .iter()
1370 .map(|d| d.package_name.as_str())
1371 .collect();
1372 assert_eq!(names, vec!["axios", "react", "zlib"]);
1373 }
1374
1375 #[test]
1378 fn sort_unused_dev_dependencies() {
1379 let mut r = AnalysisResults::default();
1380 r.unused_dev_dependencies.push(UnusedDependency {
1381 package_name: "vitest".to_string(),
1382 location: DependencyLocation::DevDependencies,
1383 path: PathBuf::from("package.json"),
1384 line: 10,
1385 used_in_workspaces: Vec::new(),
1386 });
1387 r.unused_dev_dependencies.push(UnusedDependency {
1388 package_name: "jest".to_string(),
1389 location: DependencyLocation::DevDependencies,
1390 path: PathBuf::from("package.json"),
1391 line: 5,
1392 used_in_workspaces: Vec::new(),
1393 });
1394 r.sort();
1395 assert_eq!(r.unused_dev_dependencies[0].package_name, "jest");
1396 assert_eq!(r.unused_dev_dependencies[1].package_name, "vitest");
1397 }
1398
1399 #[test]
1402 fn sort_unused_optional_dependencies() {
1403 let mut r = AnalysisResults::default();
1404 r.unused_optional_dependencies.push(UnusedDependency {
1405 package_name: "zod".to_string(),
1406 location: DependencyLocation::OptionalDependencies,
1407 path: PathBuf::from("package.json"),
1408 line: 3,
1409 used_in_workspaces: Vec::new(),
1410 });
1411 r.unused_optional_dependencies.push(UnusedDependency {
1412 package_name: "ajv".to_string(),
1413 location: DependencyLocation::OptionalDependencies,
1414 path: PathBuf::from("package.json"),
1415 line: 2,
1416 used_in_workspaces: Vec::new(),
1417 });
1418 r.sort();
1419 assert_eq!(r.unused_optional_dependencies[0].package_name, "ajv");
1420 assert_eq!(r.unused_optional_dependencies[1].package_name, "zod");
1421 }
1422
1423 #[test]
1426 fn sort_unused_enum_members_by_path_line_parent_member() {
1427 let mut r = AnalysisResults::default();
1428 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1429 path: PathBuf::from(path),
1430 parent_name: parent.to_string(),
1431 member_name: member.to_string(),
1432 kind: MemberKind::EnumMember,
1433 line,
1434 col: 0,
1435 };
1436 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
1437 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
1438 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
1439 r.sort();
1440 let keys: Vec<_> = r
1441 .unused_enum_members
1442 .iter()
1443 .map(|m| format!("{}:{}", m.parent_name, m.member_name))
1444 .collect();
1445 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
1446 }
1447
1448 #[test]
1451 fn sort_unused_class_members() {
1452 let mut r = AnalysisResults::default();
1453 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1454 path: PathBuf::from(path),
1455 parent_name: parent.to_string(),
1456 member_name: member.to_string(),
1457 kind: MemberKind::ClassMethod,
1458 line,
1459 col: 0,
1460 };
1461 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1462 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1463 r.sort();
1464 assert_eq!(r.unused_class_members[0].path, PathBuf::from("a.ts"));
1465 assert_eq!(r.unused_class_members[1].path, PathBuf::from("b.ts"));
1466 }
1467
1468 #[test]
1471 fn sort_unresolved_imports_by_path_line_col_specifier() {
1472 let mut r = AnalysisResults::default();
1473 let mk = |path: &str, line: u32, col: u32, spec: &str| UnresolvedImport {
1474 path: PathBuf::from(path),
1475 specifier: spec.to_string(),
1476 line,
1477 col,
1478 specifier_col: 0,
1479 };
1480 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1481 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1482 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1483 r.sort();
1484 let specs: Vec<_> = r
1485 .unresolved_imports
1486 .iter()
1487 .map(|i| i.specifier.as_str())
1488 .collect();
1489 assert_eq!(specs, vec!["./m", "./a", "./z"]);
1490 }
1491
1492 #[test]
1495 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1496 let mut r = AnalysisResults::default();
1497 r.unlisted_dependencies.push(UnlistedDependency {
1498 package_name: "zod".to_string(),
1499 imported_from: vec![
1500 ImportSite {
1501 path: PathBuf::from("b.ts"),
1502 line: 10,
1503 col: 0,
1504 },
1505 ImportSite {
1506 path: PathBuf::from("a.ts"),
1507 line: 1,
1508 col: 0,
1509 },
1510 ],
1511 });
1512 r.unlisted_dependencies.push(UnlistedDependency {
1513 package_name: "axios".to_string(),
1514 imported_from: vec![ImportSite {
1515 path: PathBuf::from("c.ts"),
1516 line: 1,
1517 col: 0,
1518 }],
1519 });
1520 r.sort();
1521
1522 assert_eq!(r.unlisted_dependencies[0].package_name, "axios");
1524 assert_eq!(r.unlisted_dependencies[1].package_name, "zod");
1525
1526 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1528 .imported_from
1529 .iter()
1530 .map(|s| s.path.to_string_lossy().to_string())
1531 .collect();
1532 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1533 }
1534
1535 #[test]
1538 fn sort_duplicate_exports_by_name_and_inner_locations() {
1539 let mut r = AnalysisResults::default();
1540 r.duplicate_exports.push(DuplicateExport {
1541 export_name: "z".to_string(),
1542 locations: vec![
1543 DuplicateLocation {
1544 path: PathBuf::from("c.ts"),
1545 line: 1,
1546 col: 0,
1547 },
1548 DuplicateLocation {
1549 path: PathBuf::from("a.ts"),
1550 line: 5,
1551 col: 0,
1552 },
1553 ],
1554 });
1555 r.duplicate_exports.push(DuplicateExport {
1556 export_name: "a".to_string(),
1557 locations: vec![DuplicateLocation {
1558 path: PathBuf::from("b.ts"),
1559 line: 1,
1560 col: 0,
1561 }],
1562 });
1563 r.sort();
1564
1565 assert_eq!(r.duplicate_exports[0].export_name, "a");
1567 assert_eq!(r.duplicate_exports[1].export_name, "z");
1568
1569 let z_locs: Vec<_> = r.duplicate_exports[1]
1571 .locations
1572 .iter()
1573 .map(|l| l.path.to_string_lossy().to_string())
1574 .collect();
1575 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1576 }
1577
1578 #[test]
1581 fn sort_type_only_dependencies() {
1582 let mut r = AnalysisResults::default();
1583 r.type_only_dependencies.push(TypeOnlyDependency {
1584 package_name: "zod".to_string(),
1585 path: PathBuf::from("package.json"),
1586 line: 10,
1587 });
1588 r.type_only_dependencies.push(TypeOnlyDependency {
1589 package_name: "ajv".to_string(),
1590 path: PathBuf::from("package.json"),
1591 line: 5,
1592 });
1593 r.sort();
1594 assert_eq!(r.type_only_dependencies[0].package_name, "ajv");
1595 assert_eq!(r.type_only_dependencies[1].package_name, "zod");
1596 }
1597
1598 #[test]
1601 fn sort_test_only_dependencies() {
1602 let mut r = AnalysisResults::default();
1603 r.test_only_dependencies.push(TestOnlyDependency {
1604 package_name: "vitest".to_string(),
1605 path: PathBuf::from("package.json"),
1606 line: 15,
1607 });
1608 r.test_only_dependencies.push(TestOnlyDependency {
1609 package_name: "jest".to_string(),
1610 path: PathBuf::from("package.json"),
1611 line: 10,
1612 });
1613 r.sort();
1614 assert_eq!(r.test_only_dependencies[0].package_name, "jest");
1615 assert_eq!(r.test_only_dependencies[1].package_name, "vitest");
1616 }
1617
1618 #[test]
1621 fn sort_circular_dependencies_by_files_then_length() {
1622 let mut r = AnalysisResults::default();
1623 r.circular_dependencies.push(CircularDependency {
1624 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1625 length: 2,
1626 line: 1,
1627 col: 0,
1628 is_cross_package: false,
1629 });
1630 r.circular_dependencies.push(CircularDependency {
1631 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1632 length: 2,
1633 line: 1,
1634 col: 0,
1635 is_cross_package: true,
1636 });
1637 r.sort();
1638 assert_eq!(r.circular_dependencies[0].files[0], PathBuf::from("a.ts"));
1639 assert_eq!(r.circular_dependencies[1].files[0], PathBuf::from("b.ts"));
1640 }
1641
1642 #[test]
1645 fn sort_boundary_violations() {
1646 let mut r = AnalysisResults::default();
1647 let mk = |from: &str, line: u32, col: u32, to: &str| BoundaryViolation {
1648 from_path: PathBuf::from(from),
1649 to_path: PathBuf::from(to),
1650 from_zone: "a".to_string(),
1651 to_zone: "b".to_string(),
1652 import_specifier: to.to_string(),
1653 line,
1654 col,
1655 };
1656 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
1657 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
1658 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
1659 r.sort();
1660 let from_paths: Vec<_> = r
1661 .boundary_violations
1662 .iter()
1663 .map(|v| format!("{}:{}", v.from_path.to_string_lossy(), v.line))
1664 .collect();
1665 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
1666 }
1667
1668 #[test]
1671 fn sort_export_usages_and_inner_reference_locations() {
1672 let mut r = AnalysisResults::default();
1673 r.export_usages.push(ExportUsage {
1674 path: PathBuf::from("z.ts"),
1675 export_name: "foo".to_string(),
1676 line: 1,
1677 col: 0,
1678 reference_count: 2,
1679 reference_locations: vec![
1680 ReferenceLocation {
1681 path: PathBuf::from("c.ts"),
1682 line: 10,
1683 col: 0,
1684 },
1685 ReferenceLocation {
1686 path: PathBuf::from("a.ts"),
1687 line: 5,
1688 col: 0,
1689 },
1690 ],
1691 });
1692 r.export_usages.push(ExportUsage {
1693 path: PathBuf::from("a.ts"),
1694 export_name: "bar".to_string(),
1695 line: 1,
1696 col: 0,
1697 reference_count: 1,
1698 reference_locations: vec![ReferenceLocation {
1699 path: PathBuf::from("b.ts"),
1700 line: 1,
1701 col: 0,
1702 }],
1703 });
1704 r.sort();
1705
1706 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
1708 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
1709
1710 let refs: Vec<_> = r.export_usages[1]
1712 .reference_locations
1713 .iter()
1714 .map(|l| l.path.to_string_lossy().to_string())
1715 .collect();
1716 assert_eq!(refs, vec!["a.ts", "c.ts"]);
1717 }
1718
1719 #[test]
1722 fn sort_empty_results_is_noop() {
1723 let mut r = AnalysisResults::default();
1724 r.sort(); assert_eq!(r.total_issues(), 0);
1726 }
1727
1728 #[test]
1731 fn sort_single_element_lists_stable() {
1732 let mut r = AnalysisResults::default();
1733 r.unused_files.push(UnusedFile {
1734 path: PathBuf::from("only.ts"),
1735 });
1736 r.sort();
1737 assert_eq!(r.unused_files[0].path, PathBuf::from("only.ts"));
1738 }
1739
1740 #[test]
1743 fn serialize_empty_results() {
1744 let r = AnalysisResults::default();
1745 let json = serde_json::to_value(&r).unwrap();
1746
1747 assert!(json["unused_files"].as_array().unwrap().is_empty());
1749 assert!(json["unused_exports"].as_array().unwrap().is_empty());
1750 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
1751
1752 assert!(json.get("export_usages").is_none());
1754 assert!(json.get("entry_point_summary").is_none());
1755 }
1756
1757 #[test]
1758 fn serialize_unused_file_path() {
1759 let r = UnusedFile {
1760 path: PathBuf::from("src/utils/index.ts"),
1761 };
1762 let json = serde_json::to_value(&r).unwrap();
1763 assert_eq!(json["path"], "src/utils/index.ts");
1764 }
1765
1766 #[test]
1767 fn serialize_dependency_location_camel_case() {
1768 let dep = UnusedDependency {
1769 package_name: "react".to_string(),
1770 location: DependencyLocation::DevDependencies,
1771 path: PathBuf::from("package.json"),
1772 line: 5,
1773 used_in_workspaces: Vec::new(),
1774 };
1775 let json = serde_json::to_value(&dep).unwrap();
1776 assert_eq!(json["location"], "devDependencies");
1777
1778 let dep2 = UnusedDependency {
1779 package_name: "react".to_string(),
1780 location: DependencyLocation::Dependencies,
1781 path: PathBuf::from("package.json"),
1782 line: 3,
1783 used_in_workspaces: Vec::new(),
1784 };
1785 let json2 = serde_json::to_value(&dep2).unwrap();
1786 assert_eq!(json2["location"], "dependencies");
1787
1788 let dep3 = UnusedDependency {
1789 package_name: "fsevents".to_string(),
1790 location: DependencyLocation::OptionalDependencies,
1791 path: PathBuf::from("package.json"),
1792 line: 7,
1793 used_in_workspaces: Vec::new(),
1794 };
1795 let json3 = serde_json::to_value(&dep3).unwrap();
1796 assert_eq!(json3["location"], "optionalDependencies");
1797 }
1798
1799 #[test]
1800 fn serialize_circular_dependency_skips_false_cross_package() {
1801 let cd = CircularDependency {
1802 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1803 length: 2,
1804 line: 1,
1805 col: 0,
1806 is_cross_package: false,
1807 };
1808 let json = serde_json::to_value(&cd).unwrap();
1809 assert!(json.get("is_cross_package").is_none());
1811 }
1812
1813 #[test]
1814 fn serialize_circular_dependency_includes_true_cross_package() {
1815 let cd = CircularDependency {
1816 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1817 length: 2,
1818 line: 1,
1819 col: 0,
1820 is_cross_package: true,
1821 };
1822 let json = serde_json::to_value(&cd).unwrap();
1823 assert_eq!(json["is_cross_package"], true);
1824 }
1825
1826 #[test]
1827 fn serialize_unused_export_fields() {
1828 let e = UnusedExport {
1829 path: PathBuf::from("src/mod.ts"),
1830 export_name: "helper".to_string(),
1831 is_type_only: true,
1832 line: 42,
1833 col: 7,
1834 span_start: 100,
1835 is_re_export: true,
1836 };
1837 let json = serde_json::to_value(&e).unwrap();
1838 assert_eq!(json["path"], "src/mod.ts");
1839 assert_eq!(json["export_name"], "helper");
1840 assert_eq!(json["is_type_only"], true);
1841 assert_eq!(json["line"], 42);
1842 assert_eq!(json["col"], 7);
1843 assert_eq!(json["span_start"], 100);
1844 assert_eq!(json["is_re_export"], true);
1845 }
1846
1847 #[test]
1848 fn serialize_boundary_violation_fields() {
1849 let v = BoundaryViolation {
1850 from_path: PathBuf::from("src/ui/button.tsx"),
1851 to_path: PathBuf::from("src/db/queries.ts"),
1852 from_zone: "ui".to_string(),
1853 to_zone: "db".to_string(),
1854 import_specifier: "../db/queries".to_string(),
1855 line: 3,
1856 col: 0,
1857 };
1858 let json = serde_json::to_value(&v).unwrap();
1859 assert_eq!(json["from_path"], "src/ui/button.tsx");
1860 assert_eq!(json["to_path"], "src/db/queries.ts");
1861 assert_eq!(json["from_zone"], "ui");
1862 assert_eq!(json["to_zone"], "db");
1863 assert_eq!(json["import_specifier"], "../db/queries");
1864 }
1865
1866 #[test]
1867 fn serialize_unlisted_dependency_with_import_sites() {
1868 let d = UnlistedDependency {
1869 package_name: "chalk".to_string(),
1870 imported_from: vec![
1871 ImportSite {
1872 path: PathBuf::from("a.ts"),
1873 line: 1,
1874 col: 0,
1875 },
1876 ImportSite {
1877 path: PathBuf::from("b.ts"),
1878 line: 5,
1879 col: 3,
1880 },
1881 ],
1882 };
1883 let json = serde_json::to_value(&d).unwrap();
1884 assert_eq!(json["package_name"], "chalk");
1885 let sites = json["imported_from"].as_array().unwrap();
1886 assert_eq!(sites.len(), 2);
1887 assert_eq!(sites[0]["path"], "a.ts");
1888 assert_eq!(sites[1]["line"], 5);
1889 }
1890
1891 #[test]
1892 fn serialize_duplicate_export_with_locations() {
1893 let d = DuplicateExport {
1894 export_name: "Button".to_string(),
1895 locations: vec![
1896 DuplicateLocation {
1897 path: PathBuf::from("src/a.ts"),
1898 line: 10,
1899 col: 0,
1900 },
1901 DuplicateLocation {
1902 path: PathBuf::from("src/b.ts"),
1903 line: 20,
1904 col: 5,
1905 },
1906 ],
1907 };
1908 let json = serde_json::to_value(&d).unwrap();
1909 assert_eq!(json["export_name"], "Button");
1910 let locs = json["locations"].as_array().unwrap();
1911 assert_eq!(locs.len(), 2);
1912 assert_eq!(locs[0]["line"], 10);
1913 assert_eq!(locs[1]["col"], 5);
1914 }
1915
1916 #[test]
1917 fn serialize_type_only_dependency() {
1918 let d = TypeOnlyDependency {
1919 package_name: "@types/react".to_string(),
1920 path: PathBuf::from("package.json"),
1921 line: 12,
1922 };
1923 let json = serde_json::to_value(&d).unwrap();
1924 assert_eq!(json["package_name"], "@types/react");
1925 assert_eq!(json["line"], 12);
1926 }
1927
1928 #[test]
1929 fn serialize_test_only_dependency() {
1930 let d = TestOnlyDependency {
1931 package_name: "vitest".to_string(),
1932 path: PathBuf::from("package.json"),
1933 line: 8,
1934 };
1935 let json = serde_json::to_value(&d).unwrap();
1936 assert_eq!(json["package_name"], "vitest");
1937 assert_eq!(json["line"], 8);
1938 }
1939
1940 #[test]
1941 fn serialize_unused_member() {
1942 let m = UnusedMember {
1943 path: PathBuf::from("enums.ts"),
1944 parent_name: "Status".to_string(),
1945 member_name: "Pending".to_string(),
1946 kind: MemberKind::EnumMember,
1947 line: 3,
1948 col: 4,
1949 };
1950 let json = serde_json::to_value(&m).unwrap();
1951 assert_eq!(json["parent_name"], "Status");
1952 assert_eq!(json["member_name"], "Pending");
1953 assert_eq!(json["line"], 3);
1954 }
1955
1956 #[test]
1957 fn serialize_unresolved_import() {
1958 let i = UnresolvedImport {
1959 path: PathBuf::from("app.ts"),
1960 specifier: "./missing-module".to_string(),
1961 line: 7,
1962 col: 0,
1963 specifier_col: 21,
1964 };
1965 let json = serde_json::to_value(&i).unwrap();
1966 assert_eq!(json["specifier"], "./missing-module");
1967 assert_eq!(json["specifier_col"], 21);
1968 }
1969
1970 #[test]
1973 fn deserialize_circular_dependency_with_defaults() {
1974 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
1976 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1977 assert_eq!(cd.files.len(), 2);
1978 assert_eq!(cd.length, 2);
1979 assert_eq!(cd.line, 0);
1980 assert_eq!(cd.col, 0);
1981 assert!(!cd.is_cross_package);
1982 }
1983
1984 #[test]
1985 fn deserialize_circular_dependency_with_all_fields() {
1986 let json =
1987 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
1988 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1989 assert_eq!(cd.line, 5);
1990 assert_eq!(cd.col, 10);
1991 assert!(cd.is_cross_package);
1992 }
1993
1994 #[test]
1997 fn clone_results_are_independent() {
1998 let mut r = AnalysisResults::default();
1999 r.unused_files.push(UnusedFile {
2000 path: PathBuf::from("a.ts"),
2001 });
2002 let mut cloned = r.clone();
2003 cloned.unused_files.push(UnusedFile {
2004 path: PathBuf::from("b.ts"),
2005 });
2006 assert_eq!(r.total_issues(), 1);
2007 assert_eq!(cloned.total_issues(), 2);
2008 }
2009
2010 #[test]
2013 fn export_usages_not_counted_in_total_issues() {
2014 let mut r = AnalysisResults::default();
2015 r.export_usages.push(ExportUsage {
2016 path: PathBuf::from("mod.ts"),
2017 export_name: "foo".to_string(),
2018 line: 1,
2019 col: 0,
2020 reference_count: 3,
2021 reference_locations: vec![],
2022 });
2023 assert_eq!(r.total_issues(), 0);
2025 assert!(!r.has_issues());
2026 }
2027
2028 #[test]
2031 fn entry_point_summary_not_counted_in_total_issues() {
2032 let r = AnalysisResults {
2033 entry_point_summary: Some(EntryPointSummary {
2034 total: 10,
2035 by_source: vec![("config".to_string(), 10)],
2036 }),
2037 ..AnalysisResults::default()
2038 };
2039 assert_eq!(r.total_issues(), 0);
2040 assert!(!r.has_issues());
2041 }
2042}