Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

## [0.1.5] - Unreleased

### Added

- ViewComponentCssDsl::Verifier cross_declaration_conflicts check — warns when a class declared in one place (e.g. a base `leading-snug`) is silently dropped because a *different* declaration merged on top of it (e.g. a size axis's `text-sm`, since Tailwind font-size utilities also set line-height). Suppresses intentional same-family overrides (`p-2` → `p-8`) and surfaces only cross-family drops.

## [0.1.4] - 2026-06-04

### Added
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,12 +462,13 @@ Axis, method, and proc rules are appended, not overridden.

The DSL's worst failure modes are silent: a typo'd or hallucinated Tailwind class produces no CSS at all under JIT, a self-conflicting declaration quietly drops a class, and a rule referencing a missing method only raises at render time on the code path that hits it. `ViewComponentCssDsl::Verifier` catches all of these statically — fast enough to run on every edit.

`verify(component)` returns `Finding` structs (`component`, `check`, `severity`, `message`). Six checks run:
`verify(component)` returns `Finding` structs (`component`, `check`, `severity`, `message`). Seven checks run:

| Check | Asserts | Catches |
| --- | --- | --- |
| `class_validity` | Every declared class exists in the compiled Tailwind output | Typos, hallucinated classes, theme values that don't exist |
| `self_conflicts` | No declaration conflicts with itself | `css "block flex"` silently dropping `block` |
| `cross_declaration_conflicts` | No class declared in one place is silently dropped when a different declaration merges on top of it | A base `leading-snug` that a size axis's `text-sm` overrides (font-size utilities also set line-height) |
| `method_rules` | Every Symbol in `css`/`data`/`aria`/`attribute` rules resolves to a method | Render-time `NoMethodError`s |
| `axes_settable` | Every axis has an initialize param or `@ivar` assignment | Variant rules that can never fire |
| `variant_matrix` | `#css` builds cleanly for every axis-value combination | Anything the static checks miss, without rendering |
Expand Down
92 changes: 91 additions & 1 deletion lib/view_component_css_dsl/verifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
# itself can't surface until render time (or surfaces silently). Designed to be
# fast enough to run on every edit.
#
# The six checks:
# The seven checks:
#
# class_validity - every declared class exists in the compiled Tailwind output;
# catches typos, hallucinated classes, and theme values that
# don't exist (requires known_classes:)
# self_conflicts - no declaration conflicts with itself; catches e.g.
# css "block flex" silently dropping "block"
# cross_declaration_conflicts - no class declared in one place is silently
# dropped when a different declaration merges on top of it;
# catches e.g. a base leading-snug that a size axis's text-sm
# overrides (font-size utilities also set line-height)
# method_rules - every Symbol in css/data/aria/attribute rules resolves to a
# method; catches render-time NoMethodErrors
# axes_settable - every axis has an initialize param or @ivar assignment;
Expand Down Expand Up @@ -59,6 +63,7 @@ def initialize(known_classes: nil)
def verify(component)
check_class_validity(component) +
check_self_conflicts(component) +
check_cross_declaration_conflicts(component) +
check_method_rules(component) +
check_axes_settable(component) +
check_variant_matrix(component) +
Expand Down Expand Up @@ -106,6 +111,32 @@ def check_self_conflicts(component)
end
end

# A class declared in one place can be silently dropped when a *different*
# declaration is merged on top of it — the blind spot check_self_conflicts
# (one declaration at a time) and check_variant_matrix (exceptions only) both
# miss. The footgun: a base `leading-snug` that a size axis's `text-sm`
# overrides, because Tailwind font-size utilities also set line-height.
#
# Reported as warnings, not errors: a cross-declaration drop is often
# intentional — a variant overriding a base default. We suppress same-family
# overrides (p-2 -> p-8) and surface only drops whose winning class is a
# different utility family (leading-snug dropped by text-sm), which is almost
# always a surprise.
def check_cross_declaration_conflicts(component)
axis_combinations(component).flat_map do |combo|
tokens = contributing_tokens(component, combo)
next [] if tokens.size < 2

survivors = component.smart_merge(tokens.map { |t| t[:class] }.join(" ")).split
dropped_conflicts(component, tokens, survivors).map do |dropped, winner|
finding(component, :cross_declaration_conflicts, :warning,
"#{dropped[:label]}: \"#{dropped[:class]}\" is silently dropped when " \
"merged with \"#{winner[:class]}\" from #{winner[:label]} (both set " \
"the same CSS property)")
end
end
end

# Symbols in css/data/aria/attribute rules are method references; each must
# resolve on the component. The DSL only raises for these at render time.
def check_method_rules(component)
Expand Down Expand Up @@ -227,6 +258,65 @@ def resolves?(component, method_name)
component.private_method_defined?(method_name)
end

##################################################################################
# Cross-declaration conflicts
##################################################################################

# {class:, label:} for every class the static declarations contribute for this
# combo, in merge order: base declarations first, then matching axis rules.
def contributing_tokens(component, combo)
declarations =
base_declarations(component) + matching_axis_declarations(component, combo)
declarations.flat_map do |label, styles|
styles.split.map { |cls| {class: cls, label:} }
end
end

def base_declarations(component)
component._css_base_declarations.map { |styles| ["css \"#{styles}\"", styles] }
end

def matching_axis_declarations(component, combo)
component._css_axis_rules.filter_map do |rule|
matches = rule[:axes].all? { |axis, value| combo[axis] == value }
[axis_label(rule[:axes]), rule[:styles]] if matches
end
end

# [dropped_token, winning_token] pairs the merge silently removed. A class is
# dropped when it's absent from survivors; its winner is the later class that
# displaced it (found by pairwise re-merge). Only cross-declaration,
# cross-family pairs are returned — same-declaration drops belong to
# check_self_conflicts, same-family drops are intentional overrides.
def dropped_conflicts(component, tokens, survivors)
tokens.each_with_index.filter_map do |token, index|
next if survivors.include?(token[:class])

winner = winning_token(component, tokens, index)
next unless winner
next if winner[:label] == token[:label]
next if utility_family(winner[:class]) == utility_family(token[:class])

[token, winner]
end
end

# The later token that displaces tokens[index]: the first subsequent token
# that, merged after it, wins outright.
def winning_token(component, tokens, index)
dropped = tokens[index][:class]
tokens.drop(index + 1).find do |candidate|
merged = component.smart_merge("#{dropped} #{candidate[:class]}").split
merged == [candidate[:class]]
end
end

# The utility family of a Tailwind class, ignoring variant prefixes and
# negativity: hover:-mt-2 -> "mt", leading-snug -> "leading", text-sm -> "text".
def utility_family(token)
token.split(":").last.delete_prefix("-").split("-").first
end

##################################################################################
# Axis settability
##################################################################################
Expand Down
52 changes: 52 additions & 0 deletions spec/verifier_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,58 @@ def disabled? = false
end
end

describe "cross declaration conflicts" do
it "warns when a base class is dropped by an axis rule's class" do
component = Class.new(TestComponent) do
css "leading-snug rounded"
css size: :sm, style: "text-sm"

def initialize(size: :sm)
@size = size
end
end

findings = findings_for(component, :cross_declaration_conflicts)
expect(findings.size).to eq(1)
expect(findings.first.severity).to eq(:warning)
expect(findings.first.message).to include("leading-snug")
expect(findings.first.message).to include("text-sm")
end

it "does not flag a same-family override (intentional)" do
component = Class.new(TestComponent) do
css "p-2"
css size: :lg, style: "p-8"

def initialize(size: :lg)
@size = size
end
end

expect(findings_for(component, :cross_declaration_conflicts)).to be_empty
end

it "does not double-report a single-declaration conflict" do
component = Class.new(TestComponent) { css "block flex" }

expect(findings_for(component, :cross_declaration_conflicts)).to be_empty
expect(findings_for(component, :self_conflicts)).not_to be_empty
end

it "passes a clean component" do
component = Class.new(TestComponent) do
css "rounded p-4"
css size: :sm, style: "min-h-6"

def initialize(size: :sm)
@size = size
end
end

expect(findings_for(component, :cross_declaration_conflicts)).to be_empty
end
end

describe "method rules resolve" do
it "flags a css rule referencing an undefined method" do
component = Class.new(TestComponent) { css :missing?, style: "opacity-50" }
Expand Down
Loading