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 ReExportCycleFinding, TestOnlyDependencyFinding, TypeOnlyDependencyFinding,
12 UnlistedDependencyFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
13 UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedDependencyFinding,
14 UnusedDependencyOverrideFinding, UnusedDevDependencyFinding, UnusedEnumMemberFinding,
15 UnusedExportFinding, UnusedFileFinding, UnusedOptionalDependencyFinding, UnusedTypeFinding,
16};
17use crate::serde_path;
18use crate::suppress::{IssueKind, closest_known_kind_name};
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)]
130 pub re_export_cycles: Vec<ReExportCycleFinding>,
131 #[serde(default)]
135 pub boundary_violations: Vec<BoundaryViolationFinding>,
136 #[serde(default)]
138 pub stale_suppressions: Vec<StaleSuppression>,
139 #[serde(default)]
145 pub unused_catalog_entries: Vec<UnusedCatalogEntryFinding>,
146 #[serde(default)]
150 pub empty_catalog_groups: Vec<EmptyCatalogGroupFinding>,
151 #[serde(default)]
158 pub unresolved_catalog_references: Vec<UnresolvedCatalogReferenceFinding>,
159 #[serde(default)]
166 pub unused_dependency_overrides: Vec<UnusedDependencyOverrideFinding>,
167 #[serde(default)]
172 pub misconfigured_dependency_overrides: Vec<MisconfiguredDependencyOverrideFinding>,
173 #[serde(skip)]
177 pub suppression_count: usize,
178 #[serde(skip)]
185 pub active_suppressions: Vec<ActiveSuppression>,
186 #[serde(skip)]
189 pub feature_flags: Vec<FeatureFlag>,
190 #[serde(skip)]
194 pub export_usages: Vec<ExportUsage>,
195 #[serde(skip)]
199 pub entry_point_summary: Option<EntryPointSummary>,
200}
201
202impl AnalysisResults {
203 #[must_use]
234 pub const fn total_issues(&self) -> usize {
235 self.unused_files.len()
236 + self.unused_exports.len()
237 + self.unused_types.len()
238 + self.private_type_leaks.len()
239 + self.unused_dependencies.len()
240 + self.unused_dev_dependencies.len()
241 + self.unused_optional_dependencies.len()
242 + self.unused_enum_members.len()
243 + self.unused_class_members.len()
244 + self.unresolved_imports.len()
245 + self.unlisted_dependencies.len()
246 + self.duplicate_exports.len()
247 + self.type_only_dependencies.len()
248 + self.test_only_dependencies.len()
249 + self.circular_dependencies.len()
250 + self.re_export_cycles.len()
251 + self.boundary_violations.len()
252 + self.stale_suppressions.len()
253 + self.unused_catalog_entries.len()
254 + self.empty_catalog_groups.len()
255 + self.unresolved_catalog_references.len()
256 + self.unused_dependency_overrides.len()
257 + self.misconfigured_dependency_overrides.len()
258 }
259
260 #[must_use]
262 pub const fn has_issues(&self) -> bool {
263 self.total_issues() > 0
264 }
265
266 pub fn merge_into(&mut self, other: Self) {
279 let Self {
280 unused_files,
281 unused_exports,
282 unused_types,
283 private_type_leaks,
284 unused_dependencies,
285 unused_dev_dependencies,
286 unused_optional_dependencies,
287 unused_enum_members,
288 unused_class_members,
289 unresolved_imports,
290 unlisted_dependencies,
291 duplicate_exports,
292 type_only_dependencies,
293 test_only_dependencies,
294 circular_dependencies,
295 re_export_cycles,
296 boundary_violations,
297 stale_suppressions,
298 unused_catalog_entries,
299 empty_catalog_groups,
300 unresolved_catalog_references,
301 unused_dependency_overrides,
302 misconfigured_dependency_overrides,
303 suppression_count,
304 active_suppressions,
305 feature_flags,
306 export_usages,
307 entry_point_summary,
308 } = other;
309
310 self.unused_files.extend(unused_files);
311 self.unused_exports.extend(unused_exports);
312 self.unused_types.extend(unused_types);
313 self.private_type_leaks.extend(private_type_leaks);
314 self.unused_dependencies.extend(unused_dependencies);
315 self.unused_dev_dependencies.extend(unused_dev_dependencies);
316 self.unused_optional_dependencies
317 .extend(unused_optional_dependencies);
318 self.unused_enum_members.extend(unused_enum_members);
319 self.unused_class_members.extend(unused_class_members);
320 self.unresolved_imports.extend(unresolved_imports);
321 self.unlisted_dependencies.extend(unlisted_dependencies);
322 self.duplicate_exports.extend(duplicate_exports);
323 self.type_only_dependencies.extend(type_only_dependencies);
324 self.test_only_dependencies.extend(test_only_dependencies);
325 self.circular_dependencies.extend(circular_dependencies);
326 self.re_export_cycles.extend(re_export_cycles);
327 self.boundary_violations.extend(boundary_violations);
328 self.stale_suppressions.extend(stale_suppressions);
329 self.unused_catalog_entries.extend(unused_catalog_entries);
330 self.empty_catalog_groups.extend(empty_catalog_groups);
331 self.unresolved_catalog_references
332 .extend(unresolved_catalog_references);
333 self.unused_dependency_overrides
334 .extend(unused_dependency_overrides);
335 self.misconfigured_dependency_overrides
336 .extend(misconfigured_dependency_overrides);
337 self.feature_flags.extend(feature_flags);
338 self.export_usages.extend(export_usages);
339 self.active_suppressions.extend(active_suppressions);
340 self.suppression_count += suppression_count;
341 if self.entry_point_summary.is_none() {
342 self.entry_point_summary = entry_point_summary;
343 }
344 }
345
346 #[expect(
353 clippy::too_many_lines,
354 reason = "one short sort_by per result array; splitting would add indirection without clarity"
355 )]
356 pub fn sort(&mut self) {
357 self.unused_files
358 .sort_by(|a, b| a.file.path.cmp(&b.file.path));
359
360 self.unused_exports.sort_by(|a, b| {
361 a.export
362 .path
363 .cmp(&b.export.path)
364 .then(a.export.line.cmp(&b.export.line))
365 .then(a.export.export_name.cmp(&b.export.export_name))
366 });
367
368 self.unused_types.sort_by(|a, b| {
369 a.export
370 .path
371 .cmp(&b.export.path)
372 .then(a.export.line.cmp(&b.export.line))
373 .then(a.export.export_name.cmp(&b.export.export_name))
374 });
375
376 self.private_type_leaks.sort_by(|a, b| {
377 a.leak
378 .path
379 .cmp(&b.leak.path)
380 .then(a.leak.line.cmp(&b.leak.line))
381 .then(a.leak.export_name.cmp(&b.leak.export_name))
382 .then(a.leak.type_name.cmp(&b.leak.type_name))
383 });
384
385 self.unused_dependencies.sort_by(|a, b| {
386 a.dep
387 .path
388 .cmp(&b.dep.path)
389 .then(a.dep.line.cmp(&b.dep.line))
390 .then(a.dep.package_name.cmp(&b.dep.package_name))
391 });
392
393 self.unused_dev_dependencies.sort_by(|a, b| {
394 a.dep
395 .path
396 .cmp(&b.dep.path)
397 .then(a.dep.line.cmp(&b.dep.line))
398 .then(a.dep.package_name.cmp(&b.dep.package_name))
399 });
400
401 self.unused_optional_dependencies.sort_by(|a, b| {
402 a.dep
403 .path
404 .cmp(&b.dep.path)
405 .then(a.dep.line.cmp(&b.dep.line))
406 .then(a.dep.package_name.cmp(&b.dep.package_name))
407 });
408
409 self.unused_enum_members.sort_by(|a, b| {
410 a.member
411 .path
412 .cmp(&b.member.path)
413 .then(a.member.line.cmp(&b.member.line))
414 .then(a.member.parent_name.cmp(&b.member.parent_name))
415 .then(a.member.member_name.cmp(&b.member.member_name))
416 });
417
418 self.unused_class_members.sort_by(|a, b| {
419 a.member
420 .path
421 .cmp(&b.member.path)
422 .then(a.member.line.cmp(&b.member.line))
423 .then(a.member.parent_name.cmp(&b.member.parent_name))
424 .then(a.member.member_name.cmp(&b.member.member_name))
425 });
426
427 self.unresolved_imports.sort_by(|a, b| {
428 a.import
429 .path
430 .cmp(&b.import.path)
431 .then(a.import.line.cmp(&b.import.line))
432 .then(a.import.col.cmp(&b.import.col))
433 .then(a.import.specifier.cmp(&b.import.specifier))
434 });
435
436 self.unlisted_dependencies
437 .sort_by(|a, b| a.dep.package_name.cmp(&b.dep.package_name));
438 for dep in &mut self.unlisted_dependencies {
439 dep.dep
440 .imported_from
441 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
442 }
443
444 self.duplicate_exports
445 .sort_by(|a, b| a.export.export_name.cmp(&b.export.export_name));
446 for dup in &mut self.duplicate_exports {
447 dup.export
448 .locations
449 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
450 }
451
452 self.type_only_dependencies.sort_by(|a, b| {
453 a.dep
454 .path
455 .cmp(&b.dep.path)
456 .then(a.dep.line.cmp(&b.dep.line))
457 .then(a.dep.package_name.cmp(&b.dep.package_name))
458 });
459
460 self.test_only_dependencies.sort_by(|a, b| {
461 a.dep
462 .path
463 .cmp(&b.dep.path)
464 .then(a.dep.line.cmp(&b.dep.line))
465 .then(a.dep.package_name.cmp(&b.dep.package_name))
466 });
467
468 self.circular_dependencies.sort_by(|a, b| {
469 a.cycle
470 .files
471 .cmp(&b.cycle.files)
472 .then(a.cycle.length.cmp(&b.cycle.length))
473 });
474
475 self.re_export_cycles
476 .sort_by(|a, b| a.cycle.files.cmp(&b.cycle.files));
477
478 self.boundary_violations.sort_by(|a, b| {
479 a.violation
480 .from_path
481 .cmp(&b.violation.from_path)
482 .then(a.violation.line.cmp(&b.violation.line))
483 .then(a.violation.col.cmp(&b.violation.col))
484 .then(a.violation.to_path.cmp(&b.violation.to_path))
485 });
486
487 self.stale_suppressions.sort_by(|a, b| {
488 a.path
489 .cmp(&b.path)
490 .then(a.line.cmp(&b.line))
491 .then(a.col.cmp(&b.col))
492 });
493
494 self.unused_catalog_entries.sort_by(|a, b| {
495 a.entry
496 .path
497 .cmp(&b.entry.path)
498 .then_with(|| {
499 catalog_sort_key(&a.entry.catalog_name)
500 .cmp(&catalog_sort_key(&b.entry.catalog_name))
501 })
502 .then(a.entry.catalog_name.cmp(&b.entry.catalog_name))
503 .then(a.entry.entry_name.cmp(&b.entry.entry_name))
504 });
505 for finding in &mut self.unused_catalog_entries {
506 finding.entry.hardcoded_consumers.sort();
507 finding.entry.hardcoded_consumers.dedup();
508 }
509
510 self.empty_catalog_groups.sort_by(|a, b| {
511 a.group
512 .path
513 .cmp(&b.group.path)
514 .then_with(|| {
515 catalog_sort_key(&a.group.catalog_name)
516 .cmp(&catalog_sort_key(&b.group.catalog_name))
517 })
518 .then(a.group.catalog_name.cmp(&b.group.catalog_name))
519 .then(a.group.line.cmp(&b.group.line))
520 });
521
522 self.unresolved_catalog_references.sort_by(|a, b| {
523 a.reference
524 .path
525 .cmp(&b.reference.path)
526 .then(a.reference.line.cmp(&b.reference.line))
527 .then_with(|| {
528 catalog_sort_key(&a.reference.catalog_name)
529 .cmp(&catalog_sort_key(&b.reference.catalog_name))
530 })
531 .then(a.reference.catalog_name.cmp(&b.reference.catalog_name))
532 .then(a.reference.entry_name.cmp(&b.reference.entry_name))
533 });
534 for finding in &mut self.unresolved_catalog_references {
535 finding.reference.available_in_catalogs.sort();
536 finding.reference.available_in_catalogs.dedup();
537 }
538
539 self.unused_dependency_overrides.sort_by(|a, b| {
540 a.entry
541 .path
542 .cmp(&b.entry.path)
543 .then(a.entry.line.cmp(&b.entry.line))
544 .then(a.entry.raw_key.cmp(&b.entry.raw_key))
545 });
546
547 self.misconfigured_dependency_overrides.sort_by(|a, b| {
548 a.entry
549 .path
550 .cmp(&b.entry.path)
551 .then(a.entry.line.cmp(&b.entry.line))
552 .then(a.entry.raw_key.cmp(&b.entry.raw_key))
553 });
554
555 self.feature_flags.sort_by(|a, b| {
556 a.path
557 .cmp(&b.path)
558 .then(a.line.cmp(&b.line))
559 .then(a.flag_name.cmp(&b.flag_name))
560 });
561
562 for usage in &mut self.export_usages {
563 usage.reference_locations.sort_by(|a, b| {
564 a.path
565 .cmp(&b.path)
566 .then(a.line.cmp(&b.line))
567 .then(a.col.cmp(&b.col))
568 });
569 }
570 self.export_usages.sort_by(|a, b| {
571 a.path
572 .cmp(&b.path)
573 .then(a.line.cmp(&b.line))
574 .then(a.export_name.cmp(&b.export_name))
575 });
576 }
577}
578
579fn catalog_sort_key(name: &str) -> (u8, &str) {
581 if name == "default" {
582 (0, name)
583 } else {
584 (1, name)
585 }
586}
587
588#[derive(Debug, Clone, Serialize)]
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591pub struct UnusedFile {
592 #[serde(serialize_with = "serde_path::serialize")]
594 pub path: PathBuf,
595}
596
597#[derive(Debug, Clone, Serialize)]
599#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
600pub struct UnusedExport {
601 #[serde(serialize_with = "serde_path::serialize")]
603 pub path: PathBuf,
604 pub export_name: String,
606 pub is_type_only: bool,
608 pub line: u32,
610 pub col: u32,
612 pub span_start: u32,
614 pub is_re_export: bool,
616}
617
618#[derive(Debug, Clone, Serialize)]
620#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
621pub struct PrivateTypeLeak {
622 #[serde(serialize_with = "serde_path::serialize")]
624 pub path: PathBuf,
625 pub export_name: String,
627 pub type_name: String,
629 pub line: u32,
631 pub col: u32,
633 pub span_start: u32,
635}
636
637#[derive(Debug, Clone, Serialize)]
639#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
640pub struct UnusedDependency {
641 pub package_name: String,
643 pub location: DependencyLocation,
645 #[serde(serialize_with = "serde_path::serialize")]
648 pub path: PathBuf,
649 pub line: u32,
651 #[serde(
653 serialize_with = "serde_path::serialize_vec",
654 skip_serializing_if = "Vec::is_empty"
655 )]
656 #[cfg_attr(feature = "schema", schemars(default))]
657 pub used_in_workspaces: Vec<PathBuf>,
658}
659
660#[derive(Debug, Clone, Serialize)]
677#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
678#[serde(rename_all = "camelCase")]
679pub enum DependencyLocation {
680 Dependencies,
682 DevDependencies,
684 OptionalDependencies,
686}
687
688#[derive(Debug, Clone, Serialize)]
690#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
691pub struct UnusedMember {
692 #[serde(serialize_with = "serde_path::serialize")]
694 pub path: PathBuf,
695 pub parent_name: String,
697 pub member_name: String,
699 pub kind: MemberKind,
701 pub line: u32,
703 pub col: u32,
705}
706
707#[derive(Debug, Clone, Serialize)]
709#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
710pub struct UnresolvedImport {
711 #[serde(serialize_with = "serde_path::serialize")]
713 pub path: PathBuf,
714 pub specifier: String,
716 pub line: u32,
718 pub col: u32,
720 pub specifier_col: u32,
723}
724
725#[derive(Debug, Clone, Serialize)]
727#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
728pub struct UnlistedDependency {
729 pub package_name: String,
732 pub imported_from: Vec<ImportSite>,
734}
735
736#[derive(Debug, Clone, Serialize)]
738#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
739pub struct ImportSite {
740 #[serde(serialize_with = "serde_path::serialize")]
742 pub path: PathBuf,
743 pub line: u32,
745 pub col: u32,
747}
748
749#[derive(Debug, Clone, Serialize)]
751#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
752pub struct DuplicateExport {
753 pub export_name: String,
755 pub locations: Vec<DuplicateLocation>,
757}
758
759#[derive(Debug, Clone, Serialize)]
761#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
762pub struct DuplicateLocation {
763 #[serde(serialize_with = "serde_path::serialize")]
765 pub path: PathBuf,
766 pub line: u32,
768 pub col: u32,
770}
771
772#[derive(Debug, Clone, Serialize)]
776#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
777pub struct TypeOnlyDependency {
778 pub package_name: String,
780 #[serde(serialize_with = "serde_path::serialize")]
782 pub path: PathBuf,
783 pub line: u32,
785}
786
787#[derive(Debug, Clone, Serialize)]
793#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
794pub struct UnusedCatalogEntry {
795 pub entry_name: String,
797 pub catalog_name: String,
800 #[serde(serialize_with = "serde_path::serialize")]
802 pub path: PathBuf,
803 pub line: u32,
805 #[serde(
810 default,
811 serialize_with = "serde_path::serialize_vec",
812 skip_serializing_if = "Vec::is_empty"
813 )]
814 pub hardcoded_consumers: Vec<PathBuf>,
815}
816
817#[derive(Debug, Clone, Serialize)]
819#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
820pub struct EmptyCatalogGroup {
821 pub catalog_name: String,
823 #[serde(serialize_with = "serde_path::serialize")]
825 pub path: PathBuf,
826 pub line: u32,
828}
829
830#[derive(Debug, Clone, Serialize)]
841#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
842pub struct UnresolvedCatalogReference {
843 pub entry_name: String,
845 pub catalog_name: String,
848 #[serde(serialize_with = "serde_path::serialize")]
855 pub path: PathBuf,
856 pub line: u32,
858 #[serde(default, skip_serializing_if = "Vec::is_empty")]
863 pub available_in_catalogs: Vec<String>,
864}
865
866#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
870#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
871pub enum DependencyOverrideSource {
872 #[serde(rename = "pnpm-workspace.yaml")]
874 PnpmWorkspaceYaml,
875 #[serde(rename = "package.json")]
877 PnpmPackageJson,
878}
879
880impl DependencyOverrideSource {
881 #[must_use]
884 pub const fn as_label(&self) -> &'static str {
885 match self {
886 Self::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
887 Self::PnpmPackageJson => "package.json",
888 }
889 }
890}
891
892impl std::fmt::Display for DependencyOverrideSource {
893 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
894 f.write_str(self.as_label())
895 }
896}
897
898#[derive(Debug, Clone, Serialize)]
904#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
905pub struct UnusedDependencyOverride {
906 pub raw_key: String,
910 pub target_package: String,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub parent_package: Option<String>,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub version_constraint: Option<String>,
920 pub version_range: String,
922 pub source: DependencyOverrideSource,
925 #[serde(serialize_with = "serde_path::serialize")]
932 pub path: PathBuf,
933 pub line: u32,
935 #[serde(default, skip_serializing_if = "Option::is_none")]
940 pub hint: Option<String>,
941}
942
943#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
947#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
948#[serde(rename_all = "kebab-case")]
949pub enum DependencyOverrideMisconfigReason {
950 UnparsableKey,
953 EmptyValue,
955}
956
957impl DependencyOverrideMisconfigReason {
958 #[must_use]
960 pub const fn describe(self) -> &'static str {
961 match self {
962 Self::UnparsableKey => "override key cannot be parsed",
963 Self::EmptyValue => "override value is missing or empty",
964 }
965 }
966}
967
968#[derive(Debug, Clone, Serialize)]
972#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
973pub struct MisconfiguredDependencyOverride {
974 pub raw_key: String,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub target_package: Option<String>,
985 pub raw_value: String,
988 pub reason: DependencyOverrideMisconfigReason,
992 pub source: DependencyOverrideSource,
994 #[serde(serialize_with = "serde_path::serialize")]
998 pub path: PathBuf,
999 pub line: u32,
1001}
1002
1003#[derive(Debug, Clone, Serialize)]
1006#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1007pub struct TestOnlyDependency {
1008 pub package_name: String,
1011 #[serde(serialize_with = "serde_path::serialize")]
1013 pub path: PathBuf,
1014 pub line: u32,
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize)]
1029#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1030#[cfg_attr(feature = "schema", schemars(extend("required" = ["files", "length", "line", "col"])))]
1031pub struct CircularDependency {
1032 #[serde(serialize_with = "serde_path::serialize_vec")]
1034 pub files: Vec<PathBuf>,
1035 pub length: usize,
1037 #[serde(default)]
1039 pub line: u32,
1040 #[serde(default)]
1042 pub col: u32,
1043 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1045 pub is_cross_package: bool,
1046}
1047
1048#[derive(Debug, Clone, Serialize, Deserialize)]
1058#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1059pub struct ReExportCycle {
1060 #[serde(serialize_with = "serde_path::serialize_vec")]
1063 pub files: Vec<PathBuf>,
1064 pub kind: ReExportCycleKind,
1066}
1067
1068#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1070#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1071#[serde(rename_all = "kebab-case")]
1072pub enum ReExportCycleKind {
1073 MultiNode,
1076 SelfLoop,
1078}
1079
1080#[derive(Debug, Clone, Serialize)]
1082#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1083pub struct BoundaryViolation {
1084 #[serde(serialize_with = "serde_path::serialize")]
1086 pub from_path: PathBuf,
1087 #[serde(serialize_with = "serde_path::serialize")]
1089 pub to_path: PathBuf,
1090 pub from_zone: String,
1092 pub to_zone: String,
1094 pub import_specifier: String,
1096 pub line: u32,
1098 pub col: u32,
1100}
1101
1102#[derive(Debug, Clone, Serialize)]
1104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1105#[serde(rename_all = "snake_case", tag = "type")]
1106pub enum SuppressionOrigin {
1107 Comment {
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1111 issue_kind: Option<String>,
1112 is_file_level: bool,
1114 #[serde(default = "default_true", skip_serializing_if = "is_true")]
1121 kind_known: bool,
1122 },
1123 JsdocTag {
1125 export_name: String,
1127 },
1128}
1129
1130#[expect(
1131 clippy::trivially_copy_pass_by_ref,
1132 reason = "serde skip_serializing_if takes a reference by contract"
1133)]
1134const fn is_true(b: &bool) -> bool {
1135 *b
1136}
1137
1138#[cfg_attr(
1153 not(feature = "schema"),
1154 expect(
1155 dead_code,
1156 reason = "referenced via #[serde(default = ...)]; only consumed by schemars under the `schema` feature, dead on default builds today"
1157 )
1158)]
1159const fn default_true() -> bool {
1160 true
1161}
1162
1163#[derive(Debug, Clone, Serialize)]
1165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1166pub struct StaleSuppression {
1167 #[serde(serialize_with = "serde_path::serialize")]
1169 pub path: PathBuf,
1170 pub line: u32,
1172 pub col: u32,
1174 pub origin: SuppressionOrigin,
1176}
1177
1178impl StaleSuppression {
1179 #[must_use]
1181 pub fn description(&self) -> String {
1182 match &self.origin {
1183 SuppressionOrigin::Comment {
1184 issue_kind,
1185 is_file_level,
1186 ..
1187 } => {
1188 let directive = if *is_file_level {
1189 "fallow-ignore-file"
1190 } else {
1191 "fallow-ignore-next-line"
1192 };
1193 match issue_kind {
1194 Some(kind) => format!("// {directive} {kind}"),
1195 None => format!("// {directive}"),
1196 }
1197 }
1198 SuppressionOrigin::JsdocTag { export_name } => {
1199 format!("@expected-unused on {export_name}")
1200 }
1201 }
1202 }
1203
1204 #[must_use]
1211 pub fn explanation(&self) -> String {
1212 match &self.origin {
1213 SuppressionOrigin::Comment {
1214 issue_kind,
1215 is_file_level,
1216 kind_known,
1217 } => {
1218 let scope = if *is_file_level {
1219 "in this file"
1220 } else {
1221 "on the next line"
1222 };
1223 match issue_kind {
1224 Some(kind) if !*kind_known => match closest_known_kind_name(kind) {
1225 Some(suggestion) => format!(
1226 "'{kind}' is not a recognized fallow issue kind. Did you mean '{suggestion}'? Other tokens on this line still apply."
1227 ),
1228 None => format!(
1229 "'{kind}' is not a recognized fallow issue kind. Other tokens on this line still apply."
1230 ),
1231 },
1232 Some(kind) => format!("no {kind} issue found {scope}"),
1233 None => format!("no issues found {scope}"),
1234 }
1235 }
1236 SuppressionOrigin::JsdocTag { export_name } => {
1237 format!("{export_name} is now used")
1238 }
1239 }
1240 }
1241
1242 #[must_use]
1247 pub fn suppressed_kind(&self) -> Option<IssueKind> {
1248 match &self.origin {
1249 SuppressionOrigin::Comment {
1250 issue_kind,
1251 kind_known: true,
1252 ..
1253 } => issue_kind.as_deref().and_then(IssueKind::parse),
1254 SuppressionOrigin::Comment { .. } | SuppressionOrigin::JsdocTag { .. } => None,
1255 }
1256 }
1257
1258 #[must_use]
1265 pub fn display_message(&self) -> String {
1266 match &self.origin {
1267 SuppressionOrigin::Comment {
1268 kind_known: false, ..
1269 } => format!("{} ({})", self.description(), self.explanation()),
1270 SuppressionOrigin::Comment { .. } | SuppressionOrigin::JsdocTag { .. } => {
1271 self.description()
1272 }
1273 }
1274 }
1275}
1276
1277#[derive(Debug, Clone)]
1291pub struct ActiveSuppression {
1292 pub path: PathBuf,
1294 pub kind: Option<String>,
1297 pub is_file_level: bool,
1300}
1301
1302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1304#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1305#[serde(rename_all = "snake_case")]
1306pub enum FlagKind {
1307 EnvironmentVariable,
1309 SdkCall,
1311 ConfigObject,
1313}
1314
1315#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1317#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1318#[serde(rename_all = "snake_case")]
1319pub enum FlagConfidence {
1320 Low,
1322 Medium,
1324 High,
1326}
1327
1328#[derive(Debug, Clone, Serialize)]
1330#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1331pub struct FeatureFlag {
1332 #[serde(serialize_with = "serde_path::serialize")]
1334 pub path: PathBuf,
1335 pub flag_name: String,
1337 pub kind: FlagKind,
1339 pub confidence: FlagConfidence,
1341 pub line: u32,
1343 pub col: u32,
1345 #[serde(skip)]
1347 pub guard_span_start: Option<u32>,
1348 #[serde(skip)]
1350 pub guard_span_end: Option<u32>,
1351 #[serde(default, skip_serializing_if = "Option::is_none")]
1353 pub sdk_name: Option<String>,
1354 #[serde(skip)]
1357 pub guard_line_start: Option<u32>,
1358 #[serde(skip)]
1360 pub guard_line_end: Option<u32>,
1361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1364 pub guarded_dead_exports: Vec<String>,
1365}
1366
1367const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
1369
1370#[derive(Debug, Clone, Serialize)]
1373#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1374pub struct ExportUsage {
1375 #[serde(serialize_with = "serde_path::serialize")]
1377 pub path: PathBuf,
1378 pub export_name: String,
1380 pub line: u32,
1382 pub col: u32,
1384 pub reference_count: usize,
1386 pub reference_locations: Vec<ReferenceLocation>,
1389}
1390
1391#[derive(Debug, Clone, Serialize)]
1393#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1394pub struct ReferenceLocation {
1395 #[serde(serialize_with = "serde_path::serialize")]
1397 pub path: PathBuf,
1398 pub line: u32,
1400 pub col: u32,
1402}
1403
1404#[cfg(test)]
1405mod tests {
1406 use super::*;
1407 use crate::output_dead_code::{
1408 BoundaryViolationFinding, CircularDependencyFinding, UnresolvedImportFinding,
1409 UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
1410 UnusedTypeFinding,
1411 };
1412
1413 #[test]
1414 fn empty_results_no_issues() {
1415 let results = AnalysisResults::default();
1416 assert_eq!(results.total_issues(), 0);
1417 assert!(!results.has_issues());
1418 }
1419
1420 #[test]
1421 fn results_with_unused_file() {
1422 let mut results = AnalysisResults::default();
1423 results
1424 .unused_files
1425 .push(UnusedFileFinding::with_actions(UnusedFile {
1426 path: PathBuf::from("test.ts"),
1427 }));
1428 assert_eq!(results.total_issues(), 1);
1429 assert!(results.has_issues());
1430 }
1431
1432 #[test]
1433 fn results_with_unused_export() {
1434 let mut results = AnalysisResults::default();
1435 results
1436 .unused_exports
1437 .push(UnusedExportFinding::with_actions(UnusedExport {
1438 path: PathBuf::from("test.ts"),
1439 export_name: "foo".to_string(),
1440 is_type_only: false,
1441 line: 1,
1442 col: 0,
1443 span_start: 0,
1444 is_re_export: false,
1445 }));
1446 assert_eq!(results.total_issues(), 1);
1447 assert!(results.has_issues());
1448 }
1449
1450 fn test_unused_export(path: &str, export_name: &str, is_type_only: bool) -> UnusedExport {
1451 UnusedExport {
1452 path: PathBuf::from(path),
1453 export_name: export_name.to_string(),
1454 is_type_only,
1455 line: 1,
1456 col: 0,
1457 span_start: 0,
1458 is_re_export: false,
1459 }
1460 }
1461
1462 fn test_unused_dependency(
1463 package_name: &str,
1464 location: DependencyLocation,
1465 ) -> UnusedDependency {
1466 UnusedDependency {
1467 package_name: package_name.to_string(),
1468 location,
1469 path: PathBuf::from("package.json"),
1470 line: 5,
1471 used_in_workspaces: Vec::new(),
1472 }
1473 }
1474
1475 fn test_unused_member(member_name: &str, kind: MemberKind) -> UnusedMember {
1476 UnusedMember {
1477 path: PathBuf::from("members.ts"),
1478 parent_name: "Parent".to_string(),
1479 member_name: member_name.to_string(),
1480 kind,
1481 line: 1,
1482 col: 0,
1483 }
1484 }
1485
1486 #[test]
1487 fn results_total_counts_all_types() {
1488 let results = AnalysisResults {
1489 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1490 path: PathBuf::from("a.ts"),
1491 })],
1492 unused_exports: vec![UnusedExportFinding::with_actions(test_unused_export(
1493 "b.ts", "x", false,
1494 ))],
1495 unused_types: vec![UnusedTypeFinding::with_actions(test_unused_export(
1496 "c.ts", "T", true,
1497 ))],
1498 unused_dependencies: vec![UnusedDependencyFinding::with_actions(
1499 test_unused_dependency("dep", DependencyLocation::Dependencies),
1500 )],
1501 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
1502 test_unused_dependency("dev", DependencyLocation::DevDependencies),
1503 )],
1504 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(test_unused_member(
1505 "A",
1506 MemberKind::EnumMember,
1507 ))],
1508 unused_class_members: vec![UnusedClassMemberFinding::with_actions(test_unused_member(
1509 "m",
1510 MemberKind::ClassMethod,
1511 ))],
1512 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
1513 path: PathBuf::from("f.ts"),
1514 specifier: "./missing".to_string(),
1515 line: 1,
1516 col: 0,
1517 specifier_col: 0,
1518 })],
1519 unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
1520 UnlistedDependency {
1521 package_name: "unlisted".to_string(),
1522 imported_from: vec![ImportSite {
1523 path: PathBuf::from("g.ts"),
1524 line: 1,
1525 col: 0,
1526 }],
1527 },
1528 )],
1529 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
1530 export_name: "dup".to_string(),
1531 locations: vec![
1532 DuplicateLocation {
1533 path: PathBuf::from("h.ts"),
1534 line: 15,
1535 col: 0,
1536 },
1537 DuplicateLocation {
1538 path: PathBuf::from("i.ts"),
1539 line: 30,
1540 col: 0,
1541 },
1542 ],
1543 })],
1544 unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
1545 test_unused_dependency("optional", DependencyLocation::OptionalDependencies),
1546 )],
1547 type_only_dependencies: vec![TypeOnlyDependencyFinding::with_actions(
1548 TypeOnlyDependency {
1549 package_name: "type-only".to_string(),
1550 path: PathBuf::from("package.json"),
1551 line: 8,
1552 },
1553 )],
1554 test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
1555 TestOnlyDependency {
1556 package_name: "test-only".to_string(),
1557 path: PathBuf::from("package.json"),
1558 line: 9,
1559 },
1560 )],
1561 circular_dependencies: vec![CircularDependencyFinding::with_actions(
1562 CircularDependency {
1563 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1564 length: 2,
1565 line: 3,
1566 col: 0,
1567 is_cross_package: false,
1568 },
1569 )],
1570 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
1571 from_path: PathBuf::from("src/ui/Button.tsx"),
1572 to_path: PathBuf::from("src/db/queries.ts"),
1573 from_zone: "ui".to_string(),
1574 to_zone: "database".to_string(),
1575 import_specifier: "../db/queries".to_string(),
1576 line: 3,
1577 col: 0,
1578 })],
1579 ..Default::default()
1580 };
1581
1582 assert_eq!(results.total_issues(), 15);
1584 assert!(results.has_issues());
1585 }
1586
1587 #[test]
1590 fn total_issues_and_has_issues_are_consistent() {
1591 let results = AnalysisResults::default();
1592 assert_eq!(results.total_issues(), 0);
1593 assert!(!results.has_issues());
1594 assert_eq!(results.total_issues() > 0, results.has_issues());
1595 }
1596
1597 #[test]
1600 fn total_issues_sums_all_categories_independently() {
1601 let mut results = AnalysisResults::default();
1602 results
1603 .unused_files
1604 .push(UnusedFileFinding::with_actions(UnusedFile {
1605 path: PathBuf::from("a.ts"),
1606 }));
1607 assert_eq!(results.total_issues(), 1);
1608
1609 results
1610 .unused_files
1611 .push(UnusedFileFinding::with_actions(UnusedFile {
1612 path: PathBuf::from("b.ts"),
1613 }));
1614 assert_eq!(results.total_issues(), 2);
1615
1616 results
1617 .unresolved_imports
1618 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1619 path: PathBuf::from("c.ts"),
1620 specifier: "./missing".to_string(),
1621 line: 1,
1622 col: 0,
1623 specifier_col: 0,
1624 }));
1625 assert_eq!(results.total_issues(), 3);
1626 }
1627
1628 #[test]
1631 fn default_results_all_fields_empty() {
1632 let r = AnalysisResults::default();
1633 assert!(r.unused_files.is_empty());
1634 assert!(r.unused_exports.is_empty());
1635 assert!(r.unused_types.is_empty());
1636 assert!(r.unused_dependencies.is_empty());
1637 assert!(r.unused_dev_dependencies.is_empty());
1638 assert!(r.unused_optional_dependencies.is_empty());
1639 assert!(r.unused_enum_members.is_empty());
1640 assert!(r.unused_class_members.is_empty());
1641 assert!(r.unresolved_imports.is_empty());
1642 assert!(r.unlisted_dependencies.is_empty());
1643 assert!(r.duplicate_exports.is_empty());
1644 assert!(r.type_only_dependencies.is_empty());
1645 assert!(r.test_only_dependencies.is_empty());
1646 assert!(r.circular_dependencies.is_empty());
1647 assert!(r.boundary_violations.is_empty());
1648 assert!(r.unused_catalog_entries.is_empty());
1649 assert!(r.unresolved_catalog_references.is_empty());
1650 assert!(r.export_usages.is_empty());
1651 }
1652
1653 #[test]
1656 fn entry_point_summary_default() {
1657 let summary = EntryPointSummary::default();
1658 assert_eq!(summary.total, 0);
1659 assert!(summary.by_source.is_empty());
1660 }
1661
1662 #[test]
1663 fn entry_point_summary_not_in_default_results() {
1664 let r = AnalysisResults::default();
1665 assert!(r.entry_point_summary.is_none());
1666 }
1667
1668 #[test]
1669 fn entry_point_summary_some_preserves_data() {
1670 let r = AnalysisResults {
1671 entry_point_summary: Some(EntryPointSummary {
1672 total: 5,
1673 by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
1674 }),
1675 ..AnalysisResults::default()
1676 };
1677 let summary = r.entry_point_summary.as_ref().unwrap();
1678 assert_eq!(summary.total, 5);
1679 assert_eq!(summary.by_source.len(), 2);
1680 assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
1681 }
1682
1683 #[test]
1686 fn sort_unused_files_by_path() {
1687 let mut r = AnalysisResults::default();
1688 r.unused_files
1689 .push(UnusedFileFinding::with_actions(UnusedFile {
1690 path: PathBuf::from("z.ts"),
1691 }));
1692 r.unused_files
1693 .push(UnusedFileFinding::with_actions(UnusedFile {
1694 path: PathBuf::from("a.ts"),
1695 }));
1696 r.unused_files
1697 .push(UnusedFileFinding::with_actions(UnusedFile {
1698 path: PathBuf::from("m.ts"),
1699 }));
1700 r.sort();
1701 let paths: Vec<_> = r
1702 .unused_files
1703 .iter()
1704 .map(|f| f.file.path.to_string_lossy().to_string())
1705 .collect();
1706 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
1707 }
1708
1709 #[test]
1712 fn sort_unused_exports_by_path_line_name() {
1713 let mut r = AnalysisResults::default();
1714 let mk = |path: &str, line: u32, name: &str| {
1715 UnusedExportFinding::with_actions(UnusedExport {
1716 path: PathBuf::from(path),
1717 export_name: name.to_string(),
1718 is_type_only: false,
1719 line,
1720 col: 0,
1721 span_start: 0,
1722 is_re_export: false,
1723 })
1724 };
1725 r.unused_exports.push(mk("b.ts", 5, "beta"));
1726 r.unused_exports.push(mk("a.ts", 10, "zeta"));
1727 r.unused_exports.push(mk("a.ts", 10, "alpha"));
1728 r.unused_exports.push(mk("a.ts", 1, "gamma"));
1729 r.sort();
1730 let keys: Vec<_> = r
1731 .unused_exports
1732 .iter()
1733 .map(|e| {
1734 format!(
1735 "{}:{}:{}",
1736 e.export.path.to_string_lossy(),
1737 e.export.line,
1738 e.export.export_name
1739 )
1740 })
1741 .collect();
1742 assert_eq!(
1743 keys,
1744 vec![
1745 "a.ts:1:gamma",
1746 "a.ts:10:alpha",
1747 "a.ts:10:zeta",
1748 "b.ts:5:beta"
1749 ]
1750 );
1751 }
1752
1753 #[test]
1756 fn sort_unused_types_by_path_line_name() {
1757 let mut r = AnalysisResults::default();
1758 let mk = |path: &str, line: u32, name: &str| {
1759 UnusedTypeFinding::with_actions(UnusedExport {
1760 path: PathBuf::from(path),
1761 export_name: name.to_string(),
1762 is_type_only: true,
1763 line,
1764 col: 0,
1765 span_start: 0,
1766 is_re_export: false,
1767 })
1768 };
1769 r.unused_types.push(mk("z.ts", 1, "Z"));
1770 r.unused_types.push(mk("a.ts", 1, "A"));
1771 r.sort();
1772 assert_eq!(r.unused_types[0].export.path, PathBuf::from("a.ts"));
1773 assert_eq!(r.unused_types[1].export.path, PathBuf::from("z.ts"));
1774 }
1775
1776 #[test]
1779 fn sort_unused_dependencies_by_path_line_name() {
1780 let mut r = AnalysisResults::default();
1781 let mk = |path: &str, line: u32, name: &str| {
1782 UnusedDependencyFinding::with_actions(UnusedDependency {
1783 package_name: name.to_string(),
1784 location: DependencyLocation::Dependencies,
1785 path: PathBuf::from(path),
1786 line,
1787 used_in_workspaces: Vec::new(),
1788 })
1789 };
1790 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
1791 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
1792 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
1793 r.sort();
1794 let names: Vec<_> = r
1795 .unused_dependencies
1796 .iter()
1797 .map(|d| d.dep.package_name.as_str())
1798 .collect();
1799 assert_eq!(names, vec!["axios", "react", "zlib"]);
1800 }
1801
1802 #[test]
1805 fn sort_unused_dev_dependencies() {
1806 let mut r = AnalysisResults::default();
1807 r.unused_dev_dependencies
1808 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1809 package_name: "vitest".to_string(),
1810 location: DependencyLocation::DevDependencies,
1811 path: PathBuf::from("package.json"),
1812 line: 10,
1813 used_in_workspaces: Vec::new(),
1814 }));
1815 r.unused_dev_dependencies
1816 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1817 package_name: "jest".to_string(),
1818 location: DependencyLocation::DevDependencies,
1819 path: PathBuf::from("package.json"),
1820 line: 5,
1821 used_in_workspaces: Vec::new(),
1822 }));
1823 r.sort();
1824 assert_eq!(r.unused_dev_dependencies[0].dep.package_name, "jest");
1825 assert_eq!(r.unused_dev_dependencies[1].dep.package_name, "vitest");
1826 }
1827
1828 #[test]
1831 fn sort_unused_optional_dependencies() {
1832 let mut r = AnalysisResults::default();
1833 r.unused_optional_dependencies
1834 .push(UnusedOptionalDependencyFinding::with_actions(
1835 UnusedDependency {
1836 package_name: "zod".to_string(),
1837 location: DependencyLocation::OptionalDependencies,
1838 path: PathBuf::from("package.json"),
1839 line: 3,
1840 used_in_workspaces: Vec::new(),
1841 },
1842 ));
1843 r.unused_optional_dependencies
1844 .push(UnusedOptionalDependencyFinding::with_actions(
1845 UnusedDependency {
1846 package_name: "ajv".to_string(),
1847 location: DependencyLocation::OptionalDependencies,
1848 path: PathBuf::from("package.json"),
1849 line: 2,
1850 used_in_workspaces: Vec::new(),
1851 },
1852 ));
1853 r.sort();
1854 assert_eq!(r.unused_optional_dependencies[0].dep.package_name, "ajv");
1855 assert_eq!(r.unused_optional_dependencies[1].dep.package_name, "zod");
1856 }
1857
1858 #[test]
1861 fn sort_unused_enum_members_by_path_line_parent_member() {
1862 let mut r = AnalysisResults::default();
1863 let mk = |path: &str, line: u32, parent: &str, member: &str| {
1864 UnusedEnumMemberFinding::with_actions(UnusedMember {
1865 path: PathBuf::from(path),
1866 parent_name: parent.to_string(),
1867 member_name: member.to_string(),
1868 kind: MemberKind::EnumMember,
1869 line,
1870 col: 0,
1871 })
1872 };
1873 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
1874 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
1875 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
1876 r.sort();
1877 let keys: Vec<_> = r
1878 .unused_enum_members
1879 .iter()
1880 .map(|m| format!("{}:{}", m.member.parent_name, m.member.member_name))
1881 .collect();
1882 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
1883 }
1884
1885 #[test]
1888 fn sort_unused_class_members() {
1889 let mut r = AnalysisResults::default();
1890 let mk = |path: &str, line: u32, parent: &str, member: &str| {
1891 UnusedClassMemberFinding::with_actions(UnusedMember {
1892 path: PathBuf::from(path),
1893 parent_name: parent.to_string(),
1894 member_name: member.to_string(),
1895 kind: MemberKind::ClassMethod,
1896 line,
1897 col: 0,
1898 })
1899 };
1900 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1901 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1902 r.sort();
1903 assert_eq!(r.unused_class_members[0].member.path, PathBuf::from("a.ts"));
1904 assert_eq!(r.unused_class_members[1].member.path, PathBuf::from("b.ts"));
1905 }
1906
1907 #[test]
1910 fn sort_unresolved_imports_by_path_line_col_specifier() {
1911 let mut r = AnalysisResults::default();
1912 let mk = |path: &str, line: u32, col: u32, spec: &str| {
1913 UnresolvedImportFinding::with_actions(UnresolvedImport {
1914 path: PathBuf::from(path),
1915 specifier: spec.to_string(),
1916 line,
1917 col,
1918 specifier_col: 0,
1919 })
1920 };
1921 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1922 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1923 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1924 r.sort();
1925 let specs: Vec<_> = r
1926 .unresolved_imports
1927 .iter()
1928 .map(|i| i.import.specifier.as_str())
1929 .collect();
1930 assert_eq!(specs, vec!["./m", "./a", "./z"]);
1931 }
1932
1933 #[test]
1936 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1937 let mut r = AnalysisResults::default();
1938 r.unlisted_dependencies
1939 .push(UnlistedDependencyFinding::with_actions(
1940 UnlistedDependency {
1941 package_name: "zod".to_string(),
1942 imported_from: vec![
1943 ImportSite {
1944 path: PathBuf::from("b.ts"),
1945 line: 10,
1946 col: 0,
1947 },
1948 ImportSite {
1949 path: PathBuf::from("a.ts"),
1950 line: 1,
1951 col: 0,
1952 },
1953 ],
1954 },
1955 ));
1956 r.unlisted_dependencies
1957 .push(UnlistedDependencyFinding::with_actions(
1958 UnlistedDependency {
1959 package_name: "axios".to_string(),
1960 imported_from: vec![ImportSite {
1961 path: PathBuf::from("c.ts"),
1962 line: 1,
1963 col: 0,
1964 }],
1965 },
1966 ));
1967 r.sort();
1968
1969 assert_eq!(r.unlisted_dependencies[0].dep.package_name, "axios");
1971 assert_eq!(r.unlisted_dependencies[1].dep.package_name, "zod");
1972
1973 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1975 .dep
1976 .imported_from
1977 .iter()
1978 .map(|s| s.path.to_string_lossy().to_string())
1979 .collect();
1980 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1981 }
1982
1983 #[test]
1986 fn sort_duplicate_exports_by_name_and_inner_locations() {
1987 let mut r = AnalysisResults::default();
1988 r.duplicate_exports
1989 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1990 export_name: "z".to_string(),
1991 locations: vec![
1992 DuplicateLocation {
1993 path: PathBuf::from("c.ts"),
1994 line: 1,
1995 col: 0,
1996 },
1997 DuplicateLocation {
1998 path: PathBuf::from("a.ts"),
1999 line: 5,
2000 col: 0,
2001 },
2002 ],
2003 }));
2004 r.duplicate_exports
2005 .push(DuplicateExportFinding::with_actions(DuplicateExport {
2006 export_name: "a".to_string(),
2007 locations: vec![DuplicateLocation {
2008 path: PathBuf::from("b.ts"),
2009 line: 1,
2010 col: 0,
2011 }],
2012 }));
2013 r.sort();
2014
2015 assert_eq!(r.duplicate_exports[0].export.export_name, "a");
2017 assert_eq!(r.duplicate_exports[1].export.export_name, "z");
2018
2019 let z_locs: Vec<_> = r.duplicate_exports[1]
2021 .export
2022 .locations
2023 .iter()
2024 .map(|l| l.path.to_string_lossy().to_string())
2025 .collect();
2026 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
2027 }
2028
2029 #[test]
2032 fn sort_type_only_dependencies() {
2033 let mut r = AnalysisResults::default();
2034 r.type_only_dependencies
2035 .push(TypeOnlyDependencyFinding::with_actions(
2036 TypeOnlyDependency {
2037 package_name: "zod".to_string(),
2038 path: PathBuf::from("package.json"),
2039 line: 10,
2040 },
2041 ));
2042 r.type_only_dependencies
2043 .push(TypeOnlyDependencyFinding::with_actions(
2044 TypeOnlyDependency {
2045 package_name: "ajv".to_string(),
2046 path: PathBuf::from("package.json"),
2047 line: 5,
2048 },
2049 ));
2050 r.sort();
2051 assert_eq!(r.type_only_dependencies[0].dep.package_name, "ajv");
2052 assert_eq!(r.type_only_dependencies[1].dep.package_name, "zod");
2053 }
2054
2055 #[test]
2058 fn sort_test_only_dependencies() {
2059 let mut r = AnalysisResults::default();
2060 r.test_only_dependencies
2061 .push(TestOnlyDependencyFinding::with_actions(
2062 TestOnlyDependency {
2063 package_name: "vitest".to_string(),
2064 path: PathBuf::from("package.json"),
2065 line: 15,
2066 },
2067 ));
2068 r.test_only_dependencies
2069 .push(TestOnlyDependencyFinding::with_actions(
2070 TestOnlyDependency {
2071 package_name: "jest".to_string(),
2072 path: PathBuf::from("package.json"),
2073 line: 10,
2074 },
2075 ));
2076 r.sort();
2077 assert_eq!(r.test_only_dependencies[0].dep.package_name, "jest");
2078 assert_eq!(r.test_only_dependencies[1].dep.package_name, "vitest");
2079 }
2080
2081 #[test]
2084 fn sort_circular_dependencies_by_files_then_length() {
2085 let mut r = AnalysisResults::default();
2086 r.circular_dependencies
2087 .push(CircularDependencyFinding::with_actions(
2088 CircularDependency {
2089 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
2090 length: 2,
2091 line: 1,
2092 col: 0,
2093 is_cross_package: false,
2094 },
2095 ));
2096 r.circular_dependencies
2097 .push(CircularDependencyFinding::with_actions(
2098 CircularDependency {
2099 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2100 length: 2,
2101 line: 1,
2102 col: 0,
2103 is_cross_package: true,
2104 },
2105 ));
2106 r.sort();
2107 assert_eq!(
2108 r.circular_dependencies[0].cycle.files[0],
2109 PathBuf::from("a.ts")
2110 );
2111 assert_eq!(
2112 r.circular_dependencies[1].cycle.files[0],
2113 PathBuf::from("b.ts")
2114 );
2115 }
2116
2117 #[test]
2120 fn sort_boundary_violations() {
2121 let mut r = AnalysisResults::default();
2122 let mk = |from: &str, line: u32, col: u32, to: &str| {
2123 BoundaryViolationFinding::with_actions(BoundaryViolation {
2124 from_path: PathBuf::from(from),
2125 to_path: PathBuf::from(to),
2126 from_zone: "a".to_string(),
2127 to_zone: "b".to_string(),
2128 import_specifier: to.to_string(),
2129 line,
2130 col,
2131 })
2132 };
2133 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
2134 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
2135 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
2136 r.sort();
2137 let from_paths: Vec<_> = r
2138 .boundary_violations
2139 .iter()
2140 .map(|v| {
2141 format!(
2142 "{}:{}",
2143 v.violation.from_path.to_string_lossy(),
2144 v.violation.line
2145 )
2146 })
2147 .collect();
2148 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
2149 }
2150
2151 #[test]
2154 fn sort_export_usages_and_inner_reference_locations() {
2155 let mut r = AnalysisResults::default();
2156 r.export_usages.push(ExportUsage {
2157 path: PathBuf::from("z.ts"),
2158 export_name: "foo".to_string(),
2159 line: 1,
2160 col: 0,
2161 reference_count: 2,
2162 reference_locations: vec![
2163 ReferenceLocation {
2164 path: PathBuf::from("c.ts"),
2165 line: 10,
2166 col: 0,
2167 },
2168 ReferenceLocation {
2169 path: PathBuf::from("a.ts"),
2170 line: 5,
2171 col: 0,
2172 },
2173 ],
2174 });
2175 r.export_usages.push(ExportUsage {
2176 path: PathBuf::from("a.ts"),
2177 export_name: "bar".to_string(),
2178 line: 1,
2179 col: 0,
2180 reference_count: 1,
2181 reference_locations: vec![ReferenceLocation {
2182 path: PathBuf::from("b.ts"),
2183 line: 1,
2184 col: 0,
2185 }],
2186 });
2187 r.sort();
2188
2189 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
2191 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
2192
2193 let refs: Vec<_> = r.export_usages[1]
2195 .reference_locations
2196 .iter()
2197 .map(|l| l.path.to_string_lossy().to_string())
2198 .collect();
2199 assert_eq!(refs, vec!["a.ts", "c.ts"]);
2200 }
2201
2202 #[test]
2205 fn sort_empty_results_is_noop() {
2206 let mut r = AnalysisResults::default();
2207 r.sort(); assert_eq!(r.total_issues(), 0);
2209 }
2210
2211 #[test]
2214 fn sort_single_element_lists_stable() {
2215 let mut r = AnalysisResults::default();
2216 r.unused_files
2217 .push(UnusedFileFinding::with_actions(UnusedFile {
2218 path: PathBuf::from("only.ts"),
2219 }));
2220 r.sort();
2221 assert_eq!(r.unused_files[0].file.path, PathBuf::from("only.ts"));
2222 }
2223
2224 #[test]
2227 fn serialize_empty_results() {
2228 let r = AnalysisResults::default();
2229 let json = serde_json::to_value(&r).unwrap();
2230
2231 assert!(json["unused_files"].as_array().unwrap().is_empty());
2233 assert!(json["unused_exports"].as_array().unwrap().is_empty());
2234 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
2235
2236 assert!(json.get("export_usages").is_none());
2238 assert!(json.get("entry_point_summary").is_none());
2239 }
2240
2241 #[test]
2242 fn serialize_unused_file_path() {
2243 let r = UnusedFile {
2244 path: PathBuf::from("src/utils/index.ts"),
2245 };
2246 let json = serde_json::to_value(&r).unwrap();
2247 assert_eq!(json["path"], "src/utils/index.ts");
2248 }
2249
2250 #[test]
2251 fn serialize_dependency_location_camel_case() {
2252 let dep = UnusedDependency {
2253 package_name: "react".to_string(),
2254 location: DependencyLocation::DevDependencies,
2255 path: PathBuf::from("package.json"),
2256 line: 5,
2257 used_in_workspaces: Vec::new(),
2258 };
2259 let json = serde_json::to_value(&dep).unwrap();
2260 assert_eq!(json["location"], "devDependencies");
2261
2262 let dep2 = UnusedDependency {
2263 package_name: "react".to_string(),
2264 location: DependencyLocation::Dependencies,
2265 path: PathBuf::from("package.json"),
2266 line: 3,
2267 used_in_workspaces: Vec::new(),
2268 };
2269 let json2 = serde_json::to_value(&dep2).unwrap();
2270 assert_eq!(json2["location"], "dependencies");
2271
2272 let dep3 = UnusedDependency {
2273 package_name: "fsevents".to_string(),
2274 location: DependencyLocation::OptionalDependencies,
2275 path: PathBuf::from("package.json"),
2276 line: 7,
2277 used_in_workspaces: Vec::new(),
2278 };
2279 let json3 = serde_json::to_value(&dep3).unwrap();
2280 assert_eq!(json3["location"], "optionalDependencies");
2281 }
2282
2283 #[test]
2284 fn serialize_circular_dependency_skips_false_cross_package() {
2285 let cd = CircularDependency {
2286 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2287 length: 2,
2288 line: 1,
2289 col: 0,
2290 is_cross_package: false,
2291 };
2292 let json = serde_json::to_value(&cd).unwrap();
2293 assert!(json.get("is_cross_package").is_none());
2295 }
2296
2297 #[test]
2298 fn serialize_circular_dependency_includes_true_cross_package() {
2299 let cd = CircularDependency {
2300 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2301 length: 2,
2302 line: 1,
2303 col: 0,
2304 is_cross_package: true,
2305 };
2306 let json = serde_json::to_value(&cd).unwrap();
2307 assert_eq!(json["is_cross_package"], true);
2308 }
2309
2310 #[test]
2311 fn serialize_unused_export_fields() {
2312 let e = UnusedExport {
2313 path: PathBuf::from("src/mod.ts"),
2314 export_name: "helper".to_string(),
2315 is_type_only: true,
2316 line: 42,
2317 col: 7,
2318 span_start: 100,
2319 is_re_export: true,
2320 };
2321 let json = serde_json::to_value(&e).unwrap();
2322 assert_eq!(json["path"], "src/mod.ts");
2323 assert_eq!(json["export_name"], "helper");
2324 assert_eq!(json["is_type_only"], true);
2325 assert_eq!(json["line"], 42);
2326 assert_eq!(json["col"], 7);
2327 assert_eq!(json["span_start"], 100);
2328 assert_eq!(json["is_re_export"], true);
2329 }
2330
2331 #[test]
2332 fn serialize_boundary_violation_fields() {
2333 let v = BoundaryViolation {
2334 from_path: PathBuf::from("src/ui/button.tsx"),
2335 to_path: PathBuf::from("src/db/queries.ts"),
2336 from_zone: "ui".to_string(),
2337 to_zone: "db".to_string(),
2338 import_specifier: "../db/queries".to_string(),
2339 line: 3,
2340 col: 0,
2341 };
2342 let json = serde_json::to_value(&v).unwrap();
2343 assert_eq!(json["from_path"], "src/ui/button.tsx");
2344 assert_eq!(json["to_path"], "src/db/queries.ts");
2345 assert_eq!(json["from_zone"], "ui");
2346 assert_eq!(json["to_zone"], "db");
2347 assert_eq!(json["import_specifier"], "../db/queries");
2348 }
2349
2350 #[test]
2351 fn serialize_unlisted_dependency_with_import_sites() {
2352 let d = UnlistedDependency {
2353 package_name: "chalk".to_string(),
2354 imported_from: vec![
2355 ImportSite {
2356 path: PathBuf::from("a.ts"),
2357 line: 1,
2358 col: 0,
2359 },
2360 ImportSite {
2361 path: PathBuf::from("b.ts"),
2362 line: 5,
2363 col: 3,
2364 },
2365 ],
2366 };
2367 let json = serde_json::to_value(&d).unwrap();
2368 assert_eq!(json["package_name"], "chalk");
2369 let sites = json["imported_from"].as_array().unwrap();
2370 assert_eq!(sites.len(), 2);
2371 assert_eq!(sites[0]["path"], "a.ts");
2372 assert_eq!(sites[1]["line"], 5);
2373 }
2374
2375 #[test]
2376 fn serialize_duplicate_export_with_locations() {
2377 let d = DuplicateExport {
2378 export_name: "Button".to_string(),
2379 locations: vec![
2380 DuplicateLocation {
2381 path: PathBuf::from("src/a.ts"),
2382 line: 10,
2383 col: 0,
2384 },
2385 DuplicateLocation {
2386 path: PathBuf::from("src/b.ts"),
2387 line: 20,
2388 col: 5,
2389 },
2390 ],
2391 };
2392 let json = serde_json::to_value(&d).unwrap();
2393 assert_eq!(json["export_name"], "Button");
2394 let locs = json["locations"].as_array().unwrap();
2395 assert_eq!(locs.len(), 2);
2396 assert_eq!(locs[0]["line"], 10);
2397 assert_eq!(locs[1]["col"], 5);
2398 }
2399
2400 #[test]
2401 fn serialize_type_only_dependency() {
2402 let d = TypeOnlyDependency {
2403 package_name: "@types/react".to_string(),
2404 path: PathBuf::from("package.json"),
2405 line: 12,
2406 };
2407 let json = serde_json::to_value(&d).unwrap();
2408 assert_eq!(json["package_name"], "@types/react");
2409 assert_eq!(json["line"], 12);
2410 }
2411
2412 #[test]
2413 fn serialize_test_only_dependency() {
2414 let d = TestOnlyDependency {
2415 package_name: "vitest".to_string(),
2416 path: PathBuf::from("package.json"),
2417 line: 8,
2418 };
2419 let json = serde_json::to_value(&d).unwrap();
2420 assert_eq!(json["package_name"], "vitest");
2421 assert_eq!(json["line"], 8);
2422 }
2423
2424 #[test]
2425 fn serialize_unused_member() {
2426 let m = UnusedMember {
2427 path: PathBuf::from("enums.ts"),
2428 parent_name: "Status".to_string(),
2429 member_name: "Pending".to_string(),
2430 kind: MemberKind::EnumMember,
2431 line: 3,
2432 col: 4,
2433 };
2434 let json = serde_json::to_value(&m).unwrap();
2435 assert_eq!(json["parent_name"], "Status");
2436 assert_eq!(json["member_name"], "Pending");
2437 assert_eq!(json["line"], 3);
2438 }
2439
2440 #[test]
2441 fn serialize_unresolved_import() {
2442 let i = UnresolvedImport {
2443 path: PathBuf::from("app.ts"),
2444 specifier: "./missing-module".to_string(),
2445 line: 7,
2446 col: 0,
2447 specifier_col: 21,
2448 };
2449 let json = serde_json::to_value(&i).unwrap();
2450 assert_eq!(json["specifier"], "./missing-module");
2451 assert_eq!(json["specifier_col"], 21);
2452 }
2453
2454 #[test]
2457 fn deserialize_circular_dependency_with_defaults() {
2458 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
2460 let cd: CircularDependency = serde_json::from_str(json).unwrap();
2461 assert_eq!(cd.files.len(), 2);
2462 assert_eq!(cd.length, 2);
2463 assert_eq!(cd.line, 0);
2464 assert_eq!(cd.col, 0);
2465 assert!(!cd.is_cross_package);
2466 }
2467
2468 #[test]
2469 fn deserialize_circular_dependency_with_all_fields() {
2470 let json =
2471 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
2472 let cd: CircularDependency = serde_json::from_str(json).unwrap();
2473 assert_eq!(cd.line, 5);
2474 assert_eq!(cd.col, 10);
2475 assert!(cd.is_cross_package);
2476 }
2477
2478 #[test]
2481 fn clone_results_are_independent() {
2482 let mut r = AnalysisResults::default();
2483 r.unused_files
2484 .push(UnusedFileFinding::with_actions(UnusedFile {
2485 path: PathBuf::from("a.ts"),
2486 }));
2487 let mut cloned = r.clone();
2488 cloned
2489 .unused_files
2490 .push(UnusedFileFinding::with_actions(UnusedFile {
2491 path: PathBuf::from("b.ts"),
2492 }));
2493 assert_eq!(r.total_issues(), 1);
2494 assert_eq!(cloned.total_issues(), 2);
2495 }
2496
2497 #[test]
2500 fn export_usages_not_counted_in_total_issues() {
2501 let mut r = AnalysisResults::default();
2502 r.export_usages.push(ExportUsage {
2503 path: PathBuf::from("mod.ts"),
2504 export_name: "foo".to_string(),
2505 line: 1,
2506 col: 0,
2507 reference_count: 3,
2508 reference_locations: vec![],
2509 });
2510 assert_eq!(r.total_issues(), 0);
2512 assert!(!r.has_issues());
2513 }
2514
2515 #[test]
2518 fn entry_point_summary_not_counted_in_total_issues() {
2519 let r = AnalysisResults {
2520 entry_point_summary: Some(EntryPointSummary {
2521 total: 10,
2522 by_source: vec![("config".to_string(), 10)],
2523 }),
2524 ..AnalysisResults::default()
2525 };
2526 assert_eq!(r.total_issues(), 0);
2527 assert!(!r.has_issues());
2528 }
2529}