use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::extract::MemberKind;
use crate::serde_path;
use crate::suppress::IssueKind;
#[derive(Debug, Clone, Default)]
pub struct EntryPointSummary {
pub total: usize,
pub by_source: Vec<(String, usize)>,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct AnalysisResults {
pub unused_files: Vec<UnusedFile>,
pub unused_exports: Vec<UnusedExport>,
pub unused_types: Vec<UnusedExport>,
pub unused_dependencies: Vec<UnusedDependency>,
pub unused_dev_dependencies: Vec<UnusedDependency>,
pub unused_optional_dependencies: Vec<UnusedDependency>,
pub unused_enum_members: Vec<UnusedMember>,
pub unused_class_members: Vec<UnusedMember>,
pub unresolved_imports: Vec<UnresolvedImport>,
pub unlisted_dependencies: Vec<UnlistedDependency>,
pub duplicate_exports: Vec<DuplicateExport>,
pub type_only_dependencies: Vec<TypeOnlyDependency>,
#[serde(default)]
pub test_only_dependencies: Vec<TestOnlyDependency>,
pub circular_dependencies: Vec<CircularDependency>,
#[serde(default)]
pub boundary_violations: Vec<BoundaryViolation>,
#[serde(default)]
pub stale_suppressions: Vec<StaleSuppression>,
#[serde(skip)]
pub feature_flags: Vec<FeatureFlag>,
#[serde(skip)]
pub export_usages: Vec<ExportUsage>,
#[serde(skip)]
pub entry_point_summary: Option<EntryPointSummary>,
}
impl AnalysisResults {
#[must_use]
pub const fn total_issues(&self) -> usize {
self.unused_files.len()
+ self.unused_exports.len()
+ self.unused_types.len()
+ self.unused_dependencies.len()
+ self.unused_dev_dependencies.len()
+ self.unused_optional_dependencies.len()
+ self.unused_enum_members.len()
+ self.unused_class_members.len()
+ self.unresolved_imports.len()
+ self.unlisted_dependencies.len()
+ self.duplicate_exports.len()
+ self.type_only_dependencies.len()
+ self.test_only_dependencies.len()
+ self.circular_dependencies.len()
+ self.boundary_violations.len()
+ self.stale_suppressions.len()
}
#[must_use]
pub const fn has_issues(&self) -> bool {
self.total_issues() > 0
}
pub fn sort(&mut self) {
self.unused_files.sort_by(|a, b| a.path.cmp(&b.path));
self.unused_exports.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.export_name.cmp(&b.export_name))
});
self.unused_types.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.export_name.cmp(&b.export_name))
});
self.unused_dependencies.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.package_name.cmp(&b.package_name))
});
self.unused_dev_dependencies.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.package_name.cmp(&b.package_name))
});
self.unused_optional_dependencies.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.package_name.cmp(&b.package_name))
});
self.unused_enum_members.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.parent_name.cmp(&b.parent_name))
.then(a.member_name.cmp(&b.member_name))
});
self.unused_class_members.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.parent_name.cmp(&b.parent_name))
.then(a.member_name.cmp(&b.member_name))
});
self.unresolved_imports.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.col.cmp(&b.col))
.then(a.specifier.cmp(&b.specifier))
});
self.unlisted_dependencies
.sort_by(|a, b| a.package_name.cmp(&b.package_name));
for dep in &mut self.unlisted_dependencies {
dep.imported_from
.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
}
self.duplicate_exports
.sort_by(|a, b| a.export_name.cmp(&b.export_name));
for dup in &mut self.duplicate_exports {
dup.locations
.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
}
self.type_only_dependencies.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.package_name.cmp(&b.package_name))
});
self.test_only_dependencies.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.package_name.cmp(&b.package_name))
});
self.circular_dependencies
.sort_by(|a, b| a.files.cmp(&b.files).then(a.length.cmp(&b.length)));
self.boundary_violations.sort_by(|a, b| {
a.from_path
.cmp(&b.from_path)
.then(a.line.cmp(&b.line))
.then(a.col.cmp(&b.col))
.then(a.to_path.cmp(&b.to_path))
});
self.stale_suppressions.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.col.cmp(&b.col))
});
self.feature_flags.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.flag_name.cmp(&b.flag_name))
});
for usage in &mut self.export_usages {
usage.reference_locations.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.col.cmp(&b.col))
});
}
self.export_usages.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.export_name.cmp(&b.export_name))
});
}
}
#[derive(Debug, Clone, Serialize)]
pub struct UnusedFile {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnusedExport {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub export_name: String,
pub is_type_only: bool,
pub line: u32,
pub col: u32,
pub span_start: u32,
pub is_re_export: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnusedDependency {
pub package_name: String,
pub location: DependencyLocation,
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub line: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum DependencyLocation {
Dependencies,
DevDependencies,
OptionalDependencies,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnusedMember {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub parent_name: String,
pub member_name: String,
pub kind: MemberKind,
pub line: u32,
pub col: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnresolvedImport {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub specifier: String,
pub line: u32,
pub col: u32,
pub specifier_col: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnlistedDependency {
pub package_name: String,
pub imported_from: Vec<ImportSite>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ImportSite {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub line: u32,
pub col: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct DuplicateExport {
pub export_name: String,
pub locations: Vec<DuplicateLocation>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DuplicateLocation {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub line: u32,
pub col: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct TypeOnlyDependency {
pub package_name: String,
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub line: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct TestOnlyDependency {
pub package_name: String,
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub line: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircularDependency {
#[serde(serialize_with = "serde_path::serialize_vec")]
pub files: Vec<PathBuf>,
pub length: usize,
#[serde(default)]
pub line: u32,
#[serde(default)]
pub col: u32,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_cross_package: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct BoundaryViolation {
#[serde(serialize_with = "serde_path::serialize")]
pub from_path: PathBuf,
#[serde(serialize_with = "serde_path::serialize")]
pub to_path: PathBuf,
pub from_zone: String,
pub to_zone: String,
pub import_specifier: String,
pub line: u32,
pub col: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum SuppressionOrigin {
Comment {
#[serde(skip_serializing_if = "Option::is_none")]
issue_kind: Option<String>,
is_file_level: bool,
},
JsdocTag {
export_name: String,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct StaleSuppression {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub line: u32,
pub col: u32,
pub origin: SuppressionOrigin,
}
impl StaleSuppression {
#[must_use]
pub fn description(&self) -> String {
match &self.origin {
SuppressionOrigin::Comment {
issue_kind,
is_file_level,
} => {
let directive = if *is_file_level {
"fallow-ignore-file"
} else {
"fallow-ignore-next-line"
};
match issue_kind {
Some(kind) => format!("// {directive} {kind}"),
None => format!("// {directive}"),
}
}
SuppressionOrigin::JsdocTag { export_name } => {
format!("@expected-unused on {export_name}")
}
}
}
#[must_use]
pub fn explanation(&self) -> String {
match &self.origin {
SuppressionOrigin::Comment {
issue_kind,
is_file_level,
} => {
let scope = if *is_file_level {
"in this file"
} else {
"on the next line"
};
match issue_kind {
Some(kind) => format!("no {kind} issue found {scope}"),
None => format!("no issues found {scope}"),
}
}
SuppressionOrigin::JsdocTag { export_name } => {
format!("{export_name} is now used")
}
}
}
#[must_use]
pub fn suppressed_kind(&self) -> Option<IssueKind> {
match &self.origin {
SuppressionOrigin::Comment { issue_kind, .. } => {
issue_kind.as_deref().and_then(IssueKind::parse)
}
SuppressionOrigin::JsdocTag { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FlagKind {
EnvironmentVariable,
SdkCall,
ConfigObject,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FlagConfidence {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Serialize)]
pub struct FeatureFlag {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub flag_name: String,
pub kind: FlagKind,
pub confidence: FlagConfidence,
pub line: u32,
pub col: u32,
#[serde(skip)]
pub guard_span_start: Option<u32>,
#[serde(skip)]
pub guard_span_end: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sdk_name: Option<String>,
#[serde(skip)]
pub guard_line_start: Option<u32>,
#[serde(skip)]
pub guard_line_end: Option<u32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub guarded_dead_exports: Vec<String>,
}
const _: () = assert!(std::mem::size_of::<FeatureFlag>() <= 160);
#[derive(Debug, Clone, Serialize)]
pub struct ExportUsage {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub export_name: String,
pub line: u32,
pub col: u32,
pub reference_count: usize,
pub reference_locations: Vec<ReferenceLocation>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReferenceLocation {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
pub line: u32,
pub col: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_results_no_issues() {
let results = AnalysisResults::default();
assert_eq!(results.total_issues(), 0);
assert!(!results.has_issues());
}
#[test]
fn results_with_unused_file() {
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: PathBuf::from("test.ts"),
});
assert_eq!(results.total_issues(), 1);
assert!(results.has_issues());
}
#[test]
fn results_with_unused_export() {
let mut results = AnalysisResults::default();
results.unused_exports.push(UnusedExport {
path: PathBuf::from("test.ts"),
export_name: "foo".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
});
assert_eq!(results.total_issues(), 1);
assert!(results.has_issues());
}
#[test]
fn results_total_counts_all_types() {
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: PathBuf::from("a.ts"),
});
results.unused_exports.push(UnusedExport {
path: PathBuf::from("b.ts"),
export_name: "x".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
});
results.unused_types.push(UnusedExport {
path: PathBuf::from("c.ts"),
export_name: "T".to_string(),
is_type_only: true,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
});
results.unused_dependencies.push(UnusedDependency {
package_name: "dep".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("package.json"),
line: 5,
});
results.unused_dev_dependencies.push(UnusedDependency {
package_name: "dev".to_string(),
location: DependencyLocation::DevDependencies,
path: PathBuf::from("package.json"),
line: 5,
});
results.unused_enum_members.push(UnusedMember {
path: PathBuf::from("d.ts"),
parent_name: "E".to_string(),
member_name: "A".to_string(),
kind: MemberKind::EnumMember,
line: 1,
col: 0,
});
results.unused_class_members.push(UnusedMember {
path: PathBuf::from("e.ts"),
parent_name: "C".to_string(),
member_name: "m".to_string(),
kind: MemberKind::ClassMethod,
line: 1,
col: 0,
});
results.unresolved_imports.push(UnresolvedImport {
path: PathBuf::from("f.ts"),
specifier: "./missing".to_string(),
line: 1,
col: 0,
specifier_col: 0,
});
results.unlisted_dependencies.push(UnlistedDependency {
package_name: "unlisted".to_string(),
imported_from: vec![ImportSite {
path: PathBuf::from("g.ts"),
line: 1,
col: 0,
}],
});
results.duplicate_exports.push(DuplicateExport {
export_name: "dup".to_string(),
locations: vec![
DuplicateLocation {
path: PathBuf::from("h.ts"),
line: 15,
col: 0,
},
DuplicateLocation {
path: PathBuf::from("i.ts"),
line: 30,
col: 0,
},
],
});
results.unused_optional_dependencies.push(UnusedDependency {
package_name: "optional".to_string(),
location: DependencyLocation::OptionalDependencies,
path: PathBuf::from("package.json"),
line: 5,
});
results.type_only_dependencies.push(TypeOnlyDependency {
package_name: "type-only".to_string(),
path: PathBuf::from("package.json"),
line: 8,
});
results.test_only_dependencies.push(TestOnlyDependency {
package_name: "test-only".to_string(),
path: PathBuf::from("package.json"),
line: 9,
});
results.circular_dependencies.push(CircularDependency {
files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
length: 2,
line: 3,
col: 0,
is_cross_package: false,
});
results.boundary_violations.push(BoundaryViolation {
from_path: PathBuf::from("src/ui/Button.tsx"),
to_path: PathBuf::from("src/db/queries.ts"),
from_zone: "ui".to_string(),
to_zone: "database".to_string(),
import_specifier: "../db/queries".to_string(),
line: 3,
col: 0,
});
assert_eq!(results.total_issues(), 15);
assert!(results.has_issues());
}
#[test]
fn total_issues_and_has_issues_are_consistent() {
let results = AnalysisResults::default();
assert_eq!(results.total_issues(), 0);
assert!(!results.has_issues());
assert_eq!(results.total_issues() > 0, results.has_issues());
}
#[test]
fn total_issues_sums_all_categories_independently() {
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: PathBuf::from("a.ts"),
});
assert_eq!(results.total_issues(), 1);
results.unused_files.push(UnusedFile {
path: PathBuf::from("b.ts"),
});
assert_eq!(results.total_issues(), 2);
results.unresolved_imports.push(UnresolvedImport {
path: PathBuf::from("c.ts"),
specifier: "./missing".to_string(),
line: 1,
col: 0,
specifier_col: 0,
});
assert_eq!(results.total_issues(), 3);
}
#[test]
fn default_results_all_fields_empty() {
let r = AnalysisResults::default();
assert!(r.unused_files.is_empty());
assert!(r.unused_exports.is_empty());
assert!(r.unused_types.is_empty());
assert!(r.unused_dependencies.is_empty());
assert!(r.unused_dev_dependencies.is_empty());
assert!(r.unused_optional_dependencies.is_empty());
assert!(r.unused_enum_members.is_empty());
assert!(r.unused_class_members.is_empty());
assert!(r.unresolved_imports.is_empty());
assert!(r.unlisted_dependencies.is_empty());
assert!(r.duplicate_exports.is_empty());
assert!(r.type_only_dependencies.is_empty());
assert!(r.test_only_dependencies.is_empty());
assert!(r.circular_dependencies.is_empty());
assert!(r.boundary_violations.is_empty());
assert!(r.export_usages.is_empty());
}
#[test]
fn entry_point_summary_default() {
let summary = EntryPointSummary::default();
assert_eq!(summary.total, 0);
assert!(summary.by_source.is_empty());
}
#[test]
fn entry_point_summary_not_in_default_results() {
let r = AnalysisResults::default();
assert!(r.entry_point_summary.is_none());
}
#[test]
fn entry_point_summary_some_preserves_data() {
let r = AnalysisResults {
entry_point_summary: Some(EntryPointSummary {
total: 5,
by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
}),
..AnalysisResults::default()
};
let summary = r.entry_point_summary.as_ref().unwrap();
assert_eq!(summary.total, 5);
assert_eq!(summary.by_source.len(), 2);
assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
}
#[test]
fn sort_unused_files_by_path() {
let mut r = AnalysisResults::default();
r.unused_files.push(UnusedFile {
path: PathBuf::from("z.ts"),
});
r.unused_files.push(UnusedFile {
path: PathBuf::from("a.ts"),
});
r.unused_files.push(UnusedFile {
path: PathBuf::from("m.ts"),
});
r.sort();
let paths: Vec<_> = r
.unused_files
.iter()
.map(|f| f.path.to_string_lossy().to_string())
.collect();
assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
}
#[test]
fn sort_unused_exports_by_path_line_name() {
let mut r = AnalysisResults::default();
let mk = |path: &str, line: u32, name: &str| UnusedExport {
path: PathBuf::from(path),
export_name: name.to_string(),
is_type_only: false,
line,
col: 0,
span_start: 0,
is_re_export: false,
};
r.unused_exports.push(mk("b.ts", 5, "beta"));
r.unused_exports.push(mk("a.ts", 10, "zeta"));
r.unused_exports.push(mk("a.ts", 10, "alpha"));
r.unused_exports.push(mk("a.ts", 1, "gamma"));
r.sort();
let keys: Vec<_> = r
.unused_exports
.iter()
.map(|e| format!("{}:{}:{}", e.path.to_string_lossy(), e.line, e.export_name))
.collect();
assert_eq!(
keys,
vec![
"a.ts:1:gamma",
"a.ts:10:alpha",
"a.ts:10:zeta",
"b.ts:5:beta"
]
);
}
#[test]
fn sort_unused_types_by_path_line_name() {
let mut r = AnalysisResults::default();
let mk = |path: &str, line: u32, name: &str| UnusedExport {
path: PathBuf::from(path),
export_name: name.to_string(),
is_type_only: true,
line,
col: 0,
span_start: 0,
is_re_export: false,
};
r.unused_types.push(mk("z.ts", 1, "Z"));
r.unused_types.push(mk("a.ts", 1, "A"));
r.sort();
assert_eq!(r.unused_types[0].path, PathBuf::from("a.ts"));
assert_eq!(r.unused_types[1].path, PathBuf::from("z.ts"));
}
#[test]
fn sort_unused_dependencies_by_path_line_name() {
let mut r = AnalysisResults::default();
let mk = |path: &str, line: u32, name: &str| UnusedDependency {
package_name: name.to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from(path),
line,
};
r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
r.unused_dependencies.push(mk("a/package.json", 5, "react"));
r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
r.sort();
let names: Vec<_> = r
.unused_dependencies
.iter()
.map(|d| d.package_name.as_str())
.collect();
assert_eq!(names, vec!["axios", "react", "zlib"]);
}
#[test]
fn sort_unused_dev_dependencies() {
let mut r = AnalysisResults::default();
r.unused_dev_dependencies.push(UnusedDependency {
package_name: "vitest".to_string(),
location: DependencyLocation::DevDependencies,
path: PathBuf::from("package.json"),
line: 10,
});
r.unused_dev_dependencies.push(UnusedDependency {
package_name: "jest".to_string(),
location: DependencyLocation::DevDependencies,
path: PathBuf::from("package.json"),
line: 5,
});
r.sort();
assert_eq!(r.unused_dev_dependencies[0].package_name, "jest");
assert_eq!(r.unused_dev_dependencies[1].package_name, "vitest");
}
#[test]
fn sort_unused_optional_dependencies() {
let mut r = AnalysisResults::default();
r.unused_optional_dependencies.push(UnusedDependency {
package_name: "zod".to_string(),
location: DependencyLocation::OptionalDependencies,
path: PathBuf::from("package.json"),
line: 3,
});
r.unused_optional_dependencies.push(UnusedDependency {
package_name: "ajv".to_string(),
location: DependencyLocation::OptionalDependencies,
path: PathBuf::from("package.json"),
line: 2,
});
r.sort();
assert_eq!(r.unused_optional_dependencies[0].package_name, "ajv");
assert_eq!(r.unused_optional_dependencies[1].package_name, "zod");
}
#[test]
fn sort_unused_enum_members_by_path_line_parent_member() {
let mut r = AnalysisResults::default();
let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
path: PathBuf::from(path),
parent_name: parent.to_string(),
member_name: member.to_string(),
kind: MemberKind::EnumMember,
line,
col: 0,
};
r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
r.sort();
let keys: Vec<_> = r
.unused_enum_members
.iter()
.map(|m| format!("{}:{}", m.parent_name, m.member_name))
.collect();
assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
}
#[test]
fn sort_unused_class_members() {
let mut r = AnalysisResults::default();
let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
path: PathBuf::from(path),
parent_name: parent.to_string(),
member_name: member.to_string(),
kind: MemberKind::ClassMethod,
line,
col: 0,
};
r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
r.sort();
assert_eq!(r.unused_class_members[0].path, PathBuf::from("a.ts"));
assert_eq!(r.unused_class_members[1].path, PathBuf::from("b.ts"));
}
#[test]
fn sort_unresolved_imports_by_path_line_col_specifier() {
let mut r = AnalysisResults::default();
let mk = |path: &str, line: u32, col: u32, spec: &str| UnresolvedImport {
path: PathBuf::from(path),
specifier: spec.to_string(),
line,
col,
specifier_col: 0,
};
r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
r.sort();
let specs: Vec<_> = r
.unresolved_imports
.iter()
.map(|i| i.specifier.as_str())
.collect();
assert_eq!(specs, vec!["./m", "./a", "./z"]);
}
#[test]
fn sort_unlisted_dependencies_by_name_and_inner_sites() {
let mut r = AnalysisResults::default();
r.unlisted_dependencies.push(UnlistedDependency {
package_name: "zod".to_string(),
imported_from: vec![
ImportSite {
path: PathBuf::from("b.ts"),
line: 10,
col: 0,
},
ImportSite {
path: PathBuf::from("a.ts"),
line: 1,
col: 0,
},
],
});
r.unlisted_dependencies.push(UnlistedDependency {
package_name: "axios".to_string(),
imported_from: vec![ImportSite {
path: PathBuf::from("c.ts"),
line: 1,
col: 0,
}],
});
r.sort();
assert_eq!(r.unlisted_dependencies[0].package_name, "axios");
assert_eq!(r.unlisted_dependencies[1].package_name, "zod");
let zod_sites: Vec<_> = r.unlisted_dependencies[1]
.imported_from
.iter()
.map(|s| s.path.to_string_lossy().to_string())
.collect();
assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
}
#[test]
fn sort_duplicate_exports_by_name_and_inner_locations() {
let mut r = AnalysisResults::default();
r.duplicate_exports.push(DuplicateExport {
export_name: "z".to_string(),
locations: vec![
DuplicateLocation {
path: PathBuf::from("c.ts"),
line: 1,
col: 0,
},
DuplicateLocation {
path: PathBuf::from("a.ts"),
line: 5,
col: 0,
},
],
});
r.duplicate_exports.push(DuplicateExport {
export_name: "a".to_string(),
locations: vec![DuplicateLocation {
path: PathBuf::from("b.ts"),
line: 1,
col: 0,
}],
});
r.sort();
assert_eq!(r.duplicate_exports[0].export_name, "a");
assert_eq!(r.duplicate_exports[1].export_name, "z");
let z_locs: Vec<_> = r.duplicate_exports[1]
.locations
.iter()
.map(|l| l.path.to_string_lossy().to_string())
.collect();
assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
}
#[test]
fn sort_type_only_dependencies() {
let mut r = AnalysisResults::default();
r.type_only_dependencies.push(TypeOnlyDependency {
package_name: "zod".to_string(),
path: PathBuf::from("package.json"),
line: 10,
});
r.type_only_dependencies.push(TypeOnlyDependency {
package_name: "ajv".to_string(),
path: PathBuf::from("package.json"),
line: 5,
});
r.sort();
assert_eq!(r.type_only_dependencies[0].package_name, "ajv");
assert_eq!(r.type_only_dependencies[1].package_name, "zod");
}
#[test]
fn sort_test_only_dependencies() {
let mut r = AnalysisResults::default();
r.test_only_dependencies.push(TestOnlyDependency {
package_name: "vitest".to_string(),
path: PathBuf::from("package.json"),
line: 15,
});
r.test_only_dependencies.push(TestOnlyDependency {
package_name: "jest".to_string(),
path: PathBuf::from("package.json"),
line: 10,
});
r.sort();
assert_eq!(r.test_only_dependencies[0].package_name, "jest");
assert_eq!(r.test_only_dependencies[1].package_name, "vitest");
}
#[test]
fn sort_circular_dependencies_by_files_then_length() {
let mut r = AnalysisResults::default();
r.circular_dependencies.push(CircularDependency {
files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
length: 2,
line: 1,
col: 0,
is_cross_package: false,
});
r.circular_dependencies.push(CircularDependency {
files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
length: 2,
line: 1,
col: 0,
is_cross_package: true,
});
r.sort();
assert_eq!(r.circular_dependencies[0].files[0], PathBuf::from("a.ts"));
assert_eq!(r.circular_dependencies[1].files[0], PathBuf::from("b.ts"));
}
#[test]
fn sort_boundary_violations() {
let mut r = AnalysisResults::default();
let mk = |from: &str, line: u32, col: u32, to: &str| BoundaryViolation {
from_path: PathBuf::from(from),
to_path: PathBuf::from(to),
from_zone: "a".to_string(),
to_zone: "b".to_string(),
import_specifier: to.to_string(),
line,
col,
};
r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
r.sort();
let from_paths: Vec<_> = r
.boundary_violations
.iter()
.map(|v| format!("{}:{}", v.from_path.to_string_lossy(), v.line))
.collect();
assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
}
#[test]
fn sort_export_usages_and_inner_reference_locations() {
let mut r = AnalysisResults::default();
r.export_usages.push(ExportUsage {
path: PathBuf::from("z.ts"),
export_name: "foo".to_string(),
line: 1,
col: 0,
reference_count: 2,
reference_locations: vec![
ReferenceLocation {
path: PathBuf::from("c.ts"),
line: 10,
col: 0,
},
ReferenceLocation {
path: PathBuf::from("a.ts"),
line: 5,
col: 0,
},
],
});
r.export_usages.push(ExportUsage {
path: PathBuf::from("a.ts"),
export_name: "bar".to_string(),
line: 1,
col: 0,
reference_count: 1,
reference_locations: vec![ReferenceLocation {
path: PathBuf::from("b.ts"),
line: 1,
col: 0,
}],
});
r.sort();
assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
let refs: Vec<_> = r.export_usages[1]
.reference_locations
.iter()
.map(|l| l.path.to_string_lossy().to_string())
.collect();
assert_eq!(refs, vec!["a.ts", "c.ts"]);
}
#[test]
fn sort_empty_results_is_noop() {
let mut r = AnalysisResults::default();
r.sort(); assert_eq!(r.total_issues(), 0);
}
#[test]
fn sort_single_element_lists_stable() {
let mut r = AnalysisResults::default();
r.unused_files.push(UnusedFile {
path: PathBuf::from("only.ts"),
});
r.sort();
assert_eq!(r.unused_files[0].path, PathBuf::from("only.ts"));
}
#[test]
fn serialize_empty_results() {
let r = AnalysisResults::default();
let json = serde_json::to_value(&r).unwrap();
assert!(json["unused_files"].as_array().unwrap().is_empty());
assert!(json["unused_exports"].as_array().unwrap().is_empty());
assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
assert!(json.get("export_usages").is_none());
assert!(json.get("entry_point_summary").is_none());
}
#[test]
fn serialize_unused_file_path() {
let r = UnusedFile {
path: PathBuf::from("src/utils/index.ts"),
};
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["path"], "src/utils/index.ts");
}
#[test]
fn serialize_dependency_location_camel_case() {
let dep = UnusedDependency {
package_name: "react".to_string(),
location: DependencyLocation::DevDependencies,
path: PathBuf::from("package.json"),
line: 5,
};
let json = serde_json::to_value(&dep).unwrap();
assert_eq!(json["location"], "devDependencies");
let dep2 = UnusedDependency {
package_name: "react".to_string(),
location: DependencyLocation::Dependencies,
path: PathBuf::from("package.json"),
line: 3,
};
let json2 = serde_json::to_value(&dep2).unwrap();
assert_eq!(json2["location"], "dependencies");
let dep3 = UnusedDependency {
package_name: "fsevents".to_string(),
location: DependencyLocation::OptionalDependencies,
path: PathBuf::from("package.json"),
line: 7,
};
let json3 = serde_json::to_value(&dep3).unwrap();
assert_eq!(json3["location"], "optionalDependencies");
}
#[test]
fn serialize_circular_dependency_skips_false_cross_package() {
let cd = CircularDependency {
files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
length: 2,
line: 1,
col: 0,
is_cross_package: false,
};
let json = serde_json::to_value(&cd).unwrap();
assert!(json.get("is_cross_package").is_none());
}
#[test]
fn serialize_circular_dependency_includes_true_cross_package() {
let cd = CircularDependency {
files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
length: 2,
line: 1,
col: 0,
is_cross_package: true,
};
let json = serde_json::to_value(&cd).unwrap();
assert_eq!(json["is_cross_package"], true);
}
#[test]
fn serialize_unused_export_fields() {
let e = UnusedExport {
path: PathBuf::from("src/mod.ts"),
export_name: "helper".to_string(),
is_type_only: true,
line: 42,
col: 7,
span_start: 100,
is_re_export: true,
};
let json = serde_json::to_value(&e).unwrap();
assert_eq!(json["path"], "src/mod.ts");
assert_eq!(json["export_name"], "helper");
assert_eq!(json["is_type_only"], true);
assert_eq!(json["line"], 42);
assert_eq!(json["col"], 7);
assert_eq!(json["span_start"], 100);
assert_eq!(json["is_re_export"], true);
}
#[test]
fn serialize_boundary_violation_fields() {
let v = BoundaryViolation {
from_path: PathBuf::from("src/ui/button.tsx"),
to_path: PathBuf::from("src/db/queries.ts"),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
import_specifier: "../db/queries".to_string(),
line: 3,
col: 0,
};
let json = serde_json::to_value(&v).unwrap();
assert_eq!(json["from_path"], "src/ui/button.tsx");
assert_eq!(json["to_path"], "src/db/queries.ts");
assert_eq!(json["from_zone"], "ui");
assert_eq!(json["to_zone"], "db");
assert_eq!(json["import_specifier"], "../db/queries");
}
#[test]
fn serialize_unlisted_dependency_with_import_sites() {
let d = UnlistedDependency {
package_name: "chalk".to_string(),
imported_from: vec![
ImportSite {
path: PathBuf::from("a.ts"),
line: 1,
col: 0,
},
ImportSite {
path: PathBuf::from("b.ts"),
line: 5,
col: 3,
},
],
};
let json = serde_json::to_value(&d).unwrap();
assert_eq!(json["package_name"], "chalk");
let sites = json["imported_from"].as_array().unwrap();
assert_eq!(sites.len(), 2);
assert_eq!(sites[0]["path"], "a.ts");
assert_eq!(sites[1]["line"], 5);
}
#[test]
fn serialize_duplicate_export_with_locations() {
let d = DuplicateExport {
export_name: "Button".to_string(),
locations: vec![
DuplicateLocation {
path: PathBuf::from("src/a.ts"),
line: 10,
col: 0,
},
DuplicateLocation {
path: PathBuf::from("src/b.ts"),
line: 20,
col: 5,
},
],
};
let json = serde_json::to_value(&d).unwrap();
assert_eq!(json["export_name"], "Button");
let locs = json["locations"].as_array().unwrap();
assert_eq!(locs.len(), 2);
assert_eq!(locs[0]["line"], 10);
assert_eq!(locs[1]["col"], 5);
}
#[test]
fn serialize_type_only_dependency() {
let d = TypeOnlyDependency {
package_name: "@types/react".to_string(),
path: PathBuf::from("package.json"),
line: 12,
};
let json = serde_json::to_value(&d).unwrap();
assert_eq!(json["package_name"], "@types/react");
assert_eq!(json["line"], 12);
}
#[test]
fn serialize_test_only_dependency() {
let d = TestOnlyDependency {
package_name: "vitest".to_string(),
path: PathBuf::from("package.json"),
line: 8,
};
let json = serde_json::to_value(&d).unwrap();
assert_eq!(json["package_name"], "vitest");
assert_eq!(json["line"], 8);
}
#[test]
fn serialize_unused_member() {
let m = UnusedMember {
path: PathBuf::from("enums.ts"),
parent_name: "Status".to_string(),
member_name: "Pending".to_string(),
kind: MemberKind::EnumMember,
line: 3,
col: 4,
};
let json = serde_json::to_value(&m).unwrap();
assert_eq!(json["parent_name"], "Status");
assert_eq!(json["member_name"], "Pending");
assert_eq!(json["line"], 3);
}
#[test]
fn serialize_unresolved_import() {
let i = UnresolvedImport {
path: PathBuf::from("app.ts"),
specifier: "./missing-module".to_string(),
line: 7,
col: 0,
specifier_col: 21,
};
let json = serde_json::to_value(&i).unwrap();
assert_eq!(json["specifier"], "./missing-module");
assert_eq!(json["specifier_col"], 21);
}
#[test]
fn deserialize_circular_dependency_with_defaults() {
let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
let cd: CircularDependency = serde_json::from_str(json).unwrap();
assert_eq!(cd.files.len(), 2);
assert_eq!(cd.length, 2);
assert_eq!(cd.line, 0);
assert_eq!(cd.col, 0);
assert!(!cd.is_cross_package);
}
#[test]
fn deserialize_circular_dependency_with_all_fields() {
let json =
r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
let cd: CircularDependency = serde_json::from_str(json).unwrap();
assert_eq!(cd.line, 5);
assert_eq!(cd.col, 10);
assert!(cd.is_cross_package);
}
#[test]
fn clone_results_are_independent() {
let mut r = AnalysisResults::default();
r.unused_files.push(UnusedFile {
path: PathBuf::from("a.ts"),
});
let mut cloned = r.clone();
cloned.unused_files.push(UnusedFile {
path: PathBuf::from("b.ts"),
});
assert_eq!(r.total_issues(), 1);
assert_eq!(cloned.total_issues(), 2);
}
#[test]
fn export_usages_not_counted_in_total_issues() {
let mut r = AnalysisResults::default();
r.export_usages.push(ExportUsage {
path: PathBuf::from("mod.ts"),
export_name: "foo".to_string(),
line: 1,
col: 0,
reference_count: 3,
reference_locations: vec![],
});
assert_eq!(r.total_issues(), 0);
assert!(!r.has_issues());
}
#[test]
fn entry_point_summary_not_counted_in_total_issues() {
let r = AnalysisResults {
entry_point_summary: Some(EntryPointSummary {
total: 10,
by_source: vec![("config".to_string(), 10)],
}),
..AnalysisResults::default()
};
assert_eq!(r.total_issues(), 0);
assert!(!r.has_issues());
}
}