Skip to main content

fallow_cli/health_types/
mod.rs

1//! Health / complexity analysis report types.
2//!
3//! Separated from the `health` command module so that report formatters
4//! (which are compiled as part of both the lib and bin targets) can
5//! reference these types without pulling in binary-only dependencies.
6
7mod coverage;
8mod coverage_intelligence;
9mod finding;
10mod grouped;
11mod runtime_coverage;
12mod scores;
13mod targets;
14mod trends;
15mod vital_signs;
16
17pub use coverage::*;
18pub use coverage_intelligence::*;
19pub use finding::*;
20pub use grouped::*;
21pub use runtime_coverage::*;
22pub use scores::*;
23pub use targets::*;
24pub use trends::*;
25pub use vital_signs::*;
26
27use fallow_types::output_dead_code::PropDrillingChainFinding;
28
29/// Detailed timing breakdown for the health pipeline.
30///
31/// Only populated when `--performance` is passed.
32#[derive(Debug, Clone, serde::Serialize)]
33pub struct HealthTimings {
34    pub config_ms: f64,
35    pub discover_ms: f64,
36    pub parse_ms: f64,
37    /// Summed wall-clock time of the actual AST parses across all rayon
38    /// workers (the parse stage's CPU cost). `parse_ms` is the stage's
39    /// wall-clock time. Observational and non-deterministic; do not assert
40    /// against it. `0.0` when `shared_parse` is true (parse was reused).
41    pub parse_cpu_ms: f64,
42    pub complexity_ms: f64,
43    pub file_scores_ms: f64,
44    pub git_churn_ms: f64,
45    pub git_churn_cache_hit: bool,
46    pub hotspots_ms: f64,
47    pub duplication_ms: f64,
48    pub targets_ms: f64,
49    pub total_ms: f64,
50    /// True when discover + parse were reused from the upstream dead-code
51    /// (check) pass in combined mode, so their timings are `0.0` here and
52    /// the cost is attributed to the `Pipeline Performance` table instead.
53    /// The renderer shows those two stages as `(measured above)`.
54    pub shared_parse: bool,
55}
56
57/// Auditable breadcrumb recording when health-finding `suppress-line`
58/// action hints were omitted from the report.
59///
60/// Set at construction time on [`HealthReport::actions_meta`] (and on
61/// each [`HealthGroup::actions_meta`](crate::health_types::HealthGroup)
62/// when grouped) by the report builder, derived from the active
63/// [`HealthActionContext`]. Lets consumers see "where did the
64/// suppress-line hints go?" without having to grep the config or CLI
65/// history.
66///
67/// Stable `reason` codes:
68/// - `baseline-active`: a baseline is active and inline ignores would
69///   become dead annotations once the baseline regenerates.
70/// - `config-disabled`: `health.suggestInlineSuppression` is `false`.
71/// - `unspecified`: the caller did not record a reason.
72#[derive(Debug, Clone, serde::Serialize)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct HealthActionsMeta {
75    /// Always `true` when the breadcrumb is emitted. Absent from the wire
76    /// when no suppression occurred.
77    pub suppression_hints_omitted: bool,
78    /// Stable code describing why the suppression occurred.
79    pub reason: String,
80    /// Scope of the omission. Always `"health-findings"` today.
81    pub scope: String,
82}
83
84/// Structural CSS analytics surfaced by `fallow health --css`.
85#[derive(Debug, Clone, serde::Serialize)]
86#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
87pub struct CssAnalyticsReport {
88    /// Stylesheets with at least one structurally notable rule, in scan order.
89    pub files: Vec<CssFileAnalytics>,
90    /// Project-wide CSS aggregates across every analyzed stylesheet.
91    pub summary: CssAnalyticsSummary,
92    /// Vue SFCs whose `<style scoped>` defines classes used nowhere else in the
93    /// component (cleanup candidates).
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub scoped_unused: Vec<ScopedUnusedClasses>,
96    /// `@keyframes` defined but referenced via no `animation` / `animation-name`
97    /// in any stylesheet, with the stylesheet that defines them (cleanup
98    /// candidates; an animation name can still be applied from JavaScript).
99    /// The "defined-but-unused" direction.
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub unreferenced_keyframes: Vec<UnreferencedKeyframes>,
102    /// Animation references (`animation` / `animation-name`) to a `@keyframes`
103    /// name that is defined in NO stylesheet anywhere in the project, with the
104    /// first stylesheet that references them. The "used-but-undefined" direction
105    /// (the inverse of `unreferenced_keyframes`): usually a typo or a removed
106    /// animation, occasionally a `@keyframes` defined in CSS-in-JS (which the
107    /// CSS parser never sees). Conservative candidates, never gated findings.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub undefined_keyframes: Vec<UndefinedKeyframes>,
110    /// Groups of style rules across the project that share an identical
111    /// declaration block (4+ declarations, sorted and `!important`-aware),
112    /// grouped by content: copy-paste consolidation candidates (fallow's
113    /// duplication signal applied to CSS). Sorted by estimated savings
114    /// descending.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub duplicate_declaration_blocks: Vec<CssDuplicateBlock>,
117    /// Tailwind arbitrary-value utilities (`w-[13px]`, `bg-[#abc]`) found in
118    /// markup, which hardcode a one-off value instead of a configured scale
119    /// token (design-token bypass). Present only when the project uses Tailwind.
120    /// Sorted by use count descending. Candidates, not findings: an arbitrary
121    /// value is sometimes the right call.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub tailwind_arbitrary_values: Vec<TailwindArbitraryValue>,
124    /// Unused CSS at-rule entities: an `@property` registered but never read via
125    /// `var()` in any stylesheet, or an `@layer` declared but never populated by
126    /// a block. Cleanup candidates (an `@property` can be read from JS; a layer
127    /// can be populated via `@import layer()`). Located by first definition.
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub unused_at_rules: Vec<UnusedAtRule>,
130    /// Static `class` / `className` tokens in markup that match no CSS class
131    /// defined anywhere in the project AND are one edit away from a class that
132    /// IS defined (a likely typo or stale rename, with the suggested class). The
133    /// CSS analogue of an unresolved import; the near-miss restriction keeps it
134    /// near-zero false-positive (Tailwind utilities and third-party classes are
135    /// not one edit from an authored class). Candidates, never gated: the token
136    /// could be defined in CSS-in-JS or an external stylesheet the parser never
137    /// sees. Sorted by `(path, line, class)`.
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub unresolved_class_references: Vec<UnresolvedClassReference>,
140    /// Global CSS classes (defined in a plain `.css`/`.scss` rule) whose literal
141    /// name is referenced by NO in-project markup, static or dynamic (the CSS
142    /// analogue of an unused export). Heavily gated to stay near-zero-false-
143    /// positive: emitted only when the project is plain-CSS-dominant, the
144    /// stylesheet is locally consumed (not a published design-system surface),
145    /// and the whole project is in scope. Candidates, never gated findings: the
146    /// class may be used by an HTML email, server template, CMS, or Markdown the
147    /// parser never scans. Sorted by `(path, line, class)`.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub unreferenced_css_classes: Vec<UnreferencedCssClass>,
150    /// `@font-face` families declared in a stylesheet but referenced by no
151    /// `font-family` anywhere in the project: a dead web-font payload (the font
152    /// file is downloaded but never applied). Located at the declaring
153    /// stylesheet. Cleanup candidates: the family could be applied from inline
154    /// styles or set via JavaScript. Sorted by `(path, family)`.
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub unused_font_faces: Vec<UnusedFontFace>,
157    /// Tailwind v4 `@theme` design tokens (`--color-brand`, `--radius-card`)
158    /// defined in a stylesheet but used by no generated utility, `var()` read,
159    /// `@apply`, or arbitrary value anywhere in the project: dead design tokens
160    /// (the `unused-export` of the token era). Present only when the project is
161    /// Tailwind v4 (a `tailwindcss` dependency plus at least one `@theme` block)
162    /// and not a plugin / published-library / partial-scope run. Candidates,
163    /// never gated findings: the token may be consumed by a Tailwind plugin or a
164    /// downstream repo. Sorted by `(path, line, token)`.
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub unused_theme_tokens: Vec<UnusedThemeToken>,
167    /// The project authors `font-size` values in several units (`px`, `rem`,
168    /// `em`, `%`), with a per-unit distinct-value count: a type-scale
169    /// inconsistency smell (mixing `px` and `rem` for type works against
170    /// user-zoom accessibility). Present only above a conservative floor.
171    /// Advisory candidate, never gated: the spread can be intentional (fixed
172    /// chrome in `px`, body type in `rem`).
173    ///
174    /// Color-notation mixing (hex vs rgb vs hsl) is deliberately NOT surfaced:
175    /// the CSS parser canonicalizes every legacy sRGB notation to hex before
176    /// fallow sees the value, so the authored distinction is already gone and
177    /// cannot be recovered without a separate raw-token pass.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub font_size_unit_mix: Option<CssNotationConsistency>,
180}
181
182/// A design-token notation-consistency candidate: the distinct notations used
183/// across the codebase for one value axis (today, length units on `font-size`),
184/// with a per-notation distinct-value count. Emitted only above a floor, since
185/// mixing notations for one axis is a "no single source of truth" smell.
186/// Advisory: the action is "standardize on one notation", not a single search.
187#[derive(Debug, Clone, serde::Serialize)]
188#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
189pub struct CssNotationConsistency {
190    /// The value axis these notations describe, e.g. `"Colors"` or
191    /// `"Font sizes"`.
192    pub axis: String,
193    /// Per-notation distinct-value counts, sorted by count descending then
194    /// notation name (so the dominant notation is first and ties are stable).
195    pub notations: Vec<CssNotationCount>,
196    /// Read-only guidance step(s), so consumers can iterate `actions` uniformly
197    /// across every candidate type. Always at least one entry.
198    pub actions: Vec<CssCandidateAction>,
199}
200
201/// One notation bucket and the count of distinct values authored in it.
202#[derive(Debug, Clone, serde::Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct CssNotationCount {
205    /// The notation family, e.g. `"hex"`, `"rgb"`, `"hsl"`, `"modern"`, `"px"`,
206    /// `"rem"`, `"em"`, `"%"`.
207    pub notation: String,
208    /// Distinct values authored in this notation across the codebase.
209    pub count: u32,
210}
211
212/// An unused CSS at-rule entity (an `@property` registration with no `var()`
213/// reference, or an `@layer` declaration never populated), located by its first
214/// definition. A cleanup candidate, never a gated finding.
215#[derive(Debug, Clone, serde::Serialize)]
216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217pub struct UnusedAtRule {
218    /// Which kind of at-rule entity is unused.
219    #[serde(rename = "type")]
220    pub kind: UnusedAtRuleKind,
221    /// The entity name (`--x` for `@property`, the layer name for `@layer`).
222    pub name: String,
223    /// Project-root-relative, forward-slash path to the first defining stylesheet.
224    pub path: String,
225    /// Read-only verification step(s) before removal (parity with other findings).
226    pub actions: Vec<CssCandidateAction>,
227}
228
229/// Discriminant for [`UnusedAtRule::kind`].
230#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
231#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
232#[serde(rename_all = "kebab-case")]
233#[repr(u8)]
234pub enum UnusedAtRuleKind {
235    /// An `@property --x { }` registered but never referenced via `var()`.
236    PropertyRegistration,
237    /// An `@layer a` declared (in a statement or named block) but never
238    /// populated by a `@layer a { }` block.
239    Layer,
240}
241
242/// A distinct Tailwind arbitrary-value utility token used in markup, with its
243/// total use count and first location (a design-token-bypass candidate).
244#[derive(Debug, Clone, serde::Serialize)]
245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
246pub struct TailwindArbitraryValue {
247    /// The `prefix-[value]` token (e.g. `w-[13px]`). Variant prefixes are
248    /// stripped, so `hover:w-[13px]` and `w-[13px]` aggregate under `w-[13px]`.
249    pub value: String,
250    /// Total occurrences across all scanned markup files.
251    pub count: u32,
252    /// Project-root-relative, forward-slash path to the first file using it.
253    pub path: String,
254    /// 1-based line of the first occurrence.
255    pub line: u32,
256    /// Read-only action(s): a find-all-occurrences search so the token can be
257    /// replaced with a scale token. Always at least one entry, so consumers can
258    /// iterate `actions` uniformly across every finding type.
259    pub actions: Vec<CssCandidateAction>,
260}
261
262/// A group of style rules across the project that share an identical declaration
263/// block: a copy-paste consolidation candidate (fallow's duplication signal
264/// applied to CSS). Only blocks of 4+ declarations appearing in 2+ rules are
265/// reported, so the signal stays a strong copy-paste indicator rather than
266/// flagging legitimately-repeated small blocks.
267#[derive(Debug, Clone, serde::Serialize)]
268#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
269pub struct CssDuplicateBlock {
270    /// Declarations in the shared block.
271    pub declaration_count: u16,
272    /// Number of rules that share the block (always >= 2).
273    pub occurrence_count: u32,
274    /// Declarations removable by extracting the block into one shared rule:
275    /// `(occurrence_count - 1) * declaration_count`.
276    pub estimated_savings: u32,
277    /// The rules sharing the block, sorted by `(path, line)`.
278    pub occurrences: Vec<CssBlockOccurrence>,
279    /// Read-only guidance step(s), so consumers can iterate `actions`
280    /// uniformly across every finding type. Always at least one entry.
281    pub actions: Vec<CssCandidateAction>,
282}
283
284/// One occurrence of a duplicate declaration block.
285#[derive(Debug, Clone, serde::Serialize)]
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287pub struct CssBlockOccurrence {
288    /// Project-root-relative, forward-slash path to the stylesheet.
289    pub path: String,
290    /// 1-based line of the rule's first selector.
291    pub line: u32,
292}
293
294/// A `@keyframes` defined in a stylesheet but referenced by no animation in any
295/// stylesheet (cleanup candidate).
296#[derive(Debug, Clone, serde::Serialize)]
297#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
298pub struct UnreferencedKeyframes {
299    /// The `@keyframes` name.
300    pub name: String,
301    /// Project-root-relative, forward-slash path to the stylesheet that defines it.
302    pub path: String,
303    /// Read-only verification step(s) an agent can run before removing the
304    /// candidate. Always at least one entry, so consumers can iterate
305    /// `actions` uniformly across every finding type.
306    pub actions: Vec<CssCandidateAction>,
307}
308
309/// An `@font-face` family declared in a stylesheet but referenced by no
310/// `font-family` anywhere in the project: a dead web-font payload. A cleanup
311/// candidate (the family could be applied from inline styles or JavaScript).
312#[derive(Debug, Clone, serde::Serialize)]
313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
314pub struct UnusedFontFace {
315    /// The declared font family name (quotes stripped).
316    pub family: String,
317    /// Project-root-relative, forward-slash path to the declaring stylesheet.
318    pub path: String,
319    /// Read-only verification step(s) before removing. Always at least one entry,
320    /// so consumers can iterate `actions` uniformly across every finding type.
321    pub actions: Vec<CssCandidateAction>,
322}
323
324/// A Tailwind v4 `@theme` design token defined in a stylesheet whose generated
325/// utility, `var()` reads, and arbitrary-value references appear nowhere in the
326/// project: a dead design token (the `unused-export` of the token era). A
327/// candidate, never a gated finding: the token could be consumed by a Tailwind
328/// plugin, a published design-system surface, or a non-CSS-aware build step the
329/// scan cannot see (those cases are gated out before this is emitted).
330#[derive(Debug, Clone, serde::Serialize)]
331#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
332pub struct UnusedThemeToken {
333    /// The full custom property as authored, including the `--` prefix
334    /// (`--color-brand`).
335    pub token: String,
336    /// The Tailwind v4 theme namespace the token belongs to (`color`, `radius`,
337    /// `font-weight`, `breakpoint`, ...).
338    pub namespace: String,
339    /// Project-root-relative, forward-slash path to the declaring stylesheet.
340    pub path: String,
341    /// 1-based line of the token's definition inside the `@theme` block.
342    pub line: u32,
343    /// Read-only verification step(s) before removing. Always at least one entry,
344    /// so consumers can iterate `actions` uniformly across every finding type.
345    pub actions: Vec<CssCandidateAction>,
346}
347
348/// A global CSS class defined in a plain `.css`/`.scss` rule whose literal name
349/// is referenced by no in-project markup (the CSS analogue of an unused export).
350/// A heavily-gated candidate, never a gated finding: the class may be applied
351/// from an HTML email, server template, CMS, or Markdown the parser never sees.
352#[derive(Debug, Clone, serde::Serialize)]
353#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
354pub struct UnreferencedCssClass {
355    /// The class name (no dot).
356    pub class: String,
357    /// Project-root-relative, forward-slash path to the defining stylesheet.
358    pub path: String,
359    /// 1-based line of the class's first definition.
360    pub line: u32,
361    /// Read-only verification step(s) before removing. Always at least one entry,
362    /// so consumers can iterate `actions` uniformly across every finding type.
363    pub actions: Vec<CssCandidateAction>,
364}
365
366/// An animation reference (`animation` / `animation-name`) to a `@keyframes`
367/// name that is defined in no stylesheet anywhere in the project (the
368/// "used-but-undefined" direction). Usually a typo or a removed animation;
369/// occasionally a `@keyframes` defined in CSS-in-JS the CSS parser never sees.
370#[derive(Debug, Clone, serde::Serialize)]
371#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
372pub struct UndefinedKeyframes {
373    /// The referenced `@keyframes` name that resolves to no definition.
374    pub name: String,
375    /// Project-root-relative, forward-slash path to the first stylesheet that
376    /// references it.
377    pub path: String,
378    /// Read-only verification step(s) an agent can run before fixing the
379    /// reference. Always at least one entry, so consumers can iterate `actions`
380    /// uniformly across every finding type.
381    pub actions: Vec<CssCandidateAction>,
382}
383
384/// A static `class` / `className` token in markup that matches no CSS class
385/// defined anywhere in the project but is one edit away from a class that IS
386/// defined (a likely typo or stale rename). The CSS analogue of an unresolved
387/// import. A candidate, never a gated finding: the token could be defined in
388/// CSS-in-JS or an external stylesheet the parser never sees.
389#[derive(Debug, Clone, serde::Serialize)]
390#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
391pub struct UnresolvedClassReference {
392    /// The static class token referenced in markup (no dot).
393    pub class: String,
394    /// The defined CSS class one edit away: the likely intended class.
395    pub suggestion: String,
396    /// Project-root-relative, forward-slash path to the markup file.
397    pub path: String,
398    /// 1-based line of the `class` / `className` attribute.
399    pub line: u32,
400    /// Read-only verification step(s) before fixing the reference. Always at
401    /// least one entry, so consumers can iterate `actions` uniformly across
402    /// every finding type.
403    pub actions: Vec<CssCandidateAction>,
404}
405
406/// A Vue SFC's `<style scoped>` classes that appear nowhere else in the
407/// component (cleanup candidates).
408#[derive(Debug, Clone, serde::Serialize)]
409#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
410pub struct ScopedUnusedClasses {
411    /// Project-root-relative, forward-slash path to the SFC.
412    pub path: String,
413    /// The scoped class names with no use elsewhere in the component, sorted.
414    pub classes: Vec<String>,
415    /// Read-only verification step(s) an agent can run before removing the
416    /// candidate. Always at least one entry, so consumers can iterate
417    /// `actions` uniformly across every finding type.
418    pub actions: Vec<CssCandidateAction>,
419}
420
421/// A read-only verification step attached to a CSS cleanup candidate.
422///
423/// CSS candidates (unreferenced `@keyframes`, unused scoped classes) are never
424/// auto-removed: an animation name can still be applied from JavaScript, and a
425/// class can be assembled from a dynamic string binding. The action gives an
426/// agent a machine-readable next step, mirroring the `actions` array carried by
427/// every other health finding, plus an optional runnable probe to confirm the
428/// candidate is genuinely unused before deleting it.
429#[derive(Debug, Clone, serde::Serialize)]
430#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
431pub struct CssCandidateAction {
432    /// Action type identifier (`verify-unused`).
433    #[serde(rename = "type")]
434    pub kind: CssCandidateActionType,
435    /// Always `false`: CSS candidates are never auto-fixed (`fallow fix` does
436    /// not touch them) because the residual consumer may live outside CSS.
437    pub auto_fixable: bool,
438    /// Human-readable description of what to confirm before removing.
439    pub description: String,
440    /// A runnable, read-only, placeholder-free token search that surfaces any
441    /// out-of-CSS use of the candidate. Absent when no shell-safe command can
442    /// be built (e.g. the residual risk is a dynamic string binding that a
443    /// single search cannot probe), in which case `description` is the guide.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub command: Option<String>,
446}
447
448/// Discriminant for [`CssCandidateAction::kind`].
449#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
450#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
451#[serde(rename_all = "kebab-case")]
452pub enum CssCandidateActionType {
453    /// Confirm the candidate has no JavaScript / HTML / dynamic consumer
454    /// before removing it (the defined-but-unused candidates).
455    VerifyUnused,
456    /// Confirm the referenced name is genuinely undefined (not defined in
457    /// CSS-in-JS the parser cannot see) before treating it as a typo (the
458    /// used-but-undefined candidates).
459    VerifyUndefined,
460    /// Extract the shared declaration block into one rule and reference it from
461    /// each occurrence (the duplicate-declaration-block candidates).
462    Consolidate,
463    /// Replace a Tailwind arbitrary value with a configured scale token, or
464    /// confirm the one-off is intentional (the arbitrary-value candidates).
465    ReplaceWithToken,
466    /// Standardize an inconsistent value axis on a single notation (the
467    /// color-format / length-unit mixing candidates).
468    Standardize,
469}
470
471impl CssCandidateAction {
472    /// Verify action for an unused `@font-face` family: a read-only token search
473    /// for any inline-style or JavaScript application of the family before
474    /// removing the dead web-font.
475    #[must_use]
476    pub fn verify_unused_font_face(family: &str) -> Self {
477        Self {
478            kind: CssCandidateActionType::VerifyUnused,
479            auto_fixable: false,
480            description: format!(
481                "Confirm the \"{family}\" font family is not applied from an inline style or JavaScript before removing the @font-face and its font files."
482            ),
483            command: safe_token_search(family),
484        }
485    }
486
487    /// Verify action for an unused Tailwind v4 `@theme` token: a read-only search
488    /// that embeds the LITERAL terms an agent should grep for, the generated
489    /// utility suffix (`bg-<name>` / `text-<name>` / `<namespace>-<name>`), the
490    /// `var(--<ns>-<name>)` read, and the arbitrary `[--<ns>-<name>]` value,
491    /// before removing the token. Verify-then-remove; never auto-fixable.
492    #[must_use]
493    pub fn verify_unused_theme_token(token: &str, namespace: &str, name: &str) -> Self {
494        Self {
495            kind: CssCandidateActionType::VerifyUnused,
496            auto_fixable: false,
497            description: format!(
498                "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."
499            ),
500            command: theme_token_search(namespace, name),
501        }
502    }
503
504    /// Verify action for an unreferenced global CSS class: name the surfaces the
505    /// in-project scan does NOT cover (the class could be applied from there) and
506    /// ship a read-only token search to double-check before removing.
507    #[must_use]
508    pub fn verify_unreferenced_class(name: &str) -> Self {
509        Self {
510            kind: CssCandidateActionType::VerifyUnused,
511            auto_fixable: false,
512            description: format!(
513                "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)."
514            ),
515            command: safe_token_search(name),
516        }
517    }
518
519    /// Verify action for an unreferenced `@keyframes`: a read-only token search
520    /// for any JavaScript or template reference that applies the animation
521    /// (which the CSS-only scan cannot see).
522    #[must_use]
523    pub fn verify_keyframe(name: &str) -> Self {
524        Self {
525            kind: CssCandidateActionType::VerifyUnused,
526            auto_fixable: false,
527            description: format!(
528                "Confirm no JavaScript or template applies the \"{name}\" animation before removing the @keyframes."
529            ),
530            command: safe_token_search(name),
531        }
532    }
533
534    /// Verify action for an animation reference to a `@keyframes` that is
535    /// defined in no stylesheet: a read-only token search for a CSS-in-JS
536    /// `@keyframes`/animation definition of the name (styled-components,
537    /// Emotion, vanilla-extract) before treating the reference as a typo.
538    #[must_use]
539    pub fn verify_undefined_keyframe(name: &str) -> Self {
540        Self {
541            kind: CssCandidateActionType::VerifyUndefined,
542            auto_fixable: false,
543            description: format!(
544                "Confirm \"{name}\" is not a @keyframes defined in CSS-in-JS (styled-components, Emotion, vanilla-extract) before treating the animation reference as a typo."
545            ),
546            command: safe_token_search(name),
547        }
548    }
549
550    /// Guidance action for a mixed value axis (colors authored in several
551    /// notations, or font sizes in several units): standardize on the single
552    /// dominant notation. No command (this is a project-wide refactor, and the
553    /// per-notation breakdown already quantifies the spread); the residual
554    /// judgment is whether the spread is an intentional migration in progress.
555    #[must_use]
556    pub fn standardize_notation(axis: &str, dominant: &str) -> Self {
557        Self {
558            kind: CssCandidateActionType::Standardize,
559            auto_fixable: false,
560            description: format!(
561                "{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."
562            ),
563            command: None,
564        }
565    }
566
567    /// Guidance action for a duplicate declaration block: consolidate the shared
568    /// declarations into one rule. No command (consolidation is a refactor, and
569    /// the occurrences list already names every site); the residual judgment is
570    /// whether the rules are intentionally separate overrides.
571    #[must_use]
572    pub fn consolidate_block(occurrence_count: u32) -> Self {
573        Self {
574            kind: CssCandidateActionType::Consolidate,
575            auto_fixable: false,
576            description: format!(
577                "Extract this declaration block into one rule and reference it from all {occurrence_count} occurrences, unless they are intentionally separate overrides."
578            ),
579            command: None,
580        }
581    }
582
583    /// Action for a Tailwind arbitrary-value bypass: a read-only fixed-string
584    /// search for every occurrence of the token so it can be replaced with a
585    /// scale token (or confirmed an intentional one-off). The value is a Tailwind
586    /// utility token (no quotes / whitespace by construction), so it is safe to
587    /// single-quote; the `-F` keeps the `[` / `]` literal rather than a glob.
588    #[must_use]
589    pub fn replace_arbitrary_value(value: &str) -> Self {
590        let command = (!value.contains('\'')).then(|| {
591            format!(
592                "grep -rnF '{value}' --include='*.jsx' --include='*.tsx' --include='*.html' --include='*.vue' --include='*.svelte' --include='*.astro' ."
593            )
594        });
595        Self {
596            kind: CssCandidateActionType::ReplaceWithToken,
597            auto_fixable: false,
598            description:
599                "Replace this one-off arbitrary value with a scale token from your Tailwind theme, or confirm it is intentional."
600                    .to_string(),
601            command,
602        }
603    }
604
605    /// Verify action for an unused CSS at-rule entity: a read-only search for
606    /// any out-of-CSS consumer (JS reading an `@property`; an `@import layer()`
607    /// populating a layer) before removing it.
608    #[must_use]
609    pub fn verify_unused_at_rule(kind: UnusedAtRuleKind, name: &str) -> Self {
610        let description = match kind {
611            UnusedAtRuleKind::PropertyRegistration => format!(
612                "Confirm \"{name}\" is not read or set from JavaScript before removing the @property registration."
613            ),
614            UnusedAtRuleKind::Layer => format!(
615                "Confirm the @layer \"{name}\" is not populated via @import layer() before removing the declaration."
616            ),
617        };
618        Self {
619            kind: CssCandidateActionType::VerifyUnused,
620            auto_fixable: false,
621            description,
622            command: safe_token_search(name),
623        }
624    }
625
626    /// Verify action for a markup class token that matches no defined CSS class
627    /// but is one edit from a class that is defined: surface the suggestion and a
628    /// read-only token search so the residual risk (a class defined in CSS-in-JS
629    /// or an external stylesheet) can be ruled out before fixing the typo.
630    #[must_use]
631    pub fn verify_unresolved_class(class: &str, suggestion: &str) -> Self {
632        Self {
633            kind: CssCandidateActionType::VerifyUndefined,
634            auto_fixable: false,
635            description: format!(
636                "\"{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."
637            ),
638            command: safe_token_search(class),
639        }
640    }
641
642    /// Verify action for a Vue SFC's unused scoped classes. The component-scoped
643    /// scan already covers every static use, so the only residual risk is a
644    /// class assembled from a dynamic string; that is a manual check, so the
645    /// action carries guidance but no command.
646    #[must_use]
647    pub fn verify_scoped_classes() -> Self {
648        Self {
649            kind: CssCandidateActionType::VerifyUnused,
650            auto_fixable: false,
651            description:
652                "Confirm none of these scoped classes is assembled from a dynamic string (e.g. `:class=\"prefix + name\"`) before removing them."
653                    .to_string(),
654            command: None,
655        }
656    }
657}
658
659/// Build a read-only, placeholder-free, namespace-QUALIFIED search for a Tailwind
660/// v4 `@theme` token, or `None` when the namespace / name is not a plain CSS
661/// identifier (so the emitted command is always shell-safe). The pattern matches
662/// any `*-<name>` utility (`bg-<name>`, `rounded-<name>`, `font-<name>`, ...) AND
663/// the `--<ns>-<name>` custom property (covering `var()` reads and `[--ns-name]`
664/// arbitrary values), deliberately NOT a bare `<name>` (which would substring-hit
665/// every file for a dictionary-word token like `brand` / `card`).
666fn theme_token_search(namespace: &str, name: &str) -> Option<String> {
667    let is_plain = |s: &str| {
668        !s.is_empty()
669            && s.bytes()
670                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
671    };
672    (is_plain(namespace) && is_plain(name)).then(|| {
673        format!(
674            "grep -rnE -- '-{name}\\b|--{namespace}-{name}' --include='*.css' --include='*.html' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.astro' ."
675        )
676    })
677}
678
679/// Build a read-only, placeholder-free token search for `name`, or `None` when
680/// the name is not a plain CSS identifier, so the emitted command is always
681/// shell-safe without quoting tricks.
682fn safe_token_search(name: &str) -> Option<String> {
683    let is_plain = !name.is_empty()
684        && name
685            .bytes()
686            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
687    is_plain.then(|| {
688        format!(
689            "grep -rnw '{name}' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.html' ."
690        )
691    })
692}
693
694/// Per-stylesheet CSS analytics.
695#[derive(Debug, Clone, serde::Serialize)]
696#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
697pub struct CssFileAnalytics {
698    /// Project-root-relative, forward-slash path.
699    pub path: String,
700    /// The stylesheet's structural metrics.
701    pub analytics: fallow_types::extract::CssAnalytics,
702}
703
704/// Project-wide CSS analytics aggregates across every analyzed stylesheet
705/// (including stylesheets with no notable rule, which are not listed
706/// individually).
707#[derive(Debug, Clone, Default, serde::Serialize)]
708#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
709pub struct CssAnalyticsSummary {
710    /// Stylesheets analyzed (standard CSS only; SCSS is skipped).
711    pub files_analyzed: u32,
712    /// Total style rules across analyzed stylesheets.
713    pub total_rules: u32,
714    /// Total declarations across analyzed stylesheets.
715    pub total_declarations: u32,
716    /// Total `!important` declarations across analyzed stylesheets.
717    pub important_declarations: u32,
718    /// Total empty style rules across analyzed stylesheets.
719    pub empty_rules: u32,
720    /// Deepest style-rule nesting depth observed across analyzed stylesheets.
721    pub max_nesting_depth: u8,
722    /// Distinct color values (authored form) across the whole codebase. A high
723    /// count signals an uncontrolled palette (design-token sprawl).
724    pub unique_colors: u32,
725    /// Distinct `font-size` values across the whole codebase.
726    pub unique_font_sizes: u32,
727    /// Distinct `z-index` values across the whole codebase.
728    pub unique_z_indexes: u32,
729    /// Distinct `box-shadow` values across the whole codebase (shadow-scale sprawl).
730    pub unique_box_shadows: u32,
731    /// Distinct `border-radius` values across the whole codebase (radius-scale sprawl).
732    pub unique_border_radii: u32,
733    /// Distinct `line-height` values across the whole codebase (type-scale sprawl).
734    pub unique_line_heights: u32,
735    /// Distinct custom properties (`--x`) defined anywhere in the codebase.
736    pub custom_properties_defined: u32,
737    /// Custom properties defined but never referenced via `var()` in any
738    /// stylesheet (the defined-but-unused direction). These are cleanup
739    /// CANDIDATES, not confirmed dead: a property may still be read or set from
740    /// JavaScript or inline HTML styles.
741    pub custom_properties_unreferenced: u32,
742    /// Distinct custom properties referenced via `var()` that are defined in no
743    /// stylesheet anywhere (the used-but-undefined direction). A COUNT only, not
744    /// a located list: a `var(--x)` with no CSS definition is extremely common
745    /// in JavaScript-driven theming and design-token libraries, so locating
746    /// these would be net-noise. The count is an architecture signal (how much
747    /// of the `var()` surface is resolved outside CSS), not a finding.
748    pub custom_properties_undefined: u32,
749    /// Distinct `@keyframes` defined anywhere in the codebase.
750    pub keyframes_defined: u32,
751    /// `@keyframes` defined but never referenced via `animation` /
752    /// `animation-name` in any stylesheet (the defined-but-unused direction;
753    /// cleanup CANDIDATES; an animation name can still be applied from
754    /// JavaScript).
755    pub keyframes_unreferenced: u32,
756    /// Distinct animation names referenced via `animation` / `animation-name`
757    /// that resolve to no `@keyframes` definition anywhere (the used-but-
758    /// undefined direction). Located in `undefined_keyframes`; usually a typo or
759    /// a removed animation.
760    pub keyframes_undefined: u32,
761    /// Total Vue `<style scoped>` classes used nowhere else in their component
762    /// (cleanup candidates), across all SFCs.
763    pub scoped_unused_classes: u32,
764    /// Number of distinct declaration blocks (4+ declarations) that appear in
765    /// two or more rules across the project (copy-paste consolidation
766    /// candidates). Located in `duplicate_declaration_blocks`.
767    pub duplicate_declaration_blocks: u32,
768    /// Total declarations removable by consolidating every duplicate block:
769    /// the sum of `(occurrence_count - 1) * declaration_count` across groups.
770    pub duplicate_declarations_total: u32,
771    /// Distinct Tailwind arbitrary-value tokens used in markup (design-token
772    /// bypass). Zero when the project does not use Tailwind. Located in
773    /// `tailwind_arbitrary_values`.
774    pub tailwind_arbitrary_values: u32,
775    /// Total Tailwind arbitrary-value occurrences across markup.
776    pub tailwind_arbitrary_value_uses: u32,
777    /// `@property` registrations never referenced via `var()` in any stylesheet
778    /// (located in `unused_at_rules`). Cleanup candidates.
779    pub unused_property_registrations: u32,
780    /// Cascade layers declared but never populated by a block (located in
781    /// `unused_at_rules`). Cleanup candidates.
782    pub unused_layers: u32,
783    /// Static markup class tokens that match no defined CSS class but are one
784    /// edit from a defined class (likely typos / stale renames). Located in
785    /// `unresolved_class_references`. Candidates, never gated.
786    pub unresolved_class_references: u32,
787    /// Global CSS classes defined in a stylesheet but referenced by no in-project
788    /// markup (located in `unreferenced_css_classes`). Heavily gated cleanup
789    /// candidates; zero on preprocessor-dominant or partial-scope runs.
790    pub unreferenced_css_classes: u32,
791    /// `@font-face` families declared but referenced by no `font-family` anywhere
792    /// (located in `unused_font_faces`). Dead web-font cleanup candidates.
793    pub unused_font_faces: u32,
794    /// Tailwind v4 `@theme` design tokens defined but used by no generated
795    /// utility, `var()`, `@apply`, or arbitrary value anywhere (located in
796    /// `unused_theme_tokens`). Dead-design-token cleanup candidates; zero when
797    /// the project is not Tailwind v4 or a plugin / published-library /
798    /// partial-scope run gated the scan out.
799    pub unused_theme_tokens: u32,
800    /// Number of distinct `font-size` units (`px` / `rem` / `em` / `%`) authored
801    /// across the codebase. Mixing units is a type-scale consistency smell,
802    /// broken out in `font_size_unit_mix`.
803    pub font_size_units_used: u32,
804    /// Number of analyzed stylesheets whose per-rule `notable_rules` list was
805    /// truncated at the per-file cap, so a consumer knows the per-rule detail is
806    /// incomplete without walking every file.
807    pub notable_truncated_files: u32,
808}
809
810/// Result of complexity analysis for reporting.
811#[derive(Debug, Clone, serde::Serialize)]
812#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
813pub struct HealthReport {
814    /// Functions and synthetic template entries exceeding complexity
815    /// thresholds, sorted by the --sort criteria. Each entry wraps its
816    /// inner [`ComplexityViolation`] payload (flattened on the wire) with
817    /// the typed `actions` list and an optional audit-mode `introduced`
818    /// flag.
819    pub findings: Vec<HealthFinding>,
820    /// Summary statistics.
821    pub summary: HealthSummary,
822    /// Configured threshold override states. Entries are emitted for active
823    /// exceptions, stale exceptions, and full-run no-match cleanup hints.
824    #[serde(default, skip_serializing_if = "Vec::is_empty")]
825    pub threshold_overrides: Vec<ThresholdOverrideState>,
826    /// Project-wide vital signs (always computed from available data).
827    #[serde(default, skip_serializing_if = "Option::is_none")]
828    pub vital_signs: Option<VitalSigns>,
829    /// Project-wide health score (only populated with `--score`).
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub health_score: Option<HealthScore>,
832    /// Per-file health scores. Only present when --file-scores is used. Sorted
833    /// by risk-aware triage concern, combining low maintainability and high
834    /// CRAP risk. Zero-function files (barrels) are excluded by default.
835    #[serde(default, skip_serializing_if = "Vec::is_empty")]
836    pub file_scores: Vec<FileHealthScore>,
837    /// Static coverage gaps.
838    ///
839    /// Populated when coverage gaps are explicitly requested, or when the
840    /// top-level `health` command allows config severity to surface them in the
841    /// default report.
842    #[serde(default, skip_serializing_if = "Option::is_none")]
843    pub coverage_gaps: Option<CoverageGaps>,
844    /// Located prop-drilling chains (React/Preact props forwarded unchanged
845    /// through 3+ pass-through components). Only present when the opt-in
846    /// `prop-drilling` rule is enabled (it defaults to off). Each entry carries
847    /// the source, every pass-through hop, and the consumer with file + line +
848    /// component, so CI / an agent can act. Surfaced alongside hotspots as a
849    /// graph-derived health signal.
850    #[serde(default, skip_serializing_if = "Vec::is_empty")]
851    pub prop_drilling_chains: Vec<PropDrillingChainFinding>,
852    /// Hotspot entries combining git churn with complexity. Only present when
853    /// --hotspots is used. Sorted by score descending (highest risk first).
854    /// Each entry wraps its inner [`HotspotEntry`] payload (flattened on the
855    /// wire) with a typed `actions` list.
856    #[serde(default, skip_serializing_if = "Vec::is_empty")]
857    pub hotspots: Vec<HotspotFinding>,
858    /// Hotspot analysis summary (only set with `--hotspots`).
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub hotspot_summary: Option<HotspotSummary>,
861    /// Runtime coverage findings from the paid sidecar (only populated with
862    /// `--runtime-coverage`).
863    #[serde(default, skip_serializing_if = "Option::is_none")]
864    pub runtime_coverage: Option<RuntimeCoverageReport>,
865    /// Combined coverage, runtime, complexity, and change-scope verdicts.
866    #[serde(default, skip_serializing_if = "Option::is_none")]
867    pub coverage_intelligence: Option<CoverageIntelligenceReport>,
868    /// Functions exceeding 60 LOC (very high risk). Only present when unit size
869    /// very-high-risk bin >= 3%. Sorted by line count descending.
870    #[serde(default, skip_serializing_if = "Vec::is_empty")]
871    pub large_functions: Vec<LargeFunctionEntry>,
872    /// Ranked refactoring recommendations. Only present when --targets is used.
873    /// Sorted by efficiency (priority/effort) descending. Each entry wraps
874    /// its inner [`RefactoringTarget`] payload (flattened on the wire) with
875    /// a typed `actions` list.
876    #[serde(default, skip_serializing_if = "Vec::is_empty")]
877    pub targets: Vec<RefactoringTargetFinding>,
878    /// Adaptive thresholds used for target scoring (only set with `--targets`).
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub target_thresholds: Option<TargetThresholds>,
881    /// Health trend comparison against a previous snapshot (only set with `--trend`).
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub health_trend: Option<HealthTrend>,
884    /// Audit breadcrumb explaining systemic action-array adjustments. Present
885    /// only when at least one adjustment was made (e.g., health finding
886    /// suppression hints omitted because a baseline is active). When --group-by
887    /// is active, each entry of `groups` may carry its own `actions_meta`
888    /// describing the same omission so per-group consumers do not need to walk
889    /// back to the report root.
890    #[serde(default, skip_serializing_if = "Option::is_none")]
891    pub actions_meta: Option<HealthActionsMeta>,
892    /// Structural CSS analytics (specificity hotspots, `!important` density,
893    /// over-complex selectors, deep nesting). Present only with `--css`.
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub css_analytics: Option<CssAnalyticsReport>,
896    /// Per-file top render fan-in for the descriptive human drill-down only:
897    /// maps a component file's absolute path to its highest-fan-in component
898    /// `(component name, render SITES)`. Drives the `rendered in N places`
899    /// blast-radius line on the React hotspot/complexity surface. INTERNAL: the
900    /// public render-fan-in surface is the `VitalSigns` aggregate, so this is
901    /// `#[serde(skip)]` and never widens the JSON contract.
902    #[serde(skip)]
903    pub render_fan_in_top: rustc_hash::FxHashMap<std::path::PathBuf, (String, u32)>,
904}
905
906#[cfg(test)]
907#[expect(
908    clippy::derivable_impls,
909    reason = "test-only Default with custom HealthSummary thresholds (20/15)"
910)]
911impl Default for HealthReport {
912    fn default() -> Self {
913        Self {
914            findings: vec![],
915            summary: HealthSummary::default(),
916            threshold_overrides: vec![],
917            vital_signs: None,
918            health_score: None,
919            file_scores: vec![],
920            coverage_gaps: None,
921            prop_drilling_chains: vec![],
922            hotspots: vec![],
923            hotspot_summary: None,
924            runtime_coverage: None,
925            coverage_intelligence: None,
926            large_functions: vec![],
927            targets: vec![],
928            target_thresholds: None,
929            health_trend: None,
930            actions_meta: None,
931            css_analytics: None,
932            render_fan_in_top: rustc_hash::FxHashMap::default(),
933        }
934    }
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn health_report_skips_empty_collections() {
943        let report = HealthReport::default();
944        let json = serde_json::to_string(&report).unwrap();
945        assert!(!json.contains("file_scores"));
946        assert!(!json.contains("hotspots"));
947        assert!(!json.contains("hotspot_summary"));
948        assert!(!json.contains("runtime_coverage"));
949        assert!(!json.contains("coverage_intelligence"));
950        assert!(!json.contains("large_functions"));
951        assert!(!json.contains("targets"));
952        assert!(!json.contains("threshold_overrides"));
953        assert!(!json.contains("vital_signs"));
954        assert!(!json.contains("health_score"));
955    }
956
957    #[test]
958    fn health_score_none_skipped_in_report() {
959        let report = HealthReport::default();
960        let json = serde_json::to_string(&report).unwrap();
961        assert!(!json.contains("health_score"));
962    }
963}