1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::extract::MemberKind;
8use crate::output_dead_code::{
9 BoundaryViolationFinding, CircularDependencyFinding, DuplicateExportFinding,
10 EmptyCatalogGroupFinding, MisconfiguredDependencyOverrideFinding, PrivateTypeLeakFinding,
11 TestOnlyDependencyFinding, TypeOnlyDependencyFinding, UnlistedDependencyFinding,
12 UnresolvedCatalogReferenceFinding, UnresolvedImportFinding, UnusedCatalogEntryFinding,
13 UnusedClassMemberFinding, UnusedDependencyFinding, UnusedDependencyOverrideFinding,
14 UnusedDevDependencyFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
15 UnusedOptionalDependencyFinding, UnusedTypeFinding,
16};
17use crate::serde_path;
18use crate::suppress::IssueKind;
19
20#[derive(Debug, Clone, Default)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct EntryPointSummary {
27 pub total: usize,
29 pub by_source: Vec<(String, usize)>,
32}
33
34#[derive(Debug, Default, Clone, Serialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57pub struct AnalysisResults {
58 pub unused_files: Vec<UnusedFileFinding>,
62 pub unused_exports: Vec<UnusedExportFinding>,
66 pub unused_types: Vec<UnusedTypeFinding>,
71 pub private_type_leaks: Vec<PrivateTypeLeakFinding>,
75 pub unused_dependencies: Vec<UnusedDependencyFinding>,
80 pub unused_dev_dependencies: Vec<UnusedDevDependencyFinding>,
85 pub unused_optional_dependencies: Vec<UnusedOptionalDependencyFinding>,
89 pub unused_enum_members: Vec<UnusedEnumMemberFinding>,
93 pub unused_class_members: Vec<UnusedClassMemberFinding>,
99 pub unresolved_imports: Vec<UnresolvedImportFinding>,
103 pub unlisted_dependencies: Vec<UnlistedDependencyFinding>,
106 pub duplicate_exports: Vec<DuplicateExportFinding>,
111 pub type_only_dependencies: Vec<TypeOnlyDependencyFinding>,
115 #[serde(default)]
118 pub test_only_dependencies: Vec<TestOnlyDependencyFinding>,
119 pub circular_dependencies: Vec<CircularDependencyFinding>,
123 #[serde(default)]
127 pub boundary_violations: Vec<BoundaryViolationFinding>,
128 #[serde(default)]
130 pub stale_suppressions: Vec<StaleSuppression>,
131 #[serde(default)]
137 pub unused_catalog_entries: Vec<UnusedCatalogEntryFinding>,
138 #[serde(default)]
142 pub empty_catalog_groups: Vec<EmptyCatalogGroupFinding>,
143 #[serde(default)]
150 pub unresolved_catalog_references: Vec<UnresolvedCatalogReferenceFinding>,
151 #[serde(default)]
158 pub unused_dependency_overrides: Vec<UnusedDependencyOverrideFinding>,
159 #[serde(default)]
164 pub misconfigured_dependency_overrides: Vec<MisconfiguredDependencyOverrideFinding>,
165 #[serde(skip)]
169 pub suppression_count: usize,
170 #[serde(skip)]
173 pub feature_flags: Vec<FeatureFlag>,
174 #[serde(skip)]
178 pub export_usages: Vec<ExportUsage>,
179 #[serde(skip)]
183 pub entry_point_summary: Option<EntryPointSummary>,
184}
185
186impl AnalysisResults {
187 #[must_use]
218 pub const fn total_issues(&self) -> usize {
219 self.unused_files.len()
220 + self.unused_exports.len()
221 + self.unused_types.len()
222 + self.private_type_leaks.len()
223 + self.unused_dependencies.len()
224 + self.unused_dev_dependencies.len()
225 + self.unused_optional_dependencies.len()
226 + self.unused_enum_members.len()
227 + self.unused_class_members.len()
228 + self.unresolved_imports.len()
229 + self.unlisted_dependencies.len()
230 + self.duplicate_exports.len()
231 + self.type_only_dependencies.len()
232 + self.test_only_dependencies.len()
233 + self.circular_dependencies.len()
234 + self.boundary_violations.len()
235 + self.stale_suppressions.len()
236 + self.unused_catalog_entries.len()
237 + self.empty_catalog_groups.len()
238 + self.unresolved_catalog_references.len()
239 + self.unused_dependency_overrides.len()
240 + self.misconfigured_dependency_overrides.len()
241 }
242
243 #[must_use]
245 pub const fn has_issues(&self) -> bool {
246 self.total_issues() > 0
247 }
248
249 #[expect(
256 clippy::too_many_lines,
257 reason = "one short sort_by per result array; splitting would add indirection without clarity"
258 )]
259 pub fn sort(&mut self) {
260 self.unused_files
261 .sort_by(|a, b| a.file.path.cmp(&b.file.path));
262
263 self.unused_exports.sort_by(|a, b| {
264 a.export
265 .path
266 .cmp(&b.export.path)
267 .then(a.export.line.cmp(&b.export.line))
268 .then(a.export.export_name.cmp(&b.export.export_name))
269 });
270
271 self.unused_types.sort_by(|a, b| {
272 a.export
273 .path
274 .cmp(&b.export.path)
275 .then(a.export.line.cmp(&b.export.line))
276 .then(a.export.export_name.cmp(&b.export.export_name))
277 });
278
279 self.private_type_leaks.sort_by(|a, b| {
280 a.leak
281 .path
282 .cmp(&b.leak.path)
283 .then(a.leak.line.cmp(&b.leak.line))
284 .then(a.leak.export_name.cmp(&b.leak.export_name))
285 .then(a.leak.type_name.cmp(&b.leak.type_name))
286 });
287
288 self.unused_dependencies.sort_by(|a, b| {
289 a.dep
290 .path
291 .cmp(&b.dep.path)
292 .then(a.dep.line.cmp(&b.dep.line))
293 .then(a.dep.package_name.cmp(&b.dep.package_name))
294 });
295
296 self.unused_dev_dependencies.sort_by(|a, b| {
297 a.dep
298 .path
299 .cmp(&b.dep.path)
300 .then(a.dep.line.cmp(&b.dep.line))
301 .then(a.dep.package_name.cmp(&b.dep.package_name))
302 });
303
304 self.unused_optional_dependencies.sort_by(|a, b| {
305 a.dep
306 .path
307 .cmp(&b.dep.path)
308 .then(a.dep.line.cmp(&b.dep.line))
309 .then(a.dep.package_name.cmp(&b.dep.package_name))
310 });
311
312 self.unused_enum_members.sort_by(|a, b| {
313 a.member
314 .path
315 .cmp(&b.member.path)
316 .then(a.member.line.cmp(&b.member.line))
317 .then(a.member.parent_name.cmp(&b.member.parent_name))
318 .then(a.member.member_name.cmp(&b.member.member_name))
319 });
320
321 self.unused_class_members.sort_by(|a, b| {
322 a.member
323 .path
324 .cmp(&b.member.path)
325 .then(a.member.line.cmp(&b.member.line))
326 .then(a.member.parent_name.cmp(&b.member.parent_name))
327 .then(a.member.member_name.cmp(&b.member.member_name))
328 });
329
330 self.unresolved_imports.sort_by(|a, b| {
331 a.import
332 .path
333 .cmp(&b.import.path)
334 .then(a.import.line.cmp(&b.import.line))
335 .then(a.import.col.cmp(&b.import.col))
336 .then(a.import.specifier.cmp(&b.import.specifier))
337 });
338
339 self.unlisted_dependencies
340 .sort_by(|a, b| a.dep.package_name.cmp(&b.dep.package_name));
341 for dep in &mut self.unlisted_dependencies {
342 dep.dep
343 .imported_from
344 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
345 }
346
347 self.duplicate_exports
348 .sort_by(|a, b| a.export.export_name.cmp(&b.export.export_name));
349 for dup in &mut self.duplicate_exports {
350 dup.export
351 .locations
352 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
353 }
354
355 self.type_only_dependencies.sort_by(|a, b| {
356 a.dep
357 .path
358 .cmp(&b.dep.path)
359 .then(a.dep.line.cmp(&b.dep.line))
360 .then(a.dep.package_name.cmp(&b.dep.package_name))
361 });
362
363 self.test_only_dependencies.sort_by(|a, b| {
364 a.dep
365 .path
366 .cmp(&b.dep.path)
367 .then(a.dep.line.cmp(&b.dep.line))
368 .then(a.dep.package_name.cmp(&b.dep.package_name))
369 });
370
371 self.circular_dependencies.sort_by(|a, b| {
372 a.cycle
373 .files
374 .cmp(&b.cycle.files)
375 .then(a.cycle.length.cmp(&b.cycle.length))
376 });
377
378 self.boundary_violations.sort_by(|a, b| {
379 a.violation
380 .from_path
381 .cmp(&b.violation.from_path)
382 .then(a.violation.line.cmp(&b.violation.line))
383 .then(a.violation.col.cmp(&b.violation.col))
384 .then(a.violation.to_path.cmp(&b.violation.to_path))
385 });
386
387 self.stale_suppressions.sort_by(|a, b| {
388 a.path
389 .cmp(&b.path)
390 .then(a.line.cmp(&b.line))
391 .then(a.col.cmp(&b.col))
392 });
393
394 self.unused_catalog_entries.sort_by(|a, b| {
395 a.entry
396 .path
397 .cmp(&b.entry.path)
398 .then_with(|| {
399 catalog_sort_key(&a.entry.catalog_name)
400 .cmp(&catalog_sort_key(&b.entry.catalog_name))
401 })
402 .then(a.entry.catalog_name.cmp(&b.entry.catalog_name))
403 .then(a.entry.entry_name.cmp(&b.entry.entry_name))
404 });
405 for finding in &mut self.unused_catalog_entries {
406 finding.entry.hardcoded_consumers.sort();
407 finding.entry.hardcoded_consumers.dedup();
408 }
409
410 self.empty_catalog_groups.sort_by(|a, b| {
411 a.group
412 .path
413 .cmp(&b.group.path)
414 .then_with(|| {
415 catalog_sort_key(&a.group.catalog_name)
416 .cmp(&catalog_sort_key(&b.group.catalog_name))
417 })
418 .then(a.group.catalog_name.cmp(&b.group.catalog_name))
419 .then(a.group.line.cmp(&b.group.line))
420 });
421
422 self.unresolved_catalog_references.sort_by(|a, b| {
423 a.reference
424 .path
425 .cmp(&b.reference.path)
426 .then(a.reference.line.cmp(&b.reference.line))
427 .then_with(|| {
428 catalog_sort_key(&a.reference.catalog_name)
429 .cmp(&catalog_sort_key(&b.reference.catalog_name))
430 })
431 .then(a.reference.catalog_name.cmp(&b.reference.catalog_name))
432 .then(a.reference.entry_name.cmp(&b.reference.entry_name))
433 });
434 for finding in &mut self.unresolved_catalog_references {
435 finding.reference.available_in_catalogs.sort();
436 finding.reference.available_in_catalogs.dedup();
437 }
438
439 self.unused_dependency_overrides.sort_by(|a, b| {
440 a.entry
441 .path
442 .cmp(&b.entry.path)
443 .then(a.entry.line.cmp(&b.entry.line))
444 .then(a.entry.raw_key.cmp(&b.entry.raw_key))
445 });
446
447 self.misconfigured_dependency_overrides.sort_by(|a, b| {
448 a.entry
449 .path
450 .cmp(&b.entry.path)
451 .then(a.entry.line.cmp(&b.entry.line))
452 .then(a.entry.raw_key.cmp(&b.entry.raw_key))
453 });
454
455 self.feature_flags.sort_by(|a, b| {
456 a.path
457 .cmp(&b.path)
458 .then(a.line.cmp(&b.line))
459 .then(a.flag_name.cmp(&b.flag_name))
460 });
461
462 for usage in &mut self.export_usages {
463 usage.reference_locations.sort_by(|a, b| {
464 a.path
465 .cmp(&b.path)
466 .then(a.line.cmp(&b.line))
467 .then(a.col.cmp(&b.col))
468 });
469 }
470 self.export_usages.sort_by(|a, b| {
471 a.path
472 .cmp(&b.path)
473 .then(a.line.cmp(&b.line))
474 .then(a.export_name.cmp(&b.export_name))
475 });
476 }
477}
478
479fn catalog_sort_key(name: &str) -> (u8, &str) {
481 if name == "default" {
482 (0, name)
483 } else {
484 (1, name)
485 }
486}
487
488#[derive(Debug, Clone, Serialize)]
490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491pub struct UnusedFile {
492 #[serde(serialize_with = "serde_path::serialize")]
494 pub path: PathBuf,
495}
496
497#[derive(Debug, Clone, Serialize)]
499#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
500pub struct UnusedExport {
501 #[serde(serialize_with = "serde_path::serialize")]
503 pub path: PathBuf,
504 pub export_name: String,
506 pub is_type_only: bool,
508 pub line: u32,
510 pub col: u32,
512 pub span_start: u32,
514 pub is_re_export: bool,
516}
517
518#[derive(Debug, Clone, Serialize)]
520#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
521pub struct PrivateTypeLeak {
522 #[serde(serialize_with = "serde_path::serialize")]
524 pub path: PathBuf,
525 pub export_name: String,
527 pub type_name: String,
529 pub line: u32,
531 pub col: u32,
533 pub span_start: u32,
535}
536
537#[derive(Debug, Clone, Serialize)]
539#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
540pub struct UnusedDependency {
541 pub package_name: String,
543 pub location: DependencyLocation,
545 #[serde(serialize_with = "serde_path::serialize")]
548 pub path: PathBuf,
549 pub line: u32,
551 #[serde(
553 serialize_with = "serde_path::serialize_vec",
554 skip_serializing_if = "Vec::is_empty"
555 )]
556 #[cfg_attr(feature = "schema", schemars(default))]
557 pub used_in_workspaces: Vec<PathBuf>,
558}
559
560#[derive(Debug, Clone, Serialize)]
577#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
578#[serde(rename_all = "camelCase")]
579pub enum DependencyLocation {
580 Dependencies,
582 DevDependencies,
584 OptionalDependencies,
586}
587
588#[derive(Debug, Clone, Serialize)]
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591pub struct UnusedMember {
592 #[serde(serialize_with = "serde_path::serialize")]
594 pub path: PathBuf,
595 pub parent_name: String,
597 pub member_name: String,
599 pub kind: MemberKind,
601 pub line: u32,
603 pub col: u32,
605}
606
607#[derive(Debug, Clone, Serialize)]
609#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
610pub struct UnresolvedImport {
611 #[serde(serialize_with = "serde_path::serialize")]
613 pub path: PathBuf,
614 pub specifier: String,
616 pub line: u32,
618 pub col: u32,
620 pub specifier_col: u32,
623}
624
625#[derive(Debug, Clone, Serialize)]
627#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
628pub struct UnlistedDependency {
629 pub package_name: String,
632 pub imported_from: Vec<ImportSite>,
634}
635
636#[derive(Debug, Clone, Serialize)]
638#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
639pub struct ImportSite {
640 #[serde(serialize_with = "serde_path::serialize")]
642 pub path: PathBuf,
643 pub line: u32,
645 pub col: u32,
647}
648
649#[derive(Debug, Clone, Serialize)]
651#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
652pub struct DuplicateExport {
653 pub export_name: String,
655 pub locations: Vec<DuplicateLocation>,
657}
658
659#[derive(Debug, Clone, Serialize)]
661#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
662pub struct DuplicateLocation {
663 #[serde(serialize_with = "serde_path::serialize")]
665 pub path: PathBuf,
666 pub line: u32,
668 pub col: u32,
670}
671
672#[derive(Debug, Clone, Serialize)]
676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
677pub struct TypeOnlyDependency {
678 pub package_name: String,
680 #[serde(serialize_with = "serde_path::serialize")]
682 pub path: PathBuf,
683 pub line: u32,
685}
686
687#[derive(Debug, Clone, Serialize)]
693#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
694pub struct UnusedCatalogEntry {
695 pub entry_name: String,
697 pub catalog_name: String,
700 #[serde(serialize_with = "serde_path::serialize")]
702 pub path: PathBuf,
703 pub line: u32,
705 #[serde(
710 default,
711 serialize_with = "serde_path::serialize_vec",
712 skip_serializing_if = "Vec::is_empty"
713 )]
714 pub hardcoded_consumers: Vec<PathBuf>,
715}
716
717#[derive(Debug, Clone, Serialize)]
719#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
720pub struct EmptyCatalogGroup {
721 pub catalog_name: String,
723 #[serde(serialize_with = "serde_path::serialize")]
725 pub path: PathBuf,
726 pub line: u32,
728}
729
730#[derive(Debug, Clone, Serialize)]
741#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
742pub struct UnresolvedCatalogReference {
743 pub entry_name: String,
745 pub catalog_name: String,
748 #[serde(serialize_with = "serde_path::serialize")]
755 pub path: PathBuf,
756 pub line: u32,
758 #[serde(default, skip_serializing_if = "Vec::is_empty")]
763 pub available_in_catalogs: Vec<String>,
764}
765
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
770#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
771pub enum DependencyOverrideSource {
772 #[serde(rename = "pnpm-workspace.yaml")]
774 PnpmWorkspaceYaml,
775 #[serde(rename = "package.json")]
777 PnpmPackageJson,
778}
779
780impl DependencyOverrideSource {
781 #[must_use]
784 pub const fn as_label(&self) -> &'static str {
785 match self {
786 Self::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
787 Self::PnpmPackageJson => "package.json",
788 }
789 }
790}
791
792impl std::fmt::Display for DependencyOverrideSource {
793 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794 f.write_str(self.as_label())
795 }
796}
797
798#[derive(Debug, Clone, Serialize)]
804#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
805pub struct UnusedDependencyOverride {
806 pub raw_key: String,
810 pub target_package: String,
813 #[serde(default, skip_serializing_if = "Option::is_none")]
815 pub parent_package: Option<String>,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub version_constraint: Option<String>,
820 pub version_range: String,
822 pub source: DependencyOverrideSource,
825 #[serde(serialize_with = "serde_path::serialize")]
832 pub path: PathBuf,
833 pub line: u32,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
840 pub hint: Option<String>,
841}
842
843#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
847#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
848#[serde(rename_all = "kebab-case")]
849pub enum DependencyOverrideMisconfigReason {
850 UnparsableKey,
853 EmptyValue,
855}
856
857impl DependencyOverrideMisconfigReason {
858 #[must_use]
860 pub const fn describe(self) -> &'static str {
861 match self {
862 Self::UnparsableKey => "override key cannot be parsed",
863 Self::EmptyValue => "override value is missing or empty",
864 }
865 }
866}
867
868#[derive(Debug, Clone, Serialize)]
872#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
873pub struct MisconfiguredDependencyOverride {
874 pub raw_key: String,
876 #[serde(default, skip_serializing_if = "Option::is_none")]
884 pub target_package: Option<String>,
885 pub raw_value: String,
888 pub reason: DependencyOverrideMisconfigReason,
892 pub source: DependencyOverrideSource,
894 #[serde(serialize_with = "serde_path::serialize")]
898 pub path: PathBuf,
899 pub line: u32,
901}
902
903#[derive(Debug, Clone, Serialize)]
906#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
907pub struct TestOnlyDependency {
908 pub package_name: String,
911 #[serde(serialize_with = "serde_path::serialize")]
913 pub path: PathBuf,
914 pub line: u32,
916}
917
918#[derive(Debug, Clone, Serialize, Deserialize)]
929#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
930#[cfg_attr(feature = "schema", schemars(extend("required" = ["files", "length", "line", "col"])))]
931pub struct CircularDependency {
932 #[serde(serialize_with = "serde_path::serialize_vec")]
934 pub files: Vec<PathBuf>,
935 pub length: usize,
937 #[serde(default)]
939 pub line: u32,
940 #[serde(default)]
942 pub col: u32,
943 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
945 pub is_cross_package: bool,
946}
947
948#[derive(Debug, Clone, Serialize)]
950#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
951pub struct BoundaryViolation {
952 #[serde(serialize_with = "serde_path::serialize")]
954 pub from_path: PathBuf,
955 #[serde(serialize_with = "serde_path::serialize")]
957 pub to_path: PathBuf,
958 pub from_zone: String,
960 pub to_zone: String,
962 pub import_specifier: String,
964 pub line: u32,
966 pub col: u32,
968}
969
970#[derive(Debug, Clone, Serialize)]
972#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
973#[serde(rename_all = "snake_case", tag = "type")]
974pub enum SuppressionOrigin {
975 Comment {
977 #[serde(skip_serializing_if = "Option::is_none")]
979 issue_kind: Option<String>,
980 is_file_level: bool,
982 },
983 JsdocTag {
985 export_name: String,
987 },
988}
989
990#[derive(Debug, Clone, Serialize)]
992#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
993pub struct StaleSuppression {
994 #[serde(serialize_with = "serde_path::serialize")]
996 pub path: PathBuf,
997 pub line: u32,
999 pub col: u32,
1001 pub origin: SuppressionOrigin,
1003}
1004
1005impl StaleSuppression {
1006 #[must_use]
1008 pub fn description(&self) -> String {
1009 match &self.origin {
1010 SuppressionOrigin::Comment {
1011 issue_kind,
1012 is_file_level,
1013 } => {
1014 let directive = if *is_file_level {
1015 "fallow-ignore-file"
1016 } else {
1017 "fallow-ignore-next-line"
1018 };
1019 match issue_kind {
1020 Some(kind) => format!("// {directive} {kind}"),
1021 None => format!("// {directive}"),
1022 }
1023 }
1024 SuppressionOrigin::JsdocTag { export_name } => {
1025 format!("@expected-unused on {export_name}")
1026 }
1027 }
1028 }
1029
1030 #[must_use]
1032 pub fn explanation(&self) -> String {
1033 match &self.origin {
1034 SuppressionOrigin::Comment {
1035 issue_kind,
1036 is_file_level,
1037 } => {
1038 let scope = if *is_file_level {
1039 "in this file"
1040 } else {
1041 "on the next line"
1042 };
1043 match issue_kind {
1044 Some(kind) => format!("no {kind} issue found {scope}"),
1045 None => format!("no issues found {scope}"),
1046 }
1047 }
1048 SuppressionOrigin::JsdocTag { export_name } => {
1049 format!("{export_name} is now used")
1050 }
1051 }
1052 }
1053
1054 #[must_use]
1056 pub fn suppressed_kind(&self) -> Option<IssueKind> {
1057 match &self.origin {
1058 SuppressionOrigin::Comment { issue_kind, .. } => {
1059 issue_kind.as_deref().and_then(IssueKind::parse)
1060 }
1061 SuppressionOrigin::JsdocTag { .. } => None,
1062 }
1063 }
1064}
1065
1066#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1068#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1069#[serde(rename_all = "snake_case")]
1070pub enum FlagKind {
1071 EnvironmentVariable,
1073 SdkCall,
1075 ConfigObject,
1077}
1078
1079#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1081#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1082#[serde(rename_all = "snake_case")]
1083pub enum FlagConfidence {
1084 Low,
1086 Medium,
1088 High,
1090}
1091
1092#[derive(Debug, Clone, Serialize)]
1094#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1095pub struct FeatureFlag {
1096 #[serde(serialize_with = "serde_path::serialize")]
1098 pub path: PathBuf,
1099 pub flag_name: String,
1101 pub kind: FlagKind,
1103 pub confidence: FlagConfidence,
1105 pub line: u32,
1107 pub col: u32,
1109 #[serde(skip)]
1111 pub guard_span_start: Option<u32>,
1112 #[serde(skip)]
1114 pub guard_span_end: Option<u32>,
1115 #[serde(skip_serializing_if = "Option::is_none")]
1117 pub sdk_name: Option<String>,
1118 #[serde(skip)]
1121 pub guard_line_start: Option<u32>,
1122 #[serde(skip)]
1124 pub guard_line_end: Option<u32>,
1125 #[serde(skip_serializing_if = "Vec::is_empty")]
1128 pub guarded_dead_exports: Vec<String>,
1129}
1130
1131const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
1133
1134#[derive(Debug, Clone, Serialize)]
1137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1138pub struct ExportUsage {
1139 #[serde(serialize_with = "serde_path::serialize")]
1141 pub path: PathBuf,
1142 pub export_name: String,
1144 pub line: u32,
1146 pub col: u32,
1148 pub reference_count: usize,
1150 pub reference_locations: Vec<ReferenceLocation>,
1153}
1154
1155#[derive(Debug, Clone, Serialize)]
1157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1158pub struct ReferenceLocation {
1159 #[serde(serialize_with = "serde_path::serialize")]
1161 pub path: PathBuf,
1162 pub line: u32,
1164 pub col: u32,
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171 use crate::output_dead_code::{
1172 BoundaryViolationFinding, CircularDependencyFinding, UnresolvedImportFinding,
1173 UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
1174 UnusedTypeFinding,
1175 };
1176
1177 #[test]
1178 fn empty_results_no_issues() {
1179 let results = AnalysisResults::default();
1180 assert_eq!(results.total_issues(), 0);
1181 assert!(!results.has_issues());
1182 }
1183
1184 #[test]
1185 fn results_with_unused_file() {
1186 let mut results = AnalysisResults::default();
1187 results
1188 .unused_files
1189 .push(UnusedFileFinding::with_actions(UnusedFile {
1190 path: PathBuf::from("test.ts"),
1191 }));
1192 assert_eq!(results.total_issues(), 1);
1193 assert!(results.has_issues());
1194 }
1195
1196 #[test]
1197 fn results_with_unused_export() {
1198 let mut results = AnalysisResults::default();
1199 results
1200 .unused_exports
1201 .push(UnusedExportFinding::with_actions(UnusedExport {
1202 path: PathBuf::from("test.ts"),
1203 export_name: "foo".to_string(),
1204 is_type_only: false,
1205 line: 1,
1206 col: 0,
1207 span_start: 0,
1208 is_re_export: false,
1209 }));
1210 assert_eq!(results.total_issues(), 1);
1211 assert!(results.has_issues());
1212 }
1213
1214 fn test_unused_export(path: &str, export_name: &str, is_type_only: bool) -> UnusedExport {
1215 UnusedExport {
1216 path: PathBuf::from(path),
1217 export_name: export_name.to_string(),
1218 is_type_only,
1219 line: 1,
1220 col: 0,
1221 span_start: 0,
1222 is_re_export: false,
1223 }
1224 }
1225
1226 fn test_unused_dependency(
1227 package_name: &str,
1228 location: DependencyLocation,
1229 ) -> UnusedDependency {
1230 UnusedDependency {
1231 package_name: package_name.to_string(),
1232 location,
1233 path: PathBuf::from("package.json"),
1234 line: 5,
1235 used_in_workspaces: Vec::new(),
1236 }
1237 }
1238
1239 fn test_unused_member(member_name: &str, kind: MemberKind) -> UnusedMember {
1240 UnusedMember {
1241 path: PathBuf::from("members.ts"),
1242 parent_name: "Parent".to_string(),
1243 member_name: member_name.to_string(),
1244 kind,
1245 line: 1,
1246 col: 0,
1247 }
1248 }
1249
1250 #[test]
1251 fn results_total_counts_all_types() {
1252 let results = AnalysisResults {
1253 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1254 path: PathBuf::from("a.ts"),
1255 })],
1256 unused_exports: vec![UnusedExportFinding::with_actions(test_unused_export(
1257 "b.ts", "x", false,
1258 ))],
1259 unused_types: vec![UnusedTypeFinding::with_actions(test_unused_export(
1260 "c.ts", "T", true,
1261 ))],
1262 unused_dependencies: vec![UnusedDependencyFinding::with_actions(
1263 test_unused_dependency("dep", DependencyLocation::Dependencies),
1264 )],
1265 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
1266 test_unused_dependency("dev", DependencyLocation::DevDependencies),
1267 )],
1268 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(test_unused_member(
1269 "A",
1270 MemberKind::EnumMember,
1271 ))],
1272 unused_class_members: vec![UnusedClassMemberFinding::with_actions(test_unused_member(
1273 "m",
1274 MemberKind::ClassMethod,
1275 ))],
1276 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
1277 path: PathBuf::from("f.ts"),
1278 specifier: "./missing".to_string(),
1279 line: 1,
1280 col: 0,
1281 specifier_col: 0,
1282 })],
1283 unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
1284 UnlistedDependency {
1285 package_name: "unlisted".to_string(),
1286 imported_from: vec![ImportSite {
1287 path: PathBuf::from("g.ts"),
1288 line: 1,
1289 col: 0,
1290 }],
1291 },
1292 )],
1293 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
1294 export_name: "dup".to_string(),
1295 locations: vec![
1296 DuplicateLocation {
1297 path: PathBuf::from("h.ts"),
1298 line: 15,
1299 col: 0,
1300 },
1301 DuplicateLocation {
1302 path: PathBuf::from("i.ts"),
1303 line: 30,
1304 col: 0,
1305 },
1306 ],
1307 })],
1308 unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
1309 test_unused_dependency("optional", DependencyLocation::OptionalDependencies),
1310 )],
1311 type_only_dependencies: vec![TypeOnlyDependencyFinding::with_actions(
1312 TypeOnlyDependency {
1313 package_name: "type-only".to_string(),
1314 path: PathBuf::from("package.json"),
1315 line: 8,
1316 },
1317 )],
1318 test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
1319 TestOnlyDependency {
1320 package_name: "test-only".to_string(),
1321 path: PathBuf::from("package.json"),
1322 line: 9,
1323 },
1324 )],
1325 circular_dependencies: vec![CircularDependencyFinding::with_actions(
1326 CircularDependency {
1327 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1328 length: 2,
1329 line: 3,
1330 col: 0,
1331 is_cross_package: false,
1332 },
1333 )],
1334 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
1335 from_path: PathBuf::from("src/ui/Button.tsx"),
1336 to_path: PathBuf::from("src/db/queries.ts"),
1337 from_zone: "ui".to_string(),
1338 to_zone: "database".to_string(),
1339 import_specifier: "../db/queries".to_string(),
1340 line: 3,
1341 col: 0,
1342 })],
1343 ..Default::default()
1344 };
1345
1346 assert_eq!(results.total_issues(), 15);
1348 assert!(results.has_issues());
1349 }
1350
1351 #[test]
1354 fn total_issues_and_has_issues_are_consistent() {
1355 let results = AnalysisResults::default();
1356 assert_eq!(results.total_issues(), 0);
1357 assert!(!results.has_issues());
1358 assert_eq!(results.total_issues() > 0, results.has_issues());
1359 }
1360
1361 #[test]
1364 fn total_issues_sums_all_categories_independently() {
1365 let mut results = AnalysisResults::default();
1366 results
1367 .unused_files
1368 .push(UnusedFileFinding::with_actions(UnusedFile {
1369 path: PathBuf::from("a.ts"),
1370 }));
1371 assert_eq!(results.total_issues(), 1);
1372
1373 results
1374 .unused_files
1375 .push(UnusedFileFinding::with_actions(UnusedFile {
1376 path: PathBuf::from("b.ts"),
1377 }));
1378 assert_eq!(results.total_issues(), 2);
1379
1380 results
1381 .unresolved_imports
1382 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1383 path: PathBuf::from("c.ts"),
1384 specifier: "./missing".to_string(),
1385 line: 1,
1386 col: 0,
1387 specifier_col: 0,
1388 }));
1389 assert_eq!(results.total_issues(), 3);
1390 }
1391
1392 #[test]
1395 fn default_results_all_fields_empty() {
1396 let r = AnalysisResults::default();
1397 assert!(r.unused_files.is_empty());
1398 assert!(r.unused_exports.is_empty());
1399 assert!(r.unused_types.is_empty());
1400 assert!(r.unused_dependencies.is_empty());
1401 assert!(r.unused_dev_dependencies.is_empty());
1402 assert!(r.unused_optional_dependencies.is_empty());
1403 assert!(r.unused_enum_members.is_empty());
1404 assert!(r.unused_class_members.is_empty());
1405 assert!(r.unresolved_imports.is_empty());
1406 assert!(r.unlisted_dependencies.is_empty());
1407 assert!(r.duplicate_exports.is_empty());
1408 assert!(r.type_only_dependencies.is_empty());
1409 assert!(r.test_only_dependencies.is_empty());
1410 assert!(r.circular_dependencies.is_empty());
1411 assert!(r.boundary_violations.is_empty());
1412 assert!(r.unused_catalog_entries.is_empty());
1413 assert!(r.unresolved_catalog_references.is_empty());
1414 assert!(r.export_usages.is_empty());
1415 }
1416
1417 #[test]
1420 fn entry_point_summary_default() {
1421 let summary = EntryPointSummary::default();
1422 assert_eq!(summary.total, 0);
1423 assert!(summary.by_source.is_empty());
1424 }
1425
1426 #[test]
1427 fn entry_point_summary_not_in_default_results() {
1428 let r = AnalysisResults::default();
1429 assert!(r.entry_point_summary.is_none());
1430 }
1431
1432 #[test]
1433 fn entry_point_summary_some_preserves_data() {
1434 let r = AnalysisResults {
1435 entry_point_summary: Some(EntryPointSummary {
1436 total: 5,
1437 by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
1438 }),
1439 ..AnalysisResults::default()
1440 };
1441 let summary = r.entry_point_summary.as_ref().unwrap();
1442 assert_eq!(summary.total, 5);
1443 assert_eq!(summary.by_source.len(), 2);
1444 assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
1445 }
1446
1447 #[test]
1450 fn sort_unused_files_by_path() {
1451 let mut r = AnalysisResults::default();
1452 r.unused_files
1453 .push(UnusedFileFinding::with_actions(UnusedFile {
1454 path: PathBuf::from("z.ts"),
1455 }));
1456 r.unused_files
1457 .push(UnusedFileFinding::with_actions(UnusedFile {
1458 path: PathBuf::from("a.ts"),
1459 }));
1460 r.unused_files
1461 .push(UnusedFileFinding::with_actions(UnusedFile {
1462 path: PathBuf::from("m.ts"),
1463 }));
1464 r.sort();
1465 let paths: Vec<_> = r
1466 .unused_files
1467 .iter()
1468 .map(|f| f.file.path.to_string_lossy().to_string())
1469 .collect();
1470 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
1471 }
1472
1473 #[test]
1476 fn sort_unused_exports_by_path_line_name() {
1477 let mut r = AnalysisResults::default();
1478 let mk = |path: &str, line: u32, name: &str| {
1479 UnusedExportFinding::with_actions(UnusedExport {
1480 path: PathBuf::from(path),
1481 export_name: name.to_string(),
1482 is_type_only: false,
1483 line,
1484 col: 0,
1485 span_start: 0,
1486 is_re_export: false,
1487 })
1488 };
1489 r.unused_exports.push(mk("b.ts", 5, "beta"));
1490 r.unused_exports.push(mk("a.ts", 10, "zeta"));
1491 r.unused_exports.push(mk("a.ts", 10, "alpha"));
1492 r.unused_exports.push(mk("a.ts", 1, "gamma"));
1493 r.sort();
1494 let keys: Vec<_> = r
1495 .unused_exports
1496 .iter()
1497 .map(|e| {
1498 format!(
1499 "{}:{}:{}",
1500 e.export.path.to_string_lossy(),
1501 e.export.line,
1502 e.export.export_name
1503 )
1504 })
1505 .collect();
1506 assert_eq!(
1507 keys,
1508 vec![
1509 "a.ts:1:gamma",
1510 "a.ts:10:alpha",
1511 "a.ts:10:zeta",
1512 "b.ts:5:beta"
1513 ]
1514 );
1515 }
1516
1517 #[test]
1520 fn sort_unused_types_by_path_line_name() {
1521 let mut r = AnalysisResults::default();
1522 let mk = |path: &str, line: u32, name: &str| {
1523 UnusedTypeFinding::with_actions(UnusedExport {
1524 path: PathBuf::from(path),
1525 export_name: name.to_string(),
1526 is_type_only: true,
1527 line,
1528 col: 0,
1529 span_start: 0,
1530 is_re_export: false,
1531 })
1532 };
1533 r.unused_types.push(mk("z.ts", 1, "Z"));
1534 r.unused_types.push(mk("a.ts", 1, "A"));
1535 r.sort();
1536 assert_eq!(r.unused_types[0].export.path, PathBuf::from("a.ts"));
1537 assert_eq!(r.unused_types[1].export.path, PathBuf::from("z.ts"));
1538 }
1539
1540 #[test]
1543 fn sort_unused_dependencies_by_path_line_name() {
1544 let mut r = AnalysisResults::default();
1545 let mk = |path: &str, line: u32, name: &str| {
1546 UnusedDependencyFinding::with_actions(UnusedDependency {
1547 package_name: name.to_string(),
1548 location: DependencyLocation::Dependencies,
1549 path: PathBuf::from(path),
1550 line,
1551 used_in_workspaces: Vec::new(),
1552 })
1553 };
1554 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
1555 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
1556 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
1557 r.sort();
1558 let names: Vec<_> = r
1559 .unused_dependencies
1560 .iter()
1561 .map(|d| d.dep.package_name.as_str())
1562 .collect();
1563 assert_eq!(names, vec!["axios", "react", "zlib"]);
1564 }
1565
1566 #[test]
1569 fn sort_unused_dev_dependencies() {
1570 let mut r = AnalysisResults::default();
1571 r.unused_dev_dependencies
1572 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1573 package_name: "vitest".to_string(),
1574 location: DependencyLocation::DevDependencies,
1575 path: PathBuf::from("package.json"),
1576 line: 10,
1577 used_in_workspaces: Vec::new(),
1578 }));
1579 r.unused_dev_dependencies
1580 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1581 package_name: "jest".to_string(),
1582 location: DependencyLocation::DevDependencies,
1583 path: PathBuf::from("package.json"),
1584 line: 5,
1585 used_in_workspaces: Vec::new(),
1586 }));
1587 r.sort();
1588 assert_eq!(r.unused_dev_dependencies[0].dep.package_name, "jest");
1589 assert_eq!(r.unused_dev_dependencies[1].dep.package_name, "vitest");
1590 }
1591
1592 #[test]
1595 fn sort_unused_optional_dependencies() {
1596 let mut r = AnalysisResults::default();
1597 r.unused_optional_dependencies
1598 .push(UnusedOptionalDependencyFinding::with_actions(
1599 UnusedDependency {
1600 package_name: "zod".to_string(),
1601 location: DependencyLocation::OptionalDependencies,
1602 path: PathBuf::from("package.json"),
1603 line: 3,
1604 used_in_workspaces: Vec::new(),
1605 },
1606 ));
1607 r.unused_optional_dependencies
1608 .push(UnusedOptionalDependencyFinding::with_actions(
1609 UnusedDependency {
1610 package_name: "ajv".to_string(),
1611 location: DependencyLocation::OptionalDependencies,
1612 path: PathBuf::from("package.json"),
1613 line: 2,
1614 used_in_workspaces: Vec::new(),
1615 },
1616 ));
1617 r.sort();
1618 assert_eq!(r.unused_optional_dependencies[0].dep.package_name, "ajv");
1619 assert_eq!(r.unused_optional_dependencies[1].dep.package_name, "zod");
1620 }
1621
1622 #[test]
1625 fn sort_unused_enum_members_by_path_line_parent_member() {
1626 let mut r = AnalysisResults::default();
1627 let mk = |path: &str, line: u32, parent: &str, member: &str| {
1628 UnusedEnumMemberFinding::with_actions(UnusedMember {
1629 path: PathBuf::from(path),
1630 parent_name: parent.to_string(),
1631 member_name: member.to_string(),
1632 kind: MemberKind::EnumMember,
1633 line,
1634 col: 0,
1635 })
1636 };
1637 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
1638 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
1639 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
1640 r.sort();
1641 let keys: Vec<_> = r
1642 .unused_enum_members
1643 .iter()
1644 .map(|m| format!("{}:{}", m.member.parent_name, m.member.member_name))
1645 .collect();
1646 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
1647 }
1648
1649 #[test]
1652 fn sort_unused_class_members() {
1653 let mut r = AnalysisResults::default();
1654 let mk = |path: &str, line: u32, parent: &str, member: &str| {
1655 UnusedClassMemberFinding::with_actions(UnusedMember {
1656 path: PathBuf::from(path),
1657 parent_name: parent.to_string(),
1658 member_name: member.to_string(),
1659 kind: MemberKind::ClassMethod,
1660 line,
1661 col: 0,
1662 })
1663 };
1664 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1665 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1666 r.sort();
1667 assert_eq!(r.unused_class_members[0].member.path, PathBuf::from("a.ts"));
1668 assert_eq!(r.unused_class_members[1].member.path, PathBuf::from("b.ts"));
1669 }
1670
1671 #[test]
1674 fn sort_unresolved_imports_by_path_line_col_specifier() {
1675 let mut r = AnalysisResults::default();
1676 let mk = |path: &str, line: u32, col: u32, spec: &str| {
1677 UnresolvedImportFinding::with_actions(UnresolvedImport {
1678 path: PathBuf::from(path),
1679 specifier: spec.to_string(),
1680 line,
1681 col,
1682 specifier_col: 0,
1683 })
1684 };
1685 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1686 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1687 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1688 r.sort();
1689 let specs: Vec<_> = r
1690 .unresolved_imports
1691 .iter()
1692 .map(|i| i.import.specifier.as_str())
1693 .collect();
1694 assert_eq!(specs, vec!["./m", "./a", "./z"]);
1695 }
1696
1697 #[test]
1700 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1701 let mut r = AnalysisResults::default();
1702 r.unlisted_dependencies
1703 .push(UnlistedDependencyFinding::with_actions(
1704 UnlistedDependency {
1705 package_name: "zod".to_string(),
1706 imported_from: vec![
1707 ImportSite {
1708 path: PathBuf::from("b.ts"),
1709 line: 10,
1710 col: 0,
1711 },
1712 ImportSite {
1713 path: PathBuf::from("a.ts"),
1714 line: 1,
1715 col: 0,
1716 },
1717 ],
1718 },
1719 ));
1720 r.unlisted_dependencies
1721 .push(UnlistedDependencyFinding::with_actions(
1722 UnlistedDependency {
1723 package_name: "axios".to_string(),
1724 imported_from: vec![ImportSite {
1725 path: PathBuf::from("c.ts"),
1726 line: 1,
1727 col: 0,
1728 }],
1729 },
1730 ));
1731 r.sort();
1732
1733 assert_eq!(r.unlisted_dependencies[0].dep.package_name, "axios");
1735 assert_eq!(r.unlisted_dependencies[1].dep.package_name, "zod");
1736
1737 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1739 .dep
1740 .imported_from
1741 .iter()
1742 .map(|s| s.path.to_string_lossy().to_string())
1743 .collect();
1744 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1745 }
1746
1747 #[test]
1750 fn sort_duplicate_exports_by_name_and_inner_locations() {
1751 let mut r = AnalysisResults::default();
1752 r.duplicate_exports
1753 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1754 export_name: "z".to_string(),
1755 locations: vec![
1756 DuplicateLocation {
1757 path: PathBuf::from("c.ts"),
1758 line: 1,
1759 col: 0,
1760 },
1761 DuplicateLocation {
1762 path: PathBuf::from("a.ts"),
1763 line: 5,
1764 col: 0,
1765 },
1766 ],
1767 }));
1768 r.duplicate_exports
1769 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1770 export_name: "a".to_string(),
1771 locations: vec![DuplicateLocation {
1772 path: PathBuf::from("b.ts"),
1773 line: 1,
1774 col: 0,
1775 }],
1776 }));
1777 r.sort();
1778
1779 assert_eq!(r.duplicate_exports[0].export.export_name, "a");
1781 assert_eq!(r.duplicate_exports[1].export.export_name, "z");
1782
1783 let z_locs: Vec<_> = r.duplicate_exports[1]
1785 .export
1786 .locations
1787 .iter()
1788 .map(|l| l.path.to_string_lossy().to_string())
1789 .collect();
1790 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1791 }
1792
1793 #[test]
1796 fn sort_type_only_dependencies() {
1797 let mut r = AnalysisResults::default();
1798 r.type_only_dependencies
1799 .push(TypeOnlyDependencyFinding::with_actions(
1800 TypeOnlyDependency {
1801 package_name: "zod".to_string(),
1802 path: PathBuf::from("package.json"),
1803 line: 10,
1804 },
1805 ));
1806 r.type_only_dependencies
1807 .push(TypeOnlyDependencyFinding::with_actions(
1808 TypeOnlyDependency {
1809 package_name: "ajv".to_string(),
1810 path: PathBuf::from("package.json"),
1811 line: 5,
1812 },
1813 ));
1814 r.sort();
1815 assert_eq!(r.type_only_dependencies[0].dep.package_name, "ajv");
1816 assert_eq!(r.type_only_dependencies[1].dep.package_name, "zod");
1817 }
1818
1819 #[test]
1822 fn sort_test_only_dependencies() {
1823 let mut r = AnalysisResults::default();
1824 r.test_only_dependencies
1825 .push(TestOnlyDependencyFinding::with_actions(
1826 TestOnlyDependency {
1827 package_name: "vitest".to_string(),
1828 path: PathBuf::from("package.json"),
1829 line: 15,
1830 },
1831 ));
1832 r.test_only_dependencies
1833 .push(TestOnlyDependencyFinding::with_actions(
1834 TestOnlyDependency {
1835 package_name: "jest".to_string(),
1836 path: PathBuf::from("package.json"),
1837 line: 10,
1838 },
1839 ));
1840 r.sort();
1841 assert_eq!(r.test_only_dependencies[0].dep.package_name, "jest");
1842 assert_eq!(r.test_only_dependencies[1].dep.package_name, "vitest");
1843 }
1844
1845 #[test]
1848 fn sort_circular_dependencies_by_files_then_length() {
1849 let mut r = AnalysisResults::default();
1850 r.circular_dependencies
1851 .push(CircularDependencyFinding::with_actions(
1852 CircularDependency {
1853 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1854 length: 2,
1855 line: 1,
1856 col: 0,
1857 is_cross_package: false,
1858 },
1859 ));
1860 r.circular_dependencies
1861 .push(CircularDependencyFinding::with_actions(
1862 CircularDependency {
1863 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1864 length: 2,
1865 line: 1,
1866 col: 0,
1867 is_cross_package: true,
1868 },
1869 ));
1870 r.sort();
1871 assert_eq!(
1872 r.circular_dependencies[0].cycle.files[0],
1873 PathBuf::from("a.ts")
1874 );
1875 assert_eq!(
1876 r.circular_dependencies[1].cycle.files[0],
1877 PathBuf::from("b.ts")
1878 );
1879 }
1880
1881 #[test]
1884 fn sort_boundary_violations() {
1885 let mut r = AnalysisResults::default();
1886 let mk = |from: &str, line: u32, col: u32, to: &str| {
1887 BoundaryViolationFinding::with_actions(BoundaryViolation {
1888 from_path: PathBuf::from(from),
1889 to_path: PathBuf::from(to),
1890 from_zone: "a".to_string(),
1891 to_zone: "b".to_string(),
1892 import_specifier: to.to_string(),
1893 line,
1894 col,
1895 })
1896 };
1897 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
1898 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
1899 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
1900 r.sort();
1901 let from_paths: Vec<_> = r
1902 .boundary_violations
1903 .iter()
1904 .map(|v| {
1905 format!(
1906 "{}:{}",
1907 v.violation.from_path.to_string_lossy(),
1908 v.violation.line
1909 )
1910 })
1911 .collect();
1912 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
1913 }
1914
1915 #[test]
1918 fn sort_export_usages_and_inner_reference_locations() {
1919 let mut r = AnalysisResults::default();
1920 r.export_usages.push(ExportUsage {
1921 path: PathBuf::from("z.ts"),
1922 export_name: "foo".to_string(),
1923 line: 1,
1924 col: 0,
1925 reference_count: 2,
1926 reference_locations: vec![
1927 ReferenceLocation {
1928 path: PathBuf::from("c.ts"),
1929 line: 10,
1930 col: 0,
1931 },
1932 ReferenceLocation {
1933 path: PathBuf::from("a.ts"),
1934 line: 5,
1935 col: 0,
1936 },
1937 ],
1938 });
1939 r.export_usages.push(ExportUsage {
1940 path: PathBuf::from("a.ts"),
1941 export_name: "bar".to_string(),
1942 line: 1,
1943 col: 0,
1944 reference_count: 1,
1945 reference_locations: vec![ReferenceLocation {
1946 path: PathBuf::from("b.ts"),
1947 line: 1,
1948 col: 0,
1949 }],
1950 });
1951 r.sort();
1952
1953 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
1955 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
1956
1957 let refs: Vec<_> = r.export_usages[1]
1959 .reference_locations
1960 .iter()
1961 .map(|l| l.path.to_string_lossy().to_string())
1962 .collect();
1963 assert_eq!(refs, vec!["a.ts", "c.ts"]);
1964 }
1965
1966 #[test]
1969 fn sort_empty_results_is_noop() {
1970 let mut r = AnalysisResults::default();
1971 r.sort(); assert_eq!(r.total_issues(), 0);
1973 }
1974
1975 #[test]
1978 fn sort_single_element_lists_stable() {
1979 let mut r = AnalysisResults::default();
1980 r.unused_files
1981 .push(UnusedFileFinding::with_actions(UnusedFile {
1982 path: PathBuf::from("only.ts"),
1983 }));
1984 r.sort();
1985 assert_eq!(r.unused_files[0].file.path, PathBuf::from("only.ts"));
1986 }
1987
1988 #[test]
1991 fn serialize_empty_results() {
1992 let r = AnalysisResults::default();
1993 let json = serde_json::to_value(&r).unwrap();
1994
1995 assert!(json["unused_files"].as_array().unwrap().is_empty());
1997 assert!(json["unused_exports"].as_array().unwrap().is_empty());
1998 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
1999
2000 assert!(json.get("export_usages").is_none());
2002 assert!(json.get("entry_point_summary").is_none());
2003 }
2004
2005 #[test]
2006 fn serialize_unused_file_path() {
2007 let r = UnusedFile {
2008 path: PathBuf::from("src/utils/index.ts"),
2009 };
2010 let json = serde_json::to_value(&r).unwrap();
2011 assert_eq!(json["path"], "src/utils/index.ts");
2012 }
2013
2014 #[test]
2015 fn serialize_dependency_location_camel_case() {
2016 let dep = UnusedDependency {
2017 package_name: "react".to_string(),
2018 location: DependencyLocation::DevDependencies,
2019 path: PathBuf::from("package.json"),
2020 line: 5,
2021 used_in_workspaces: Vec::new(),
2022 };
2023 let json = serde_json::to_value(&dep).unwrap();
2024 assert_eq!(json["location"], "devDependencies");
2025
2026 let dep2 = UnusedDependency {
2027 package_name: "react".to_string(),
2028 location: DependencyLocation::Dependencies,
2029 path: PathBuf::from("package.json"),
2030 line: 3,
2031 used_in_workspaces: Vec::new(),
2032 };
2033 let json2 = serde_json::to_value(&dep2).unwrap();
2034 assert_eq!(json2["location"], "dependencies");
2035
2036 let dep3 = UnusedDependency {
2037 package_name: "fsevents".to_string(),
2038 location: DependencyLocation::OptionalDependencies,
2039 path: PathBuf::from("package.json"),
2040 line: 7,
2041 used_in_workspaces: Vec::new(),
2042 };
2043 let json3 = serde_json::to_value(&dep3).unwrap();
2044 assert_eq!(json3["location"], "optionalDependencies");
2045 }
2046
2047 #[test]
2048 fn serialize_circular_dependency_skips_false_cross_package() {
2049 let cd = CircularDependency {
2050 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2051 length: 2,
2052 line: 1,
2053 col: 0,
2054 is_cross_package: false,
2055 };
2056 let json = serde_json::to_value(&cd).unwrap();
2057 assert!(json.get("is_cross_package").is_none());
2059 }
2060
2061 #[test]
2062 fn serialize_circular_dependency_includes_true_cross_package() {
2063 let cd = CircularDependency {
2064 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2065 length: 2,
2066 line: 1,
2067 col: 0,
2068 is_cross_package: true,
2069 };
2070 let json = serde_json::to_value(&cd).unwrap();
2071 assert_eq!(json["is_cross_package"], true);
2072 }
2073
2074 #[test]
2075 fn serialize_unused_export_fields() {
2076 let e = UnusedExport {
2077 path: PathBuf::from("src/mod.ts"),
2078 export_name: "helper".to_string(),
2079 is_type_only: true,
2080 line: 42,
2081 col: 7,
2082 span_start: 100,
2083 is_re_export: true,
2084 };
2085 let json = serde_json::to_value(&e).unwrap();
2086 assert_eq!(json["path"], "src/mod.ts");
2087 assert_eq!(json["export_name"], "helper");
2088 assert_eq!(json["is_type_only"], true);
2089 assert_eq!(json["line"], 42);
2090 assert_eq!(json["col"], 7);
2091 assert_eq!(json["span_start"], 100);
2092 assert_eq!(json["is_re_export"], true);
2093 }
2094
2095 #[test]
2096 fn serialize_boundary_violation_fields() {
2097 let v = BoundaryViolation {
2098 from_path: PathBuf::from("src/ui/button.tsx"),
2099 to_path: PathBuf::from("src/db/queries.ts"),
2100 from_zone: "ui".to_string(),
2101 to_zone: "db".to_string(),
2102 import_specifier: "../db/queries".to_string(),
2103 line: 3,
2104 col: 0,
2105 };
2106 let json = serde_json::to_value(&v).unwrap();
2107 assert_eq!(json["from_path"], "src/ui/button.tsx");
2108 assert_eq!(json["to_path"], "src/db/queries.ts");
2109 assert_eq!(json["from_zone"], "ui");
2110 assert_eq!(json["to_zone"], "db");
2111 assert_eq!(json["import_specifier"], "../db/queries");
2112 }
2113
2114 #[test]
2115 fn serialize_unlisted_dependency_with_import_sites() {
2116 let d = UnlistedDependency {
2117 package_name: "chalk".to_string(),
2118 imported_from: vec![
2119 ImportSite {
2120 path: PathBuf::from("a.ts"),
2121 line: 1,
2122 col: 0,
2123 },
2124 ImportSite {
2125 path: PathBuf::from("b.ts"),
2126 line: 5,
2127 col: 3,
2128 },
2129 ],
2130 };
2131 let json = serde_json::to_value(&d).unwrap();
2132 assert_eq!(json["package_name"], "chalk");
2133 let sites = json["imported_from"].as_array().unwrap();
2134 assert_eq!(sites.len(), 2);
2135 assert_eq!(sites[0]["path"], "a.ts");
2136 assert_eq!(sites[1]["line"], 5);
2137 }
2138
2139 #[test]
2140 fn serialize_duplicate_export_with_locations() {
2141 let d = DuplicateExport {
2142 export_name: "Button".to_string(),
2143 locations: vec![
2144 DuplicateLocation {
2145 path: PathBuf::from("src/a.ts"),
2146 line: 10,
2147 col: 0,
2148 },
2149 DuplicateLocation {
2150 path: PathBuf::from("src/b.ts"),
2151 line: 20,
2152 col: 5,
2153 },
2154 ],
2155 };
2156 let json = serde_json::to_value(&d).unwrap();
2157 assert_eq!(json["export_name"], "Button");
2158 let locs = json["locations"].as_array().unwrap();
2159 assert_eq!(locs.len(), 2);
2160 assert_eq!(locs[0]["line"], 10);
2161 assert_eq!(locs[1]["col"], 5);
2162 }
2163
2164 #[test]
2165 fn serialize_type_only_dependency() {
2166 let d = TypeOnlyDependency {
2167 package_name: "@types/react".to_string(),
2168 path: PathBuf::from("package.json"),
2169 line: 12,
2170 };
2171 let json = serde_json::to_value(&d).unwrap();
2172 assert_eq!(json["package_name"], "@types/react");
2173 assert_eq!(json["line"], 12);
2174 }
2175
2176 #[test]
2177 fn serialize_test_only_dependency() {
2178 let d = TestOnlyDependency {
2179 package_name: "vitest".to_string(),
2180 path: PathBuf::from("package.json"),
2181 line: 8,
2182 };
2183 let json = serde_json::to_value(&d).unwrap();
2184 assert_eq!(json["package_name"], "vitest");
2185 assert_eq!(json["line"], 8);
2186 }
2187
2188 #[test]
2189 fn serialize_unused_member() {
2190 let m = UnusedMember {
2191 path: PathBuf::from("enums.ts"),
2192 parent_name: "Status".to_string(),
2193 member_name: "Pending".to_string(),
2194 kind: MemberKind::EnumMember,
2195 line: 3,
2196 col: 4,
2197 };
2198 let json = serde_json::to_value(&m).unwrap();
2199 assert_eq!(json["parent_name"], "Status");
2200 assert_eq!(json["member_name"], "Pending");
2201 assert_eq!(json["line"], 3);
2202 }
2203
2204 #[test]
2205 fn serialize_unresolved_import() {
2206 let i = UnresolvedImport {
2207 path: PathBuf::from("app.ts"),
2208 specifier: "./missing-module".to_string(),
2209 line: 7,
2210 col: 0,
2211 specifier_col: 21,
2212 };
2213 let json = serde_json::to_value(&i).unwrap();
2214 assert_eq!(json["specifier"], "./missing-module");
2215 assert_eq!(json["specifier_col"], 21);
2216 }
2217
2218 #[test]
2221 fn deserialize_circular_dependency_with_defaults() {
2222 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
2224 let cd: CircularDependency = serde_json::from_str(json).unwrap();
2225 assert_eq!(cd.files.len(), 2);
2226 assert_eq!(cd.length, 2);
2227 assert_eq!(cd.line, 0);
2228 assert_eq!(cd.col, 0);
2229 assert!(!cd.is_cross_package);
2230 }
2231
2232 #[test]
2233 fn deserialize_circular_dependency_with_all_fields() {
2234 let json =
2235 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
2236 let cd: CircularDependency = serde_json::from_str(json).unwrap();
2237 assert_eq!(cd.line, 5);
2238 assert_eq!(cd.col, 10);
2239 assert!(cd.is_cross_package);
2240 }
2241
2242 #[test]
2245 fn clone_results_are_independent() {
2246 let mut r = AnalysisResults::default();
2247 r.unused_files
2248 .push(UnusedFileFinding::with_actions(UnusedFile {
2249 path: PathBuf::from("a.ts"),
2250 }));
2251 let mut cloned = r.clone();
2252 cloned
2253 .unused_files
2254 .push(UnusedFileFinding::with_actions(UnusedFile {
2255 path: PathBuf::from("b.ts"),
2256 }));
2257 assert_eq!(r.total_issues(), 1);
2258 assert_eq!(cloned.total_issues(), 2);
2259 }
2260
2261 #[test]
2264 fn export_usages_not_counted_in_total_issues() {
2265 let mut r = AnalysisResults::default();
2266 r.export_usages.push(ExportUsage {
2267 path: PathBuf::from("mod.ts"),
2268 export_name: "foo".to_string(),
2269 line: 1,
2270 col: 0,
2271 reference_count: 3,
2272 reference_locations: vec![],
2273 });
2274 assert_eq!(r.total_issues(), 0);
2276 assert!(!r.has_issues());
2277 }
2278
2279 #[test]
2282 fn entry_point_summary_not_counted_in_total_issues() {
2283 let r = AnalysisResults {
2284 entry_point_summary: Some(EntryPointSummary {
2285 total: 10,
2286 by_source: vec![("config".to_string(), 10)],
2287 }),
2288 ..AnalysisResults::default()
2289 };
2290 assert_eq!(r.total_issues(), 0);
2291 assert!(!r.has_issues());
2292 }
2293}