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)]
181 pub feature_flags: Vec<FeatureFlag>,
182 #[serde(skip)]
186 pub export_usages: Vec<ExportUsage>,
187 #[serde(skip)]
191 pub entry_point_summary: Option<EntryPointSummary>,
192}
193
194impl AnalysisResults {
195 #[must_use]
226 pub const fn total_issues(&self) -> usize {
227 self.unused_files.len()
228 + self.unused_exports.len()
229 + self.unused_types.len()
230 + self.private_type_leaks.len()
231 + self.unused_dependencies.len()
232 + self.unused_dev_dependencies.len()
233 + self.unused_optional_dependencies.len()
234 + self.unused_enum_members.len()
235 + self.unused_class_members.len()
236 + self.unresolved_imports.len()
237 + self.unlisted_dependencies.len()
238 + self.duplicate_exports.len()
239 + self.type_only_dependencies.len()
240 + self.test_only_dependencies.len()
241 + self.circular_dependencies.len()
242 + self.re_export_cycles.len()
243 + self.boundary_violations.len()
244 + self.stale_suppressions.len()
245 + self.unused_catalog_entries.len()
246 + self.empty_catalog_groups.len()
247 + self.unresolved_catalog_references.len()
248 + self.unused_dependency_overrides.len()
249 + self.misconfigured_dependency_overrides.len()
250 }
251
252 #[must_use]
254 pub const fn has_issues(&self) -> bool {
255 self.total_issues() > 0
256 }
257
258 #[expect(
265 clippy::too_many_lines,
266 reason = "one short sort_by per result array; splitting would add indirection without clarity"
267 )]
268 pub fn sort(&mut self) {
269 self.unused_files
270 .sort_by(|a, b| a.file.path.cmp(&b.file.path));
271
272 self.unused_exports.sort_by(|a, b| {
273 a.export
274 .path
275 .cmp(&b.export.path)
276 .then(a.export.line.cmp(&b.export.line))
277 .then(a.export.export_name.cmp(&b.export.export_name))
278 });
279
280 self.unused_types.sort_by(|a, b| {
281 a.export
282 .path
283 .cmp(&b.export.path)
284 .then(a.export.line.cmp(&b.export.line))
285 .then(a.export.export_name.cmp(&b.export.export_name))
286 });
287
288 self.private_type_leaks.sort_by(|a, b| {
289 a.leak
290 .path
291 .cmp(&b.leak.path)
292 .then(a.leak.line.cmp(&b.leak.line))
293 .then(a.leak.export_name.cmp(&b.leak.export_name))
294 .then(a.leak.type_name.cmp(&b.leak.type_name))
295 });
296
297 self.unused_dependencies.sort_by(|a, b| {
298 a.dep
299 .path
300 .cmp(&b.dep.path)
301 .then(a.dep.line.cmp(&b.dep.line))
302 .then(a.dep.package_name.cmp(&b.dep.package_name))
303 });
304
305 self.unused_dev_dependencies.sort_by(|a, b| {
306 a.dep
307 .path
308 .cmp(&b.dep.path)
309 .then(a.dep.line.cmp(&b.dep.line))
310 .then(a.dep.package_name.cmp(&b.dep.package_name))
311 });
312
313 self.unused_optional_dependencies.sort_by(|a, b| {
314 a.dep
315 .path
316 .cmp(&b.dep.path)
317 .then(a.dep.line.cmp(&b.dep.line))
318 .then(a.dep.package_name.cmp(&b.dep.package_name))
319 });
320
321 self.unused_enum_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.unused_class_members.sort_by(|a, b| {
331 a.member
332 .path
333 .cmp(&b.member.path)
334 .then(a.member.line.cmp(&b.member.line))
335 .then(a.member.parent_name.cmp(&b.member.parent_name))
336 .then(a.member.member_name.cmp(&b.member.member_name))
337 });
338
339 self.unresolved_imports.sort_by(|a, b| {
340 a.import
341 .path
342 .cmp(&b.import.path)
343 .then(a.import.line.cmp(&b.import.line))
344 .then(a.import.col.cmp(&b.import.col))
345 .then(a.import.specifier.cmp(&b.import.specifier))
346 });
347
348 self.unlisted_dependencies
349 .sort_by(|a, b| a.dep.package_name.cmp(&b.dep.package_name));
350 for dep in &mut self.unlisted_dependencies {
351 dep.dep
352 .imported_from
353 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
354 }
355
356 self.duplicate_exports
357 .sort_by(|a, b| a.export.export_name.cmp(&b.export.export_name));
358 for dup in &mut self.duplicate_exports {
359 dup.export
360 .locations
361 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
362 }
363
364 self.type_only_dependencies.sort_by(|a, b| {
365 a.dep
366 .path
367 .cmp(&b.dep.path)
368 .then(a.dep.line.cmp(&b.dep.line))
369 .then(a.dep.package_name.cmp(&b.dep.package_name))
370 });
371
372 self.test_only_dependencies.sort_by(|a, b| {
373 a.dep
374 .path
375 .cmp(&b.dep.path)
376 .then(a.dep.line.cmp(&b.dep.line))
377 .then(a.dep.package_name.cmp(&b.dep.package_name))
378 });
379
380 self.circular_dependencies.sort_by(|a, b| {
381 a.cycle
382 .files
383 .cmp(&b.cycle.files)
384 .then(a.cycle.length.cmp(&b.cycle.length))
385 });
386
387 self.re_export_cycles
388 .sort_by(|a, b| a.cycle.files.cmp(&b.cycle.files));
389
390 self.boundary_violations.sort_by(|a, b| {
391 a.violation
392 .from_path
393 .cmp(&b.violation.from_path)
394 .then(a.violation.line.cmp(&b.violation.line))
395 .then(a.violation.col.cmp(&b.violation.col))
396 .then(a.violation.to_path.cmp(&b.violation.to_path))
397 });
398
399 self.stale_suppressions.sort_by(|a, b| {
400 a.path
401 .cmp(&b.path)
402 .then(a.line.cmp(&b.line))
403 .then(a.col.cmp(&b.col))
404 });
405
406 self.unused_catalog_entries.sort_by(|a, b| {
407 a.entry
408 .path
409 .cmp(&b.entry.path)
410 .then_with(|| {
411 catalog_sort_key(&a.entry.catalog_name)
412 .cmp(&catalog_sort_key(&b.entry.catalog_name))
413 })
414 .then(a.entry.catalog_name.cmp(&b.entry.catalog_name))
415 .then(a.entry.entry_name.cmp(&b.entry.entry_name))
416 });
417 for finding in &mut self.unused_catalog_entries {
418 finding.entry.hardcoded_consumers.sort();
419 finding.entry.hardcoded_consumers.dedup();
420 }
421
422 self.empty_catalog_groups.sort_by(|a, b| {
423 a.group
424 .path
425 .cmp(&b.group.path)
426 .then_with(|| {
427 catalog_sort_key(&a.group.catalog_name)
428 .cmp(&catalog_sort_key(&b.group.catalog_name))
429 })
430 .then(a.group.catalog_name.cmp(&b.group.catalog_name))
431 .then(a.group.line.cmp(&b.group.line))
432 });
433
434 self.unresolved_catalog_references.sort_by(|a, b| {
435 a.reference
436 .path
437 .cmp(&b.reference.path)
438 .then(a.reference.line.cmp(&b.reference.line))
439 .then_with(|| {
440 catalog_sort_key(&a.reference.catalog_name)
441 .cmp(&catalog_sort_key(&b.reference.catalog_name))
442 })
443 .then(a.reference.catalog_name.cmp(&b.reference.catalog_name))
444 .then(a.reference.entry_name.cmp(&b.reference.entry_name))
445 });
446 for finding in &mut self.unresolved_catalog_references {
447 finding.reference.available_in_catalogs.sort();
448 finding.reference.available_in_catalogs.dedup();
449 }
450
451 self.unused_dependency_overrides.sort_by(|a, b| {
452 a.entry
453 .path
454 .cmp(&b.entry.path)
455 .then(a.entry.line.cmp(&b.entry.line))
456 .then(a.entry.raw_key.cmp(&b.entry.raw_key))
457 });
458
459 self.misconfigured_dependency_overrides.sort_by(|a, b| {
460 a.entry
461 .path
462 .cmp(&b.entry.path)
463 .then(a.entry.line.cmp(&b.entry.line))
464 .then(a.entry.raw_key.cmp(&b.entry.raw_key))
465 });
466
467 self.feature_flags.sort_by(|a, b| {
468 a.path
469 .cmp(&b.path)
470 .then(a.line.cmp(&b.line))
471 .then(a.flag_name.cmp(&b.flag_name))
472 });
473
474 for usage in &mut self.export_usages {
475 usage.reference_locations.sort_by(|a, b| {
476 a.path
477 .cmp(&b.path)
478 .then(a.line.cmp(&b.line))
479 .then(a.col.cmp(&b.col))
480 });
481 }
482 self.export_usages.sort_by(|a, b| {
483 a.path
484 .cmp(&b.path)
485 .then(a.line.cmp(&b.line))
486 .then(a.export_name.cmp(&b.export_name))
487 });
488 }
489}
490
491fn catalog_sort_key(name: &str) -> (u8, &str) {
493 if name == "default" {
494 (0, name)
495 } else {
496 (1, name)
497 }
498}
499
500#[derive(Debug, Clone, Serialize)]
502#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
503pub struct UnusedFile {
504 #[serde(serialize_with = "serde_path::serialize")]
506 pub path: PathBuf,
507}
508
509#[derive(Debug, Clone, Serialize)]
511#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
512pub struct UnusedExport {
513 #[serde(serialize_with = "serde_path::serialize")]
515 pub path: PathBuf,
516 pub export_name: String,
518 pub is_type_only: bool,
520 pub line: u32,
522 pub col: u32,
524 pub span_start: u32,
526 pub is_re_export: bool,
528}
529
530#[derive(Debug, Clone, Serialize)]
532#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
533pub struct PrivateTypeLeak {
534 #[serde(serialize_with = "serde_path::serialize")]
536 pub path: PathBuf,
537 pub export_name: String,
539 pub type_name: String,
541 pub line: u32,
543 pub col: u32,
545 pub span_start: u32,
547}
548
549#[derive(Debug, Clone, Serialize)]
551#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
552pub struct UnusedDependency {
553 pub package_name: String,
555 pub location: DependencyLocation,
557 #[serde(serialize_with = "serde_path::serialize")]
560 pub path: PathBuf,
561 pub line: u32,
563 #[serde(
565 serialize_with = "serde_path::serialize_vec",
566 skip_serializing_if = "Vec::is_empty"
567 )]
568 #[cfg_attr(feature = "schema", schemars(default))]
569 pub used_in_workspaces: Vec<PathBuf>,
570}
571
572#[derive(Debug, Clone, Serialize)]
589#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
590#[serde(rename_all = "camelCase")]
591pub enum DependencyLocation {
592 Dependencies,
594 DevDependencies,
596 OptionalDependencies,
598}
599
600#[derive(Debug, Clone, Serialize)]
602#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
603pub struct UnusedMember {
604 #[serde(serialize_with = "serde_path::serialize")]
606 pub path: PathBuf,
607 pub parent_name: String,
609 pub member_name: String,
611 pub kind: MemberKind,
613 pub line: u32,
615 pub col: u32,
617}
618
619#[derive(Debug, Clone, Serialize)]
621#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
622pub struct UnresolvedImport {
623 #[serde(serialize_with = "serde_path::serialize")]
625 pub path: PathBuf,
626 pub specifier: String,
628 pub line: u32,
630 pub col: u32,
632 pub specifier_col: u32,
635}
636
637#[derive(Debug, Clone, Serialize)]
639#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
640pub struct UnlistedDependency {
641 pub package_name: String,
644 pub imported_from: Vec<ImportSite>,
646}
647
648#[derive(Debug, Clone, Serialize)]
650#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
651pub struct ImportSite {
652 #[serde(serialize_with = "serde_path::serialize")]
654 pub path: PathBuf,
655 pub line: u32,
657 pub col: u32,
659}
660
661#[derive(Debug, Clone, Serialize)]
663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
664pub struct DuplicateExport {
665 pub export_name: String,
667 pub locations: Vec<DuplicateLocation>,
669}
670
671#[derive(Debug, Clone, Serialize)]
673#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
674pub struct DuplicateLocation {
675 #[serde(serialize_with = "serde_path::serialize")]
677 pub path: PathBuf,
678 pub line: u32,
680 pub col: u32,
682}
683
684#[derive(Debug, Clone, Serialize)]
688#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
689pub struct TypeOnlyDependency {
690 pub package_name: String,
692 #[serde(serialize_with = "serde_path::serialize")]
694 pub path: PathBuf,
695 pub line: u32,
697}
698
699#[derive(Debug, Clone, Serialize)]
705#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
706pub struct UnusedCatalogEntry {
707 pub entry_name: String,
709 pub catalog_name: String,
712 #[serde(serialize_with = "serde_path::serialize")]
714 pub path: PathBuf,
715 pub line: u32,
717 #[serde(
722 default,
723 serialize_with = "serde_path::serialize_vec",
724 skip_serializing_if = "Vec::is_empty"
725 )]
726 pub hardcoded_consumers: Vec<PathBuf>,
727}
728
729#[derive(Debug, Clone, Serialize)]
731#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
732pub struct EmptyCatalogGroup {
733 pub catalog_name: String,
735 #[serde(serialize_with = "serde_path::serialize")]
737 pub path: PathBuf,
738 pub line: u32,
740}
741
742#[derive(Debug, Clone, Serialize)]
753#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
754pub struct UnresolvedCatalogReference {
755 pub entry_name: String,
757 pub catalog_name: String,
760 #[serde(serialize_with = "serde_path::serialize")]
767 pub path: PathBuf,
768 pub line: u32,
770 #[serde(default, skip_serializing_if = "Vec::is_empty")]
775 pub available_in_catalogs: Vec<String>,
776}
777
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
782#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
783pub enum DependencyOverrideSource {
784 #[serde(rename = "pnpm-workspace.yaml")]
786 PnpmWorkspaceYaml,
787 #[serde(rename = "package.json")]
789 PnpmPackageJson,
790}
791
792impl DependencyOverrideSource {
793 #[must_use]
796 pub const fn as_label(&self) -> &'static str {
797 match self {
798 Self::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
799 Self::PnpmPackageJson => "package.json",
800 }
801 }
802}
803
804impl std::fmt::Display for DependencyOverrideSource {
805 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
806 f.write_str(self.as_label())
807 }
808}
809
810#[derive(Debug, Clone, Serialize)]
816#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
817pub struct UnusedDependencyOverride {
818 pub raw_key: String,
822 pub target_package: String,
825 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub parent_package: Option<String>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
831 pub version_constraint: Option<String>,
832 pub version_range: String,
834 pub source: DependencyOverrideSource,
837 #[serde(serialize_with = "serde_path::serialize")]
844 pub path: PathBuf,
845 pub line: u32,
847 #[serde(default, skip_serializing_if = "Option::is_none")]
852 pub hint: Option<String>,
853}
854
855#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
859#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
860#[serde(rename_all = "kebab-case")]
861pub enum DependencyOverrideMisconfigReason {
862 UnparsableKey,
865 EmptyValue,
867}
868
869impl DependencyOverrideMisconfigReason {
870 #[must_use]
872 pub const fn describe(self) -> &'static str {
873 match self {
874 Self::UnparsableKey => "override key cannot be parsed",
875 Self::EmptyValue => "override value is missing or empty",
876 }
877 }
878}
879
880#[derive(Debug, Clone, Serialize)]
884#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
885pub struct MisconfiguredDependencyOverride {
886 pub raw_key: String,
888 #[serde(default, skip_serializing_if = "Option::is_none")]
896 pub target_package: Option<String>,
897 pub raw_value: String,
900 pub reason: DependencyOverrideMisconfigReason,
904 pub source: DependencyOverrideSource,
906 #[serde(serialize_with = "serde_path::serialize")]
910 pub path: PathBuf,
911 pub line: u32,
913}
914
915#[derive(Debug, Clone, Serialize)]
918#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
919pub struct TestOnlyDependency {
920 pub package_name: String,
923 #[serde(serialize_with = "serde_path::serialize")]
925 pub path: PathBuf,
926 pub line: u32,
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize)]
941#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
942#[cfg_attr(feature = "schema", schemars(extend("required" = ["files", "length", "line", "col"])))]
943pub struct CircularDependency {
944 #[serde(serialize_with = "serde_path::serialize_vec")]
946 pub files: Vec<PathBuf>,
947 pub length: usize,
949 #[serde(default)]
951 pub line: u32,
952 #[serde(default)]
954 pub col: u32,
955 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
957 pub is_cross_package: bool,
958}
959
960#[derive(Debug, Clone, Serialize, Deserialize)]
970#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
971pub struct ReExportCycle {
972 #[serde(serialize_with = "serde_path::serialize_vec")]
975 pub files: Vec<PathBuf>,
976 pub kind: ReExportCycleKind,
978}
979
980#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
982#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
983#[serde(rename_all = "kebab-case")]
984pub enum ReExportCycleKind {
985 MultiNode,
988 SelfLoop,
990}
991
992#[derive(Debug, Clone, Serialize)]
994#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
995pub struct BoundaryViolation {
996 #[serde(serialize_with = "serde_path::serialize")]
998 pub from_path: PathBuf,
999 #[serde(serialize_with = "serde_path::serialize")]
1001 pub to_path: PathBuf,
1002 pub from_zone: String,
1004 pub to_zone: String,
1006 pub import_specifier: String,
1008 pub line: u32,
1010 pub col: u32,
1012}
1013
1014#[derive(Debug, Clone, Serialize)]
1016#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1017#[serde(rename_all = "snake_case", tag = "type")]
1018pub enum SuppressionOrigin {
1019 Comment {
1021 #[serde(skip_serializing_if = "Option::is_none")]
1023 issue_kind: Option<String>,
1024 is_file_level: bool,
1026 #[serde(default = "default_true", skip_serializing_if = "is_true")]
1033 kind_known: bool,
1034 },
1035 JsdocTag {
1037 export_name: String,
1039 },
1040}
1041
1042#[expect(
1043 clippy::trivially_copy_pass_by_ref,
1044 reason = "serde skip_serializing_if takes a reference by contract"
1045)]
1046const fn is_true(b: &bool) -> bool {
1047 *b
1048}
1049
1050#[cfg_attr(
1065 not(feature = "schema"),
1066 expect(
1067 dead_code,
1068 reason = "referenced via #[serde(default = ...)]; only consumed by schemars under the `schema` feature, dead on default builds today"
1069 )
1070)]
1071const fn default_true() -> bool {
1072 true
1073}
1074
1075#[derive(Debug, Clone, Serialize)]
1077#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1078pub struct StaleSuppression {
1079 #[serde(serialize_with = "serde_path::serialize")]
1081 pub path: PathBuf,
1082 pub line: u32,
1084 pub col: u32,
1086 pub origin: SuppressionOrigin,
1088}
1089
1090impl StaleSuppression {
1091 #[must_use]
1093 pub fn description(&self) -> String {
1094 match &self.origin {
1095 SuppressionOrigin::Comment {
1096 issue_kind,
1097 is_file_level,
1098 ..
1099 } => {
1100 let directive = if *is_file_level {
1101 "fallow-ignore-file"
1102 } else {
1103 "fallow-ignore-next-line"
1104 };
1105 match issue_kind {
1106 Some(kind) => format!("// {directive} {kind}"),
1107 None => format!("// {directive}"),
1108 }
1109 }
1110 SuppressionOrigin::JsdocTag { export_name } => {
1111 format!("@expected-unused on {export_name}")
1112 }
1113 }
1114 }
1115
1116 #[must_use]
1123 pub fn explanation(&self) -> String {
1124 match &self.origin {
1125 SuppressionOrigin::Comment {
1126 issue_kind,
1127 is_file_level,
1128 kind_known,
1129 } => {
1130 let scope = if *is_file_level {
1131 "in this file"
1132 } else {
1133 "on the next line"
1134 };
1135 match issue_kind {
1136 Some(kind) if !*kind_known => match closest_known_kind_name(kind) {
1137 Some(suggestion) => format!(
1138 "'{kind}' is not a recognized fallow issue kind. Did you mean '{suggestion}'? Other tokens on this line still apply."
1139 ),
1140 None => format!(
1141 "'{kind}' is not a recognized fallow issue kind. Other tokens on this line still apply."
1142 ),
1143 },
1144 Some(kind) => format!("no {kind} issue found {scope}"),
1145 None => format!("no issues found {scope}"),
1146 }
1147 }
1148 SuppressionOrigin::JsdocTag { export_name } => {
1149 format!("{export_name} is now used")
1150 }
1151 }
1152 }
1153
1154 #[must_use]
1159 pub fn suppressed_kind(&self) -> Option<IssueKind> {
1160 match &self.origin {
1161 SuppressionOrigin::Comment {
1162 issue_kind,
1163 kind_known: true,
1164 ..
1165 } => issue_kind.as_deref().and_then(IssueKind::parse),
1166 SuppressionOrigin::Comment { .. } | SuppressionOrigin::JsdocTag { .. } => None,
1167 }
1168 }
1169
1170 #[must_use]
1177 pub fn display_message(&self) -> String {
1178 match &self.origin {
1179 SuppressionOrigin::Comment {
1180 kind_known: false, ..
1181 } => format!("{} ({})", self.description(), self.explanation()),
1182 SuppressionOrigin::Comment { .. } | SuppressionOrigin::JsdocTag { .. } => {
1183 self.description()
1184 }
1185 }
1186 }
1187}
1188
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1192#[serde(rename_all = "snake_case")]
1193pub enum FlagKind {
1194 EnvironmentVariable,
1196 SdkCall,
1198 ConfigObject,
1200}
1201
1202#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
1204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1205#[serde(rename_all = "snake_case")]
1206pub enum FlagConfidence {
1207 Low,
1209 Medium,
1211 High,
1213}
1214
1215#[derive(Debug, Clone, Serialize)]
1217#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1218pub struct FeatureFlag {
1219 #[serde(serialize_with = "serde_path::serialize")]
1221 pub path: PathBuf,
1222 pub flag_name: String,
1224 pub kind: FlagKind,
1226 pub confidence: FlagConfidence,
1228 pub line: u32,
1230 pub col: u32,
1232 #[serde(skip)]
1234 pub guard_span_start: Option<u32>,
1235 #[serde(skip)]
1237 pub guard_span_end: Option<u32>,
1238 #[serde(skip_serializing_if = "Option::is_none")]
1240 pub sdk_name: Option<String>,
1241 #[serde(skip)]
1244 pub guard_line_start: Option<u32>,
1245 #[serde(skip)]
1247 pub guard_line_end: Option<u32>,
1248 #[serde(skip_serializing_if = "Vec::is_empty")]
1251 pub guarded_dead_exports: Vec<String>,
1252}
1253
1254const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
1256
1257#[derive(Debug, Clone, Serialize)]
1260#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1261pub struct ExportUsage {
1262 #[serde(serialize_with = "serde_path::serialize")]
1264 pub path: PathBuf,
1265 pub export_name: String,
1267 pub line: u32,
1269 pub col: u32,
1271 pub reference_count: usize,
1273 pub reference_locations: Vec<ReferenceLocation>,
1276}
1277
1278#[derive(Debug, Clone, Serialize)]
1280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1281pub struct ReferenceLocation {
1282 #[serde(serialize_with = "serde_path::serialize")]
1284 pub path: PathBuf,
1285 pub line: u32,
1287 pub col: u32,
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294 use crate::output_dead_code::{
1295 BoundaryViolationFinding, CircularDependencyFinding, UnresolvedImportFinding,
1296 UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding,
1297 UnusedTypeFinding,
1298 };
1299
1300 #[test]
1301 fn empty_results_no_issues() {
1302 let results = AnalysisResults::default();
1303 assert_eq!(results.total_issues(), 0);
1304 assert!(!results.has_issues());
1305 }
1306
1307 #[test]
1308 fn results_with_unused_file() {
1309 let mut results = AnalysisResults::default();
1310 results
1311 .unused_files
1312 .push(UnusedFileFinding::with_actions(UnusedFile {
1313 path: PathBuf::from("test.ts"),
1314 }));
1315 assert_eq!(results.total_issues(), 1);
1316 assert!(results.has_issues());
1317 }
1318
1319 #[test]
1320 fn results_with_unused_export() {
1321 let mut results = AnalysisResults::default();
1322 results
1323 .unused_exports
1324 .push(UnusedExportFinding::with_actions(UnusedExport {
1325 path: PathBuf::from("test.ts"),
1326 export_name: "foo".to_string(),
1327 is_type_only: false,
1328 line: 1,
1329 col: 0,
1330 span_start: 0,
1331 is_re_export: false,
1332 }));
1333 assert_eq!(results.total_issues(), 1);
1334 assert!(results.has_issues());
1335 }
1336
1337 fn test_unused_export(path: &str, export_name: &str, is_type_only: bool) -> UnusedExport {
1338 UnusedExport {
1339 path: PathBuf::from(path),
1340 export_name: export_name.to_string(),
1341 is_type_only,
1342 line: 1,
1343 col: 0,
1344 span_start: 0,
1345 is_re_export: false,
1346 }
1347 }
1348
1349 fn test_unused_dependency(
1350 package_name: &str,
1351 location: DependencyLocation,
1352 ) -> UnusedDependency {
1353 UnusedDependency {
1354 package_name: package_name.to_string(),
1355 location,
1356 path: PathBuf::from("package.json"),
1357 line: 5,
1358 used_in_workspaces: Vec::new(),
1359 }
1360 }
1361
1362 fn test_unused_member(member_name: &str, kind: MemberKind) -> UnusedMember {
1363 UnusedMember {
1364 path: PathBuf::from("members.ts"),
1365 parent_name: "Parent".to_string(),
1366 member_name: member_name.to_string(),
1367 kind,
1368 line: 1,
1369 col: 0,
1370 }
1371 }
1372
1373 #[test]
1374 fn results_total_counts_all_types() {
1375 let results = AnalysisResults {
1376 unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1377 path: PathBuf::from("a.ts"),
1378 })],
1379 unused_exports: vec![UnusedExportFinding::with_actions(test_unused_export(
1380 "b.ts", "x", false,
1381 ))],
1382 unused_types: vec![UnusedTypeFinding::with_actions(test_unused_export(
1383 "c.ts", "T", true,
1384 ))],
1385 unused_dependencies: vec![UnusedDependencyFinding::with_actions(
1386 test_unused_dependency("dep", DependencyLocation::Dependencies),
1387 )],
1388 unused_dev_dependencies: vec![UnusedDevDependencyFinding::with_actions(
1389 test_unused_dependency("dev", DependencyLocation::DevDependencies),
1390 )],
1391 unused_enum_members: vec![UnusedEnumMemberFinding::with_actions(test_unused_member(
1392 "A",
1393 MemberKind::EnumMember,
1394 ))],
1395 unused_class_members: vec![UnusedClassMemberFinding::with_actions(test_unused_member(
1396 "m",
1397 MemberKind::ClassMethod,
1398 ))],
1399 unresolved_imports: vec![UnresolvedImportFinding::with_actions(UnresolvedImport {
1400 path: PathBuf::from("f.ts"),
1401 specifier: "./missing".to_string(),
1402 line: 1,
1403 col: 0,
1404 specifier_col: 0,
1405 })],
1406 unlisted_dependencies: vec![UnlistedDependencyFinding::with_actions(
1407 UnlistedDependency {
1408 package_name: "unlisted".to_string(),
1409 imported_from: vec![ImportSite {
1410 path: PathBuf::from("g.ts"),
1411 line: 1,
1412 col: 0,
1413 }],
1414 },
1415 )],
1416 duplicate_exports: vec![DuplicateExportFinding::with_actions(DuplicateExport {
1417 export_name: "dup".to_string(),
1418 locations: vec![
1419 DuplicateLocation {
1420 path: PathBuf::from("h.ts"),
1421 line: 15,
1422 col: 0,
1423 },
1424 DuplicateLocation {
1425 path: PathBuf::from("i.ts"),
1426 line: 30,
1427 col: 0,
1428 },
1429 ],
1430 })],
1431 unused_optional_dependencies: vec![UnusedOptionalDependencyFinding::with_actions(
1432 test_unused_dependency("optional", DependencyLocation::OptionalDependencies),
1433 )],
1434 type_only_dependencies: vec![TypeOnlyDependencyFinding::with_actions(
1435 TypeOnlyDependency {
1436 package_name: "type-only".to_string(),
1437 path: PathBuf::from("package.json"),
1438 line: 8,
1439 },
1440 )],
1441 test_only_dependencies: vec![TestOnlyDependencyFinding::with_actions(
1442 TestOnlyDependency {
1443 package_name: "test-only".to_string(),
1444 path: PathBuf::from("package.json"),
1445 line: 9,
1446 },
1447 )],
1448 circular_dependencies: vec![CircularDependencyFinding::with_actions(
1449 CircularDependency {
1450 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1451 length: 2,
1452 line: 3,
1453 col: 0,
1454 is_cross_package: false,
1455 },
1456 )],
1457 boundary_violations: vec![BoundaryViolationFinding::with_actions(BoundaryViolation {
1458 from_path: PathBuf::from("src/ui/Button.tsx"),
1459 to_path: PathBuf::from("src/db/queries.ts"),
1460 from_zone: "ui".to_string(),
1461 to_zone: "database".to_string(),
1462 import_specifier: "../db/queries".to_string(),
1463 line: 3,
1464 col: 0,
1465 })],
1466 ..Default::default()
1467 };
1468
1469 assert_eq!(results.total_issues(), 15);
1471 assert!(results.has_issues());
1472 }
1473
1474 #[test]
1477 fn total_issues_and_has_issues_are_consistent() {
1478 let results = AnalysisResults::default();
1479 assert_eq!(results.total_issues(), 0);
1480 assert!(!results.has_issues());
1481 assert_eq!(results.total_issues() > 0, results.has_issues());
1482 }
1483
1484 #[test]
1487 fn total_issues_sums_all_categories_independently() {
1488 let mut results = AnalysisResults::default();
1489 results
1490 .unused_files
1491 .push(UnusedFileFinding::with_actions(UnusedFile {
1492 path: PathBuf::from("a.ts"),
1493 }));
1494 assert_eq!(results.total_issues(), 1);
1495
1496 results
1497 .unused_files
1498 .push(UnusedFileFinding::with_actions(UnusedFile {
1499 path: PathBuf::from("b.ts"),
1500 }));
1501 assert_eq!(results.total_issues(), 2);
1502
1503 results
1504 .unresolved_imports
1505 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1506 path: PathBuf::from("c.ts"),
1507 specifier: "./missing".to_string(),
1508 line: 1,
1509 col: 0,
1510 specifier_col: 0,
1511 }));
1512 assert_eq!(results.total_issues(), 3);
1513 }
1514
1515 #[test]
1518 fn default_results_all_fields_empty() {
1519 let r = AnalysisResults::default();
1520 assert!(r.unused_files.is_empty());
1521 assert!(r.unused_exports.is_empty());
1522 assert!(r.unused_types.is_empty());
1523 assert!(r.unused_dependencies.is_empty());
1524 assert!(r.unused_dev_dependencies.is_empty());
1525 assert!(r.unused_optional_dependencies.is_empty());
1526 assert!(r.unused_enum_members.is_empty());
1527 assert!(r.unused_class_members.is_empty());
1528 assert!(r.unresolved_imports.is_empty());
1529 assert!(r.unlisted_dependencies.is_empty());
1530 assert!(r.duplicate_exports.is_empty());
1531 assert!(r.type_only_dependencies.is_empty());
1532 assert!(r.test_only_dependencies.is_empty());
1533 assert!(r.circular_dependencies.is_empty());
1534 assert!(r.boundary_violations.is_empty());
1535 assert!(r.unused_catalog_entries.is_empty());
1536 assert!(r.unresolved_catalog_references.is_empty());
1537 assert!(r.export_usages.is_empty());
1538 }
1539
1540 #[test]
1543 fn entry_point_summary_default() {
1544 let summary = EntryPointSummary::default();
1545 assert_eq!(summary.total, 0);
1546 assert!(summary.by_source.is_empty());
1547 }
1548
1549 #[test]
1550 fn entry_point_summary_not_in_default_results() {
1551 let r = AnalysisResults::default();
1552 assert!(r.entry_point_summary.is_none());
1553 }
1554
1555 #[test]
1556 fn entry_point_summary_some_preserves_data() {
1557 let r = AnalysisResults {
1558 entry_point_summary: Some(EntryPointSummary {
1559 total: 5,
1560 by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
1561 }),
1562 ..AnalysisResults::default()
1563 };
1564 let summary = r.entry_point_summary.as_ref().unwrap();
1565 assert_eq!(summary.total, 5);
1566 assert_eq!(summary.by_source.len(), 2);
1567 assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
1568 }
1569
1570 #[test]
1573 fn sort_unused_files_by_path() {
1574 let mut r = AnalysisResults::default();
1575 r.unused_files
1576 .push(UnusedFileFinding::with_actions(UnusedFile {
1577 path: PathBuf::from("z.ts"),
1578 }));
1579 r.unused_files
1580 .push(UnusedFileFinding::with_actions(UnusedFile {
1581 path: PathBuf::from("a.ts"),
1582 }));
1583 r.unused_files
1584 .push(UnusedFileFinding::with_actions(UnusedFile {
1585 path: PathBuf::from("m.ts"),
1586 }));
1587 r.sort();
1588 let paths: Vec<_> = r
1589 .unused_files
1590 .iter()
1591 .map(|f| f.file.path.to_string_lossy().to_string())
1592 .collect();
1593 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
1594 }
1595
1596 #[test]
1599 fn sort_unused_exports_by_path_line_name() {
1600 let mut r = AnalysisResults::default();
1601 let mk = |path: &str, line: u32, name: &str| {
1602 UnusedExportFinding::with_actions(UnusedExport {
1603 path: PathBuf::from(path),
1604 export_name: name.to_string(),
1605 is_type_only: false,
1606 line,
1607 col: 0,
1608 span_start: 0,
1609 is_re_export: false,
1610 })
1611 };
1612 r.unused_exports.push(mk("b.ts", 5, "beta"));
1613 r.unused_exports.push(mk("a.ts", 10, "zeta"));
1614 r.unused_exports.push(mk("a.ts", 10, "alpha"));
1615 r.unused_exports.push(mk("a.ts", 1, "gamma"));
1616 r.sort();
1617 let keys: Vec<_> = r
1618 .unused_exports
1619 .iter()
1620 .map(|e| {
1621 format!(
1622 "{}:{}:{}",
1623 e.export.path.to_string_lossy(),
1624 e.export.line,
1625 e.export.export_name
1626 )
1627 })
1628 .collect();
1629 assert_eq!(
1630 keys,
1631 vec![
1632 "a.ts:1:gamma",
1633 "a.ts:10:alpha",
1634 "a.ts:10:zeta",
1635 "b.ts:5:beta"
1636 ]
1637 );
1638 }
1639
1640 #[test]
1643 fn sort_unused_types_by_path_line_name() {
1644 let mut r = AnalysisResults::default();
1645 let mk = |path: &str, line: u32, name: &str| {
1646 UnusedTypeFinding::with_actions(UnusedExport {
1647 path: PathBuf::from(path),
1648 export_name: name.to_string(),
1649 is_type_only: true,
1650 line,
1651 col: 0,
1652 span_start: 0,
1653 is_re_export: false,
1654 })
1655 };
1656 r.unused_types.push(mk("z.ts", 1, "Z"));
1657 r.unused_types.push(mk("a.ts", 1, "A"));
1658 r.sort();
1659 assert_eq!(r.unused_types[0].export.path, PathBuf::from("a.ts"));
1660 assert_eq!(r.unused_types[1].export.path, PathBuf::from("z.ts"));
1661 }
1662
1663 #[test]
1666 fn sort_unused_dependencies_by_path_line_name() {
1667 let mut r = AnalysisResults::default();
1668 let mk = |path: &str, line: u32, name: &str| {
1669 UnusedDependencyFinding::with_actions(UnusedDependency {
1670 package_name: name.to_string(),
1671 location: DependencyLocation::Dependencies,
1672 path: PathBuf::from(path),
1673 line,
1674 used_in_workspaces: Vec::new(),
1675 })
1676 };
1677 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
1678 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
1679 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
1680 r.sort();
1681 let names: Vec<_> = r
1682 .unused_dependencies
1683 .iter()
1684 .map(|d| d.dep.package_name.as_str())
1685 .collect();
1686 assert_eq!(names, vec!["axios", "react", "zlib"]);
1687 }
1688
1689 #[test]
1692 fn sort_unused_dev_dependencies() {
1693 let mut r = AnalysisResults::default();
1694 r.unused_dev_dependencies
1695 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1696 package_name: "vitest".to_string(),
1697 location: DependencyLocation::DevDependencies,
1698 path: PathBuf::from("package.json"),
1699 line: 10,
1700 used_in_workspaces: Vec::new(),
1701 }));
1702 r.unused_dev_dependencies
1703 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1704 package_name: "jest".to_string(),
1705 location: DependencyLocation::DevDependencies,
1706 path: PathBuf::from("package.json"),
1707 line: 5,
1708 used_in_workspaces: Vec::new(),
1709 }));
1710 r.sort();
1711 assert_eq!(r.unused_dev_dependencies[0].dep.package_name, "jest");
1712 assert_eq!(r.unused_dev_dependencies[1].dep.package_name, "vitest");
1713 }
1714
1715 #[test]
1718 fn sort_unused_optional_dependencies() {
1719 let mut r = AnalysisResults::default();
1720 r.unused_optional_dependencies
1721 .push(UnusedOptionalDependencyFinding::with_actions(
1722 UnusedDependency {
1723 package_name: "zod".to_string(),
1724 location: DependencyLocation::OptionalDependencies,
1725 path: PathBuf::from("package.json"),
1726 line: 3,
1727 used_in_workspaces: Vec::new(),
1728 },
1729 ));
1730 r.unused_optional_dependencies
1731 .push(UnusedOptionalDependencyFinding::with_actions(
1732 UnusedDependency {
1733 package_name: "ajv".to_string(),
1734 location: DependencyLocation::OptionalDependencies,
1735 path: PathBuf::from("package.json"),
1736 line: 2,
1737 used_in_workspaces: Vec::new(),
1738 },
1739 ));
1740 r.sort();
1741 assert_eq!(r.unused_optional_dependencies[0].dep.package_name, "ajv");
1742 assert_eq!(r.unused_optional_dependencies[1].dep.package_name, "zod");
1743 }
1744
1745 #[test]
1748 fn sort_unused_enum_members_by_path_line_parent_member() {
1749 let mut r = AnalysisResults::default();
1750 let mk = |path: &str, line: u32, parent: &str, member: &str| {
1751 UnusedEnumMemberFinding::with_actions(UnusedMember {
1752 path: PathBuf::from(path),
1753 parent_name: parent.to_string(),
1754 member_name: member.to_string(),
1755 kind: MemberKind::EnumMember,
1756 line,
1757 col: 0,
1758 })
1759 };
1760 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
1761 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
1762 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
1763 r.sort();
1764 let keys: Vec<_> = r
1765 .unused_enum_members
1766 .iter()
1767 .map(|m| format!("{}:{}", m.member.parent_name, m.member.member_name))
1768 .collect();
1769 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
1770 }
1771
1772 #[test]
1775 fn sort_unused_class_members() {
1776 let mut r = AnalysisResults::default();
1777 let mk = |path: &str, line: u32, parent: &str, member: &str| {
1778 UnusedClassMemberFinding::with_actions(UnusedMember {
1779 path: PathBuf::from(path),
1780 parent_name: parent.to_string(),
1781 member_name: member.to_string(),
1782 kind: MemberKind::ClassMethod,
1783 line,
1784 col: 0,
1785 })
1786 };
1787 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
1788 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
1789 r.sort();
1790 assert_eq!(r.unused_class_members[0].member.path, PathBuf::from("a.ts"));
1791 assert_eq!(r.unused_class_members[1].member.path, PathBuf::from("b.ts"));
1792 }
1793
1794 #[test]
1797 fn sort_unresolved_imports_by_path_line_col_specifier() {
1798 let mut r = AnalysisResults::default();
1799 let mk = |path: &str, line: u32, col: u32, spec: &str| {
1800 UnresolvedImportFinding::with_actions(UnresolvedImport {
1801 path: PathBuf::from(path),
1802 specifier: spec.to_string(),
1803 line,
1804 col,
1805 specifier_col: 0,
1806 })
1807 };
1808 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
1809 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
1810 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
1811 r.sort();
1812 let specs: Vec<_> = r
1813 .unresolved_imports
1814 .iter()
1815 .map(|i| i.import.specifier.as_str())
1816 .collect();
1817 assert_eq!(specs, vec!["./m", "./a", "./z"]);
1818 }
1819
1820 #[test]
1823 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
1824 let mut r = AnalysisResults::default();
1825 r.unlisted_dependencies
1826 .push(UnlistedDependencyFinding::with_actions(
1827 UnlistedDependency {
1828 package_name: "zod".to_string(),
1829 imported_from: vec![
1830 ImportSite {
1831 path: PathBuf::from("b.ts"),
1832 line: 10,
1833 col: 0,
1834 },
1835 ImportSite {
1836 path: PathBuf::from("a.ts"),
1837 line: 1,
1838 col: 0,
1839 },
1840 ],
1841 },
1842 ));
1843 r.unlisted_dependencies
1844 .push(UnlistedDependencyFinding::with_actions(
1845 UnlistedDependency {
1846 package_name: "axios".to_string(),
1847 imported_from: vec![ImportSite {
1848 path: PathBuf::from("c.ts"),
1849 line: 1,
1850 col: 0,
1851 }],
1852 },
1853 ));
1854 r.sort();
1855
1856 assert_eq!(r.unlisted_dependencies[0].dep.package_name, "axios");
1858 assert_eq!(r.unlisted_dependencies[1].dep.package_name, "zod");
1859
1860 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1862 .dep
1863 .imported_from
1864 .iter()
1865 .map(|s| s.path.to_string_lossy().to_string())
1866 .collect();
1867 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1868 }
1869
1870 #[test]
1873 fn sort_duplicate_exports_by_name_and_inner_locations() {
1874 let mut r = AnalysisResults::default();
1875 r.duplicate_exports
1876 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1877 export_name: "z".to_string(),
1878 locations: vec![
1879 DuplicateLocation {
1880 path: PathBuf::from("c.ts"),
1881 line: 1,
1882 col: 0,
1883 },
1884 DuplicateLocation {
1885 path: PathBuf::from("a.ts"),
1886 line: 5,
1887 col: 0,
1888 },
1889 ],
1890 }));
1891 r.duplicate_exports
1892 .push(DuplicateExportFinding::with_actions(DuplicateExport {
1893 export_name: "a".to_string(),
1894 locations: vec![DuplicateLocation {
1895 path: PathBuf::from("b.ts"),
1896 line: 1,
1897 col: 0,
1898 }],
1899 }));
1900 r.sort();
1901
1902 assert_eq!(r.duplicate_exports[0].export.export_name, "a");
1904 assert_eq!(r.duplicate_exports[1].export.export_name, "z");
1905
1906 let z_locs: Vec<_> = r.duplicate_exports[1]
1908 .export
1909 .locations
1910 .iter()
1911 .map(|l| l.path.to_string_lossy().to_string())
1912 .collect();
1913 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1914 }
1915
1916 #[test]
1919 fn sort_type_only_dependencies() {
1920 let mut r = AnalysisResults::default();
1921 r.type_only_dependencies
1922 .push(TypeOnlyDependencyFinding::with_actions(
1923 TypeOnlyDependency {
1924 package_name: "zod".to_string(),
1925 path: PathBuf::from("package.json"),
1926 line: 10,
1927 },
1928 ));
1929 r.type_only_dependencies
1930 .push(TypeOnlyDependencyFinding::with_actions(
1931 TypeOnlyDependency {
1932 package_name: "ajv".to_string(),
1933 path: PathBuf::from("package.json"),
1934 line: 5,
1935 },
1936 ));
1937 r.sort();
1938 assert_eq!(r.type_only_dependencies[0].dep.package_name, "ajv");
1939 assert_eq!(r.type_only_dependencies[1].dep.package_name, "zod");
1940 }
1941
1942 #[test]
1945 fn sort_test_only_dependencies() {
1946 let mut r = AnalysisResults::default();
1947 r.test_only_dependencies
1948 .push(TestOnlyDependencyFinding::with_actions(
1949 TestOnlyDependency {
1950 package_name: "vitest".to_string(),
1951 path: PathBuf::from("package.json"),
1952 line: 15,
1953 },
1954 ));
1955 r.test_only_dependencies
1956 .push(TestOnlyDependencyFinding::with_actions(
1957 TestOnlyDependency {
1958 package_name: "jest".to_string(),
1959 path: PathBuf::from("package.json"),
1960 line: 10,
1961 },
1962 ));
1963 r.sort();
1964 assert_eq!(r.test_only_dependencies[0].dep.package_name, "jest");
1965 assert_eq!(r.test_only_dependencies[1].dep.package_name, "vitest");
1966 }
1967
1968 #[test]
1971 fn sort_circular_dependencies_by_files_then_length() {
1972 let mut r = AnalysisResults::default();
1973 r.circular_dependencies
1974 .push(CircularDependencyFinding::with_actions(
1975 CircularDependency {
1976 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1977 length: 2,
1978 line: 1,
1979 col: 0,
1980 is_cross_package: false,
1981 },
1982 ));
1983 r.circular_dependencies
1984 .push(CircularDependencyFinding::with_actions(
1985 CircularDependency {
1986 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1987 length: 2,
1988 line: 1,
1989 col: 0,
1990 is_cross_package: true,
1991 },
1992 ));
1993 r.sort();
1994 assert_eq!(
1995 r.circular_dependencies[0].cycle.files[0],
1996 PathBuf::from("a.ts")
1997 );
1998 assert_eq!(
1999 r.circular_dependencies[1].cycle.files[0],
2000 PathBuf::from("b.ts")
2001 );
2002 }
2003
2004 #[test]
2007 fn sort_boundary_violations() {
2008 let mut r = AnalysisResults::default();
2009 let mk = |from: &str, line: u32, col: u32, to: &str| {
2010 BoundaryViolationFinding::with_actions(BoundaryViolation {
2011 from_path: PathBuf::from(from),
2012 to_path: PathBuf::from(to),
2013 from_zone: "a".to_string(),
2014 to_zone: "b".to_string(),
2015 import_specifier: to.to_string(),
2016 line,
2017 col,
2018 })
2019 };
2020 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
2021 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
2022 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
2023 r.sort();
2024 let from_paths: Vec<_> = r
2025 .boundary_violations
2026 .iter()
2027 .map(|v| {
2028 format!(
2029 "{}:{}",
2030 v.violation.from_path.to_string_lossy(),
2031 v.violation.line
2032 )
2033 })
2034 .collect();
2035 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
2036 }
2037
2038 #[test]
2041 fn sort_export_usages_and_inner_reference_locations() {
2042 let mut r = AnalysisResults::default();
2043 r.export_usages.push(ExportUsage {
2044 path: PathBuf::from("z.ts"),
2045 export_name: "foo".to_string(),
2046 line: 1,
2047 col: 0,
2048 reference_count: 2,
2049 reference_locations: vec![
2050 ReferenceLocation {
2051 path: PathBuf::from("c.ts"),
2052 line: 10,
2053 col: 0,
2054 },
2055 ReferenceLocation {
2056 path: PathBuf::from("a.ts"),
2057 line: 5,
2058 col: 0,
2059 },
2060 ],
2061 });
2062 r.export_usages.push(ExportUsage {
2063 path: PathBuf::from("a.ts"),
2064 export_name: "bar".to_string(),
2065 line: 1,
2066 col: 0,
2067 reference_count: 1,
2068 reference_locations: vec![ReferenceLocation {
2069 path: PathBuf::from("b.ts"),
2070 line: 1,
2071 col: 0,
2072 }],
2073 });
2074 r.sort();
2075
2076 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
2078 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
2079
2080 let refs: Vec<_> = r.export_usages[1]
2082 .reference_locations
2083 .iter()
2084 .map(|l| l.path.to_string_lossy().to_string())
2085 .collect();
2086 assert_eq!(refs, vec!["a.ts", "c.ts"]);
2087 }
2088
2089 #[test]
2092 fn sort_empty_results_is_noop() {
2093 let mut r = AnalysisResults::default();
2094 r.sort(); assert_eq!(r.total_issues(), 0);
2096 }
2097
2098 #[test]
2101 fn sort_single_element_lists_stable() {
2102 let mut r = AnalysisResults::default();
2103 r.unused_files
2104 .push(UnusedFileFinding::with_actions(UnusedFile {
2105 path: PathBuf::from("only.ts"),
2106 }));
2107 r.sort();
2108 assert_eq!(r.unused_files[0].file.path, PathBuf::from("only.ts"));
2109 }
2110
2111 #[test]
2114 fn serialize_empty_results() {
2115 let r = AnalysisResults::default();
2116 let json = serde_json::to_value(&r).unwrap();
2117
2118 assert!(json["unused_files"].as_array().unwrap().is_empty());
2120 assert!(json["unused_exports"].as_array().unwrap().is_empty());
2121 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
2122
2123 assert!(json.get("export_usages").is_none());
2125 assert!(json.get("entry_point_summary").is_none());
2126 }
2127
2128 #[test]
2129 fn serialize_unused_file_path() {
2130 let r = UnusedFile {
2131 path: PathBuf::from("src/utils/index.ts"),
2132 };
2133 let json = serde_json::to_value(&r).unwrap();
2134 assert_eq!(json["path"], "src/utils/index.ts");
2135 }
2136
2137 #[test]
2138 fn serialize_dependency_location_camel_case() {
2139 let dep = UnusedDependency {
2140 package_name: "react".to_string(),
2141 location: DependencyLocation::DevDependencies,
2142 path: PathBuf::from("package.json"),
2143 line: 5,
2144 used_in_workspaces: Vec::new(),
2145 };
2146 let json = serde_json::to_value(&dep).unwrap();
2147 assert_eq!(json["location"], "devDependencies");
2148
2149 let dep2 = UnusedDependency {
2150 package_name: "react".to_string(),
2151 location: DependencyLocation::Dependencies,
2152 path: PathBuf::from("package.json"),
2153 line: 3,
2154 used_in_workspaces: Vec::new(),
2155 };
2156 let json2 = serde_json::to_value(&dep2).unwrap();
2157 assert_eq!(json2["location"], "dependencies");
2158
2159 let dep3 = UnusedDependency {
2160 package_name: "fsevents".to_string(),
2161 location: DependencyLocation::OptionalDependencies,
2162 path: PathBuf::from("package.json"),
2163 line: 7,
2164 used_in_workspaces: Vec::new(),
2165 };
2166 let json3 = serde_json::to_value(&dep3).unwrap();
2167 assert_eq!(json3["location"], "optionalDependencies");
2168 }
2169
2170 #[test]
2171 fn serialize_circular_dependency_skips_false_cross_package() {
2172 let cd = CircularDependency {
2173 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2174 length: 2,
2175 line: 1,
2176 col: 0,
2177 is_cross_package: false,
2178 };
2179 let json = serde_json::to_value(&cd).unwrap();
2180 assert!(json.get("is_cross_package").is_none());
2182 }
2183
2184 #[test]
2185 fn serialize_circular_dependency_includes_true_cross_package() {
2186 let cd = CircularDependency {
2187 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
2188 length: 2,
2189 line: 1,
2190 col: 0,
2191 is_cross_package: true,
2192 };
2193 let json = serde_json::to_value(&cd).unwrap();
2194 assert_eq!(json["is_cross_package"], true);
2195 }
2196
2197 #[test]
2198 fn serialize_unused_export_fields() {
2199 let e = UnusedExport {
2200 path: PathBuf::from("src/mod.ts"),
2201 export_name: "helper".to_string(),
2202 is_type_only: true,
2203 line: 42,
2204 col: 7,
2205 span_start: 100,
2206 is_re_export: true,
2207 };
2208 let json = serde_json::to_value(&e).unwrap();
2209 assert_eq!(json["path"], "src/mod.ts");
2210 assert_eq!(json["export_name"], "helper");
2211 assert_eq!(json["is_type_only"], true);
2212 assert_eq!(json["line"], 42);
2213 assert_eq!(json["col"], 7);
2214 assert_eq!(json["span_start"], 100);
2215 assert_eq!(json["is_re_export"], true);
2216 }
2217
2218 #[test]
2219 fn serialize_boundary_violation_fields() {
2220 let v = BoundaryViolation {
2221 from_path: PathBuf::from("src/ui/button.tsx"),
2222 to_path: PathBuf::from("src/db/queries.ts"),
2223 from_zone: "ui".to_string(),
2224 to_zone: "db".to_string(),
2225 import_specifier: "../db/queries".to_string(),
2226 line: 3,
2227 col: 0,
2228 };
2229 let json = serde_json::to_value(&v).unwrap();
2230 assert_eq!(json["from_path"], "src/ui/button.tsx");
2231 assert_eq!(json["to_path"], "src/db/queries.ts");
2232 assert_eq!(json["from_zone"], "ui");
2233 assert_eq!(json["to_zone"], "db");
2234 assert_eq!(json["import_specifier"], "../db/queries");
2235 }
2236
2237 #[test]
2238 fn serialize_unlisted_dependency_with_import_sites() {
2239 let d = UnlistedDependency {
2240 package_name: "chalk".to_string(),
2241 imported_from: vec![
2242 ImportSite {
2243 path: PathBuf::from("a.ts"),
2244 line: 1,
2245 col: 0,
2246 },
2247 ImportSite {
2248 path: PathBuf::from("b.ts"),
2249 line: 5,
2250 col: 3,
2251 },
2252 ],
2253 };
2254 let json = serde_json::to_value(&d).unwrap();
2255 assert_eq!(json["package_name"], "chalk");
2256 let sites = json["imported_from"].as_array().unwrap();
2257 assert_eq!(sites.len(), 2);
2258 assert_eq!(sites[0]["path"], "a.ts");
2259 assert_eq!(sites[1]["line"], 5);
2260 }
2261
2262 #[test]
2263 fn serialize_duplicate_export_with_locations() {
2264 let d = DuplicateExport {
2265 export_name: "Button".to_string(),
2266 locations: vec![
2267 DuplicateLocation {
2268 path: PathBuf::from("src/a.ts"),
2269 line: 10,
2270 col: 0,
2271 },
2272 DuplicateLocation {
2273 path: PathBuf::from("src/b.ts"),
2274 line: 20,
2275 col: 5,
2276 },
2277 ],
2278 };
2279 let json = serde_json::to_value(&d).unwrap();
2280 assert_eq!(json["export_name"], "Button");
2281 let locs = json["locations"].as_array().unwrap();
2282 assert_eq!(locs.len(), 2);
2283 assert_eq!(locs[0]["line"], 10);
2284 assert_eq!(locs[1]["col"], 5);
2285 }
2286
2287 #[test]
2288 fn serialize_type_only_dependency() {
2289 let d = TypeOnlyDependency {
2290 package_name: "@types/react".to_string(),
2291 path: PathBuf::from("package.json"),
2292 line: 12,
2293 };
2294 let json = serde_json::to_value(&d).unwrap();
2295 assert_eq!(json["package_name"], "@types/react");
2296 assert_eq!(json["line"], 12);
2297 }
2298
2299 #[test]
2300 fn serialize_test_only_dependency() {
2301 let d = TestOnlyDependency {
2302 package_name: "vitest".to_string(),
2303 path: PathBuf::from("package.json"),
2304 line: 8,
2305 };
2306 let json = serde_json::to_value(&d).unwrap();
2307 assert_eq!(json["package_name"], "vitest");
2308 assert_eq!(json["line"], 8);
2309 }
2310
2311 #[test]
2312 fn serialize_unused_member() {
2313 let m = UnusedMember {
2314 path: PathBuf::from("enums.ts"),
2315 parent_name: "Status".to_string(),
2316 member_name: "Pending".to_string(),
2317 kind: MemberKind::EnumMember,
2318 line: 3,
2319 col: 4,
2320 };
2321 let json = serde_json::to_value(&m).unwrap();
2322 assert_eq!(json["parent_name"], "Status");
2323 assert_eq!(json["member_name"], "Pending");
2324 assert_eq!(json["line"], 3);
2325 }
2326
2327 #[test]
2328 fn serialize_unresolved_import() {
2329 let i = UnresolvedImport {
2330 path: PathBuf::from("app.ts"),
2331 specifier: "./missing-module".to_string(),
2332 line: 7,
2333 col: 0,
2334 specifier_col: 21,
2335 };
2336 let json = serde_json::to_value(&i).unwrap();
2337 assert_eq!(json["specifier"], "./missing-module");
2338 assert_eq!(json["specifier_col"], 21);
2339 }
2340
2341 #[test]
2344 fn deserialize_circular_dependency_with_defaults() {
2345 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
2347 let cd: CircularDependency = serde_json::from_str(json).unwrap();
2348 assert_eq!(cd.files.len(), 2);
2349 assert_eq!(cd.length, 2);
2350 assert_eq!(cd.line, 0);
2351 assert_eq!(cd.col, 0);
2352 assert!(!cd.is_cross_package);
2353 }
2354
2355 #[test]
2356 fn deserialize_circular_dependency_with_all_fields() {
2357 let json =
2358 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
2359 let cd: CircularDependency = serde_json::from_str(json).unwrap();
2360 assert_eq!(cd.line, 5);
2361 assert_eq!(cd.col, 10);
2362 assert!(cd.is_cross_package);
2363 }
2364
2365 #[test]
2368 fn clone_results_are_independent() {
2369 let mut r = AnalysisResults::default();
2370 r.unused_files
2371 .push(UnusedFileFinding::with_actions(UnusedFile {
2372 path: PathBuf::from("a.ts"),
2373 }));
2374 let mut cloned = r.clone();
2375 cloned
2376 .unused_files
2377 .push(UnusedFileFinding::with_actions(UnusedFile {
2378 path: PathBuf::from("b.ts"),
2379 }));
2380 assert_eq!(r.total_issues(), 1);
2381 assert_eq!(cloned.total_issues(), 2);
2382 }
2383
2384 #[test]
2387 fn export_usages_not_counted_in_total_issues() {
2388 let mut r = AnalysisResults::default();
2389 r.export_usages.push(ExportUsage {
2390 path: PathBuf::from("mod.ts"),
2391 export_name: "foo".to_string(),
2392 line: 1,
2393 col: 0,
2394 reference_count: 3,
2395 reference_locations: vec![],
2396 });
2397 assert_eq!(r.total_issues(), 0);
2399 assert!(!r.has_issues());
2400 }
2401
2402 #[test]
2405 fn entry_point_summary_not_counted_in_total_issues() {
2406 let r = AnalysisResults {
2407 entry_point_summary: Some(EntryPointSummary {
2408 total: 10,
2409 by_source: vec![("config".to_string(), 10)],
2410 }),
2411 ..AnalysisResults::default()
2412 };
2413 assert_eq!(r.total_issues(), 0);
2414 assert!(!r.has_issues());
2415 }
2416}