fallow_output/health_css.rs
1/// Structural CSS analytics surfaced by `fallow health --css`.
2#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
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 /// CVA / shadcn variant class strings that repeat the same normalized class
35 /// block in several variant values. Kept separate from CSS declaration-block
36 /// duplication because the source is JS config, not parsed CSS rules.
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub cva_duplicate_variant_blocks: Vec<CvaDuplicateVariantBlock>,
39 /// CVA / shadcn variant class strings that hardcode a Tailwind arbitrary
40 /// value even though an existing token has the same or nearest comparable
41 /// value. Advisory: variants often encode product semantics, so agents
42 /// should verify intent before replacing.
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub cva_variant_token_drifts: Vec<CvaVariantTokenDrift>,
45 /// Tailwind arbitrary-value utilities (`w-[13px]`, `bg-[#abc]`) found in
46 /// markup, which hardcode a one-off value instead of a configured scale
47 /// token (design-token bypass). Present only when the project uses Tailwind.
48 /// Sorted by use count descending. Candidates, not findings: an arbitrary
49 /// value is sometimes the right call.
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub tailwind_arbitrary_values: Vec<TailwindArbitraryValue>,
52 /// Located raw CSS declaration values that bypass token surfaces (`var()`,
53 /// `token()`, `theme()`) on scale-sensitive axes such as color, font-size,
54 /// line-height, radius, and shadow. Conservative candidates: a raw value can
55 /// be intentional, but introduced raw values are useful audit feedback.
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub raw_style_values: Vec<RawStyleValue>,
58 /// Unused CSS at-rule entities: an `@property` registered but never read via
59 /// `var()` in any stylesheet, or an `@layer` declared but never populated by
60 /// a block. Cleanup candidates (an `@property` can be read from JS; a layer
61 /// can be populated via `@import layer()`). Located by first definition.
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub unused_at_rules: Vec<UnusedAtRule>,
64 /// Static `class` / `className` tokens in markup that match no CSS class
65 /// defined anywhere in the project AND are one edit away from a class that
66 /// IS defined (a likely typo or stale rename, with the suggested class). The
67 /// CSS analogue of an unresolved import; the near-miss restriction keeps it
68 /// near-zero false-positive (Tailwind utilities and third-party classes are
69 /// not one edit from an authored class). Candidates, never gated: the token
70 /// could be defined in CSS-in-JS or an external stylesheet the parser never
71 /// sees. Sorted by `(path, line, class)`.
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub unresolved_class_references: Vec<UnresolvedClassReference>,
74 /// Global CSS classes (defined in a plain `.css`/`.scss` rule) whose literal
75 /// name is referenced by NO in-project markup, static or dynamic (the CSS
76 /// analogue of an unused export). Heavily gated to stay near-zero-false-
77 /// positive: emitted only when the project is plain-CSS-dominant, the
78 /// stylesheet is locally consumed (not a published design-system surface),
79 /// and the whole project is in scope. Candidates, never gated findings: the
80 /// class may be used by an HTML email, server template, CMS, or Markdown the
81 /// parser never scans. Sorted by `(path, line, class)`.
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub unreferenced_css_classes: Vec<UnreferencedCssClass>,
84 /// `@font-face` families declared in a stylesheet but referenced by no
85 /// `font-family` anywhere in the project: a dead web-font payload (the font
86 /// file is downloaded but never applied). Located at the declaring
87 /// stylesheet. Cleanup candidates: the family could be applied from inline
88 /// styles or set via JavaScript. Sorted by `(path, family)`.
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub unused_font_faces: Vec<UnusedFontFace>,
91 /// Tailwind v4 `@theme` design tokens (`--color-brand`, `--radius-card`)
92 /// defined in a stylesheet but used by no generated utility, `var()` read,
93 /// `@apply`, or arbitrary value anywhere in the project: dead design tokens
94 /// (the `unused-export` of the token era). Present only when the project is
95 /// Tailwind v4 (a `tailwindcss` dependency plus at least one `@theme` block)
96 /// and not a plugin / published-library / partial-scope run. Candidates,
97 /// never gated findings: the token may be consumed by a Tailwind plugin or a
98 /// downstream repo. Sorted by `(path, line, token)`.
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub unused_theme_tokens: Vec<UnusedThemeToken>,
101 /// Tailwind v4 theme tokens whose comparable values are close to another
102 /// token in the same theme dictionary. These are opt-in `--css-deep`
103 /// candidates because they need whole-project token context.
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub near_duplicate_theme_tokens: Vec<NearDuplicateThemeToken>,
106 /// CSS-in-JS design tokens whose comparable values are close to another
107 /// token from the same project. Covers StyleX, vanilla-extract, PandaCSS,
108 /// styled-components, and Emotion token definitions. These are opt-in
109 /// `--css-deep` candidates because they need whole-project token context.
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub near_duplicate_css_in_js_tokens: Vec<NearDuplicateThemeToken>,
112 /// A location-aware reverse index of design-token consumers. Tailwind v4
113 /// entries cover `@theme` tokens consumed through `var()` reads, `@apply`
114 /// bodies, or generated utilities. CSS-in-JS entries cover supported StyleX,
115 /// vanilla-extract, PandaCSS, styled-components, and Emotion definitions and
116 /// their member or call consumers. Every entry includes the defining site,
117 /// located consumer samples, and the full `consumer_count` as a static lower
118 /// bound. Tailwind entries use the same gated candidate set as
119 /// `unused_theme_tokens`; CSS-in-JS entries require supported direct imports.
120 /// Partial-scope runs omit the index. Sorted by token and empty when no
121 /// eligible token definitions are found. A zero count is evidence for
122 /// investigation, not deletion proof.
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub token_consumers: Vec<TokenConsumers>,
125 /// The project authors `font-size` values in several units (`px`, `rem`,
126 /// `em`, `%`), with a per-unit distinct-value count: a type-scale
127 /// inconsistency smell (mixing `px` and `rem` for type works against
128 /// user-zoom accessibility). Present only above a conservative floor.
129 /// Advisory candidate, never gated: the spread can be intentional (fixed
130 /// chrome in `px`, body type in `rem`).
131 ///
132 /// Color-notation mixing (hex vs rgb vs hsl) is deliberately NOT surfaced:
133 /// the CSS parser canonicalizes every legacy sRGB notation to hex before
134 /// fallow sees the value, so the authored distinction is already gone and
135 /// cannot be recovered without a separate raw-token pass.
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub font_size_unit_mix: Option<CssNotationConsistency>,
138}
139
140/// A design-token notation-consistency candidate: the distinct notations used
141/// across the codebase for one value axis (today, length units on `font-size`),
142/// with a per-notation distinct-value count. Emitted only above a floor, since
143/// mixing notations for one axis is a "no single source of truth" smell.
144/// Advisory: the action is "standardize on one notation", not a single search.
145#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147pub struct CssNotationConsistency {
148 /// The value axis these notations describe, e.g. `"Colors"` or
149 /// `"Font sizes"`.
150 pub axis: String,
151 /// Per-notation distinct-value counts, sorted by count descending then
152 /// notation name (so the dominant notation is first and ties are stable).
153 pub notations: Vec<CssNotationCount>,
154 /// Read-only guidance step(s), so consumers can iterate `actions` uniformly
155 /// across every candidate type. Always at least one entry.
156 pub actions: Vec<CssCandidateAction>,
157}
158
159/// One notation bucket and the count of distinct values authored in it.
160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162pub struct CssNotationCount {
163 /// The notation family, e.g. `"hex"`, `"rgb"`, `"hsl"`, `"modern"`, `"px"`,
164 /// `"rem"`, `"em"`, `"%"`.
165 pub notation: String,
166 /// Distinct values authored in this notation across the codebase.
167 pub count: u32,
168}
169
170/// An unused CSS at-rule entity (an `@property` registration with no `var()`
171/// reference, or an `@layer` declaration never populated), located by its first
172/// definition. A cleanup candidate, never a gated finding.
173#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175pub struct UnusedAtRule {
176 /// Which kind of at-rule entity is unused.
177 #[serde(rename = "type")]
178 pub kind: UnusedAtRuleKind,
179 /// The entity name (`--x` for `@property`, the layer name for `@layer`).
180 pub name: String,
181 /// Project-root-relative, forward-slash path to the first defining stylesheet.
182 pub path: String,
183 /// Read-only verification step(s) before removal (parity with other findings).
184 pub actions: Vec<CssCandidateAction>,
185}
186
187/// Discriminant for [`UnusedAtRule::kind`].
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[serde(rename_all = "kebab-case")]
191#[repr(u8)]
192pub enum UnusedAtRuleKind {
193 /// An `@property --x { }` registered but never referenced via `var()`.
194 PropertyRegistration,
195 /// An `@layer a` declared (in a statement or named block) but never
196 /// populated by a `@layer a { }` block.
197 Layer,
198}
199
200/// A distinct Tailwind arbitrary-value utility token used in markup, with its
201/// total use count and first location (a design-token-bypass candidate).
202#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct TailwindArbitraryValue {
205 /// The `prefix-[value]` token (e.g. `w-[13px]`). Variant prefixes are
206 /// stripped, so `hover:w-[13px]` and `w-[13px]` aggregate under `w-[13px]`.
207 pub value: String,
208 /// Total occurrences across all scanned markup files.
209 pub count: u32,
210 /// Project-root-relative, forward-slash path to the first file using it.
211 pub path: String,
212 /// 1-based line of the first occurrence.
213 pub line: u32,
214 /// Read-only action(s): a find-all-occurrences search so the token can be
215 /// replaced with a scale token. Always at least one entry, so consumers can
216 /// iterate `actions` uniformly across every finding type.
217 pub actions: Vec<CssCandidateAction>,
218}
219
220/// A located raw CSS declaration value on a scale-sensitive styling axis.
221#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223pub struct RawStyleValue {
224 /// Value axis, e.g. `color`, `font-size`, `line-height`, `radius`, or `shadow`.
225 pub axis: String,
226 /// CSS property where the raw value appears.
227 pub property: String,
228 /// Rendered declaration value.
229 pub value: String,
230 /// Project-root-relative, forward-slash path to the stylesheet.
231 pub path: String,
232 /// 1-based line of the containing style rule.
233 pub line: u32,
234 /// Concrete token with the same or nearest comparable value, when resolved.
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub nearest_token: Option<NearestStylingToken>,
237 /// Read-only guidance step(s). Never auto-fixable.
238 pub actions: Vec<CssCandidateAction>,
239}
240
241/// A group of style rules across the project that share an identical declaration
242/// block: a copy-paste consolidation candidate (fallow's duplication signal
243/// applied to CSS). Only blocks of 4+ declarations appearing in 2+ rules are
244/// reported, so the signal stays a strong copy-paste indicator rather than
245/// flagging legitimately-repeated small blocks.
246#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248pub struct CssDuplicateBlock {
249 /// Declarations in the shared block.
250 pub declaration_count: u16,
251 /// Number of rules that share the block (always >= 2).
252 pub occurrence_count: u32,
253 /// Declarations removable by extracting the block into one shared rule:
254 /// `(occurrence_count - 1) * declaration_count`.
255 pub estimated_savings: u32,
256 /// The rules sharing the block, sorted by `(path, line)`.
257 pub occurrences: Vec<CssBlockOccurrence>,
258 /// Read-only guidance step(s), so consumers can iterate `actions`
259 /// uniformly across every finding type. Always at least one entry.
260 pub actions: Vec<CssCandidateAction>,
261}
262
263/// One occurrence of a duplicate declaration block.
264#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266pub struct CssBlockOccurrence {
267 /// Project-root-relative, forward-slash path to the stylesheet.
268 pub path: String,
269 /// 1-based line of the rule's first selector.
270 pub line: u32,
271}
272
273/// A duplicated CVA / shadcn variant class block.
274#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
275#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
276pub struct CvaDuplicateVariantBlock {
277 /// Normalized class block shared by several variant values.
278 pub value: String,
279 /// Number of variant values with this class block.
280 pub occurrence_count: u32,
281 /// First locations of the duplicate values, sorted by path and line.
282 pub occurrences: Vec<CssBlockOccurrence>,
283 /// Read-only guidance step(s), so consumers can iterate `actions`
284 /// uniformly across every candidate type.
285 pub actions: Vec<CssCandidateAction>,
286}
287
288/// A CVA / shadcn variant class value that can reuse an existing styling token.
289#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291pub struct CvaVariantTokenDrift {
292 /// Tailwind arbitrary-value utility inside the variant class string.
293 pub class_token: String,
294 /// Normalized value inside the arbitrary utility.
295 pub value: String,
296 /// Full normalized variant class block containing the token.
297 pub variant_classes: String,
298 /// Project-root-relative, forward-slash path to the variant definition.
299 pub path: String,
300 /// 1-based line of the variant class string.
301 pub line: u32,
302 /// Existing token candidate to reuse.
303 pub nearest_token: NearestStylingToken,
304 /// Read-only guidance step(s), so consumers can iterate `actions`
305 /// uniformly across every candidate type.
306 pub actions: Vec<CssCandidateAction>,
307}
308
309/// A `@keyframes` defined in a stylesheet but referenced by no animation in any
310/// stylesheet (cleanup candidate).
311#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313pub struct UnreferencedKeyframes {
314 /// The `@keyframes` name.
315 pub name: String,
316 /// Project-root-relative, forward-slash path to the stylesheet that defines it.
317 pub path: String,
318 /// Read-only verification step(s) an agent can run before removing the
319 /// candidate. Always at least one entry, so consumers can iterate
320 /// `actions` uniformly across every finding type.
321 pub actions: Vec<CssCandidateAction>,
322}
323
324/// An `@font-face` family declared in a stylesheet but referenced by no
325/// `font-family` anywhere in the project: a dead web-font payload. A cleanup
326/// candidate (the family could be applied from inline styles or JavaScript).
327#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
328#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
329pub struct UnusedFontFace {
330 /// The declared font family name (quotes stripped).
331 pub family: String,
332 /// Project-root-relative, forward-slash path to the declaring stylesheet.
333 pub path: String,
334 /// Read-only verification step(s) before removing. Always at least one entry,
335 /// so consumers can iterate `actions` uniformly across every finding type.
336 pub actions: Vec<CssCandidateAction>,
337}
338
339/// A Tailwind v4 `@theme` design token defined in a stylesheet whose generated
340/// utility, `var()` reads, and arbitrary-value references appear nowhere in the
341/// project: a dead design token (the `unused-export` of the token era). A
342/// candidate, never a gated finding: the token could be consumed by a Tailwind
343/// plugin, a published design-system surface, or a non-CSS-aware build step the
344/// scan cannot see (those cases are gated out before this is emitted).
345#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
346#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
347pub struct UnusedThemeToken {
348 /// The full custom property as authored, including the `--` prefix
349 /// (`--color-brand`).
350 pub token: String,
351 /// The Tailwind v4 theme namespace the token belongs to (`color`, `radius`,
352 /// `font-weight`, `breakpoint`, ...).
353 pub namespace: String,
354 /// Project-root-relative, forward-slash path to the declaring stylesheet.
355 pub path: String,
356 /// 1-based line of the token's definition inside the `@theme` block.
357 pub line: u32,
358 /// Read-only verification step(s) before removing. Always at least one entry,
359 /// so consumers can iterate `actions` uniformly across every finding type.
360 pub actions: Vec<CssCandidateAction>,
361}
362
363/// A Tailwind v4 `@theme` token that appears to duplicate an existing token by
364/// value. Emitted conservatively for comparable token namespaces, with the
365/// nearest existing token named so an agent has a concrete reuse target.
366#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
367#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
368pub struct NearDuplicateThemeToken {
369 /// The full custom property as authored, including the `--` prefix.
370 pub token: String,
371 /// The normalized authored token value.
372 pub value: String,
373 /// Project-root-relative, forward-slash path to the token definition.
374 pub path: String,
375 /// 1-based line of the token definition inside the `@theme` block.
376 pub line: u32,
377 /// The nearest existing token candidate to reuse instead.
378 pub nearest_token: NearestStylingToken,
379 /// Read-only guidance step(s) before replacing the token reference.
380 pub actions: Vec<CssCandidateAction>,
381}
382
383/// A styling token candidate that can replace or explain a finding.
384#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
386pub struct NearestStylingToken {
387 /// Token name, e.g. `--color-brand`.
388 pub name: String,
389 /// Normalized token value.
390 pub value: String,
391 /// Project-root-relative, forward-slash definition path.
392 pub path: String,
393 /// 1-based definition line.
394 pub line: u32,
395 /// Distance from the finding value. Lower is closer; units depend on the
396 /// comparable token namespace.
397 pub distance: f64,
398}
399
400/// Where one Tailwind or CSS-in-JS design token is consumed, and through which
401/// surface. One entry in a [`TokenConsumers::consumers`] sample.
402#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
403#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
404pub struct TokenConsumerLocation {
405 /// Project-root-relative, forward-slash path to the consuming file.
406 pub path: String,
407 /// 1-based line of the consuming reference in that file.
408 pub line: u32,
409 /// Which surface consumes the token at this location.
410 pub kind: ConsumerKind,
411}
412
413/// The surface through which a design token is consumed. The `theme-var` /
414/// `css-var` / `utility` / `apply` kinds are Tailwind v4 `@theme` consumption; the
415/// `js-member` / `js-call` kinds are CSS-in-JS consumption (member access through a
416/// same-file or imported StyleX/vanilla-extract token binding, a StyleX
417/// theme-group call, or a PandaCSS token-path call). The kind is the disjoint origin signal that
418/// distinguishes a Tailwind token entry from a CSS-in-JS token entry in the
419/// shared `token_consumers` list.
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
421#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
422#[serde(rename_all = "kebab-case")]
423pub enum ConsumerKind {
424 /// A `var(--token)` read inside a `@theme` block interior (a token backing
425 /// another token).
426 ThemeVar,
427 /// A `var(--token)` read in regular CSS, outside any `@theme` block.
428 CssVar,
429 /// A generated utility class ending in `-<name>` (`bg-brand` consuming
430 /// `--color-brand`) found in markup / className strings / CSS-in-JS.
431 Utility,
432 /// A class-shaped token inside an `@apply` body in a stylesheet.
433 Apply,
434 /// A same-file or cross-module JS member access on a CSS-in-JS token binding
435 /// (`import { vars } from './tokens'; vars.color.primary`), for StyleX
436 /// `defineVars` families or vanilla-extract `createTheme` families.
437 JsMember,
438 /// A CSS-in-JS call that consumes this token. PandaCSS calls consume an
439 /// explicit token path (`token('colors.brand')` or a static style-object
440 /// value). StyleX `createTheme` / `unstable_createThemeNested` calls consume
441 /// every token in their resolved contract group, including tokens omitted by
442 /// partial overrides and empty reset themes.
443 JsCall,
444}
445
446/// A location-aware reverse index of where one design token is consumed, so an
447/// agent editing the token can see its blast radius before changing or removing
448/// it. Covers TWO token origins. The always-available discriminator is the `token`
449/// SHAPE: a Tailwind token is the `--`-prefixed custom property (`--color-brand`),
450/// a CSS-in-JS token is a dotted access path with no `--` prefix
451/// (`vars.color.primary`). The per-consumer `kind` also discriminates origin, but
452/// only when `consumer_count > 0` (a `consumer_count: 0` entry has an empty
453/// `consumers` array and thus no `kind`), so branch on the `token` prefix for the
454/// zero-consumer case. The two origins:
455///
456/// - Tailwind v4 `@theme` tokens (kinds `theme-var` / `css-var` / `utility` /
457/// `apply`), built from the same gated candidate set as `unused_theme_tokens`
458/// (v4 + non-plugin + non-published + whole-scope), so a `consumer_count: 0`
459/// corroborates the `unused_theme_tokens` "nothing consumes this" finding.
460/// - CSS-in-JS tokens (kind `js-member` / `js-call`) from StyleX `defineVars` /
461/// `unstable_defineVarsNested`, vanilla-extract `createTheme` family definitions,
462/// and PandaCSS `defineTokens`, consumed via same-file or cross-module member
463/// access, StyleX theme-group calls, or PandaCSS `token('...')` calls. NOTE:
464/// CSS-in-JS has NO corroborating dead-token finding (there is no
465/// `unused_theme_tokens` analogue), so a CSS-in-JS `consumer_count: 0` is a weaker
466/// signal than the Tailwind case (and unresolved dynamic imports or computed
467/// accesses are not counted).
468///
469/// This is DESCRIPTIVE context (a blast-radius lookup), not a finding, so it
470/// deliberately carries no `actions` array (unlike the cleanup-candidate types in
471/// this module). `consumer_count` is always a STATIC lower bound (a computed class
472/// name like `bg-${color}`, or a CSS-in-JS access through an unresolved alias
473/// import, is not counted).
474#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
475#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
476pub struct TokenConsumers {
477 /// The token identity. For a Tailwind `@theme` token this is the full custom
478 /// property as authored, INCLUDING the `--` prefix (`--color-brand`). For a
479 /// CSS-in-JS token (kind `js-member` / `js-call`) this is the binding-qualified dotted
480 /// access path, NO `--` prefix (`vars.color.primary`), matching how consumers
481 /// read it. The presence of the `--` prefix distinguishes the two origins.
482 pub token: String,
483 /// For a Tailwind token, the v4 theme namespace (`color`, `radius`,
484 /// `font-weight`, ...). For a CSS-in-JS token (kind `js-member` / `js-call`), the defining
485 /// export BINDING the token set is accessed through (`vars`), which identifies
486 /// the token set, NOT a semantic group. (The field is thus overloaded by
487 /// origin; branch on `consumers[].kind` or the `token` shape.)
488 pub namespace: String,
489 /// Project-root-relative, forward-slash path to the declaring stylesheet
490 /// (Tailwind) or the JS/TS token-definition file (CSS-in-JS).
491 pub definition_path: String,
492 /// 1-based line of the token's definition (inside the `@theme` block for
493 /// Tailwind; the token key inside the `defineVars`/`createTheme` object for
494 /// CSS-in-JS).
495 pub definition_line: u32,
496 /// The FULL number of consumer locations found, a STATIC LOWER BOUND: a
497 /// computed class name (`bg-${color}`), unresolved import, dynamic token
498 /// structure, or computed CSS-in-JS access is not counted. This is the
499 /// aggregate over every consumer, computed BEFORE
500 /// [`consumers`](Self::consumers) is capped to a sample.
501 pub consumer_count: u32,
502 /// A capped, deterministically-sorted sample of consumer locations (at most
503 /// [`TOKEN_CONSUMER_SAMPLE_CAP`]). The full count lives in
504 /// [`consumer_count`](Self::consumer_count); use this list to jump to
505 /// representative consumers, not to enumerate every one.
506 pub consumers: Vec<TokenConsumerLocation>,
507}
508
509/// Maximum number of consumer locations sampled into [`TokenConsumers::consumers`].
510/// The full count is preserved in [`TokenConsumers::consumer_count`]
511/// (aggregate-before-truncate), so capping the sample never distorts the count.
512pub const TOKEN_CONSUMER_SAMPLE_CAP: usize = 20;
513
514/// A global CSS class defined in a plain `.css`/`.scss` rule whose literal name
515/// is referenced by no in-project markup (the CSS analogue of an unused export).
516/// A heavily-gated candidate, never a gated finding: the class may be applied
517/// from an HTML email, server template, CMS, or Markdown the parser never sees.
518#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
519#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
520pub struct UnreferencedCssClass {
521 /// The class name (no dot).
522 pub class: String,
523 /// Project-root-relative, forward-slash path to the defining stylesheet.
524 pub path: String,
525 /// 1-based line of the class's first definition.
526 pub line: u32,
527 /// Read-only verification step(s) before removing. Always at least one entry,
528 /// so consumers can iterate `actions` uniformly across every finding type.
529 pub actions: Vec<CssCandidateAction>,
530}
531
532/// An animation reference (`animation` / `animation-name`) to a `@keyframes`
533/// name that is defined in no stylesheet anywhere in the project (the
534/// "used-but-undefined" direction). Usually a typo or a removed animation;
535/// occasionally a `@keyframes` defined in CSS-in-JS the CSS parser never sees.
536#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
537#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
538pub struct UndefinedKeyframes {
539 /// The referenced `@keyframes` name that resolves to no definition.
540 pub name: String,
541 /// Project-root-relative, forward-slash path to the first stylesheet that
542 /// references it.
543 pub path: String,
544 /// Read-only verification step(s) an agent can run before fixing the
545 /// reference. Always at least one entry, so consumers can iterate `actions`
546 /// uniformly across every finding type.
547 pub actions: Vec<CssCandidateAction>,
548}
549
550/// A static `class` / `className` token in markup that matches no CSS class
551/// defined anywhere in the project but is one edit away from a class that IS
552/// defined (a likely typo or stale rename). The CSS analogue of an unresolved
553/// import. A candidate, never a gated finding: the token could be defined in
554/// CSS-in-JS or an external stylesheet the parser never sees.
555#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
556#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
557pub struct UnresolvedClassReference {
558 /// The static class token referenced in markup (no dot).
559 pub class: String,
560 /// The defined CSS class one edit away: the likely intended class.
561 pub suggestion: String,
562 /// Project-root-relative, forward-slash path to the markup file.
563 pub path: String,
564 /// 1-based line of the `class` / `className` attribute.
565 pub line: u32,
566 /// Read-only verification step(s) before fixing the reference. Always at
567 /// least one entry, so consumers can iterate `actions` uniformly across
568 /// every finding type.
569 pub actions: Vec<CssCandidateAction>,
570}
571
572/// A Vue SFC's `<style scoped>` classes that appear nowhere else in the
573/// component (cleanup candidates).
574#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
575#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
576pub struct ScopedUnusedClasses {
577 /// Project-root-relative, forward-slash path to the SFC.
578 pub path: String,
579 /// The scoped class names with no use elsewhere in the component, sorted.
580 pub classes: Vec<String>,
581 /// Read-only verification step(s) an agent can run before removing the
582 /// candidate. Always at least one entry, so consumers can iterate
583 /// `actions` uniformly across every finding type.
584 pub actions: Vec<CssCandidateAction>,
585}
586
587/// One advisory STYLING FINDING: the graduation of a descriptive css candidate
588/// into a first-class, severity-aware, suppressible finding surfaced in
589/// `fallow audit`. The styling domain's OWN finding type (not borrowed into the
590/// dead-code `AnalysisResults`, and not glued in the CLI). `code` is the kebab
591/// IssueKind code (e.g. `css-token-drift`), so severity / inline suppression /
592/// SARIF / MCP all resolve via the shared `issue_meta` contract through
593/// `IssueKind::parse(code)`. One `Vec<StylingFinding>` carries every styling
594/// family; the `code` discriminates.
595#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
596#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
597pub struct StylingFinding {
598 /// The kebab IssueKind code, e.g. `css-token-drift`.
599 pub code: String,
600 /// The specific sub-kind within the family, e.g. `tailwind-arbitrary-value`.
601 pub sub_kind: String,
602 /// Workspace-relative path of the finding.
603 pub path: String,
604 /// 1-based line of the finding.
605 pub line: u32,
606 /// The offending literal value, e.g. `w-[13px]`.
607 pub value: String,
608 /// Effective severity after applying `rules.css-*` config. Styling defaults
609 /// to `warn`, but projects can escalate a family to `error` for audit gates
610 /// and CI formats.
611 pub effective_severity: StylingFindingSeverity,
612 /// Optional static lower-bound blast radius. For a dead design token this is
613 /// `0`; for other styling findings it is omitted.
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub blast_radius: Option<u32>,
616 /// Confidence hint for agents and review UIs. Structural findings are high,
617 /// reachability findings are low because dynamic consumers may exist.
618 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub confidence: Option<StylingFindingConfidence>,
620 /// Suggested handling posture for agents. This is advisory data, fallow
621 /// still never applies styling changes automatically.
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub agent_disposition: Option<StylingAgentDisposition>,
624 /// Concrete reuse target for token-drift findings, when one can be resolved.
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub nearest_token: Option<NearestStylingToken>,
627 /// One concise machine-readable edit hint for agent consumers.
628 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub fix_hint: Option<String>,
630 /// Suggested next steps (verify / suppress; never an auto-fix).
631 pub actions: Vec<CssCandidateAction>,
632}
633
634/// Effective configured severity for a styling finding.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
636#[serde(rename_all = "kebab-case")]
637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
638pub enum StylingFindingSeverity {
639 /// Advisory finding; never fails a gate.
640 Warn,
641 /// Gating finding under an error-severity rule.
642 Error,
643}
644
645/// Confidence hint for a [`StylingFinding`].
646#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
647#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
648#[serde(rename_all = "kebab-case")]
649pub enum StylingFindingConfidence {
650 /// The finding is local and structural.
651 High,
652 /// The finding depends on reachability and should be verified.
653 Low,
654}
655
656/// Agent handling hint for a [`StylingFinding`].
657#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
658#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
659#[serde(rename_all = "kebab-case")]
660pub enum StylingAgentDisposition {
661 /// The finding names a concrete structural edit target.
662 FixConfidently,
663 /// Verify dynamic or external consumers before changing code.
664 VerifyFirst,
665}
666
667/// A read-only verification step attached to a CSS cleanup candidate.
668///
669/// CSS candidates (unreferenced `@keyframes`, unused scoped classes) are never
670/// auto-removed: an animation name can still be applied from JavaScript, and a
671/// class can be assembled from a dynamic string binding. The action gives an
672/// agent a machine-readable next step, mirroring the `actions` array carried by
673/// every other health finding, plus an optional runnable probe to confirm the
674/// candidate is genuinely unused before deleting it.
675#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
677pub struct CssCandidateAction {
678 /// Action type identifier (`verify-unused`).
679 #[serde(rename = "type")]
680 pub kind: CssCandidateActionType,
681 /// Always `false`: CSS candidates are never auto-fixed (`fallow fix` does
682 /// not touch them) because the residual consumer may live outside CSS.
683 pub auto_fixable: bool,
684 /// Human-readable description of what to confirm before removing.
685 pub description: String,
686 /// A runnable, read-only, placeholder-free token search that surfaces any
687 /// out-of-CSS use of the candidate. Absent when no shell-safe command can
688 /// be built (e.g. the residual risk is a dynamic string binding that a
689 /// single search cannot probe), in which case `description` is the guide.
690 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub command: Option<String>,
692}
693
694/// Discriminant for [`CssCandidateAction::kind`].
695#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
696#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
697#[serde(rename_all = "kebab-case")]
698pub enum CssCandidateActionType {
699 /// Confirm the candidate has no JavaScript / HTML / dynamic consumer
700 /// before removing it (the defined-but-unused candidates).
701 VerifyUnused,
702 /// Confirm the referenced name is genuinely undefined (not defined in
703 /// CSS-in-JS the parser cannot see) before treating it as a typo (the
704 /// used-but-undefined candidates).
705 VerifyUndefined,
706 /// Extract the shared declaration block into one rule and reference it from
707 /// each occurrence (the duplicate-declaration-block candidates).
708 Consolidate,
709 /// Replace a Tailwind arbitrary value with a configured scale token, or
710 /// confirm the one-off is intentional (the arbitrary-value candidates).
711 ReplaceWithToken,
712 /// Standardize an inconsistent value axis on a single notation (the
713 /// color-format / length-unit mixing candidates).
714 Standardize,
715 /// Simplify a selector, reduce nesting, or remove unnecessary `!important`
716 /// usage after verifying the cascade.
717 SimplifySelector,
718}
719
720impl CssCandidateAction {
721 /// Read-only guidance for a selector / nesting / important-density finding.
722 #[must_use]
723 pub fn simplify_selector(reason: &str) -> Self {
724 Self {
725 kind: CssCandidateActionType::SimplifySelector,
726 auto_fixable: false,
727 description: format!(
728 "Review cascade impact, then simplify this selector or rule because {reason}."
729 ),
730 command: None,
731 }
732 }
733
734 /// Verify action for an unused `@font-face` family: a read-only token search
735 /// for any inline-style or JavaScript application of the family before
736 /// removing the dead web-font.
737 #[must_use]
738 pub fn verify_unused_font_face(family: &str) -> Self {
739 Self {
740 kind: CssCandidateActionType::VerifyUnused,
741 auto_fixable: false,
742 description: format!(
743 "Confirm the \"{family}\" font family is not applied from an inline style or JavaScript before removing the @font-face and its font files."
744 ),
745 command: safe_token_search(family),
746 }
747 }
748
749 /// Verify action for an unused Tailwind v4 `@theme` token: a read-only search
750 /// that embeds the LITERAL terms an agent should grep for, the generated
751 /// utility suffix (`bg-<name>` / `text-<name>` / `<namespace>-<name>`), the
752 /// `var(--<ns>-<name>)` read, and the arbitrary `[--<ns>-<name>]` value,
753 /// before removing the token. Verify-then-remove; never auto-fixable.
754 #[must_use]
755 pub fn verify_unused_theme_token(token: &str, namespace: &str, name: &str) -> Self {
756 Self {
757 kind: CssCandidateActionType::VerifyUnused,
758 auto_fixable: false,
759 description: format!(
760 "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."
761 ),
762 command: theme_token_search(namespace, name),
763 }
764 }
765
766 /// Guidance for a near-duplicate theme token: reuse the named existing
767 /// token after checking semantic intent.
768 #[must_use]
769 pub fn replace_near_duplicate_token(token: &str, nearest: &str) -> Self {
770 Self {
771 kind: CssCandidateActionType::ReplaceWithToken,
772 auto_fixable: false,
773 description: format!(
774 "Verify {token} is not an intentional semantic alias, then reuse {nearest} instead."
775 ),
776 command: safe_token_search(token),
777 }
778 }
779
780 /// Verify action for an unreferenced global CSS class: name the surfaces the
781 /// in-project scan does NOT cover (the class could be applied from there) and
782 /// ship a read-only token search to double-check before removing.
783 ///
784 /// `template_extensions` names the server-side template files the scan
785 /// also read (for example `liquid`), so the text and the search cover them.
786 #[must_use]
787 pub fn verify_unreferenced_class(name: &str, template_extensions: &[String]) -> Self {
788 let templates = if template_extensions.is_empty() {
789 String::new()
790 } else {
791 let listed: Vec<String> = template_extensions
792 .iter()
793 .map(|extension| format!(".{extension}"))
794 .collect();
795 format!(", plus {} templates", listed.join(", "))
796 };
797 Self {
798 kind: CssCandidateActionType::VerifyUnused,
799 auto_fixable: false,
800 description: format!(
801 "Confirm no HTML email, server-rendered template, or CMS content applies the \"{name}\" class before removing it (fallow scanned in-project JS/TS/HTML/Vue/Svelte/Astro/Markdown markup{templates})."
802 ),
803 command: safe_token_search_with(name, template_extensions),
804 }
805 }
806
807 /// Verify action for an unreferenced `@keyframes`: a read-only token search
808 /// for any JavaScript or template reference that applies the animation
809 /// (which the CSS-only scan cannot see).
810 #[must_use]
811 pub fn verify_keyframe(name: &str) -> Self {
812 Self {
813 kind: CssCandidateActionType::VerifyUnused,
814 auto_fixable: false,
815 description: format!(
816 "Confirm no JavaScript or template applies the \"{name}\" animation before removing the @keyframes."
817 ),
818 command: safe_token_search(name),
819 }
820 }
821
822 /// Verify action for an animation reference to a `@keyframes` that is
823 /// defined in no stylesheet: a read-only token search for a CSS-in-JS
824 /// `@keyframes`/animation definition of the name (styled-components,
825 /// Emotion, vanilla-extract) before treating the reference as a typo.
826 #[must_use]
827 pub fn verify_undefined_keyframe(name: &str) -> Self {
828 Self {
829 kind: CssCandidateActionType::VerifyUndefined,
830 auto_fixable: false,
831 description: format!(
832 "Confirm \"{name}\" is not a @keyframes defined in CSS-in-JS (styled-components, Emotion, vanilla-extract) before treating the animation reference as a typo."
833 ),
834 command: safe_token_search(name),
835 }
836 }
837
838 /// Guidance action for a mixed value axis (colors authored in several
839 /// notations, or font sizes in several units): standardize on the single
840 /// dominant notation. No command (this is a project-wide refactor, and the
841 /// per-notation breakdown already quantifies the spread); the residual
842 /// judgment is whether the spread is an intentional migration in progress.
843 #[must_use]
844 pub fn standardize_notation(axis: &str, dominant: &str) -> Self {
845 Self {
846 kind: CssCandidateActionType::Standardize,
847 auto_fixable: false,
848 description: format!(
849 "{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."
850 ),
851 command: None,
852 }
853 }
854
855 /// Guidance action for a duplicate declaration block: consolidate the shared
856 /// declarations into one rule. No command (consolidation is a refactor, and
857 /// the occurrences list already names every site); the residual judgment is
858 /// whether the rules are intentionally separate overrides.
859 #[must_use]
860 pub fn consolidate_block(occurrence_count: u32) -> Self {
861 Self {
862 kind: CssCandidateActionType::Consolidate,
863 auto_fixable: false,
864 description: format!(
865 "Extract this declaration block into one rule and reference it from all {occurrence_count} occurrences, unless they are intentionally separate overrides."
866 ),
867 command: None,
868 }
869 }
870
871 /// Action for a Tailwind arbitrary-value bypass: a read-only fixed-string
872 /// search for every occurrence of the token so it can be replaced with a
873 /// scale token (or confirmed an intentional one-off). The value is a Tailwind
874 /// utility token (no quotes / whitespace by construction), so it is safe to
875 /// single-quote; the `-F` keeps the `[` / `]` literal rather than a glob.
876 #[must_use]
877 pub fn replace_arbitrary_value(value: &str) -> Self {
878 let command = (!value.contains('\'')).then(|| {
879 format!(
880 "grep -rnF '{value}' --include='*.jsx' --include='*.tsx' --include='*.html' --include='*.vue' --include='*.svelte' --include='*.astro' ."
881 )
882 });
883 Self {
884 kind: CssCandidateActionType::ReplaceWithToken,
885 auto_fixable: false,
886 description:
887 "Replace this one-off arbitrary value with a scale token from your Tailwind theme, or confirm it is intentional."
888 .to_string(),
889 command,
890 }
891 }
892
893 /// Guidance for a CVA / shadcn variant arbitrary value: replace the
894 /// utility with a token-backed variant class after checking variant intent.
895 #[must_use]
896 pub fn replace_cva_variant_arbitrary_value(class_token: &str, nearest: &str) -> Self {
897 Self {
898 kind: CssCandidateActionType::ReplaceWithToken,
899 auto_fixable: false,
900 description: format!(
901 "Verify this CVA variant value is not an intentional one-off, then replace {class_token} with a class backed by {nearest}."
902 ),
903 command: safe_token_search(class_token),
904 }
905 }
906
907 /// Guidance for a raw CSS value on a scale-sensitive axis: replace with an
908 /// existing token or confirm the one-off is intentional.
909 #[must_use]
910 pub fn replace_raw_style_value(axis: &str, value: &str) -> Self {
911 Self {
912 kind: CssCandidateActionType::ReplaceWithToken,
913 auto_fixable: false,
914 description: format!(
915 "Replace this raw {axis} value with an existing design token or CSS custom property, or confirm this one-off is intentional."
916 ),
917 command: safe_token_search(value),
918 }
919 }
920
921 /// Verify action for an unused CSS at-rule entity: a read-only search for
922 /// any out-of-CSS consumer (JS reading an `@property`; an `@import layer()`
923 /// populating a layer) before removing it.
924 #[must_use]
925 pub fn verify_unused_at_rule(kind: UnusedAtRuleKind, name: &str) -> Self {
926 let description = match kind {
927 UnusedAtRuleKind::PropertyRegistration => format!(
928 "Confirm \"{name}\" is not read or set from JavaScript before removing the @property registration."
929 ),
930 UnusedAtRuleKind::Layer => format!(
931 "Confirm the @layer \"{name}\" is not populated via @import layer() before removing the declaration."
932 ),
933 };
934 Self {
935 kind: CssCandidateActionType::VerifyUnused,
936 auto_fixable: false,
937 description,
938 command: safe_token_search(name),
939 }
940 }
941
942 /// Verify action for a markup class token that matches no defined CSS class
943 /// but is one edit from a class that is defined: surface the suggestion and a
944 /// read-only token search so the residual risk (a class defined in CSS-in-JS
945 /// or an external stylesheet) can be ruled out before fixing the typo.
946 #[must_use]
947 pub fn verify_unresolved_class(class: &str, suggestion: &str) -> Self {
948 Self {
949 kind: CssCandidateActionType::VerifyUndefined,
950 auto_fixable: false,
951 description: format!(
952 "\"{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."
953 ),
954 command: safe_token_search(class),
955 }
956 }
957
958 /// Verify action for a Vue SFC's unused scoped classes. The component-scoped
959 /// scan already covers every static use, so the only residual risk is a
960 /// class assembled from a dynamic string; that is a manual check, so the
961 /// action carries guidance but no command.
962 #[must_use]
963 pub fn verify_scoped_classes() -> Self {
964 Self {
965 kind: CssCandidateActionType::VerifyUnused,
966 auto_fixable: false,
967 description:
968 "Confirm none of these scoped classes is assembled from a dynamic string (e.g. `:class=\"prefix + name\"`) before removing them."
969 .to_string(),
970 command: None,
971 }
972 }
973}
974
975/// Build a read-only, placeholder-free, namespace-QUALIFIED search for a Tailwind
976/// v4 `@theme` token, or `None` when the namespace / name is not a plain CSS
977/// identifier (so the emitted command is always shell-safe). The pattern matches
978/// any `*-<name>` utility (`bg-<name>`, `rounded-<name>`, `font-<name>`, ...) AND
979/// the `--<ns>-<name>` custom property (covering `var()` reads and `[--ns-name]`
980/// arbitrary values), deliberately NOT a bare `<name>` (which would substring-hit
981/// every file for a dictionary-word token like `brand` / `card`).
982fn theme_token_search(namespace: &str, name: &str) -> Option<String> {
983 let is_plain = |s: &str| {
984 !s.is_empty()
985 && s.bytes()
986 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
987 };
988 (is_plain(namespace) && is_plain(name)).then(|| {
989 format!(
990 "grep -rnE -- '-{name}\\b|--{namespace}-{name}' --include='*.css' --include='*.html' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.astro' ."
991 )
992 })
993}
994
995/// Build a read-only, placeholder-free token search for `name`, or `None` when
996/// the name is not a plain CSS identifier, so the emitted command is always
997/// shell-safe without quoting tricks.
998fn safe_token_search(name: &str) -> Option<String> {
999 safe_token_search_with(name, &[])
1000}
1001
1002/// [`safe_token_search`] that also searches files with the given extensions.
1003/// An extension with a byte outside `[A-Za-z0-9._-]` is left out, so the
1004/// command stays safe to paste into a shell.
1005fn safe_token_search_with(name: &str, extra_extensions: &[String]) -> Option<String> {
1006 let is_plain = !name.is_empty()
1007 && name
1008 .bytes()
1009 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
1010 is_plain.then(|| {
1011 let mut extra = String::new();
1012 for extension in extra_extensions {
1013 let is_safe = !extension.is_empty()
1014 && extension
1015 .bytes()
1016 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
1017 if is_safe {
1018 extra.push_str(" --include='*.");
1019 extra.push_str(extension);
1020 extra.push('\'');
1021 }
1022 }
1023 format!(
1024 "grep -rnw '{name}' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.astro' --include='*.html' --include='*.md' --include='*.mdx'{extra} ."
1025 )
1026 })
1027}
1028
1029/// Per-stylesheet CSS analytics.
1030#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1031#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1032pub struct CssFileAnalytics {
1033 /// Project-root-relative, forward-slash path.
1034 pub path: String,
1035 /// The stylesheet's structural metrics.
1036 pub analytics: fallow_types::extract::CssAnalytics,
1037}
1038
1039/// Project-wide CSS analytics aggregates across every analyzed stylesheet
1040/// (including stylesheets with no notable rule, which are not listed
1041/// individually in `files`).
1042#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
1043#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1044pub struct CssAnalyticsSummary {
1045 /// Stylesheets analyzed: standard `.css` files, Vue/Svelte SFC `<style>`
1046 /// blocks, and (dep-gated) CSS-in-JS, both the tagged-template form and the
1047 /// object form (`style({...})` / `stylex.create({...})` / `css({...})`). SCSS
1048 /// is skipped. Note: flat atomic object CSS-in-JS (StyleX/Panda) is counted
1049 /// here and contributes to these aggregates, but has no notable rules, so its
1050 /// files never appear in the per-file `files` list.
1051 pub files_analyzed: u32,
1052 /// Total style rules across analyzed stylesheets.
1053 pub total_rules: u32,
1054 /// Total declarations across analyzed stylesheets.
1055 pub total_declarations: u32,
1056 /// Total `!important` declarations across analyzed stylesheets.
1057 pub important_declarations: u32,
1058 /// Total empty style rules across analyzed stylesheets.
1059 pub empty_rules: u32,
1060 /// Deepest style-rule nesting depth observed across analyzed stylesheets.
1061 pub max_nesting_depth: u8,
1062 /// Distinct color values (authored form) across the whole codebase. A high
1063 /// count signals an uncontrolled palette (design-token sprawl).
1064 pub unique_colors: u32,
1065 /// Distinct `font-size` values across the whole codebase.
1066 pub unique_font_sizes: u32,
1067 /// Distinct `z-index` values across the whole codebase.
1068 pub unique_z_indexes: u32,
1069 /// Distinct `box-shadow` values across the whole codebase (shadow-scale sprawl).
1070 pub unique_box_shadows: u32,
1071 /// Distinct `border-radius` values across the whole codebase (radius-scale sprawl).
1072 pub unique_border_radii: u32,
1073 /// Distinct `line-height` values across the whole codebase (type-scale sprawl).
1074 pub unique_line_heights: u32,
1075 /// Distinct custom properties (`--x`) defined anywhere in the codebase.
1076 pub custom_properties_defined: u32,
1077 /// Custom properties defined but never referenced via `var()` in any
1078 /// stylesheet (the defined-but-unused direction). These are cleanup
1079 /// CANDIDATES, not confirmed dead: a property may still be read or set from
1080 /// JavaScript or inline HTML styles.
1081 pub custom_properties_unreferenced: u32,
1082 /// Distinct custom properties referenced via `var()` that are defined in no
1083 /// stylesheet anywhere (the used-but-undefined direction). A COUNT only, not
1084 /// a located list: a `var(--x)` with no CSS definition is extremely common
1085 /// in JavaScript-driven theming and design-token libraries, so locating
1086 /// these would be net-noise. The count is an architecture signal (how much
1087 /// of the `var()` surface is resolved outside CSS), not a finding.
1088 pub custom_properties_undefined: u32,
1089 /// Distinct `@keyframes` defined anywhere in the codebase.
1090 pub keyframes_defined: u32,
1091 /// `@keyframes` defined but never referenced via `animation` /
1092 /// `animation-name` in any stylesheet (the defined-but-unused direction;
1093 /// cleanup CANDIDATES; an animation name can still be applied from
1094 /// JavaScript).
1095 pub keyframes_unreferenced: u32,
1096 /// Distinct animation names referenced via `animation` / `animation-name`
1097 /// that resolve to no `@keyframes` definition anywhere (the used-but-
1098 /// undefined direction). Located in `undefined_keyframes`; usually a typo or
1099 /// a removed animation.
1100 pub keyframes_undefined: u32,
1101 /// Total Vue `<style scoped>` classes used nowhere else in their component
1102 /// (cleanup candidates), across all SFCs.
1103 pub scoped_unused_classes: u32,
1104 /// Number of distinct declaration blocks (4+ declarations) that appear in
1105 /// two or more rules across the project (copy-paste consolidation
1106 /// candidates). Located in `duplicate_declaration_blocks`.
1107 pub duplicate_declaration_blocks: u32,
1108 /// Total declarations removable by consolidating every duplicate block:
1109 /// the sum of `(occurrence_count - 1) * declaration_count` across groups.
1110 pub duplicate_declarations_total: u32,
1111 /// Distinct Tailwind arbitrary-value tokens used in markup (design-token
1112 /// bypass). Zero when the project does not use Tailwind. Located in
1113 /// `tailwind_arbitrary_values`.
1114 pub tailwind_arbitrary_values: u32,
1115 /// Total Tailwind arbitrary-value occurrences across markup.
1116 pub tailwind_arbitrary_value_uses: u32,
1117 /// Preprocessor stylesheets (`.scss`, `.sass`, `.less`) seen by the styling
1118 /// scan. These are parsed textually for local candidates, not compiled.
1119 pub preprocessor_stylesheets: u32,
1120 /// True when project-wide class reachability was skipped because
1121 /// preprocessor stylesheets outnumber plain CSS, making generated classes
1122 /// invisible without a Sass/Less compiler.
1123 pub preprocessor_reachability_abstained: bool,
1124 /// Located raw CSS declaration values that bypass token surfaces on
1125 /// scale-sensitive axes. Located in `raw_style_values`.
1126 pub raw_style_values: u32,
1127 /// `@property` registrations never referenced via `var()` in any stylesheet
1128 /// (located in `unused_at_rules`). Cleanup candidates.
1129 pub unused_property_registrations: u32,
1130 /// Cascade layers declared but never populated by a block (located in
1131 /// `unused_at_rules`). Cleanup candidates.
1132 pub unused_layers: u32,
1133 /// Static markup class tokens that match no defined CSS class but are one
1134 /// edit from a defined class (likely typos / stale renames). Located in
1135 /// `unresolved_class_references`. Candidates, never gated.
1136 pub unresolved_class_references: u32,
1137 /// Global CSS classes defined in a stylesheet but referenced by no in-project
1138 /// markup (located in `unreferenced_css_classes`). Heavily gated cleanup
1139 /// candidates; zero on preprocessor-dominant or partial-scope runs.
1140 pub unreferenced_css_classes: u32,
1141 /// `@font-face` families declared but referenced by no `font-family` anywhere
1142 /// (located in `unused_font_faces`). Dead web-font cleanup candidates.
1143 pub unused_font_faces: u32,
1144 /// Tailwind v4 `@theme` design tokens defined but used by no generated
1145 /// utility, `var()`, `@apply`, or arbitrary value anywhere (located in
1146 /// `unused_theme_tokens`). Dead-design-token cleanup candidates; zero when
1147 /// the project is not Tailwind v4 or a plugin / published-library /
1148 /// partial-scope run gated the scan out.
1149 pub unused_theme_tokens: u32,
1150 /// Tailwind v4 theme tokens whose comparable values are close to another
1151 /// token in the same theme dictionary. Located in
1152 /// `near_duplicate_theme_tokens`.
1153 pub near_duplicate_theme_tokens: u32,
1154 /// CSS-in-JS design tokens whose comparable values are close to another
1155 /// token from the same project. Located in
1156 /// `near_duplicate_css_in_js_tokens`.
1157 pub near_duplicate_css_in_js_tokens: u32,
1158 /// Number of distinct `font-size` units (`px` / `rem` / `em` / `%`) authored
1159 /// across the codebase. Mixing units is a type-scale consistency smell,
1160 /// broken out in `font_size_unit_mix`.
1161 pub font_size_units_used: u32,
1162 /// Number of analyzed stylesheets whose per-rule `notable_rules` list was
1163 /// truncated at the per-file cap, so a consumer knows the per-rule detail is
1164 /// incomplete without walking every file.
1165 pub notable_truncated_files: u32,
1166}
1167
1168#[cfg(test)]
1169#[allow(
1170 clippy::unwrap_used,
1171 reason = "tests use unwrap to keep serialization assertions concise"
1172)]
1173mod tests {
1174 use super::*;
1175
1176 #[test]
1177 fn consumer_kind_serializes_kebab_case() {
1178 let kinds = [
1179 (ConsumerKind::ThemeVar, "\"theme-var\""),
1180 (ConsumerKind::CssVar, "\"css-var\""),
1181 (ConsumerKind::Utility, "\"utility\""),
1182 (ConsumerKind::Apply, "\"apply\""),
1183 ];
1184 for (kind, expected) in kinds {
1185 assert_eq!(serde_json::to_string(&kind).unwrap(), expected);
1186 }
1187 }
1188
1189 #[test]
1190 fn token_consumers_serializes_full_shape() {
1191 let entry = TokenConsumers {
1192 token: "--color-brand".to_string(),
1193 namespace: "color".to_string(),
1194 definition_path: "src/theme.css".to_string(),
1195 definition_line: 4,
1196 consumer_count: 2,
1197 consumers: vec![
1198 TokenConsumerLocation {
1199 path: "src/Button.tsx".to_string(),
1200 line: 12,
1201 kind: ConsumerKind::Utility,
1202 },
1203 TokenConsumerLocation {
1204 path: "src/theme.css".to_string(),
1205 line: 9,
1206 kind: ConsumerKind::CssVar,
1207 },
1208 ],
1209 };
1210 let value = serde_json::to_value(&entry).unwrap();
1211 assert_eq!(value["consumer_count"], 2);
1212 assert_eq!(value["definition_line"], 4);
1213 assert_eq!(value["consumers"][0]["kind"], "utility");
1214 assert_eq!(value["consumers"][1]["kind"], "css-var");
1215 }
1216
1217 #[test]
1218 fn token_consumers_omitted_when_empty() {
1219 let report = CssAnalyticsReport {
1220 files: Vec::new(),
1221 summary: CssAnalyticsSummary::default(),
1222 scoped_unused: Vec::new(),
1223 unreferenced_keyframes: Vec::new(),
1224 undefined_keyframes: Vec::new(),
1225 duplicate_declaration_blocks: Vec::new(),
1226 cva_duplicate_variant_blocks: Vec::new(),
1227 cva_variant_token_drifts: Vec::new(),
1228 tailwind_arbitrary_values: Vec::new(),
1229 raw_style_values: Vec::new(),
1230 unused_at_rules: Vec::new(),
1231 unresolved_class_references: Vec::new(),
1232 unreferenced_css_classes: Vec::new(),
1233 unused_font_faces: Vec::new(),
1234 unused_theme_tokens: Vec::new(),
1235 near_duplicate_theme_tokens: Vec::new(),
1236 near_duplicate_css_in_js_tokens: Vec::new(),
1237 token_consumers: Vec::new(),
1238 font_size_unit_mix: None,
1239 };
1240 let value = serde_json::to_value(&report).unwrap();
1241 assert!(
1242 value.get("token_consumers").is_none(),
1243 "empty token_consumers must be skipped"
1244 );
1245 }
1246
1247 #[test]
1248 fn token_consumers_present_when_non_empty() {
1249 let report = CssAnalyticsReport {
1250 files: Vec::new(),
1251 summary: CssAnalyticsSummary::default(),
1252 scoped_unused: Vec::new(),
1253 unreferenced_keyframes: Vec::new(),
1254 undefined_keyframes: Vec::new(),
1255 duplicate_declaration_blocks: Vec::new(),
1256 cva_duplicate_variant_blocks: Vec::new(),
1257 cva_variant_token_drifts: Vec::new(),
1258 tailwind_arbitrary_values: Vec::new(),
1259 raw_style_values: Vec::new(),
1260 unused_at_rules: Vec::new(),
1261 unresolved_class_references: Vec::new(),
1262 unreferenced_css_classes: Vec::new(),
1263 unused_font_faces: Vec::new(),
1264 unused_theme_tokens: Vec::new(),
1265 near_duplicate_theme_tokens: Vec::new(),
1266 near_duplicate_css_in_js_tokens: Vec::new(),
1267 token_consumers: vec![TokenConsumers {
1268 token: "--color-brand".to_string(),
1269 namespace: "color".to_string(),
1270 definition_path: "src/theme.css".to_string(),
1271 definition_line: 4,
1272 consumer_count: 0,
1273 consumers: Vec::new(),
1274 }],
1275 font_size_unit_mix: None,
1276 };
1277 let value = serde_json::to_value(&report).unwrap();
1278 assert_eq!(value["token_consumers"][0]["consumer_count"], 0);
1279 }
1280}