Skip to main content

fallow_output/
health_css.rs

1/// Structural CSS analytics surfaced by `fallow health --css`.
2#[derive(Debug, Clone, serde::Serialize)]
3#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
4pub struct CssAnalyticsReport {
5    /// Stylesheets with at least one structurally notable rule, in scan order.
6    pub files: Vec<CssFileAnalytics>,
7    /// Project-wide CSS aggregates across every analyzed stylesheet.
8    pub summary: CssAnalyticsSummary,
9    /// Vue SFCs whose `<style scoped>` defines classes used nowhere else in the
10    /// component (cleanup candidates).
11    #[serde(default, skip_serializing_if = "Vec::is_empty")]
12    pub scoped_unused: Vec<ScopedUnusedClasses>,
13    /// `@keyframes` defined but referenced via no `animation` / `animation-name`
14    /// in any stylesheet, with the stylesheet that defines them (cleanup
15    /// candidates; an animation name can still be applied from JavaScript).
16    /// The "defined-but-unused" direction.
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub unreferenced_keyframes: Vec<UnreferencedKeyframes>,
19    /// Animation references (`animation` / `animation-name`) to a `@keyframes`
20    /// name that is defined in NO stylesheet anywhere in the project, with the
21    /// first stylesheet that references them. The "used-but-undefined" direction
22    /// (the inverse of `unreferenced_keyframes`): usually a typo or a removed
23    /// animation, occasionally a `@keyframes` defined in CSS-in-JS (which the
24    /// CSS parser never sees). Conservative candidates, never gated findings.
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub undefined_keyframes: Vec<UndefinedKeyframes>,
27    /// Groups of style rules across the project that share an identical
28    /// declaration block (4+ declarations, sorted and `!important`-aware),
29    /// grouped by content: copy-paste consolidation candidates (fallow's
30    /// duplication signal applied to CSS). Sorted by estimated savings
31    /// descending.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub duplicate_declaration_blocks: Vec<CssDuplicateBlock>,
34    /// Tailwind arbitrary-value utilities (`w-[13px]`, `bg-[#abc]`) found in
35    /// markup, which hardcode a one-off value instead of a configured scale
36    /// token (design-token bypass). Present only when the project uses Tailwind.
37    /// Sorted by use count descending. Candidates, not findings: an arbitrary
38    /// value is sometimes the right call.
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub tailwind_arbitrary_values: Vec<TailwindArbitraryValue>,
41    /// Unused CSS at-rule entities: an `@property` registered but never read via
42    /// `var()` in any stylesheet, or an `@layer` declared but never populated by
43    /// a block. Cleanup candidates (an `@property` can be read from JS; a layer
44    /// can be populated via `@import layer()`). Located by first definition.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub unused_at_rules: Vec<UnusedAtRule>,
47    /// Static `class` / `className` tokens in markup that match no CSS class
48    /// defined anywhere in the project AND are one edit away from a class that
49    /// IS defined (a likely typo or stale rename, with the suggested class). The
50    /// CSS analogue of an unresolved import; the near-miss restriction keeps it
51    /// near-zero false-positive (Tailwind utilities and third-party classes are
52    /// not one edit from an authored class). Candidates, never gated: the token
53    /// could be defined in CSS-in-JS or an external stylesheet the parser never
54    /// sees. Sorted by `(path, line, class)`.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub unresolved_class_references: Vec<UnresolvedClassReference>,
57    /// Global CSS classes (defined in a plain `.css`/`.scss` rule) whose literal
58    /// name is referenced by NO in-project markup, static or dynamic (the CSS
59    /// analogue of an unused export). Heavily gated to stay near-zero-false-
60    /// positive: emitted only when the project is plain-CSS-dominant, the
61    /// stylesheet is locally consumed (not a published design-system surface),
62    /// and the whole project is in scope. Candidates, never gated findings: the
63    /// class may be used by an HTML email, server template, CMS, or Markdown the
64    /// parser never scans. Sorted by `(path, line, class)`.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub unreferenced_css_classes: Vec<UnreferencedCssClass>,
67    /// `@font-face` families declared in a stylesheet but referenced by no
68    /// `font-family` anywhere in the project: a dead web-font payload (the font
69    /// file is downloaded but never applied). Located at the declaring
70    /// stylesheet. Cleanup candidates: the family could be applied from inline
71    /// styles or set via JavaScript. Sorted by `(path, family)`.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub unused_font_faces: Vec<UnusedFontFace>,
74    /// Tailwind v4 `@theme` design tokens (`--color-brand`, `--radius-card`)
75    /// defined in a stylesheet but used by no generated utility, `var()` read,
76    /// `@apply`, or arbitrary value anywhere in the project: dead design tokens
77    /// (the `unused-export` of the token era). Present only when the project is
78    /// Tailwind v4 (a `tailwindcss` dependency plus at least one `@theme` block)
79    /// and not a plugin / published-library / partial-scope run. Candidates,
80    /// never gated findings: the token may be consumed by a Tailwind plugin or a
81    /// downstream repo. Sorted by `(path, line, token)`.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub unused_theme_tokens: Vec<UnusedThemeToken>,
84    /// A location-aware reverse index of Tailwind v4 `@theme` token consumers:
85    /// per token, where it is consumed (`var()` reads, `@apply` bodies, generated
86    /// utility classes) and through which surface, plus the full `consumer_count`
87    /// (a static lower bound) and the defining site. Built from the same gated
88    /// candidate set as `unused_theme_tokens` (v4 + non-plugin + non-published +
89    /// whole-scope), so a token with `consumer_count: 0` is the same "nothing
90    /// consumes this" signal. Sorted by token; empty when the project is not
91    /// Tailwind v4 or a plugin / published-library / partial-scope run gated the
92    /// scan out.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub token_consumers: Vec<TokenConsumers>,
95    /// The project authors `font-size` values in several units (`px`, `rem`,
96    /// `em`, `%`), with a per-unit distinct-value count: a type-scale
97    /// inconsistency smell (mixing `px` and `rem` for type works against
98    /// user-zoom accessibility). Present only above a conservative floor.
99    /// Advisory candidate, never gated: the spread can be intentional (fixed
100    /// chrome in `px`, body type in `rem`).
101    ///
102    /// Color-notation mixing (hex vs rgb vs hsl) is deliberately NOT surfaced:
103    /// the CSS parser canonicalizes every legacy sRGB notation to hex before
104    /// fallow sees the value, so the authored distinction is already gone and
105    /// cannot be recovered without a separate raw-token pass.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub font_size_unit_mix: Option<CssNotationConsistency>,
108}
109
110/// A design-token notation-consistency candidate: the distinct notations used
111/// across the codebase for one value axis (today, length units on `font-size`),
112/// with a per-notation distinct-value count. Emitted only above a floor, since
113/// mixing notations for one axis is a "no single source of truth" smell.
114/// Advisory: the action is "standardize on one notation", not a single search.
115#[derive(Debug, Clone, serde::Serialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117pub struct CssNotationConsistency {
118    /// The value axis these notations describe, e.g. `"Colors"` or
119    /// `"Font sizes"`.
120    pub axis: String,
121    /// Per-notation distinct-value counts, sorted by count descending then
122    /// notation name (so the dominant notation is first and ties are stable).
123    pub notations: Vec<CssNotationCount>,
124    /// Read-only guidance step(s), so consumers can iterate `actions` uniformly
125    /// across every candidate type. Always at least one entry.
126    pub actions: Vec<CssCandidateAction>,
127}
128
129/// One notation bucket and the count of distinct values authored in it.
130#[derive(Debug, Clone, serde::Serialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct CssNotationCount {
133    /// The notation family, e.g. `"hex"`, `"rgb"`, `"hsl"`, `"modern"`, `"px"`,
134    /// `"rem"`, `"em"`, `"%"`.
135    pub notation: String,
136    /// Distinct values authored in this notation across the codebase.
137    pub count: u32,
138}
139
140/// An unused CSS at-rule entity (an `@property` registration with no `var()`
141/// reference, or an `@layer` declaration never populated), located by its first
142/// definition. A cleanup candidate, never a gated finding.
143#[derive(Debug, Clone, serde::Serialize)]
144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
145pub struct UnusedAtRule {
146    /// Which kind of at-rule entity is unused.
147    #[serde(rename = "type")]
148    pub kind: UnusedAtRuleKind,
149    /// The entity name (`--x` for `@property`, the layer name for `@layer`).
150    pub name: String,
151    /// Project-root-relative, forward-slash path to the first defining stylesheet.
152    pub path: String,
153    /// Read-only verification step(s) before removal (parity with other findings).
154    pub actions: Vec<CssCandidateAction>,
155}
156
157/// Discriminant for [`UnusedAtRule::kind`].
158#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160#[serde(rename_all = "kebab-case")]
161#[repr(u8)]
162pub enum UnusedAtRuleKind {
163    /// An `@property --x { }` registered but never referenced via `var()`.
164    PropertyRegistration,
165    /// An `@layer a` declared (in a statement or named block) but never
166    /// populated by a `@layer a { }` block.
167    Layer,
168}
169
170/// A distinct Tailwind arbitrary-value utility token used in markup, with its
171/// total use count and first location (a design-token-bypass candidate).
172#[derive(Debug, Clone, serde::Serialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174pub struct TailwindArbitraryValue {
175    /// The `prefix-[value]` token (e.g. `w-[13px]`). Variant prefixes are
176    /// stripped, so `hover:w-[13px]` and `w-[13px]` aggregate under `w-[13px]`.
177    pub value: String,
178    /// Total occurrences across all scanned markup files.
179    pub count: u32,
180    /// Project-root-relative, forward-slash path to the first file using it.
181    pub path: String,
182    /// 1-based line of the first occurrence.
183    pub line: u32,
184    /// Read-only action(s): a find-all-occurrences search so the token can be
185    /// replaced with a scale token. Always at least one entry, so consumers can
186    /// iterate `actions` uniformly across every finding type.
187    pub actions: Vec<CssCandidateAction>,
188}
189
190/// A group of style rules across the project that share an identical declaration
191/// block: a copy-paste consolidation candidate (fallow's duplication signal
192/// applied to CSS). Only blocks of 4+ declarations appearing in 2+ rules are
193/// reported, so the signal stays a strong copy-paste indicator rather than
194/// flagging legitimately-repeated small blocks.
195#[derive(Debug, Clone, serde::Serialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct CssDuplicateBlock {
198    /// Declarations in the shared block.
199    pub declaration_count: u16,
200    /// Number of rules that share the block (always >= 2).
201    pub occurrence_count: u32,
202    /// Declarations removable by extracting the block into one shared rule:
203    /// `(occurrence_count - 1) * declaration_count`.
204    pub estimated_savings: u32,
205    /// The rules sharing the block, sorted by `(path, line)`.
206    pub occurrences: Vec<CssBlockOccurrence>,
207    /// Read-only guidance step(s), so consumers can iterate `actions`
208    /// uniformly across every finding type. Always at least one entry.
209    pub actions: Vec<CssCandidateAction>,
210}
211
212/// One occurrence of a duplicate declaration block.
213#[derive(Debug, Clone, serde::Serialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub struct CssBlockOccurrence {
216    /// Project-root-relative, forward-slash path to the stylesheet.
217    pub path: String,
218    /// 1-based line of the rule's first selector.
219    pub line: u32,
220}
221
222/// A `@keyframes` defined in a stylesheet but referenced by no animation in any
223/// stylesheet (cleanup candidate).
224#[derive(Debug, Clone, serde::Serialize)]
225#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
226pub struct UnreferencedKeyframes {
227    /// The `@keyframes` name.
228    pub name: String,
229    /// Project-root-relative, forward-slash path to the stylesheet that defines it.
230    pub path: String,
231    /// Read-only verification step(s) an agent can run before removing the
232    /// candidate. Always at least one entry, so consumers can iterate
233    /// `actions` uniformly across every finding type.
234    pub actions: Vec<CssCandidateAction>,
235}
236
237/// An `@font-face` family declared in a stylesheet but referenced by no
238/// `font-family` anywhere in the project: a dead web-font payload. A cleanup
239/// candidate (the family could be applied from inline styles or JavaScript).
240#[derive(Debug, Clone, serde::Serialize)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242pub struct UnusedFontFace {
243    /// The declared font family name (quotes stripped).
244    pub family: String,
245    /// Project-root-relative, forward-slash path to the declaring stylesheet.
246    pub path: String,
247    /// Read-only verification step(s) before removing. Always at least one entry,
248    /// so consumers can iterate `actions` uniformly across every finding type.
249    pub actions: Vec<CssCandidateAction>,
250}
251
252/// A Tailwind v4 `@theme` design token defined in a stylesheet whose generated
253/// utility, `var()` reads, and arbitrary-value references appear nowhere in the
254/// project: a dead design token (the `unused-export` of the token era). A
255/// candidate, never a gated finding: the token could be consumed by a Tailwind
256/// plugin, a published design-system surface, or a non-CSS-aware build step the
257/// scan cannot see (those cases are gated out before this is emitted).
258#[derive(Debug, Clone, serde::Serialize)]
259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
260pub struct UnusedThemeToken {
261    /// The full custom property as authored, including the `--` prefix
262    /// (`--color-brand`).
263    pub token: String,
264    /// The Tailwind v4 theme namespace the token belongs to (`color`, `radius`,
265    /// `font-weight`, `breakpoint`, ...).
266    pub namespace: String,
267    /// Project-root-relative, forward-slash path to the declaring stylesheet.
268    pub path: String,
269    /// 1-based line of the token's definition inside the `@theme` block.
270    pub line: u32,
271    /// Read-only verification step(s) before removing. Always at least one entry,
272    /// so consumers can iterate `actions` uniformly across every finding type.
273    pub actions: Vec<CssCandidateAction>,
274}
275
276/// Where one Tailwind v4 `@theme` token is consumed, and through which surface.
277/// One entry in a [`TokenConsumers::consumers`] sample.
278#[derive(Debug, Clone, serde::Serialize)]
279#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280pub struct TokenConsumerLocation {
281    /// Project-root-relative, forward-slash path to the consuming file.
282    pub path: String,
283    /// 1-based line of the consuming reference in that file.
284    pub line: u32,
285    /// Which surface consumes the token at this location.
286    pub kind: ConsumerKind,
287}
288
289/// The surface through which a design token is consumed. The `theme-var` /
290/// `css-var` / `utility` / `apply` kinds are Tailwind v4 `@theme` consumption; the
291/// `js-member` kind is CSS-in-JS consumption (a cross-module member access on an
292/// imported StyleX/vanilla-extract token binding). The kind is the disjoint origin
293/// signal that distinguishes a Tailwind token entry from a CSS-in-JS token entry in
294/// the shared `token_consumers` list.
295#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
296#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
297#[serde(rename_all = "kebab-case")]
298pub enum ConsumerKind {
299    /// A `var(--token)` read inside a `@theme` block interior (a token backing
300    /// another token).
301    ThemeVar,
302    /// A `var(--token)` read in regular CSS, outside any `@theme` block.
303    CssVar,
304    /// A generated utility class ending in `-<name>` (`bg-brand` consuming
305    /// `--color-brand`) found in markup / className strings / CSS-in-JS.
306    Utility,
307    /// A class-shaped token inside an `@apply` body in a stylesheet.
308    Apply,
309    /// A cross-module JS member access on an imported CSS-in-JS token binding
310    /// (`import { vars } from './tokens'; vars.color.primary`), for StyleX
311    /// `defineVars` / vanilla-extract `createTheme` family tokens.
312    JsMember,
313}
314
315/// A location-aware reverse index of where one design token is consumed, so an
316/// agent editing the token can see its blast radius before changing or removing
317/// it. Covers TWO token origins. The always-available discriminator is the `token`
318/// SHAPE: a Tailwind token is the `--`-prefixed custom property (`--color-brand`),
319/// a CSS-in-JS token is a dotted access path with no `--` prefix
320/// (`vars.color.primary`). The per-consumer `kind` also discriminates origin, but
321/// only when `consumer_count > 0` (a `consumer_count: 0` entry has an empty
322/// `consumers` array and thus no `kind`), so branch on the `token` prefix for the
323/// zero-consumer case. The two origins:
324///
325/// - Tailwind v4 `@theme` tokens (kinds `theme-var` / `css-var` / `utility` /
326///   `apply`), built from the same gated candidate set as `unused_theme_tokens`
327///   (v4 + non-plugin + non-published + whole-scope), so a `consumer_count: 0`
328///   corroborates the `unused_theme_tokens` "nothing consumes this" finding.
329/// - CSS-in-JS tokens (kind `js-member`) from StyleX `defineVars` /
330///   vanilla-extract `createTheme` family definitions, consumed via cross-module
331///   member access. NOTE: CSS-in-JS has NO corroborating dead-token finding (there
332///   is no `unused_theme_tokens` analogue), so a CSS-in-JS `consumer_count: 0` is a
333///   weaker signal than the Tailwind case (and the cross-file scan is relative-import
334///   only, so alias / bare-package imports are not counted).
335///
336/// This is DESCRIPTIVE context (a blast-radius lookup), not a finding, so it
337/// deliberately carries no `actions` array (unlike the cleanup-candidate types in
338/// this module). `consumer_count` is always a STATIC lower bound (a computed class
339/// name like `bg-${color}`, or a CSS-in-JS access through an unresolved alias
340/// import, is not counted).
341#[derive(Debug, Clone, serde::Serialize)]
342#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
343pub struct TokenConsumers {
344    /// The token identity. For a Tailwind `@theme` token this is the full custom
345    /// property as authored, INCLUDING the `--` prefix (`--color-brand`). For a
346    /// CSS-in-JS token (kind `js-member`) this is the binding-qualified dotted
347    /// access path, NO `--` prefix (`vars.color.primary`), matching how consumers
348    /// read it. The presence of the `--` prefix distinguishes the two origins.
349    pub token: String,
350    /// For a Tailwind token, the v4 theme namespace (`color`, `radius`,
351    /// `font-weight`, ...). For a CSS-in-JS token (kind `js-member`), the defining
352    /// export BINDING the token set is accessed through (`vars`), which identifies
353    /// the token set, NOT a semantic group. (The field is thus overloaded by
354    /// origin; branch on `consumers[].kind` or the `token` shape.)
355    pub namespace: String,
356    /// Project-root-relative, forward-slash path to the declaring stylesheet
357    /// (Tailwind) or the JS/TS token-definition file (CSS-in-JS).
358    pub definition_path: String,
359    /// 1-based line of the token's definition (inside the `@theme` block for
360    /// Tailwind; the token key inside the `defineVars`/`createTheme` object for
361    /// CSS-in-JS).
362    pub definition_line: u32,
363    /// The FULL number of consumer locations found, a STATIC LOWER BOUND: a
364    /// computed class name (`bg-${color}`) or a value read outside CSS/markup the
365    /// scan never sees is not counted. This is the aggregate over every consumer,
366    /// computed BEFORE [`consumers`](Self::consumers) is capped to a sample.
367    pub consumer_count: u32,
368    /// A capped, deterministically-sorted sample of consumer locations (at most
369    /// [`TOKEN_CONSUMER_SAMPLE_CAP`]). The full count lives in
370    /// [`consumer_count`](Self::consumer_count); use this list to jump to
371    /// representative consumers, not to enumerate every one.
372    pub consumers: Vec<TokenConsumerLocation>,
373}
374
375/// Maximum number of consumer locations sampled into [`TokenConsumers::consumers`].
376/// The full count is preserved in [`TokenConsumers::consumer_count`]
377/// (aggregate-before-truncate), so capping the sample never distorts the count.
378pub const TOKEN_CONSUMER_SAMPLE_CAP: usize = 20;
379
380/// A global CSS class defined in a plain `.css`/`.scss` rule whose literal name
381/// is referenced by no in-project markup (the CSS analogue of an unused export).
382/// A heavily-gated candidate, never a gated finding: the class may be applied
383/// from an HTML email, server template, CMS, or Markdown the parser never sees.
384#[derive(Debug, Clone, serde::Serialize)]
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
386pub struct UnreferencedCssClass {
387    /// The class name (no dot).
388    pub class: String,
389    /// Project-root-relative, forward-slash path to the defining stylesheet.
390    pub path: String,
391    /// 1-based line of the class's first definition.
392    pub line: u32,
393    /// Read-only verification step(s) before removing. Always at least one entry,
394    /// so consumers can iterate `actions` uniformly across every finding type.
395    pub actions: Vec<CssCandidateAction>,
396}
397
398/// An animation reference (`animation` / `animation-name`) to a `@keyframes`
399/// name that is defined in no stylesheet anywhere in the project (the
400/// "used-but-undefined" direction). Usually a typo or a removed animation;
401/// occasionally a `@keyframes` defined in CSS-in-JS the CSS parser never sees.
402#[derive(Debug, Clone, serde::Serialize)]
403#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
404pub struct UndefinedKeyframes {
405    /// The referenced `@keyframes` name that resolves to no definition.
406    pub name: String,
407    /// Project-root-relative, forward-slash path to the first stylesheet that
408    /// references it.
409    pub path: String,
410    /// Read-only verification step(s) an agent can run before fixing the
411    /// reference. Always at least one entry, so consumers can iterate `actions`
412    /// uniformly across every finding type.
413    pub actions: Vec<CssCandidateAction>,
414}
415
416/// A static `class` / `className` token in markup that matches no CSS class
417/// defined anywhere in the project but is one edit away from a class that IS
418/// defined (a likely typo or stale rename). The CSS analogue of an unresolved
419/// import. A candidate, never a gated finding: the token could be defined in
420/// CSS-in-JS or an external stylesheet the parser never sees.
421#[derive(Debug, Clone, serde::Serialize)]
422#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
423pub struct UnresolvedClassReference {
424    /// The static class token referenced in markup (no dot).
425    pub class: String,
426    /// The defined CSS class one edit away: the likely intended class.
427    pub suggestion: String,
428    /// Project-root-relative, forward-slash path to the markup file.
429    pub path: String,
430    /// 1-based line of the `class` / `className` attribute.
431    pub line: u32,
432    /// Read-only verification step(s) before fixing the reference. Always at
433    /// least one entry, so consumers can iterate `actions` uniformly across
434    /// every finding type.
435    pub actions: Vec<CssCandidateAction>,
436}
437
438/// A Vue SFC's `<style scoped>` classes that appear nowhere else in the
439/// component (cleanup candidates).
440#[derive(Debug, Clone, serde::Serialize)]
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
442pub struct ScopedUnusedClasses {
443    /// Project-root-relative, forward-slash path to the SFC.
444    pub path: String,
445    /// The scoped class names with no use elsewhere in the component, sorted.
446    pub classes: Vec<String>,
447    /// Read-only verification step(s) an agent can run before removing the
448    /// candidate. Always at least one entry, so consumers can iterate
449    /// `actions` uniformly across every finding type.
450    pub actions: Vec<CssCandidateAction>,
451}
452
453/// A read-only verification step attached to a CSS cleanup candidate.
454///
455/// CSS candidates (unreferenced `@keyframes`, unused scoped classes) are never
456/// auto-removed: an animation name can still be applied from JavaScript, and a
457/// class can be assembled from a dynamic string binding. The action gives an
458/// agent a machine-readable next step, mirroring the `actions` array carried by
459/// every other health finding, plus an optional runnable probe to confirm the
460/// candidate is genuinely unused before deleting it.
461#[derive(Debug, Clone, serde::Serialize)]
462#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
463pub struct CssCandidateAction {
464    /// Action type identifier (`verify-unused`).
465    #[serde(rename = "type")]
466    pub kind: CssCandidateActionType,
467    /// Always `false`: CSS candidates are never auto-fixed (`fallow fix` does
468    /// not touch them) because the residual consumer may live outside CSS.
469    pub auto_fixable: bool,
470    /// Human-readable description of what to confirm before removing.
471    pub description: String,
472    /// A runnable, read-only, placeholder-free token search that surfaces any
473    /// out-of-CSS use of the candidate. Absent when no shell-safe command can
474    /// be built (e.g. the residual risk is a dynamic string binding that a
475    /// single search cannot probe), in which case `description` is the guide.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub command: Option<String>,
478}
479
480/// Discriminant for [`CssCandidateAction::kind`].
481#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
482#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
483#[serde(rename_all = "kebab-case")]
484pub enum CssCandidateActionType {
485    /// Confirm the candidate has no JavaScript / HTML / dynamic consumer
486    /// before removing it (the defined-but-unused candidates).
487    VerifyUnused,
488    /// Confirm the referenced name is genuinely undefined (not defined in
489    /// CSS-in-JS the parser cannot see) before treating it as a typo (the
490    /// used-but-undefined candidates).
491    VerifyUndefined,
492    /// Extract the shared declaration block into one rule and reference it from
493    /// each occurrence (the duplicate-declaration-block candidates).
494    Consolidate,
495    /// Replace a Tailwind arbitrary value with a configured scale token, or
496    /// confirm the one-off is intentional (the arbitrary-value candidates).
497    ReplaceWithToken,
498    /// Standardize an inconsistent value axis on a single notation (the
499    /// color-format / length-unit mixing candidates).
500    Standardize,
501}
502
503impl CssCandidateAction {
504    /// Verify action for an unused `@font-face` family: a read-only token search
505    /// for any inline-style or JavaScript application of the family before
506    /// removing the dead web-font.
507    #[must_use]
508    pub fn verify_unused_font_face(family: &str) -> Self {
509        Self {
510            kind: CssCandidateActionType::VerifyUnused,
511            auto_fixable: false,
512            description: format!(
513                "Confirm the \"{family}\" font family is not applied from an inline style or JavaScript before removing the @font-face and its font files."
514            ),
515            command: safe_token_search(family),
516        }
517    }
518
519    /// Verify action for an unused Tailwind v4 `@theme` token: a read-only search
520    /// that embeds the LITERAL terms an agent should grep for, the generated
521    /// utility suffix (`bg-<name>` / `text-<name>` / `<namespace>-<name>`), the
522    /// `var(--<ns>-<name>)` read, and the arbitrary `[--<ns>-<name>]` value,
523    /// before removing the token. Verify-then-remove; never auto-fixable.
524    #[must_use]
525    pub fn verify_unused_theme_token(token: &str, namespace: &str, name: &str) -> Self {
526        Self {
527            kind: CssCandidateActionType::VerifyUnused,
528            auto_fixable: false,
529            description: format!(
530                "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."
531            ),
532            command: theme_token_search(namespace, name),
533        }
534    }
535
536    /// Verify action for an unreferenced global CSS class: name the surfaces the
537    /// in-project scan does NOT cover (the class could be applied from there) and
538    /// ship a read-only token search to double-check before removing.
539    #[must_use]
540    pub fn verify_unreferenced_class(name: &str) -> Self {
541        Self {
542            kind: CssCandidateActionType::VerifyUnused,
543            auto_fixable: false,
544            description: format!(
545                "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)."
546            ),
547            command: safe_token_search(name),
548        }
549    }
550
551    /// Verify action for an unreferenced `@keyframes`: a read-only token search
552    /// for any JavaScript or template reference that applies the animation
553    /// (which the CSS-only scan cannot see).
554    #[must_use]
555    pub fn verify_keyframe(name: &str) -> Self {
556        Self {
557            kind: CssCandidateActionType::VerifyUnused,
558            auto_fixable: false,
559            description: format!(
560                "Confirm no JavaScript or template applies the \"{name}\" animation before removing the @keyframes."
561            ),
562            command: safe_token_search(name),
563        }
564    }
565
566    /// Verify action for an animation reference to a `@keyframes` that is
567    /// defined in no stylesheet: a read-only token search for a CSS-in-JS
568    /// `@keyframes`/animation definition of the name (styled-components,
569    /// Emotion, vanilla-extract) before treating the reference as a typo.
570    #[must_use]
571    pub fn verify_undefined_keyframe(name: &str) -> Self {
572        Self {
573            kind: CssCandidateActionType::VerifyUndefined,
574            auto_fixable: false,
575            description: format!(
576                "Confirm \"{name}\" is not a @keyframes defined in CSS-in-JS (styled-components, Emotion, vanilla-extract) before treating the animation reference as a typo."
577            ),
578            command: safe_token_search(name),
579        }
580    }
581
582    /// Guidance action for a mixed value axis (colors authored in several
583    /// notations, or font sizes in several units): standardize on the single
584    /// dominant notation. No command (this is a project-wide refactor, and the
585    /// per-notation breakdown already quantifies the spread); the residual
586    /// judgment is whether the spread is an intentional migration in progress.
587    #[must_use]
588    pub fn standardize_notation(axis: &str, dominant: &str) -> Self {
589        Self {
590            kind: CssCandidateActionType::Standardize,
591            auto_fixable: false,
592            description: format!(
593                "{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."
594            ),
595            command: None,
596        }
597    }
598
599    /// Guidance action for a duplicate declaration block: consolidate the shared
600    /// declarations into one rule. No command (consolidation is a refactor, and
601    /// the occurrences list already names every site); the residual judgment is
602    /// whether the rules are intentionally separate overrides.
603    #[must_use]
604    pub fn consolidate_block(occurrence_count: u32) -> Self {
605        Self {
606            kind: CssCandidateActionType::Consolidate,
607            auto_fixable: false,
608            description: format!(
609                "Extract this declaration block into one rule and reference it from all {occurrence_count} occurrences, unless they are intentionally separate overrides."
610            ),
611            command: None,
612        }
613    }
614
615    /// Action for a Tailwind arbitrary-value bypass: a read-only fixed-string
616    /// search for every occurrence of the token so it can be replaced with a
617    /// scale token (or confirmed an intentional one-off). The value is a Tailwind
618    /// utility token (no quotes / whitespace by construction), so it is safe to
619    /// single-quote; the `-F` keeps the `[` / `]` literal rather than a glob.
620    #[must_use]
621    pub fn replace_arbitrary_value(value: &str) -> Self {
622        let command = (!value.contains('\'')).then(|| {
623            format!(
624                "grep -rnF '{value}' --include='*.jsx' --include='*.tsx' --include='*.html' --include='*.vue' --include='*.svelte' --include='*.astro' ."
625            )
626        });
627        Self {
628            kind: CssCandidateActionType::ReplaceWithToken,
629            auto_fixable: false,
630            description:
631                "Replace this one-off arbitrary value with a scale token from your Tailwind theme, or confirm it is intentional."
632                    .to_string(),
633            command,
634        }
635    }
636
637    /// Verify action for an unused CSS at-rule entity: a read-only search for
638    /// any out-of-CSS consumer (JS reading an `@property`; an `@import layer()`
639    /// populating a layer) before removing it.
640    #[must_use]
641    pub fn verify_unused_at_rule(kind: UnusedAtRuleKind, name: &str) -> Self {
642        let description = match kind {
643            UnusedAtRuleKind::PropertyRegistration => format!(
644                "Confirm \"{name}\" is not read or set from JavaScript before removing the @property registration."
645            ),
646            UnusedAtRuleKind::Layer => format!(
647                "Confirm the @layer \"{name}\" is not populated via @import layer() before removing the declaration."
648            ),
649        };
650        Self {
651            kind: CssCandidateActionType::VerifyUnused,
652            auto_fixable: false,
653            description,
654            command: safe_token_search(name),
655        }
656    }
657
658    /// Verify action for a markup class token that matches no defined CSS class
659    /// but is one edit from a class that is defined: surface the suggestion and a
660    /// read-only token search so the residual risk (a class defined in CSS-in-JS
661    /// or an external stylesheet) can be ruled out before fixing the typo.
662    #[must_use]
663    pub fn verify_unresolved_class(class: &str, suggestion: &str) -> Self {
664        Self {
665            kind: CssCandidateActionType::VerifyUndefined,
666            auto_fixable: false,
667            description: format!(
668                "\"{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."
669            ),
670            command: safe_token_search(class),
671        }
672    }
673
674    /// Verify action for a Vue SFC's unused scoped classes. The component-scoped
675    /// scan already covers every static use, so the only residual risk is a
676    /// class assembled from a dynamic string; that is a manual check, so the
677    /// action carries guidance but no command.
678    #[must_use]
679    pub fn verify_scoped_classes() -> Self {
680        Self {
681            kind: CssCandidateActionType::VerifyUnused,
682            auto_fixable: false,
683            description:
684                "Confirm none of these scoped classes is assembled from a dynamic string (e.g. `:class=\"prefix + name\"`) before removing them."
685                    .to_string(),
686            command: None,
687        }
688    }
689}
690
691/// Build a read-only, placeholder-free, namespace-QUALIFIED search for a Tailwind
692/// v4 `@theme` token, or `None` when the namespace / name is not a plain CSS
693/// identifier (so the emitted command is always shell-safe). The pattern matches
694/// any `*-<name>` utility (`bg-<name>`, `rounded-<name>`, `font-<name>`, ...) AND
695/// the `--<ns>-<name>` custom property (covering `var()` reads and `[--ns-name]`
696/// arbitrary values), deliberately NOT a bare `<name>` (which would substring-hit
697/// every file for a dictionary-word token like `brand` / `card`).
698fn theme_token_search(namespace: &str, name: &str) -> Option<String> {
699    let is_plain = |s: &str| {
700        !s.is_empty()
701            && s.bytes()
702                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
703    };
704    (is_plain(namespace) && is_plain(name)).then(|| {
705        format!(
706            "grep -rnE -- '-{name}\\b|--{namespace}-{name}' --include='*.css' --include='*.html' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.astro' ."
707        )
708    })
709}
710
711/// Build a read-only, placeholder-free token search for `name`, or `None` when
712/// the name is not a plain CSS identifier, so the emitted command is always
713/// shell-safe without quoting tricks.
714fn safe_token_search(name: &str) -> Option<String> {
715    let is_plain = !name.is_empty()
716        && name
717            .bytes()
718            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
719    is_plain.then(|| {
720        format!(
721            "grep -rnw '{name}' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.html' ."
722        )
723    })
724}
725
726/// Per-stylesheet CSS analytics.
727#[derive(Debug, Clone, serde::Serialize)]
728#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
729pub struct CssFileAnalytics {
730    /// Project-root-relative, forward-slash path.
731    pub path: String,
732    /// The stylesheet's structural metrics.
733    pub analytics: fallow_types::extract::CssAnalytics,
734}
735
736/// Project-wide CSS analytics aggregates across every analyzed stylesheet
737/// (including stylesheets with no notable rule, which are not listed
738/// individually in `files`).
739#[derive(Debug, Clone, Default, serde::Serialize)]
740#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
741pub struct CssAnalyticsSummary {
742    /// Stylesheets analyzed: standard `.css` files, Vue/Svelte SFC `<style>`
743    /// blocks, and (dep-gated) CSS-in-JS, both the tagged-template form and the
744    /// object form (`style({...})` / `stylex.create({...})` / `css({...})`). SCSS
745    /// is skipped. Note: flat atomic object CSS-in-JS (StyleX/Panda) is counted
746    /// here and contributes to these aggregates, but has no notable rules, so its
747    /// files never appear in the per-file `files` list.
748    pub files_analyzed: u32,
749    /// Total style rules across analyzed stylesheets.
750    pub total_rules: u32,
751    /// Total declarations across analyzed stylesheets.
752    pub total_declarations: u32,
753    /// Total `!important` declarations across analyzed stylesheets.
754    pub important_declarations: u32,
755    /// Total empty style rules across analyzed stylesheets.
756    pub empty_rules: u32,
757    /// Deepest style-rule nesting depth observed across analyzed stylesheets.
758    pub max_nesting_depth: u8,
759    /// Distinct color values (authored form) across the whole codebase. A high
760    /// count signals an uncontrolled palette (design-token sprawl).
761    pub unique_colors: u32,
762    /// Distinct `font-size` values across the whole codebase.
763    pub unique_font_sizes: u32,
764    /// Distinct `z-index` values across the whole codebase.
765    pub unique_z_indexes: u32,
766    /// Distinct `box-shadow` values across the whole codebase (shadow-scale sprawl).
767    pub unique_box_shadows: u32,
768    /// Distinct `border-radius` values across the whole codebase (radius-scale sprawl).
769    pub unique_border_radii: u32,
770    /// Distinct `line-height` values across the whole codebase (type-scale sprawl).
771    pub unique_line_heights: u32,
772    /// Distinct custom properties (`--x`) defined anywhere in the codebase.
773    pub custom_properties_defined: u32,
774    /// Custom properties defined but never referenced via `var()` in any
775    /// stylesheet (the defined-but-unused direction). These are cleanup
776    /// CANDIDATES, not confirmed dead: a property may still be read or set from
777    /// JavaScript or inline HTML styles.
778    pub custom_properties_unreferenced: u32,
779    /// Distinct custom properties referenced via `var()` that are defined in no
780    /// stylesheet anywhere (the used-but-undefined direction). A COUNT only, not
781    /// a located list: a `var(--x)` with no CSS definition is extremely common
782    /// in JavaScript-driven theming and design-token libraries, so locating
783    /// these would be net-noise. The count is an architecture signal (how much
784    /// of the `var()` surface is resolved outside CSS), not a finding.
785    pub custom_properties_undefined: u32,
786    /// Distinct `@keyframes` defined anywhere in the codebase.
787    pub keyframes_defined: u32,
788    /// `@keyframes` defined but never referenced via `animation` /
789    /// `animation-name` in any stylesheet (the defined-but-unused direction;
790    /// cleanup CANDIDATES; an animation name can still be applied from
791    /// JavaScript).
792    pub keyframes_unreferenced: u32,
793    /// Distinct animation names referenced via `animation` / `animation-name`
794    /// that resolve to no `@keyframes` definition anywhere (the used-but-
795    /// undefined direction). Located in `undefined_keyframes`; usually a typo or
796    /// a removed animation.
797    pub keyframes_undefined: u32,
798    /// Total Vue `<style scoped>` classes used nowhere else in their component
799    /// (cleanup candidates), across all SFCs.
800    pub scoped_unused_classes: u32,
801    /// Number of distinct declaration blocks (4+ declarations) that appear in
802    /// two or more rules across the project (copy-paste consolidation
803    /// candidates). Located in `duplicate_declaration_blocks`.
804    pub duplicate_declaration_blocks: u32,
805    /// Total declarations removable by consolidating every duplicate block:
806    /// the sum of `(occurrence_count - 1) * declaration_count` across groups.
807    pub duplicate_declarations_total: u32,
808    /// Distinct Tailwind arbitrary-value tokens used in markup (design-token
809    /// bypass). Zero when the project does not use Tailwind. Located in
810    /// `tailwind_arbitrary_values`.
811    pub tailwind_arbitrary_values: u32,
812    /// Total Tailwind arbitrary-value occurrences across markup.
813    pub tailwind_arbitrary_value_uses: u32,
814    /// `@property` registrations never referenced via `var()` in any stylesheet
815    /// (located in `unused_at_rules`). Cleanup candidates.
816    pub unused_property_registrations: u32,
817    /// Cascade layers declared but never populated by a block (located in
818    /// `unused_at_rules`). Cleanup candidates.
819    pub unused_layers: u32,
820    /// Static markup class tokens that match no defined CSS class but are one
821    /// edit from a defined class (likely typos / stale renames). Located in
822    /// `unresolved_class_references`. Candidates, never gated.
823    pub unresolved_class_references: u32,
824    /// Global CSS classes defined in a stylesheet but referenced by no in-project
825    /// markup (located in `unreferenced_css_classes`). Heavily gated cleanup
826    /// candidates; zero on preprocessor-dominant or partial-scope runs.
827    pub unreferenced_css_classes: u32,
828    /// `@font-face` families declared but referenced by no `font-family` anywhere
829    /// (located in `unused_font_faces`). Dead web-font cleanup candidates.
830    pub unused_font_faces: u32,
831    /// Tailwind v4 `@theme` design tokens defined but used by no generated
832    /// utility, `var()`, `@apply`, or arbitrary value anywhere (located in
833    /// `unused_theme_tokens`). Dead-design-token cleanup candidates; zero when
834    /// the project is not Tailwind v4 or a plugin / published-library /
835    /// partial-scope run gated the scan out.
836    pub unused_theme_tokens: u32,
837    /// Number of distinct `font-size` units (`px` / `rem` / `em` / `%`) authored
838    /// across the codebase. Mixing units is a type-scale consistency smell,
839    /// broken out in `font_size_unit_mix`.
840    pub font_size_units_used: u32,
841    /// Number of analyzed stylesheets whose per-rule `notable_rules` list was
842    /// truncated at the per-file cap, so a consumer knows the per-rule detail is
843    /// incomplete without walking every file.
844    pub notable_truncated_files: u32,
845}
846
847#[cfg(test)]
848#[allow(
849    clippy::unwrap_used,
850    reason = "tests use unwrap to keep serialization assertions concise"
851)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn consumer_kind_serializes_kebab_case() {
857        let kinds = [
858            (ConsumerKind::ThemeVar, "\"theme-var\""),
859            (ConsumerKind::CssVar, "\"css-var\""),
860            (ConsumerKind::Utility, "\"utility\""),
861            (ConsumerKind::Apply, "\"apply\""),
862        ];
863        for (kind, expected) in kinds {
864            assert_eq!(serde_json::to_string(&kind).unwrap(), expected);
865        }
866    }
867
868    #[test]
869    fn token_consumers_serializes_full_shape() {
870        let entry = TokenConsumers {
871            token: "--color-brand".to_string(),
872            namespace: "color".to_string(),
873            definition_path: "src/theme.css".to_string(),
874            definition_line: 4,
875            consumer_count: 2,
876            consumers: vec![
877                TokenConsumerLocation {
878                    path: "src/Button.tsx".to_string(),
879                    line: 12,
880                    kind: ConsumerKind::Utility,
881                },
882                TokenConsumerLocation {
883                    path: "src/theme.css".to_string(),
884                    line: 9,
885                    kind: ConsumerKind::CssVar,
886                },
887            ],
888        };
889        let value = serde_json::to_value(&entry).unwrap();
890        assert_eq!(value["consumer_count"], 2);
891        assert_eq!(value["definition_line"], 4);
892        assert_eq!(value["consumers"][0]["kind"], "utility");
893        assert_eq!(value["consumers"][1]["kind"], "css-var");
894    }
895
896    #[test]
897    fn token_consumers_omitted_when_empty() {
898        let report = CssAnalyticsReport {
899            files: Vec::new(),
900            summary: CssAnalyticsSummary::default(),
901            scoped_unused: Vec::new(),
902            unreferenced_keyframes: Vec::new(),
903            undefined_keyframes: Vec::new(),
904            duplicate_declaration_blocks: Vec::new(),
905            tailwind_arbitrary_values: Vec::new(),
906            unused_at_rules: Vec::new(),
907            unresolved_class_references: Vec::new(),
908            unreferenced_css_classes: Vec::new(),
909            unused_font_faces: Vec::new(),
910            unused_theme_tokens: Vec::new(),
911            token_consumers: Vec::new(),
912            font_size_unit_mix: None,
913        };
914        let value = serde_json::to_value(&report).unwrap();
915        assert!(
916            value.get("token_consumers").is_none(),
917            "empty token_consumers must be skipped"
918        );
919    }
920
921    #[test]
922    fn token_consumers_present_when_non_empty() {
923        let report = CssAnalyticsReport {
924            files: Vec::new(),
925            summary: CssAnalyticsSummary::default(),
926            scoped_unused: Vec::new(),
927            unreferenced_keyframes: Vec::new(),
928            undefined_keyframes: Vec::new(),
929            duplicate_declaration_blocks: Vec::new(),
930            tailwind_arbitrary_values: Vec::new(),
931            unused_at_rules: Vec::new(),
932            unresolved_class_references: Vec::new(),
933            unreferenced_css_classes: Vec::new(),
934            unused_font_faces: Vec::new(),
935            unused_theme_tokens: Vec::new(),
936            token_consumers: vec![TokenConsumers {
937                token: "--color-brand".to_string(),
938                namespace: "color".to_string(),
939                definition_path: "src/theme.css".to_string(),
940                definition_line: 4,
941                consumer_count: 0,
942                consumers: Vec::new(),
943            }],
944            font_size_unit_mix: None,
945        };
946        let value = serde_json::to_value(&report).unwrap();
947        assert_eq!(value["token_consumers"][0]["consumer_count"], 0);
948    }
949}