mod coverage;
mod coverage_intelligence;
mod finding;
mod grouped;
mod runtime_coverage;
mod scores;
mod targets;
mod trends;
mod vital_signs;
pub use coverage::*;
pub use coverage_intelligence::*;
pub use finding::*;
pub use grouped::*;
pub use runtime_coverage::*;
pub use scores::*;
pub use targets::*;
pub use trends::*;
pub use vital_signs::*;
use fallow_types::output_dead_code::PropDrillingChainFinding;
#[derive(Debug, Clone, serde::Serialize)]
pub struct HealthTimings {
pub config_ms: f64,
pub discover_ms: f64,
pub parse_ms: f64,
pub parse_cpu_ms: f64,
pub complexity_ms: f64,
pub file_scores_ms: f64,
pub git_churn_ms: f64,
pub git_churn_cache_hit: bool,
pub hotspots_ms: f64,
pub duplication_ms: f64,
pub targets_ms: f64,
pub total_ms: f64,
pub shared_parse: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct HealthActionsMeta {
pub suppression_hints_omitted: bool,
pub reason: String,
pub scope: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssAnalyticsReport {
pub files: Vec<CssFileAnalytics>,
pub summary: CssAnalyticsSummary,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scoped_unused: Vec<ScopedUnusedClasses>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unreferenced_keyframes: Vec<UnreferencedKeyframes>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub undefined_keyframes: Vec<UndefinedKeyframes>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub duplicate_declaration_blocks: Vec<CssDuplicateBlock>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tailwind_arbitrary_values: Vec<TailwindArbitraryValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unused_at_rules: Vec<UnusedAtRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unresolved_class_references: Vec<UnresolvedClassReference>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unreferenced_css_classes: Vec<UnreferencedCssClass>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unused_font_faces: Vec<UnusedFontFace>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unused_theme_tokens: Vec<UnusedThemeToken>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size_unit_mix: Option<CssNotationConsistency>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssNotationConsistency {
pub axis: String,
pub notations: Vec<CssNotationCount>,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssNotationCount {
pub notation: String,
pub count: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedAtRule {
#[serde(rename = "type")]
pub kind: UnusedAtRuleKind,
pub name: String,
pub path: String,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
#[repr(u8)]
pub enum UnusedAtRuleKind {
PropertyRegistration,
Layer,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TailwindArbitraryValue {
pub value: String,
pub count: u32,
pub path: String,
pub line: u32,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssDuplicateBlock {
pub declaration_count: u16,
pub occurrence_count: u32,
pub estimated_savings: u32,
pub occurrences: Vec<CssBlockOccurrence>,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssBlockOccurrence {
pub path: String,
pub line: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnreferencedKeyframes {
pub name: String,
pub path: String,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedFontFace {
pub family: String,
pub path: String,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedThemeToken {
pub token: String,
pub namespace: String,
pub path: String,
pub line: u32,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnreferencedCssClass {
pub class: String,
pub path: String,
pub line: u32,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UndefinedKeyframes {
pub name: String,
pub path: String,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnresolvedClassReference {
pub class: String,
pub suggestion: String,
pub path: String,
pub line: u32,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ScopedUnusedClasses {
pub path: String,
pub classes: Vec<String>,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssCandidateAction {
#[serde(rename = "type")]
pub kind: CssCandidateActionType,
pub auto_fixable: bool,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CssCandidateActionType {
VerifyUnused,
VerifyUndefined,
Consolidate,
ReplaceWithToken,
Standardize,
}
impl CssCandidateAction {
#[must_use]
pub fn verify_unused_font_face(family: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description: format!(
"Confirm the \"{family}\" font family is not applied from an inline style or JavaScript before removing the @font-face and its font files."
),
command: safe_token_search(family),
}
}
#[must_use]
pub fn verify_unused_theme_token(token: &str, namespace: &str, name: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description: format!(
"Confirm the {token} @theme token is used by nothing, no `*-{name}` utility (e.g. `bg-{name}` / `text-{name}` / `{namespace}-{name}`) in markup or @apply, no `var({token})` read in any stylesheet or JS, and no arbitrary `[{token}]` value, before removing it from the @theme block."
),
command: theme_token_search(namespace, name),
}
}
#[must_use]
pub fn verify_unreferenced_class(name: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description: format!(
"Confirm no HTML email, server-rendered template, CMS content, or Markdown applies the \"{name}\" class before removing it (fallow scanned only in-project JS/TS/HTML/Vue/Svelte/Astro markup)."
),
command: safe_token_search(name),
}
}
#[must_use]
pub fn verify_keyframe(name: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description: format!(
"Confirm no JavaScript or template applies the \"{name}\" animation before removing the @keyframes."
),
command: safe_token_search(name),
}
}
#[must_use]
pub fn verify_undefined_keyframe(name: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUndefined,
auto_fixable: false,
description: format!(
"Confirm \"{name}\" is not a @keyframes defined in CSS-in-JS (styled-components, Emotion, vanilla-extract) before treating the animation reference as a typo."
),
command: safe_token_search(name),
}
}
#[must_use]
pub fn standardize_notation(axis: &str, dominant: &str) -> Self {
Self {
kind: CssCandidateActionType::Standardize,
auto_fixable: false,
description: format!(
"{axis} are authored in several notations; standardize on one ({dominant} is the most common) so the scale is a single source of truth, unless this is an intentional migration in progress."
),
command: None,
}
}
#[must_use]
pub fn consolidate_block(occurrence_count: u32) -> Self {
Self {
kind: CssCandidateActionType::Consolidate,
auto_fixable: false,
description: format!(
"Extract this declaration block into one rule and reference it from all {occurrence_count} occurrences, unless they are intentionally separate overrides."
),
command: None,
}
}
#[must_use]
pub fn replace_arbitrary_value(value: &str) -> Self {
let command = (!value.contains('\'')).then(|| {
format!(
"grep -rnF '{value}' --include='*.jsx' --include='*.tsx' --include='*.html' --include='*.vue' --include='*.svelte' --include='*.astro' ."
)
});
Self {
kind: CssCandidateActionType::ReplaceWithToken,
auto_fixable: false,
description:
"Replace this one-off arbitrary value with a scale token from your Tailwind theme, or confirm it is intentional."
.to_string(),
command,
}
}
#[must_use]
pub fn verify_unused_at_rule(kind: UnusedAtRuleKind, name: &str) -> Self {
let description = match kind {
UnusedAtRuleKind::PropertyRegistration => format!(
"Confirm \"{name}\" is not read or set from JavaScript before removing the @property registration."
),
UnusedAtRuleKind::Layer => format!(
"Confirm the @layer \"{name}\" is not populated via @import layer() before removing the declaration."
),
};
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description,
command: safe_token_search(name),
}
}
#[must_use]
pub fn verify_unresolved_class(class: &str, suggestion: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUndefined,
auto_fixable: false,
description: format!(
"\"{class}\" matches no CSS class; did you mean \"{suggestion}\"? Confirm \"{class}\" is not defined in CSS-in-JS or an external stylesheet before fixing the reference."
),
command: safe_token_search(class),
}
}
#[must_use]
pub fn verify_scoped_classes() -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description:
"Confirm none of these scoped classes is assembled from a dynamic string (e.g. `:class=\"prefix + name\"`) before removing them."
.to_string(),
command: None,
}
}
}
fn theme_token_search(namespace: &str, name: &str) -> Option<String> {
let is_plain = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
};
(is_plain(namespace) && is_plain(name)).then(|| {
format!(
"grep -rnE -- '-{name}\\b|--{namespace}-{name}' --include='*.css' --include='*.html' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.astro' ."
)
})
}
fn safe_token_search(name: &str) -> Option<String> {
let is_plain = !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
is_plain.then(|| {
format!(
"grep -rnw '{name}' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.html' ."
)
})
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssFileAnalytics {
pub path: String,
pub analytics: fallow_types::extract::CssAnalytics,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssAnalyticsSummary {
pub files_analyzed: u32,
pub total_rules: u32,
pub total_declarations: u32,
pub important_declarations: u32,
pub empty_rules: u32,
pub max_nesting_depth: u8,
pub unique_colors: u32,
pub unique_font_sizes: u32,
pub unique_z_indexes: u32,
pub unique_box_shadows: u32,
pub unique_border_radii: u32,
pub unique_line_heights: u32,
pub custom_properties_defined: u32,
pub custom_properties_unreferenced: u32,
pub custom_properties_undefined: u32,
pub keyframes_defined: u32,
pub keyframes_unreferenced: u32,
pub keyframes_undefined: u32,
pub scoped_unused_classes: u32,
pub duplicate_declaration_blocks: u32,
pub duplicate_declarations_total: u32,
pub tailwind_arbitrary_values: u32,
pub tailwind_arbitrary_value_uses: u32,
pub unused_property_registrations: u32,
pub unused_layers: u32,
pub unresolved_class_references: u32,
pub unreferenced_css_classes: u32,
pub unused_font_faces: u32,
pub unused_theme_tokens: u32,
pub font_size_units_used: u32,
pub notable_truncated_files: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct HealthReport {
pub findings: Vec<HealthFinding>,
pub summary: HealthSummary,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub threshold_overrides: Vec<ThresholdOverrideState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vital_signs: Option<VitalSigns>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_score: Option<HealthScore>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub file_scores: Vec<FileHealthScore>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage_gaps: Option<CoverageGaps>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prop_drilling_chains: Vec<PropDrillingChainFinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hotspots: Vec<HotspotFinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hotspot_summary: Option<HotspotSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_coverage: Option<RuntimeCoverageReport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage_intelligence: Option<CoverageIntelligenceReport>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub large_functions: Vec<LargeFunctionEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub targets: Vec<RefactoringTargetFinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_thresholds: Option<TargetThresholds>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_trend: Option<HealthTrend>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actions_meta: Option<HealthActionsMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub css_analytics: Option<CssAnalyticsReport>,
#[serde(skip)]
pub render_fan_in_top: rustc_hash::FxHashMap<std::path::PathBuf, (String, u32)>,
}
#[cfg(test)]
#[expect(
clippy::derivable_impls,
reason = "test-only Default with custom HealthSummary thresholds (20/15)"
)]
impl Default for HealthReport {
fn default() -> Self {
Self {
findings: vec![],
summary: HealthSummary::default(),
threshold_overrides: vec![],
vital_signs: None,
health_score: None,
file_scores: vec![],
coverage_gaps: None,
prop_drilling_chains: vec![],
hotspots: vec![],
hotspot_summary: None,
runtime_coverage: None,
coverage_intelligence: None,
large_functions: vec![],
targets: vec![],
target_thresholds: None,
health_trend: None,
actions_meta: None,
css_analytics: None,
render_fan_in_top: rustc_hash::FxHashMap::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn health_report_skips_empty_collections() {
let report = HealthReport::default();
let json = serde_json::to_string(&report).unwrap();
assert!(!json.contains("file_scores"));
assert!(!json.contains("hotspots"));
assert!(!json.contains("hotspot_summary"));
assert!(!json.contains("runtime_coverage"));
assert!(!json.contains("coverage_intelligence"));
assert!(!json.contains("large_functions"));
assert!(!json.contains("targets"));
assert!(!json.contains("threshold_overrides"));
assert!(!json.contains("vital_signs"));
assert!(!json.contains("health_score"));
}
#[test]
fn health_score_none_skipped_in_report() {
let report = HealthReport::default();
let json = serde_json::to_string(&report).unwrap();
assert!(!json.contains("health_score"));
}
}