diff --git a/CHANGELOG.md b/CHANGELOG.md index c6926fb..64438b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9d0c3a1..03b9d27 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/lib/view_component_css_dsl/verifier.rb b/lib/view_component_css_dsl/verifier.rb index bcba64a..8857282 100644 --- a/lib/view_component_css_dsl/verifier.rb +++ b/lib/view_component_css_dsl/verifier.rb @@ -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; @@ -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) + @@ -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) @@ -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 ################################################################################## diff --git a/spec/verifier_spec.rb b/spec/verifier_spec.rb index 1e31198..eb097ad 100644 --- a/spec/verifier_spec.rb +++ b/spec/verifier_spec.rb @@ -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" }