Skip to main content

fallow_output/
health_css.rs

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