use std::path::{Path, PathBuf};
use rustc_hash::{FxHashMap, FxHashSet};
use serde::Serialize;
use crate::cache_rejection::CacheRejection;
use crate::duplicates::{CloneInstance, RefactoringSuggestion};
use crate::semantic::SemanticNamespace;
use crate::serde_path;
use crate::trace_chain::StarExportAmbiguity;
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ExportTrace {
#[serde(serialize_with = "serde_path::serialize")]
pub file: PathBuf,
pub export_name: String,
#[cfg_attr(feature = "schema", schemars(default))]
pub namespace: crate::semantic::SemanticNamespace,
pub file_reachable: bool,
pub is_entry_point: bool,
pub is_used: bool,
pub direct_references: Vec<ExportReference>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub direct_references_by_namespace: Vec<NamespacedExportReferences>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub star_export_ambiguity: Option<StarExportAmbiguity>,
pub re_export_chains: Vec<ReExportChain>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub plugin_credits: Vec<PluginExportCredit>,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<crate::semantic::SemanticSymbolTrace>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ClassMemberTrace {
#[serde(serialize_with = "serde_path::serialize")]
pub file: PathBuf,
pub member_name: String,
pub member_kind: String,
pub owner_export: String,
#[cfg_attr(feature = "schema", schemars(default))]
pub owner_namespace: crate::semantic::SemanticNamespace,
pub owner_is_used: bool,
pub owner_file_reachable: bool,
pub owner_is_entry_point: bool,
pub owner_direct_references: Vec<ExportReference>,
pub owner_re_export_chains: Vec<ReExportChain>,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<crate::semantic::SemanticSymbolTrace>,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ExportReference {
#[serde(serialize_with = "serde_path::serialize")]
pub from_file: PathBuf,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_visibility: Option<SearchVisibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<SearchGapReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_as: Option<String>,
}
impl ExportReference {
#[must_use]
pub const fn new(from_file: PathBuf, kind: String) -> Self {
Self {
from_file,
kind,
line: None,
search_visibility: None,
reason: None,
search_as: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SearchVisibility {
Found,
Renamed,
Missed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SearchGapReason {
ImportAlias,
DefaultImport,
ReExportAlias,
NamespaceUse,
DynamicImport,
PassThrough,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct NamespacedExportReferences {
pub namespace: SemanticNamespace,
pub reference_count: usize,
pub references: Vec<ExportReference>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ReExportChain {
#[serde(serialize_with = "serde_path::serialize")]
pub barrel_file: PathBuf,
pub exported_as: String,
pub reference_count: usize,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct FileTrace {
#[serde(serialize_with = "serde_path::serialize")]
pub file: PathBuf,
pub is_reachable: bool,
pub is_entry_point: bool,
pub exports: Vec<TracedExport>,
#[serde(serialize_with = "serde_path::serialize_vec")]
pub imports_from: Vec<PathBuf>,
#[serde(serialize_with = "serde_path::serialize_vec")]
pub imported_by: Vec<PathBuf>,
pub re_exports: Vec<TracedReExport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<TraceSource>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TraceProvenance {
files: Vec<(PathBuf, TraceSource)>,
dependencies: Vec<(String, TraceSource)>,
tooling_credits: Vec<(String, ToolingCredit)>,
config_exports: FxHashMap<PathBuf, FxHashSet<String>>,
export_credits: FxHashMap<PathBuf, FxHashMap<String, Vec<PluginExportCredit>>>,
peer_seeds: PeerClosureSeeds,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PeerClosureSeeds {
shared: FxHashSet<String>,
by_manifest: FxHashMap<PathBuf, FxHashSet<String>>,
}
impl PeerClosureSeeds {
#[must_use]
pub fn from_shared(names: impl IntoIterator<Item = String>) -> Self {
Self {
shared: names.into_iter().collect(),
by_manifest: FxHashMap::default(),
}
}
pub fn push_manifest_seed(&mut self, manifest: PathBuf, name: String) {
self.by_manifest.entry(manifest).or_default().insert(name);
}
pub fn for_manifest<'a>(&'a self, manifest: &Path) -> impl Iterator<Item = &'a str> {
self.shared
.iter()
.chain(self.by_manifest.get(manifest).into_iter().flatten())
.map(String::as_str)
}
}
impl TraceProvenance {
pub fn push_config_export(&mut self, file: PathBuf, name: String) {
self.config_exports.entry(file).or_default().insert(name);
}
#[must_use]
pub fn is_config_export(&self, file: &Path, name: &str) -> bool {
self.config_exports
.get(file)
.is_some_and(|names| names.contains(name))
}
pub fn push_export_credits(
&mut self,
file: PathBuf,
credits: impl IntoIterator<Item = (String, PluginExportCredit)>,
) {
let by_name = self.export_credits.entry(file).or_default();
for (name, credit) in credits {
let known = by_name.entry(name).or_default();
if !known.contains(&credit) {
known.push(credit);
}
}
}
#[must_use]
pub fn export_credits(&self, file: &Path, name: &str) -> Vec<PluginExportCredit> {
self.export_credits
.get(file)
.and_then(|by_name| by_name.get(name))
.cloned()
.unwrap_or_default()
}
pub fn push_file(&mut self, file: PathBuf, source: TraceSource) {
if !self
.files
.iter()
.any(|(known, known_source)| *known == file && *known_source == source)
{
self.files.push((file, source));
}
}
pub fn push_dependency(&mut self, name: String, source: TraceSource) {
if !self
.dependencies
.iter()
.any(|(known, known_source)| *known == name && *known_source == source)
{
self.dependencies.push((name, source));
}
}
#[must_use]
pub fn file_sources(&self, file: &std::path::Path) -> Vec<TraceSource> {
self.files
.iter()
.filter(|(known, _)| known == file)
.map(|(_, source)| source.clone())
.collect()
}
#[must_use]
pub fn dependency_sources(&self, name: &str) -> Vec<TraceSource> {
self.dependencies
.iter()
.filter(|(known, _)| known == name)
.map(|(_, source)| source.clone())
.collect()
}
pub fn push_tooling_credit(&mut self, name: String, credit: ToolingCredit) {
if !self.tooling_credits.iter().any(|(known, _)| *known == name) {
self.tooling_credits.push((name, credit));
}
}
#[must_use]
pub fn tooling_credit(&self, name: &str) -> Option<ToolingCredit> {
self.tooling_credits
.iter()
.find(|(known, _)| known == name)
.map(|(_, credit)| credit.clone())
}
#[must_use]
pub fn with_peer_seeds(mut self, seeds: PeerClosureSeeds) -> Self {
self.peer_seeds = seeds;
self
}
#[must_use]
pub const fn peer_seeds(&self) -> &PeerClosureSeeds {
&self.peer_seeds
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ToolingCredit {
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin: Option<String>,
#[serde(
serialize_with = "serde_path::serialize_option",
default,
skip_serializing_if = "Option::is_none"
)]
pub config: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TraceSource {
pub kind: String,
pub plugin: String,
#[serde(serialize_with = "serde_path::serialize")]
pub config: PathBuf,
pub key: String,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TracedExport {
pub name: String,
pub is_type_only: bool,
pub reference_count: usize,
pub referenced_by: Vec<ExportReference>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub plugin_credits: Vec<PluginExportCredit>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PluginExportCredit {
pub plugin: String,
pub pattern: String,
}
impl ExportTrace {
pub fn apply_plugin_credits(&mut self, provenance: &TraceProvenance) {
let credits = provenance.export_credits(&self.file, &self.export_name);
if credits.is_empty() {
return;
}
if self.file_reachable && !self.is_used && self.star_export_ambiguity.is_none() {
let mut plugins: Vec<&str> = Vec::new();
for credit in &credits {
if !plugins.contains(&credit.plugin.as_str()) {
plugins.push(&credit.plugin);
}
}
self.is_used = true;
self.reason = format!(
"Used by the framework: the {} plugin marks this export as used",
plugins.join(", ")
);
}
self.plugin_credits = credits;
}
}
impl FileTrace {
pub fn apply_plugin_credits(&mut self, provenance: &TraceProvenance) {
for export in &mut self.exports {
export.plugin_credits = provenance.export_credits(&self.file, &export.name);
}
}
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TracedReExport {
#[serde(serialize_with = "serde_path::serialize")]
pub source_file: PathBuf,
pub imported_name: String,
pub exported_name: String,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DependencyTrace {
pub package_name: String,
#[serde(serialize_with = "serde_path::serialize_vec")]
pub imported_by: Vec<PathBuf>,
#[serde(serialize_with = "serde_path::serialize_vec")]
pub type_only_imported_by: Vec<PathBuf>,
pub used_in_scripts: bool,
pub is_used: bool,
pub import_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peer_of: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<TraceSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tooling_credit: Option<ToolingCredit>,
#[serde(
default,
serialize_with = "serde_path::serialize_vec",
skip_serializing_if = "Vec::is_empty"
)]
pub unused_in: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<crate::trace_usage::DependencyUsage>,
}
impl DependencyTrace {
pub fn apply_unused_declarations(
&mut self,
results: &crate::results::AnalysisResults,
root: &Path,
) {
let flagged = results
.unused_dependencies
.iter()
.map(|finding| &finding.dep)
.chain(
results
.unused_dev_dependencies
.iter()
.map(|finding| &finding.dep),
)
.chain(
results
.unused_optional_dependencies
.iter()
.map(|finding| &finding.dep),
)
.filter(|dep| dep.package_name == self.package_name)
.map(|dep| {
dep.path
.strip_prefix(root)
.unwrap_or(&dep.path)
.to_path_buf()
});
self.unused_in.extend(flagged);
self.unused_in.sort();
self.unused_in.dedup();
}
pub fn apply_tooling_credit(&mut self, credit: Option<ToolingCredit>) {
if self.is_used {
return;
}
if let Some(credit) = credit {
self.is_used = true;
self.tooling_credit = Some(credit);
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EntryPointSpans {
pub root_ms: f64,
pub workspaces_ms: f64,
pub plugins_ms: f64,
pub plugin_glob_build_ms: f64,
pub plugin_glob_match_ms: f64,
pub infrastructure_ms: f64,
pub dynamic_ms: f64,
pub dedup_ms: f64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PipelineCounters {
pub files_read: u64,
pub source_bytes_read: u64,
pub parse_cache_bytes_read: u64,
pub css_masked_bytes: u64,
pub resolve_specifier_calls: u64,
pub unique_specifiers: u64,
pub oxc_resolve_calls: u64,
pub canonicalize_calls: u64,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PipelineTimings {
pub discover_files_ms: f64,
pub file_count: usize,
pub workspaces_ms: f64,
pub workspace_count: usize,
pub plugins_ms: f64,
pub script_analysis_ms: f64,
pub parse_extract_ms: f64,
pub parse_cpu_ms: f64,
pub parse_cache_load_ms: f64,
pub module_count: usize,
pub cache_hits: usize,
pub cache_misses: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_rejection: Option<CacheRejection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_cache_rejection: Option<CacheRejection>,
pub cache_update_ms: f64,
pub entry_points_ms: f64,
pub entry_point_spans: EntryPointSpans,
pub entry_point_count: usize,
pub resolve_imports_ms: f64,
pub build_graph_ms: f64,
pub analyze_ms: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duplication_ms: Option<f64>,
pub total_ms: f64,
pub counters: PipelineCounters,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ImpactClosureTrace {
pub seed: String,
pub affected_not_shown: Vec<String>,
pub coordination_gap: Vec<ImpactClosureGap>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum ImportPathTraceSchemaVersion {
#[serde(rename = "1")]
V1,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(title = "fallow trace --path"))]
pub struct ImportPathTrace {
pub schema_version: ImportPathTraceSchemaVersion,
pub from: String,
pub to: String,
pub reachable: bool,
pub hops: usize,
pub path: Vec<ImportPathHop>,
pub reason: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ImportPathHop {
pub from: String,
pub to: String,
pub type_only: bool,
pub dynamic: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_line: Option<u32>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ImpactClosureGap {
pub consumer_file: String,
pub consumed_symbols: Vec<String>,
pub note: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_visibility: Option<SearchVisibility>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<SearchGapReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_as: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_symbol: Option<String>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneTrace {
#[serde(serialize_with = "serde_path::serialize")]
pub file: PathBuf,
pub line: usize,
pub matched_instance: Option<CloneInstance>,
pub clone_groups: Vec<TracedCloneGroup>,
}
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TracedCloneGroup {
pub fingerprint: String,
pub token_count: usize,
pub line_count: usize,
pub spread: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schema", schemars(with = "f64"))]
pub similarity: Option<f64>,
pub instances: Vec<CloneInstance>,
pub suggestion: RefactoringSuggestion,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_name: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn unreferenced_trace(file_reachable: bool) -> ExportTrace {
ExportTrace {
file: PathBuf::from("app/page.tsx"),
export_name: "default".to_string(),
namespace: SemanticNamespace::Value,
file_reachable,
is_entry_point: true,
is_used: false,
direct_references: Vec::new(),
direct_references_by_namespace: Vec::new(),
star_export_ambiguity: None,
re_export_chains: Vec::new(),
plugin_credits: Vec::new(),
reason: "No references".to_string(),
semantic: None,
}
}
fn page_provenance() -> TraceProvenance {
let mut provenance = TraceProvenance::default();
let credit = PluginExportCredit {
plugin: "nextjs".to_string(),
pattern: "app/**/page.tsx".to_string(),
};
provenance.push_export_credits(
PathBuf::from("app/page.tsx"),
[
("default".to_string(), credit.clone()),
("default".to_string(), credit),
],
);
provenance
}
#[test]
fn a_plugin_credit_marks_an_export_of_a_reachable_file_as_used() {
let mut trace = unreferenced_trace(true);
trace.apply_plugin_credits(&page_provenance());
assert!(trace.is_used);
assert_eq!(trace.plugin_credits.len(), 1);
assert!(trace.reason.contains("nextjs"), "{}", trace.reason);
}
#[test]
fn a_plugin_credit_does_not_mark_an_unreachable_file_as_used() {
let mut trace = unreferenced_trace(false);
trace.apply_plugin_credits(&page_provenance());
assert!(!trace.is_used);
assert_eq!(trace.plugin_credits.len(), 1);
assert_eq!(trace.reason, "No references");
}
#[test]
fn a_star_export_collision_keeps_its_abstention() {
let mut trace = unreferenced_trace(true);
trace.star_export_ambiguity = Some(StarExportAmbiguity {
sources: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
namespaces: vec![SemanticNamespace::Value],
});
trace.apply_plugin_credits(&page_provenance());
assert!(!trace.is_used);
}
#[test]
fn an_export_without_a_credit_keeps_its_trace() {
let mut trace = unreferenced_trace(true);
trace.export_name = "helper".to_string();
trace.apply_plugin_credits(&page_provenance());
assert!(!trace.is_used);
assert!(trace.plugin_credits.is_empty());
}
}