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 unresolved_catalog_references: Vec<UnresolvedCatalogReference>,
88 #[serde(default)]
90 pub unused_dependency_overrides: Vec<UnusedDependencyOverride>,
91 #[serde(default)]
93 pub misconfigured_dependency_overrides: Vec<MisconfiguredDependencyOverride>,
94 #[serde(skip)]
98 pub suppression_count: usize,
99 #[serde(skip)]
102 pub feature_flags: Vec<FeatureFlag>,
103 #[serde(skip)]
107 pub export_usages: Vec<ExportUsage>,
108 #[serde(skip)]
112 pub entry_point_summary: Option<EntryPointSummary>,
113}
114
115impl AnalysisResults {
116 #[must_use]
140 pub const fn total_issues(&self) -> usize {
141 self.unused_files.len()
142 + self.unused_exports.len()
143 + self.unused_types.len()
144 + self.private_type_leaks.len()
145 + self.unused_dependencies.len()
146 + self.unused_dev_dependencies.len()
147 + self.unused_optional_dependencies.len()
148 + self.unused_enum_members.len()
149 + self.unused_class_members.len()
150 + self.unresolved_imports.len()
151 + self.unlisted_dependencies.len()
152 + self.duplicate_exports.len()
153 + self.type_only_dependencies.len()
154 + self.test_only_dependencies.len()
155 + self.circular_dependencies.len()
156 + self.boundary_violations.len()
157 + self.stale_suppressions.len()
158 + self.unused_catalog_entries.len()
159 + self.unresolved_catalog_references.len()
160 + self.unused_dependency_overrides.len()
161 + self.misconfigured_dependency_overrides.len()
162 }
163
164 #[must_use]
166 pub const fn has_issues(&self) -> bool {
167 self.total_issues() > 0
168 }
169
170 #[expect(
177 clippy::too_many_lines,
178 reason = "one short sort_by per result array; splitting would add indirection without clarity"
179 )]
180 pub fn sort(&mut self) {
181 self.unused_files.sort_by(|a, b| a.path.cmp(&b.path));
182
183 self.unused_exports.sort_by(|a, b| {
184 a.path
185 .cmp(&b.path)
186 .then(a.line.cmp(&b.line))
187 .then(a.export_name.cmp(&b.export_name))
188 });
189
190 self.unused_types.sort_by(|a, b| {
191 a.path
192 .cmp(&b.path)
193 .then(a.line.cmp(&b.line))
194 .then(a.export_name.cmp(&b.export_name))
195 });
196
197 self.private_type_leaks.sort_by(|a, b| {
198 a.path
199 .cmp(&b.path)
200 .then(a.line.cmp(&b.line))
201 .then(a.export_name.cmp(&b.export_name))
202 .then(a.type_name.cmp(&b.type_name))
203 });
204
205 self.unused_dependencies.sort_by(|a, b| {
206 a.path
207 .cmp(&b.path)
208 .then(a.line.cmp(&b.line))
209 .then(a.package_name.cmp(&b.package_name))
210 });
211
212 self.unused_dev_dependencies.sort_by(|a, b| {
213 a.path
214 .cmp(&b.path)
215 .then(a.line.cmp(&b.line))
216 .then(a.package_name.cmp(&b.package_name))
217 });
218
219 self.unused_optional_dependencies.sort_by(|a, b| {
220 a.path
221 .cmp(&b.path)
222 .then(a.line.cmp(&b.line))
223 .then(a.package_name.cmp(&b.package_name))
224 });
225
226 self.unused_enum_members.sort_by(|a, b| {
227 a.path
228 .cmp(&b.path)
229 .then(a.line.cmp(&b.line))
230 .then(a.parent_name.cmp(&b.parent_name))
231 .then(a.member_name.cmp(&b.member_name))
232 });
233
234 self.unused_class_members.sort_by(|a, b| {
235 a.path
236 .cmp(&b.path)
237 .then(a.line.cmp(&b.line))
238 .then(a.parent_name.cmp(&b.parent_name))
239 .then(a.member_name.cmp(&b.member_name))
240 });
241
242 self.unresolved_imports.sort_by(|a, b| {
243 a.path
244 .cmp(&b.path)
245 .then(a.line.cmp(&b.line))
246 .then(a.col.cmp(&b.col))
247 .then(a.specifier.cmp(&b.specifier))
248 });
249
250 self.unlisted_dependencies
251 .sort_by(|a, b| a.package_name.cmp(&b.package_name));
252 for dep in &mut self.unlisted_dependencies {
253 dep.imported_from
254 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
255 }
256
257 self.duplicate_exports
258 .sort_by(|a, b| a.export_name.cmp(&b.export_name));
259 for dup in &mut self.duplicate_exports {
260 dup.locations
261 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
262 }
263
264 self.type_only_dependencies.sort_by(|a, b| {
265 a.path
266 .cmp(&b.path)
267 .then(a.line.cmp(&b.line))
268 .then(a.package_name.cmp(&b.package_name))
269 });
270
271 self.test_only_dependencies.sort_by(|a, b| {
272 a.path
273 .cmp(&b.path)
274 .then(a.line.cmp(&b.line))
275 .then(a.package_name.cmp(&b.package_name))
276 });
277
278 self.circular_dependencies
279 .sort_by(|a, b| a.files.cmp(&b.files).then(a.length.cmp(&b.length)));
280
281 self.boundary_violations.sort_by(|a, b| {
282 a.from_path
283 .cmp(&b.from_path)
284 .then(a.line.cmp(&b.line))
285 .then(a.col.cmp(&b.col))
286 .then(a.to_path.cmp(&b.to_path))
287 });
288
289 self.stale_suppressions.sort_by(|a, b| {
290 a.path
291 .cmp(&b.path)
292 .then(a.line.cmp(&b.line))
293 .then(a.col.cmp(&b.col))
294 });
295
296 self.unused_catalog_entries.sort_by(|a, b| {
297 a.path
298 .cmp(&b.path)
299 .then_with(|| {
300 catalog_sort_key(&a.catalog_name).cmp(&catalog_sort_key(&b.catalog_name))
301 })
302 .then(a.catalog_name.cmp(&b.catalog_name))
303 .then(a.entry_name.cmp(&b.entry_name))
304 });
305 for entry in &mut self.unused_catalog_entries {
306 entry.hardcoded_consumers.sort();
307 entry.hardcoded_consumers.dedup();
308 }
309
310 self.unresolved_catalog_references.sort_by(|a, b| {
311 a.path
312 .cmp(&b.path)
313 .then(a.line.cmp(&b.line))
314 .then_with(|| {
315 catalog_sort_key(&a.catalog_name).cmp(&catalog_sort_key(&b.catalog_name))
316 })
317 .then(a.catalog_name.cmp(&b.catalog_name))
318 .then(a.entry_name.cmp(&b.entry_name))
319 });
320 for finding in &mut self.unresolved_catalog_references {
321 finding.available_in_catalogs.sort();
322 finding.available_in_catalogs.dedup();
323 }
324
325 self.unused_dependency_overrides.sort_by(|a, b| {
326 a.path
327 .cmp(&b.path)
328 .then(a.line.cmp(&b.line))
329 .then(a.raw_key.cmp(&b.raw_key))
330 });
331
332 self.misconfigured_dependency_overrides.sort_by(|a, b| {
333 a.path
334 .cmp(&b.path)
335 .then(a.line.cmp(&b.line))
336 .then(a.raw_key.cmp(&b.raw_key))
337 });
338
339 self.feature_flags.sort_by(|a, b| {
340 a.path
341 .cmp(&b.path)
342 .then(a.line.cmp(&b.line))
343 .then(a.flag_name.cmp(&b.flag_name))
344 });
345
346 for usage in &mut self.export_usages {
347 usage.reference_locations.sort_by(|a, b| {
348 a.path
349 .cmp(&b.path)
350 .then(a.line.cmp(&b.line))
351 .then(a.col.cmp(&b.col))
352 });
353 }
354 self.export_usages.sort_by(|a, b| {
355 a.path
356 .cmp(&b.path)
357 .then(a.line.cmp(&b.line))
358 .then(a.export_name.cmp(&b.export_name))
359 });
360 }
361}
362
363fn catalog_sort_key(name: &str) -> (u8, &str) {
365 if name == "default" {
366 (0, name)
367 } else {
368 (1, name)
369 }
370}
371
372#[derive(Debug, Clone, Serialize)]
374pub struct UnusedFile {
375 #[serde(serialize_with = "serde_path::serialize")]
377 pub path: PathBuf,
378}
379
380#[derive(Debug, Clone, Serialize)]
382pub struct UnusedExport {
383 #[serde(serialize_with = "serde_path::serialize")]
385 pub path: PathBuf,
386 pub export_name: String,
388 pub is_type_only: bool,
390 pub line: u32,
392 pub col: u32,
394 pub span_start: u32,
396 pub is_re_export: bool,
398}
399
400#[derive(Debug, Clone, Serialize)]
402pub struct PrivateTypeLeak {
403 #[serde(serialize_with = "serde_path::serialize")]
405 pub path: PathBuf,
406 pub export_name: String,
408 pub type_name: String,
410 pub line: u32,
412 pub col: u32,
414 pub span_start: u32,
416}
417
418#[derive(Debug, Clone, Serialize)]
420pub struct UnusedDependency {
421 pub package_name: String,
423 pub location: DependencyLocation,
425 #[serde(serialize_with = "serde_path::serialize")]
428 pub path: PathBuf,
429 pub line: u32,
431 #[serde(
433 serialize_with = "serde_path::serialize_vec",
434 skip_serializing_if = "Vec::is_empty"
435 )]
436 pub used_in_workspaces: Vec<PathBuf>,
437}
438
439#[derive(Debug, Clone, Serialize)]
456#[serde(rename_all = "camelCase")]
457pub enum DependencyLocation {
458 Dependencies,
460 DevDependencies,
462 OptionalDependencies,
464}
465
466#[derive(Debug, Clone, Serialize)]
468pub struct UnusedMember {
469 #[serde(serialize_with = "serde_path::serialize")]
471 pub path: PathBuf,
472 pub parent_name: String,
474 pub member_name: String,
476 pub kind: MemberKind,
478 pub line: u32,
480 pub col: u32,
482}
483
484#[derive(Debug, Clone, Serialize)]
486pub struct UnresolvedImport {
487 #[serde(serialize_with = "serde_path::serialize")]
489 pub path: PathBuf,
490 pub specifier: String,
492 pub line: u32,
494 pub col: u32,
496 pub specifier_col: u32,
499}
500
501#[derive(Debug, Clone, Serialize)]
503pub struct UnlistedDependency {
504 pub package_name: String,
506 pub imported_from: Vec<ImportSite>,
508}
509
510#[derive(Debug, Clone, Serialize)]
512pub struct ImportSite {
513 #[serde(serialize_with = "serde_path::serialize")]
515 pub path: PathBuf,
516 pub line: u32,
518 pub col: u32,
520}
521
522#[derive(Debug, Clone, Serialize)]
524pub struct DuplicateExport {
525 pub export_name: String,
527 pub locations: Vec<DuplicateLocation>,
529}
530
531#[derive(Debug, Clone, Serialize)]
533pub struct DuplicateLocation {
534 #[serde(serialize_with = "serde_path::serialize")]
536 pub path: PathBuf,
537 pub line: u32,
539 pub col: u32,
541}
542
543#[derive(Debug, Clone, Serialize)]
547pub struct TypeOnlyDependency {
548 pub package_name: String,
550 #[serde(serialize_with = "serde_path::serialize")]
552 pub path: PathBuf,
553 pub line: u32,
555}
556
557#[derive(Debug, Clone, Serialize)]
563pub struct UnusedCatalogEntry {
564 pub entry_name: String,
566 pub catalog_name: String,
569 #[serde(serialize_with = "serde_path::serialize")]
571 pub path: PathBuf,
572 pub line: u32,
574 #[serde(
579 default,
580 serialize_with = "serde_path::serialize_vec",
581 skip_serializing_if = "Vec::is_empty"
582 )]
583 pub hardcoded_consumers: Vec<PathBuf>,
584}
585
586#[derive(Debug, Clone, Serialize)]
597pub struct UnresolvedCatalogReference {
598 pub entry_name: String,
600 pub catalog_name: String,
603 #[serde(serialize_with = "serde_path::serialize")]
610 pub path: PathBuf,
611 pub line: u32,
613 #[serde(default, skip_serializing_if = "Vec::is_empty")]
618 pub available_in_catalogs: Vec<String>,
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
625pub enum DependencyOverrideSource {
626 #[serde(rename = "pnpm-workspace.yaml")]
628 PnpmWorkspaceYaml,
629 #[serde(rename = "package.json")]
631 PnpmPackageJson,
632}
633
634impl DependencyOverrideSource {
635 #[must_use]
638 pub const fn as_label(&self) -> &'static str {
639 match self {
640 Self::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
641 Self::PnpmPackageJson => "package.json",
642 }
643 }
644}
645
646impl std::fmt::Display for DependencyOverrideSource {
647 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
648 f.write_str(self.as_label())
649 }
650}
651
652#[derive(Debug, Clone, Serialize)]
658pub struct UnusedDependencyOverride {
659 pub raw_key: String,
663 pub target_package: String,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
668 pub parent_package: Option<String>,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub version_constraint: Option<String>,
673 pub version_range: String,
675 pub source: DependencyOverrideSource,
677 #[serde(serialize_with = "serde_path::serialize")]
684 pub path: PathBuf,
685 pub line: u32,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub hint: Option<String>,
694}
695
696#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
700#[serde(rename_all = "kebab-case")]
701pub enum DependencyOverrideMisconfigReason {
702 UnparsableKey,
705 EmptyValue,
707}
708
709impl DependencyOverrideMisconfigReason {
710 #[must_use]
712 pub const fn describe(self) -> &'static str {
713 match self {
714 Self::UnparsableKey => "override key cannot be parsed",
715 Self::EmptyValue => "override value is missing or empty",
716 }
717 }
718}
719
720#[derive(Debug, Clone, Serialize)]
724pub struct MisconfiguredDependencyOverride {
725 pub raw_key: String,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub target_package: Option<String>,
736 pub raw_value: String,
739 pub reason: DependencyOverrideMisconfigReason,
741 pub source: DependencyOverrideSource,
743 #[serde(serialize_with = "serde_path::serialize")]
747 pub path: PathBuf,
748 pub line: u32,
750}
751
752#[derive(Debug, Clone, Serialize)]
755pub struct TestOnlyDependency {
756 pub package_name: String,
758 #[serde(serialize_with = "serde_path::serialize")]
760 pub path: PathBuf,
761 pub line: u32,
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize)]
767pub struct CircularDependency {
768 #[serde(serialize_with = "serde_path::serialize_vec")]
770 pub files: Vec<PathBuf>,
771 pub length: usize,
773 #[serde(default)]
775 pub line: u32,
776 #[serde(default)]
778 pub col: u32,
779 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
781 pub is_cross_package: bool,
782}
783
784#[derive(Debug, Clone, Serialize)]
786pub struct BoundaryViolation {
787 #[serde(serialize_with = "serde_path::serialize")]
789 pub from_path: PathBuf,
790 #[serde(serialize_with = "serde_path::serialize")]
792 pub to_path: PathBuf,
793 pub from_zone: String,
795 pub to_zone: String,
797 pub import_specifier: String,
799 pub line: u32,
801 pub col: u32,
803}
804
805#[derive(Debug, Clone, Serialize)]
807#[serde(rename_all = "snake_case", tag = "type")]
808pub enum SuppressionOrigin {
809 Comment {
811 #[serde(skip_serializing_if = "Option::is_none")]
813 issue_kind: Option<String>,
814 is_file_level: bool,
816 },
817 JsdocTag {
819 export_name: String,
821 },
822}
823
824#[derive(Debug, Clone, Serialize)]
826pub struct StaleSuppression {
827 #[serde(serialize_with = "serde_path::serialize")]
829 pub path: PathBuf,
830 pub line: u32,
832 pub col: u32,
834 pub origin: SuppressionOrigin,
836}
837
838impl StaleSuppression {
839 #[must_use]
841 pub fn description(&self) -> String {
842 match &self.origin {
843 SuppressionOrigin::Comment {
844 issue_kind,
845 is_file_level,
846 } => {
847 let directive = if *is_file_level {
848 "fallow-ignore-file"
849 } else {
850 "fallow-ignore-next-line"
851 };
852 match issue_kind {
853 Some(kind) => format!("// {directive} {kind}"),
854 None => format!("// {directive}"),
855 }
856 }
857 SuppressionOrigin::JsdocTag { export_name } => {
858 format!("@expected-unused on {export_name}")
859 }
860 }
861 }
862
863 #[must_use]
865 pub fn explanation(&self) -> String {
866 match &self.origin {
867 SuppressionOrigin::Comment {
868 issue_kind,
869 is_file_level,
870 } => {
871 let scope = if *is_file_level {
872 "in this file"
873 } else {
874 "on the next line"
875 };
876 match issue_kind {
877 Some(kind) => format!("no {kind} issue found {scope}"),
878 None => format!("no issues found {scope}"),
879 }
880 }
881 SuppressionOrigin::JsdocTag { export_name } => {
882 format!("{export_name} is now used")
883 }
884 }
885 }
886
887 #[must_use]
889 pub fn suppressed_kind(&self) -> Option<IssueKind> {
890 match &self.origin {
891 SuppressionOrigin::Comment { issue_kind, .. } => {
892 issue_kind.as_deref().and_then(IssueKind::parse)
893 }
894 SuppressionOrigin::JsdocTag { .. } => None,
895 }
896 }
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
901#[serde(rename_all = "snake_case")]
902pub enum FlagKind {
903 EnvironmentVariable,
905 SdkCall,
907 ConfigObject,
909}
910
911#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
913#[serde(rename_all = "snake_case")]
914pub enum FlagConfidence {
915 Low,
917 Medium,
919 High,
921}
922
923#[derive(Debug, Clone, Serialize)]
925pub struct FeatureFlag {
926 #[serde(serialize_with = "serde_path::serialize")]
928 pub path: PathBuf,
929 pub flag_name: String,
931 pub kind: FlagKind,
933 pub confidence: FlagConfidence,
935 pub line: u32,
937 pub col: u32,
939 #[serde(skip)]
941 pub guard_span_start: Option<u32>,
942 #[serde(skip)]
944 pub guard_span_end: Option<u32>,
945 #[serde(skip_serializing_if = "Option::is_none")]
947 pub sdk_name: Option<String>,
948 #[serde(skip)]
951 pub guard_line_start: Option<u32>,
952 #[serde(skip)]
954 pub guard_line_end: Option<u32>,
955 #[serde(skip_serializing_if = "Vec::is_empty")]
958 pub guarded_dead_exports: Vec<String>,
959}
960
961const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
963
964#[derive(Debug, Clone, Serialize)]
967pub struct ExportUsage {
968 #[serde(serialize_with = "serde_path::serialize")]
970 pub path: PathBuf,
971 pub export_name: String,
973 pub line: u32,
975 pub col: u32,
977 pub reference_count: usize,
979 pub reference_locations: Vec<ReferenceLocation>,
982}
983
984#[derive(Debug, Clone, Serialize)]
986pub struct ReferenceLocation {
987 #[serde(serialize_with = "serde_path::serialize")]
989 pub path: PathBuf,
990 pub line: u32,
992 pub col: u32,
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999
1000 #[test]
1001 fn empty_results_no_issues() {
1002 let results = AnalysisResults::default();
1003 assert_eq!(results.total_issues(), 0);
1004 assert!(!results.has_issues());
1005 }
1006
1007 #[test]
1008 fn results_with_unused_file() {
1009 let mut results = AnalysisResults::default();
1010 results.unused_files.push(UnusedFile {
1011 path: PathBuf::from("test.ts"),
1012 });
1013 assert_eq!(results.total_issues(), 1);
1014 assert!(results.has_issues());
1015 }
1016
1017 #[test]
1018 fn results_with_unused_export() {
1019 let mut results = AnalysisResults::default();
1020 results.unused_exports.push(UnusedExport {
1021 path: PathBuf::from("test.ts"),
1022 export_name: "foo".to_string(),
1023 is_type_only: false,
1024 line: 1,
1025 col: 0,
1026 span_start: 0,
1027 is_re_export: false,
1028 });
1029 assert_eq!(results.total_issues(), 1);
1030 assert!(results.has_issues());
1031 }
1032
1033 #[test]
1034 fn results_total_counts_all_types() {
1035 let mut results = AnalysisResults::default();
1036 results.unused_files.push(UnusedFile {
1037 path: PathBuf::from("a.ts"),
1038 });
1039 results.unused_exports.push(UnusedExport {
1040 path: PathBuf::from("b.ts"),
1041 export_name: "x".to_string(),
1042 is_type_only: false,
1043 line: 1,
1044 col: 0,
1045 span_start: 0,
1046 is_re_export: false,
1047 });
1048 results.unused_types.push(UnusedExport {
1049 path: PathBuf::from("c.ts"),
1050 export_name: "T".to_string(),
1051 is_type_only: true,
1052 line: 1,
1053 col: 0,
1054 span_start: 0,
1055 is_re_export: false,
1056 });
1057 results.unused_dependencies.push(UnusedDependency {
1058 package_name: "dep".to_string(),
1059 location: DependencyLocation::Dependencies,
1060 path: PathBuf::from("package.json"),
1061 line: 5,
1062 used_in_workspaces: Vec::new(),
1063 });
1064 results.unused_dev_dependencies.push(UnusedDependency {
1065 package_name: "dev".to_string(),
1066 location: DependencyLocation::DevDependencies,
1067 path: PathBuf::from("package.json"),
1068 line: 5,
1069 used_in_workspaces: Vec::new(),
1070 });
1071 results.unused_enum_members.push(UnusedMember {
1072 path: PathBuf::from("d.ts"),
1073 parent_name: "E".to_string(),
1074 member_name: "A".to_string(),
1075 kind: MemberKind::EnumMember,
1076 line: 1,
1077 col: 0,
1078 });
1079 results.unused_class_members.push(UnusedMember {
1080 path: PathBuf::from("e.ts"),
1081 parent_name: "C".to_string(),
1082 member_name: "m".to_string(),
1083 kind: MemberKind::ClassMethod,
1084 line: 1,
1085 col: 0,
1086 });
1087 results.unresolved_imports.push(UnresolvedImport {
1088 path: PathBuf::from("f.ts"),
1089 specifier: "./missing".to_string(),
1090 line: 1,
1091 col: 0,
1092 specifier_col: 0,
1093 });
1094 results.unlisted_dependencies.push(UnlistedDependency {
1095 package_name: "unlisted".to_string(),
1096 imported_from: vec![ImportSite {
1097 path: PathBuf::from("g.ts"),
1098 line: 1,
1099 col: 0,
1100 }],
1101 });
1102 results.duplicate_exports.push(DuplicateExport {
1103 export_name: "dup".to_string(),
1104 locations: vec![
1105 DuplicateLocation {
1106 path: PathBuf::from("h.ts"),
1107 line: 15,
1108 col: 0,
1109 },
1110 DuplicateLocation {
1111 path: PathBuf::from("i.ts"),
1112 line: 30,
1113 col: 0,
1114 },
1115 ],
1116 });
1117 results.unused_optional_dependencies.push(UnusedDependency {
1118 package_name: "optional".to_string(),
1119 location: DependencyLocation::OptionalDependencies,
1120 path: PathBuf::from("package.json"),
1121 line: 5,
1122 used_in_workspaces: Vec::new(),
1123 });
1124 results.type_only_dependencies.push(TypeOnlyDependency {
1125 package_name: "type-only".to_string(),
1126 path: PathBuf::from("package.json"),
1127 line: 8,
1128 });
1129 results.test_only_dependencies.push(TestOnlyDependency {
1130 package_name: "test-only".to_string(),
1131 path: PathBuf::from("package.json"),
1132 line: 9,
1133 });
1134 results.circular_dependencies.push(CircularDependency {
1135 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1136 length: 2,
1137 line: 3,
1138 col: 0,
1139 is_cross_package: false,
1140 });
1141 results.boundary_violations.push(BoundaryViolation {
1142 from_path: PathBuf::from("src/ui/Button.tsx"),
1143 to_path: PathBuf::from("src/db/queries.ts"),
1144 from_zone: "ui".to_string(),
1145 to_zone: "database".to_string(),
1146 import_specifier: "../db/queries".to_string(),
1147 line: 3,
1148 col: 0,
1149 });
1150
1151 assert_eq!(results.total_issues(), 15);
1153 assert!(results.has_issues());
1154 }
1155
1156 #[test]
1159 fn total_issues_and_has_issues_are_consistent() {
1160 let results = AnalysisResults::default();
1161 assert_eq!(results.total_issues(), 0);
1162 assert!(!results.has_issues());
1163 assert_eq!(results.total_issues() > 0, results.has_issues());
1164 }
1165
1166 #[test]
1169 fn total_issues_sums_all_categories_independently() {
1170 let mut results = AnalysisResults::default();
1171 results.unused_files.push(UnusedFile {
1172 path: PathBuf::from("a.ts"),
1173 });
1174 assert_eq!(results.total_issues(), 1);
1175
1176 results.unused_files.push(UnusedFile {
1177 path: PathBuf::from("b.ts"),
1178 });
1179 assert_eq!(results.total_issues(), 2);
1180
1181 results.unresolved_imports.push(UnresolvedImport {
1182 path: PathBuf::from("c.ts"),
1183 specifier: "./missing".to_string(),
1184 line: 1,
1185 col: 0,
1186 specifier_col: 0,
1187 });
1188 assert_eq!(results.total_issues(), 3);
1189 }
1190
1191 #[test]
1194 fn default_results_all_fields_empty() {
1195 let r = AnalysisResults::default();
1196 assert!(r.unused_files.is_empty());
1197 assert!(r.unused_exports.is_empty());
1198 assert!(r.unused_types.is_empty());
1199 assert!(r.unused_dependencies.is_empty());
1200 assert!(r.unused_dev_dependencies.is_empty());
1201 assert!(r.unused_optional_dependencies.is_empty());
1202 assert!(r.unused_enum_members.is_empty());
1203 assert!(r.unused_class_members.is_empty());
1204 assert!(r.unresolved_imports.is_empty());
1205 assert!(r.unlisted_dependencies.is_empty());
1206 assert!(r.duplicate_exports.is_empty());
1207 assert!(r.type_only_dependencies.is_empty());
1208 assert!(r.test_only_dependencies.is_empty());
1209 assert!(r.circular_dependencies.is_empty());
1210 assert!(r.boundary_violations.is_empty());
1211 assert!(r.unused_catalog_entries.is_empty());
1212 assert!(r.unresolved_catalog_references.is_empty());
1213 assert!(r.export_usages.is_empty());
1214 }
1215
1216 #[test]
1219 fn entry_point_summary_default() {
1220 let summary = EntryPointSummary::default();
1221 assert_eq!(summary.total, 0);
1222 assert!(summary.by_source.is_empty());
1223 }
1224
1225 #[test]
1226 fn entry_point_summary_not_in_default_results() {
1227 let r = AnalysisResults::default();
1228 assert!(r.entry_point_summary.is_none());
1229 }
1230
1231 #[test]
1232 fn entry_point_summary_some_preserves_data() {
1233 let r = AnalysisResults {
1234 entry_point_summary: Some(EntryPointSummary {
1235 total: 5,
1236 by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
1237 }),
1238 ..AnalysisResults::default()
1239 };
1240 let summary = r.entry_point_summary.as_ref().unwrap();
1241 assert_eq!(summary.total, 5);
1242 assert_eq!(summary.by_source.len(), 2);
1243 assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
1244 }
1245
1246 #[test]
1249 fn sort_unused_files_by_path() {
1250 let mut r = AnalysisResults::default();
1251 r.unused_files.push(UnusedFile {
1252 path: PathBuf::from("z.ts"),
1253 });
1254 r.unused_files.push(UnusedFile {
1255 path: PathBuf::from("a.ts"),
1256 });
1257 r.unused_files.push(UnusedFile {
1258 path: PathBuf::from("m.ts"),
1259 });
1260 r.sort();
1261 let paths: Vec<_> = r
1262 .unused_files
1263 .iter()
1264 .map(|f| f.path.to_string_lossy().to_string())
1265 .collect();
1266 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
1267 }
1268
1269 #[test]
1272 fn sort_unused_exports_by_path_line_name() {
1273 let mut r = AnalysisResults::default();
1274 let mk = |path: &str, line: u32, name: &str| UnusedExport {
1275 path: PathBuf::from(path),
1276 export_name: name.to_string(),
1277 is_type_only: false,
1278 line,
1279 col: 0,
1280 span_start: 0,
1281 is_re_export: false,
1282 };
1283 r.unused_exports.push(mk("b.ts", 5, "beta"));
1284 r.unused_exports.push(mk("a.ts", 10, "zeta"));
1285 r.unused_exports.push(mk("a.ts", 10, "alpha"));
1286 r.unused_exports.push(mk("a.ts", 1, "gamma"));
1287 r.sort();
1288 let keys: Vec<_> = r
1289 .unused_exports
1290 .iter()
1291 .map(|e| format!("{}:{}:{}", e.path.to_string_lossy(), e.line, e.export_name))
1292 .collect();
1293 assert_eq!(
1294 keys,
1295 vec![
1296 "a.ts:1:gamma",
1297 "a.ts:10:alpha",
1298 "a.ts:10:zeta",
1299 "b.ts:5:beta"
1300 ]
1301 );
1302 }
1303
1304 #[test]
1307 fn sort_unused_types_by_path_line_name() {
1308 let mut r = AnalysisResults::default();
1309 let mk = |path: &str, line: u32, name: &str| UnusedExport {
1310 path: PathBuf::from(path),
1311 export_name: name.to_string(),
1312 is_type_only: true,
1313 line,
1314 col: 0,
1315 span_start: 0,
1316 is_re_export: false,
1317 };
1318 r.unused_types.push(mk("z.ts", 1, "Z"));
1319 r.unused_types.push(mk("a.ts", 1, "A"));
1320 r.sort();
1321 assert_eq!(r.unused_types[0].path, PathBuf::from("a.ts"));
1322 assert_eq!(r.unused_types[1].path, PathBuf::from("z.ts"));
1323 }
1324
1325 #[test]
1328 fn sort_unused_dependencies_by_path_line_name() {
1329 let mut r = AnalysisResults::default();
1330 let mk = |path: &str, line: u32, name: &str| UnusedDependency {
1331 package_name: name.to_string(),
1332 location: DependencyLocation::Dependencies,
1333 path: PathBuf::from(path),
1334 line,
1335 used_in_workspaces: Vec::new(),
1336 };
1337 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
1338 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
1339 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
1340 r.sort();
1341 let names: Vec<_> = r
1342 .unused_dependencies
1343 .iter()
1344 .map(|d| d.package_name.as_str())
1345 .collect();
1346 assert_eq!(names, vec!["axios", "react", "zlib"]);
1347 }
1348
1349 #[test]
1352 fn sort_unused_dev_dependencies() {
1353 let mut r = AnalysisResults::default();
1354 r.unused_dev_dependencies.push(UnusedDependency {
1355 package_name: "vitest".to_string(),
1356 location: DependencyLocation::DevDependencies,
1357 path: PathBuf::from("package.json"),
1358 line: 10,
1359 used_in_workspaces: Vec::new(),
1360 });
1361 r.unused_dev_dependencies.push(UnusedDependency {
1362 package_name: "jest".to_string(),
1363 location: DependencyLocation::DevDependencies,
1364 path: PathBuf::from("package.json"),
1365 line: 5,
1366 used_in_workspaces: Vec::new(),
1367 });
1368 r.sort();
1369 assert_eq!(r.unused_dev_dependencies[0].package_name, "jest");
1370 assert_eq!(r.unused_dev_dependencies[1].package_name, "vitest");
1371 }
1372
1373 #[test]
1376 fn sort_unused_optional_dependencies() {
1377 let mut r = AnalysisResults::default();
1378 r.unused_optional_dependencies.push(UnusedDependency {
1379 package_name: "zod".to_string(),
1380 location: DependencyLocation::OptionalDependencies,
1381 path: PathBuf::from("package.json"),
1382 line: 3,
1383 used_in_workspaces: Vec::new(),
1384 });
1385 r.unused_optional_dependencies.push(UnusedDependency {
1386 package_name: "ajv".to_string(),
1387 location: DependencyLocation::OptionalDependencies,
1388 path: PathBuf::from("package.json"),
1389 line: 2,
1390 used_in_workspaces: Vec::new(),
1391 });
1392 r.sort();
1393 assert_eq!(r.unused_optional_dependencies[0].package_name, "ajv");
1394 assert_eq!(r.unused_optional_dependencies[1].package_name, "zod");
1395 }
1396
1397 #[test]
1400 fn sort_unused_enum_members_by_path_line_parent_member() {
1401 let mut r = AnalysisResults::default();
1402 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1403 path: PathBuf::from(path),
1404 parent_name: parent.to_string(),
1405 member_name: member.to_string(),
1406 kind: MemberKind::EnumMember,
1407 line,
1408 col: 0,
1409 };
1410 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
1411 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
1412 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
1413 r.sort();
1414 let keys: Vec<_> = r
1415 .unused_enum_members
1416 .iter()
1417 .map(|m| format!("{}:{}", m.parent_name, m.member_name))
1418 .collect();
1419 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
1420 }
1421
1422 #[test]
1425 fn sort_unused_class_members() {
1426 let mut r = AnalysisResults::default();
1427 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
1428 path: PathBuf::from(path),
1429 parent_name: parent.to_string(),
1430 member_name: member.to_string(),
1431 kind: MemberKind::ClassMethod,
1432 line,
1433 col: 0,
1434 };
1435 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1436 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1437 r.sort();
1438 assert_eq!(r.unused_class_members[0].path, PathBuf::from("a.ts"));
1439 assert_eq!(r.unused_class_members[1].path, PathBuf::from("b.ts"));
1440 }
1441
1442 #[test]
1445 fn sort_unresolved_imports_by_path_line_col_specifier() {
1446 let mut r = AnalysisResults::default();
1447 let mk = |path: &str, line: u32, col: u32, spec: &str| UnresolvedImport {
1448 path: PathBuf::from(path),
1449 specifier: spec.to_string(),
1450 line,
1451 col,
1452 specifier_col: 0,
1453 };
1454 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1455 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1456 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1457 r.sort();
1458 let specs: Vec<_> = r
1459 .unresolved_imports
1460 .iter()
1461 .map(|i| i.specifier.as_str())
1462 .collect();
1463 assert_eq!(specs, vec!["./m", "./a", "./z"]);
1464 }
1465
1466 #[test]
1469 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1470 let mut r = AnalysisResults::default();
1471 r.unlisted_dependencies.push(UnlistedDependency {
1472 package_name: "zod".to_string(),
1473 imported_from: vec![
1474 ImportSite {
1475 path: PathBuf::from("b.ts"),
1476 line: 10,
1477 col: 0,
1478 },
1479 ImportSite {
1480 path: PathBuf::from("a.ts"),
1481 line: 1,
1482 col: 0,
1483 },
1484 ],
1485 });
1486 r.unlisted_dependencies.push(UnlistedDependency {
1487 package_name: "axios".to_string(),
1488 imported_from: vec![ImportSite {
1489 path: PathBuf::from("c.ts"),
1490 line: 1,
1491 col: 0,
1492 }],
1493 });
1494 r.sort();
1495
1496 assert_eq!(r.unlisted_dependencies[0].package_name, "axios");
1498 assert_eq!(r.unlisted_dependencies[1].package_name, "zod");
1499
1500 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1502 .imported_from
1503 .iter()
1504 .map(|s| s.path.to_string_lossy().to_string())
1505 .collect();
1506 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1507 }
1508
1509 #[test]
1512 fn sort_duplicate_exports_by_name_and_inner_locations() {
1513 let mut r = AnalysisResults::default();
1514 r.duplicate_exports.push(DuplicateExport {
1515 export_name: "z".to_string(),
1516 locations: vec![
1517 DuplicateLocation {
1518 path: PathBuf::from("c.ts"),
1519 line: 1,
1520 col: 0,
1521 },
1522 DuplicateLocation {
1523 path: PathBuf::from("a.ts"),
1524 line: 5,
1525 col: 0,
1526 },
1527 ],
1528 });
1529 r.duplicate_exports.push(DuplicateExport {
1530 export_name: "a".to_string(),
1531 locations: vec![DuplicateLocation {
1532 path: PathBuf::from("b.ts"),
1533 line: 1,
1534 col: 0,
1535 }],
1536 });
1537 r.sort();
1538
1539 assert_eq!(r.duplicate_exports[0].export_name, "a");
1541 assert_eq!(r.duplicate_exports[1].export_name, "z");
1542
1543 let z_locs: Vec<_> = r.duplicate_exports[1]
1545 .locations
1546 .iter()
1547 .map(|l| l.path.to_string_lossy().to_string())
1548 .collect();
1549 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1550 }
1551
1552 #[test]
1555 fn sort_type_only_dependencies() {
1556 let mut r = AnalysisResults::default();
1557 r.type_only_dependencies.push(TypeOnlyDependency {
1558 package_name: "zod".to_string(),
1559 path: PathBuf::from("package.json"),
1560 line: 10,
1561 });
1562 r.type_only_dependencies.push(TypeOnlyDependency {
1563 package_name: "ajv".to_string(),
1564 path: PathBuf::from("package.json"),
1565 line: 5,
1566 });
1567 r.sort();
1568 assert_eq!(r.type_only_dependencies[0].package_name, "ajv");
1569 assert_eq!(r.type_only_dependencies[1].package_name, "zod");
1570 }
1571
1572 #[test]
1575 fn sort_test_only_dependencies() {
1576 let mut r = AnalysisResults::default();
1577 r.test_only_dependencies.push(TestOnlyDependency {
1578 package_name: "vitest".to_string(),
1579 path: PathBuf::from("package.json"),
1580 line: 15,
1581 });
1582 r.test_only_dependencies.push(TestOnlyDependency {
1583 package_name: "jest".to_string(),
1584 path: PathBuf::from("package.json"),
1585 line: 10,
1586 });
1587 r.sort();
1588 assert_eq!(r.test_only_dependencies[0].package_name, "jest");
1589 assert_eq!(r.test_only_dependencies[1].package_name, "vitest");
1590 }
1591
1592 #[test]
1595 fn sort_circular_dependencies_by_files_then_length() {
1596 let mut r = AnalysisResults::default();
1597 r.circular_dependencies.push(CircularDependency {
1598 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1599 length: 2,
1600 line: 1,
1601 col: 0,
1602 is_cross_package: false,
1603 });
1604 r.circular_dependencies.push(CircularDependency {
1605 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1606 length: 2,
1607 line: 1,
1608 col: 0,
1609 is_cross_package: true,
1610 });
1611 r.sort();
1612 assert_eq!(r.circular_dependencies[0].files[0], PathBuf::from("a.ts"));
1613 assert_eq!(r.circular_dependencies[1].files[0], PathBuf::from("b.ts"));
1614 }
1615
1616 #[test]
1619 fn sort_boundary_violations() {
1620 let mut r = AnalysisResults::default();
1621 let mk = |from: &str, line: u32, col: u32, to: &str| BoundaryViolation {
1622 from_path: PathBuf::from(from),
1623 to_path: PathBuf::from(to),
1624 from_zone: "a".to_string(),
1625 to_zone: "b".to_string(),
1626 import_specifier: to.to_string(),
1627 line,
1628 col,
1629 };
1630 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
1631 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
1632 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
1633 r.sort();
1634 let from_paths: Vec<_> = r
1635 .boundary_violations
1636 .iter()
1637 .map(|v| format!("{}:{}", v.from_path.to_string_lossy(), v.line))
1638 .collect();
1639 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
1640 }
1641
1642 #[test]
1645 fn sort_export_usages_and_inner_reference_locations() {
1646 let mut r = AnalysisResults::default();
1647 r.export_usages.push(ExportUsage {
1648 path: PathBuf::from("z.ts"),
1649 export_name: "foo".to_string(),
1650 line: 1,
1651 col: 0,
1652 reference_count: 2,
1653 reference_locations: vec![
1654 ReferenceLocation {
1655 path: PathBuf::from("c.ts"),
1656 line: 10,
1657 col: 0,
1658 },
1659 ReferenceLocation {
1660 path: PathBuf::from("a.ts"),
1661 line: 5,
1662 col: 0,
1663 },
1664 ],
1665 });
1666 r.export_usages.push(ExportUsage {
1667 path: PathBuf::from("a.ts"),
1668 export_name: "bar".to_string(),
1669 line: 1,
1670 col: 0,
1671 reference_count: 1,
1672 reference_locations: vec![ReferenceLocation {
1673 path: PathBuf::from("b.ts"),
1674 line: 1,
1675 col: 0,
1676 }],
1677 });
1678 r.sort();
1679
1680 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
1682 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
1683
1684 let refs: Vec<_> = r.export_usages[1]
1686 .reference_locations
1687 .iter()
1688 .map(|l| l.path.to_string_lossy().to_string())
1689 .collect();
1690 assert_eq!(refs, vec!["a.ts", "c.ts"]);
1691 }
1692
1693 #[test]
1696 fn sort_empty_results_is_noop() {
1697 let mut r = AnalysisResults::default();
1698 r.sort(); assert_eq!(r.total_issues(), 0);
1700 }
1701
1702 #[test]
1705 fn sort_single_element_lists_stable() {
1706 let mut r = AnalysisResults::default();
1707 r.unused_files.push(UnusedFile {
1708 path: PathBuf::from("only.ts"),
1709 });
1710 r.sort();
1711 assert_eq!(r.unused_files[0].path, PathBuf::from("only.ts"));
1712 }
1713
1714 #[test]
1717 fn serialize_empty_results() {
1718 let r = AnalysisResults::default();
1719 let json = serde_json::to_value(&r).unwrap();
1720
1721 assert!(json["unused_files"].as_array().unwrap().is_empty());
1723 assert!(json["unused_exports"].as_array().unwrap().is_empty());
1724 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
1725
1726 assert!(json.get("export_usages").is_none());
1728 assert!(json.get("entry_point_summary").is_none());
1729 }
1730
1731 #[test]
1732 fn serialize_unused_file_path() {
1733 let r = UnusedFile {
1734 path: PathBuf::from("src/utils/index.ts"),
1735 };
1736 let json = serde_json::to_value(&r).unwrap();
1737 assert_eq!(json["path"], "src/utils/index.ts");
1738 }
1739
1740 #[test]
1741 fn serialize_dependency_location_camel_case() {
1742 let dep = UnusedDependency {
1743 package_name: "react".to_string(),
1744 location: DependencyLocation::DevDependencies,
1745 path: PathBuf::from("package.json"),
1746 line: 5,
1747 used_in_workspaces: Vec::new(),
1748 };
1749 let json = serde_json::to_value(&dep).unwrap();
1750 assert_eq!(json["location"], "devDependencies");
1751
1752 let dep2 = UnusedDependency {
1753 package_name: "react".to_string(),
1754 location: DependencyLocation::Dependencies,
1755 path: PathBuf::from("package.json"),
1756 line: 3,
1757 used_in_workspaces: Vec::new(),
1758 };
1759 let json2 = serde_json::to_value(&dep2).unwrap();
1760 assert_eq!(json2["location"], "dependencies");
1761
1762 let dep3 = UnusedDependency {
1763 package_name: "fsevents".to_string(),
1764 location: DependencyLocation::OptionalDependencies,
1765 path: PathBuf::from("package.json"),
1766 line: 7,
1767 used_in_workspaces: Vec::new(),
1768 };
1769 let json3 = serde_json::to_value(&dep3).unwrap();
1770 assert_eq!(json3["location"], "optionalDependencies");
1771 }
1772
1773 #[test]
1774 fn serialize_circular_dependency_skips_false_cross_package() {
1775 let cd = CircularDependency {
1776 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1777 length: 2,
1778 line: 1,
1779 col: 0,
1780 is_cross_package: false,
1781 };
1782 let json = serde_json::to_value(&cd).unwrap();
1783 assert!(json.get("is_cross_package").is_none());
1785 }
1786
1787 #[test]
1788 fn serialize_circular_dependency_includes_true_cross_package() {
1789 let cd = CircularDependency {
1790 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1791 length: 2,
1792 line: 1,
1793 col: 0,
1794 is_cross_package: true,
1795 };
1796 let json = serde_json::to_value(&cd).unwrap();
1797 assert_eq!(json["is_cross_package"], true);
1798 }
1799
1800 #[test]
1801 fn serialize_unused_export_fields() {
1802 let e = UnusedExport {
1803 path: PathBuf::from("src/mod.ts"),
1804 export_name: "helper".to_string(),
1805 is_type_only: true,
1806 line: 42,
1807 col: 7,
1808 span_start: 100,
1809 is_re_export: true,
1810 };
1811 let json = serde_json::to_value(&e).unwrap();
1812 assert_eq!(json["path"], "src/mod.ts");
1813 assert_eq!(json["export_name"], "helper");
1814 assert_eq!(json["is_type_only"], true);
1815 assert_eq!(json["line"], 42);
1816 assert_eq!(json["col"], 7);
1817 assert_eq!(json["span_start"], 100);
1818 assert_eq!(json["is_re_export"], true);
1819 }
1820
1821 #[test]
1822 fn serialize_boundary_violation_fields() {
1823 let v = BoundaryViolation {
1824 from_path: PathBuf::from("src/ui/button.tsx"),
1825 to_path: PathBuf::from("src/db/queries.ts"),
1826 from_zone: "ui".to_string(),
1827 to_zone: "db".to_string(),
1828 import_specifier: "../db/queries".to_string(),
1829 line: 3,
1830 col: 0,
1831 };
1832 let json = serde_json::to_value(&v).unwrap();
1833 assert_eq!(json["from_path"], "src/ui/button.tsx");
1834 assert_eq!(json["to_path"], "src/db/queries.ts");
1835 assert_eq!(json["from_zone"], "ui");
1836 assert_eq!(json["to_zone"], "db");
1837 assert_eq!(json["import_specifier"], "../db/queries");
1838 }
1839
1840 #[test]
1841 fn serialize_unlisted_dependency_with_import_sites() {
1842 let d = UnlistedDependency {
1843 package_name: "chalk".to_string(),
1844 imported_from: vec![
1845 ImportSite {
1846 path: PathBuf::from("a.ts"),
1847 line: 1,
1848 col: 0,
1849 },
1850 ImportSite {
1851 path: PathBuf::from("b.ts"),
1852 line: 5,
1853 col: 3,
1854 },
1855 ],
1856 };
1857 let json = serde_json::to_value(&d).unwrap();
1858 assert_eq!(json["package_name"], "chalk");
1859 let sites = json["imported_from"].as_array().unwrap();
1860 assert_eq!(sites.len(), 2);
1861 assert_eq!(sites[0]["path"], "a.ts");
1862 assert_eq!(sites[1]["line"], 5);
1863 }
1864
1865 #[test]
1866 fn serialize_duplicate_export_with_locations() {
1867 let d = DuplicateExport {
1868 export_name: "Button".to_string(),
1869 locations: vec![
1870 DuplicateLocation {
1871 path: PathBuf::from("src/a.ts"),
1872 line: 10,
1873 col: 0,
1874 },
1875 DuplicateLocation {
1876 path: PathBuf::from("src/b.ts"),
1877 line: 20,
1878 col: 5,
1879 },
1880 ],
1881 };
1882 let json = serde_json::to_value(&d).unwrap();
1883 assert_eq!(json["export_name"], "Button");
1884 let locs = json["locations"].as_array().unwrap();
1885 assert_eq!(locs.len(), 2);
1886 assert_eq!(locs[0]["line"], 10);
1887 assert_eq!(locs[1]["col"], 5);
1888 }
1889
1890 #[test]
1891 fn serialize_type_only_dependency() {
1892 let d = TypeOnlyDependency {
1893 package_name: "@types/react".to_string(),
1894 path: PathBuf::from("package.json"),
1895 line: 12,
1896 };
1897 let json = serde_json::to_value(&d).unwrap();
1898 assert_eq!(json["package_name"], "@types/react");
1899 assert_eq!(json["line"], 12);
1900 }
1901
1902 #[test]
1903 fn serialize_test_only_dependency() {
1904 let d = TestOnlyDependency {
1905 package_name: "vitest".to_string(),
1906 path: PathBuf::from("package.json"),
1907 line: 8,
1908 };
1909 let json = serde_json::to_value(&d).unwrap();
1910 assert_eq!(json["package_name"], "vitest");
1911 assert_eq!(json["line"], 8);
1912 }
1913
1914 #[test]
1915 fn serialize_unused_member() {
1916 let m = UnusedMember {
1917 path: PathBuf::from("enums.ts"),
1918 parent_name: "Status".to_string(),
1919 member_name: "Pending".to_string(),
1920 kind: MemberKind::EnumMember,
1921 line: 3,
1922 col: 4,
1923 };
1924 let json = serde_json::to_value(&m).unwrap();
1925 assert_eq!(json["parent_name"], "Status");
1926 assert_eq!(json["member_name"], "Pending");
1927 assert_eq!(json["line"], 3);
1928 }
1929
1930 #[test]
1931 fn serialize_unresolved_import() {
1932 let i = UnresolvedImport {
1933 path: PathBuf::from("app.ts"),
1934 specifier: "./missing-module".to_string(),
1935 line: 7,
1936 col: 0,
1937 specifier_col: 21,
1938 };
1939 let json = serde_json::to_value(&i).unwrap();
1940 assert_eq!(json["specifier"], "./missing-module");
1941 assert_eq!(json["specifier_col"], 21);
1942 }
1943
1944 #[test]
1947 fn deserialize_circular_dependency_with_defaults() {
1948 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
1950 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1951 assert_eq!(cd.files.len(), 2);
1952 assert_eq!(cd.length, 2);
1953 assert_eq!(cd.line, 0);
1954 assert_eq!(cd.col, 0);
1955 assert!(!cd.is_cross_package);
1956 }
1957
1958 #[test]
1959 fn deserialize_circular_dependency_with_all_fields() {
1960 let json =
1961 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
1962 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1963 assert_eq!(cd.line, 5);
1964 assert_eq!(cd.col, 10);
1965 assert!(cd.is_cross_package);
1966 }
1967
1968 #[test]
1971 fn clone_results_are_independent() {
1972 let mut r = AnalysisResults::default();
1973 r.unused_files.push(UnusedFile {
1974 path: PathBuf::from("a.ts"),
1975 });
1976 let mut cloned = r.clone();
1977 cloned.unused_files.push(UnusedFile {
1978 path: PathBuf::from("b.ts"),
1979 });
1980 assert_eq!(r.total_issues(), 1);
1981 assert_eq!(cloned.total_issues(), 2);
1982 }
1983
1984 #[test]
1987 fn export_usages_not_counted_in_total_issues() {
1988 let mut r = AnalysisResults::default();
1989 r.export_usages.push(ExportUsage {
1990 path: PathBuf::from("mod.ts"),
1991 export_name: "foo".to_string(),
1992 line: 1,
1993 col: 0,
1994 reference_count: 3,
1995 reference_locations: vec![],
1996 });
1997 assert_eq!(r.total_issues(), 0);
1999 assert!(!r.has_issues());
2000 }
2001
2002 #[test]
2005 fn entry_point_summary_not_counted_in_total_issues() {
2006 let r = AnalysisResults {
2007 entry_point_summary: Some(EntryPointSummary {
2008 total: 10,
2009 by_source: vec![("config".to_string(), 10)],
2010 }),
2011 ..AnalysisResults::default()
2012 };
2013 assert_eq!(r.total_issues(), 0);
2014 assert!(!r.has_issues());
2015 }
2016}