Skip to main content

fallow_output/
health_css.rs

1/// Structural CSS analytics surfaced by `fallow health --css`.
2#[derive(Debug, Clone, serde::Serialize)]
3#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
4pub struct CssAnalyticsReport {
5    /// Stylesheets with at least one structurally notable rule, in scan order.
6    pub files: Vec<CssFileAnalytics>,
7    /// Project-wide CSS aggregates across every analyzed stylesheet.
8    pub summary: CssAnalyticsSummary,
9    /// Vue SFCs whose `<style scoped>` defines classes used nowhere else in the
10    /// component (cleanup candidates).
11    #[serde(default, skip_serializing_if = "Vec::is_empty")]
12    pub scoped_unused: Vec<ScopedUnusedClasses>,
13    /// `@keyframes` defined but referenced via no `animation` / `animation-name`
14    /// in any stylesheet, with the stylesheet that defines them (cleanup
15    /// candidates; an animation name can still be applied from JavaScript).
16    /// The "defined-but-unused" direction.
17    #[serde(default, skip_serializing_if = "Vec::is_empty")]
18    pub unreferenced_keyframes: Vec<UnreferencedKeyframes>,
19    /// Animation references (`animation` / `animation-name`) to a `@keyframes`
20    /// name that is defined in NO stylesheet anywhere in the project, with the
21    /// first stylesheet that references them. The "used-but-undefined" direction
22    /// (the inverse of `unreferenced_keyframes`): usually a typo or a removed
23    /// animation, occasionally a `@keyframes` defined in CSS-in-JS (which the
24    /// CSS parser never sees). Conservative candidates, never gated findings.
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub undefined_keyframes: Vec<UndefinedKeyframes>,
27    /// Groups of style rules across the project that share an identical
28    /// declaration block (4+ declarations, sorted and `!important`-aware),
29    /// grouped by content: copy-paste consolidation candidates (fallow's
30    /// duplication signal applied to CSS). Sorted by estimated savings
31    /// descending.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub duplicate_declaration_blocks: Vec<CssDuplicateBlock>,
34    /// Tailwind arbitrary-value utilities (`w-[13px]`, `bg-[#abc]`) found in
35    /// markup, which hardcode a one-off value instead of a configured scale
36    /// token (design-token bypass). Present only when the project uses Tailwind.
37    /// Sorted by use count descending. Candidates, not findings: an arbitrary
38    /// value is sometimes the right call.
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub tailwind_arbitrary_values: Vec<TailwindArbitraryValue>,
41    /// Unused CSS at-rule entities: an `@property` registered but never read via
42    /// `var()` in any stylesheet, or an `@layer` declared but never populated by
43    /// a block. Cleanup candidates (an `@property` can be read from JS; a layer
44    /// can be populated via `@import layer()`). Located by first definition.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub unused_at_rules: Vec<UnusedAtRule>,
47    /// Static `class` / `className` tokens in markup that match no CSS class
48    /// defined anywhere in the project AND are one edit away from a class that
49    /// IS defined (a likely typo or stale rename, with the suggested class). The
50    /// CSS analogue of an unresolved import; the near-miss restriction keeps it
51    /// near-zero false-positive (Tailwind utilities and third-party classes are
52    /// not one edit from an authored class). Candidates, never gated: the token
53    /// could be defined in CSS-in-JS or an external stylesheet the parser never
54    /// sees. Sorted by `(path, line, class)`.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub unresolved_class_references: Vec<UnresolvedClassReference>,
57    /// Global CSS classes (defined in a plain `.css`/`.scss` rule) whose literal
58    /// name is referenced by NO in-project markup, static or dynamic (the CSS
59    /// analogue of an unused export). Heavily gated to stay near-zero-false-
60    /// positive: emitted only when the project is plain-CSS-dominant, the
61    /// stylesheet is locally consumed (not a published design-system surface),
62    /// and the whole project is in scope. Candidates, never gated findings: the
63    /// class may be used by an HTML email, server template, CMS, or Markdown the
64    /// parser never scans. Sorted by `(path, line, class)`.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub unreferenced_css_classes: Vec<UnreferencedCssClass>,
67    /// `@font-face` families declared in a stylesheet but referenced by no
68    /// `font-family` anywhere in the project: a dead web-font payload (the font
69    /// file is downloaded but never applied). Located at the declaring
70    /// stylesheet. Cleanup candidates: the family could be applied from inline
71    /// styles or set via JavaScript. Sorted by `(path, family)`.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub unused_font_faces: Vec<UnusedFontFace>,
74    /// Tailwind v4 `@theme` design tokens (`--color-brand`, `--radius-card`)
75    /// defined in a stylesheet but used by no generated utility, `var()` read,
76    /// `@apply`, or arbitrary value anywhere in the project: dead design tokens
77    /// (the `unused-export` of the token era). Present only when the project is
78    /// Tailwind v4 (a `tailwindcss` dependency plus at least one `@theme` block)
79    /// and not a plugin / published-library / partial-scope run. Candidates,
80    /// never gated findings: the token may be consumed by a Tailwind plugin or a
81    /// downstream repo. Sorted by `(path, line, token)`.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub unused_theme_tokens: Vec<UnusedThemeToken>,
84    /// The project authors `font-size` values in several units (`px`, `rem`,
85    /// `em`, `%`), with a per-unit distinct-value count: a type-scale
86    /// inconsistency smell (mixing `px` and `rem` for type works against
87    /// user-zoom accessibility). Present only above a conservative floor.
88    /// Advisory candidate, never gated: the spread can be intentional (fixed
89    /// chrome in `px`, body type in `rem`).
90    ///
91    /// Color-notation mixing (hex vs rgb vs hsl) is deliberately NOT surfaced:
92    /// the CSS parser canonicalizes every legacy sRGB notation to hex before
93    /// fallow sees the value, so the authored distinction is already gone and
94    /// cannot be recovered without a separate raw-token pass.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub font_size_unit_mix: Option<CssNotationConsistency>,
97}
98
99/// A design-token notation-consistency candidate: the distinct notations used
100/// across the codebase for one value axis (today, length units on `font-size`),
101/// with a per-notation distinct-value count. Emitted only above a floor, since
102/// mixing notations for one axis is a "no single source of truth" smell.
103/// Advisory: the action is "standardize on one notation", not a single search.
104#[derive(Debug, Clone, serde::Serialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct CssNotationConsistency {
107    /// The value axis these notations describe, e.g. `"Colors"` or
108    /// `"Font sizes"`.
109    pub axis: String,
110    /// Per-notation distinct-value counts, sorted by count descending then
111    /// notation name (so the dominant notation is first and ties are stable).
112    pub notations: Vec<CssNotationCount>,
113    /// Read-only guidance step(s), so consumers can iterate `actions` uniformly
114    /// across every candidate type. Always at least one entry.
115    pub actions: Vec<CssCandidateAction>,
116}
117
118/// One notation bucket and the count of distinct values authored in it.
119#[derive(Debug, Clone, serde::Serialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121pub struct CssNotationCount {
122    /// The notation family, e.g. `"hex"`, `"rgb"`, `"hsl"`, `"modern"`, `"px"`,
123    /// `"rem"`, `"em"`, `"%"`.
124    pub notation: String,
125    /// Distinct values authored in this notation across the codebase.
126    pub count: u32,
127}
128
129/// An unused CSS at-rule entity (an `@property` registration with no `var()`
130/// reference, or an `@layer` declaration never populated), located by its first
131/// definition. A cleanup candidate, never a gated finding.
132#[derive(Debug, Clone, serde::Serialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct UnusedAtRule {
135    /// Which kind of at-rule entity is unused.
136    #[serde(rename = "type")]
137    pub kind: UnusedAtRuleKind,
138    /// The entity name (`--x` for `@property`, the layer name for `@layer`).
139    pub name: String,
140    /// Project-root-relative, forward-slash path to the first defining stylesheet.
141    pub path: String,
142    /// Read-only verification step(s) before removal (parity with other findings).
143    pub actions: Vec<CssCandidateAction>,
144}
145
146/// Discriminant for [`UnusedAtRule::kind`].
147#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
148#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
149#[serde(rename_all = "kebab-case")]
150#[repr(u8)]
151pub enum UnusedAtRuleKind {
152    /// An `@property --x { }` registered but never referenced via `var()`.
153    PropertyRegistration,
154    /// An `@layer a` declared (in a statement or named block) but never
155    /// populated by a `@layer a { }` block.
156    Layer,
157}
158
159/// A distinct Tailwind arbitrary-value utility token used in markup, with its
160/// total use count and first location (a design-token-bypass candidate).
161#[derive(Debug, Clone, serde::Serialize)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163pub struct TailwindArbitraryValue {
164    /// The `prefix-[value]` token (e.g. `w-[13px]`). Variant prefixes are
165    /// stripped, so `hover:w-[13px]` and `w-[13px]` aggregate under `w-[13px]`.
166    pub value: String,
167    /// Total occurrences across all scanned markup files.
168    pub count: u32,
169    /// Project-root-relative, forward-slash path to the first file using it.
170    pub path: String,
171    /// 1-based line of the first occurrence.
172    pub line: u32,
173    /// Read-only action(s): a find-all-occurrences search so the token can be
174    /// replaced with a scale token. Always at least one entry, so consumers can
175    /// iterate `actions` uniformly across every finding type.
176    pub actions: Vec<CssCandidateAction>,
177}
178
179/// A group of style rules across the project that share an identical declaration
180/// block: a copy-paste consolidation candidate (fallow's duplication signal
181/// applied to CSS). Only blocks of 4+ declarations appearing in 2+ rules are
182/// reported, so the signal stays a strong copy-paste indicator rather than
183/// flagging legitimately-repeated small blocks.
184#[derive(Debug, Clone, serde::Serialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186pub struct CssDuplicateBlock {
187    /// Declarations in the shared block.
188    pub declaration_count: u16,
189    /// Number of rules that share the block (always >= 2).
190    pub occurrence_count: u32,
191    /// Declarations removable by extracting the block into one shared rule:
192    /// `(occurrence_count - 1) * declaration_count`.
193    pub estimated_savings: u32,
194    /// The rules sharing the block, sorted by `(path, line)`.
195    pub occurrences: Vec<CssBlockOccurrence>,
196    /// Read-only guidance step(s), so consumers can iterate `actions`
197    /// uniformly across every finding type. Always at least one entry.
198    pub actions: Vec<CssCandidateAction>,
199}
200
201/// One occurrence of a duplicate declaration block.
202#[derive(Debug, Clone, serde::Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204pub struct CssBlockOccurrence {
205    /// Project-root-relative, forward-slash path to the stylesheet.
206    pub path: String,
207    /// 1-based line of the rule's first selector.
208    pub line: u32,
209}
210
211/// A `@keyframes` defined in a stylesheet but referenced by no animation in any
212/// stylesheet (cleanup candidate).
213#[derive(Debug, Clone, serde::Serialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215pub struct UnreferencedKeyframes {
216    /// The `@keyframes` name.
217    pub name: String,
218    /// Project-root-relative, forward-slash path to the stylesheet that defines it.
219    pub path: String,
220    /// Read-only verification step(s) an agent can run before removing the
221    /// candidate. Always at least one entry, so consumers can iterate
222    /// `actions` uniformly across every finding type.
223    pub actions: Vec<CssCandidateAction>,
224}
225
226/// An `@font-face` family declared in a stylesheet but referenced by no
227/// `font-family` anywhere in the project: a dead web-font payload. A cleanup
228/// candidate (the family could be applied from inline styles or JavaScript).
229#[derive(Debug, Clone, serde::Serialize)]
230#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
231pub struct UnusedFontFace {
232    /// The declared font family name (quotes stripped).
233    pub family: String,
234    /// Project-root-relative, forward-slash path to the declaring stylesheet.
235    pub path: String,
236    /// Read-only verification step(s) before removing. Always at least one entry,
237    /// so consumers can iterate `actions` uniformly across every finding type.
238    pub actions: Vec<CssCandidateAction>,
239}
240
241/// A Tailwind v4 `@theme` design token defined in a stylesheet whose generated
242/// utility, `var()` reads, and arbitrary-value references appear nowhere in the
243/// project: a dead design token (the `unused-export` of the token era). A
244/// candidate, never a gated finding: the token could be consumed by a Tailwind
245/// plugin, a published design-system surface, or a non-CSS-aware build step the
246/// scan cannot see (those cases are gated out before this is emitted).
247#[derive(Debug, Clone, serde::Serialize)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249pub struct UnusedThemeToken {
250    /// The full custom property as authored, including the `--` prefix
251    /// (`--color-brand`).
252    pub token: String,
253    /// The Tailwind v4 theme namespace the token belongs to (`color`, `radius`,
254    /// `font-weight`, `breakpoint`, ...).
255    pub namespace: String,
256    /// Project-root-relative, forward-slash path to the declaring stylesheet.
257    pub path: String,
258    /// 1-based line of the token's definition inside the `@theme` block.
259    pub line: u32,
260    /// Read-only verification step(s) before removing. Always at least one entry,
261    /// so consumers can iterate `actions` uniformly across every finding type.
262    pub actions: Vec<CssCandidateAction>,
263}
264
265/// A global CSS class defined in a plain `.css`/`.scss` rule whose literal name
266/// is referenced by no in-project markup (the CSS analogue of an unused export).
267/// A heavily-gated candidate, never a gated finding: the class may be applied
268/// from an HTML email, server template, CMS, or Markdown the parser never sees.
269#[derive(Debug, Clone, serde::Serialize)]
270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
271pub struct UnreferencedCssClass {
272    /// The class name (no dot).
273    pub class: String,
274    /// Project-root-relative, forward-slash path to the defining stylesheet.
275    pub path: String,
276    /// 1-based line of the class's first definition.
277    pub line: u32,
278    /// Read-only verification step(s) before removing. Always at least one entry,
279    /// so consumers can iterate `actions` uniformly across every finding type.
280    pub actions: Vec<CssCandidateAction>,
281}
282
283/// An animation reference (`animation` / `animation-name`) to a `@keyframes`
284/// name that is defined in no stylesheet anywhere in the project (the
285/// "used-but-undefined" direction). Usually a typo or a removed animation;
286/// occasionally a `@keyframes` defined in CSS-in-JS the CSS parser never sees.
287#[derive(Debug, Clone, serde::Serialize)]
288#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
289pub struct UndefinedKeyframes {
290    /// The referenced `@keyframes` name that resolves to no definition.
291    pub name: String,
292    /// Project-root-relative, forward-slash path to the first stylesheet that
293    /// references it.
294    pub path: String,
295    /// Read-only verification step(s) an agent can run before fixing the
296    /// reference. Always at least one entry, so consumers can iterate `actions`
297    /// uniformly across every finding type.
298    pub actions: Vec<CssCandidateAction>,
299}
300
301/// A static `class` / `className` token in markup that matches no CSS class
302/// defined anywhere in the project but is one edit away from a class that IS
303/// defined (a likely typo or stale rename). The CSS analogue of an unresolved
304/// import. A candidate, never a gated finding: the token could be defined in
305/// CSS-in-JS or an external stylesheet the parser never sees.
306#[derive(Debug, Clone, serde::Serialize)]
307#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
308pub struct UnresolvedClassReference {
309    /// The static class token referenced in markup (no dot).
310    pub class: String,
311    /// The defined CSS class one edit away: the likely intended class.
312    pub suggestion: String,
313    /// Project-root-relative, forward-slash path to the markup file.
314    pub path: String,
315    /// 1-based line of the `class` / `className` attribute.
316    pub line: u32,
317    /// Read-only verification step(s) before fixing the reference. Always at
318    /// least one entry, so consumers can iterate `actions` uniformly across
319    /// every finding type.
320    pub actions: Vec<CssCandidateAction>,
321}
322
323/// A Vue SFC's `<style scoped>` classes that appear nowhere else in the
324/// component (cleanup candidates).
325#[derive(Debug, Clone, serde::Serialize)]
326#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
327pub struct ScopedUnusedClasses {
328    /// Project-root-relative, forward-slash path to the SFC.
329    pub path: String,
330    /// The scoped class names with no use elsewhere in the component, sorted.
331    pub classes: Vec<String>,
332    /// Read-only verification step(s) an agent can run before removing the
333    /// candidate. Always at least one entry, so consumers can iterate
334    /// `actions` uniformly across every finding type.
335    pub actions: Vec<CssCandidateAction>,
336}
337
338/// A read-only verification step attached to a CSS cleanup candidate.
339///
340/// CSS candidates (unreferenced `@keyframes`, unused scoped classes) are never
341/// auto-removed: an animation name can still be applied from JavaScript, and a
342/// class can be assembled from a dynamic string binding. The action gives an
343/// agent a machine-readable next step, mirroring the `actions` array carried by
344/// every other health finding, plus an optional runnable probe to confirm the
345/// candidate is genuinely unused before deleting it.
346#[derive(Debug, Clone, serde::Serialize)]
347#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
348pub struct CssCandidateAction {
349    /// Action type identifier (`verify-unused`).
350    #[serde(rename = "type")]
351    pub kind: CssCandidateActionType,
352    /// Always `false`: CSS candidates are never auto-fixed (`fallow fix` does
353    /// not touch them) because the residual consumer may live outside CSS.
354    pub auto_fixable: bool,
355    /// Human-readable description of what to confirm before removing.
356    pub description: String,
357    /// A runnable, read-only, placeholder-free token search that surfaces any
358    /// out-of-CSS use of the candidate. Absent when no shell-safe command can
359    /// be built (e.g. the residual risk is a dynamic string binding that a
360    /// single search cannot probe), in which case `description` is the guide.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub command: Option<String>,
363}
364
365/// Discriminant for [`CssCandidateAction::kind`].
366#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
367#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
368#[serde(rename_all = "kebab-case")]
369pub enum CssCandidateActionType {
370    /// Confirm the candidate has no JavaScript / HTML / dynamic consumer
371    /// before removing it (the defined-but-unused candidates).
372    VerifyUnused,
373    /// Confirm the referenced name is genuinely undefined (not defined in
374    /// CSS-in-JS the parser cannot see) before treating it as a typo (the
375    /// used-but-undefined candidates).
376    VerifyUndefined,
377    /// Extract the shared declaration block into one rule and reference it from
378    /// each occurrence (the duplicate-declaration-block candidates).
379    Consolidate,
380    /// Replace a Tailwind arbitrary value with a configured scale token, or
381    /// confirm the one-off is intentional (the arbitrary-value candidates).
382    ReplaceWithToken,
383    /// Standardize an inconsistent value axis on a single notation (the
384    /// color-format / length-unit mixing candidates).
385    Standardize,
386}
387
388impl CssCandidateAction {
389    /// Verify action for an unused `@font-face` family: a read-only token search
390    /// for any inline-style or JavaScript application of the family before
391    /// removing the dead web-font.
392    #[must_use]
393    pub fn verify_unused_font_face(family: &str) -> Self {
394        Self {
395            kind: CssCandidateActionType::VerifyUnused,
396            auto_fixable: false,
397            description: format!(
398                "Confirm the \"{family}\" font family is not applied from an inline style or JavaScript before removing the @font-face and its font files."
399            ),
400            command: safe_token_search(family),
401        }
402    }
403
404    /// Verify action for an unused Tailwind v4 `@theme` token: a read-only search
405    /// that embeds the LITERAL terms an agent should grep for, the generated
406    /// utility suffix (`bg-<name>` / `text-<name>` / `<namespace>-<name>`), the
407    /// `var(--<ns>-<name>)` read, and the arbitrary `[--<ns>-<name>]` value,
408    /// before removing the token. Verify-then-remove; never auto-fixable.
409    #[must_use]
410    pub fn verify_unused_theme_token(token: &str, namespace: &str, name: &str) -> Self {
411        Self {
412            kind: CssCandidateActionType::VerifyUnused,
413            auto_fixable: false,
414            description: format!(
415                "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."
416            ),
417            command: theme_token_search(namespace, name),
418        }
419    }
420
421    /// Verify action for an unreferenced global CSS class: name the surfaces the
422    /// in-project scan does NOT cover (the class could be applied from there) and
423    /// ship a read-only token search to double-check before removing.
424    #[must_use]
425    pub fn verify_unreferenced_class(name: &str) -> Self {
426        Self {
427            kind: CssCandidateActionType::VerifyUnused,
428            auto_fixable: false,
429            description: format!(
430                "Confirm no HTML email, server-rendered template, CMS content, or Markdown applies the \"{name}\" class before removing it (fallow scanned only in-project JS/TS/HTML/Vue/Svelte/Astro markup)."
431            ),
432            command: safe_token_search(name),
433        }
434    }
435
436    /// Verify action for an unreferenced `@keyframes`: a read-only token search
437    /// for any JavaScript or template reference that applies the animation
438    /// (which the CSS-only scan cannot see).
439    #[must_use]
440    pub fn verify_keyframe(name: &str) -> Self {
441        Self {
442            kind: CssCandidateActionType::VerifyUnused,
443            auto_fixable: false,
444            description: format!(
445                "Confirm no JavaScript or template applies the \"{name}\" animation before removing the @keyframes."
446            ),
447            command: safe_token_search(name),
448        }
449    }
450
451    /// Verify action for an animation reference to a `@keyframes` that is
452    /// defined in no stylesheet: a read-only token search for a CSS-in-JS
453    /// `@keyframes`/animation definition of the name (styled-components,
454    /// Emotion, vanilla-extract) before treating the reference as a typo.
455    #[must_use]
456    pub fn verify_undefined_keyframe(name: &str) -> Self {
457        Self {
458            kind: CssCandidateActionType::VerifyUndefined,
459            auto_fixable: false,
460            description: format!(
461                "Confirm \"{name}\" is not a @keyframes defined in CSS-in-JS (styled-components, Emotion, vanilla-extract) before treating the animation reference as a typo."
462            ),
463            command: safe_token_search(name),
464        }
465    }
466
467    /// Guidance action for a mixed value axis (colors authored in several
468    /// notations, or font sizes in several units): standardize on the single
469    /// dominant notation. No command (this is a project-wide refactor, and the
470    /// per-notation breakdown already quantifies the spread); the residual
471    /// judgment is whether the spread is an intentional migration in progress.
472    #[must_use]
473    pub fn standardize_notation(axis: &str, dominant: &str) -> Self {
474        Self {
475            kind: CssCandidateActionType::Standardize,
476            auto_fixable: false,
477            description: format!(
478                "{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."
479            ),
480            command: None,
481        }
482    }
483
484    /// Guidance action for a duplicate declaration block: consolidate the shared
485    /// declarations into one rule. No command (consolidation is a refactor, and
486    /// the occurrences list already names every site); the residual judgment is
487    /// whether the rules are intentionally separate overrides.
488    #[must_use]
489    pub fn consolidate_block(occurrence_count: u32) -> Self {
490        Self {
491            kind: CssCandidateActionType::Consolidate,
492            auto_fixable: false,
493            description: format!(
494                "Extract this declaration block into one rule and reference it from all {occurrence_count} occurrences, unless they are intentionally separate overrides."
495            ),
496            command: None,
497        }
498    }
499
500    /// Action for a Tailwind arbitrary-value bypass: a read-only fixed-string
501    /// search for every occurrence of the token so it can be replaced with a
502    /// scale token (or confirmed an intentional one-off). The value is a Tailwind
503    /// utility token (no quotes / whitespace by construction), so it is safe to
504    /// single-quote; the `-F` keeps the `[` / `]` literal rather than a glob.
505    #[must_use]
506    pub fn replace_arbitrary_value(value: &str) -> Self {
507        let command = (!value.contains('\'')).then(|| {
508            format!(
509                "grep -rnF '{value}' --include='*.jsx' --include='*.tsx' --include='*.html' --include='*.vue' --include='*.svelte' --include='*.astro' ."
510            )
511        });
512        Self {
513            kind: CssCandidateActionType::ReplaceWithToken,
514            auto_fixable: false,
515            description:
516                "Replace this one-off arbitrary value with a scale token from your Tailwind theme, or confirm it is intentional."
517                    .to_string(),
518            command,
519        }
520    }
521
522    /// Verify action for an unused CSS at-rule entity: a read-only search for
523    /// any out-of-CSS consumer (JS reading an `@property`; an `@import layer()`
524    /// populating a layer) before removing it.
525    #[must_use]
526    pub fn verify_unused_at_rule(kind: UnusedAtRuleKind, name: &str) -> Self {
527        let description = match kind {
528            UnusedAtRuleKind::PropertyRegistration => format!(
529                "Confirm \"{name}\" is not read or set from JavaScript before removing the @property registration."
530            ),
531            UnusedAtRuleKind::Layer => format!(
532                "Confirm the @layer \"{name}\" is not populated via @import layer() before removing the declaration."
533            ),
534        };
535        Self {
536            kind: CssCandidateActionType::VerifyUnused,
537            auto_fixable: false,
538            description,
539            command: safe_token_search(name),
540        }
541    }
542
543    /// Verify action for a markup class token that matches no defined CSS class
544    /// but is one edit from a class that is defined: surface the suggestion and a
545    /// read-only token search so the residual risk (a class defined in CSS-in-JS
546    /// or an external stylesheet) can be ruled out before fixing the typo.
547    #[must_use]
548    pub fn verify_unresolved_class(class: &str, suggestion: &str) -> Self {
549        Self {
550            kind: CssCandidateActionType::VerifyUndefined,
551            auto_fixable: false,
552            description: format!(
553                "\"{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."
554            ),
555            command: safe_token_search(class),
556        }
557    }
558
559    /// Verify action for a Vue SFC's unused scoped classes. The component-scoped
560    /// scan already covers every static use, so the only residual risk is a
561    /// class assembled from a dynamic string; that is a manual check, so the
562    /// action carries guidance but no command.
563    #[must_use]
564    pub fn verify_scoped_classes() -> Self {
565        Self {
566            kind: CssCandidateActionType::VerifyUnused,
567            auto_fixable: false,
568            description:
569                "Confirm none of these scoped classes is assembled from a dynamic string (e.g. `:class=\"prefix + name\"`) before removing them."
570                    .to_string(),
571            command: None,
572        }
573    }
574}
575
576/// Build a read-only, placeholder-free, namespace-QUALIFIED search for a Tailwind
577/// v4 `@theme` token, or `None` when the namespace / name is not a plain CSS
578/// identifier (so the emitted command is always shell-safe). The pattern matches
579/// any `*-<name>` utility (`bg-<name>`, `rounded-<name>`, `font-<name>`, ...) AND
580/// the `--<ns>-<name>` custom property (covering `var()` reads and `[--ns-name]`
581/// arbitrary values), deliberately NOT a bare `<name>` (which would substring-hit
582/// every file for a dictionary-word token like `brand` / `card`).
583fn theme_token_search(namespace: &str, name: &str) -> Option<String> {
584    let is_plain = |s: &str| {
585        !s.is_empty()
586            && s.bytes()
587                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
588    };
589    (is_plain(namespace) && is_plain(name)).then(|| {
590        format!(
591            "grep -rnE -- '-{name}\\b|--{namespace}-{name}' --include='*.css' --include='*.html' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.astro' ."
592        )
593    })
594}
595
596/// Build a read-only, placeholder-free token search for `name`, or `None` when
597/// the name is not a plain CSS identifier, so the emitted command is always
598/// shell-safe without quoting tricks.
599fn safe_token_search(name: &str) -> Option<String> {
600    let is_plain = !name.is_empty()
601        && name
602            .bytes()
603            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
604    is_plain.then(|| {
605        format!(
606            "grep -rnw '{name}' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.html' ."
607        )
608    })
609}
610
611/// Per-stylesheet CSS analytics.
612#[derive(Debug, Clone, serde::Serialize)]
613#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
614pub struct CssFileAnalytics {
615    /// Project-root-relative, forward-slash path.
616    pub path: String,
617    /// The stylesheet's structural metrics.
618    pub analytics: fallow_types::extract::CssAnalytics,
619}
620
621/// Project-wide CSS analytics aggregates across every analyzed stylesheet
622/// (including stylesheets with no notable rule, which are not listed
623/// individually).
624#[derive(Debug, Clone, Default, serde::Serialize)]
625#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
626pub struct CssAnalyticsSummary {
627    /// Stylesheets analyzed (standard CSS only; SCSS is skipped).
628    pub files_analyzed: u32,
629    /// Total style rules across analyzed stylesheets.
630    pub total_rules: u32,
631    /// Total declarations across analyzed stylesheets.
632    pub total_declarations: u32,
633    /// Total `!important` declarations across analyzed stylesheets.
634    pub important_declarations: u32,
635    /// Total empty style rules across analyzed stylesheets.
636    pub empty_rules: u32,
637    /// Deepest style-rule nesting depth observed across analyzed stylesheets.
638    pub max_nesting_depth: u8,
639    /// Distinct color values (authored form) across the whole codebase. A high
640    /// count signals an uncontrolled palette (design-token sprawl).
641    pub unique_colors: u32,
642    /// Distinct `font-size` values across the whole codebase.
643    pub unique_font_sizes: u32,
644    /// Distinct `z-index` values across the whole codebase.
645    pub unique_z_indexes: u32,
646    /// Distinct `box-shadow` values across the whole codebase (shadow-scale sprawl).
647    pub unique_box_shadows: u32,
648    /// Distinct `border-radius` values across the whole codebase (radius-scale sprawl).
649    pub unique_border_radii: u32,
650    /// Distinct `line-height` values across the whole codebase (type-scale sprawl).
651    pub unique_line_heights: u32,
652    /// Distinct custom properties (`--x`) defined anywhere in the codebase.
653    pub custom_properties_defined: u32,
654    /// Custom properties defined but never referenced via `var()` in any
655    /// stylesheet (the defined-but-unused direction). These are cleanup
656    /// CANDIDATES, not confirmed dead: a property may still be read or set from
657    /// JavaScript or inline HTML styles.
658    pub custom_properties_unreferenced: u32,
659    /// Distinct custom properties referenced via `var()` that are defined in no
660    /// stylesheet anywhere (the used-but-undefined direction). A COUNT only, not
661    /// a located list: a `var(--x)` with no CSS definition is extremely common
662    /// in JavaScript-driven theming and design-token libraries, so locating
663    /// these would be net-noise. The count is an architecture signal (how much
664    /// of the `var()` surface is resolved outside CSS), not a finding.
665    pub custom_properties_undefined: u32,
666    /// Distinct `@keyframes` defined anywhere in the codebase.
667    pub keyframes_defined: u32,
668    /// `@keyframes` defined but never referenced via `animation` /
669    /// `animation-name` in any stylesheet (the defined-but-unused direction;
670    /// cleanup CANDIDATES; an animation name can still be applied from
671    /// JavaScript).
672    pub keyframes_unreferenced: u32,
673    /// Distinct animation names referenced via `animation` / `animation-name`
674    /// that resolve to no `@keyframes` definition anywhere (the used-but-
675    /// undefined direction). Located in `undefined_keyframes`; usually a typo or
676    /// a removed animation.
677    pub keyframes_undefined: u32,
678    /// Total Vue `<style scoped>` classes used nowhere else in their component
679    /// (cleanup candidates), across all SFCs.
680    pub scoped_unused_classes: u32,
681    /// Number of distinct declaration blocks (4+ declarations) that appear in
682    /// two or more rules across the project (copy-paste consolidation
683    /// candidates). Located in `duplicate_declaration_blocks`.
684    pub duplicate_declaration_blocks: u32,
685    /// Total declarations removable by consolidating every duplicate block:
686    /// the sum of `(occurrence_count - 1) * declaration_count` across groups.
687    pub duplicate_declarations_total: u32,
688    /// Distinct Tailwind arbitrary-value tokens used in markup (design-token
689    /// bypass). Zero when the project does not use Tailwind. Located in
690    /// `tailwind_arbitrary_values`.
691    pub tailwind_arbitrary_values: u32,
692    /// Total Tailwind arbitrary-value occurrences across markup.
693    pub tailwind_arbitrary_value_uses: u32,
694    /// `@property` registrations never referenced via `var()` in any stylesheet
695    /// (located in `unused_at_rules`). Cleanup candidates.
696    pub unused_property_registrations: u32,
697    /// Cascade layers declared but never populated by a block (located in
698    /// `unused_at_rules`). Cleanup candidates.
699    pub unused_layers: u32,
700    /// Static markup class tokens that match no defined CSS class but are one
701    /// edit from a defined class (likely typos / stale renames). Located in
702    /// `unresolved_class_references`. Candidates, never gated.
703    pub unresolved_class_references: u32,
704    /// Global CSS classes defined in a stylesheet but referenced by no in-project
705    /// markup (located in `unreferenced_css_classes`). Heavily gated cleanup
706    /// candidates; zero on preprocessor-dominant or partial-scope runs.
707    pub unreferenced_css_classes: u32,
708    /// `@font-face` families declared but referenced by no `font-family` anywhere
709    /// (located in `unused_font_faces`). Dead web-font cleanup candidates.
710    pub unused_font_faces: u32,
711    /// Tailwind v4 `@theme` design tokens defined but used by no generated
712    /// utility, `var()`, `@apply`, or arbitrary value anywhere (located in
713    /// `unused_theme_tokens`). Dead-design-token cleanup candidates; zero when
714    /// the project is not Tailwind v4 or a plugin / published-library /
715    /// partial-scope run gated the scan out.
716    pub unused_theme_tokens: u32,
717    /// Number of distinct `font-size` units (`px` / `rem` / `em` / `%`) authored
718    /// across the codebase. Mixing units is a type-scale consistency smell,
719    /// broken out in `font_size_unit_mix`.
720    pub font_size_units_used: u32,
721    /// Number of analyzed stylesheets whose per-rule `notable_rules` list was
722    /// truncated at the per-file cap, so a consumer knows the per-rule detail is
723    /// incomplete without walking every file.
724    pub notable_truncated_files: u32,
725}