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}